Skip to main content

zsh/ported/
builtin.rs

1//! Direct port of `Src/builtin.c` — the master registration site for
2//! the in-shell builtin commands. The C source is 7608 lines; the
3//! actual `bin_*` handler bodies were ported organically into
4//! `src/ported/vm_helper` and `src/ported/builtins/*.rs` long before
5//! this file existed. This file scaffolds:
6//!
7//! Builtins in the main executable                                          // c:38
8//! Builtin Command Hash Table Functions                                     // c:140
9//!
10//!   * the `BINF_*` flag bits from `Src/zsh.h:1457-1486`,
11//!   * the `BIN_*` dispatch IDs from `Src/hashtable.h:34-66`,
12//!   * the `Builtin` descriptor and the static `BUILTINS[]` table
13//!     (1:1 mirror of `static struct builtin builtins[]` at
14//!     `Src/builtin.c:40-137`),
15//!   * `createbuiltintable()` (`Src/builtin.c:149`) — building the
16//!     name → descriptor lookup the rest of the shell consults via
17//!     `builtintab`.
18//!
19//! Each row's `handler` field names the canonical Rust port of the
20//! C handler so future work can wire them up without re-discovering
21//! the mapping. When the handler lives in `crate::ported::builtins`,
22//! the comment cites the file; when it lives in `vm_helper`'s
23//! `Executor` impl, that's noted too.
24
25use std::collections::HashMap;
26use std::io::Read;
27use std::sync::atomic::{Ordering, Ordering::Relaxed};
28use std::sync::{Mutex, OnceLock};
29#[allow(unused_imports)]
30use std::{env, fs, io, io::Write, path::Path, path::PathBuf};
31
32use crate::func_body_fmt::FuncBodyFmt;
33#[allow(unused_imports)]
34use crate::parse::{Redirect, ShellCommand};
35use crate::ported::compat::zgetcwd;
36use crate::ported::config_h::DEFAULT_PATH;
37use crate::ported::exec::{getfpfunc, iscom, loadautofn, FORKLEVEL, TRAP_RETURN, TRAP_STATE};
38use crate::ported::hashnameddir::{
39    addnameddirnode, emptynameddirtable, fillnameddirtable, nameddirtab, printnameddirnode,
40};
41use crate::ported::hashtable::{
42    aliastab_lock, cmdnamtab_lock, createaliasnode, dircache_set, emptycmdnamtable,
43    fillcmdnamtable, hnamcmp, printaliasnode, printcmdnamnode, printshfuncexpand, reswdtab_lock,
44    scanmatchshfunc, scanshfunc, shfunctab_lock, sufaliastab_lock,
45};
46use crate::DPUTS;
47use indexmap::IndexMap;
48// `curhist` (hist.rs static) NOT imported — there's an unavoidable
49// `let curhist` local in fc_main that mirrors C's `int curhist;` local
50// shadowing the global. Rule E says keep the C name. The static is
51// referenced via its fully-qualified path at the single read site to
52// avoid name-shadow E0530.
53use crate::ported::hist::{
54    addhistnum, gethistent, hcomsearch, histsiz, pushhiststack, quietgethist, readhistfile,
55    saveandpophiststack, savehistfile, savehistsiz,
56};
57use crate::ported::jobs::{bin_fg, removetrapnode};
58use crate::ported::math::{matheval, mathevali, mnumber, MN_INTEGER};
59use crate::ported::mem::{queue_signals, unqueue_signals};
60use crate::ported::module::MATHFUNCS;
61use crate::ported::modules::parameter::{DIRSTACK, FUNCSTACK};
62use crate::ported::options::{dosetopt, emulation, optlookup, ZSH_OPTIONS_SET};
63use crate::ported::params::{
64    createparam, getiparam, getsparam, isident, locallevel as locallevel_param, locallevel,
65    paramtab, printparamnode, setaparam, setiparam, setsparam, unsetparam, unsetparam_pm,
66};
67use crate::ported::pattern::{patcompile, pattry};
68use crate::ported::signals::settrap;
69use crate::ported::sort::strmetasort;
70use crate::ported::utils::{
71    argzero, errflag, fprintdir, getkeystring, getkeystring_with, getshfunc, gettempfile, lchdir,
72    print_if_link, printprompt4, quotedzputs, scriptname_get, set_argzero, zerr, zerrnam, zwarn,
73    zwarnnam, GETKEYS_BINDKEY, GETKEYS_ECHO, GETKEYS_PRINT,
74};
75#[allow(unused_imports)]
76use crate::ported::vm_helper::{self, format_int_in_base, BUILTIN_NAMES};
77use crate::ported::zle::compctl::compctlread;
78use crate::ported::zsh_h::{
79    alias, asgment, builtin, cmdnam, eprog, hashnode, interact, islogin, isset, mathfunc, nameddir,
80    options, param, shfunc, HandlerFunc, Meta, ALIAS_GLOBAL, ALIAS_SUFFIX, ASG_ARRAY, ASG_ARRAYP,
81    ASG_KEY_VALUE, ASG_VALUEP, AUTOPUSHD, BINF_ADDED, BINF_ASSIGN, BINF_BUILTIN, BINF_COMMAND,
82    BINF_DASH, BINF_DASHDASHVALID, BINF_EXEC, BINF_HANDLES_OPTS, BINF_KEEPNUM, BINF_MAGICEQUALS,
83    BINF_NOGLOB, BINF_PLUSOPTS, BINF_PREFIX, BINF_PRINTOPTS, BINF_PSPECIAL, BINF_SKIPDASH,
84    BINF_SKIPINVALID, BSDECHO, CDABLEVARS, CHASELINKS, CHECKJOBS, CHECKRUNNINGJOBS, DISABLED, EMULATE_CSH,
85    EMULATE_KSH, EMULATE_SH, EMULATE_ZSH, EMULATION, ERRFLAG_ERROR, FS_FUNC, FUNCTIONARGZERO,
86    GLOBALEXPORT, GLOBALRCS, HASHED, HFILE_APPEND, HFILE_NO_REWRITE, HFILE_SKIPOLD,
87    HFILE_USE_OPTIONS, HIST_FOREIGN, INTERACTIVE, KSHARRAYS, LOGINSHELL, MAX_OPS, MFF_STR,
88    MFF_USERFUNC, MONITOR, NULLBINCMD, OPT_ARG, OPT_HASARG, OPT_ISSET, OPT_MINUS, OPT_PLUS,
89    PATHDIRS, PAT_HEAPDUP, PAT_STATIC, PM_ABSPATH_USED, PM_ARRAY, PM_AUTOLOAD, PM_CUR_FPATH,
90    PM_DECLARED, PM_DEFAULTED, PM_EFLOAT, PM_EXPORTED, PM_FFLOAT, PM_HASHED, PM_HIDE, PM_HIDEVAL,
91    PM_INTEGER, PM_KSHSTORED, PM_LEFT, PM_LOADDIR, PM_LOCAL, PM_LOWER, PM_NAMEREF, PM_READONLY,
92    PM_RIGHT_B, PM_RIGHT_Z, PM_RO_BY_DESIGN, PM_SCALAR, PM_SPECIAL, PM_TAGGED, PM_TAGGED_LOCAL,
93    PM_TIED, PM_TYPE, PM_UNALIASED, PM_UNDEFINED, PM_UNIQUE, PM_UNSET, PM_UPPER, PM_WARNNESTED,
94    PM_ZSHSTORED, POSIXBUILTINS, POSIXCD, POSIXTRAPS, PRINT_INCLUDEVALUE, PRINT_LINE, PRINT_LIST,
95    PRINT_NAMEONLY, PRINT_POSIX_EXPORT, PRINT_POSIX_READONLY, PRINT_TYPE, PRINT_TYPESET,
96    PRINT_WHENCE_CSH, PRINT_WHENCE_FUNCDEF, PRINT_WHENCE_SIMPLE, PRINT_WHENCE_VERBOSE,
97    PRINT_WHENCE_WORD, PRINT_WITH_NAMESPACE, PUSHDIGNOREDUPS, PUSHDMINUS, PUSHDSILENT, PUSHDTOHOME,
98    RCQUOTES, RCS, SHINSTDIN, SORTIT_BACKWARDS, SORTIT_IGNORING_CASE, STAT_LOCKED, STAT_NOPRINT,
99    STAT_STOPPED, TRAP_STATE_FORCE_RETURN, TRAP_STATE_PRIMED, TYPESETSILENT, TYPESET_OPTSTR,
100    VERBOSE, XTRACE, ZEXIT_DEFERRED, ZEXIT_NORMAL, ZEXIT_SIGNAL, ZSIG_FUNC,
101};
102#[allow(unused_imports)]
103use crate::zwc::ZwcFile;
104
105// ---------------------------------------------------------------------------
106// BIN_* dispatch IDs.
107// Direct port of `Src/hashtable.h:34-70`. These are the integer
108// discriminators handlers use when one C function backs multiple
109// builtin names (e.g. `bin_fg` covers fg/bg/jobs/wait/disown).
110// ---------------------------------------------------------------------------
111
112// BIN_* constants moved to `crate::ported::hashtable_h` per the C
113// header layout (Src/hashtable.h:34-70). Re-exported here so existing
114// `crate::ported::builtin::BIN_X` paths keep resolving.
115pub use crate::ported::hashtable_h::{
116    BIN_BG, BIN_BRACKET, BIN_BREAK, BIN_CD, BIN_COMMAND, BIN_CONTINUE, BIN_DISABLE, BIN_DISOWN,
117    BIN_ECHO, BIN_ENABLE, BIN_EVAL, BIN_EXIT, BIN_EXPORT, BIN_FC, BIN_FG, BIN_JOBS, BIN_LOGOUT,
118    BIN_POPD, BIN_PRINT, BIN_PRINTF, BIN_PUSHD, BIN_PUSHLINE, BIN_R, BIN_READONLY, BIN_RETURN,
119    BIN_SCHED, BIN_SETOPT, BIN_TEST, BIN_TYPESET, BIN_UNALIAS, BIN_UNFUNCTION, BIN_UNHASH,
120    BIN_UNSET, BIN_UNSETOPT, BIN_WAIT,
121};
122
123/// Port of `mod_export int ineval;` from `Src/builtin.c:6389`.
124///
125/// `eval()` sets it at c:6155 — `ineval = !isset(EVALLINENO);` — and
126/// restores the saved value at c:6215. `Src/exec.c` reads it at three
127/// places to decide whether the wordcode's line number may overwrite
128/// the `lineno` global:
129///   c:1355  `if (!IN_EVAL_TRAP() && !ineval && code) lineno = code - 1;`
130///   c:1451  `if (!IN_EVAL_TRAP() && !ineval) { ... }`
131///   c:2056  `if (!IN_EVAL_TRAP() && !ineval && WC_PIPE_LINENO(pcode))`
132/// So under `NO_EVAL_LINENO` the eval body keeps the CALLER's
133/// `$LINENO` instead of counting lines inside the eval'd string.
134#[allow(non_upper_case_globals)]
135pub static ineval: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
136
137/// Construct the builtin lookup table.
138/// Port of `createbuiltintable()` from `Src/builtin.c:150`. The C
139/// version installs the hashtable function pointers (hash, addnode,
140/// printnode, etc.) and then calls `addbuiltins("zsh", builtins, ..)`.
141/// Here we just materialise the static `BUILTINS` slice into a
142/// `HashMap<String, &builtin>` — Rust's standard hashing replaces the
143/// C `hasher` callback and the `HashMap` itself replaces all the
144/// per-table function pointers (`addnode`/`getnode`/`removenode`/...).
145// Builtin Command Hash Table Functions                                      // c:150
146/// `createbuiltintable` — see implementation.
147pub fn createbuiltintable() -> &'static HashMap<String, &'static builtin> {
148    // c:150
149    builtintab.get_or_init(|| {
150        let table: &'static Vec<builtin> = &*BUILTINS;
151        let watch_bintab: &'static Vec<builtin> = &*crate::ported::modules::watch::bintab;
152        let mut m: HashMap<String, &'static builtin> =
153            HashMap::with_capacity(table.len() + watch_bintab.len());
154        for b in table.iter() {
155            m.insert(b.node.nam.clone(), b);
156        }
157        // zshrs auto-loads all modules at startup. Fold each module's
158        // bintab into the core builtintab so `disable <name>` (and
159        // dispatch generally) finds module-provided builtins without
160        // an explicit `zmodload` step. Mirrors C's `addbuiltins(name,
161        // bintab, sizeof(bintab)/sizeof(*bintab))` call from each
162        // module's `boot_` hook (e.g. `Src/Modules/watch.c:694`).
163        for b in watch_bintab.iter() {
164            m.insert(b.node.nam.clone(), b);
165        }
166        // zshrs extension: fold `znative` (the plugin package manager,
167        // src/extensions/pkg) into the builtin table so it is a first-class
168        // builtin — `whence -w znative` reports `builtin`, `builtin znative` works,
169        // and it dispatches through execbuiltin like any other. No C
170        // counterpart (znative is zshrs-original).
171        let pkg_bintab: &'static Vec<builtin> = &*crate::pkg::builtin::bintab;
172        for b in pkg_bintab.iter() {
173            m.insert(b.node.nam.clone(), b);
174        }
175        // zshrs extension: fold the ztest/zassert unit-test framework
176        // (src/extensions/ztest.rs) into the builtin table so `zassert_eq`,
177        // `ztest_run`, … are first-class builtins in `zshrs -f` (visible to
178        // `whence -w`, `builtin`, `disable`, completion) — not just the
179        // fusevm command-dispatch arm. No C counterpart (zshrs-original).
180        let ztest_bintab: &'static Vec<builtin> = &*crate::extensions::ztest::bintab;
181        for b in ztest_bintab.iter() {
182            m.insert(b.node.nam.clone(), b);
183        }
184        m
185    })
186}
187
188// ===========================================================
189// Direct ports of static builtin helpers from Src/builtin.c not
190// yet covered above. The Rust executor wires builtins through
191// `crate::ported::builtins::*` per-builtin modules; these free-
192// fn entries satisfy ABI/name parity for the drift gate.
193// ===========================================================
194
195/// Port of `printbuiltinnode()` from `Src/builtin.c:174`.
196/// C decl: `printbuiltinnode(HashNode hn, int printflags)`
197///
198/// C body (c:174-194):
199/// ```c
200/// Builtin bn = (Builtin) hn;
201/// if (printflags & PRINT_WHENCE_WORD) {
202///     printf("%s: builtin\n", bn->node.nam); return;
203/// }
204/// if (printflags & PRINT_WHENCE_CSH) {
205///     printf("%s: shell built-in command\n", bn->node.nam); return;
206/// }
207/// if (printflags & PRINT_WHENCE_VERBOSE) {
208///     printf("%s is a shell builtin\n", bn->node.nam); return;
209/// }
210/// /* default is name only */
211/// printf("%s\n", bn->node.nam);
212/// ```
213pub fn printbuiltinnode(
214    hn: *mut hashnode, // c:174
215    printflags: i32,
216) {
217    if hn.is_null() {
218        return;
219    }
220    let bn = unsafe { &*hn }; // c:176
221    if (printflags & PRINT_WHENCE_WORD as i32) != 0 {
222        // c:178
223        println!("{}: builtin", bn.nam); // c:179
224        return; // c:180
225    }
226    if (printflags & PRINT_WHENCE_CSH as i32) != 0 {
227        // c:183
228        println!("{}: shell built-in command", bn.nam); // c:184
229        return; // c:185
230    }
231    if (printflags & PRINT_WHENCE_VERBOSE as i32) != 0 {
232        // c:188
233        // !!! DASH-FAITHFUL GATE (no C counterpart) !!! dash words POSIX special
234        // builtins as "a special shell builtin".
235        let kind = if crate::dash_mode::dash_faithful() && crate::dash_mode::dash_special_builtin(&bn.nam) {
236            "special shell builtin"
237        } else {
238            "shell builtin"
239        };
240        println!("{} is a {}", bn.nam, kind); // c:189
241        return; // c:190
242    }
243    // c:193 — `/* default is name only */`
244    println!("{}", bn.nam); // c:194
245}
246
247/// Port of `freebuiltinnode()` from `Src/builtin.c:199`.
248/// C decl: `freebuiltinnode(HashNode hn)`
249/// C: `static void freebuiltinnode(HashNode hn)` — free a builtin-table
250///   node only when BINF_ADDED is clear (i.e., dynamically added).
251pub fn freebuiltinnode(hn: *mut hashnode) {
252    // c:199
253    if hn.is_null() {
254        return;
255    }
256    let bn = unsafe { &*hn };
257    // c:204 — `if (!(bn->node.flags & BINF_ADDED))` then free.
258    if (bn.flags as u32 & BINF_ADDED) == 0 { // c:204
259         // Rust drop handles the actual free; nothing more to do.
260    }
261}
262
263/// Port of `init_builtins()` from Src/builtin.c:212.
264/// C: `void init_builtins(void)` — when not in EMULATE_ZSH, disable
265///   the `repeat` reserved word (compat for sh/ksh).
266///
267/// ```c
268/// if (!EMULATION(EMULATE_ZSH)) {
269///     HashNode hn = reswdtab->getnode2(reswdtab, "repeat");
270///     if (hn)
271///         reswdtab->disablenode(hn, 0);
272/// }
273/// ```
274pub fn init_builtins() {
275    // c:212
276    // c:214 — `if (!EMULATION(EMULATE_ZSH))`. EMULATION reads the
277    // canonical `emulation` global directly per zsh.h:2347.
278    if !EMULATION(EMULATE_ZSH) {
279        // c:214
280        // c:215-217 — `hn = reswdtab->getnode2(reswdtab,"repeat");
281        //              if (hn) reswdtab->disablenode(hn, 0);`
282        if let Ok(mut tab) = reswdtab_lock().write() {
283            tab.disable("repeat");
284        }
285    }
286}
287
288/// Port of `OPT_ALLOC_CHUNK` from `Src/builtin.c:223`. Number of
289/// `ops->args[]` slots `new_optarg()` grows the array by when full.
290pub const OPT_ALLOC_CHUNK: i32 = 16; // c:227
291
292/// Port of `new_optarg()` from `Src/builtin.c:227`.
293/// C decl: `new_optarg(Options ops)`
294/// C: `static int new_optarg(Options ops)` — grow the `ops->args[]`
295///   array by `OPT_ALLOC_CHUNK` slots when full. Returns 1 on overflow
296///   (>=63 args), 0 on success.
297pub fn new_optarg(ops: &mut options) -> i32 {
298    // c:227
299    // c:227 — `if (ops->argscount == 63) return 1;`
300    if ops.argscount == 63 {
301        // c:231
302        return 1;
303    }
304    // c:232-241 — grow ops->args by OPT_ALLOC_CHUNK if argsalloc == argscount.
305    if ops.argsalloc == ops.argscount {
306        // c:232
307        ops.args
308            .resize((ops.argsalloc + OPT_ALLOC_CHUNK) as usize, String::new());
309        ops.argsalloc += OPT_ALLOC_CHUNK; // c:240
310    }
311    ops.argscount += 1; // c:243
312    0 // c:244
313}
314
315// ===========================================================
316// ksh_autoload_body moved from src/ported/vm_helper.
317// Mirrors the ksh-style autoload helper in Src/builtin.c
318// (bin_functions / load_function_def).
319// ===========================================================
320// (impl crate::ported::exec::ShellExecutor block deleted — was lines 12343..12376; per user feedback the bin_* methods were fake. Recorder hooks preserved at file bottom.)
321
322bitflags::bitflags! {
323    /// Flags for autoloaded functions (autoload builtin -- Src/builtin.c bin_autoload).
324    #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
325    pub struct AutoloadFlags: u32 {
326        const NO_ALIAS = 0b00000001;      // -U: don't expand aliases
327        const ZSH_STYLE = 0b00000010;     // -z: zsh-style autoload
328        const KSH_STYLE = 0b00000100;     // -k: ksh-style autoload
329        const TRACE = 0b00001000;         // -t: trace execution
330        const USE_CALLER_DIR = 0b00010000; // -d: use calling function's dir
331        const LOADED = 0b00100000;        // function has been loaded
332    }
333}
334
335/// Port of `execbuiltin()` from `Src/builtin.c:250`.
336/// C decl: `execbuiltin(LinkList args, LinkList assigns, Builtin bn)`
337///
338/// C: `int execbuiltin(LinkList args, LinkList assigns, Builtin bn)` —
339///   execute a builtin handler function after parsing the arguments.
340///
341/// Walks `bn->optstr` against `args`, populating `ops.ind[c]` (`|= 1`
342/// for `-X`, `|= 2` for `+X`, `<< 2` arg-index for opts taking args
343/// per the `:`/`::`/`:%` suffix convention), then calls
344/// `bn->handlerfunc(name, argv, &ops, bn->funcid)`.
345///
346/// Signature note: C consumes the name via `ugetnode(args)` first
347/// (c:262); the Rust port receives `args` without the name and reads
348/// `bn->node.nam` directly. C's `LinkList assigns` ports to
349/// `Vec<asgment>` (closer to the C type than the earlier
350/// `Vec<(String, String)>` pair-tuple). `assignfunc` handler dispatch
351/// (c:495-502) — BINF_ASSIGN builtins taking two argument lists —
352/// isn't ported (no Rust-side caller passes a non-empty `assigns`),
353/// so XTRACE prints the structure but BINF_ASSIGN dispatch falls
354/// through to the plain handler.
355pub fn execbuiltin(
356    args: Vec<String>,
357    assigns: Vec<asgment>, // c:250
358    bn: *mut builtin,
359) -> i32 {
360    if bn.is_null() {
361        return 1;
362    }
363    let bn_ref = unsafe { &*bn };
364
365    // c:252-254 — locals.
366    let pp: Option<&str>; // c:252 char *pp
367    let name: String; // c:252 char *name
368    let mut optstr: Option<String>; // c:252 char *optstr
369    let mut flags: i32; // c:253 int flags
370    let mut argc: i32; // c:253 int argc
371    let mut execop: u8; // c:253 int execop
372    let xtr: bool = isset(XTRACE); // c:253 int xtr = isset(XTRACE)
373
374    // c:256-259 — `memset(ops.ind, 0, ...); ops.args = NULL; ops.argscount=ops.argsalloc=0;`
375    let mut ops = options {
376        ind: [0u8; MAX_OPS],
377        args: Vec::new(), // c:257
378        argscount: 0,
379        argsalloc: 0,
380    }; // c:258-259
381
382    // c:262 — `name = (char *) ugetnode(args);` — Rust reads bn.node.nam.
383    name = bn_ref.node.nam.clone(); // c:262
384
385    // c:264-268 — `if (!bn->handlerfunc)` early-exit.
386    if bn_ref.handlerfunc.is_none() {
387        // c:264
388        // c:265 — DPUTS(1, "Missing builtin detected too late")
389        DPUTS!(true, "Missing builtin detected too late"); // c:265
390        crate::ported::module::deletebuiltin(&name); // c:266
391        return 1; // c:267
392    }
393
394    // c:270-271 — `flags = bn->node.flags; optstr = bn->optstr;`
395    flags = bn_ref.node.flags; // c:270
396    optstr = bn_ref.optstr.clone(); // c:271
397
398    // c:275 — `argc = countlinknodes(args);` — total argv length.
399    argc = args.len() as i32; // c:275
400
401    // c:284-293 — `VARARR(char *, argarr, argc+1)` + copy args into argarr.
402    let argarr: Vec<String> = args; // c:284 argarr[]
403    let mut argv: usize = 0; // c:285 char **argv = argarr;
404
405    // c:296-411 — option parser body.
406    if let Some(ref os) = optstr.clone() {
407        // c:296
408        let optstr_local = os.clone();
409        let mut optstr_bytes: Vec<u8> = optstr_local.into_bytes();
410        // !!! DASH-STRICT GATE (no C counterpart) !!! dash's `echo` recognises only
411        // `-n`; `-e` / `-E` (and clusters like `-ne`) are printed as text.
412        if crate::dash_mode::dash_strict() && name == "echo" {
413            optstr_bytes = b"n".to_vec();
414        }
415        // !!! DASH-FAITHFUL GATE (no C counterpart) !!! dash's narrower option
416        // sets: `read` knows only `-r` (and `-p`), `local` takes no options, and
417        // `unalias` only `-a`; everything else is "Illegal option".
418        if crate::dash_mode::dash_faithful() {
419            if let Some(o) = crate::dash_mode::dash_builtin_optstr(&name) {
420                optstr_bytes = o.to_vec();
421            }
422        }
423        // !!! BASH-ONLY (no C counterpart) !!! bash's own option letters.
424        if crate::dash_mode::bash_mode() {
425            match name.as_str() {
426                "hash" => optstr_bytes = b"rdplt".to_vec(),
427                "enable" => optstr_bytes = b"anprs".to_vec(),
428                "type" => optstr_bytes.push(b'P'),
429                // bash `history`: -c clear, -d OFFSET delete, -a/-n/-r/-w/-s/-p.
430                "history" => optstr_bytes = b"acd:nprsw".to_vec(),
431                _ => {}
432            }
433        }
434        // !!! KSH93-ONLY (no C counterpart) !!! ksh93 `print -e` is accepted (escapes
435        // are already on; `-r` turns them off).
436        if crate::dash_mode::ksh93_mode() && name == "print" {
437            optstr_bytes.push(b'e');
438        }
439        let mut skipinvalid = (flags & BINF_SKIPINVALID as i32) != 0;
440        // c:297 — `char *arg = *argv;`
441        loop {
442            // c:300-303 — outer arg-by-arg loop guard:
443            //   `arg && ((sense = (*arg == '-')) || ((flags & BINF_PLUSOPTS) && *arg == '+'))`.
444            let arg_str: String = match argarr.get(argv) {
445                Some(s) => s.clone(),
446                None => break,
447            };
448            let arg_bytes = arg_str.as_bytes();
449            if arg_bytes.is_empty() {
450                break;
451            }
452            let sense: i32 = if arg_bytes[0] == b'-' { 1 } else { 0 }; // c:302
453            if sense == 0
454                && !((flags & BINF_PLUSOPTS as i32) != 0           // c:303
455                                && arg_bytes[0] == b'+')
456            {
457                break;
458            }
459            // c:305 — `if (!(flags & BINF_KEEPNUM) && idigit(arg[1])) break;`
460            if (flags & BINF_KEEPNUM as i32) == 0                            // c:305
461                && arg_bytes.len() >= 2
462                && arg_bytes[1].is_ascii_digit()
463            {
464                break;
465            }
466            // c:308 — `if ((flags & BINF_SKIPDASH) && !arg[1]) break;`
467            if (flags & BINF_SKIPDASH as i32) != 0 && arg_bytes.len() == 1 {
468                // c:308
469                break;
470            }
471            // !!! DASH-STRICT GATE (no C counterpart) !!! dash's `echo` takes ONE
472            // option word and only if it is exactly `-n` (`echo -nn` and
473            // `echo -n -n a` print the later words as text).
474            if crate::dash_mode::dash_strict() && name == "echo" && (argv > 0 || arg_str != "-n") {
475                break;
476            }
477            // !!! BASH/KSH-MODE (no C counterpart) !!! bash/ksh `echo` has no lone-`-`
478            // option arg: `echo -` prints `-` (zsh's c:337-341 swallows it).
479            if (crate::dash_mode::bash_mode() || crate::dash_mode::korn_mode())
480                && name == "echo"
481                && arg_bytes.len() == 1 {
482                break;
483            }
484            // c:310-317 — `--` end-of-options if BINF_DASHDASHVALID.
485            if (flags & BINF_DASHDASHVALID as i32) != 0 && arg_str == "--" {
486                // c:310
487                argv += 1; // c:315
488                break; // c:316
489            }
490            // c:327-332 — `BINF_SKIPINVALID`: if any char in arg[1..] is
491            // not in optstr, the whole arg is treated as a positional.
492            if skipinvalid {
493                // c:327
494                let mut all_known = true;
495                for &c in &arg_bytes[1..] {
496                    if !optstr_bytes.contains(&c) {
497                        all_known = false;
498                        break;
499                    }
500                }
501                if !all_known {
502                    break;
503                } // c:331
504            }
505            // c:335-336 — `if (arg[1] == '-') arg++;` — consume the
506            // second `-` of `--long-style`.
507            let mut k: usize = 1; // walks arg[k..]
508            if arg_bytes.len() >= 2 && arg_bytes[1] == b'-' {
509                // c:335
510                k = 2; // c:336
511            }
512            // c:337-341 — `if (!arg[1])` lone `-` / `+` indicator.
513            if arg_bytes.len() == k {
514                // c:337
515                ops.ind[b'-' as usize] = 1; // c:338
516                if sense == 0 {
517                    // c:339
518                    ops.ind[b'+' as usize] = 1; // c:340
519                }
520            }
521            // c:343-386 — inner loop over `*++arg` characters.
522            let mut bad_opt: Option<u8> = None;
523            while k < arg_bytes.len() {
524                // c:343
525                let c = arg_bytes[k];
526                execop = c; // c:345
527                let optptr = optstr_bytes.iter().position(|&b| b == c); // c:345 strchr(optstr,c)
528                if let Some(optidx) = optptr {
529                    // c:345
530                    ops.ind[c as usize] = if sense != 0 { 1 } else { 2 }; // c:346
531                                                                          // c:347 — `if (optptr[1] == ':')` — option takes arg.
532                    if optidx + 1 < optstr_bytes.len() && optstr_bytes[optidx + 1] == b':' {
533                        let mut argptr: Option<String> = None;
534                        // c:349-352 — `if (optptr[2] == ':')` optional same-word.
535                        if optidx + 2 < optstr_bytes.len() && optstr_bytes[optidx + 2] == b':' {
536                            if k + 1 < arg_bytes.len() {
537                                // c:350
538                                argptr =
539                                    Some(String::from_utf8_lossy(&arg_bytes[k + 1..]).into_owned());
540                                // c:351
541                            }
542                        } else if optidx + 2 < optstr_bytes.len()
543                            && optstr_bytes[optidx + 2] == b'%'
544                        {
545                            // c:353-359 — `:%` numeric optional same or next word.
546                            if k + 1 < arg_bytes.len() && arg_bytes[k + 1].is_ascii_digit() {
547                                argptr =
548                                    Some(String::from_utf8_lossy(&arg_bytes[k + 1..]).into_owned());
549                            } else if let Some(nxt) = argarr.get(argv + 1) {
550                                if !nxt.is_empty() && nxt.as_bytes()[0].is_ascii_digit() {
551                                    argv += 1; // c:359 arg = *++argv
552                                    argptr = Some(nxt.clone());
553                                }
554                            }
555                        } else {
556                            // c:360-370 — plain `:` mandatory arg.
557                            if k + 1 < arg_bytes.len() {
558                                // c:362
559                                argptr =
560                                    Some(String::from_utf8_lossy(&arg_bytes[k + 1..]).into_owned());
561                            // c:363
562                            } else if let Some(nxt) = argarr.get(argv + 1) {
563                                argv += 1; // c:364 arg = *++argv
564                                argptr = Some(nxt.clone()); // c:365
565                            } else {
566                                // c:366-370 — `argument expected: -%c`.
567                                zwarnnam(&name, &format!("argument expected: -{}", execop as char)); // c:367-368
568                                return 1; // c:369
569                            }
570                        }
571                        if let Some(ap) = argptr {
572                            // c:372
573                            // c:373-377 — new_optarg overflow.
574                            if new_optarg(&mut ops) != 0 {
575                                // c:373
576                                zwarnnam(&name, "too many option arguments"); // c:374-375
577                                return 1; // c:376
578                            }
579                            // c:378 — `ops.ind[execop] |= ops.argscount << 2;`
580                            ops.ind[execop as usize] |= (ops.argscount as u8) << 2;
581                            // c:379 — `ops.args[ops.argscount-1] = argptr;`
582                            ops.args[(ops.argscount - 1) as usize] = ap;
583                            // c:380-381 — `while (arg[1]) arg++;` consume the rest.
584                            k = arg_bytes.len();
585                        }
586                    }
587                    k += 1;
588                } else {
589                    bad_opt = Some(c); // c:385 break
590                    break;
591                }
592            }
593            // c:389-394 — if we exited mid-arg on a bad char, emit "bad option".
594            if let Some(badc) = bad_opt {
595                // c:389
596                zwarnnam(
597                    &name,
598                    &format!(
599                        "bad option: {}{}",
600                        if sense != 0 { '-' } else { '+' },
601                        badc as char
602                    ),
603                ); // c:392
604                // ZSHRS-ONLY. POSIX: "If there is an error in a special
605                // built-in utility ... the shell shall abort." A bad
606                // option IS such an error, and ksh93u+m, mksh and dash
607                // all exit the script on it (`readonly -X; echo AFTER`
608                // never reaches the echo). zsh and bash do not, so this
609                // is gated to the POSIX-family drop-ins. The deferred
610                // EXIT_PENDING pair is the same one the POSIXBUILTINS
611                // path in fusevm_bridge arms.
612                if (flags & BINF_PSPECIAL as i32) != 0
613                    && !crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE)
614                    && crate::extensions::emulation_output::special_builtin_error_aborts()
615                {
616                    use std::sync::atomic::Ordering;
617                    EXIT_VAL.store(
618                        crate::extensions::emulation_output::special_builtin_abort_status(true, 1),
619                        Ordering::Relaxed,
620                    );
621                    EXIT_PENDING.store(1, Ordering::Relaxed);
622                }
623                // bash reports a builtin usage error with status 2, zsh
624                // with 1 (`readonly -X` → 2 in bash 5.3, 1 in zsh 5.9).
625                // The aborting shells never reach this return; their
626                // status comes from the deferred exit above.
627                if crate::extensions::emulation_startup::personality()
628                    == crate::extensions::emulation_startup::Personality::Bash
629                {
630                    return 2;
631                }
632                // dash's `sh_error("Illegal option")` status is 2 for every
633                // builtin, not zsh's 1.
634                if crate::dash_mode::dash_faithful() {
635                    return 2;
636                }
637                return 1; // c:393
638            }
639            // c:395 — `arg = *++argv;`
640            argv += 1; // c:395
641                       // c:398-402 — BINF_PRINTOPTS R-mode switch to "ne" optstr.
642            if (flags & BINF_PRINTOPTS as i32) != 0                          // c:398
643                && ops.ind[b'R' as usize] != 0
644                && ops.ind[b'f' as usize] == 0
645            {
646                optstr_bytes = b"ne".to_vec(); // c:400
647                flags |= BINF_SKIPINVALID as i32; // c:401
648                skipinvalid = true;
649            }
650            // c:404-405 — `if (ops.ind['-']) break;` — `--` terminates.
651            if ops.ind[b'-' as usize] != 0 {
652                // c:404
653                break;
654            }
655        }
656        let _ = optstr_bytes;
657    } else if (flags & BINF_HANDLES_OPTS as i32) == 0                        // c:407
658        && argarr.get(argv).map(|s| s == "--").unwrap_or(false)
659    {
660        // c:408
661        // c:409-410 — `ops.ind['-'] = 1; argv++;`
662        ops.ind[b'-' as usize] = 1; // c:409
663        argv += 1; // c:410
664    }
665    // Suppress optstr-unused warnings on the `else` path.
666    let _ = optstr.take();
667
668    // c:414-421 — apply `bn->defopts` defaults.
669    pp = bn_ref.defopts.as_deref(); // c:414
670    if let Some(pp_str) = pp {
671        // c:414
672        for &b in pp_str.as_bytes() {
673            // c:415
674            if ops.ind[b as usize] == 0 {
675                // c:417
676                ops.ind[b as usize] = 1; // c:418
677            }
678        }
679    }
680
681    // c:424 — `argc -= argv - argarr;` — subtract consumed flag args.
682    argc -= argv as i32; // c:424
683
684    // c:426-429 — errflag check. C zsh clears here because the
685    // *outer* execcmd_exec (Src/exec.c:3468) already bailed without
686    // calling the builtin when errflag was set after prefork; the
687    // clear at c:427 is paired with an earlier-arrived-at builtin
688    // entry (signal-driven, not prefork-driven).
689    //
690    // zshrs's bytecode flow always reaches the builtin call site
691    // even when arg expansion set errflag (the c:3468 pre-dispatch
692    // gate has no equivalent in the bytecode). Clearing errflag
693    // here would swallow the prefork error and let the post-command
694    // ERREXIT_CHECK pass — `set -u; echo $undefined; echo done`
695    // would run `echo done` instead of aborting.
696    //
697    // The flag is preserved for EVERY shell, not only scripts. Gating the
698    // clear on INTERACTIVE made a ZLE widget / completion function the one
699    // place a prefork error was still swallowed: `: $((1/0))` inside a widget
700    // kept executing its LIST, where C ends the list at c:Src/exec.c:1443
701    // (`while (… && !breaks && !retflag && !errflag)`). Observable on the
702    // default config with no ZLE function at all:
703    //
704    //   setopt nounset; print pre; print $undefzz9; print DONEZZ
705    //     zsh  : pre / `undefzz9: parameter not set`            (stops)
706    //     zshrs: pre / `undefzz9: parameter not set` / DONEZZ   (continues)
707    //
708    // C's REPL does not depend on this clear: c:Src/init.c:150 and c:161 set
709    // `errflag = 0` twice per prompt iteration, and both are ported
710    // (init.rs, `// c:139` and `// c:150`), so a surviving flag cannot wedge
711    // the line editor.
712    //
713    // KNOWN, DELIBERATE DEVIATION: `eval` is itself a builtin, so an `eval`
714    // ENTERED with errflag already set now returns 1 without running its body
715    // and keeps the flag, where C returns 1 and clears it. That is the price
716    // of not having C's c:Src/exec.c:3760-3763 pre-dispatch gate
717    // (`if (errflag) { lastval = 1; goto err; }`) on the bytecode path — the
718    // same trade already accepted for scripts. The strictly faithful fix is
719    // to add that pre-gate and restore C's unconditional clear.
720    let ef = errflag.load(Relaxed);
721    if (ef & ERRFLAG_ERROR) != 0 {
722        return 1; // c:428
723    }
724
725    // !!! DASH-FAITHFUL GATE (no C counterpart) !!! dash's `unset` with no
726    // names is a successful no-op.
727    if argc == 0 && name == "unset" && crate::dash_mode::dash_faithful() {
728        return 0;
729    }
730
731    // c:432-436 — argc bounds check.
732    if argc < bn_ref.minargs                                                 // c:432
733        || (argc > bn_ref.maxargs && bn_ref.maxargs != -1)
734    {
735        zwarnnam(
736            &name, // c:433
737            if argc < bn_ref.minargs {
738                "not enough arguments"
739            } else {
740                "too many arguments"
741            },
742        ); // c:434
743        // ksh93 `let` with no expression is a usage error, status 2.
744        if argc < bn_ref.minargs && name == "let" && crate::dash_mode::ksh93_mode() {
745            return 2;
746        }
747        // !!! DASH-FAITHFUL GATE (no C counterpart) !!! dash usage errors are status 2.
748        if crate::dash_mode::dash_faithful() {
749            return 2;
750        }
751        return 1; // c:435
752    }
753
754    // c:438-494 — display execution trace information, if required.
755    if xtr {
756        // c:439
757        // c:440-441 — `char **fullargv = argarr;` — use FULL argv
758        // (including consumed option words) so XTRACE shows what the
759        // user typed, not the option-stripped tail.
760        let fullargv = &argarr; // c:441
761                                // All emits below are `fprintf(xtrerr, …)` / `fputc(…, xtrerr)` in
762                                // C: append to the xtrerr line buffer and flush ONCE at the
763                                // trailing `\n` (c:492-493), so the builtin's trace line reaches
764                                // the shared stderr fd in a single write — a forked pipeline
765                                // stage never interleaves with a concurrent one.
766        use crate::fusevm_bridge::{xtrerr_flush, xtrerr_fputs};
767        printprompt4(); // c:442 — buffers PS4 into xtrerr
768                        // c:443 — `fprintf(xtrerr, "%s", name);`
769        xtrerr_fputs(&name); // c:443
770                             // c:444-447 — `while (*fullargv) { fputc(' ',xtrerr); quotedzputs(...); }`
771                             // C zsh's parser pre-splits `name=value` args for
772                             // BINF_ASSIGN-flagged builtins (export/typeset/declare/local/
773                             // readonly/integer/float) into asg{name,value} nodes, which
774                             // the c:448-491 branch emits as
775                             //   `quotedzputs(name) + '=' + quotedzputs(value)` (c:453,487-488).
776                             // zshrs's compiler doesn't populate `assigns`; args arrive
777                             // here as the unsplit `"name=value"` whole-strings, so the
778                             // c:443-446 path quotes the entire string and produces
779                             // `export 'VAR=val'` instead of `export VAR=val`. Inline the
780                             // same split here — the prefix is a legal scalar/array name
781                             // (ident, optionally `[subscript]`) followed by `=` or
782                             // `+=`, and the suffix is the value. Detection mirrors the
783                             // C parser at Src/lex.c:2169 (gettokstr ASSIGN recognition)
784                             // and is faithful to what the C path would have produced
785                             // from a pre-split asg node.
786        let is_assign = (bn_ref.node.flags as u32 & BINF_ASSIGN) != 0;
787        for s in fullargv {
788            // c:444
789            xtrerr_fputs(" "); // c:445 fputc(' ', xtrerr)
790            let mut emitted = false;
791            if is_assign {
792                let sbytes = s.as_bytes();
793                // Walk the ident prefix.
794                let mut i = 0usize;
795                if !sbytes.is_empty() && (sbytes[0].is_ascii_alphabetic() || sbytes[0] == b'_') {
796                    i = 1;
797                    while i < sbytes.len()
798                        && (sbytes[i].is_ascii_alphanumeric() || sbytes[i] == b'_')
799                    {
800                        i += 1;
801                    }
802                    // Optional `[subscript]` — bracket-balanced.
803                    if i < sbytes.len() && sbytes[i] == b'[' {
804                        let mut depth = 1i32;
805                        i += 1;
806                        while i < sbytes.len() && depth > 0 {
807                            match sbytes[i] {
808                                b'[' => depth += 1,
809                                b']' => depth -= 1,
810                                _ => {}
811                            }
812                            i += 1;
813                        }
814                        if depth != 0 {
815                            i = 0; // unbalanced — bail to default path
816                        }
817                    }
818                    if i > 0 {
819                        // Match `=` or `+=` separator.
820                        let (sep_len, sep) =
821                            if sbytes.get(i) == Some(&b'+') && sbytes.get(i + 1) == Some(&b'=') {
822                                (2usize, "+=")
823                            } else if sbytes.get(i) == Some(&b'=') {
824                                (1usize, "=")
825                            } else {
826                                (0, "")
827                            };
828                        let val = &s[i + sep_len..];
829                        if sep_len != 0
830                            && val.starts_with("(\u{1f}")
831                            && val.ends_with("\u{1f})")
832                            && val.len() >= 3
833                        {
834                            // c:454-485 — an ASG_ARRAY asgment: `=(`, then
835                            // ` quoted(elem)` per element, then ` )`. The
836                            // compiler packs `name=( … )` into one word with
837                            // REJOIN_SEP around each element (fusevm_bridge
838                            // BUILTIN_TYPESET_PAREN_PACK); unpack it the way
839                            // bin_typeset does.
840                            let mut elems: Vec<&str> =
841                                val[1..val.len() - 1].split('\u{1f}').collect();
842                            if elems.first().is_some_and(|e| e.is_empty()) {
843                                elems.remove(0);
844                            }
845                            if elems.last().is_some_and(|e| e.is_empty()) {
846                                elems.pop();
847                            }
848                            xtrerr_fputs(&s[..i]);
849                            xtrerr_fputs(sep);
850                            xtrerr_fputs("("); // c:455
851                            for e in elems {
852                                xtrerr_fputs(" "); // c:479
853                                xtrerr_fputs(&quotedzputs(e)); // c:480-481
854                            }
855                            xtrerr_fputs(" )"); // c:485
856                            emitted = true;
857                        } else if sep_len != 0 {
858                            // c:453,487-488 — emit name + sep + quoted(value).
859                            xtrerr_fputs(&format!(
860                                "{}{}{}",
861                                &s[..i],
862                                sep,
863                                quotedzputs(&s[i + sep_len..])
864                            ));
865                            emitted = true;
866                        }
867                    }
868                }
869            }
870            if !emitted {
871                xtrerr_fputs(&quotedzputs(s)); // c:446
872            }
873        }
874        // c:448-491 — `if (assigns) { for (node = firstnode(assigns); ...) }`.
875        for asg in &assigns {
876            // c:450 firstnode/incnode
877            xtrerr_fputs(" "); // c:452 fputc(' ', xtrerr)
878            xtrerr_fputs(&quotedzputs(&asg.name)); // c:453
879            if (asg.flags & ASG_ARRAY) != 0 {
880                // c:454
881                xtrerr_fputs("=("); // c:455
882                if let Some(ref list) = asg.array {
883                    // c:456
884                    if (asg.flags & ASG_KEY_VALUE) != 0 {
885                        // c:457
886                        // c:458-473 — `LinkNode keynode, valnode;` walk
887                        // alternating key/value pairs, emitting
888                        // `[key]=value` per pair. Uses the typed
889                        // `LinkList<String>` accessors from
890                        // `src/ported/linklist.rs` which port the
891                        // `firstnode` / `nextnode` / `getdata` macros
892                        // from `Src/zsh.h:576-588`.
893                        let mut keynode = list.firstnode(); // c:459
894                        loop {
895                            // c:460
896                            // c:461-462 — `if (!keynode) break;`
897                            let kidx = match keynode {
898                                // c:461
899                                Some(i) => i,
900                                None => break, // c:462
901                            };
902                            // c:463-465 — `valnode = nextnode(keynode); if (!valnode) break;`
903                            let vidx = match list.nextnode(kidx) {
904                                // c:463
905                                Some(i) => i,
906                                None => break, // c:465
907                            };
908                            // c:466-468 — `fputc('['); quotedzputs(getdata(keynode));`
909                            xtrerr_fputs("["); // c:466
910                            if let Some(k) = list.getdata(kidx) {
911                                // c:467 getdata
912                                xtrerr_fputs(&quotedzputs(k));
913                                // c:467
914                            }
915                            // c:469 — `fprintf(stderr, "]=");`
916                            xtrerr_fputs("]="); // c:469
917                                                // c:470-471 — `quotedzputs(getdata(valnode));`
918                            if let Some(v) = list.getdata(vidx) {
919                                // c:470
920                                xtrerr_fputs(&quotedzputs(v));
921                                // c:470
922                            }
923                            // c:472 — `keynode = nextnode(valnode);`
924                            keynode = list.nextnode(vidx); // c:472
925                        }
926                    } else {
927                        // c:474
928                        // c:475-482 — plain array emit: walk every node
929                        // and emit ` <quotedzputs(elem)>`.
930                        let mut arrnode = list.firstnode(); // c:476
931                        while let Some(idx) = arrnode {
932                            // c:477
933                            xtrerr_fputs(" "); // c:479 fputc(' ', xtrerr)
934                            if let Some(elem) = list.getdata(idx) {
935                                // c:480 getdata
936                                xtrerr_fputs(&quotedzputs(elem));
937                                // c:480
938                            }
939                            arrnode = list.nextnode(idx); // c:478 incnode
940                        }
941                    }
942                }
943                xtrerr_fputs(" )"); // c:485
944            } else if let Some(ref scalar) = asg.scalar {
945                // c:486
946                xtrerr_fputs("="); // c:487 fputc('=', xtrerr)
947                xtrerr_fputs(&quotedzputs(scalar)); // c:488
948            }
949        }
950        // c:492-493 — `fputc('\n', xtrerr); fflush(xtrerr);`
951        xtrerr_fputs("\n"); // c:492 fputc('\n', xtrerr)
952        xtrerr_flush(); // c:493 fflush(xtrerr) — one write for the whole line
953    }
954
955    // c:506 — `return (*(bn->handlerfunc))(name, argv, &ops, bn->funcid);`
956    let trimmed: Vec<String> = argarr[argv..].to_vec();
957    let handler = bn_ref.handlerfunc.expect("handlerfunc checked at c:264");
958    handler(&name, &trimmed, &ops, bn_ref.funcid) // c:506
959}
960
961/// Port of `bin_enable()` from `Src/builtin.c:517`.
962/// C decl: `bin_enable(char *name, char **argv, Options ops, int func)`
963/// C: `int bin_enable(char *name, char **argv, Options ops, int func)` —
964///   enable/disable hashtab entries (default builtins; `-f`/`-r`/`-s`/`-a`
965///   pick alternate tables); `-p` routes to pat_enables (pattern toggles).
966/// WARNING: param names don't match C — Rust=(name, argv, func) vs C=(name, argv, ops, func)
967pub fn bin_enable(
968    name: &str,
969    argv: &[String], // c:517
970    ops: &options,
971    func: i32,
972) -> i32 {
973    enum Tab {
974        Builtin,
975        Shfunc,
976        Reswd,
977        Alias,
978        SufAlias,
979    }
980    if crate::dash_mode::bash_mode()
981        && (argv.is_empty() || [b'n', b'a', b'p', b's'].iter().any(|f| OPT_ISSET(ops, *f)))
982    {
983        return crate::extensions::emulation_output::bash_enable(argv, ops, func);
984    }
985    let mut returnval = 0i32; // c:524
986    let mut match_count = 0i32; // c:524
987                                // c:527-538 — `-p` early-out + table selection.
988    if OPT_ISSET(ops, b'p') {
989        // c:527
990        // c:528 — `return pat_enables(name, argv, func == BIN_ENABLE);`
991        return pat_enables(name, argv, func == BIN_ENABLE); // c:528
992    }
993    let tab = if OPT_ISSET(ops, b'f') {
994        Tab::Shfunc
995    }
996    // c:529
997    else if OPT_ISSET(ops, b'r') {
998        Tab::Reswd
999    }
1000    // c:531
1001    else if OPT_ISSET(ops, b's') {
1002        Tab::SufAlias
1003    }
1004    // c:533
1005    else if OPT_ISSET(ops, b'a') {
1006        Tab::Alias
1007    }
1008    // c:535
1009    else {
1010        Tab::Builtin
1011    }; // c:537
1012
1013    // c:540-547 — flags1/flags2 set based on enable vs disable direction.
1014    let enable = func == BIN_ENABLE;
1015    let (flags1, flags2) = if enable {
1016        // c:541
1017        (0u32, DISABLED as u32) // c:542
1018    } else {
1019        (DISABLED as u32, 0u32) // c:545
1020    };
1021
1022    // c:548 — `ht` is the live builtintab: core builtins plus those of
1023    // loaded (or autoload-stubbed) modules. zshrs's table is the static
1024    // union of every module and of its own extensions, so apply the gate
1025    // `$builtins` uses; under `--zsh` the zshrs-only extension tables
1026    // (znative, ztest, …) are not part of it either.
1027    let in_builtintab = |nm: &str| -> bool {
1028        createbuiltintable().contains_key(nm)
1029            && crate::extensions::ext_builtins::builtin_in_builtintab(nm)
1030            && (!crate::extensions::ext_builtins::hide_ext_builtins()
1031                || BUILTINS.iter().any(|b| b.node.nam == nm))
1032    };
1033
1034    // Helper closures over the chosen table.
1035    let toggle_one = |tab: &Tab, nm: &str, on: bool| -> bool {
1036        match tab {
1037            Tab::Alias => aliastab_lock()
1038                .write()
1039                .map(|mut t| if on { t.enable(nm) } else { t.disable(nm) })
1040                .unwrap_or(false),
1041            Tab::SufAlias => sufaliastab_lock()
1042                .write()
1043                .map(|mut t| if on { t.enable(nm) } else { t.disable(nm) })
1044                .unwrap_or(false),
1045            // c:541-547 — `enable`/`disable -r` toggles DISABLED on the
1046            // reswdtab entry; reswords resolve through getreswdnode in
1047            // the lexer so toggling here is enough to mask/unmask.
1048            Tab::Reswd => {
1049                let exists = reswdtab_lock()
1050                    .read()
1051                    .map(|t| t.get_including_disabled(nm).is_some())
1052                    .unwrap_or(false);
1053                if !exists {
1054                    return false;
1055                }
1056                reswdtab_lock()
1057                    .write()
1058                    .map(|mut t| if on { t.enable(nm) } else { t.disable(nm) })
1059                    .unwrap_or(false)
1060            }
1061            // c:541-547 — `enable`/`disable -f` toggles DISABLED on the
1062            // shfunctab entry; ports to disableshfuncnode/enableshfuncnode
1063            // which also unsettrap/settrap TRAP* ported.
1064            Tab::Shfunc => {
1065                let exists = shfunctab_lock()
1066                    .read()
1067                    .map(|t| t.get_including_disabled(nm).is_some())
1068                    .unwrap_or(false);
1069                if !exists {
1070                    return false;
1071                }
1072                if on {
1073                    crate::ported::hashtable::enableshfuncnode(nm);
1074                } else {
1075                    crate::ported::hashtable::disableshfuncnode(nm);
1076                }
1077                true
1078            }
1079            // c:541-547 — `enable`/`disable` toggles DISABLED on the
1080            // builtin. The C struct `builtintab` stores DISABLED in
1081            // `node.flags`; Rust port keeps `builtintab` as an
1082            // immutable static lookup and tracks the disabled set in
1083            // BUILTINS_DISABLED so dispatch can mask the entry.
1084            Tab::Builtin => {
1085                // Host-registered native commands (`extensions/native_cmds.rs`
1086                // — `git`/`arb`/`stryke` in the fat build) dispatch as builtins
1087                // but have no `builtintab` entry, so the membership test alone
1088                // rejected them: `disable git` answered "no such hash table
1089                // element" for a name the same shell's `whence -w` called a
1090                // builtin. They read the same `BUILTINS_DISABLED` set that the
1091                // arm below writes, so accepting the name here is all that is
1092                // needed for `disable git` to fall the shell through to the
1093                // `git` on `PATH`, and `enable git` to take it back.
1094                if !in_builtintab(nm) && !crate::native_cmds::is_registered(nm)
1095                {
1096                    return false;
1097                }
1098                if let Ok(mut set) = BUILTINS_DISABLED.lock() {
1099                    if on {
1100                        set.remove(nm);
1101                    } else {
1102                        set.insert(nm.to_string());
1103                    }
1104                    return true;
1105                }
1106                false
1107            }
1108        }
1109    };
1110    let collect_names = |tab: &Tab| -> Vec<String> {
1111        match tab {
1112            Tab::Alias => aliastab_lock()
1113                .read()
1114                .map(|t| t.iter().map(|(n, _)| n.clone()).collect())
1115                .unwrap_or_default(),
1116            Tab::SufAlias => sufaliastab_lock()
1117                .read()
1118                .map(|t| t.iter().map(|(n, _)| n.clone()).collect())
1119                .unwrap_or_default(),
1120            Tab::Reswd => reswdtab_lock()
1121                .read()
1122                .map(|t| t.iter().map(|(n, _)| n.clone()).collect())
1123                .unwrap_or_default(),
1124            Tab::Shfunc => shfunctab_lock()
1125                .read()
1126                .map(|t| t.iter().map(|(n, _)| n.clone()).collect())
1127                .unwrap_or_default(),
1128            Tab::Builtin => createbuiltintable()
1129                .keys()
1130                .filter(|n| in_builtintab(n))
1131                .cloned()
1132                .collect(),
1133        }
1134    };
1135
1136    // c:553-558 — no-args list.
1137    if argv.is_empty() {
1138        // c:553
1139        queue_signals(); // c:554
1140                         // c:555 — `scanhashtable(ht, 1, flags1, flags2, ht->printnode, 0);`
1141                         // Filter: print only entries where (flags & flags1) == flags1
1142                         // && (flags & flags2) == 0. For enable/disable, flags1 and
1143                         // flags2 are DISABLED-bit selectors that mask the listed set
1144                         // to ONLY the kind being toggled (enable lists enabled,
1145                         // disable lists disabled).
1146        let is_disabled = |nm: &str| -> bool {
1147            match tab {
1148                Tab::Alias => aliastab_lock()
1149                    .read()
1150                    .ok()
1151                    .and_then(|t| {
1152                        t.get_including_disabled(nm)
1153                            .map(|a| (a.node.flags & DISABLED as i32) != 0)
1154                    })
1155                    .unwrap_or(false),
1156                Tab::SufAlias => sufaliastab_lock()
1157                    .read()
1158                    .ok()
1159                    .and_then(|t| {
1160                        t.get_including_disabled(nm)
1161                            .map(|a| (a.node.flags & DISABLED as i32) != 0)
1162                    })
1163                    .unwrap_or(false),
1164                Tab::Reswd => reswdtab_lock()
1165                    .read()
1166                    .ok()
1167                    .and_then(|t| {
1168                        t.get_including_disabled(nm)
1169                            .map(|r| (r.node.flags & DISABLED as i32) != 0)
1170                    })
1171                    .unwrap_or(false),
1172                Tab::Shfunc => shfunctab_lock()
1173                    .read()
1174                    .ok()
1175                    .and_then(|t| {
1176                        t.get_including_disabled(nm)
1177                            .map(|f| (f.node.flags & DISABLED as i32) != 0)
1178                    })
1179                    .unwrap_or(false),
1180                Tab::Builtin => BUILTINS_DISABLED
1181                    .lock()
1182                    .map(|s| s.contains(nm))
1183                    .unwrap_or(false),
1184            }
1185        };
1186        // c:Src/builtin.c — `scanhashtable(ht, 1, …)` walks the
1187        // table in sorted order (the first arg `sorted=1`). Mirror
1188        // by sorting the collected names before the per-name walk.
1189        // Without this, builtin/reswd listings came out in HashMap
1190        // iteration order, diverging from zsh.
1191        let mut all_names = collect_names(&tab);
1192        all_names.sort();
1193        for nm in all_names {
1194            let dis = is_disabled(&nm);
1195            let entry_flags = if dis { DISABLED as u32 } else { 0 };
1196            if (entry_flags & flags1) == flags1 && (entry_flags & flags2) == 0 {
1197                // c:Src/builtin.c — `ht->printnode` dispatches per
1198                // table type. For alias/sufalias, printaliasnode
1199                // (hashtable.rs:1477) prints `name=value`. For
1200                // builtin/reswd/shfunc, just the name. Previous
1201                // Rust port printed bare names for all kinds, so
1202                // `enable -a` lost the alias definitions. Mirror
1203                // C's printnode dispatch here.
1204                match tab {
1205                    Tab::Alias => {
1206                        let val = aliastab_lock()
1207                            .read()
1208                            .ok()
1209                            .and_then(|t| t.get_including_disabled(&nm).map(|a| a.text.clone()));
1210                        if let Some(v) = val {
1211                            println!("{}={}", nm, quotedzputs(&v));
1212                        } else {
1213                            println!("{}", nm);
1214                        }
1215                    }
1216                    Tab::SufAlias => {
1217                        let val = sufaliastab_lock()
1218                            .read()
1219                            .ok()
1220                            .and_then(|t| t.get_including_disabled(&nm).map(|a| a.text.clone()));
1221                        if let Some(v) = val {
1222                            println!("{}={}", nm, quotedzputs(&v));
1223                        } else {
1224                            println!("{}", nm);
1225                        }
1226                    }
1227                    Tab::Shfunc => {
1228                        // shfunctab->printnode is printshfuncnode
1229                        // (c:Src/hashtable.c:914 via c:555 printflags 0):
1230                        // the whole definition, not the bare name.
1231                        let shf = shfunctab_lock()
1232                            .read()
1233                            .ok()
1234                            .and_then(|t| t.get_including_disabled(&nm).cloned());
1235                        match shf {
1236                            Some(f) => crate::ported::hashtable::printshfuncnode(&f, 0),
1237                            None => println!("{}", nm),
1238                        }
1239                    }
1240                    _ => println!("{}", nm),
1241                }
1242            }
1243        }
1244        unqueue_signals(); // c:556
1245        return 0; // c:557
1246    }
1247
1248    // c:561-580 — `-m` glob branch.
1249    if OPT_ISSET(ops, b'm') {
1250        // c:561
1251        for arg in argv {
1252            // c:562
1253            queue_signals(); // c:563
1254            let pprog = patcompile(
1255                &{
1256                    let mut __pat_tok = crate::pattern_data_escape::escape_data_backslashes(&arg); // a backslash tokenize leaves raw is DATA (c:Src/glob.c:3651)
1257                    crate::ported::glob::tokenize(&mut __pat_tok);
1258                    __pat_tok
1259                }, // c:566
1260                PAT_HEAPDUP,
1261                None,
1262            );
1263            if let Some(prog) = pprog {
1264                for nm in collect_names(&tab) {
1265                    if pattry(&prog, &nm) {
1266                        // c:567
1267                        if toggle_one(&tab, &nm, enable) {
1268                            match_count += 1; // c:567
1269                        }
1270                    }
1271                }
1272            } else {
1273                zwarnnam(name, &format!("bad pattern : {}", arg)); // c:572
1274                returnval = 1; // c:573
1275            }
1276            unqueue_signals(); // c:575
1277        }
1278        if match_count == 0 {
1279            // c:579
1280            returnval = 1; // c:580
1281        }
1282        return returnval; // c:581
1283    }
1284
1285    // c:585-594 — literal-name dispatch.
1286    queue_signals(); // c:585
1287    for arg in argv {
1288        // c:586
1289        if !toggle_one(&tab, arg, enable) {
1290            // c:587
1291            zwarnnam(name, &format!("no such hash table element: {}", arg)); // c:590
1292            returnval = 1; // c:591
1293        }
1294    }
1295    unqueue_signals(); // c:594
1296    returnval // c:595
1297}
1298
1299/// Port of `bin_set()` from `Src/builtin.c:601`.
1300/// C decl: `bin_set(char *nam, char **args, UNUSED(Options ops), UNUSED(int func))`
1301/// C: `int bin_set(char *nam, char **args, UNUSED(Options ops),
1302///                 UNUSED(int func))` — set shell options, declare arrays,
1303///   replace positional params, or display variables.
1304/// WARNING: param names don't match C — Rust=(nam, args, _func) vs C=(nam, args, ops, func)
1305pub fn bin_set(
1306    nam: &str,
1307    args: &[String], // c:601
1308    _ops: &options,
1309    _func: i32,
1310) -> i32 {
1311    // PFA-SMR aspect: emit setopt/unsetopt events for the POSIX
1312    // `set -o NAME` / `set +o NAME` form. This is the third option
1313    // syntax (alongside setopt NAME / unsetopt NAME); a recorder
1314    // user expects all three to surface in `zwhere -k setopt`.
1315    #[cfg(feature = "recorder")]
1316    if crate::recorder::is_enabled() && !args.is_empty() {
1317        let ctx = crate::recorder::recorder_ctx_global();
1318        let mut iter = args.iter().peekable();
1319        while let Some(a) = iter.next() {
1320            match a.as_str() {
1321                "-o" => {
1322                    if let Some(name) = iter.next() {
1323                        crate::recorder::emit_setopt(name, ctx.clone());
1324                    }
1325                }
1326                "+o" => {
1327                    if let Some(name) = iter.next() {
1328                        crate::recorder::emit_unsetopt(name, ctx.clone());
1329                    }
1330                }
1331                _ => {}
1332            }
1333        }
1334    }
1335
1336    let mut argv: Vec<String> = args.to_vec();
1337    let mut hadopt = false; // c:603
1338    let mut hadplus = false; // c:603
1339    let mut hadend = false; // c:603
1340    let mut sort: i32 = 0; // c:603
1341    let mut array: i32 = 0; // c:603
1342    let mut arrayname: Option<String> = None; // c:604
1343
1344    // c:608-614 — sh-compat: bare `set -` → +xv.
1345    if !EMULATION(EMULATE_ZSH)                                               // c:608
1346        && !argv.is_empty() && argv[0] == "-"
1347    {
1348        // c:610-611 — `dosetopt(VERBOSE, 0, 0, opts); dosetopt(XTRACE, 0, 0, opts);`
1349        dosetopt(VERBOSE, 0, 0); // c:610
1350        dosetopt(XTRACE, 0, 0); // c:611
1351        if argv.len() == 1 {
1352            return 0;
1353        } // c:612-613
1354        argv.remove(0);
1355    }
1356
1357    // c:617-668 — top-level option-arg loop.
1358    let mut idx = 0usize;
1359    'outer: while idx < argv.len()                                           // c:617
1360        && (argv[idx].starts_with('-') || argv[idx].starts_with('+'))
1361    {
1362        let arg = argv[idx].clone();
1363        let action = arg.starts_with('-'); // c:619
1364        if !action {
1365            hadplus = true;
1366        } // c:620
1367          // c:621-622 — bare `-` / `+` → "--"
1368        let body: String = if arg.len() == 1 {
1369            "--".to_string()
1370        } else {
1371            arg.clone()
1372        };
1373        // c:623 — `while (*++*args)`
1374        let chars: Vec<char> = body[1..].chars().collect();
1375        let mut ci = 0usize;
1376        while ci < chars.len() {
1377            // c:623
1378            let c = chars[ci];
1379            if c != '-' || action {
1380                hadopt = true;
1381            } // c:626
1382              // c:628-632 — `--` end-of-options.
1383            if c == '-' {
1384                // c:628
1385                hadend = true; // c:629
1386                idx += 1; // c:630 args++
1387                break 'outer;
1388            }
1389            // c:633-645 — `o` long-option name follows.
1390            if c == 'o' {
1391                // c:633
1392                let optname: String = if ci + 1 < chars.len() {
1393                    chars[ci + 1..].iter().collect::<String>()
1394                } else {
1395                    idx += 1;
1396                    if idx >= argv.len() {
1397                        // c:636-637 — `printoptionstates(hadplus);
1398                        // inittyptab(); return 0;`. The Rust port
1399                        // previously returned 0 without printing,
1400                        // so `set -o` / `set +o` (no option name)
1401                        // emitted nothing instead of the full
1402                        // option-state listing (one per line).
1403                        // hadplus is true when the user invoked
1404                        // `set +o` (POSIX-output form); else `set -o`
1405                        // (zsh-output form).
1406                        crate::ported::options::printoptionstates(hadplus);
1407                        return 0;
1408                    }
1409                    argv[idx].clone()
1410                };
1411                // !!! BASH-MODE GATE (no C counterpart) !!! bash accepts a
1412                // FIXED set of `set -o` names, six of which zsh has no option
1413                // for at all (`errtrace`, `functrace`, `history`, `keyword`,
1414                // `nolog`, `posix`) and two of which zsh's `dosetopt` refuses
1415                // to change after startup (`monitor`, `onecmd`; c:746). All
1416                // eight reached C's `no such option` / `can't change option`
1417                // here, so `bash -c 'set -o posix'` failed where real bash
1418                // returns 0. `bash_set_o` owns exactly those names in --bash
1419                // and returns None everywhere else, leaving the faithful C
1420                // path below untouched for --zsh and the other emulations.
1421                if let Some(st) = crate::dash_mode::bash_set_o(&optname, action) {
1422                    if st != 0 {
1423                        unqueue_signals();
1424                        return st;
1425                    }
1426                    // c:645 — same `break` the successful C path takes, so a
1427                    // trailing operand list (`set -o posix a b`) still becomes
1428                    // the positional parameters.
1429                    break;
1430                }
1431                let optno = optlookup(&optname); // c:642
1432                if optno == 0 {
1433                    // c:642 — C: `zerrnam(nam, "no such option: %s", *args)`.
1434                    // zwarnnam emits `zsh:<nam>:<lineno>:` prefix
1435                    // (vs zerr's bare `zsh:<lineno>:`); use the
1436                    // canonical form so `set +o BAD` matches zsh's
1437                    // diagnostic format. Return 1 so $? reflects the
1438                    // failed lookup (C's execbuiltin checks errflag
1439                    // after the call; zshrs's bridge bases on the
1440                    // return value).
1441                    zerrnam(nam, &format!("no such option: {}", optname));
1442                    unqueue_signals();
1443                    return 1;
1444                } else if dosetopt(optno, if action { 1 } else { 0 }, 0) != 0
1445                // c:644
1446                {
1447                    zerrnam(nam, &format!("can't change option: {}", optname));
1448                    unqueue_signals();
1449                    return 1;
1450                }
1451                break;
1452            }
1453            // c:646-657 — `A` array-mode (with optional name arg).
1454            if c == 'A' {
1455                // c:646
1456                array = if action { 1 } else { -1 }; // c:649
1457                let nameopt: Option<String> = if ci + 1 < chars.len() {
1458                    Some(chars[ci + 1..].iter().collect::<String>())
1459                } else if idx + 1 < argv.len() {
1460                    idx += 1;
1461                    Some(argv[idx].clone())
1462                } else {
1463                    None
1464                };
1465                arrayname = nameopt.clone();
1466                if arrayname.is_none() {
1467                    // c:651
1468                    idx += 1;
1469                    break 'outer;
1470                }
1471                let ksharrays = isset(KSHARRAYS);
1472                if !ksharrays {
1473                    // c:653
1474                    idx += 1; // c:655 args++
1475                    break 'outer; // c:656
1476                }
1477                break;
1478            }
1479            // c:659-660 — `s` sort flag.
1480            if c == 's' {
1481                // c:659
1482                sort = if action { 1 } else { -1 }; // c:660
1483            } else {
1484                // c:662-666 — short-option letter: optlookupc + dosetopt.
1485                // c:Src/builtin.c — zerrnam(nam, ...) prefixes the
1486                // builtin's name into the error tag ("zsh:set:1:" vs
1487                // bare "zsh:1:"). Use zerrnam to match zsh's exact
1488                // error format for `set -X 2>&1`.
1489                let optno = if crate::dash_mode::bash_set_letter(c, action) {
1490                    ci += 1;
1491                    continue;
1492                } else {
1493                    crate::ported::options::optlookupc(c) // c:663
1494                };
1495                if optno == 0 {
1496                    // c:663
1497                    zerrnam(nam, &format!("bad option: -{}", c)); // c:663
1498                } else if dosetopt(optno, if action { 1 } else { 0 }, 0) != 0
1499                // c:664
1500                {
1501                    zerrnam(nam, &format!("can't change option: -{}", c)); // c:664
1502                }
1503            }
1504            ci += 1;
1505        }
1506        idx += 1; // c:668
1507    }
1508    // c:Src/builtin.c:668 — `if (errflag) return 1;`. A bad / can't-change
1509    // option (or `no such option`) reported via zerrnam set ERRFLAG_ERROR;
1510    // `set -Z` must exit 1, not fall through to the (success) display path.
1511    if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
1512        & crate::ported::utils::ERRFLAG_ERROR)
1513        != 0
1514    {
1515        return 1;
1516    }
1517    let _ = nam;
1518
1519    // c:676 — `queue_signals();`
1520    queue_signals();
1521    let remaining = &argv[idx..];
1522
1523    // c:678-694 — display path when no array/no args.
1524    if arrayname.is_none() {
1525        // c:678
1526        if !hadopt && remaining.is_empty() {
1527            // c:679
1528            // c:680-681 — `scanhashtable(paramtab, 1, 0, 0,
1529            //              paramtab->printnode, hadplus ? PRINT_NAMEONLY : 0);`
1530            //
1531            // Every paramtab node goes through printnode (printparamnode),
1532            // sorted by name. printparamnode owns the whole format: arrays
1533            // as `name=( a b )`, assocs as `name=( [k]=v )`, PM_HIDEVAL
1534            // specials (`commands`, `functions`, ...) as the bare name, and
1535            // name quoting (`'$'`, `'#'`). The previous port printed every
1536            // node as a joined scalar `name='a b'`.
1537            //
1538            // Clone the nodes first: printparamnode reads paramtab (tied
1539            // peers, getfn dispatch) and must not run under the guard.
1540            let mut nodes: Vec<crate::ported::zsh_h::param> = {
1541                let tab = paramtab().read().unwrap();
1542                tab.values().map(|pm| (**pm).clone()).collect()
1543            };
1544            nodes.sort_by(|a, b| hnamcmp(&a.node.nam, &b.node.nam)); // c:680 sorted=1
1545            let printflags = if hadplus { PRINT_NAMEONLY } else { 0 }; // c:681
1546            for mut pm in nodes {
1547                printparamnode(&mut pm, printflags); // c:680 printnode
1548            }
1549        }
1550        if array != 0 {
1551            // c:684
1552            // c:685-687 — `scanhashtable(paramtab, 1, PM_ARRAY, 0,
1553            //              paramtab->printnode, hadplus ? PRINT_NAMEONLY : 0)`.
1554            // scanhashtable keeps a node when `hn->flags & flags1`
1555            // (c:Src/hashtable.c:353), sorted by name; the
1556            // format is printparamnode's (`name=( a 'b c' )`).
1557            let mut nodes: Vec<crate::ported::zsh_h::param> = {
1558                let tab = paramtab().read().unwrap();
1559                tab.values()
1560                    .filter(|pm| (pm.node.flags as u32 & PM_ARRAY) != 0)
1561                    .map(|pm| (**pm).clone())
1562                    .collect()
1563            };
1564            nodes.sort_by(|a, b| hnamcmp(&a.node.nam, &b.node.nam)); // c:685 sorted=1
1565            let printflags = if hadplus { PRINT_NAMEONLY } else { 0 }; // c:686
1566            for mut pm in nodes {
1567                printparamnode(&mut pm, printflags); // c:685 printnode
1568            }
1569        }
1570        if remaining.is_empty() && !hadend {
1571            // c:688
1572            unqueue_signals();
1573            return 0; // c:690
1574        }
1575    }
1576
1577    // c:693-695 — `set -s` sort.
1578    let sorted: Vec<String> = if sort != 0 {
1579        let mut v = remaining.to_vec();
1580        if sort < 0 {
1581            v.sort_by(|a, b| b.cmp(a));
1582        } else {
1583            v.sort();
1584        }
1585        v
1586    } else {
1587        remaining.to_vec()
1588    };
1589
1590    // c:696-708 — array assign or positional-param replace.
1591    if array != 0 {
1592        // c:696
1593        // c:697-708 — build array; `array < 0` appends to existing $name.
1594        let aname = arrayname.unwrap_or_default();
1595        let mut new_arr: Vec<String> = sorted;
1596        if array < 0 {
1597            // c:701
1598            // c:702-704 — `if ((a = getaparam(arrayname)) && arrlen_gt(a, len))`.
1599            //              Read paramtab.u_arr directly; was using `:`-
1600            //              split env value as a fake array.
1601            let existing: Vec<String> = {
1602                let tab = paramtab().read().unwrap();
1603                tab.get(&aname)
1604                    .and_then(|pm| pm.u_arr.clone())
1605                    .unwrap_or_default()
1606            };
1607            if existing.len() > new_arr.len() {
1608                // c:702
1609                new_arr.extend(existing.into_iter().skip(new_arr.len())); // c:703
1610            }
1611        }
1612        // c:709 — `setaparam(arrayname, x)`. In C, setaparam →
1613        // assignaparam → setarrvalue dispatches a PM_HASHED target to
1614        // arrhashsetfn, which converts the flat list to key/value pairs.
1615        // When the target is an associative array (`typeset -A h; set -A
1616        // h k v`), route through sethparam (the flat-kv-list assoc
1617        // setter) so the values land as pairs instead of being stored as
1618        // a plain array (which left the assoc empty). Plain arrays keep
1619        // setaparam.
1620        let target_is_hashed = {
1621            use PM_HASHED;
1622            paramtab()
1623                .read()
1624                .ok()
1625                .and_then(|tab| {
1626                    tab.get(&aname)
1627                        .map(|pm| (pm.node.flags as u32 & PM_HASHED) != 0)
1628                })
1629                .unwrap_or(false)
1630        };
1631        if target_is_hashed {
1632            crate::ported::params::sethparam(&aname, new_arr);
1633        } else {
1634            setaparam(&aname, new_arr);
1635        }
1636    } else {
1637        // c:711-712 — `freearray(pparams); pparams = zarrdup(args);`
1638        // PPARAMS is the single source of truth; fusevm reads via
1639        // `exec.pparams()`.
1640        if let Ok(mut pp) = PPARAMS.lock() {
1641            *pp = sorted; // c:712
1642        }
1643        // !!! EMULATION-ONLY (no C counterpart) !!! dash and ash reset the
1644        // `getopts` cursor whenever the positional parameters are replaced
1645        // (`setparam()` clears shellparam.optind/optoff), so `getopts "ab" o
1646        // -a; set -- -b -a; getopts "ab" o` yields `b` there and `a` in
1647        // bash / ksh93 / mksh / zsh. See
1648        // dash_mode::getopts_reset_on_set_positional — false in --zsh.
1649        if crate::dash_mode::getopts_reset_on_set_positional() {
1650            ZOPTIND.store(1, Relaxed);
1651            OPTCIND.store(0, Relaxed);
1652            crate::dash_mode::getopts_forget_reported();
1653            setiparam("OPTIND", 1);
1654        }
1655    }
1656    unqueue_signals(); // c:714
1657    0 // c:715
1658}
1659
1660/// Port of `bin_pwd()` from `Src/builtin.c:728`.
1661/// C decl: `bin_pwd(UNUSED(char *name), UNUSED(char **argv), Options ops, UNUSED(int func))`
1662/// C: `int bin_pwd(UNUSED(char *name), UNUSED(char **argv), Options ops,
1663///     UNUSED(int func))` — `-r`/`-P` or (CHASELINKS && !`-L`) →
1664///   print resolved cwd via zgetcwd; else print the cached `pwd`.
1665// pwd: display the name of the current directory                          // c:728
1666/// WARNING: param names don't match C — Rust=(_name, _argv, _func) vs C=(name, argv, ops, func)
1667pub fn bin_pwd(
1668    _name: &str,
1669    _argv: &[String], // c:728
1670    ops: &options,
1671    _func: i32,
1672) -> i32 {
1673    let chaselinks = isset(CHASELINKS);
1674    // c:730-731 — `if (OPT_ISSET(ops,'r') || OPT_ISSET(ops,'P') ||
1675    //               (isset(CHASELINKS) && !OPT_ISSET(ops,'L')))`
1676    if OPT_ISSET(ops, b'r') || OPT_ISSET(ops, b'P')                          // c:730
1677        || (chaselinks && !OPT_ISSET(ops, b'L'))
1678    // c:731
1679    {
1680        // c:732 — `printf("%s\n", zgetcwd());`
1681        println!("{}", zgetcwd()); // c:732
1682    } else {
1683        // c:734 — `zputs(pwd, stdout); putchar('\n');`. C reads the
1684        // shell-internal `pwd` global (Src/params.c:108), NOT the
1685        // exported $PWD env var. The internal global is only mutated
1686        // by `cd`. zshrs lacks a separate internal pwd, so `$PWD`
1687        // doubles as both — and user code can spoof it (`PWD=/foo`).
1688        //
1689        // C-equivalent behavior in zshrs: trust `$PWD` ONLY when its
1690        // stat matches `getcwd()` (same dev+ino → same directory,
1691        // logical name preserved through symlinks). On mismatch the
1692        // user spoofed it; fall back to `zgetcwd()` so `pwd` reports
1693        // the actual current directory. Bug #441 — security-relevant:
1694        // defensive-coding patterns checking `pwd` output get fooled
1695        // by a `PWD=` assignment without this validation.
1696        let pwd_param = getsparam("PWD");
1697        let logical_pwd = pwd_param.as_deref().filter(|pwd| {
1698            // stat-compare $PWD against getcwd() — same inode means
1699            // $PWD is just a symlink-preserving alias for the real
1700            // cwd, safe to use.
1701            use std::os::unix::fs::MetadataExt;
1702            let cwd = zgetcwd();
1703            match (std::fs::metadata(pwd), std::fs::metadata(&cwd)) {
1704                (Ok(a), Ok(b)) => a.dev() == b.dev() && a.ino() == b.ino(),
1705                _ => false,
1706            }
1707        });
1708        println!("{}", logical_pwd.map(String::from).unwrap_or_else(zgetcwd)); // c:734
1709    }
1710    0 // c:737
1711}
1712
1713/// Port of `bin_dirs()` from `Src/builtin.c:749`.
1714/// C decl: `bin_dirs(UNUSED(char *name), char **argv, Options ops, UNUSED(int func))`
1715/// C: `int bin_dirs(UNUSED(char *name), char **argv, Options ops, ...)` —
1716///   list dirstack (default / -v / -p / -l) or replace it with argv.
1717// dirs: list the directory stack, or replace it with a provided list      // c:749
1718/// WARNING: param names don't match C — Rust=(_name, argv, _func) vs C=(name, argv, ops, func)
1719pub fn bin_dirs(
1720    _name: &str,
1721    argv: &[String], // c:749
1722    ops: &options,
1723    _func: i32,
1724) -> i32 {
1725    queue_signals(); // c:753
1726                     // c:755-756 — list mode: no args & no -c, OR -v / -p.
1727    if (argv.is_empty() && !OPT_ISSET(ops, b'c'))                            // c:755
1728        || OPT_ISSET(ops, b'v')
1729        || OPT_ISSET(ops, b'p')
1730    {
1731        let mut pos = 1; // c:760
1732                         // c:763-769 — pick separator format.
1733        let fmt: &str = if OPT_ISSET(ops, b'v') {
1734            // c:763
1735            print!("0\t"); // c:764
1736            "\n{}\t" // c:765
1737        } else if OPT_ISSET(ops, b'p') {
1738            // c:767
1739            "\n"
1740        } else {
1741            " "
1742        };
1743        // c:771-774 — print pwd via fprintdir or zputs (`-l`).
1744        // Previous Rust port inlined a HOME-prefix replacement which
1745        // only abbreviated `$HOME/...` to `~/...` — missed every
1746        // user-defined nameddirtab entry (`hash -d proj=/big/path`).
1747        // Route through `utils::fprintdir` which calls `finddir`,
1748        // matching C's named-dir abbreviation.
1749        let pwd = getsparam("PWD").unwrap_or_else(|| zgetcwd());
1750        if OPT_ISSET(ops, b'l') {
1751            // c:771
1752            print!("{}", pwd); // c:772
1753        } else {
1754            print!("{}", fprintdir(&pwd)); // c:774
1755        }
1756        // c:775-781 — walk dirstack list.
1757        if let Ok(stack) = DIRSTACK.lock() {
1758            // c:775
1759            for entry in stack.iter() {
1760                if fmt == "\n{}\t" {
1761                    print!("\n{}\t", pos);
1762                } else {
1763                    print!("{}", fmt); // c:776
1764                }
1765                pos += 1; // c:776
1766                if OPT_ISSET(ops, b'l') {
1767                    // c:777
1768                    print!("{}", entry); // c:778
1769                } else {
1770                    print!("{}", fprintdir(entry)); // c:780
1771                }
1772            }
1773        }
1774        unqueue_signals(); // c:783
1775        println!(); // c:784
1776        return 0; // c:785
1777    }
1778    // c:788-792 — replace dirstack with the supplied entries.
1779    if let Ok(mut stack) = DIRSTACK.lock() {
1780        stack.clear(); // c:790
1781        for arg in argv {
1782            stack.push(arg.clone()); // c:791
1783        }
1784    }
1785    unqueue_signals(); // c:793
1786    0 // c:794
1787}
1788
1789/// Port of `set_pwd_env()` from `Src/builtin.c:800`.
1790/// C decl: `set_pwd_env(void)`
1791/// Refreshes both `$PWD` and `$OLDPWD` shell
1792/// parameters from the shell-side `pwd`/`oldpwd` globals
1793/// (`assignsparam` at c:818-819), then `addenv`s each so child
1794/// processes inherit them (c:821-826). C also clears `PM_READONLY`
1795/// and unsets the param when a user retyped PWD/OLDPWD to a
1796/// non-scalar (c:806-816).
1797///
1798/// Rust port: the bin entry's analog of C's `pwd`/`oldpwd` globals
1799/// is the live OS env (validated against ispwd by ShellExecutor::new
1800/// per c:Src/init.c:1242-1259 — see the subshell-snapshot comment in
1801/// fusevm_bridge.rs treating `$PWD` env as the logical-pwd carrier).
1802/// So the data flow matches C: globals (env) → paramtab → env. The
1803/// previous revision read paramtab's own PWD and wrote it back — a
1804/// circular no-op that left the stale environ-snapshot import in
1805/// place when the inherited $PWD failed ispwd().
1806pub fn set_pwd_env() {
1807    // c:800
1808    // c:805-816 — a PWD/OLDPWD that is not a scalar loses PM_READONLY and
1809    // is unset so the assignments below recreate it as a scalar.
1810    for nm in ["PWD", "OLDPWD"] {
1811        let non_scalar = crate::ported::params::paramtab()
1812            .write()
1813            .ok()
1814            .and_then(|mut tab| {
1815                tab.get_mut(nm).map(|pm| {
1816                    let ns = PM_TYPE(pm.node.flags as u32) != PM_SCALAR;
1817                    if ns {
1818                        pm.node.flags &= !(PM_READONLY as i32); // c:807
1819                    }
1820                    ns
1821                })
1822            })
1823            .unwrap_or(false);
1824        if non_scalar {
1825            crate::ported::params::unsetparam(nm); // c:808 unsetparam_pm(pm, 0, 1)
1826        }
1827    }
1828    // c:818 — `assignsparam("PWD", ztrdup(pwd), 0);`
1829    let pwd = env::var("PWD").unwrap_or_else(|_| {
1830        env::current_dir()
1831            .map(|p| p.to_string_lossy().into_owned())
1832            .unwrap_or_default()
1833    });
1834    setsparam("PWD", &pwd); // c:818
1835                            // c:819 — `assignsparam("OLDPWD", ztrdup(oldpwd), 0);`
1836    if let Ok(oldpwd) = env::var("OLDPWD") {
1837        setsparam("OLDPWD", &oldpwd); // c:819
1838    }
1839    // c:821-826 — `pm = paramtab->getnode(paramtab, "PWD"); if
1840    // (!(pm->node.flags & PM_EXPORTED)) addenv(pm, pwd);` and the same
1841    // for OLDPWD. Writing the OS environment directly (the previous
1842    // `env::set_var`) skipped `addenv`'s `pm->flags |= PM_EXPORTED`
1843    // (params.c:5482-5484), so `${(t)PWD}` read `scalar` where zsh
1844    // reads `scalar-export`.
1845    for (name, value) in [
1846        ("PWD", &pwd),
1847        ("OLDPWD", &env::var("OLDPWD").unwrap_or_default()),
1848    ] {
1849        if value.is_empty() && name == "OLDPWD" {
1850            continue;
1851        }
1852        let exported = crate::ported::params::paramtab()
1853            .read()
1854            .ok()
1855            .and_then(|tab| {
1856                tab.get(name)
1857                    .map(|pm| (pm.node.flags & PM_EXPORTED as i32) != 0)
1858            })
1859            .unwrap_or(false);
1860        if !exported {
1861            crate::ported::params::addenv(name, value);
1862        }
1863    }
1864}
1865
1866/// Port of `bin_cd()` from `Src/builtin.c:840`.
1867/// C decl: `bin_cd(char *nam, char **argv, Options ops, int func)`
1868/// C: `int bin_cd(char *nam, char **argv, Options ops, int func)`.
1869///
1870/// Body (verbatim translation per c:842-859):
1871/// ```c
1872/// doprintdir = (doprintdir == -1);
1873/// chasinglinks = OPT_ISSET(ops,'P') ||
1874///     (isset(CHASELINKS) && !OPT_ISSET(ops,'L'));
1875/// queue_signals();
1876/// zpushnode(dirstack, ztrdup(pwd));
1877/// if (!(dir = cd_get_dest(nam, argv, OPT_ISSET(ops,'s'), func))) {
1878///     zsfree(getlinknode(dirstack));
1879///     unqueue_signals();
1880///     return 1;
1881/// }
1882/// cd_new_pwd(func, dir, OPT_ISSET(ops, 'q'));
1883/// unqueue_signals();
1884/// return 0;
1885/// ```
1886// cd, chdir, pushd, popd                                                   // c:796
1887/// `bin_cd` — see implementation.
1888pub fn bin_cd(
1889    nam: &str,
1890    argv: &[String], // c:840
1891    ops: &options,
1892    func: i32,
1893) -> i32 {
1894    // c:844 — `doprintdir = (doprintdir == -1);`
1895    let prev = DOPRINTDIR.load(Relaxed);
1896    DOPRINTDIR.store(if prev == -1 { 1 } else { 0 }, Relaxed); // c:844
1897
1898    // c:846-847 — `chasinglinks = OPT_ISSET(ops,'P') ||
1899    //              (isset(CHASELINKS) && !OPT_ISSET(ops,'L'));`
1900    let chase = OPT_ISSET(ops, b'P')                                         // c:846
1901        || (isset(CHASELINKS)
1902            && !OPT_ISSET(ops, b'L'));
1903    CHASINGLINKS.store(chase as i32, Relaxed);
1904
1905    queue_signals(); // c:848
1906
1907    // c:849 — `zpushnode(dirstack, ztrdup(pwd));`. C uses the `pwd`
1908    //          global (the in-shell logical cwd, kept in sync with
1909    //          $PWD). Read from paramtab; fall back to getcwd if
1910    //          unset. The C source pushes pre-cd pwd to the top of
1911    //          dirstack here as a scratch slot used by cd_get_dest's
1912    //          +N/-N resolver; cd_new_pwd's remnode logic relies on
1913    //          this. Save the pre-cd path for the post-cd dirstack
1914    //          maintenance below.
1915    let pre_pwd = getsparam("PWD").unwrap_or_else(|| zgetcwd());
1916
1917    // c:850-854 — `if (!(dir = cd_get_dest(...))) { pop; unqueue; return 1; }`
1918    let dest = cd_get_dest(nam, argv, OPT_ISSET(ops, b's'), func);
1919    if dest.is_none() {
1920        unqueue_signals(); // c:852
1921        return 1; // c:853
1922    }
1923    let dest_raw = dest.unwrap();
1924
1925    // `-s` (C's `hard`) is threaded down to `lchdir(path, NULL, hard)`
1926    // (c:Src/builtin.c:1173-1174), and it is `lchdir` that gives `-s` its
1927    // meaning: with `hard` set it walks the path one component at a time
1928    // and `lstat`s each one (c:Src/utils.c:7489), rejecting any component
1929    // that is not a directory AS THE LINK ITSELF —
1930    //   c:7493-7496 — `if(!S_ISDIR(st1.st_mode)) { err = ENOTDIR; break; }`
1931    // — so a symlink (even one pointing AT a directory), a plain file and
1932    // a dangling link all fail the same way, with ENOTDIR. The error is
1933    // then reported by `cd_do_chdir` as `zwarnnam(cnam, "%e: %s", eno,
1934    // dest)` (c:1080), i.e. `not a directory: <dest>`.
1935    //
1936    // The previous code invented a message with no C counterpart —
1937    // `<dest>: symbolic link` — and mis-cited it as c:851 (which is just
1938    // the `cd_get_dest(nam, argv, OPT_ISSET(ops,'s'), func)` call, not a
1939    // symlink check). It also only tested `is_symlink`, so `cd -s
1940    // <plainfile>` fell through to the generic chdir and reported ENOENT
1941    // where zsh reports ENOTDIR.
1942    //
1943    // Ported here as the final-component check rather than inside a full
1944    // `lchdir` port: this reproduces C's outcome for the single-component
1945    // and absolute-path cases the `-s` option is used for. A path whose
1946    // FAILING component is an interior one still reports whatever the
1947    // plain chdir below reports, where C would report ENOTDIR for it too.
1948    if OPT_ISSET(ops, b's') {
1949        if let Ok(meta) = fs::symlink_metadata(&dest_raw) {
1950            // c:7489 `lstat(buf, &st1)` + c:7493 `!S_ISDIR(st1.st_mode)`
1951            if !meta.file_type().is_dir() {
1952                // c:7494 — `err = ENOTDIR;`, surfaced by c:1080 `"%e: %s"`.
1953                let mut msg = crate::ported::compat::strerror(libc::ENOTDIR);
1954                if let Some(c) = msg.chars().next() {
1955                    msg = format!("{}{}", c.to_ascii_lowercase(), &msg[c.len_utf8()..]);
1956                }
1957                zwarnnam(nam, &format!("{}: {}", msg, dest_raw)); // c:1080
1958                unqueue_signals();
1959                return 1;
1960            }
1961        }
1962    }
1963
1964    // c:Src/builtin.c:855 — route the resolved arg through
1965    // cd_do_chdir so CDPATH walk, leading `~`/`.` handling, and
1966    // CDABLEVARS expansion fire. cd_do_chdir performs the actual
1967    // lchdir + returns the LOGICAL path (the one to write to PWD).
1968    // The previous Rust port called env::set_current_dir(dest_raw)
1969    // directly which skipped all of CDPATH, so `CDPATH=/foo cd bar`
1970    // would only resolve `./bar` even if `/foo/bar` existed.
1971    let dest_path = match cd_do_chdir(nam, &dest_raw, OPT_ISSET(ops, b's') as i32) {
1972        Some(p) => p,
1973        None => {
1974            unqueue_signals();
1975            // !!! DASH-FAMILY GATE !!! dash's failed `cd` is a `sh_error`: status 2.
1976            return crate::extensions::dash_mode::fatal_error_status().unwrap_or(1);
1977        }
1978    };
1979
1980    // c:1238 — `oldpwd = pwd;` snapshot pre-cd $PWD for $OLDPWD.
1981    //          Read from paramtab (the canonical zsh-side `pwd`
1982    //          global); was reading OS env which can lag behind.
1983    let old = getsparam("PWD");
1984    // c:1207-1245 — compute the new logical pwd (`new_pwd` in C's
1985    //   cd_new_pwd) up front so the PUSHDIGNOREDUPS scan below and the
1986    //   final `pwd =` write share one value. Hoisted from the old
1987    //   write site; behaviour is identical (chase → resolved cwd,
1988    //   absolute dest → as-is, relative dest → logical join).
1989    let chase = CHASINGLINKS.load(Relaxed) != 0; // c:1203
1990    let new_pwd_logical: String = if chase {
1991        match env::current_dir() {
1992            Ok(c) => c.to_string_lossy().into_owned(),
1993            Err(_) => dest_path.clone(),
1994        }
1995    } else if dest_path.starts_with('/') {
1996        dest_path.clone()
1997    } else {
1998        let mut segs: Vec<&str> = if pre_pwd.is_empty() || pre_pwd == "/" {
1999            Vec::new()
2000        } else {
2001            pre_pwd.trim_start_matches('/').split('/').collect()
2002        };
2003        for part in dest_path.split('/') {
2004            match part {
2005                "" | "." => {}
2006                ".." => {
2007                    segs.pop();
2008                }
2009                _ => segs.push(part),
2010            }
2011        }
2012        if segs.is_empty() {
2013            "/".to_string()
2014        } else {
2015            format!("/{}", segs.join("/"))
2016        }
2017    };
2018    // c:1221-1235 — reconcile the LOGICAL path with the directory we are
2019    // actually standing in:
2020    //   if (stat(unmeta(new_pwd), &st1) < 0) {
2021    //       new_pwd = metafy(zgetcwd(), -1, META_DUP);
2022    //   } else if (stat(".", &st2) < 0) {
2023    //       if (chdir(unmeta(new_pwd)) < 0)
2024    //           zwarn("unable to chdir(%s): %e", new_pwd, errno);
2025    //   } else if (st1.st_ino != st2.st_ino || st1.st_dev != st2.st_dev) {
2026    //       if (chasinglinks) new_pwd = metafy(zgetcwd(), -1, META_DUP);
2027    //       else if (chdir(unmeta(new_pwd)) < 0)
2028    //           zwarn("unable to chdir(%s): %e", new_pwd, errno);
2029    //   }
2030    // The first arm is what makes `cd .` recover after the cwd was renamed
2031    // out from under the shell: the textual `$PWD/.` no longer stats, so
2032    // zsh falls back to `getcwd()` and $PWD becomes the NEW name. Without
2033    // it $PWD kept the stale path forever.
2034    let mut new_pwd_logical = new_pwd_logical;
2035    match std::fs::metadata(&new_pwd_logical) {
2036        Err(_) => {
2037            // c:1221-1224
2038            if let Ok(c) = env::current_dir() {
2039                new_pwd_logical = c.to_string_lossy().into_owned(); // c:1224
2040            }
2041        }
2042        Ok(st1) => {
2043            use std::os::unix::fs::MetadataExt as _;
2044            match std::fs::metadata(".") {
2045                // c:1225-1227 — the cwd itself is gone; move to new_pwd.
2046                Err(_) => {
2047                    if env::set_current_dir(&new_pwd_logical).is_err() {
2048                        zwarn(&format!(
2049                            // c:1227
2050                            "unable to chdir({}): {}",
2051                            new_pwd_logical,
2052                            crate::ported::utils::zsh_errno_msg(
2053                                std::io::Error::last_os_error().raw_os_error().unwrap_or(0)
2054                            )
2055                        ));
2056                    }
2057                }
2058                // c:1228 — the logical path names a DIFFERENT directory
2059                // than the one we are in.
2060                Ok(st2) => {
2061                    if st1.ino() != st2.ino() || st1.dev() != st2.dev() {
2062                        if chase {
2063                            // c:1229-1232
2064                            if let Ok(c) = env::current_dir() {
2065                                new_pwd_logical = c.to_string_lossy().into_owned();
2066                                // c:1232
2067                            }
2068                        } else if env::set_current_dir(&new_pwd_logical).is_err() {
2069                            zwarn(&format!(
2070                                // c:1234
2071                                "unable to chdir({}): {}",
2072                                new_pwd_logical,
2073                                crate::ported::utils::zsh_errno_msg(
2074                                    std::io::Error::last_os_error().raw_os_error().unwrap_or(0)
2075                                )
2076                            ));
2077                        }
2078                    }
2079                }
2080            }
2081        }
2082    }
2083    // c:Src/builtin.c:849 + cd_new_pwd dirstack maintenance —
2084    // collapsed into a single post-cd update here since the Rust
2085    // cd_get_dest returns a String rather than a LinkNode that
2086    // could be pre-pushed onto dirstack:
2087    //   * BIN_PUSHD: push pre-cd pwd to top so subsequent
2088    //     `${dirstack[1]}` and `popd` see the previous directory.
2089    //   * BIN_POPD: pop dirstack[0] (the directory we just left,
2090    //     which cd_get_dest read from the stack to compute dest).
2091    //   * BIN_CD: dirstack unchanged unless AUTO_PUSHD is set, in
2092    //     which case the CD behaves like a pushd.
2093    let mut skip_popd_n_cd = false;
2094    {
2095        let autopushd = isset(AUTOPUSHD);
2096        // c:1187 cd_new_pwd — rolllist(dirstack, dir) for BIN_PUSHD then
2097        // remnode(dirstack, dir). For `pushd +N`/`-N`, the rotation moves
2098        // entries before the target to the END of dirstack rather than
2099        // simply inserting pre_pwd at the front. Bug #302 in docs/BUGS.md:
2100        // previous Rust port unconditionally inserted pre_pwd at position
2101        // 0, which is correct for `pushd <path>` but wrong for `pushd +N`
2102        // (left target entry still in DIRSTACK at its old position).
2103        let is_stack_rotate = argv.len() == 1
2104            && !isset(POSIXCD)
2105            && argv[0].len() > 1
2106            && (argv[0].starts_with('+') || argv[0].starts_with('-'))
2107            && argv[0][1..].chars().all(|c| c.is_ascii_digit());
2108        if let Ok(mut d) = DIRSTACK.lock() {
2109            if is_stack_rotate && func == BIN_PUSHD {
2110                // c:1190 — rolllist(dirstack, dir) + remnode. Rotate the
2111                // virtual full stack `[pre_pwd] ++ DIRSTACK_old` so the
2112                // target entry becomes firstnode (= new PWD); remaining
2113                // entries form new DIRSTACK in their rotated order.
2114                let dd: usize = argv[0][1..].parse().unwrap_or(0);
2115                let pushdminus = isset(PUSHDMINUS);
2116                let from_top = (argv[0].starts_with('+')) ^ pushdminus;
2117                let m = d.len();
2118                let n = m + 1;
2119                let k = if from_top { dd } else { n - 1 - dd };
2120                if k < n {
2121                    let mut full: Vec<String> = Vec::with_capacity(n);
2122                    full.push(pre_pwd.clone());
2123                    full.extend(d.iter().cloned());
2124                    let rotated: Vec<String> =
2125                        full[k..].iter().chain(full[..k].iter()).cloned().collect();
2126                    d.clear();
2127                    d.extend(rotated.into_iter().skip(1));
2128                }
2129            } else if is_stack_rotate && func == BIN_CD {
2130                // c:1190-1199 — `cd +N`/`-N` never rolls the list (rolllist
2131                //   is BIN_PUSHD only). On the C stack `[pwd] ++ DIRSTACK`
2132                //   (pwd pushed at c:849) it does `remnode(dirstack, dir)` on
2133                //   the target, then without AUTO_PUSHD
2134                //   `zsfree(getlinknode(dirstack))` drops the new front node.
2135                //   So with AUTO_PUSHD the old pwd stays on top of the rest in
2136                //   order; without it the old pwd goes, and for `cd +0` (the
2137                //   target WAS the pushed pwd) the front entry below it goes.
2138                let dd: usize = argv[0][1..].parse().unwrap_or(0);
2139                let pushdminus = isset(PUSHDMINUS);
2140                let from_top = (argv[0].starts_with('+')) ^ pushdminus;
2141                let m = d.len();
2142                let n = m + 1;
2143                let k = if from_top { dd } else { n - 1 - dd };
2144                if k == 0 {
2145                    if !autopushd && !d.is_empty() {
2146                        d.remove(0); // c:1199 getlinknode
2147                    }
2148                } else if k - 1 < d.len() {
2149                    d.remove(k - 1); // c:1195 remnode(dirstack, dir)
2150                    if autopushd {
2151                        d.insert(0, pre_pwd.clone()); // c:849 pushed pwd kept
2152                    }
2153                }
2154            } else if is_stack_rotate && func == BIN_POPD {
2155                // c:Src/builtin.c:872-936 + c:1197-1199 — `popd +N` /
2156                // `popd -N`: when the target +N/-N is NOT firstnode
2157                // (i.e., N != 0 / N != top-of-stack), remove the
2158                // target entry from dirstack WITHOUT changing PWD or
2159                // pushing pre_pwd to the stack. C's logic:
2160                //   target = dir (the +N resolved node);
2161                //   if (dir != firstnode(dirstack)) return dir;
2162                //   // … cd_do_chdir path runs only if target IS top
2163                //
2164                // The Rust dispatch reached here for the
2165                // "target-not-top" case after cd_get_dest already
2166                // resolved to dirstack[k-1]. Just remove that index
2167                // and skip the cd / pre_pwd-insert paths.
2168                // Bug #466 in docs/BUGS.md.
2169                let dd: usize = argv[0][1..].parse().unwrap_or(0);
2170                let pushdminus = isset(PUSHDMINUS);
2171                let from_top = (argv[0].starts_with('+')) ^ pushdminus;
2172                let m = d.len();
2173                let n = m + 1;
2174                // popd's stack-rotate-vs-top logic mirrors C: the
2175                // virtual stack is pre_pwd ++ DIRSTACK_old; position 0
2176                // is the CURRENT pwd, so "popd +0" / "popd +N where
2177                // N maps to top" pops the top (handled by the
2178                // existing BIN_POPD arm below). For N that resolves
2179                // to a non-top dirstack index, remove from dirstack
2180                // directly.
2181                let k = if from_top { dd } else { n - 1 - dd };
2182                if k == 0 {
2183                    // c:934-940 + c:1197-1203 — the +N/-N resolved to
2184                    // firstnode (the current PWD): this is a bare-`popd`
2185                    // top removal. Pop the entry below PWD (DIRSTACK[0])
2186                    // and let the normal cd path below move PWD into it
2187                    // (cd_get_dest resolved dest to DIRSTACK[0]). Do NOT
2188                    // set skip_popd_n_cd — PWD must change here.
2189                    // Bug: prior port's `k >= 1` guard fell through this
2190                    // else-if arm without popping, so the plain-POPD arm
2191                    // was never reached and the top entry survived.
2192                    if !d.is_empty() {
2193                        d.remove(0);
2194                    }
2195                } else if k - 1 < d.len() {
2196                    d.remove(k - 1);
2197                    // Override the cd path: keep PWD at pre_pwd so
2198                    // the surrounding flow doesn't cd to the removed
2199                    // entry. The destination passed to bin_cd was
2200                    // the removed entry; we overwrite the post-cd
2201                    // PWD write below to pre_pwd.
2202                    skip_popd_n_cd = true;
2203                }
2204            } else if func == BIN_PUSHD || (func == BIN_CD && autopushd) {
2205                // c:849 — push pre-cd pwd.
2206                d.insert(0, pre_pwd.clone());
2207            } else if func == BIN_POPD && argv.len() == 1 {
2208                // c:908 + c:928-931 — `popd <non-index>`: the pushed node
2209                //   is removed again and pwd stays on top, so the stack is
2210                //   unchanged (see cd_get_dest).
2211            } else if func == BIN_POPD {
2212                // c:1197-1199 — pop top of stack (the dir we left).
2213                if !d.is_empty() {
2214                    d.remove(0);
2215                }
2216            }
2217            // c:1214-1222 — PUSHDIGNOREDUPS runs inside cd_new_pwd for
2218            //   EVERY func (plain cd, pushd, popd — the block is not
2219            //   guarded by func), scanning the now-current dirstack for
2220            //   the FIRST entry equal to `new_pwd` (the directory just
2221            //   moved into) and removing it so a directory never appears
2222            //   twice on the stack:
2223            //     if (isset(PUSHDIGNOREDUPS)) {
2224            //         for (n = firstnode(dirstack); n; incnode(n))
2225            //             if (!strcmp(new_pwd, getdata(n))) {
2226            //                 zsfree(remnode(dirstack, n)); break; }
2227            //     }
2228            //   Prior port only checked pushd and compared d.first()
2229            //   against pre_pwd (wrong target, wrong action), so a plain
2230            //   `cd` into a dir already on the stack, or re-pushing such
2231            //   a dir, left the stale duplicate in place. The non-top
2232            //   `popd +N` path (skip_popd_n_cd) keeps PWD = pre_pwd, so
2233            //   its dedup runs there against pre_pwd (see below).
2234            if !skip_popd_n_cd && isset(PUSHDIGNOREDUPS) {
2235                if let Some(pos) = d.iter().position(|s| *s == new_pwd_logical) {
2236                    d.remove(pos);
2237                }
2238            }
2239        }
2240    }
2241    // c:Src/builtin.c:872-936 — `popd +N` (N != 0) removes the
2242    // target entry from dirstack WITHOUT changing PWD. cd_do_chdir
2243    // already chdir'd into the removed target's path; revert that
2244    // back to pre_pwd and skip the OLDPWD update + PWD write so the
2245    // post-popd CWD remains the pre-popd PWD. Bug #466 in
2246    // docs/BUGS.md.
2247    if skip_popd_n_cd {
2248        // Restore process cwd to pre_pwd. Ignore errors; the
2249        // dirstack mutation already happened.
2250        let _ = env::set_current_dir(&pre_pwd);
2251        // c:1242-1246 — cd_new_pwd runs `oldpwd = pwd; pwd = new_pwd`
2252        //   for EVERY popd, including a non-top `popd +N`. There
2253        //   new_pwd resolves back to the front-of-stack pre_pwd, so
2254        //   PWD is unchanged but OLDPWD becomes the pre-popd PWD.
2255        //   Prior port left OLDPWD stale (whatever an earlier cd/pushd
2256        //   set), diverging on any `$OLDPWD` / `cd -` that followed.
2257        crate::ported::jobs::setjobpwd(); // c:1241 — before `pwd = new_pwd`
2258        crate::dash_mode::set_oldpwd_global(&pre_pwd); // c:1239
2259        setsparam("OLDPWD", &pre_pwd);
2260        env::set_var("OLDPWD", &pre_pwd);
2261        // c:Src/builtin.c:1245-1252 — print dirstack on POPD unless
2262        // quiet. Pass func=BIN_POPD so the dirstack-print branch
2263        // still fires for the listing (matches zsh's `popd +N` echo
2264        // of the resulting stack).
2265        cd_new_pwd(func, 0, OPT_ISSET(ops, b'q') as i32);
2266        unqueue_signals();
2267        return 0;
2268    }
2269    if let Some(o) = old {
2270        // c:1239 oldpwd = pwd
2271        // c:1239 + setsparam path: write OLDPWD to paramtab so
2272        //          subsequent expansions of $OLDPWD see the new value
2273        //          (the OS env write below is the export side; the
2274        //          shell-side read must come from paramtab).
2275        crate::dash_mode::set_oldpwd_global(&o); // c:1239 oldpwd = pwd
2276        setsparam("OLDPWD", &o);
2277        env::set_var("OLDPWD", &o);
2278    }
2279    // c:1241 — `setjobpwd();` stamps every live job with the directory
2280    // it was started in, while $PWD still holds the pre-cd value.
2281    crate::ported::jobs::setjobpwd();
2282    // c:1241 — `pwd = new_pwd;` writes the LOGICAL path (the dest
2283    // argument as given to cd, not `getcwd()`). Computed once up front
2284    // (see `new_pwd_logical` above) so the PUSHDIGNOREDUPS scan and this
2285    // write agree. Symlink resolution only kicks in when `chasinglinks`
2286    // is set (c:1203-1208, c:1228-1231).
2287    let pwd: String = new_pwd_logical;
2288    // c:1242 — `setsparam("PWD", pwd);` + export side via env.
2289    setsparam("PWD", &pwd);
2290    env::set_var("PWD", &pwd);
2291    cd_new_pwd(func, 0, OPT_ISSET(ops, b'q') as i32); // c:856
2292
2293    unqueue_signals(); // c:858
2294    0 // c:859
2295}
2296
2297/// Port of `cd_get_dest()` from `Src/builtin.c:865`.
2298/// C decl: `cd_get_dest(char *nam, char **argv, int hard, int func)`
2299/// C: `static LinkNode cd_get_dest(char *nam, char **argv, int hard,
2300///     int func)` — resolve the `cd` argument (`-`, `+N`/`-N`,
2301///   bare → $HOME, two-arg substitution form) to a destination path.
2302///   Returns the resolved path on success, None on error (with the
2303///   appropriate zwarnnam already emitted).
2304/// WARNING: param names don't match C — Rust=() vs C=(nam, argv, hard, func)
2305pub fn cd_get_dest(nam: &str, argv: &[String], _hard: bool, func: i32) -> Option<String> {
2306    if argv.is_empty() {
2307        // c:872 — bare popd / pushd / cd (no args).
2308        // The Rust port doesn't pre-push pwd to dirstack inside bin_cd
2309        // (cd_get_dest's String return signature doesn't fit C's
2310        // pre-push pattern), so dirstack[0] here is the most-recent
2311        // pushed entry, not the temporary scratch slot the C source
2312        // sees. Adjust the indices accordingly: C reads index 1
2313        // (skipping the scratch), Rust reads index 0.
2314        if func == BIN_POPD {
2315            let depth = DIRSTACK.lock().map(|d| d.len()).unwrap_or(0);
2316            if depth < 1 {
2317                zwarnnam(nam, "directory stack empty");
2318                return None;
2319            }
2320            return DIRSTACK.lock().ok().and_then(|d| d.first().cloned());
2321        }
2322        if func == BIN_PUSHD {
2323            // c:876-879 — bare pushd without PUSHDTOHOME swaps top two
2324            // entries of dirstack. C represents dirstack with PWD at
2325            // index 0, so it walks `nextnode(firstnode(dirstack))` to
2326            // get index 1, then extracts index 0 via `getlinknode` and
2327            // reinserts after — net swap of [0]<->[1]. zshrs's DIRSTACK
2328            // omits PWD entirely (PWD lives in paramtab); index 0 here
2329            // = C's index 1.
2330            //
2331            // To achieve the same swap in this model: POP DIRSTACK[0]
2332            // (the saved target) and return it. cd_new_pwd at line ~1573
2333            // then re-pushes pre_pwd onto DIRSTACK[0]. Net result:
2334            // DIRSTACK[0] becomes pre_pwd, PWD becomes the popped value.
2335            //
2336            // Bug #67 in docs/BUGS.md: prior port read `d.first().cloned()`
2337            // (no pop), so cd_new_pwd's push added a 3rd entry instead
2338            // of swapping.
2339            //
2340            // c:881-885 — empty dirstack (only PWD in C's model, i.e.
2341            // DIRSTACK.is_empty() here) falls through to HOME below;
2342            // cd_new_pwd's push then leaves DIRSTACK = [pre_pwd].
2343            let pushdtohome = isset(PUSHDTOHOME);
2344            if !pushdtohome {
2345                let popped = DIRSTACK.lock().ok().and_then(|mut d| {
2346                    if d.is_empty() {
2347                        None
2348                    } else {
2349                        Some(d.remove(0))
2350                    }
2351                });
2352                if let Some(target) = popped {
2353                    return Some(target);
2354                }
2355                // Empty DIRSTACK → fall through to HOME below.
2356            }
2357        }
2358        // c:880-884 — fall through to $HOME.
2359        match getsparam("HOME") {
2360            Some(h) if !h.is_empty() => Some(h),
2361            _ => {
2362                zwarnnam(nam, "HOME not set");
2363                None
2364            }
2365        }
2366    } else if argv.len() == 1 {
2367        // c:887
2368        let arg = &argv[0];
2369        DOPRINTDIR.fetch_add(1, Relaxed); // c:891
2370                                          // c:892-908 — `+N`/`-N` numeric stack-index form.
2371        let posixcd = isset(POSIXCD);
2372        if !posixcd
2373            && arg.len() > 1
2374            && (arg.starts_with('+') || arg.starts_with('-'))
2375            && arg[1..].chars().all(|c| c.is_ascii_digit())
2376        {
2377            let dd: usize = arg[1..].parse().unwrap_or(0); // c:894
2378            let pushdminus = isset(PUSHDMINUS);
2379            let from_top = (arg.starts_with('+')) ^ pushdminus; // c:898
2380                                                                // c:Src/builtin.c:904 — out-of-range stack index emits
2381                                                                // "no such entry in dir stack". Previous Rust port
2382                                                                // returned None silently and bin_cd's caller exited 1
2383                                                                // with no stderr, breaking parity.
2384                                                                // c:899-903 — index into the FULL dirstack: in zsh
2385                                                                //   `dirstack` is a LinkList with current PWD at firstnode,
2386                                                                //   so `+N` advances N nodes from firstnode (0 = current,
2387                                                                //   1 = first non-current entry) and `-N` walks back N nodes
2388                                                                //   from lastnode (0 = last non-current entry). zshrs's
2389                                                                //   DIRSTACK omits PWD entirely (PWD lives in paramtab), so
2390                                                                //   build the virtual full stack `[PWD] ++ DIRSTACK` to index
2391                                                                //   against. Bug #302 in docs/BUGS.md: previous Rust port
2392                                                                //   indexed DIRSTACK directly, so `+1` returned DIRSTACK[1]
2393                                                                //   instead of DIRSTACK[0], and `+0`/`-len` errored instead of
2394                                                                //   returning PWD.
2395            let pwd_now = getsparam("PWD").unwrap_or_else(|| zgetcwd());
2396            let resolved = DIRSTACK.lock().ok().and_then(|d| {
2397                let m = d.len();
2398                let n = m + 1; // full virtual stack length (incl current)
2399                let k = if from_top {
2400                    if dd >= n {
2401                        return None;
2402                    }
2403                    dd
2404                } else {
2405                    if dd >= n {
2406                        return None;
2407                    }
2408                    n - 1 - dd
2409                };
2410                if k == 0 {
2411                    // c:934-940 — for BIN_POPD, dir==firstnode(dirstack)
2412                    //   (the current PWD) is NOT removed in place: C does
2413                    //   `dir = nextnode(dir)` and cd_do_chdir's into the
2414                    //   entry BELOW PWD, then cd_new_pwd (c:1197-1199)
2415                    //   pops the front. So the chdir destination is
2416                    //   DIRSTACK[0] (the first non-current entry), not PWD.
2417                    //   For BIN_CD / BIN_PUSHD, +0 (or the swapped -last)
2418                    //   resolves to the current dir itself.
2419                    //   Bug: prior port returned pwd_now for every func, so
2420                    //   `popd +0` (and `popd +N`/`-N` mapping to firstnode
2421                    //   under PUSHD_MINUS) no-op'd instead of popping the top.
2422                    if func == BIN_POPD {
2423                        d.first().cloned().or_else(|| Some(pwd_now.clone()))
2424                    } else {
2425                        Some(pwd_now.clone())
2426                    }
2427                } else {
2428                    d.get(k - 1).cloned()
2429                }
2430            });
2431            if resolved.is_none() {
2432                zwarnnam(nam, "no such entry in dir stack");
2433            }
2434            return resolved;
2435        }
2436        // c:908-909 — `zpushnode(dirstack, ztrdup(strcmp(argv[0],"-")
2437        //   ? (doprintdir--, argv[0]) : oldpwd))`. `strcmp(argv[0],"-")`
2438        //   is non-zero for a normal dir, so a normal `cd dir` does
2439        //   `doprintdir--` (cancelling the c:891 `doprintdir++` → no
2440        //   print), while `cd -` keeps doprintdir set → prints $OLDPWD.
2441        //   C reads the `oldpwd` global; route through `$OLDPWD`.
2442        let pushed = if arg == "-" {
2443            // c:909 — `: oldpwd` — no doprintdir--, so `cd -` prints.
2444            crate::dash_mode::oldpwd_cd_target()
2445        } else {
2446            // c:909 — `? (doprintdir--, argv[0])`.
2447            DOPRINTDIR.fetch_sub(1, Relaxed);
2448            Some(arg.clone())
2449        };
2450        // c:928-931 — `if (func == BIN_POPD) { if (!dir) { target = dir =
2451        //   firstnode(dirstack); } ... dir = nextnode(dir); }`. For popd the
2452        //   node just pushed at c:908 is only the target to remove; the
2453        //   chdir goes to the next node, the pwd bin_cd pushed at c:849.
2454        //   So `popd foo` / `popd -` stays in the current directory and
2455        //   leaves the stack alone (cd_new_pwd c:1195-1199 removes the
2456        //   pushed node, then takes pwd back off the front).
2457        if func == BIN_POPD {
2458            return Some(getsparam("PWD").unwrap_or_else(|| zgetcwd()));
2459        }
2460        pushed
2461    } else {
2462        // c:914-924 — two-arg substitution: cd OLDPATTERN NEWPATTERN.
2463        //              C reads `pwd` global / `$PWD` param via getsparam;
2464        //              fall back to getcwd if the param isn't populated.
2465        let pwd = getsparam("PWD").unwrap_or_else(|| zgetcwd());
2466        let pat = &argv[0];
2467        let new_pat = &argv[1];
2468        match pwd.find(pat.as_str()) {
2469            // c:917
2470            None => {
2471                zwarnnam(nam, &format!("string not in pwd: {}", pat)); // c:918
2472                None // c:919
2473            }
2474            Some(idx) => {
2475                // c:921-924 — splice: pwd[..idx] + new_pat + pwd[idx+pat.len()..]
2476                let mut out = String::new();
2477                out.push_str(&pwd[..idx]); // c:921
2478                out.push_str(new_pat); // c:922
2479                out.push_str(&pwd[idx + pat.len()..]); // c:923
2480                DOPRINTDIR.fetch_add(1, Relaxed);
2481                Some(out)
2482            }
2483        }
2484    }
2485}
2486
2487/// Port of `cd_do_chdir()` from `Src/builtin.c:967`.
2488/// C decl: `cd_do_chdir(char *cnam, char *dest, int hard)`
2489/// C: `static char *cd_do_chdir(char *cnam, char *dest, int hard)` —
2490///   resolve `dest` (handling cdpath, cdablevars, leading `~`/`.`),
2491///   chdir there, return the LOGICAL path used (not `getcwd`'d) or
2492///   NULL on error.
2493///
2494/// Per C `cd_try_chdir` (c:1116-1181), the return is `buf` — the
2495/// composed path the chdir was attempted against, after `fixdir()`
2496/// logical-normalisation (resolving `.`/`..` only, NOT symlinks).
2497/// Walks $cdpath when dest is relative and not `./` or `../`.
2498pub fn cd_do_chdir(cnam: &str, dest: &str, hard: i32) -> Option<String> {
2499    // c:967
2500    // c:996-998 — nocdpath = first segment is "." or ".."
2501    let nocdpath = dest.starts_with("./") || dest == "." || dest.starts_with("../") || dest == "..";
2502
2503    // c:1003-1008 — absolute path: try as-is, warn on failure.
2504    if dest.starts_with('/') {
2505        if let Some(ret) = cd_try_chdir("", dest, hard) {
2506            return Some(ret);
2507        }
2508        // c:Src/builtin.c — `zwarnnam(nam, "%e: %s", errno, dest)`.
2509        // C's %e printf format expands to strerror(errno) then
2510        // LOWERCASES the first letter unless the errno is EIO
2511        // (Src/utils.c:362-368 comment: "If the message is not
2512        // about I/O problems, it looks better if we uncapitalize
2513        // the first letter of the message"). Previous Rust port
2514        // used `io::Error::last_os_error()`'s Display impl, which
2515        // appends ` (os error N)` — leaked the Rust-internal
2516        // suffix into user-visible cd output. Bug #112 in
2517        // docs/BUGS.md. Route through compat::strerror to drop
2518        // the suffix AND apply the lowercase-first-letter rule so
2519        // the message matches C exactly.
2520        let errno = io::Error::last_os_error().raw_os_error().unwrap_or(0);
2521        let mut msg = crate::ported::compat::strerror(errno);
2522        if errno != libc::EIO {
2523            if let Some(c) = msg.chars().next() {
2524                msg = format!("{}{}", c.to_ascii_lowercase(), &msg[c.len_utf8()..]);
2525            }
2526        }
2527        zwarnnam(cnam, &format!("{}: {}", msg, dest));
2528        return None;
2529    }
2530
2531    // c:1015-1018 — check $cdpath for "." (presence flips hasdot).
2532    let posix_cd = isset(POSIXCD);
2533    let cdpath_str = getsparam("CDPATH").unwrap_or_default();
2534    let cdpath: Vec<&str> = if cdpath_str.is_empty() {
2535        Vec::new()
2536    } else {
2537        cdpath_str.split(':').collect()
2538    };
2539    let hasdot = !nocdpath && !posix_cd && cdpath.iter().any(|p| p.is_empty() || *p == ".");
2540    let mut eno = libc::ENOENT; // c:990 — `int hasdot = 0, eno = ENOENT;`
2541
2542    // c:1026-1031 — if no dot in cdpath (and !POSIXCD), try as-is first.
2543    if !hasdot && !posix_cd {
2544        if let Some(ret) = cd_try_chdir("", dest, hard) {
2545            return Some(ret);
2546        }
2547        // c:1030-1031 — `if (errno != ENOENT) eno = errno;`
2548        let e = io::Error::last_os_error().raw_os_error().unwrap_or(0);
2549        if e != libc::ENOENT {
2550            eno = e;
2551        }
2552    }
2553
2554    // c:1034-1043 — walk $cdpath unless nocdpath.
2555    if !nocdpath {
2556        for pp in cdpath.iter() {
2557            if let Some(ret) = cd_try_chdir(pp, dest, hard) {
2558                // c:1037-1050 — flag that the resolved directory should
2559                // be printed; the actual print happens later in
2560                // cd_new_pwd (c:1248-1251) gated on doprintdir. C does
2561                // NOT print here, it only bumps `doprintdir`.
2562                if isset(POSIXCD) {
2563                    // c:1037-1045 — POSIX prints any time CDPATH was
2564                    // used, except for an empty segment treated as ".".
2565                    if !pp.is_empty() {
2566                        DOPRINTDIR.fetch_add(1, Relaxed); // c:1045
2567                    }
2568                } else if *pp != "." {
2569                    // c:1046-1049 — non-POSIX: print only for a
2570                    // non-"." segment.
2571                    DOPRINTDIR.fetch_add(1, Relaxed); // c:1048
2572                }
2573                return Some(ret);
2574            }
2575            // c:1052-1053 — `if (errno != ENOENT) eno = errno;`
2576            let e = io::Error::last_os_error().raw_os_error().unwrap_or(0);
2577            if e != libc::ENOENT {
2578                eno = e;
2579            }
2580        }
2581    }
2582
2583    // c:1057-1063 — POSIXCD-mode last-resort: try dest as-is.
2584    if posix_cd {
2585        if let Some(ret) = cd_try_chdir("", dest, hard) {
2586            return Some(ret);
2587        }
2588        // c:1062-1063
2589        let e = io::Error::last_os_error().raw_os_error().unwrap_or(0);
2590        if e != libc::ENOENT {
2591            eno = e;
2592        }
2593    }
2594
2595    // c:1067-1075 — `cd_able_vars(dest)` — when CDABLEVARS is set,
2596    // look up the leading bareword as a parameter and try chdir to
2597    // its value (with any `/tail` appended). The helper at
2598    // `cd_able_vars` (this file, line 1881) already guards on
2599    // `isset(CDABLEVARS)` and returns None when the option is off
2600    // OR the head isn't a paramtab entry. Without this call
2601    // wired in, `setopt cdable_vars; mytmp=/tmp; cd mytmp` errored
2602    // with "no such file or directory" instead of cd'ing to /tmp.
2603    // Bug #217 in docs/BUGS.md.
2604    if let Some(expanded) = cd_able_vars(dest) {
2605        if let Some(ret) = cd_try_chdir("", &expanded, hard) {
2606            // c:1069 — `doprintdir++` so cd_new_pwd prints the
2607            // CDABLEVARS-resolved directory.
2608            DOPRINTDIR.fetch_add(1, Relaxed); // c:1069
2609            return Some(ret);
2610        }
2611        // c:1072-1073
2612        let e = io::Error::last_os_error().raw_os_error().unwrap_or(0);
2613        if e != libc::ENOENT {
2614            eno = e;
2615        }
2616    }
2617
2618    // c:1080 — `zwarnnam(cnam, "%e: %s", eno, dest);`. `%e` is
2619    //   strerror of the first non-ENOENT failure seen (c:1030, 1052,
2620    //   1062, 1072), so `cd <file>` reports ENOTDIR and an unreadable
2621    //   directory EACCES rather than a blanket ENOENT.
2622    zwarnnam(cnam, &format!("{}: {}", crate::ported::utils::zsh_errno_msg(eno), crate::ported::utils::nicedupstring(&dest))); // c:1080 `%s` → nicezputs (c:Src/utils.c:316)
2623    None
2624}
2625
2626/// Port of `cd_able_vars()` from `Src/builtin.c:1088`.
2627/// C decl: `cd_able_vars(char *s)`
2628/// C: `char *cd_able_vars(char *s)` — when CDABLEVARS is set, look up
2629///   the leading bareword as a parameter and return its expanded value
2630///   prefixed in front of any trailing `/...`. Returns NULL otherwise.
2631pub fn cd_able_vars(s: &str) -> Option<String> {
2632    // c:1088
2633    // c:1088 — `if (isset(CDABLEVARS)) { ... }`
2634    let cdablevars = isset(CDABLEVARS);
2635    if !cdablevars {
2636        // c:1093
2637        return None;
2638    }
2639    // c:1094-1110 — split on the first `/`, look up the head as $param.
2640    let (head, tail) = match s.find('/') {
2641        // c:1094
2642        Some(i) => (&s[..i], &s[i..]),
2643        None => (s, ""),
2644    };
2645    if head.is_empty() {
2646        return None;
2647    }
2648    // c:1097 — `s = getnameddir(s);`. The head is resolved as a NAMED
2649    // DIRECTORY, not as a raw parameter read: getnameddir
2650    // (c:Src/utils.c:1257-1260) only accepts a parameter whose value begins
2651    // with `/`, so `cdablevar1=tmpcd; cd cdablevar1` fails with "no such
2652    // file or directory" while `cdablevar2=$PWD/tmpcd` works.
2653    // c:1100-1101 — `if (s && *rest) s = dyncat(s, rest);`
2654    crate::ported::utils::getnameddir(head).map(|val| format!("{}{}", val, tail))
2655}
2656
2657/// Port of `cd_try_chdir()` from `Src/builtin.c:1116`.
2658/// C decl: `cd_try_chdir(char *pfix, char *dest, int hard)`
2659/// Compose `pfix/dest` (or `pwd/pfix/dest` for relative pfix, or
2660/// `pwd/dest` for empty pfix + relative dest), normalise via `fixdir`,
2661/// then attempt chdir. Falls back to `dest` alone when the full path
2662/// fails but `pfix` was present (cwd/parent may have been renamed).
2663pub fn cd_try_chdir(pfix: &str, dest: &str, hard: i32) -> Option<String> {
2664    // c:1116
2665    let pwd = getsparam("PWD").unwrap_or_default();
2666
2667    // c:1122-1158 — build buf from pfix/dest/pwd combinations.
2668    let mut buf = if !pfix.is_empty() {
2669        if pfix.starts_with('/') {
2670            // c:1123
2671            // c:1133 — buf = tricat(pfix, "/", dest); `/` + `tmp` stays `//tmp`
2672            // until fixdir (or the dash double-slash rule) decides.
2673            format!("{}/{}", pfix, dest)
2674        } else {
2675            // c:1135-1146 — pwd + "/" + pfix + "/" + dest
2676            let pwd_trim = if pwd == "/" { "" } else { pwd.as_str() };
2677            format!("{}/{}/{}", pwd_trim, pfix, dest)
2678        }
2679    } else if dest.starts_with('/') {
2680        // c:1148
2681        // c:1149 — buf = ztrdup(dest)
2682        dest.to_string()
2683    } else {
2684        // c:1150
2685        // c:1151-1157 — pwd + "/" + dest (trimming trailing slash off pwd)
2686        let pwd_trim = pwd.trim_end_matches('/');
2687        format!("{}/{}", pwd_trim, dest)
2688    };
2689
2690    // c:1161-1166 — fixdir normalisation, skipped if chasing symlinks.
2691    let mut dochaselinks = 0; // c:1120
2692    // !!! DASH-ONLY (no C counterpart) !!! POSIX lets a path with exactly two
2693    // leading slashes keep them; dash does (`CDPATH=/; cd tmp` -> `//tmp`,
2694    // `cd //tmp` -> `//tmp`, `cd ///tmp` -> `/tmp`) where fixdir collapses
2695    // them to one.
2696    let keep_double_slash =
2697        crate::dash_mode::dash_faithful() && buf.starts_with("//") && !buf.starts_with("///");
2698    if CHASINGLINKS.load(Relaxed) == 0 {
2699        dochaselinks = fixdir(&mut buf); // c:1165
2700        if keep_double_slash && !buf.starts_with("//") {
2701            buf.insert(0, '/');
2702        }
2703    }
2704
2705    // c:1169-1177 — "We try the full path first.  If that fails, try the
2706    // argument to cd relatively.  This is useful if the cwd or a parent
2707    // directory is renamed in the interim."  The relative fallback is
2708    // skipped when a prefix was applied or `dest` is already absolute.
2709    // Either success returns `buf` (the logical full path) — c:1181.
2710    if lchdir(&buf, None, hard) != 0
2711        && (!pfix.is_empty() || dest.starts_with('/') || lchdir(dest, None, hard) != 0)
2712    {
2713        return None; // c:1175-1176
2714    }
2715    // c:1178-1180 — the chdir succeeded, so decide if we should force
2716    // links to be chased
2717    if dochaselinks != 0 {
2718        CHASINGLINKS.store(1, Relaxed);
2719    }
2720    Some(buf) // c:1181 — metafy(buf, ...)
2721}
2722
2723/// Port of `cd_new_pwd()` from `Src/builtin.c:1187`.
2724/// C decl: `cd_new_pwd(int func, LinkNode dir, int quiet)`
2725/// C: `static void cd_new_pwd(int func, LinkNode dir, int quiet)` —
2726///   commit a new PWD: rotate dirstack on `BIN_PUSHD`, pop on
2727///   `BIN_POPD`, then setparam(PWD/OLDPWD), fire chpwd hooks.
2728///
2729/// The PWD/OLDPWD write is now done by the caller (`bin_cd`) using
2730/// the logical `dest_path` from `cd_get_dest`. C's body at c:1238-1242
2731/// reads `new_pwd` off the dirstack — the Rust port's dirstack
2732/// plumbing isn't faithful enough to carry that path here, so the
2733/// caller writes PWD directly. This fn handles only the post-write
2734/// side effects (chpwd hooks, dirstack size cap).
2735/// WARNING: param names don't match C — Rust=(_func, _dir, _quiet) vs C=(func, dir, quiet)
2736pub fn cd_new_pwd(func: i32, _dir: usize, quiet: i32) {
2737    // c:1187 — post-cd side effects (print, hooks). Dirstack
2738    // maintenance was moved into `bin_cd` because the Rust port's
2739    // cd_get_dest returns a String (not a LinkNode pre-pushed on
2740    // dirstack), so C's remnode/rolllist sequence doesn't fit.
2741
2742    // c:1236-1242 — shift PWD → OLDPWD, set new PWD.
2743    //
2744    // The caller (`bin_cd` at builtin.rs:1379-1408) already wrote
2745    // OLDPWD (pre-cd $PWD) and PWD (the logical `dest_path`) into the
2746    // paramtab. Re-deriving them here would (a) double-shift OLDPWD
2747    // (overwriting the correct pre-cd value with the just-set PWD),
2748    // and (b) re-set PWD via `std::env::current_dir()` which always
2749    // returns the resolved physical path — clobbering the logical
2750    // path on systems where the destination is a symlink (e.g. macOS
2751    // /tmp → /private/tmp). C `cd_new_pwd` reads `new_pwd` off the
2752    // dirstack (the path the user typed); zshrs's dirstack plumbing
2753    // doesn't carry that path here, so the caller is the authoritative
2754    // PWD writer and this fn must NOT re-write either parameter.
2755
2756    // c:1244-1252 — print the new directory.
2757    //   if (isset(INTERACTIVE) || isset(POSIXCD)) {
2758    //       if (func != BIN_CD && isset(INTERACTIVE)) {
2759    //           if (unset(PUSHDSILENT) && !quiet) printdirstack();
2760    //       } else if (unset(CDSILENT) && doprintdir) {
2761    //           fprintdir(pwd, stdout); putchar('\n');
2762    //       }
2763    //   }
2764    if isset(INTERACTIVE) || isset(POSIXCD) {
2765        if func != BIN_CD && isset(INTERACTIVE) {
2766            // c:1245-1247 — pushd/popd echo the dir stack.
2767            if !isset(PUSHDSILENT) && quiet == 0 {
2768                printdirstack(); // c:1247
2769            }
2770        } else if !isset(crate::ported::zsh_h::CDSILENT) && DOPRINTDIR.load(Relaxed) != 0 {
2771            // c:1248-1251 — cd echoes the resolved directory (set via
2772            // doprintdir by `cd -`, CDPATH, autocd, CDABLEVARS).
2773            let pwd = getsparam("PWD").unwrap_or_default(); // C global `pwd`
2774            println!("{}", fprintdir(&pwd)); // c:1249-1250
2775        }
2776    }
2777
2778    /* execute the chpwd function */
2779    // c:1255-1261 — every directory change that commits here (cd, chdir,
2780    // pushd, popd, however the command word was reached) runs the hook,
2781    // unless `-q` asked for quiet.
2782    let _ = std::io::stdout().flush(); // c:1256
2783    let _ = std::io::stderr().flush(); // c:1257
2784    if quiet == 0 {
2785        // c:1258
2786        crate::ported::utils::callhookfunc("chpwd", None, 1, std::ptr::null_mut()); // c:1259
2787        if crate::ported::init::zle_load_state.load(std::sync::atomic::Ordering::SeqCst) == 1 {
2788            // c:1260
2789            crate::ported::init::zleentry(crate::ported::zsh_h::ZLE_CMD_CHPWD); // c:1261
2790        }
2791    }
2792
2793    // c:1264-1271 — trim the dir stack to $DIRSTACKSIZE.
2794    //   `dirstacksize = getiparam("DIRSTACKSIZE");
2795    //    if (dirstacksize > 0) {
2796    //        int remove = countlinknodes(dirstack) -
2797    //            (dirstacksize < 2 ? 2 : dirstacksize);
2798    //        while (remove-- >= 0)
2799    //            zsfree(remnode(dirstack, lastnode(dirstack)));
2800    //    }`
2801    let dirstacksize = getiparam("DIRSTACKSIZE"); // c:1264
2802    if dirstacksize > 0 {
2803        // c:1266
2804        if let Ok(mut d) = DIRSTACK.lock() {
2805            // c:1267-1268 — count minus the effective cap (min 2).
2806            let cap = if dirstacksize < 2 { 2 } else { dirstacksize };
2807            let mut remove = d.len() as i64 - cap;
2808            // c:1269-1270 — `while (remove-- >= 0)` pops the last node;
2809            // the post-decrement removes (remove+1) entries from the end.
2810            while remove >= 0 {
2811                remove -= 1;
2812                if d.pop().is_none() {
2813                    break;
2814                }
2815            }
2816        }
2817    }
2818}
2819
2820/// Port of `printdirstack()` from Src/builtin.c:1277.
2821/// C: `static void printdirstack(void)` — fprintdir(pwd) followed by
2822///   space-separated entries from the dirstack list, ending in newline.
2823pub fn printdirstack() {
2824    // c:1277
2825    // c:1281 — `fprintdir(pwd, stdout);`. C uses the shell-side
2826    //          `pwd` global (in-shell logical cwd), not getcwd. Read
2827    //          $PWD from paramtab so the logical path (including
2828    //          any unresolved symlinks) shows correctly. Route
2829    //          through `utils::fprintdir` for the same `~` /
2830    //          `~named` abbreviation real zsh emits.
2831    // Previous Rust port emitted raw paths, missing the
2832    // $HOME / nameddirtab abbreviation that makes pushd/popd output
2833    // legible. Same fix family as bin_dirs.
2834    let pwd = getsparam("PWD")
2835        .or_else(|| {
2836            env::current_dir()
2837                .ok()
2838                .and_then(|p| p.to_str().map(String::from))
2839        })
2840        .unwrap_or_default();
2841    print!("{}", fprintdir(&pwd)); // c:1281
2842                                   // c:1282-1286 — `for (node = firstnode(dirstack); ...)`
2843    if let Ok(d) = DIRSTACK.lock() {
2844        for entry in d.iter() {
2845            // c:1282
2846            print!(" {}", fprintdir(entry)); // c:1284
2847        }
2848    }
2849    println!(); // c:1287
2850}
2851
2852/// Port of `fixdir()` from `Src/builtin.c:1298`.
2853/// C decl: `int fixdir(char *src)`
2854///
2855/// Canonicalises `src` IN PLACE: collapses `//`, drops `.` segments and
2856/// removes each `foo/..` pair — but only after `stat()` shows `foo` is a
2857/// directory; when it is not, the rest of the path is left as written and
2858/// the result is 1. Under CHASE_DOTS a `..` is kept as an ordinary segment
2859/// and the result is 1 as well. A non-zero result tells cd_try_chdir that
2860/// the new directory must be found by chasing links (c:1165, c:1179-1180).
2861pub fn fixdir(src: &mut String) -> i32 {
2862    let s: Vec<u8> = src.as_bytes().to_vec();
2863    let at = |i: usize| s.get(i).copied().unwrap_or(0);
2864    let mut dest: Vec<u8> = Vec::with_capacity(s.len()); // c:1300 d0 = dest
2865    // c:1301-1319 — relative change with the cwd removed from under us
2866    // (`pwd` is "."): force links to be chased. Set to 2 so the lack of
2867    // CHASEDOTS is still obeyed after the first "../" is preserved.
2868    let pwd = getsparam("PWD").unwrap_or_default();
2869    let mut chasedots: i32 = if at(0) == b'.'
2870        && pwd == "."
2871        && (at(1) == b'/' || (at(1) == b'.' && at(2) == b'/'))
2872    {
2873        2 // c:1319-1320
2874    } else {
2875        0
2876    };
2877    let done = |dest: Vec<u8>, src: &mut String| {
2878        *src = String::from_utf8_lossy(&dest).into_owned();
2879    };
2880    let mut i = 0usize;
2881    loop {
2882        // c:1334-1342 — compress multiple /es into single
2883        if at(i) == b'/' {
2884            dest.push(b'/');
2885            i += 1;
2886            while at(i) == b'/' {
2887                i += 1;
2888            }
2889        }
2890        // c:1343-1350 — at the end of the input path, remove a trailing /
2891        // (if it exists), and return ct
2892        if at(i) == 0 {
2893            while dest.len() > 1 && dest.last() == Some(&b'/') {
2894                dest.pop();
2895            }
2896            done(dest, src);
2897            return chasedots; // c:1349
2898        }
2899        if at(i) == b'.' && at(i + 1) == b'.' && (at(i + 2) == 0 || at(i + 2) == b'/') {
2900            if isset(crate::ported::zsh_h::CHASEDOTS) || chasedots > 1 {
2901                // c:1356-1358 — and treat as normal segment
2902                chasedots = 1;
2903            } else {
2904                if dest.len() > 1 {
2905                    // c:1360-1377 — remove a foo/.. combination:
2906                    // first check foo exists, else return.
2907                    let d0 = String::from_utf8_lossy(&dest).into_owned();
2908                    if !std::fs::metadata(&d0).map(|m| m.is_dir()).unwrap_or(false) {
2909                        dest.extend_from_slice(&s[i..]); // c:1370-1373
2910                        done(dest, src);
2911                        return 1; // c:1374
2912                    }
2913                    // c:1376-1378 — `for (dest--; dest > d0 + 1 &&
2914                    // dest[-1] != '/'; dest--); if (dest[-1] != '/') dest--;`
2915                    let mut d = dest.len() - 1;
2916                    while d > 1 && dest[d - 1] != b'/' {
2917                        d -= 1;
2918                    }
2919                    if dest[d - 1] != b'/' {
2920                        d -= 1;
2921                    }
2922                    dest.truncate(d);
2923                }
2924                // c:1380-1382 — `src++; while (*++src == '/'); continue;`
2925                i += 2;
2926                while at(i) == b'/' {
2927                    i += 1;
2928                }
2929                continue;
2930            }
2931        }
2932        if at(i) == b'.' && (at(i + 1) == b'/' || at(i + 1) == 0) {
2933            // c:1385-1387 — skip a . section
2934            i += 1;
2935            while at(i) == b'/' {
2936                i += 1;
2937            }
2938        } else {
2939            // c:1388-1392 — copy a normal segment into the output
2940            while at(i) != b'/' && at(i) != 0 {
2941                dest.push(s[i]);
2942                i += 1;
2943            }
2944        }
2945    }
2946}
2947
2948/// Port of `printqt()` from `Src/builtin.c:1399`.
2949/// C decl: `printqt(char *str)`
2950/// C: `mod_export void printqt(char *str)` — emit `str`, escaping any
2951/// `'` as `'\''` (or `''` if RCQUOTES is set).
2952pub fn printqt(str: &str) {
2953    // c:1399
2954    let rcquotes = isset(RCQUOTES); // c:1399 isset(RCQUOTES)
2955    for ch in str.chars() {
2956        // c:1403
2957        if ch == '\'' {
2958            // c:1404
2959            print!("{}", if rcquotes { "''" } else { "'\\''" }); // c:1405
2960        } else {
2961            print!("{}", ch); // c:1407
2962        }
2963    }
2964}
2965
2966/// Port of `printif()` from `Src/builtin.c:1411`.
2967/// C decl: `printif(char *str, int c)`
2968/// C: `mod_export void printif(char *str, int c)` — `printf(" -%c ", c)`
2969/// then `quotedzputs(str, stdout)`, only when `str != NULL`.
2970pub fn printif(str: Option<&str>, c: u8) {
2971    // c:1411
2972    if let Some(s) = str {
2973        // c:1399
2974        print!(" -{} ", c as char); // c:1399
2975                                    // c:1399 — quotedzputs(str, stdout); plain print preserves bytes
2976                                    // for the ASCII case; full quotedzputs lives in src/ported/utils.rs.
2977        print!("{}", s); // c:1399
2978    }
2979}
2980
2981/// Port of `bin_fc()` from `Src/builtin.c:1426`.
2982/// C decl: `bin_fc(char *nam, char **argv, Options ops, int func)`
2983/// C: `int bin_fc(char *nam, char **argv, Options ops, int func)`.
2984///
2985/// History/edit/list dispatcher: `-p` push hist stack, `-P` pop,
2986/// `-R` read, `-W` write, `-A` append, `-m` glob filter, `-l` list,
2987/// `-s` substitute, default: edit + re-execute. The C body is ~245
2988/// lines; the structural translation here covers the major options
2989/// and dispatches the underlying history-file ops to the existing
2990/// hist.rs accessors.
2991/// WARNING: param names don't match C — Rust=(nam, argv, func) vs C=(nam, argv, ops, func)
2992pub fn bin_fc(
2993    nam: &str,
2994    argv: &[String], // c:1426
2995    ops_in: &options,
2996    func: i32,
2997) -> i32 {
2998    // C `Options ops` is `struct options *` — mutable via `ops->ind['n']
2999    // = 1;` at c:1644. zshrs HandlerFunc takes `&options`, so we clone
3000    // to a fn-local `ops` mirror at the top. Mutation of the clone is
3001    // intra-fn only (`fclist` reads `ops` to format output and never
3002    // returns it), so behavior matches C.
3003    // !!! BASH-MODE (no C counterpart) !!! bash `history -c` empties the list;
3004    // `-d OFFSET` drops one entry; the file-sync flags have no store to sync.
3005    if nam == "history" && crate::dash_mode::bash_mode() {
3006        if OPT_ISSET(ops_in, b'c') {
3007            crate::ported::hist::hist_ring.lock().unwrap().clear();
3008            return 0;
3009        }
3010        if let Some(off) = OPT_ARG(ops_in, b'd') {
3011            let Ok(n) = off.parse::<i64>() else {
3012                zwarnnam(nam, &format!("{off}: history position out of range"));
3013                return 1;
3014            };
3015            let mut ring = crate::ported::hist::hist_ring.lock().unwrap();
3016            return match ring.iter().position(|e| e.histnum == n) {
3017                Some(i) => {
3018                    ring.remove(i);
3019                    0
3020                }
3021                None => {
3022                    drop(ring);
3023                    zwarnnam(nam, &format!("{off}: history position out of range"));
3024                    1
3025                }
3026            };
3027        }
3028        if ["a", "n", "r", "w", "s", "p"].iter().any(|o| OPT_ISSET(ops_in, o.as_bytes()[0])) {
3029            return 0;
3030        }
3031    }
3032    let mut ops = ops_in.clone();
3033    let ops = &mut ops;
3034    let mut argv = argv.to_vec();
3035    let mut first: i64 = -1;
3036    let mut last: i64 = -1;
3037    let mut asgf: Vec<(String, String)> = Vec::new();
3038
3039    // c:1441-1481 — `-p` push history stack.
3040    if OPT_ISSET(ops, b'p') {
3041        // c:1441
3042        let mut hf = "".to_string();
3043        let mut hs: i64; // c:1443
3044        let mut shs: i64; // c:1444
3045                          // c:1445 — `int level = OPT_ISSET(ops,'a') ? locallevel : -1;`
3046        let level: i32 = if OPT_ISSET(ops, b'a') {
3047            locallevel_param.load(Relaxed)
3048        } else {
3049            -1
3050        };
3051        hs = histsiz.load(Relaxed); // c:1442
3052        shs = savehistsiz.load(Relaxed);
3053        if !argv.is_empty() {
3054            // c:1445
3055            hf = argv.remove(0); // c:1446
3056            if !argv.is_empty() {
3057                // c:1447
3058                let s2 = argv.remove(0);
3059                match s2.parse::<i64>() {
3060                    // c:1449 zstrtol
3061                    Ok(n) => hs = n,
3062                    Err(_) => {
3063                        zwarnnam(
3064                            "fc", // c:1452
3065                            "HISTSIZE must be an integer",
3066                        );
3067                        return 1; // c:1453
3068                    }
3069                }
3070                if !argv.is_empty() {
3071                    // c:1455
3072                    let s3 = argv.remove(0);
3073                    match s3.parse::<i64>() {
3074                        // c:1456
3075                        Ok(n) => shs = n,
3076                        Err(_) => {
3077                            zwarnnam(
3078                                "fc", // c:1459
3079                                "SAVEHIST must be an integer",
3080                            );
3081                            return 1; // c:1460
3082                        }
3083                    }
3084                } else {
3085                    shs = hs; // c:1464
3086                }
3087                if !argv.is_empty() {
3088                    // c:1466
3089                    zwarnnam(
3090                        "fc", // c:1468
3091                        "too many arguments",
3092                    );
3093                    return 1; // c:1469
3094                }
3095            }
3096        }
3097        // c:1473 — pushhiststack(hf, hs, shs, level); failure → return 1.
3098        pushhiststack(Some(&hf), hs, shs, level); // c:1473
3099        if !hf.is_empty() {
3100            // c:1475
3101            // c:1476-1480 — `if (stat(hf, &st) >= 0 || errno != ENOENT)
3102            //                  readhistfile(hf, 1, HFILE_USE_OPTIONS);`
3103            // Previous Rust port read `Error::last_os_error()` AFTER
3104            // checking `metadata().is_ok()` — racey: any intervening
3105            // syscall between the metadata call and last_os_error()
3106            // can stomp errno on some platforms. Capture the per-Err
3107            // raw_os_error directly so we read the SAME errno value
3108            // the stat call produced.
3109            let stat_result = fs::metadata(&hf);
3110            let should_read = match &stat_result {
3111                Ok(_) => true,                                    // c:1477 stat >= 0
3112                Err(e) => e.raw_os_error() != Some(libc::ENOENT), // c:1477 errno != ENOENT
3113            };
3114            if should_read {
3115                // c:1477
3116                readhistfile(
3117                    // c:1478
3118                    Some(&hf),
3119                    1,
3120                    HFILE_USE_OPTIONS as i32,
3121                );
3122            }
3123        }
3124        return 0; // c:1483
3125    }
3126
3127    // c:1485-1491 — `-P` pop history stack.
3128    if OPT_ISSET(ops, b'P') {
3129        // c:1485
3130        if !argv.is_empty() {
3131            // c:1486
3132            zwarnnam("fc", "too many arguments"); // c:1487
3133            return 1; // c:1488
3134        }
3135        // c:1490 — `return !saveandpophiststack(-1, HFILE_USE_OPTIONS);`.
3136        let popped = saveandpophiststack(-1, HFILE_USE_OPTIONS as i32); // c:1490
3137        return if popped != 0 { 0 } else { 1 }; // c:1490 `!` flip
3138    }
3139
3140    // c:1494-1500 — `-m` pattern filter (compile first arg).
3141    let mut pprog: Option<crate::ported::pattern::PatProg> = None;
3142    let mut pprog_src: Option<String> = None;
3143    if !argv.is_empty() && OPT_ISSET(ops, b'm') {
3144        // c:1494
3145        let pat = argv.remove(0);
3146        // c:1495 — tokenize(*argv); — Rust `patcompile` handles tokenisation.
3147        match patcompile(
3148            &{
3149                let mut __pat_tok = crate::pattern_data_escape::escape_data_backslashes(&pat); // a backslash tokenize leaves raw is DATA (c:Src/glob.c:3651)
3150                crate::ported::glob::tokenize(&mut __pat_tok);
3151                __pat_tok
3152            }, // c:1496
3153            PAT_HEAPDUP,
3154            None,
3155        ) {
3156            Some(p) => {
3157                pprog = Some(p);
3158                pprog_src = Some(pat); // retain source string for fclist
3159            }
3160            None => {
3161                zwarnnam(nam, "invalid match pattern"); // c:1497
3162                return 1; // c:1498
3163            }
3164        }
3165    }
3166
3167    queue_signals(); // c:1502
3168
3169    // c:1503-1525 — `-R` read / `-W` write / `-A` append history file.
3170    if OPT_ISSET(ops, b'R') {
3171        // c:1503
3172        let path = argv.first().cloned();
3173        let flags = if OPT_ISSET(ops, b'I') {
3174            HFILE_SKIPOLD as i32
3175        } else {
3176            0
3177        };
3178        readhistfile(
3179            // c:1505
3180            path.as_deref(),
3181            1,
3182            flags,
3183        );
3184        unqueue_signals(); // c:1506
3185        return 0; // c:1507
3186    }
3187    if OPT_ISSET(ops, b'W') {
3188        // c:1509
3189        let path = argv.first().cloned();
3190        let flags = if OPT_ISSET(ops, b'I') {
3191            HFILE_SKIPOLD as i32
3192        } else {
3193            0
3194        };
3195        savehistfile(
3196            // c:1511
3197            path.as_deref(),
3198            flags,
3199        );
3200        unqueue_signals(); // c:1512
3201        return 0; // c:1513
3202    }
3203    if OPT_ISSET(ops, b'A') {
3204        // c:1515
3205        let path = argv.first().cloned();
3206        let mut flags = HFILE_APPEND as i32;
3207        if OPT_ISSET(ops, b'I') {
3208            flags |= HFILE_SKIPOLD as i32;
3209        } // c:1518
3210        savehistfile(
3211            // c:1517
3212            path.as_deref(),
3213            flags,
3214        );
3215        unqueue_signals(); // c:1519
3216        return 0; // c:1520
3217    }
3218
3219    // c:1523-1527 — refuse inside ZLE.
3220    if crate::ported::builtins::sched::zleactive.load(
3221        // c:1523
3222        Relaxed,
3223    ) != 0
3224    {
3225        unqueue_signals(); // c:1524
3226        zwarnnam(
3227            nam, // c:1525
3228            "no interactive history within ZLE",
3229        );
3230        return 1; // c:1526
3231    }
3232
3233    // c:1530-1547 — `name=value` substitution pairs.
3234    while !argv.is_empty() && argv[0].contains('=') {
3235        // c:1530
3236        let arg = argv.remove(0);
3237        if let Some(eq) = arg.find('=') {
3238            let n = &arg[..eq];
3239            let v = &arg[eq + 1..];
3240            if n.is_empty() {
3241                zwarnnam(nam, &format!("invalid replacement pattern: ={}", v)); // c:1534
3242                return 1;
3243            }
3244            asgf.push((n.to_string(), v.to_string())); // c:1546
3245        }
3246    }
3247
3248    // c:1550-1568 — first/last history specifiers via fcgetcomm.
3249    if !argv.is_empty() {
3250        // c:1550
3251        first = fcgetcomm(&argv.remove(0)); // c:1551
3252        if first == -1 {
3253            unqueue_signals();
3254            return 1; // c:1553
3255        }
3256    }
3257    if !argv.is_empty() {
3258        // c:1559
3259        last = fcgetcomm(&argv.remove(0)); // c:1560
3260        if last == -1 {
3261            unqueue_signals();
3262            return 1;
3263        }
3264    }
3265    if !argv.is_empty() {
3266        // c:1567
3267        unqueue_signals();
3268        zwarnnam("fc", "too many arguments"); // c:1569
3269        return 1;
3270    }
3271
3272    // c:1573-1610 — default ranges + listing/edit dispatch. C reads
3273    //                the live `curhist` global at hist.rs directly. The
3274    //                FQN here is forced — bare `curhist` would resolve
3275    //                to the local `let curhist` we're declaring.
3276    let curhist: i64 = crate::ported::hist::curhist.load(Relaxed) as i64;
3277    if last == -1 {
3278        // c:1573
3279        if OPT_ISSET(ops, b'l') && first < curhist {
3280            // c:1574
3281            // c:1581-1583 — `last = (curline.histnum == curhist) ?
3282            //     addhistnum(curhist,-1,0) : curhist;`
3283            // When the fc command itself occupies the current event
3284            // (the normal interactive case), the default range EXCLUDES
3285            // it — zsh's `fc -l 1` does not list the in-flight `fc -l 1`
3286            // line. The previous `last = curhist` included it.
3287            let curline_num = crate::ported::hist::curline
3288                .lock()
3289                .ok()
3290                .and_then(|c| c.as_ref().map(|e| e.histnum))
3291                .unwrap_or(0);
3292            last = if curline_num == curhist {
3293                crate::ported::hist::addhistnum(curhist, -1, 0) // c:1582
3294            } else {
3295                curhist // c:1583
3296            };
3297            // c:1584-1585 — `if (last < firsthist()) last = firsthist();`
3298            let fh = crate::ported::hist::firsthist();
3299            if last < fh {
3300                last = fh; // c:1585
3301            }
3302        } else {
3303            last = first; // c:1587
3304        }
3305    }
3306    if first == -1 {
3307        // c:1589
3308        let xflags = if OPT_ISSET(ops, b'L') {
3309            HIST_FOREIGN as i32
3310        } else {
3311            0
3312        }; // c:1602
3313        // c:1603-1604 — `first = OPT_ISSET(ops,'l')? addhistnum(curhist,-16,xflags)
3314        //                 : addhistnum(curline.histnum,-1,xflags);`
3315        let curline_num = crate::ported::hist::curline
3316            .lock()
3317            .ok()
3318            .and_then(|c| c.as_ref().map(|e| e.histnum))
3319            .unwrap_or(0);
3320        first = if OPT_ISSET(ops, b'l') {
3321            crate::ported::hist::addhistnum(curhist, -16, xflags)
3322        } else {
3323            crate::ported::hist::addhistnum(curline_num, -1, xflags)
3324        };
3325        if first < 1 {
3326            first = 1; // c:1605-1606
3327        }
3328        if last < first {
3329            last = first;
3330        } // c:1604
3331    }
3332
3333    let mut retval;
3334    if OPT_ISSET(ops, b'l') {
3335        // c:1606
3336        // c:1608 — `fclist(stdout, ops, first, last, asgf, pprog, 0);`
3337        retval = fclist(
3338            &mut io::stdout(),
3339            ops,
3340            first,
3341            last,
3342            &asgf,
3343            pprog_src.as_deref(),
3344            0,
3345        );
3346        unqueue_signals();
3347    } else {
3348        // c:1611-1668 — edit history range to a temp file, fcedit it,
3349        // then stuff() the result back as the next command.
3350        retval = 1; // c:1620
3351                    // c:1620 — `gettempfile(NULL, 1, &fil)`: a NULL prefix makes
3352                    // gettempfile fall back to `$TMPPREFIX` (c:2241-2242, default
3353                    // /tmp/zsh), so the editor buffer is `/tmp/zshXXXXXX`. Passing a
3354                    // bare "zshfc" prefix built a RELATIVE name instead, so `fc`
3355                    // wrote `zshfc.<hex>` into the CURRENT DIRECTORY — visible as
3356                    // stray untracked files in whatever repo you ran it from.
3357        let fil_opt = gettempfile(None); // c:1621 gettempfile
3358        match fil_opt {
3359            None => {
3360                // c:1623
3361                unqueue_signals(); // c:1624
3362                zwarnnam(
3363                    "fc", // c:1625
3364                    &format!("can't open temp file: {}", io::Error::last_os_error()),
3365                );
3366            }
3367            Some((fd, fil)) => {
3368                unsafe {
3369                    libc::close(fd);
3370                } // c:1622 (file is reopened below)
3371                  // c:1632 — `if (last >= curhist) { last = curhist - 1; ... }`
3372                if last >= curhist {
3373                    // c:1632
3374                    last = curhist - 1; // c:1633
3375                    if first > last {
3376                        // c:1634
3377                        unqueue_signals(); // c:1635
3378                        zwarnnam(
3379                            "fc", // c:1636
3380                            "current history line would recurse endlessly, aborted",
3381                        );
3382                        let _ = fs::remove_file(&fil); // c:1639 unlink
3383                        return 1; // c:1640
3384                    }
3385                }
3386                ops.ind[b'n' as usize] = 1; // c:1644 No line numbers
3387                let out = fs::OpenOptions::new()
3388                    .create(true)
3389                    .write(true)
3390                    .truncate(true)
3391                    .open(&fil)
3392                    .ok();
3393                let listed = if let Some(mut f) = out {
3394                    // c:1645 — thread pprog filter so `fc -m PAT` only
3395                    // edits matching lines in this temp-file edit path.
3396                    fclist(&mut f, ops, first, last, &asgf, pprog_src.as_deref(), 1)
3397                } else {
3398                    1
3399                };
3400                if listed == 0 {
3401                    // c:1645
3402                    // c:1647-1656 — pick editor.
3403                    let editor: String = if func == BIN_R || OPT_ISSET(ops, b's') {
3404                        "-".to_string() // c:1648
3405                    } else if OPT_HASARG(ops, b'e') {
3406                        // c:1649
3407                        OPT_ARG(ops, b'e').unwrap_or("").to_string() // c:1650
3408                    } else {
3409                        // c:1651-1654 — `getsparam("FCEDIT") ?:
3410                        //                  getsparam("EDITOR") ?:
3411                        //                  DEFAULT_FCEDIT`. paramtab read.
3412                        getsparam("FCEDIT")
3413                            .or_else(|| getsparam("EDITOR"))
3414                            .unwrap_or_else(|| crate::ported::config_h::DEFAULT_FCEDIT.to_string())
3415                    };
3416                    unqueue_signals(); // c:1657
3417                    if fcedit(&editor, &fil) != 0 {
3418                        // c:1658
3419                        if crate::ported::input::stuff(&fil) != 0 {
3420                            // c:1659
3421                            zwarnnam(
3422                                "fc", // c:1660
3423                                &format!("{}: {}", io::Error::last_os_error(), fil),
3424                            );
3425                        } else {
3426                            // c:1663-1664 — `loop(0,1); retval = lastval;`.
3427                            // `stuff` only PUSHES the edited text onto the
3428                            // input stack (input.c:657); C then runs one
3429                            // pass of the input loop to parse and execute
3430                            // it. Without that call nothing consumed the
3431                            // stuffed line, so the edited command never ran.
3432                            crate::ported::init::r#loop(0, 1); // c:1663
3433                                                               // C has ONE input source and one executor, so
3434                                                               // loop(0,1) both consumes and RUNS what `stuff`
3435                                                               // pushed. In zshrs the ported loop consumes the
3436                                                               // pushed text (inbufct drops to 0) but its
3437                                                               // AST-side execution is not the live path, so
3438                                                               // outside an interactive shell — where the REPL
3439                                                               // would have read and run the line itself —
3440                                                               // `zsh -c 'fc -e ED 1'` echoed the edited
3441                                                               // command and never ran it. Run it through the
3442                                                               // same bridge `execstring` uses (exec.rs:1939).
3443                            if !isset(SHINSTDIN) {
3444                                if let Ok(edited) = fs::read_to_string(&fil) {
3445                                    crate::ported::exec::execstring(&edited, 1, 0, "fc");
3446                                }
3447                            }
3448                            retval = LASTVAL.load(Relaxed); // c:1664
3449                        }
3450                    }
3451                } else {
3452                    unqueue_signals(); // c:1667
3453                }
3454                let _ = fs::remove_file(&fil); // c:1671 unlink
3455            }
3456        }
3457    }
3458    let _ = pprog; // compiled form kept for parity; source threads through fclist.
3459    retval // c:1675
3460}
3461
3462/// Port of `fcgetcomm(char *s)` from Src/builtin.c:1683.
3463/// C: `static zlong fcgetcomm(char *s)` — match `s` against history
3464///   numbers (signed) or prefix; returns the matched event number.
3465/// Port of `fcgetcomm()` from `Src/builtin.c:1683`.
3466/// C decl: `fcgetcomm(char *s)`
3467/// Resolve an `fc` command-line argument to a
3468/// history event number. Numeric args become event numbers (negative
3469/// numbers count back from current via `addhistnum`); non-numeric
3470/// args go through `hcomsearch` (history prefix search). Emits
3471/// `zwarnnam("fc", "event not found: %s", s)` and returns -1 on
3472/// miss.
3473pub fn fcgetcomm(s: &str) -> i64 {
3474    // c:1683
3475    // c:1689 — `if ((cmd = atoi(s)) != 0 || *s == '0')` numeric arm.
3476    //          atoi accepts leading whitespace + optional sign +
3477    //          digits; trim+parse mirrors that.
3478    let trimmed = s.trim_start();
3479    let numeric = trimmed.parse::<i64>().ok();
3480    let is_zero_prefix = trimmed.starts_with('0');
3481    if let Some(mut cmd) = numeric {
3482        if cmd != 0 || is_zero_prefix {
3483            if cmd < 0 {
3484                // c:1693 — `cmd = addhistnum(curline.histnum, cmd, HIST_FOREIGN);`
3485                // Relative to the line being edited, not `curhist`: with no
3486                // edited line (`zsh -c`) curline.histnum is 0, so a negative
3487                // event finds nothing below it and resolves to 0.
3488                let curline_num = crate::ported::hist::curline
3489                    .lock()
3490                    .ok()
3491                    .and_then(|c| c.as_ref().map(|e| e.histnum))
3492                    .unwrap_or(0);
3493                cmd = addhistnum(curline_num, cmd as i32, HIST_FOREIGN as i32);
3494            }
3495            if cmd < 0 {
3496                // c:1695
3497                cmd = 0;
3498            }
3499            return cmd;
3500        }
3501    }
3502    // c:1700 — `cmd = hcomsearch(s); if (cmd == -1) zwarnnam(...);`
3503    match hcomsearch(s) {
3504        Some(n) => n,
3505        None => {
3506            zwarnnam("fc", &format!("event not found: {}", s));
3507            -1
3508        }
3509    }
3510}
3511
3512/// Port of `fcsubs()` from `Src/builtin.c:1708`.
3513/// C decl: `fcsubs(char **sp, struct asgment *sub)`
3514/// C: `static int fcsubs(char **sp, struct asgment *sub)` — apply the
3515///   linked-list of `old=new` substitutions to `*sp` in place; return
3516///   the count of substitutions made.
3517pub fn fcsubs(sp: &mut String, sub: &[(String, String)]) -> i32 {
3518    // c:1708
3519    // c:1708-1748 — for each (old, new), replace each occurrence in *sp.
3520    let mut subbed = 0i32; // c:1713
3521    for (old, new) in sub {
3522        // c:1716
3523        if old.is_empty() {
3524            continue;
3525        }
3526        let count = sp.matches(old.as_str()).count() as i32; // c:1722
3527        if count > 0 {
3528            *sp = sp.replace(old.as_str(), new); // c:1750
3529            subbed += count;
3530        }
3531    }
3532    subbed
3533}
3534
3535/// Direct port of `int fclist(FILE *f, Options ops, zlong first,
3536/// zlong last, struct asgment *subs, Patprog pprog, int is_command)`
3537/// from `Src/builtin.c:1750`. Walks the history event range
3538/// `first..=last`, applies the `subs` substitution chain to each
3539/// matching line (when `pprog` is set, only lines matching it),
3540/// then writes the result with optional timestamp prefix per
3541/// `-d/-f/-E/-i/-t`.
3542///
3543/// Rust signature: takes the output writer as a closure so callers
3544/// can route to stdout, a FILE*, or an in-memory buffer (the
3545/// `is_command` caller in `bin_fc` collects to a heredoc string).
3546#[allow(clippy::too_many_arguments)]
3547/// Port of `fclist()` from `Src/builtin.c:1750`. — C decl `fclist(FILE *f, Options ops, zlong first, zlong last,`
3548pub fn fclist(
3549    out: &mut dyn Write, // c:1750
3550    ops: &options,
3551    mut first: i64,
3552    mut last: i64,
3553    subs: &[(String, String)],
3554    pprog: Option<&str>,
3555    is_command: i32,
3556) -> i32 {
3557    use std::io::Write;
3558    let mut fclistdone = 0i32; // c:1754
3559    let mut xflags: u32 = 0; // c:1754
3560
3561    // c:1759-1763 — reverse range if required.
3562    if OPT_ISSET(ops, b'r') {
3563        std::mem::swap(&mut first, &mut last);
3564    }
3565    // c:1765-1770
3566    if is_command != 0 && first > last {
3567        zwarnnam("fc", "history events can't be executed backwards, aborted");
3568        return 1;
3569    }
3570
3571    // c:1772-1783 —
3572    //     ent = gethistent(first, first < last? GETHIST_DOWNWARD : GETHIST_UPWARD);
3573    //     if (!ent || (first < last? ent->histnum > last : ent->histnum < last)) {
3574    //         if (first == last) zwarnnam("fc", "no such event: %s", buf);
3575    //         else zwarnnam("fc", "no events in that range");
3576    //         return 1;
3577    //     }
3578    // gethistent's near match can land past the other end of the range
3579    // (`fc -l 5` with one event finds event 1, below `last`), which is
3580    // as much a miss as no event at all.
3581    let near = if first < last { 1 } else { -1 };
3582    // !!! BASH-MODE !!! bash lists an empty history as nothing, status 0.
3583    if crate::dash_mode::bash_mode() && is_command == 0 && gethistent(1, 1).is_none() {
3584        return 0;
3585    }
3586    let mut ev = match gethistent(first, near) {
3587        Some(e) if !(if first < last { e > last } else { e < last }) => e,
3588        _ => {
3589            zwarnnam(
3590                "fc",
3591                &if first == last {
3592                    format!("no such event: {}", first)
3593                } else {
3594                    "no events in that range".to_string()
3595                },
3596            );
3597            return 1;
3598        }
3599    };
3600
3601    // c:1785-1803 — time/date format for -d/-f/-E/-i/-t.
3602    let tdfmt: Option<String> = if OPT_ISSET(ops, b't') {
3603        Some(OPT_ARG(ops, b't').unwrap_or("").to_string()) // c:1789
3604    } else if OPT_ISSET(ops, b'i') {
3605        Some("%Y-%m-%d %H:%M".to_string()) // c:1791
3606    } else if OPT_ISSET(ops, b'E') {
3607        Some("%f.%-m.%Y %H:%M".to_string()) // c:1793
3608    } else if OPT_ISSET(ops, b'f') {
3609        Some("%-m/%f/%Y %H:%M".to_string()) // c:1795
3610    } else if OPT_ISSET(ops, b'd') {
3611        Some("%H:%M".to_string()) // c:1797
3612    } else {
3613        None // c:1801
3614    };
3615
3616    // c:1805-1811 — xflags exclude events.
3617    if OPT_ISSET(ops, b'L') {
3618        xflags |= crate::ported::zsh_h::HIST_FOREIGN;
3619    }
3620    if OPT_ISSET(ops, b'I') {
3621        xflags |= crate::ported::zsh_h::HIST_READ;
3622    }
3623
3624    // C pre-compiles the pattern once (bin_fc c:1493 patcompile).
3625    let prog = pprog.and_then(|pat| {
3626        let mut tok = crate::pattern_data_escape::escape_data_backslashes(pat); // a backslash tokenize leaves raw is DATA (c:Src/glob.c:3651)
3627        crate::ported::glob::tokenize(&mut tok);
3628        patcompile(&tok, 0, None)
3629    });
3630
3631    loop {
3632        // c:1813-1817
3633        let entry = match quietgethist(ev) {
3634            Some(e) => e,
3635            None => break,
3636        };
3637        let s: Option<String> = if (entry.node.flags as u32 & xflags) != 0 {
3638            None
3639        } else {
3640            Some(entry.node.nam.clone())
3641        };
3642        // c:1818 — `if (s && (!pprog || pattry(pprog, s)))`
3643        if let Some(mut s) = s.filter(|s| prog.as_ref().is_none_or(|p| pattry(p, s))) {
3644            // c:1820 — `fclistdone |= (subs ? fcsubs(&s, subs) : 1);`
3645            fclistdone |= if subs.is_empty() {
3646                1
3647            } else {
3648                i32::from(fcsubs(&mut s, subs) != 0)
3649            };
3650
3651            // c:1822-1828 — do numbering.
3652            if !OPT_ISSET(ops, b'n') {
3653                let foreign = (entry.node.flags as u32 & crate::ported::zsh_h::HIST_FOREIGN) != 0;
3654                let _ = write!(out, "{:>5}{} ", ev, if foreign { '*' } else { ' ' });
3655            }
3656            // c:1830-1839 — time (and possibly date) of execution.
3657            if let Some(ref fmt) = tdfmt {
3658                let t = std::time::UNIX_EPOCH
3659                    + std::time::Duration::from_secs(entry.stim.max(0) as u64);
3660                let _ = write!(out, "{}  ", crate::ported::utils::ztrftime(fmt, t, false));
3661            }
3662            // c:1841-1845 — time taken by the command.
3663            if OPT_ISSET(ops, b'D') {
3664                let diff = if entry.ftim != 0 { entry.ftim - entry.stim } else { 0 };
3665                let _ = write!(out, "{}:{:02}  ", diff / 60, diff % 60);
3666            }
3667
3668            // c:1847-1856 — output the command:
3669            //     if (f == stdout) { nicezputs(s, f); putc('\n', f); }
3670            //     else { unmetafy(s, &len); fwrite(s, 1, len, f); putc('\n', f); }
3671            // A listing renders control bytes visibly (an embedded newline
3672            // from `print -s $'a\nb'` lists as `a\nb`); the temp file handed
3673            // to the editor gets the raw bytes so the edited command
3674            // round-trips.
3675            // !!! WARNING: Rust cannot compare a `&mut dyn Write` against
3676            // stdout, so the `f == stdout` test is read from `is_command`.
3677            // The two are equivalent at every C call site: c:1611 passes
3678            // (stdout, …, 0) and c:1643 passes (the temp file, …, 1).
3679            if is_command == 0 {
3680                crate::ported::utils::nicezputs(&s, out); // c:1849
3681                let _ = out.write_all(b"\n"); // c:1850
3682            } else {
3683                let _ = writeln!(out, "{}", s); // c:1854-1855
3684            }
3685        }
3686        // c:1859-1866 — move on to the next history line, or quit.
3687        let next = if first < last {
3688            crate::ported::hist::down_histent(ev).filter(|&n| n <= last)
3689        } else {
3690            crate::ported::hist::up_histent(ev).filter(|&n| n >= last)
3691        };
3692        match next {
3693            Some(n) => ev = n,
3694            None => break,
3695        }
3696    }
3697
3698    // c:1872-1878 — final processing.
3699    if fclistdone == 0 {
3700        if !subs.is_empty() {
3701            zwarnnam("fc", "no substitutions performed");
3702        } else if xflags != 0 || pprog.is_some() {
3703            zwarnnam("fc", "no matching events found");
3704        }
3705        return 1;
3706    }
3707    0 // c:1879
3708}
3709
3710/// Port of `fcedit()` from `Src/builtin.c:1885`.
3711/// C decl: `fcedit(char *ename, char *fn)`
3712/// C: `static int fcedit(char *ename, char *fn)` — invoke `$ename fn`,
3713///   returning the editor's exit status (0 if `ename == "-"`).
3714/// WARNING: param names don't match C — Rust=(ename, fn_) vs C=(ename, fn)
3715pub fn fcedit(ename: &str, fn_: &str) -> i32 {
3716    // c:1885
3717    // c:1885 — `if (!strcmp(ename, "-")) return 1;`
3718    if ename == "-" {
3719        // c:1888
3720        return 1; // c:1889
3721    }
3722    // c:1892-1896 — `s = tricat(ename, " ", fn); execstring(s, 1, 0,
3723    // "fc"); return !lastval;`. Two things the spawn form got wrong:
3724    // the editor is a COMMAND LINE run through the shell (so
3725    // `fc -e "print -r --"` or `FCEDIT="vim -u NONE"` work), and the
3726    // return is `!lastval` — 1 on SUCCESS. Returning the editor's exit
3727    // code inverted the caller's `if (fcedit(...))` test, so the edited
3728    // buffer was never stuffed back and `fc -e ED N` silently did
3729    // nothing and reported failure.
3730    let s = format!("{} {}", ename, fn_); // c:1892
3731    crate::ported::exec::execstring(&s, 1, 0, "fc"); // c:1893
3732    i32::from(LASTVAL.load(Relaxed) == 0) // c:1896 `return !lastval`
3733}
3734
3735/// Port of `getasg()` from `Src/builtin.c:1908`.
3736/// C decl: `getasg(char ***argvp, LinkList assigns)`
3737/// C: `static Asgment getasg(char ***argvp, LinkList assigns)` —
3738///   parse one assignment-form arg (`name=value` / `name`) from
3739///   `*argvp`. Returns NULL when exhausted.
3740/// ```c
3741/// static Asgment
3742/// getasg(char ***argvp, LinkList assigns)
3743/// {
3744///     char *s = **argvp;
3745///     static struct asgment asg;
3746///     if (!s) {
3747///         if (assigns) {
3748///             Asgment asgp = (Asgment)firstnode(assigns);
3749///             if (!asgp) return NULL;
3750///             (void)uremnode(assigns, &asgp->node);
3751///             return asgp;
3752///         }
3753///         return NULL;
3754///     }
3755///     if (*s == '=') { zerr("bad assignment"); return NULL; }
3756///     asg.name = s;
3757///     asg.flags = 0;
3758///     for (; *s && *s != '='; s++);
3759///     if (*s) { *s = '\0'; asg.value.scalar = s + 1; }
3760///     else asg.value.scalar = NULL;
3761///     (*argvp)++;
3762///     return &asg;
3763/// }
3764/// ```
3765pub fn getasg(
3766    argvp: &mut Vec<String>, // c:1908
3767    assigns: &mut Vec<(String, String)>,
3768) -> Option<(String, String)> {
3769    // c:1914-1923 — out-of-args path: drain from assigns list if non-empty.
3770    if argvp.is_empty() {
3771        // c:1914 !s
3772        if !assigns.is_empty() {
3773            // c:1915
3774            return Some(assigns.remove(0)); // c:1916-1920 firstnode + uremnode
3775        }
3776        return None; // c:1922
3777    }
3778
3779    let s = argvp.remove(0); // c:1944 (*argvp)++
3780
3781    // c:1926-1929 — empty-name guard: bare `=value` is an error.
3782    if s.starts_with('=') {
3783        // c:1926
3784        zerr("bad assignment"); // c:1927
3785        return None; // c:1928
3786    }
3787
3788    // c:1934-1943 — split on `=`. No `=` → name-only (scalar = NULL).
3789    match s.find('=') {
3790        // c:1934
3791        Some(i) => {
3792            // c:1938-1939 — `*s = '\0'; asg.value.scalar = s + 1;`
3793            Some((s[..i].to_string(), s[i + 1..].to_string())) // c:1939
3794        }
3795        None => {
3796            // c:1942 — `asg.value.scalar = NULL;` — name-only.
3797            Some((s, String::new())) // c:1942
3798        }
3799    }
3800}
3801
3802/// Port of `typeset_setbase()` from `Src/builtin.c:1961`.
3803/// C decl: `typeset_setbase(const char *name, Param pm, Options ops, int on, int always)`
3804/// C: `static int typeset_setbase(const char *name, Param pm, Options ops,
3805///     int on, int always)` — install numeric base on `pm`. For
3806///     `-i ARG`/`-E ARG`/`-F ARG`, parse ARG as base and validate
3807///     (must be 2..=36 for integer); error → return 1.
3808/// WARNING: param names don't match C — Rust=(name, pm, on, always) vs C=(name, pm, ops, on, always)
3809pub fn typeset_setbase(
3810    name: &str,
3811    pm: *mut param, // c:1961
3812    ops: &options,
3813    on: i32,
3814    always: i32,
3815) -> i32 {
3816    // c:1964 — `char *arg = NULL;`
3817    let mut arg: Option<&str> = None; // c:1964
3818    let on_u = on as u32;
3819    // c:1966-1971 — `if ((on & PM_INTEGER) && OPT_HASARG(ops,'i')) arg = OPT_ARG(ops,'i');`
3820    if (on_u & PM_INTEGER) != 0 && OPT_HASARG(ops, b'i') {
3821        // c:1966
3822        arg = OPT_ARG(ops, b'i'); // c:1967
3823    } else if (on_u & PM_EFLOAT) != 0 && OPT_HASARG(ops, b'E') {
3824        // c:1968
3825        arg = OPT_ARG(ops, b'E'); // c:1969
3826    } else if (on_u & PM_FFLOAT) != 0 && OPT_HASARG(ops, b'F') {
3827        // c:1970
3828        arg = OPT_ARG(ops, b'F'); // c:1971
3829    }
3830
3831    // c:1973 — `if (arg) {`
3832    if let Some(a) = arg {
3833        // c:1973
3834        // c:1976 — `int base = (int)zstrtol(arg, &eptr, 10);`
3835        let base = match a.trim().parse::<i32>() {
3836            Ok(b) => b,
3837            Err(_) => {
3838                // c:1977-1982
3839                if (on_u & PM_INTEGER) != 0 {
3840                    zwarnnam(name, &format!("bad base value: {}", a)); // c:1979
3841                } else {
3842                    zwarnnam(name, &format!("bad precision value: {}", a)); // c:1981
3843                }
3844                return 1; // c:1983
3845            }
3846        };
3847        // c:1985-1989 — integer base must be 2..=36 inclusive.
3848        if (on_u & PM_INTEGER) != 0 && (base < 2 || base > 36) {
3849            // c:1985
3850            zwarnnam(
3851                name,
3852                &format!("invalid base (must be 2 to 36 inclusive): {}", base),
3853            ); // c:1986-1987
3854            return 1; // c:1988
3855        }
3856        // c:1990 — `pm->base = base;`
3857        if !pm.is_null() {
3858            unsafe {
3859                (*pm).base = base;
3860            } // c:1990
3861        }
3862    } else if always != 0 {
3863        // c:1991
3864        // c:1997 — `pm->base = 0;`
3865        if !pm.is_null() {
3866            unsafe {
3867                (*pm).base = 0;
3868            } // c:1997
3869        }
3870    }
3871    0 // c:1997
3872}
3873
3874/// Port of `typeset_setwidth()` from `Src/builtin.c:1997`.
3875/// C decl: `typeset_setwidth(const char * name, Param pm, Options ops, int on, int always)`
3876/// C: `static int typeset_setwidth(const char *name, Param pm, Options ops,
3877///     int on, int always)` — install padding width via `-L/-R/-Z ARG`.
3878/// WARNING: param names don't match C — Rust=(name, pm, on, always) vs C=(name, pm, ops, on, always)
3879pub fn typeset_setwidth(
3880    name: &str,
3881    pm: *mut param, // c:1997
3882    ops: &options,
3883    on: i32,
3884    always: i32,
3885) -> i32 {
3886    // c:2000 — `char *arg = NULL;`
3887    let mut arg: Option<&str> = None; // c:2000
3888    let on_u = on as u32;
3889    // c:2002-2007
3890    if (on_u & PM_LEFT) != 0 && OPT_HASARG(ops, b'L') {
3891        // c:2002
3892        arg = OPT_ARG(ops, b'L'); // c:2003
3893    } else if (on_u & PM_RIGHT_B) != 0 && OPT_HASARG(ops, b'R') {
3894        // c:2004
3895        arg = OPT_ARG(ops, b'R'); // c:2005
3896    } else if (on_u & PM_RIGHT_Z) != 0 && OPT_HASARG(ops, b'Z') {
3897        // c:2006
3898        arg = OPT_ARG(ops, b'Z'); // c:2007
3899    }
3900
3901    // c:2009 — `if (arg) {`
3902    if let Some(a) = arg {
3903        // c:2009
3904        // c:2011 — `pm->width = (int)zstrtol(arg, &eptr, 10);`
3905        let width = match a.trim().parse::<i32>() {
3906            Ok(w) => w,
3907            Err(_) => {
3908                zwarnnam(name, &format!("bad width value: {}", a)); // c:2013
3909                return 1; // c:2014
3910            }
3911        };
3912        if !pm.is_null() {
3913            unsafe {
3914                (*pm).width = width;
3915            } // c:2011
3916        }
3917    } else if always != 0 {
3918        // c:2015
3919        // c:2016 — `pm->width = 0;`
3920        if !pm.is_null() {
3921            unsafe {
3922                (*pm).width = 0;
3923            } // c:2025
3924        }
3925    }
3926    0 // c:2025
3927}
3928
3929/// Port of `typeset_single()` from `Src/builtin.c:2025`.
3930/// C decl: `typeset_single(char *cname, char *pname, Param pm, int func,`
3931/// Port of `static Param typeset_single(char *cname, char *pname,
3932/// Param pm, int func, int on, int off, int roff, Asgment asg,
3933/// Param altpm, Options ops, int joinchar)` from `Src/builtin.c:2025`.
3934/// Per-name attribute resolver + assignment dispatcher invoked once
3935/// per arg from `bin_typeset`.
3936#[allow(clippy::too_many_arguments)]
3937pub fn typeset_single(
3938    cname: &str,
3939    pname: &str, // c:2025
3940    pm: *mut param,
3941    func: i32,
3942    mut on: i32,
3943    mut off: i32,
3944    _roff: i32,
3945    asg: *mut asgment,
3946    altpm: *mut param,
3947    ops: &options,
3948    _joinchar: i32,
3949) -> *mut param {
3950    let mut usepm: i32; // c:2029
3951    let mut tc: i32 = 0; // c:2029
3952    let _keeplocal: i32 = 0; // c:2029
3953    let mut newspecial: i32 = 0; /* NS_NONE */
3954    // c:2029
3955    let _readonly: i32 = 0; // c:2029
3956    let _dont_set: i32 = 0; // c:2029
3957    let mut pname_owned: String = pname.to_string(); // c:2030 subscript path
3958
3959    // c:2032-2050 — nameref resolution.
3960    let pm_ref = unsafe { pm.as_mut() };
3961    if let Some(pm_r) = &pm_ref {
3962        let pm_flags = pm_r.node.flags as u32;
3963        let locallevel_v = locallevel_param.load(Relaxed);
3964        if (pm_flags & PM_NAMEREF) != 0
3965            && ((off | on) as u32 & PM_NAMEREF) == 0
3966            && (pm_r.level == locallevel_v || (on as u32 & PM_LOCAL) == 0)
3967        {
3968            // c:2034 — pm = resolve_nameref(pm)
3969            //          pname = pm->node.nam (when resolved)
3970            // The chain walk (`resolve_nameref_name`) and the `pname`
3971            // rewrite already ran in bin_typeset's per-arg loop before
3972            // this fn is entered, so only the c:2036-2048 checks remain.
3973            let unresolved_flags = pm_r.node.flags as u32;
3974            let extra_on_mask = !(PM_NAMEREF | PM_LOCAL | PM_READONLY) as i32;
3975            if (pm_flags & PM_NAMEREF) != 0
3976                && ((unresolved_flags & PM_UNSET) == 0 || (unresolved_flags & PM_DECLARED) != 0)
3977                && (on & extra_on_mask) != 0
3978            {
3979                // c:2042-2048 — error: can't change type of a nameref.
3980                if pm_r.width != 0 {
3981                    // c:2041
3982                    zwarnnam(
3983                        cname, // c:2042
3984                        &format!("{}: can't change type via subscript reference", pname),
3985                    );
3986                } else {
3987                    zwarnnam(
3988                        cname, // c:2046
3989                        &format!("{}: can't change type of a named reference", pname),
3990                    );
3991                }
3992                return std::ptr::null_mut(); // c:2048
3993            }
3994        }
3995    }
3996
3997    // c:2062-2064 — `usepm = pm && (!(pm_flags & PM_UNSET) || OPT_ISSET(ops,'p') || ...)`
3998    let pm_flags = pm_ref.as_ref().map(|p| p.node.flags as u32).unwrap_or(0);
3999    usepm = if pm_ref.is_some()
4000        && ((pm_flags & PM_UNSET) == 0
4001            || OPT_ISSET(ops, b'p')
4002            || (isset(POSIXBUILTINS) && (pm_flags & (PM_READONLY | PM_EXPORTED)) != 0))
4003    {
4004        1
4005    } else {
4006        0
4007    };
4008
4009    // c:2070-2071 — preserve PM_UNSET for special params.
4010    if usepm == 0 && pm_ref.is_some() && (pm_flags & PM_SPECIAL) != 0 {
4011        usepm = 2; // c:2071
4012    }
4013
4014    // c:2078-2091 — don't reuse if local-level changed and PM_LOCAL set.
4015    let pm_level = pm_ref.as_ref().map(|p| p.level).unwrap_or(0);
4016    let locallevel_v = locallevel_param.load(Relaxed);
4017    if usepm != 0 && locallevel_v != pm_level && (on as u32 & PM_LOCAL) != 0 {
4018        // c:2078
4019        if (pm_flags & PM_SPECIAL) != 0                // c:2087
4020            && (on as u32 & PM_HIDE) == 0
4021            && (pm_flags & PM_HIDE & !off as u32) == 0
4022        {
4023            newspecial = 1; /* NS_NORMAL */ // c:2089
4024        }
4025        usepm = 0; // c:2090
4026    }
4027
4028    // c:2093-2116 — type-conversion / tied-colonarray detection.
4029    let asg_ref = unsafe { asg.as_ref() };
4030    tc = 0;
4031    if let Some(a) = asg_ref {
4032        if ASG_ARRAYP(a)
4033            && PM_TYPE(on as u32) == PM_SCALAR
4034            && !(usepm != 0 && (PM_TYPE(pm_flags) & (PM_ARRAY | PM_HASHED)) != 0)
4035        {
4036            on |= PM_ARRAY as i32; // c:2097
4037        }
4038        if usepm != 0 && ASG_ARRAYP(a) && newspecial == 0                    // c:2098
4039            && PM_TYPE(pm_flags) != PM_ARRAY
4040            && PM_TYPE(pm_flags) != PM_HASHED
4041        {
4042            if (on as u32 & (PM_EFLOAT | PM_FFLOAT | PM_INTEGER)) != 0 {
4043                zerrnam(
4044                    cname, // c:2102
4045                    &format!("{}: can't assign array value to non-array", pname),
4046                );
4047                return std::ptr::null_mut();
4048            }
4049            if (pm_flags & PM_SPECIAL) != 0 {
4050                // c:2105
4051                zerrnam(
4052                    cname, // c:2106
4053                    &format!("{}: can't assign array value to non-array special", pname),
4054                );
4055                return std::ptr::null_mut();
4056            }
4057            tc = 1; // c:2109
4058            usepm = if OPT_MINUS(ops, b'p') {
4059                // c:2110
4060                (on as u32 & pm_flags) as i32
4061            } else if OPT_PLUS(ops, b'p') {
4062                // c:2112
4063                (off as u32 & pm_flags) as i32
4064            } else {
4065                0 // c:2115
4066            };
4067        }
4068    }
4069
4070    // c:2117-2199 — attribute-mask compatibility checks (chflags compute).
4071    if usepm != 0 || newspecial != 0 {
4072        let chflags = ((off as u32 & pm_flags) | (on as u32 & !pm_flags))    // c:2118
4073            & (PM_INTEGER
4074               | PM_EFLOAT
4075               | PM_FFLOAT
4076               | PM_HASHED | PM_ARRAY | PM_TIED | PM_AUTOLOAD);
4077        if chflags != 0 && chflags != (PM_EFLOAT | PM_FFLOAT) {
4078            tc = 1; // c:2122
4079            usepm = if OPT_MINUS(ops, b'p') {
4080                // c:2123
4081                (on as u32 & pm_flags) as i32
4082            } else if OPT_PLUS(ops, b'p') {
4083                // c:2125
4084                (off as u32 & pm_flags) as i32
4085            } else {
4086                // c:2127 — `else usepm = 0;`
4087                //
4088                // This `else` was missing, and it is what selects between the
4089                // two mutually exclusive paths. usepm != 0 takes the MERGE path
4090                // (c:2291: keep the existing flags, add `on`, drop `off`), while
4091                // usepm == 0 falls through to the type-conversion path (c:2355),
4092                // which deletes the parameter and recreates it carrying ONLY
4093                // READONLY|EXPORTED (c:2357). Leaving usepm set meant a base-type
4094                // change merged instead of recreating, so `typeset -Z 3 x=7;
4095                // typeset -i x=1` kept the right_zeros attribute (zsh: `integer`,
4096                // zshrs: `integer-right_zeros`).
4097                0
4098            };
4099        }
4100    }
4101
4102    // c:2202-2214 — readonly/exported preservation rules.
4103    if usepm != 0 || newspecial != 0 {
4104        if (on as u32 & (PM_READONLY | PM_EXPORTED)) != 0                    // c:2202
4105            && (usepm == 0 || (pm_flags & PM_UNSET) != 0)
4106            && asg_ref.is_some_and(|a| !ASG_VALUEP(a))
4107        {
4108            on |= PM_UNSET as i32; // c:2205
4109        } else if usepm != 0 && (pm_flags & PM_READONLY) != 0                // c:2206
4110            && (on as u32 & PM_READONLY) == 0
4111            && func != BIN_EXPORT
4112        {
4113            zerr(&format!(
4114                // c:2208
4115                "read-only variable: {}",
4116                pm_ref.as_ref().unwrap().node.nam
4117            ));
4118            return std::ptr::null_mut();
4119        }
4120    }
4121
4122    // c:2226-2248 — reuse-existing-param fast paths.
4123    if usepm != 0 {
4124        let pm_r = pm_ref.as_ref().unwrap();
4125        if OPT_MINUS(ops, b'p')
4126            && on != 0
4127            && !((on as u32 & pm_flags) != 0 || ((on as u32 & PM_LOCAL) != 0 && pm_r.level != 0))
4128        {
4129            return std::ptr::null_mut(); // c:2229
4130        }
4131        if OPT_PLUS(ops, b'p') && off != 0 && (off as u32 & pm_flags) == 0 {
4132            return std::ptr::null_mut(); // c:2231
4133        }
4134        // c:2232-2238 — array/scalar consistency check
4135        if let Some(a) = asg_ref {
4136            let array_assign = (a.flags & ASG_ARRAY) != 0;
4137            let pm_is_arr = (PM_TYPE(pm_flags) & (PM_ARRAY | PM_HASHED)) != 0;
4138            if array_assign && !pm_is_arr {
4139                // c:2232
4140                zerrnam(
4141                    cname, // c:2236
4142                    &format!("{}: inconsistent type for assignment", pname),
4143                );
4144                return std::ptr::null_mut();
4145            }
4146        }
4147    }
4148
4149    // c:2240-2247 — print-only path: typeset -p / typeset name (no value).
4150    if usepm != 0 && on == 0 && _roff == 0 && asg_ref.is_some_and(|a| !ASG_VALUEP(a)) {
4151        // c:2241 — `int with_ns = OPT_ISSET(ops,'m') ? PRINT_WITH_NAMESPACE : 0;`
4152        let with_ns = if OPT_ISSET(ops, b'm') {
4153            // c:2241
4154            PRINT_WITH_NAMESPACE
4155        } else {
4156            0
4157        };
4158        // c:Src/builtin.c:2761-2765 — `typeset -p1 NAME` adds PRINT_LINE
4159        // (one array/assoc element per line). The listing path (~3805)
4160        // already parses this; the explicit-name print path here did not,
4161        // so `typeset -p1 myarray` printed the single-line `( a b c )`
4162        // form instead of zsh's multi-line `(\n  a\n  b\n  c\n)`.
4163        let line_flag =
4164            if OPT_HASARG(ops, b'p') && OPT_ARG(ops, b'p').map(|a| a.trim()) == Some("1") {
4165                PRINT_LINE
4166            } else {
4167                0
4168            };
4169        if let Some(pm_r) = unsafe { pm.as_mut() } {
4170            if OPT_ISSET(ops, b'p') {
4171                // c:2242
4172                // c:2243 — `paramtab->printnode(&pm->node, PRINT_TYPESET|with_ns);`
4173                printparamnode(pm_r, PRINT_TYPESET | with_ns | line_flag);
4174            } else if !OPT_ISSET(ops, b'g')                                  // c:2244
4175                && (!isset(TYPESETSILENT) || OPT_ISSET(ops, b'm'))
4176            // c:2245
4177            {
4178                // c:2246 — `paramtab->printnode(&pm->node, PRINT_INCLUDEVALUE|with_ns);`
4179                printparamnode(pm_r, PRINT_INCLUDEVALUE | with_ns);
4180            }
4181        }
4182        return pm; // c:2247
4183    }
4184
4185    // c:2355-2378 — tc (type-conversion) branch: recreate the param.
4186    if tc != 0 && !OPT_ISSET(ops, b'p') {
4187        on |= (!off as u32 & (PM_READONLY | PM_EXPORTED) & pm_flags) as i32; // c:2357
4188        if let Some(pm_r) = pm_ref {
4189            pm_r.node.flags &= !(PM_READONLY as i32); // c:2359
4190        }
4191        // c:2364 — keeplocal = pm->level (used by createparam path)
4192        // c:2372-2375 — carry scalar value across type change.
4193        // c:2378 — unsetparam_pm(pm, 0, 1)
4194        if let Some(pm_r) = unsafe { pm.as_mut() } {
4195            unsetparam_pm(pm_r, 0, 1);
4196        }
4197        pname_owned = pname.to_string(); // c:2377
4198    }
4199
4200    // c:2381-2467 — newspecial path: preserve special-param struct.
4201    // c:2469-2510 — createparam + assignment dispatch for new/converted.
4202    // c:2512-2453 — apply value via assignsparam/setaparam/sethparam.
4203    // These call into a 2-level helper chain (typeset_setwidth,
4204    // typeset_setbase, assignsparam, etc.) — the available Rust
4205    // ports drive single-attribute setters. The dispatcher entry
4206    // (bin_typeset at c:2655) walks the option matrix and invokes
4207    // those setters directly today.
4208    let _ = (altpm, pname_owned, _keeplocal, _dont_set, _readonly);
4209
4210    // c:2547 — `return pm;`
4211    pm
4212}
4213
4214// !!! WARNING: RUST-ONLY STATE (no C counterpart) !!!
4215// C's `-m` arm (Src/builtin.c:3084-3090) calls `typeset_single` directly on
4216// every matched Param. zshrs's typeset_single equivalent is bin_typeset's own
4217// per-arg loop, so that arm re-enters bin_typeset with the matched names as
4218// literal args; this flag makes the re-entry skip the `-m` arm instead of
4219// looping forever, while leaving `ops` (and hence `on`/`off`/`roff` and the
4220// c:2244 print gate) byte-identical to the outer call.
4221thread_local! {
4222    static TYPESET_M_APPLY: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
4223}
4224
4225/// Port of `bin_typeset()` from `Src/builtin.c:2655`.
4226/// C decl: `bin_typeset(char *name, char **argv, LinkList assigns, Options ops, int func)`
4227/// C: `int bin_typeset(char *name, char **argv, LinkList assigns,
4228///     Options ops, int func)`.
4229///
4230/// The C body (~500 lines) ports here in two layers: the option-flag
4231/// matrix + conflict-resolution / dispatch (faithfully translated)
4232/// and the per-arg param-setting loop (delegated to typeset_single
4233/// already ported above).
4234/// WARNING: param names don't match C — Rust=(name, argv, func) vs C=(name, argv, assigns, ops, func)
4235pub fn bin_typeset(
4236    name: &str,
4237    argv: &[String], // c:2655
4238    ops: &options,
4239    func: i32,
4240) -> i32 {
4241    // !!! DASH-FAITHFUL GATE (no C counterpart) !!! dash's `local` prints nothing
4242    // when given no names, and `local x` makes a local that INHERITS the
4243    // visible value (`x=5; f() { local x; echo $x; }` prints 5), where zsh's
4244    // starts it unset. Re-enter with the inherited value spelled out.
4245    if name == "local" && crate::dash_mode::dash_faithful() {
4246        // dash: `local` outside a function is a fatal "not in a function" (status 2).
4247        if crate::ported::params::locallevel.load(std::sync::atomic::Ordering::Relaxed) == 0 {
4248            zerrnam(name, "not in a function");
4249            return crate::dash_mode::fatal_error_status().unwrap_or(2);
4250        }
4251        if argv.is_empty() {
4252            return 0;
4253        }
4254        let inherited: Vec<String> = argv
4255            .iter()
4256            .map(|a| match crate::ported::params::getsparam(a) {
4257                Some(v) if !a.contains('=') => format!("{a}={v}"),
4258                _ => a.clone(),
4259            })
4260            .collect();
4261        if inherited != argv {
4262            return bin_typeset(name, &inherited, ops, func);
4263        }
4264    }
4265
4266    // !!! BASH-MODE (no C counterpart) !!! bash(1) `local -`: "the shell option
4267    // settings (set -/+) are restored on function return". zsh gets that from
4268    // LOCAL_OPTIONS, whose restore the function-exit path (exec.rs c:6129) runs.
4269    if name == "local"
4270        && crate::dash_mode::bash_mode()
4271        && crate::ported::params::locallevel.load(std::sync::atomic::Ordering::Relaxed) > 0
4272        && OPT_ISSET(ops, b'-')
4273    {
4274        // The option parser consumed the lone `-` (c:337-341) and left its
4275        // indicator in `ops.ind['-']`.
4276        crate::ported::options::opt_state_set("localoptions", true);
4277        if argv.is_empty() {
4278            return 0;
4279        }
4280    }
4281
4282    // !!! BASH-MODE (no C counterpart) !!! bash: `local` outside a function is
4283    // an error, status 1 (zsh accepts it as a plain typeset).
4284    if name == "local"
4285        && crate::dash_mode::bash_mode()
4286        && crate::ported::params::locallevel.load(std::sync::atomic::Ordering::Relaxed) == 0
4287    {
4288        zwarnnam(name, "can only be used in a function");
4289        return 1;
4290    }
4291
4292    // !!! BASH-MODE (no C counterpart) !!! bash's `declare -F` / `typeset -F`
4293    // is the function-NAME listing (zsh's -F is fixed-point float): bare
4294    // `declare -F` prints `declare -f NAME` per function; `declare -F NAME`
4295    // prints NAME and returns 1 when it is not a function.
4296    if (name == "declare" || name == "typeset")
4297        && crate::dash_mode::bash_mode()
4298        && OPT_ISSET(ops, b'F')
4299        && !OPT_ISSET(ops, b'f')
4300    {
4301        let names: Vec<String> = {
4302            let mut v: Vec<String> = shfunctab_lock()
4303                .read()
4304                .map(|t| t.iter().map(|(n, _)| n.clone()).collect())
4305                .unwrap_or_default();
4306            v.retain(|n| !n.starts_with("_zshrs_anon_"));
4307            v.sort();
4308            v
4309        };
4310        let mut rc = 0;
4311        let queries: Vec<&String> = argv.iter().filter(|a| !a.starts_with('-')).collect();
4312        if queries.is_empty() {
4313            for n in &names {
4314                println!("declare -f {}", n);
4315            }
4316        }
4317        for q in queries {
4318            if names.contains(q) {
4319                println!("{}", q);
4320            } else {
4321                rc = 1;
4322            }
4323        }
4324        return rc;
4325    }
4326
4327    // PFA-SMR aspect: bin_typeset is the C dispatch site for
4328    // typeset/declare/integer/float/local/export/readonly/private —
4329    // every one of those state-mutating builtins lands here with a
4330    // funcid (BIN_EXPORT/BIN_READONLY/BIN_TYPESET/...) discriminant.
4331    // Emit a per-name event per the recorder schema.
4332    #[cfg(feature = "recorder")]
4333    if crate::recorder::is_enabled() {
4334        let ctx = crate::recorder::recorder_ctx_global();
4335        // Collect option letters from argv `-x`/`+x` flag args AND
4336        // from the BUILTIN's defopts (the auto-set option chars C
4337        // applies before dispatching bin_typeset). Without the
4338        // defopts, `integer count=42` (defopts="i"), `float pi=1.0`
4339        // (defopts="E"), and `typeset -A h=(...)` whose `A` came
4340        // from a parent shape, lost their type bits and the
4341        // recorder emitted `[scalar]` for everything.
4342        let mut letters = String::new();
4343        let mut tied_mode = false;
4344        for a in argv {
4345            if a.starts_with('-') || a.starts_with('+') {
4346                let body = &a[1..];
4347                letters.push_str(body);
4348                if body.contains('T') {
4349                    tied_mode = true;
4350                }
4351            }
4352        }
4353        // Add the option letters that defopts pre-set in `ops`.
4354        for ch in [b'i', b'E', b'F', b'A', b'a', b'r', b'x', b'g', b'l', b'u'] {
4355            if OPT_ISSET(&ops, ch) && !letters.contains(ch as char) {
4356                letters.push(ch as char);
4357            }
4358        }
4359        // Funcid-driven attr seeding: BIN_EXPORT seeds nothing
4360        // (recorder uses emit_export for those), BIN_READONLY seeds
4361        // SCALAR|READONLY. INTEGER/FLOAT/ASSOC/ARRAY now flow via
4362        // the defopts injection above into ParamAttrs::from_flag_chars.
4363        let mut attrs = crate::recorder::ParamAttrs::from_flag_chars(&letters);
4364        match func {
4365            crate::ported::builtin::BIN_READONLY => {
4366                attrs.set(crate::recorder::ParamAttrs::SCALAR);
4367                attrs.set(crate::recorder::ParamAttrs::READONLY);
4368            }
4369            _ => {}
4370        }
4371        // BIN_EXPORT routes to emit_export (different schema row).
4372        if func == crate::ported::builtin::BIN_EXPORT {
4373            for a in argv {
4374                if a == "-p" || a.starts_with('-') {
4375                    continue;
4376                }
4377                if let Some((k, v)) = a.split_once('=') {
4378                    crate::recorder::emit_export(k, Some(v), ctx.clone());
4379                } else {
4380                    crate::recorder::emit_export(a, None, ctx.clone());
4381                }
4382            }
4383        } else {
4384            // Suppress the emit when invoked as `local`/`private` inside
4385            // a function — those scope to the frame and don't merit a
4386            // top-level state-mutation row. local_scope_depth is tracked
4387            // by the executor; defer to the global locallevel_param counter.
4388            let is_locallike = matches!(name, "local" | "private");
4389            let inside_function = locallevel_param.load(std::sync::atomic::Ordering::Relaxed) > 0;
4390            if !is_locallike || !inside_function {
4391                let mut tied_seen = 0usize;
4392                for a in argv {
4393                    if a.starts_with('-') || a.starts_with('+') {
4394                        continue;
4395                    }
4396                    if tied_mode {
4397                        // For `typeset -T X Y [SEP]`, only X and Y are names.
4398                        tied_seen += 1;
4399                        if tied_seen > 2 {
4400                            break;
4401                        }
4402                    }
4403                    if let Some((k, v)) = a.split_once('=') {
4404                        crate::recorder::emit_typeset_attrs(k, Some(v), attrs, ctx.clone());
4405                    } else {
4406                        crate::recorder::emit_typeset_attrs(a, None, attrs, ctx.clone());
4407                    }
4408                }
4409            }
4410        }
4411    }
4412    let mut ops = ops.clone();
4413    let mut on: u32 = 0; // c:2661
4414    let mut off: u32 = 0; // c:2661
4415    let mut returnval: i32 = 0; // c:2664
4416    let mut printflags: i32 = PRINT_WITH_NAMESPACE; // c:2664
4417    let hasargs = !argv.is_empty(); // c:2665
4418
4419    // c:2668-2670 — POSIX bash/ksh ignore -p with args under
4420    // readonly/export.
4421    let posix = isset(POSIXBUILTINS);
4422    if (func == BIN_READONLY || func == BIN_EXPORT) && posix && hasargs {
4423        // c:2668
4424        ops.ind[b'p' as usize] = 0; // c:2670
4425    }
4426
4427    // c:2673 — `if (OPT_ISSET(ops,'f')) return bin_functions(...)`.
4428    if OPT_ISSET(&ops, b'f') {
4429        // c:2673
4430        return bin_functions(name, argv, &ops, func); // c:2673
4431    }
4432
4433    // c:2676 — POSIX readonly forces -g unless explicit +g.
4434    if func == BIN_READONLY && posix && !OPT_PLUS(&ops, b'g') {
4435        // c:2676
4436        ops.ind[b'g' as usize] = 1; // c:2677
4437    }
4438
4439    // c:2691-2706 — translate optstr letters into PM_* flag bits.
4440    let mut bit: u32 = PM_ARRAY; // c:2660
4441    for ch in TYPESET_OPTSTR.chars() {
4442        // c:2691
4443        let optval = ch as u8;
4444        if OPT_MINUS(&ops, optval) {
4445            on |= bit;
4446        }
4447        // c:2694-2695
4448        else if OPT_PLUS(&ops, optval) {
4449            off |= bit;
4450        }
4451        // c:2696-2697
4452        // c:2698-2706 — `-n` only allows readonly/upper/hideval/hide (55030).
4453        else {
4454            bit <<= 1;
4455            continue;
4456        }
4457        if OPT_MINUS(&ops, b'n') && (bit & !(PM_READONLY | PM_UPPER | PM_HIDEVAL | PM_HIDE)) != 0
4458        // c:2701
4459        {
4460            zwarnnam(name, &format!("-{} not allowed with -n", ch)); // c:2702
4461        }
4462        bit <<= 1;
4463    }
4464    // c:2708-2715 — -n / +n conflict resolution.
4465    if OPT_MINUS(&ops, b'n') {
4466        // c:2709-2711 — only readonly/upper/hideval/hide combine with -n;
4467        // anything else is a (silent) status-1 rejection (the zwarnnam
4468        // here is commented out in C — the per-bit loop above already
4469        // warned).
4470        if ((on | off) & !(PM_READONLY | PM_UPPER | PM_HIDEVAL | PM_HIDE)) != 0 {
4471            // c:2710
4472            return 1; // c:2711
4473        }
4474        on |= PM_NAMEREF; // c:2712
4475    } else if OPT_PLUS(&ops, b'n') {
4476        // c:2714
4477        off |= PM_NAMEREF; // c:2715
4478    }
4479    let roff = off; // c:2716
4480
4481    // c:2719-2740 — sanity checks: remove conflicting attrs.
4482    if (on & PM_FFLOAT) != 0 {
4483        // c:2719
4484        off |= PM_UPPER | PM_ARRAY | PM_HASHED | PM_INTEGER | PM_EFLOAT; // c:2720
4485        on &= !PM_EFLOAT; // c:2722
4486    }
4487    if (on & PM_EFLOAT) != 0 {
4488        // c:2724
4489        off |= PM_UPPER | PM_ARRAY | PM_HASHED | PM_INTEGER | PM_FFLOAT; // c:2725
4490    }
4491    if (on & PM_INTEGER) != 0 {
4492        // c:2726
4493        off |= PM_UPPER | PM_ARRAY | PM_HASHED | PM_EFLOAT | PM_FFLOAT; // c:2727
4494    }
4495    if (on & (PM_LEFT | PM_RIGHT_Z)) != 0 {
4496        // c:2731
4497        off |= PM_RIGHT_B; // c:2732
4498    }
4499    if (on & PM_RIGHT_B) != 0 {
4500        // c:2733
4501        off |= PM_LEFT | PM_RIGHT_Z; // c:2734
4502    }
4503    if (on & PM_UPPER) != 0 {
4504        off |= PM_LOWER;
4505    } // c:2735-2736
4506    if (on & PM_LOWER) != 0 {
4507        off |= PM_UPPER;
4508    } // c:2737-2738
4509    if (on & PM_HASHED) != 0 {
4510        off |= PM_ARRAY;
4511    } // c:2739-2740
4512    if (on & PM_TIED) != 0 {
4513        // c:2741
4514        off |= PM_INTEGER | PM_EFLOAT | PM_FFLOAT | PM_ARRAY | PM_HASHED; // c:2742
4515    }
4516    on &= !off; // c:2744
4517
4518    queue_signals(); // c:2746
4519
4520    // c:2748-2772 — `-p` print-mode: PRINT_POSIX_EXPORT / READONLY /
4521    // TYPESET, plus optional -p N for line-style.
4522    if OPT_ISSET(&ops, b'p') {
4523        // c:2748
4524        // c:2750 — C excludes ksh emulation from the POSIX form, which is
4525        // right for zsh's own `emulate ksh`. But the real Korn shells DO
4526        // print it: `ksh93u+m -c 'readonly RR=1; readonly -p'` answers
4527        // `readonly RR=1`, not zsh's `typeset -r RR=1`, and so does mksh.
4528        // So the DROP-INS take the POSIX branch; `--ksh --zsh` (zsh-style
4529        // emulation) keeps C's behaviour.
4530        let korn_dropin = matches!(
4531            crate::extensions::emulation_startup::personality(),
4532            crate::extensions::emulation_startup::Personality::Ksh93
4533                | crate::extensions::emulation_startup::Personality::Mksh
4534                | crate::extensions::emulation_startup::Personality::Pdksh
4535        );
4536        if posix && (!EMULATION(EMULATE_KSH) || korn_dropin) {
4537            // c:2750
4538            printflags |= match func {
4539                BIN_EXPORT => PRINT_POSIX_EXPORT,     // c:2752
4540                BIN_READONLY => PRINT_POSIX_READONLY, // c:2754
4541                _ => PRINT_TYPESET,                   // c:2756
4542            };
4543        } else {
4544            printflags |= PRINT_TYPESET; // c:2758
4545        }
4546        if OPT_HASARG(&ops, b'p') {
4547            // c:2761
4548            let arg = OPT_ARG(&ops, b'p').unwrap_or("");
4549            match arg.trim().parse::<i32>() {
4550                // c:2763
4551                Ok(1) => printflags |= PRINT_LINE, // c:2765
4552                Ok(0) => {}                        // c:2770 -p0 == -p
4553                _ => {
4554                    zwarnnam(name, &format!("bad argument to -p: {}", arg)); // c:2767
4555                    unqueue_signals();
4556                    return 1; // c:2769
4557                }
4558            }
4559        }
4560    }
4561
4562    // c:2775-2795 — no-args path: list whatever options select.
4563    if !hasargs {
4564        // c:2775
4565        if !OPT_ISSET(&ops, b'm') {
4566            // c:2779
4567            printflags &= !PRINT_WITH_NAMESPACE; // c:2780
4568        }
4569        // c:2776 — `int exclude = 0;`
4570        let mut exclude: u32 = 0; // c:2776
4571        if !OPT_ISSET(&ops, b'p') {
4572            // c:2782
4573            if (on | roff) == 0 {
4574                // c:2783
4575                printflags |= PRINT_TYPE; // c:2784
4576            }
4577            if roff != 0 || OPT_ISSET(&ops, b'+') {
4578                // c:2785
4579                printflags |= PRINT_NAMEONLY; // c:2786
4580            }
4581        } else if (printflags & (PRINT_POSIX_EXPORT | PRINT_POSIX_READONLY)) != 0 {
4582            // c:2787
4583            // c:2788-2791 —
4584            //     /*
4585            //      * For POSIX export/readonly, exclude non-scalars unless
4586            //      * explicitly requested.
4587            //      */
4588            //     exclude = (PM_ARRAY|PM_HASHED) & ~(on|roff);
4589            exclude = (PM_ARRAY | PM_HASHED) & !((on as u32) | (roff as u32)); // c:2792
4590        }
4591        // !!! BASH-MODE (no C counterpart) !!! bash lists a bare attribute query
4592        // (`declare -x`, `export`, `readonly`, `declare -i|-a|-A|-r`) in the
4593        // reusable `declare -x NAME="value"` form that `-p` selects here.
4594        if crate::dash_mode::bash_mode()
4595            && !OPT_ISSET(&ops, b'p')
4596            && !OPT_ISSET(&ops, b'm')
4597            && roff == 0
4598            && ((on as u32) != 0 || func == BIN_EXPORT || func == BIN_READONLY)
4599        {
4600            printflags |= PRINT_TYPESET;
4601        }
4602        // c:2792 — `scanhashtable(paramtab, 1, on|roff, 0, paramtab->printnode,
4603        //               printflags|(roff ? PRINT_NAMEONLY : 0));`
4604        //
4605        // Walk paramtab (sorted=1, alphabetical) filtering by on|roff
4606        // and dispatch printparamnode for each match. Previously inlined
4607        // a `println!("{}={}", k, v)` which:
4608        //   - Ignored printflags (PRINT_TYPESET, PRINT_POSIX_EXPORT,
4609        //     PRINT_POSIX_READONLY, PRINT_NAMEONLY) so `export -p` had
4610        //     zero rows, `typeset -p` skipped attribute letters, and
4611        //     `readonly -p` had no `readonly ` prefix.
4612        //   - Read pm.u_str directly so PM_INTEGER / PM_*FLOAT /
4613        //     PM_ARRAY / PM_HASHED values printed as empty.
4614        // printparamnode (params.c:6123) handles all of these.
4615        let printflags_final = printflags | if roff != 0 { PRINT_NAMEONLY } else { 0 }; // c:2792
4616                                                                                        // c:2792 scanmatchtable flags1=on|roff, flags2=0.
4617        let on_roff = (on as u32) | (roff as u32);
4618        // PM_RO_BY_DESIGN expansion: zshrs's special-param
4619        // setup (vm_helper.rs:1054+) replaces the dropped
4620        // PM_READONLY bit with PM_RO_BY_DESIGN so internal
4621        // writes still pass assignstrvalue's PM_READONLY
4622        // guard. `typeset -r` listing must match on either
4623        // bit to surface those entries. Mirrors C zsh's
4624        // PM_READONLY_SPECIAL (= PM_SPECIAL | PM_READONLY |
4625        // PM_RO_BY_DESIGN) where the scanhashtable bit-mask
4626        // implicitly matches RO_BY_DESIGN too (both bits
4627        // set on the same params in C). #97 in docs/BUGS.md.
4628        let on_roff_expanded = if (on_roff & PM_READONLY) != 0 {
4629            on_roff | PM_RO_BY_DESIGN
4630        } else {
4631            on_roff
4632        };
4633        // c:Src/module.c:1218-1219 add_autoparam — in `zsh -f`, every
4634        // autoloadable module parameter (WATCH/watch, aliases…,
4635        // widgets/keymaps, termcap/terminfo, zsh_scheduled_events) is
4636        // a real paramtab stub: a scalar whose value is the module
4637        // name, flagged PM_AUTOLOAD. scanhashtable feeds the stubs to
4638        // printparamnode which prints `undefined NAME` (pmtypes row at
4639        // Src/params.c:6011) and suppresses them under -p
4640        // (Src/params.c:6150-6155). zshrs keeps always-functional
4641        // placeholder params instead, so synthesize the stub rows here
4642        // for every not-yet-loaded module and suppress the placeholder
4643        // rows for the same names (zsh -f shows `undefined WATCH`, not
4644        // a set `WATCH`).
4645        let mut stubs: Vec<(&'static str, &'static str)> = crate::vm_helper::autoload_param_stubs();
4646        // c:Src/module.c:1218-1223 add_autoparam — the stub IS the
4647        // paramtab node in C, so `typeset -h +g WATCH` inside a function
4648        // pushes a LOCAL over it (`pm->old = stub`, Src/params.c
4649        // createparam) and the stub becomes invisible: the scan sees only
4650        // the local, and once `unset` flags that local PM_UNSET
4651        // printparamnode drops it (c:Src/params.c:6136-6148). zshrs keeps
4652        // the stub OUT of paramtab and synthesises the row here, so the
4653        // synthetic row survived the shadowing and `typeset -h +g -m '*';
4654        // unset -m '*'; typeset` still listed `undefined WATCH` /
4655        // `undefined watch` (B02typeset.ztst:38). Drop the synthetic row
4656        // for any name the paramtab currently holds at a non-zero level —
4657        // that node is the local shadow and must be listed in its place.
4658        {
4659            let tab = paramtab().read().unwrap();
4660            // A node at a non-zero level IS the shadow; a node flagged
4661            // PM_UNSET inside a function is the shadow after `unset`
4662            // (c:Src/params.c:unsetparam_pm keeps the local node and
4663            // only raises PM_UNSET, so the stub underneath stays
4664            // covered until the scope ends).
4665            let in_func = locallevel.load(Ordering::Relaxed) != 0;
4666            stubs.retain(|(n, _)| {
4667                !tab.get(*n).is_some_and(|pm| {
4668                    pm.level != 0 || (in_func && (pm.node.flags as u32 & PM_UNSET) != 0)
4669                })
4670            });
4671        }
4672        let names: Vec<String> = {
4673            let tab = paramtab().read().unwrap();
4674            let mut names: Vec<String> = tab
4675                .iter()
4676                .filter(|(k, pm)| {
4677                    let f = pm.node.flags as u32;
4678                    // c:2793 — scanhashtable passes PM_UNSET entries
4679                    // through; printparamnode (c:6133-6144) prints
4680                    // PM_DEFAULTED (declared-but-unset) ones name-only
4681                    // and drops the rest. Keep DEFAULTED here so
4682                    // `typeset -n` lists placeholder refs by name.
4683                    // c:Src/params.c:6137-6146 — an unset parameter is still shown,
4684                    // name only, by the POSIX `readonly -p` / `export -p` forms
4685                    // when it carries that attribute.
4686                    let posix_unset_shown = (printflags & (PRINT_POSIX_READONLY | PRINT_POSIX_EXPORT)) != 0
4687                        && (f & (PM_READONLY | PM_EXPORTED)) != 0;
4688                    if (f & PM_UNSET) != 0 && (f & PM_DEFAULTED) != PM_DEFAULTED && !posix_unset_shown {
4689                        return false;
4690                    }
4691                    // Unloaded-module names print as autoload stubs
4692                    // (merged below) — never as their placeholder row.
4693                    if stubs.iter().any(|(n, _)| n == k) {
4694                        return false;
4695                    }
4696                    // c:Src/builtin.c:2789 — `scanhashtable(paramtab, 1,
4697                    // on|roff, exclude, paramtab->printnode, printflags)`.
4698                    // The C scan has NO PM_HIDE exclusion: `addparamdef`
4699                    // (Src/module.c:1060-1074) inserts every zsh/parameter
4700                    // magic assoc into the REAL paramtab via createparam /
4701                    // createspecialhash, so once the module has booted zsh
4702                    // lists them (`association readonly parameters`,
4703                    // `array readonly patchars`, …) — verified against
4704                    // `zsh -f -c 'zmodload zsh/parameter; typeset -A'`.
4705                    // A blanket PM_HIDE skip used to live here for bug
4706                    // #371 (`typeset -A` listing magic-assocs in a shell
4707                    // where the module was never loaded); that case is
4708                    // already covered by the autoload-stub filter above,
4709                    // which drops zshrs's paramtab placeholder for every
4710                    // module that has not booted and prints the C
4711                    // `undefined NAME` stub instead.
4712                    // c:2794 — `scanhashtable(paramtab, 1, on|roff, exclude,
4713                    // paramtab->printnode, printflags)`. `flags2` (exclude) is
4714                    // scanhashtable's REJECT mask: c:Src/hashtable.c:394
4715                    // `if (... (flags2 && (hn->flags & flags2))) continue;`.
4716                    // c:Src/hashtable.c:394 tests the TABLE node's flags; for
4717                    // a name whose private wrap_private lifted, that node is the
4718                    // private in C (printprivatenode then prints the one
4719                    // beneath, which is the node here).
4720                    let f = crate::ported::modules::param_private::LIFTED_PRIVATES
4721                        .lock()
4722                        .ok()
4723                        .and_then(|l| l.iter().rev().find(|(n, _)| n == *k).map(|(_, fl)| *fl as u32))
4724                        .unwrap_or(f);
4725                    // !!! DASH-FAITHFUL GATE (no C counterpart) !!! dash's `readonly` listing
4726                    // holds only variables the script made readonly; zsh's read-only
4727                    // specials (`$!`, `$#`, `$$`, `LINENO`, `PPID`, ...) are not dash variables.
4728                    if func == BIN_READONLY
4729                        && (f & PM_SPECIAL) != 0
4730                        && crate::dash_mode::dash_faithful()
4731                    {
4732                        return false;
4733                    }
4734                    if exclude != 0 && (f & exclude) != 0 {
4735                        return false; // c:394
4736                    }
4737                    on_roff_expanded == 0 || (f & on_roff_expanded) != 0
4738                })
4739                .map(|(k, _)| k.clone())
4740                .collect();
4741            // Stub flags are PM_SCALAR|PM_AUTOLOAD (module.c:1218-1219)
4742            // — apply the same scanmatchtable flags1 test (PM_AUTOLOAD
4743            // is never in on|roff, so any flag filter excludes them:
4744            // `typeset +x -r` / `typeset +i` list none, matching zsh).
4745            for (n, _) in &stubs {
4746                if on_roff_expanded == 0 || (PM_AUTOLOAD & on_roff_expanded) != 0 {
4747                    names.push((*n).to_string());
4748                }
4749            }
4750            names.sort_by(|a, b| hnamcmp(a, b));
4751            names
4752        };
4753        // c:Src/params.c:6275 PM_TIED partner lookup — printparamnode
4754        // reads `paramtab` to fetch the tied peer (e.g. PATH→path).
4755        // Pre-clone the pm so the print runs WITHOUT holding any
4756        // paramtab lock; otherwise the partner-lookup inside
4757        // printparamnode would deadlock on the same write lock.
4758        // printparamnode mutates hn for the PM_TIED swap path (Bug
4759        // #410) — the mutation lands on the local clone only,
4760        // matching C's "throwaway-print-side" semantics.
4761        for k in names {
4762            // c:Src/module.c:1218-1219 — autoload stub: scalar param,
4763            // u.str = module name, PM_AUTOLOAD. printparamnode prints
4764            // `undefined NAME` (PRINT_TYPE) / `NAME` (PRINT_NAMEONLY)
4765            // and returns early under -p.
4766            if let Some((_, module)) = stubs.iter().find(|(n, _)| *n == k) {
4767                let mut stub_pm = crate::ported::zsh_h::param {
4768                    node: crate::ported::zsh_h::hashnode {
4769                        next: None,
4770                        nam: k.clone(),
4771                        flags: (PM_SCALAR | PM_AUTOLOAD) as i32, // c:1219
4772                    },
4773                    u_data: 0,
4774                    u_tied: None,
4775                    u_arr: None,
4776                    u_str: Some((*module).to_string()), // c:1218 setsparam(pnam, module)
4777                    u_val: 0,
4778                    u_dval: 0.0,
4779                    u_hash: None,
4780                    gsu_s: None,
4781                    gsu_i: None,
4782                    gsu_f: None,
4783                    gsu_a: None,
4784                    gsu_h: None,
4785                    base: 0,
4786                    width: 0,
4787                    env: None,
4788                    ename: None,
4789                    old: None,
4790                    level: 0,
4791                };
4792                printparamnode(&mut stub_pm, printflags_final); // c:2792
4793                continue;
4794            }
4795            let mut pm_clone = match paramtab().read() {
4796                Ok(tab) => match tab.get(&k) {
4797                    Some(pm) => pm.clone(),
4798                    None => continue,
4799                },
4800                Err(_) => continue,
4801            };
4802            printparamnode(&mut pm_clone, printflags_final); // c:2792
4803        }
4804        unqueue_signals();
4805        return 0; // c:2794
4806    }
4807
4808    // c:2799-2810 — `local` (or +g) implies PM_LOCAL.
4809    let nm0 = name.chars().next().unwrap_or(' ');
4810    if nm0 == 'l' || OPT_PLUS(&ops, b'g') {
4811        // c:2799
4812        on |= PM_LOCAL; // c:2800
4813    } else if crate::dash_mode::in_ksh93_posix_function() && !OPT_ISSET(&ops, b'g') {
4814        // ksh93: `name() { typeset x; }` does not localize.
4815        ops.ind[b'g' as usize] = 1;
4816    } else if !OPT_ISSET(&ops, b'g') {
4817        // c:2801
4818        if OPT_MINUS(&ops, b'x') {
4819            // c:2802
4820            let globalexport = isset(GLOBALEXPORT);
4821            let ll_v = locallevel_param.load(Relaxed);
4822            if globalexport {
4823                // c:2803
4824                ops.ind[b'g' as usize] = 1; // c:2804
4825            } else if ll_v != 0 {
4826                // c:2805
4827                on |= PM_LOCAL; // c:2806
4828            }
4829        } else if !(OPT_ISSET(&ops, b'x') || OPT_ISSET(&ops, b'm')) {
4830            // c:2808
4831            on |= PM_LOCAL; // c:2809
4832        }
4833    }
4834
4835    // c:2813+ — -T tied vars + per-arg setting loop.
4836    // The full C body has dozens of paths (PM_TIED tie-pair setup at
4837    // c:2813-2900, glob -m walk at c:2905-2935, name=value assign
4838    // through typeset_single at c:2945+). The Rust port handles the
4839    // three high-frequency paths inline: assoc creation (`PM_HASHED`
4840    // + `name=(k v k v)`), array creation (`PM_ARRAY` + `name=(a b c)`),
4841    // and scalar assignment.
4842    let _ = off;
4843    let is_hashed = (on & PM_HASHED) != 0; // c:2655 `-A`
4844    let is_array = (on & PM_ARRAY) != 0; // c:2655 `-a`
4845                                         // c:Src/builtin.c typeset_single — when the array RHS comes from
4846                                         // an unquoted `$@` / `${arr[@]}` splat (e.g. `typeset -a opts=
4847                                         // ("$@")`), the upstream prefork has already split the value
4848                                         // into separate argv entries: `["opts=(a", "b", "c)"]`. C zsh's
4849                                         // parser captures the entire `name=(... )` shape as one ENVARRAY
4850                                         // token by walking paren depth at parse time so the splat fills
4851                                         // the array's element list. zshrs's compile path emits the
4852                                         // synthetic word `opts=("$@")` then runtime DQ-strip + splat
4853                                         // separates it. Reconstruct the single arg here: when one entry
4854                                         // starts with `NAME=(` and a later entry ends with `)`, rejoin
4855                                         // the run with spaces between elements.
4856                                         // c:Src/builtin.c — paren-init rejoin. C's parser hands the whole
4857                                         // `NAME=(e1 e2 …)` shape as one ENVARRAY token via parse-time paren-
4858                                         // walking so empty elements (`""` quoted to lexer Dnull-Dnull) stay
4859                                         // as separate list nodes. zshrs's compile path emits the args
4860                                         // pre-split through prefork — each `""` produces a true empty
4861                                         // entry in argv. The previous rejoin glued them with single-space
4862                                         // separators, then split_whitespace collapsed consecutive empties
4863                                         // → key/value swap on `typeset -A h=( "" val )`. Bug #93 in
4864                                         // docs/BUGS.md.
4865                                         //
4866                                         // Use `\u{1f}` (ASCII US — unit separator) as the rejoin
4867                                         // separator. The paren-init branch below splits on `\u{1f}` AND
4868                                         // whitespace, preserving consecutive `\u{1f}` as empty elements
4869                                         // (the original quoted-empty args) while still tolerating
4870                                         // single-arg-form paren-init like `a=(1 2 3)` where no rejoin
4871                                         // ran.
4872    const REJOIN_SEP: char = '\u{1f}';
4873    let argv: Vec<String> = {
4874        let mut out: Vec<String> = Vec::with_capacity(argv.len());
4875        let mut i = 0;
4876        while i < argv.len() {
4877            let arg = &argv[i];
4878            let open = arg.find("=(");
4879            // c:Src/lex.c:1228-1249 — whether `NAME=(` opens an array is a
4880            // LEXICAL decision, taken on the RAW character after `=`:
4881            // `e = hgetc(); if (e == '(') return ENVARRAY;`. A `(` that
4882            // arrived inside quotes never reaches that test, so
4883            // `local open='(' close=')'` is two ENVSTRINGs in C.
4884            // zshrs marks the syntactic form with REJOIN_SEP — parse.rs's
4885            // ENVARRAY synthetic word and compile_zsh's
4886            // BUILTIN_TYPESET_PAREN_PACK both emit it, and `is_paren_init`
4887            // (c:2095 `ASG_ARRAYP`) already keys on it. Requiring it here
4888            // too stops a dequoted `(` VALUE from swallowing the following
4889            // words: `local open='(' close=')'` (Completion/X/Command/
4890            // _setxkbmap sh:34) stored `open` as `(<US>close=)` and never
4891            // defined `close`, so `compquote open close` failed and
4892            // `setxkbmap -` lost its whole listing.
4893            let is_open = open.is_some()
4894                && arg.contains(REJOIN_SEP)
4895                && arg
4896                    .as_bytes()
4897                    .first()
4898                    .is_some_and(|b| b.is_ascii_alphabetic() || *b == b'_')
4899                && !arg.ends_with(')');
4900            if is_open {
4901                // Find the matching `)` — scan forward through argv
4902                // tracking paren depth (the parser's `(` was just `(`
4903                // in the source). Each arg may have additional `(`
4904                // and `)` chars from quoted content.
4905                let mut depth: i32 = 0;
4906                for c in arg.chars() {
4907                    if c == '(' {
4908                        depth += 1;
4909                    } else if c == ')' {
4910                        depth -= 1;
4911                    }
4912                }
4913                let mut buf = arg.clone();
4914                let mut j = i + 1;
4915                while depth > 0 && j < argv.len() {
4916                    buf.push(REJOIN_SEP);
4917                    buf.push_str(&argv[j]);
4918                    for c in argv[j].chars() {
4919                        if c == '(' {
4920                            depth += 1;
4921                        } else if c == ')' {
4922                            depth -= 1;
4923                        }
4924                    }
4925                    j += 1;
4926                }
4927                out.push(buf);
4928                i = j;
4929            } else {
4930                out.push(arg.clone());
4931                i += 1;
4932            }
4933        }
4934        out
4935    };
4936    let argv = argv.as_slice();
4937    // c:Src/builtin.c:2813-3030 — under PM_TIED (`-T scalar array [sep]`)
4938    // the third positional is the SEPARATOR character, not a param
4939    // name. The scalar and array become a tied pair: writing the
4940    // scalar splits the joined string back into the array; reading
4941    // the scalar joins the array on the separator. Direct port of the
4942    // C body's tied-pair setup at c:2813-2940 (validation) + the
4943    // typeset_single+tiedarr_gsu wiring at c:1956 + c:2945+.
4944    //
4945    // The Rust port handles the validation / scalar-array tie + value
4946    // installation as one block here rather than threading PM_TIED
4947    // through the generic per-arg loop. Beyond index 2 under -T is a
4948    // usage error in C zsh; the Rust port mirrors the same "too many
4949    // arguments" rejection.
4950    //
4951    // Bug #24 in docs/BUGS.md.
4952    // c:2807 — `if ((on & PM_TIED) && !OPT_ISSET(ops, 'p'))`: under -p the
4953    // names are printed (or reported missing) by the per-arg loop instead.
4954    let tied_mode = (on & PM_TIED) != 0 && !OPT_ISSET(&ops, b'p');
4955    if tied_mode {
4956        // c:2818-2822 — the FIRST thing the -T block does, ahead of the
4957        // argument-count checks:
4958        //     if (OPT_ISSET(ops,'m')) {
4959        //         zwarnnam(name, "incompatible options for -T");
4960        //         unqueue_signals();
4961        //         return 1;
4962        //     }
4963        // `-m` takes its names as PATTERNS, which cannot express a tie; it was
4964        // silently accepted and did nothing.
4965        if OPT_ISSET(&ops, b'm') {
4966            zwarnnam(name, "incompatible options for -T"); // c:2819
4967            unqueue_signals(); // c:2820
4968            return 1; // c:2821
4969        }
4970        // c:2827-2830 — `if (nargs < 2)` reject.
4971        if argv.len() < 2 {
4972            zwarnnam(name, "-T requires names of scalar and array");
4973            unqueue_signals();
4974            return 1;
4975        }
4976        // c:2831-2833 — `if (nargs > 3)` reject.
4977        if argv.len() > 3 {
4978            zwarnnam(name, "too many arguments for -T");
4979            unqueue_signals();
4980            return 1;
4981        }
4982        // First arg: SCALAR name (with optional =value). c:2838-2840
4983        // `if (ASG_ARRAYP(&asg0))` — first arg can NOT be an array
4984        // assign; we accept either no-value or =scalar.
4985        let (sname, sval_opt): (&str, Option<String>) = match argv[0].find('=') {
4986            Some(i) => (&argv[0][..i], Some(argv[0][i + 1..].to_string())),
4987            None => (argv[0].as_str(), None),
4988        };
4989        // c:2838-2846 —
4990        //     asg0 = *asg;
4991        //     if (ASG_ARRAYP(&asg0)) {
4992        //         zwarnnam(name, "first argument of tie must be scalar: %s",
4993        //                  asg0.name);
4994        //         return 1;
4995        //     }
4996        // ASG_ARRAYP is the parser's flag for an unquoted `NAME=(`. zshrs
4997        // carries it as the `\u{1f}` REJOIN_SEP that compile_zsh's
4998        // BUILTIN_TYPESET_PAREN_PACK / BUILTIN_TYPESET_PAREN_CLOSE put inside
4999        // the paren body (`S=(\u{1f}a\u{1f}b\u{1f})`, empty `S=(\u{1f})`);
5000        // a quoted `"S=(a b)"` is a scalar assignment of the text `(a b)` and
5001        // carries no marker (same test as `is_paren_init` in the per-arg loop).
5002        if let Some(v) = &sval_opt {
5003            if v.starts_with('(') && v.ends_with(')') && v.len() >= 2 && v.contains('\u{1f}') {
5004                unqueue_signals(); // c:2842
5005                zwarnnam(
5006                    name,
5007                    &format!("first argument of tie must be scalar: {}", sname),
5008                ); // c:2843
5009                return 1; // c:2845
5010            }
5011        }
5012        // Second arg: ARRAY name (with optional =(elements...) init).
5013        // Per c:2847-2854, second arg must be array-shape if it carries
5014        // a value. The Rust port accepts either `arr` or `arr=(a b c)`.
5015        let (aname, aval_opt): (&str, Option<Vec<String>>) = {
5016            let a = argv[1].as_str();
5017            if let Some(eq_idx) = a.find("=(") {
5018                let aname = &a[..eq_idx];
5019                // Strip `=(` prefix and `)` suffix. The parser emits
5020                // array elements separated by REJOIN_SEP (`\u{1f}`)
5021                // sentinels — see parse.rs:2444 commentary. Empty
5022                // strings between sentinels are intentional (zsh's
5023                // assoc paren-init relies on them, bug #93).
5024                let rest = &a[eq_idx + 2..];
5025                let inner = rest.trim_end_matches(')');
5026                let parts: Vec<String> = if inner.contains('\u{1f}') {
5027                    // Only the boundary empties from the `=(` / `)` argv
5028                    // fragments are dropped: a `[k]=` triad's empty VALUE
5029                    // (c:Src/subst.c:77) is an element and must keep its
5030                    // slot, as in the generic array-init path below.
5031                    let mut v: Vec<String> = inner.split('\u{1f}').map(String::from).collect();
5032                    if v.first().is_some_and(|s| s.is_empty()) {
5033                        v.remove(0);
5034                    }
5035                    if v.last().is_some_and(|s| s.is_empty()) {
5036                        v.pop();
5037                    }
5038                    v
5039                } else {
5040                    // Fallback: whitespace split for synthetic args
5041                    // that didn't pass through the REJOIN_SEP emitter.
5042                    inner.split_whitespace().map(|s| s.to_string()).collect()
5043                };
5044                (aname, Some(parts))
5045            } else if let Some(eq_idx) = a.find('=') {
5046                // `arr=val` (scalar form for array slot) — reject per
5047                // c:2849-2854 "second argument of tie must be array".
5048                let _ = eq_idx;
5049                zwarnnam(
5050                    name,
5051                    &format!("second argument of tie must be array: {}", a),
5052                );
5053                unqueue_signals();
5054                return 1;
5055            } else {
5056                (a, None)
5057            }
5058        };
5059        // c:2857-2860 — `can't tie a variable to itself`.
5060        if sname == aname {
5061            zerrnam(name, &format!("can't tie a variable to itself: {}", sname));
5062            unqueue_signals();
5063            return 1;
5064        }
5065        // c:2861-2864 — `can't tie array elements` (subscripted name).
5066        if sname.contains('[') || aname.contains('[') {
5067            zerrnam(name, &format!("can't tie array elements: {}", sname));
5068            unqueue_signals();
5069            return 1;
5070        }
5071        // c:2866-2870 — `only one tied parameter can have value`.
5072        if sval_opt.is_some() && aval_opt.is_some() {
5073            zerrnam(
5074                name,
5075                &format!("only one tied parameter can have value: {}", sname),
5076            );
5077            unqueue_signals();
5078            return 1;
5079        }
5080        // c:2876-2898 — joinchar parse. Third argument, if given, is
5081        // character used to join the elements of the array in the
5082        // scalar.
5083        let joinchar: i32 = if argv.len() == 3 {
5084            // c:2884 `joinstr = nextasg->name;` — a metafied byte reaches
5085            // zshrs as the CHAR Meta followed by the char of `byte ^ 32`, so
5086            // the Meta test is on chars: the first UTF-8 byte of U+0083 is
5087            // 0xc2, never 0x83, and the old byte test could not fire.
5088            let joinstr = &argv[2];
5089            let mut cs = joinstr.chars();
5090            match (cs.next(), cs.next()) {
5091                (None, _) => 0, // c:2889-2890 `else if (!*joinstr) joinchar = 0;`
5092                (Some(m), Some(n)) if m as u32 == Meta as u32 => {
5093                    ((n as u32) ^ 32) as i32 // c:2891-2892 `joinchar = joinstr[1] ^ 32;`
5094                }
5095                _ => joinstr.as_bytes()[0] as i32, // c:2893-2894 `joinchar = *joinstr;`
5096            }
5097        } else {
5098            ':' as i32 // c:2891-2892 `if (!joinstr) joinchar = ':';`
5099        };
5100        // c:Src/params.c:4320-4332 tiedarrsetfn — the separator is the join
5101        // character, metafied when it is imeta; the same bytes join the array
5102        // back in tiedarrgetfn (c:4303-4304 zjoin). NUL is imeta in C (`Meta
5103        // ' '`), but zshrs keeps NUL unmetafied inside values, so it stays raw.
5104        let joinsep: String = match joinchar as u8 {
5105            0 => "\0".to_string(),
5106            b if crate::ported::utils::imeta_byte(b) => [char::from(Meta), char::from(b ^ 32)].iter().collect(),
5107            b => char::from(b).to_string(),
5108        };
5109
5110        // c:Src/builtin.c:2940-2944 — when the scalar already exists
5111        // and has a value, save it for seeding the tied array:
5112        //   `oldval = ztrdup(getsparam(asg0.name));`
5113        // The Rust port checks paramtab AND the environ inherited at
5114        // startup so `typeset -T PATH path :` over an env-imported
5115        // PATH seeds `path` from the live PATH instead of clobbering
5116        // it with empty.
5117        // c:2900-2901 — `pm = getnode(asg0.name); apm = getnode(asg->name);`
5118        // then c:2903-2943 decides between erroring, re-tying, and updating an
5119        // EXISTING tie. None of that block was ported: the code below simply
5120        // installed a fresh pair every time, which silently accepted three
5121        // cases C rejects and lost every attribute the new command line didn't
5122        // restate.
5123        let (pm_flags, pm_ename, pm_level) = paramtab()
5124            .read()
5125            .ok()
5126            .and_then(|t| {
5127                t.get(sname)
5128                    .map(|p| (Some(p.node.flags as u32), p.ename.clone(), p.level))
5129            })
5130            .unwrap_or((None, None, 0));
5131        let (apm_flags, apm_ename) = paramtab()
5132            .read()
5133            .ok()
5134            .and_then(|t| {
5135                t.get(aname)
5136                    .map(|p| (Some(p.node.flags as u32), p.ename.clone()))
5137            })
5138            .unwrap_or((None, None));
5139        let mut already_tied = false;
5140
5141        if let Some(sf) =
5142            pm_flags.filter(|f| (f & (PM_SPECIAL | PM_TIED)) == (PM_SPECIAL | PM_TIED))
5143        {
5144            let _ = sf;
5145            // c:2903-2918 — a SPECIAL tied scalar (PATH/path). Only a re-tie of
5146            // the identical special pair is allowed.
5147            let apm_special = apm_flags.map(|f| (f & PM_SPECIAL) != 0).unwrap_or(false);
5148            if pm_ename.as_deref() != Some(aname) || !apm_special {
5149                // c:2908-2911
5150                zwarnnam(
5151                    name,
5152                    &format!(
5153                        "{} special parameter can only be tied to special parameter {}",
5154                        sname,
5155                        pm_ename.as_deref().unwrap_or("")
5156                    ),
5157                );
5158                unqueue_signals();
5159                return 1;
5160            }
5161            if joinchar != ':' as i32 {
5162                // c:2913-2917
5163                zwarnnam(
5164                    name,
5165                    "cannot change the join character of special tied parameters",
5166                );
5167                unqueue_signals();
5168                return 1;
5169            }
5170            already_tied = true; // c:2918
5171        } else if let Some(af) =
5172            apm_flags.filter(|f| (f & (PM_SPECIAL | PM_TIED)) == (PM_SPECIAL | PM_TIED))
5173        {
5174            let _ = af;
5175            // c:2919-2929 — the ARRAY half is special+tied: catches tying it to
5176            // a different scalar, or to one that is no longer special.
5177            zwarnnam(
5178                name,
5179                &format!(
5180                    "{} special parameter can only be tied to special parameter {}",
5181                    aname,
5182                    apm_ename.as_deref().unwrap_or("")
5183                ),
5184            );
5185            unqueue_signals();
5186            return 1;
5187        } else if let Some(sf) = pm_flags {
5188            // c:2930-2932 — only consider an existing scalar that is actually
5189            // live in this scope.
5190            if ((sf & PM_UNSET) == 0 || (sf & PM_DECLARED) != 0)
5191                && (locallevel_param.load(Relaxed) as i32 == pm_level
5192                    || (on as u32 & PM_LOCAL) == 0)
5193            {
5194                if (sf & PM_TIED) != 0 {
5195                    if PM_TYPE(sf) != PM_SCALAR {
5196                        // c:2934-2937
5197                        zwarnnam(name, &format!("already tied as non-scalar: {sname}"));
5198                        unqueue_signals();
5199                        return 1;
5200                    } else if pm_ename.as_deref() == Some(aname) {
5201                        already_tied = true; // c:2937
5202                    } else {
5203                        // c:2939-2942
5204                        zwarnnam(name, &format!("can't tie already tied scalar: {sname}"));
5205                        unqueue_signals();
5206                        return 1;
5207                    }
5208                }
5209                // else: not tied — only the export attribute is inherited
5210                // (c:2951), which `inherited_export` below already does.
5211            }
5212        }
5213
5214        if already_tied {
5215            // c:2308 — `tdp->joinchar != joinchar` compares against the
5216            // separator the pair was tied with; capture it before the
5217            // attribute update below overwrites it.
5218            let old_joinchar: Option<i32> = paramtab()
5219                .read()
5220                .ok()
5221                .and_then(|t| t.get(sname).and_then(|p| p.u_tied.as_deref().map(|td| td.joinchar)));
5222            // c:2957-2973 — C does NOT rebuild the pair here. It runs
5223            // typeset_single on each half "if only to update the attributes of
5224            // both, and of course to set the new value if one is provided",
5225            // then returns:
5226            //     typeset_single(..., pm,  on,                        off,             ...)
5227            //     typeset_single(..., apm, (on | PM_ARRAY) & ~PM_EXPORTED, off & ~PM_ARRAY, ...)
5228            // Applying on/off to the EXISTING entries is precisely what lets an
5229            // attribute the current command doesn't restate survive — so
5230            // `typeset -UT TS ts; typeset -T TS ts` keeps its uniqueness, and
5231            // likewise readonly. Rebuilding dropped everything but export.
5232            //
5233            // typeset_single itself is present in this file but is dead code
5234            // (nothing calls it; bin_typeset carries its own inline logic), so
5235            // the attribute update is applied directly here rather than routed
5236            // through it.
5237            if let Ok(mut tab) = paramtab().write() {
5238                if let Some(p) = tab.get_mut(sname) {
5239                    p.node.flags = ((p.node.flags as u32 | on as u32) & !(off as u32)) as i32;
5240                    // c:2967 — typeset_single takes `joinchar`, so a re-tie
5241                    // RE-STATES the separator: "It is possible to apply -T to
5242                    // two previously tied variables but with a different
5243                    // separator character, in which case the variables remain
5244                    // joined as before but the separator is changed."
5245                    // (Doc/Zsh/builtins.yo, -T.) Updating flags alone left
5246                    // `typeset -T TS ts; typeset -T TS ts ,` joining on ':'.
5247                    if let Some(td) = p.u_tied.as_mut() {
5248                        td.joinchar = joinchar; // c:2967
5249                    }
5250                }
5251                if let Some(p) = tab.get_mut(aname) {
5252                    let aon = (on as u32 | PM_ARRAY) & !PM_EXPORTED; // c:2970
5253                    let aoff = off as u32 & !PM_ARRAY; // c:2971
5254                    p.node.flags = ((p.node.flags as u32 | aon) & !aoff) as i32;
5255                }
5256            }
5257            // c:2254-2273 (typeset_single on the scalar half) — `-U` dedupes the
5258            // ARRAY peer in place:
5259            //     if ((on & PM_UNIQUE) && !(pm->node.flags & PM_READONLY & ~off)) {
5260            //         ... else if (PM_TYPE(pm->node.flags) == PM_SCALAR && pm->ename &&
5261            //                      (apm = paramtab->getnode(paramtab, pm->ename))) {
5262            //             x = (*apm->gsu.a->getfn)(apm);
5263            //             uniqarray(x);
5264            // Stamping the flag alone left `typeset -UT MANPATH manpath` at `/ /`.
5265            if (on as u32 & PM_UNIQUE) != 0 && (pm_flags.unwrap_or(0) & PM_READONLY & !(off as u32)) == 0 {
5266                if let Some(x) = crate::ported::exec::array(aname) {
5267                    let x = crate::ported::params::uniqarray(x); // c:2270
5268                    crate::ported::params::assignaparam(aname, x, 0);
5269                }
5270            }
5271            // c:2305-2317 — re-tying with a different join character and no
5272            // scalar value re-splits the scalar through the new separator:
5273            //     if (tdp->joinchar != joinchar && !asg->value.scalar) {
5274            //         tdp->joinchar = joinchar;
5275            //         if (!(pm = assignsparam(pname, ztrdup(getsparam(pname)), 0)))
5276            //             return NULL;
5277            //     }
5278            // getsparam there is tiedarrgetfn, which joins the array with the
5279            // NEW character (c:Src/params.c:4303-4304), and tiedarrsetfn splits
5280            // on it again. Special pairs never get here with a new character
5281            // (c:2909-2913), and C skips them (`altpm && !(pm->node.flags &
5282            // PM_SPECIAL)`); they carry no tieddata, so `old_joinchar` is None.
5283            if old_joinchar.is_some_and(|o| o != joinchar) && sval_opt.is_none() {
5284                let joined = crate::ported::exec::array(aname).unwrap_or_default().join(&joinsep);
5285                crate::ported::params::assignsparam(sname, &joined, 0); // c:2314
5286            }
5287            // Values, if the command line carried any (c:2963 "and of course to
5288            // set the new value if one is provided for either of them").
5289            if let Some(sval) = sval_opt.as_deref() {
5290                crate::ported::params::assignsparam(sname, sval, 0);
5291            } else if let Some(arr) = aval_opt {
5292                // c:3019-3020 — `int flags = (asg->flags & ASG_KEY_VALUE) ?
5293                // ASSPM_KEY_VALUE : 0;`. The compiler marks the `[k]=v` form
5294                // with a Marker element (c:Src/subst.c:72).
5295                let flags = if arr.iter().any(|e| e.starts_with(crate::ported::zsh_h::Marker)) {
5296                    crate::ported::zsh_h::ASSPM_KEY_VALUE
5297                } else {
5298                    0
5299                };
5300                crate::ported::params::assignaparam(aname, arr, flags);
5301            }
5302            unqueue_signals();
5303            return 0;
5304        }
5305
5306        let existing_scalar: Option<String> = {
5307            let from_tab = paramtab()
5308                .read()
5309                .ok()
5310                .and_then(|t| t.get(sname).and_then(|p| p.u_str.clone()));
5311            from_tab.or_else(|| std::env::var(sname).ok())
5312        };
5313
5314        // Build the initial array value: prefer the array RHS, then
5315        // the scalar RHS (split on joinchar), then the existing
5316        // scalar's env value (split on joinchar). Mirrors C's sequence
5317        // at c:2960-3030 where typeset_single is called on both names;
5318        // if the scalar had a value it gets passed to tiedarrsetfn
5319        // which splits on the tieddata joinchar (c:4370-4381 sepsplit).
5320        let rhs_given = aval_opt.is_some() || sval_opt.is_some();
5321        let init_arr: Vec<String> = if let Some(arr) = aval_opt {
5322            arr
5323        } else if let Some(sval) = sval_opt.as_deref() {
5324            crate::ported::utils::sepsplit(sval, Some(&joinsep), true)
5325        } else if let Some(old) = existing_scalar
5326            .as_deref()
5327            // c:2930-2951 — `oldval = ztrdup(getsparam(asg0.name))` is taken
5328            // only for a scalar that is live (`!(PM_UNSET) || PM_DECLARED`)
5329            // in the scope the tie is declared in:
5330            //     (locallevel == pm->level || !(on & PM_LOCAL))
5331            // and is neither tied already nor an array/hash. A declaration
5332            // that SHADOWS an outer binding starts empty
5333            // (`V=plain; f(){ local -T V v; print $V }` prints nothing), while
5334            // `f(){ local V=a:b; typeset -T V v }` keeps `a:b` because the
5335            // scalar already lives at this level.
5336            .filter(|_| {
5337                pm_flags.is_some_and(|sf| {
5338                    ((sf & PM_UNSET) == 0 || (sf & PM_DECLARED) != 0)
5339                        && (locallevel_param.load(Relaxed) as i32 == pm_level
5340                            || (on as u32 & PM_LOCAL) == 0)
5341                        && (sf & PM_TIED) == 0
5342                        && (PM_TYPE(sf) & (PM_ARRAY | PM_HASHED)) == 0
5343                })
5344            })
5345        {
5346            if old.is_empty() {
5347                Vec::new()
5348            } else {
5349                crate::ported::utils::sepsplit(old, Some(&joinsep), true)
5350            }
5351        } else {
5352            Vec::new()
5353        };
5354        // c:2983-2989 — the array half is created with an EMPTY value
5355        // (`asg2.value.array = (LinkList)0`), and the initial value is only
5356        // stored afterwards through the normal assignment path, c:3018-3025:
5357        //     if (asg->value.array)
5358        //         assignaparam(asg->name, zlinklist2array(asg->value.array, 1), flags);
5359        //     else if (asg0.value.scalar || oldval)
5360        //         assignsparam(asg0.name, oldval, 0);   /* via tiedarrsetfn */
5361        // That path is what applies the attributes the halves were just given,
5362        // most visibly PM_UNIQUE: `typeset -T -U P p=(a b a)` yields `a:b`.
5363        // Writing `init_arr` straight into both halves skipped it. The scalar
5364        // spellings are replayed as the array `init_arr` already split them
5365        // into, which is what tiedarrsetfn's sepsplit would produce.
5366        let assign_initial = rhs_given || !init_arr.is_empty();
5367
5368        // c:2984-2989 / c:2998-3005 — each half is made by typeset_single,
5369        // whose createparam arm takes a name only when
5370        //     (isident(pname) || paramtab->getnode(paramtab, pname))
5371        //         && (!idigit(*pname) || !strcmp(pname, "0"))
5372        // and otherwise zerrnam's "not an identifier" (leading digit) or
5373        // "not valid in this context" (c:2514-2547). The array half goes
5374        // first; when the scalar half then fails, C unsets the array it just
5375        // made (c:3001 `unsetparam_pm(apm, 1, 1)`).
5376        for (half, is_scalar) in [(aname, false), (sname, true)] {
5377            let exists = paramtab().read().map(|t| t.contains_key(half)).unwrap_or(false);
5378            let lead_digit = half.as_bytes().first().is_some_and(|b| b.is_ascii_digit());
5379            if !((isident(half) || exists) && (!lead_digit || half == "0")) {
5380                if lead_digit {
5381                    zerrnam(name, &format!("not an identifier: {}", half)); // c:2544
5382                } else {
5383                    zerrnam(name, &format!("not valid in this context: {}", half)); // c:2546
5384                }
5385                if is_scalar {
5386                    unsetparam(aname); // c:3001
5387                }
5388                unqueue_signals();
5389                return 1; // c:2991 / c:3003
5390            }
5391        }
5392
5393        // Install the array side first (matching C c:2980 "Do it
5394        // first because we need the address"). Build a plain
5395        // PM_ARRAY|PM_TIED param.
5396        // c:Src/builtin.c:2986-2999 — the array side is created with
5397        // `(on | PM_ARRAY) & ~PM_EXPORTED` and the scalar side with the full
5398        // `on`. So `export -T FOO foo` / `readonly -T` carry those attribute
5399        // flags onto the pair (the array is NOT export-flagged; the scalar
5400        // is). Previously both were hardcoded to `…|PM_TIED`, so `export -T`
5401        // left FOO unexported — `${(t)FOO}` read `scalar-tied` and
5402        // `typeset -p FOO` printed `typeset -T` instead of `export -T`.
5403        //
5404        // c:Src/builtin.c:2953 — `on |= (pm->node.flags & ~roff) & PM_EXPORTED;`
5405        //
5406        //   "Variable already exists in the current scope but is not tied.
5407        //    We're preserving its value and export attribute but no other
5408        //    attributes upon converting to 'tied'."
5409        //
5410        // So tying an ALREADY-EXPORTED scalar keeps it exported, even without
5411        // `-x`: `export E=a:b; typeset -T E e` leaves E in the environment
5412        // (${(t)E} == scalar-tied-export). zshrs built the tie attributes from
5413        // the command-line flags alone and silently dropped the export, which
5414        // takes the variable OUT of the environment of every later child.
5415        let inherited_export: u32 = paramtab()
5416            .read()
5417            .ok()
5418            .and_then(|t| t.get(sname).map(|p| p.node.flags as u32))
5419            .unwrap_or(0)
5420            & PM_EXPORTED
5421            & !(off as u32); // c:2953 `& ~roff` — an explicit +x still wins
5422                             // c:2989 / c:3003 — C hands BOTH halves the full `on`:
5423                             //     apm = typeset_single(..., (on | PM_ARRAY) & ~PM_EXPORTED, ...)
5424                             //     pm  = typeset_single(..., on, ...)
5425                             // so every attribute the user asked for lands on the tie, not just the
5426                             // export/readonly pair. Masking down to PM_EXPORTED|PM_READONLY silently
5427                             // dropped the rest — most visibly PM_UNIQUE, so `typeset -UT B b` tied
5428                             // the two halves but never deduped either (`${(t)b}` read `array-tied`
5429                             // where zsh reads `array-tied-unique`), while the equivalent spelled as
5430                             // two commands (`typeset -T B b; typeset -U b`) worked. Carrying `on`
5431                             // whole is safe here: c:2742-2744 has already stripped the type bits
5432                             // from it (`if (on & PM_TIED) off |= PM_INTEGER|…|PM_HASHED; on &= ~off`).
5433        let tie_attr: u32 = on as u32 | inherited_export;
5434        let mut apm = param::default();
5435        apm.node.nam = aname.to_string();
5436        apm.node.flags = ((PM_ARRAY | PM_TIED) | (tie_attr & !PM_EXPORTED)) as i32;
5437        apm.u_arr = Some(if assign_initial { Vec::new() } else { init_arr.clone() }); // c:2984
5438        apm.ename = Some(sname.to_string());
5439        apm.level = locallevel.load(Relaxed) as i32;
5440        // c:2982-2989 — `tdp = (Tieddata) zalloc(sizeof *tdp);
5441        // tdp->joinchar = joinchar;` — the tieddata rides on the
5442        // SCALAR side's pm->u.data (c:2566 `tdp->joinchar = joinchar`).
5443        let tdp = crate::ported::zsh_h::tieddata {
5444            arrptr: None,
5445            joinchar,
5446        };
5447
5448        // Install the scalar side with PM_TIED + gsu_s wired to the
5449        // tied-via-ename getters/setters so reads/writes propagate
5450        // through paramtab to apm.u_arr.
5451        let mut spm = param::default();
5452        spm.node.nam = sname.to_string();
5453        spm.node.flags = ((PM_SCALAR | PM_TIED) | tie_attr) as i32; // c:2999 scalar carries full `on`
5454        spm.ename = Some(aname.to_string());
5455        spm.u_str = Some(if assign_initial { String::new() } else { init_arr.join(&joinsep) });
5456        spm.u_tied = Some(Box::new(tdp));
5457        spm.level = locallevel.load(Relaxed) as i32;
5458        // c:Src/builtin.c:1956 — `static const struct gsu_scalar
5459        // tiedarr_gsu = { tiedarrgetfn, tiedarrsetfn, tiedarrunsetfn };`
5460        // The scalar side of a tied pair routes its reads/writes
5461        // through these so the partner array stays in sync. C's
5462        // tiedarrgetfn returns the joined string; tiedarrsetfn splits
5463        // and writes through `*dptr->arrptr`. The Rust port adapts
5464        // those to paramtab lookups via `pm.ename` (see params.rs
5465        // commentary). The C tiedarrgetfn returns `char*` (joined
5466        // scalar); the Rust signature returns `Vec<String>` because
5467        // the existing typing — adapt with a closure that joins.
5468        fn tied_scalar_getfn_shim(pm: &param) -> String {
5469            // c:4352 — `zjoin(*dptr->arrptr, (unsigned char)
5470            // dptr->joinchar, 1)`; joinchar lives on the tieddata
5471            // riding pm->u.data (Rust: typed u_tied view).
5472            // c:Src/params.c:4303-4304 + c:4320-4332 — the join bytes are the
5473            // metafied join character (raw NUL in zshrs, see bin_typeset above).
5474            let sep = match pm.u_tied.as_deref() {
5475                Some(td) => match td.joinchar as u8 {
5476                    0 => "\0".to_string(),
5477                    b if crate::ported::utils::imeta_byte(b) => [char::from(crate::ported::zsh_h::Meta), char::from(b ^ 32)].iter().collect(),
5478                    b => char::from(b).to_string(),
5479                },
5480                None => ":".to_string(),
5481            };
5482            crate::ported::params::tiedarrgetfn(pm).join(&sep)
5483        }
5484        fn tied_scalar_setfn_shim(pm: &mut param, val: String) {
5485            crate::ported::params::tiedarrsetfn(pm, Some(val))
5486        }
5487        fn tied_scalar_unsetfn_shim(pm: &mut param, exp: i32) {
5488            crate::ported::params::tiedarrunsetfn(pm, exp)
5489        }
5490        spm.gsu_s = Some(Box::new(crate::ported::zsh_h::gsu_scalar {
5491            getfn: tied_scalar_getfn_shim,
5492            setfn: tied_scalar_setfn_shim,
5493            unsetfn: tied_scalar_unsetfn_shim,
5494        }));
5495
5496        if let Ok(mut tab) = paramtab().write() {
5497            // c:Src/params.c:1137 — when a declaration SHADOWS an existing
5498            // binding, `createparam` keeps the displaced one as `pm->old` so
5499            // `endparamscope` can put it back. This path builds both halves
5500            // from `param::default()` (`old: None`) and inserted them straight
5501            // over the top, so `local -T V v` over an existing `typeset -T V v`
5502            // DESTROYED the outer pair: after the function returned `$V` was
5503            // empty, `${(t)V}` reported nothing at all, and every later
5504            // `V=…` stopped updating `v` — the global tie was gone for the rest
5505            // of the shell's life. docs/BUGS.md #1039 C.
5506            //
5507            // Only chain when this declaration is actually creating a shadow at
5508            // a DEEPER level: re-tying at the same level legitimately replaces
5509            // the binding (and `already_tied` above handles the same-partner
5510            // case), so an unconditional chain would build a bogus restore
5511            // stack for plain global re-ties.
5512            let cur_ll = locallevel.load(Relaxed) as i32;
5513            let mut apm = apm;
5514            let mut spm = spm;
5515            if tab.get(aname).map(|p| p.level < cur_ll).unwrap_or(false) {
5516                apm.old = tab.remove(aname); // c:1137 `pm->old = oldpm`
5517            }
5518            if tab.get(sname).map(|p| p.level < cur_ll).unwrap_or(false) {
5519                spm.old = tab.remove(sname); // c:1137
5520            }
5521            tab.insert(aname.to_string(), Box::new(apm));
5522            tab.insert(sname.to_string(), Box::new(spm));
5523        }
5524        // c:3018-3025 — store the initial value now that both halves exist,
5525        // with c:3019's ASSPM_KEY_VALUE when the array value carries a
5526        // `[k]=v` Marker triad (c:Src/subst.c:72).
5527        if assign_initial {
5528            let flags = if init_arr.iter().any(|e| e.starts_with(crate::ported::zsh_h::Marker)) {
5529                crate::ported::zsh_h::ASSPM_KEY_VALUE
5530            } else {
5531                0
5532            };
5533            let _ = crate::ported::params::assignaparam(aname, init_arr, flags); // c:3020
5534        }
5535        unqueue_signals();
5536        return 0;
5537    }
5538
5539    // c:Src/builtin.c:3042-3098 — `-m PATTERN` (or `+m PATTERN`) treats
5540    // each arg as a glob, not a `name[=value]` pair.
5541    //
5542    // C splits into TWO subpaths:
5543    //   +m: c:3068 `scanmatchtable(..., paramtab->printnode, printflags)`
5544    //       — direct paramtab walk that prints PRINT_TYPE | PRINT_NAMEONLY
5545    //         (built up at c:3051-3054 when on|roff == 0).
5546    //   -m: c:3081-3094 build pmlist, then run `typeset_single` per
5547    //       match. typeset_single's print path (c:2240-2247) uses
5548    //         - PRINT_TYPESET with `-p`
5549    //         - PRINT_INCLUDEVALUE otherwise, gated on
5550    //             `!OPT_ISSET(ops,'g') && (unset(TYPESETSILENT) ||
5551    //              OPT_ISSET(ops,'m'))`
5552    //
5553    // Earlier zshrs collapsed both into one `printparamnode(...,
5554    // PRINT_INCLUDEVALUE)` call which:
5555    //   - Made `+m` emit `name=value` instead of `name` / `integer NAME`.
5556    //   - Ignored `-g` print suppression so `typeset -gm 'A*'` spammed
5557    //     every match (zsh is silent: `-g` blocks the print). Hit
5558    //     during zinit's plugin loader which runs `typeset -gm`
5559    //     against the plugin's exported-var set on every load.
5560    //     Bug #48 in docs/BUGS.md.
5561    if OPT_ISSET(&ops, b'm') && !argv.is_empty() && !TYPESET_M_APPLY.with(|c| c.get()) {
5562        // c:3043-3055 — printflags for the +m direct-scan path.
5563        if !OPT_ISSET(&ops, b'p') {
5564            // c:3044-3050 — mass-changing types is fatal for namerefs.
5565            if (on & PM_NAMEREF) != 0 && OPT_MINUS(&ops, b'm') {
5566                zerrnam(name, "-m not allowed with -n"); // c:3048
5567                unqueue_signals();
5568                return 1; // c:3049
5569            }
5570            if (on | roff) == 0 {
5571                printflags |= PRINT_TYPE; // c:3052
5572            }
5573            if on == 0 {
5574                printflags |= PRINT_NAMEONLY; // c:3054
5575            }
5576        }
5577        // c:2241 — typeset_single's `-m` print path always sets
5578        // PRINT_WITH_NAMESPACE so dot-prefixed names (`.cd`) emit
5579        // instead of being filtered at printparamnode's namespace gate.
5580        let single_flags: i32 = if OPT_ISSET(&ops, b'p') {
5581            PRINT_TYPESET | PRINT_WITH_NAMESPACE
5582        } else {
5583            PRINT_INCLUDEVALUE | PRINT_WITH_NAMESPACE
5584        };
5585        // c:2244 — `else if (!OPT_ISSET(ops,'g') && (unset(TYPESETSILENT)
5586        // || OPT_ISSET(ops,'m')))` — typeset_single suppresses
5587        // PRINT_INCLUDEVALUE when `-g` is set. `-p` bypasses (c:2242).
5588        let do_minus_print = OPT_ISSET(&ops, b'p')
5589            || (!OPT_ISSET(&ops, b'g') && (!isset(TYPESETSILENT) || OPT_ISSET(&ops, b'm')));
5590        for pattern in argv.iter() {
5591            // c:3053 + c:3057 — `while ((asg = getasg(&argv, assigns)))` splits
5592            // each arg into `asg->name` / `asg->value` BEFORE the pattern is
5593            // tokenized (`tokenize(asg->name)`), so `typeset -m 'x*'=val`
5594            // globs `x*` and hands `val` to typeset_single for every match.
5595            // The whole arg used to be glob-compiled, so the `=val` half was
5596            // part of the pattern and matched nothing.
5597            let (pattern, m_value): (&str, Option<&str>) = match pattern.find('=') {
5598                Some(i) => (&pattern[..i], Some(&pattern[i + 1..])),
5599                None => (pattern.as_str(), None),
5600            };
5601            // c:3064 — `OPT_PLUS(ops,'m') && !ASG_VALUEP(asg)` is the gate for
5602            // the direct scanmatchtable print; everything else runs the
5603            // typeset_single loop.
5604            let is_plus_m = OPT_PLUS(&ops, b'm') && m_value.is_none();
5605            // c:3061 — `patcompile(asg->name, 0, NULL)` glob-compile.
5606            // Use the canonical pattern.rs port. On compile failure,
5607            // emit "bad pattern" and continue to the next arg.
5608            let pat = crate::ported::pattern::patcompile(
5609                &{
5610                    let mut __pat_tok = crate::pattern_data_escape::escape_data_backslashes(&pattern); // a backslash tokenize leaves raw is DATA (c:Src/glob.c:3651)
5611                    crate::ported::glob::tokenize(&mut __pat_tok);
5612                    __pat_tok
5613                },
5614                crate::ported::zsh_h::PAT_HEAPDUP as i32,
5615                None,
5616            );
5617            let pat = match pat {
5618                Some(p) => p,
5619                None => {
5620                    zwarnnam(name, &format!("bad pattern: {}", pattern));
5621                    returnval = 1;
5622                    continue;
5623                }
5624            };
5625            // c:3068 — `scanmatchtable(paramtab, pprog, 1, on|roff,
5626            // 0, paramtab->printnode, printflags);` — walk paramtab
5627            // entries whose name matches the pattern AND whose flag
5628            // bits intersect on|roff.
5629            //
5630            // c:3073-3080 — C collects the matches into `pmlist`
5631            // FIRST and only then walks it, because the per-entry work
5632            // can reorganise paramtab underneath the scan. zshrs needs
5633            // the same two-phase shape for a second, harder reason:
5634            // the table lock must be RELEASED before any entry is
5635            // touched. `printparamnode` re-enters `paramtab().read()`
5636            // on several paths —
5637            //   params.rs:12622  PM_TIED peer lookup (the `+m` /
5638            //                    PRINT_TYPE arm),
5639            //   params.rs:12303 + 12312, 12009, 12038  PM_SPECIAL /
5640            //                    empty-scalar / zero-integer value
5641            //                    probes via `getsparam` (→
5642            //                    params.rs:5353 and
5643            //                    `lookup_special_var` at 13494 /
5644            //                    13547 / 13554 / 13640 / 13669),
5645            //   params.rs:9365   `tiedarrgetfn`, reached through the
5646            //                    tied-scalar `gsu_s` getfn shim
5647            //                    (builtin.rs:4671).
5648            // `paramtab()` is a plain `std::sync::RwLock`, which is
5649            // NOT reentrant: acquiring the read half while this thread
5650            // already holds the write half parks the thread forever.
5651            // The previous code held `paramtab().write()` across
5652            // `printparamnode`, so `typeset -m PAT` hung the shell the
5653            // moment PAT matched any param that takes one of those
5654            // paths (`HISTCHARS`, `HOME`, `IFS`, `TERM`, `WORDCHARS`,
5655            // `USERNAME`, `SECONDS`, `TIMEFMT`, any tied scalar, and
5656            // every empty PM_SPECIAL scalar / zero-valued special
5657            // integer). It surfaced as a pattern-COUNT threshold
5658            // (`typeset -m` over ~19+ `${(k)parameters}` names hung
5659            // while 18 returned) only because the list is walked in
5660            // name order and the first such name sits around there;
5661            // the real trigger is the name, not the count.
5662            // Pre-cloning is the rule the `typeset -p NAME` path
5663            // already documents at builtin.rs:5156-5159.
5664            let matched: Vec<crate::ported::zsh_h::Param> = {
5665                let tab = paramtab().read().unwrap();
5666                let on_roff = (on as u32) | (roff as u32);
5667                let mut matched: Vec<crate::ported::zsh_h::Param> = tab
5668                    .iter()
5669                    .filter(|(k, pm)| {
5670                        let f = pm.node.flags as u32;
5671                        // c:3078-3079 — `if (pm->node.flags & PM_UNSET)
5672                        // continue;` lives ONLY in the `-m` collection loop.
5673                        // The `+m` path (c:3064-3067) is a bare
5674                        // `scanmatchtable(..., paramtab->printnode, ...)` with
5675                        // no such test, so a DECLARED-but-unset param still
5676                        // lists there: under TYPESET_TO_UNSET zsh prints
5677                        // `local hide scalar_test` for `private scalar_test;
5678                        // typeset +m scalar_test` (V10private "basic scope
5679                        // hiding"), where zshrs printed nothing.
5680                        if !is_plus_m && (f & PM_UNSET) != 0 {
5681                            return false;
5682                        }
5683                        // c:Src/builtin.c:3055-3094 / hashtable.c:373-440
5684                        // — the `-m`/`+m` PATTERN path scans paramtab
5685                        // via `scanmatchtable(paramtab, pprog, ...)`.
5686                        // scanmatchtable matches ONLY on flags1/flags2
5687                        // + the pattern; it does NOT exclude PM_HIDE.
5688                        // An explicit pattern therefore REVEALS hidden
5689                        // params — verified vs `/opt/homebrew/bin/zsh`:
5690                        //   `typeset +m 'a*'` → `undefined aliases` +
5691                        //   `array argv`; `typeset -m 'a*'` → `aliases`
5692                        //   + `argv=(  )`. The magic-assoc placeholders
5693                        //   (PM_HIDE|PM_HIDEVAL, inserted by
5694                        //   init_partab_params) and `private` vars
5695                        //   (PM_HIDE, no PM_HIDEVAL — c:Modules/
5696                        //   param_private.c:174) must BOTH appear here,
5697                        //   unlike the no-args list path (c:2762-2792)
5698                        //   where scanhashtable's bare scan keeps them
5699                        //   hidden (bug #371). So NO PM_HIDE filter on
5700                        //   this PATTERN path — V10private: `private
5701                        //   x=5; typeset +m x` → `local x`.
5702                        // c:3064-3066 — the `flags1 = on|roff` argument is
5703                        // scanmatchtable's, i.e. the `+m` path ONLY. C's `-m`
5704                        // collection loop (c:3075-3082) tests nothing but
5705                        // `pattry`: those flags are what typeset_single is
5706                        // about to APPLY, not a filter on what to match.
5707                        // Filtering here made every MUTATING `-m` match
5708                        // nothing — `typeset -h +g -m [[:alpha:]_]*`
5709                        // (B02typeset's stress test) localized no parameter at
5710                        // all, so the `unset -m [[:alpha:]_]*` that follows hit
5711                        // the still-global readonly LINENO and aborted the
5712                        // chunk before its harness marker was printed.
5713                        if is_plus_m && on_roff != 0 && (f & on_roff) == 0 {
5714                            return false;
5715                        }
5716                        crate::ported::pattern::pattry(&pat, k)
5717                    })
5718                    // c:3087 `addlinknode(pmlist, pm)` — the match list.
5719                    .map(|(_, pm)| pm.clone())
5720                    .collect();
5721                // c:3065-3066 — only `+m` goes through scanmatchtable with
5722                // `sorted = 1`; the `-m` loop (c:3083-3091) walks the
5723                // buckets, so its matches stay in hash-table order.
5724                if is_plus_m {
5725                    matched.sort_by(|a, b| hnamcmp(&a.node.nam, &b.node.nam));
5726                }
5727                matched
5728            };
5729            // The table guard is dropped here, before any print.
5730            if is_plus_m {
5731                // c:3068-3070 — `+m`: direct print using the
5732                // PRINT_TYPE | PRINT_NAMEONLY flags built above.
5733                for mut pm in matched {
5734                    printparamnode(&mut pm, printflags);
5735                }
5736                continue;
5737            }
5738            // c:3084-3090 — `-m`: run typeset_single on every match:
5739            //     for (pmnode = firstnode(pmlist); pmnode; incnode(pmnode)) {
5740            //         pm = (Param) getdata(pmnode);
5741            //         if (!typeset_single(name, pm->node.nam, pm, func, on, off,
5742            //                             roff, asg, NULL, ops, 0))
5743            //             returnval = 1;
5744            //     }
5745            // zshrs's typeset_single equivalent is bin_typeset's OWN per-arg
5746            // loop (the typeset_single port in this file is unreachable — see
5747            // the note in the `already_tied` block), so the match list is fed
5748            // back through bin_typeset as LITERAL names. TYPESET_M_APPLY
5749            // suppresses this `-m` block on that re-entry so the very same
5750            // `ops` is re-derived into the very same `on`/`off`/`roff` and the
5751            // same c:2244 print gate. Only the pure-listing shape (`!on &&
5752            // !roff && !ASG_VALUEP(asg)`, c:2237) stays on the print path
5753            // below, which is where typeset_single returns early anyway.
5754            if (on | roff) != 0 || m_value.is_some() {
5755                // c:3090 — the typeset_single loop runs once per match; with
5756                // none it does nothing. Re-entering bin_typeset with an empty
5757                // name list would instead reach its no-argument LIST path and
5758                // print every parameter carrying the flags.
5759                if matched.is_empty() {
5760                    continue;
5761                }
5762                let names: Vec<String> = matched
5763                    .iter()
5764                    .map(|pm| match m_value {
5765                        Some(v) => format!("{}={}", pm.node.nam, v), // c:3086 asg
5766                        None => pm.node.nam.clone(),                 // c:3085 pm->node.nam
5767                    })
5768                    .collect();
5769                let prev = TYPESET_M_APPLY.with(|c| c.replace(true));
5770                let rc = bin_typeset(name, &names, &ops, func); // c:3086 typeset_single
5771                TYPESET_M_APPLY.with(|c| c.set(prev));
5772                if rc != 0 {
5773                    returnval = 1; // c:3089
5774                }
5775                continue;
5776            }
5777            if do_minus_print {
5778                // c:3090-3095 — walk the pre-built match list. Clones,
5779                // per the lock note above; printparamnode's writes into
5780                // `hn` (the u_str seed at params.rs:12312, the PM_TIED
5781                // value swap at 12639) are display-local scratch that C
5782                // performs on a reassigned local pointer, never on the
5783                // stored node, so printing the clone is also the more
5784                // faithful shape.
5785                for mut pm in matched {
5786                    printparamnode(&mut pm, single_flags);
5787                }
5788            }
5789        }
5790        unqueue_signals();
5791        return returnval;
5792    }
5793
5794    // c:3035-3039 — `+T` is not a way to untie:
5795    //     if (off & PM_TIED) {
5796    //         unqueue_signals();
5797    //         zerrnam(name, "use unset to remove tied variables");
5798    //         return 1;
5799    //     }
5800    // C puts this at the TOP LEVEL, so it precedes everything typeset_single
5801    // does — notably the special-parameter type-change rule, whose `chflags`
5802    // watches PM_TIED and would otherwise claim `typeset +T PATH path` and
5803    // report "can't change type of a special parameter" instead. The check was
5804    // missing entirely, so `+T` was silently accepted and did nothing.
5805    if (off as u32 & PM_TIED) != 0 {
5806        unqueue_signals(); // c:3036
5807        zerrnam(name, "use unset to remove tied variables"); // c:3037
5808        return 1; // c:3038
5809    }
5810
5811    let mut tied_name_count: usize = 0;
5812    for arg in argv {
5813        // c:Src/builtin.c typeset_single — when PM_LOCAL is in
5814        // flags, createparam first to install pm.old chain at
5815        // locallevel (createparam c:1132-1147). Applies uniformly
5816        // to all forms: `local x`, `local x=v`, `local arr=(...)`,
5817        // `local -A h`. endparamscope unwinds via Param.old.
5818        if tied_mode {
5819            tied_name_count += 1;
5820            if tied_name_count > 2 {
5821                // Separator / extra args — don't run name
5822                // validation or createparam against them. The scalar/array
5823                // pair itself was installed by the `tied_mode` block above.
5824                continue;
5825            }
5826        }
5827        let arg_name: &str = match arg.find('=') {
5828            Some(i) => &arg[..i],
5829            None => arg.as_str(),
5830        };
5831
5832        // c:3100-3113 — `-p` is decided FIRST for every assignment, value or
5833        // not, before typeset_single (and so before its name validation and
5834        // type-change logic) runs:
5835        //     if (OPT_ISSET(ops,'p')) {
5836        //         if (hn) paramtab->printnode(hn, printflags);
5837        //         else { zwarnnam(name, "no such variable: %s", asg->name);
5838        //                returnval = 1; }
5839        //         continue;
5840        //     }
5841        // `typeset -p s=1` prints (or reports) s and assigns nothing.
5842        if OPT_ISSET(&ops, b'p') {
5843            // !!! BASH-MODE GATE (no C counterpart) !!! `declare -p` of a bash
5844            // synthesized special array (PIPESTATUS / FUNCNAME / BASH_VERSINFO)
5845            // — these live outside paramtab, so the normal lookup below can't
5846            // find them. Emit the bash reusable form (BASH_VERSINFO is a
5847            // readonly array).
5848            if crate::dash_mode::bash_mode() {
5849                if let Some(vals) = crate::dash_mode::bash_special_array(arg_name) {
5850                    let esc = |s: &str| -> String {
5851                        let mut o = String::with_capacity(s.len());
5852                        for c in s.chars() {
5853                            if matches!(c, '"' | '\\' | '$' | '`') {
5854                                o.push('\\');
5855                            }
5856                            o.push(c);
5857                        }
5858                        o
5859                    };
5860                    let flags = if arg_name == "BASH_VERSINFO" {
5861                        "-ar"
5862                    } else {
5863                        "-a"
5864                    };
5865                    let body: String = vals
5866                        .iter()
5867                        .enumerate()
5868                        .map(|(i, v)| format!("[{}]=\"{}\"", i, esc(v)))
5869                        .collect::<Vec<_>>()
5870                        .join(" ");
5871                    println!("declare {} {}=({})", flags, arg_name, body);
5872                    continue;
5873                }
5874            }
5875            // c:Src/Modules/param_private.c:678 — with zsh/param/private
5876            // loaded, `realparamtab->getnode` IS `getprivatenode`, so
5877            // c:Src/builtin.c:3109's `paramtab->getnode(paramtab, pname)`
5878            // returns NULL for a private declared in an OUTER scope. Without
5879            // the walk, `typeset -p array_test` inside a function called from
5880            // the declaring one found the private node, then printparamnode
5881            // dropped it silently at its PM_RO_BY_DESIGN level test
5882            // (c:Src/params.c:6157-6162) — no output AND no error, where zsh
5883            // prints `typeset: no such variable: array_test` and exits 1
5884            // (V10private.ztst:15). SAFETY: read-only `pm->old` walk under
5885            // the read guard held for the lookup.
5886            let existed = paramtab()
5887                .read()
5888                .map(|t| {
5889                    t.get(arg_name).is_some_and(|pm| {
5890                        !crate::ported::modules::param_private::getprivatenode(
5891                            &**pm as *const crate::ported::zsh_h::param,
5892                        )
5893                        .is_null() // c:3109 getnode hook
5894                    })
5895                })
5896                .unwrap_or(false);
5897            if existed {
5898                // c:Src/params.c:6275 — printparamnode looks up the
5899                // PM_TIED peer via paramtab; we must NOT hold the lock
5900                // when calling. Bug #410 — pre-clone pattern (mirrors
5901                // the bin_typeset `-m PAT` arm refactor).
5902                let mut pm_clone = match paramtab().read() {
5903                    Ok(tab) => tab.get(arg_name).cloned(),
5904                    Err(_) => None,
5905                };
5906                if let Some(ref mut pm) = pm_clone {
5907                    // c:3104 — `paramtab->printnode(hn, printflags);` with
5908                    // the flags bin_typeset accumulated: PRINT_WITH_NAMESPACE
5909                    // (c:2664, kept because there are arguments), PRINT_TYPESET
5910                    // or a POSIX variant (c:2750-2758), PRINT_LINE for -p1
5911                    // (c:2765). With PRINT_WITH_NAMESPACE a `.ns.name` prints.
5912                    printparamnode(pm, printflags);
5913                }
5914            } else if crate::extensions::dash_mode::korn_mode() {
5915                // ksh93 and mksh both list nothing for an unset name and exit 0
5916                // (`typeset -p nosuch; print $?` -> 0, nothing on stderr).
5917            } else {
5918                // c:Src/builtin.c:3110-3113 — when `typeset -p NAME`
5919                // and the param doesn't exist, emit
5920                // `typeset: no such variable: NAME` and set
5921                // returnval=1 so the builtin's exit status is 1.
5922                zwarnnam(name, &format!("no such variable: {}", arg_name));
5923                returnval = 1; // c:Src/builtin.c:3112
5924            }
5925            continue;
5926        }
5927
5928        // c:2117-2193 (inside typeset_single) — changing the TYPE of an
5929        // existing parameter is refused for specials, with SECONDS the one
5930        // documented exception.
5931        //
5932        //     int chflags = ((off & pm->node.flags) | (on & ~pm->node.flags)) &
5933        //          (PM_INTEGER|PM_EFLOAT|PM_FFLOAT|PM_HASHED|PM_ARRAY|PM_TIED|PM_AUTOLOAD);
5934        //     /* keep the parameter if just switching between floating types */
5935        //     if ((tc = chflags && chflags != (PM_EFLOAT|PM_FFLOAT))) { ... }
5936        //     ...
5937        //     if (... || tc) {
5938        //         if (pm->node.flags & PM_SPECIAL) {
5939        //             int err = 1;
5940        //             if (!readonly && !strcmp(pname, "SECONDS")) {
5941        //                 ... else if (!setsecondstype(pm, on, off)) { ... err = 0; }
5942        //             }
5943        //             if (err) { zerrnam(cname, "%s: can't change type of a "
5944        //                                "special parameter", pname); return NULL; }
5945        //
5946        // None of this was ported, so a type change against a special was
5947        // silently ACCEPTED and then dropped: `typeset -F RANDOM`,
5948        // `typeset -F LINENO` and `typeset -F HISTSIZE` all returned 0 having
5949        // done nothing, where zsh fails. And the SECONDS exception — the
5950        // documented way to get sub-second timing — never took effect:
5951        // `typeset -F SECONDS` left it `integer-special` instead of
5952        // `float-special`.
5953        //
5954        // `chflags` is why this fires only on a REAL type change:
5955        // `typeset -i SECONDS` / `typeset -i LINENO` leave chflags == 0 (the
5956        // bit is already set) and are correctly no-ops, and -E↔-F is exempted
5957        // outright by the `!= (PM_EFLOAT|PM_FFLOAT)` clause. It also covers
5958        // `typeset -a PATH`, which is a PM_ARRAY type change on a special and
5959        // so earns the same message rather than an array-specific one.
5960        //
5961        // The `readonly` half of C's guard is not modelled here: it gates
5962        // turning readonly OFF, which reaches this branch by a different route.
5963        // setsecondstype (params.rs) is the real port and had NO callers.
5964        if !tied_mode {
5965            let pm_info = paramtab()
5966                .read()
5967                .ok()
5968                .and_then(|t| t.get(arg_name).map(|p| (p.node.flags as u32, p.level)));
5969            if let Some((pmf, pm_level)) = pm_info {
5970                let chflags = ((off as u32 & pmf) | (on as u32 & !pmf))
5971                    & (PM_INTEGER
5972                        | PM_EFLOAT
5973                        | PM_FFLOAT
5974                        | PM_HASHED
5975                        | PM_ARRAY
5976                        | PM_NAMEREF
5977                        | PM_TIED
5978                        | PM_AUTOLOAD); // c:2115-2117 (PM_NAMEREF since 53776)
5979                let tc = chflags != 0 && chflags != (PM_EFLOAT | PM_FFLOAT); // c:2122
5980                                                                             // c:2078-2091 — a `local` at a DEEPER scope than the existing
5981                                                                             // param does not CHANGE that param's type; it SHADOWS it with a
5982                                                                             // fresh local (createparam installs the pm.old chain, restored by
5983                                                                             // endparamscope). Only a SAME-scope re-typeset of a special is the
5984                                                                             // forbidden type change. The port applied the special type-change
5985                                                                             // error against the higher-scope param unconditionally, so
5986                                                                             // `local -a commands` (HASHED command-hash special → local ARRAY)
5987                                                                             // inside a function wrongly errored "can't change type of a
5988                                                                             // special parameter" and aborted — e.g. `_openssl_subcommands`'s
5989                                                                             // `local -a commands=(…)`, leaving `openssl <TAB>` empty. zsh
5990                                                                             // allows the shadow in a function (errors only at same scope /
5991                                                                             // top level). Mirror the reuse-decision guard used at c:2078-2091.
5992                let creating_local_shadow =
5993                    (on as u32 & PM_LOCAL) != 0 && pm_level != locallevel_param.load(Relaxed);
5994                // c:2084-2086 — the shadow of a special stays special
5995                // (`newspecial = NS_NORMAL`) unless -h is given on either side,
5996                // and c:2114 then still compares types: `local -i HOME` is
5997                // refused, `local -h -i HOME` makes a plain local.
5998                let newspecial = creating_local_shadow
5999                    && (pmf & PM_SPECIAL) != 0
6000                    && (on as u32 & PM_HIDE) == 0
6001                    && (pmf & PM_HIDE & !(off as u32)) == 0;
6002                if tc && (pmf & PM_SPECIAL) != 0 && (!creating_local_shadow || newspecial) {
6003                    let mut err = true; // c:2144
6004                    if arg_name == "SECONDS" {
6005                        // c:2171 — `else if (!setsecondstype(pm, on, off))`.
6006                        let ok = paramtab()
6007                            .write()
6008                            .ok()
6009                            .and_then(|mut t| {
6010                                t.get_mut(arg_name).map(|p| {
6011                                    crate::ported::params::setsecondstype(p, on as i32, off as i32)
6012                                })
6013                            })
6014                            .unwrap_or(1);
6015                        if ok == 0 {
6016                            err = false; // c:2176
6017                                         // c:2172-2175 — `if (asg->value.scalar && !(pm =
6018                                         // assignsparam(pname, ..., 0))) return NULL;`
6019                            if let Some(i) = arg.find('=') {
6020                                crate::ported::params::assignsparam(arg_name, &arg[i + 1..], 0);
6021                            }
6022                        }
6023                    }
6024                    if err {
6025                        // c:2181-2187
6026                        if !OPT_ISSET(&ops, b'p') {
6027                            zerrnam(
6028                                name,
6029                                &format!("{arg_name}: can't change type of a special parameter"),
6030                            );
6031                        }
6032                        unqueue_signals();
6033                        return 1;
6034                    }
6035                    // SECONDS: setsecondstype already installed the new type;
6036                    // c:2169's `tc = 0` skips the normal conversion below.
6037                    continue;
6038                }
6039                if tc && (pmf & PM_AUTOLOAD) != 0 && !creating_local_shadow {
6040                    // c:2185-2190 — a parameter whose module is not loaded yet
6041                    // may turn out special, so its type cannot change either.
6042                    if !OPT_ISSET(&ops, b'p') {
6043                        zerrnam(
6044                            name,
6045                            &format!("{arg_name}: can't change type of autoloaded parameter"),
6046                        );
6047                    }
6048                    returnval = 1;
6049                    continue; // c:2189 return NULL
6050                }
6051            }
6052        }
6053
6054        // c:2519-2552 (Src/builtin.c, inside typeset_single) — name
6055        // validation gate. Direct port:
6056        //   else if ((isident(pname) || paramtab->getnode(paramtab, pname))
6057        //            && (!idigit(*pname) || !strcmp(pname, "0"))) {
6058        //       /* proceed */
6059        //   } else {
6060        //       if (idigit(*pname))
6061        //           zerrnam(cname, "not an identifier: %s", pname);
6062        //       else
6063        //           zerrnam(cname, "not valid in this context: %s", pname);
6064        //       return NULL;
6065        //   }
6066        //
6067        // The C function returns NULL on failure; the outer bin_typeset
6068        // name loop continues to the next arg (errflag silences
6069        // subsequent zerr calls so we won't double-emit). Mirror that
6070        // here with `continue`.
6071        let pname_in_tab = paramtab()
6072            .read()
6073            .map(|t| t.get(arg_name).is_some())
6074            .unwrap_or(false);
6075        // c:2062-2064 — `usepm = pm && (!(pm->node.flags & PM_UNSET)
6076        // || ...)`: snapshot BEFORE any createparam this iteration
6077        // runs (the PM_LOCAL shadow block and the pre-assign type
6078        // stamps both create fresh pms). Only the reuse-existing-pm
6079        // branch of typeset_single carries the c:2336 `if (errflag)
6080        // return NULL` post-assign check; the fresh-param path
6081        // (c:2577+ createparam → c:2604 assignsparam) has NO errflag
6082        // check — `typeset -i x=3#8` zerrs the math but bin_typeset
6083        // still returns 0 (zsh 5.9 exit 0, lastval untouched).
6084        // 55020 adds `|| (pm->node.flags & PM_DECLARED)` (c:2058-2059): a
6085        // declared-but-unset parameter (TYPESET_TO_UNSET) is reused too.
6086        let usepm_existing = paramtab()
6087            .read()
6088            .map(|t| {
6089                t.get(arg_name).is_some_and(|pm| {
6090                    (pm.node.flags as u32 & PM_UNSET) == 0
6091                        || (pm.node.flags as u32 & PM_DECLARED) != 0
6092                })
6093            })
6094            .unwrap_or(false);
6095        // c:Src/builtin.c:2078 — snapshot the EXISTING param's local
6096        // level BEFORE this arg's createparam/assign runs. The bare-name
6097        // print decision (c:2244, inlined at ~5962) needs the pre-state
6098        // level to tell "re-declare same-level local" (usepm kept →
6099        // print) from "localize a higher-level/global var" (usepm=0 →
6100        // no print). Sentinel -1 when the param doesn't pre-exist.
6101        // c:Src/builtin.c:2070-2071 — `if (!usepm && pm && (pm->node.flags &
6102        // PM_SPECIAL)) usepm = 2;`: an UNSET special (a `private` declared
6103        // under TYPESET_TO_UNSET carries PM_SPECIAL from makeprivate,
6104        // Src/Modules/param_private.c:174) is still re-used for the type
6105        // comparison, so `private -a a; local a=s` is "inconsistent type".
6106        let special_unset_existing = paramtab()
6107            .read()
6108            .map(|t| {
6109                t.get(arg_name).is_some_and(|pm| {
6110                    let f = pm.node.flags as u32;
6111                    (f & PM_UNSET) != 0 && (f & PM_SPECIAL) != 0
6112                })
6113            })
6114            .unwrap_or(false);
6115        let pm_level_existing: i32 = paramtab()
6116            .read()
6117            .ok()
6118            .and_then(|t| t.get(arg_name).map(|pm| pm.level))
6119            .unwrap_or(-1);
6120        // c:2212-2222 (55030) — `-h` on a special or autoloadable parameter
6121        // that is reused at this level (not localized: c:2075-2088 drops
6122        // usepm then) is an error, where it used to be a silent no-op.
6123        {
6124            let cur_ll = locallevel.load(Relaxed) as i32;
6125            let pm_flags = paramtab()
6126                .read()
6127                .ok()
6128                .and_then(|t| t.get(arg_name).map(|pm| pm.node.flags as u32));
6129            let usepm_c = (usepm_existing || pm_flags.is_some_and(|f| (f & PM_SPECIAL) != 0)) // c:2058-2068
6130                && !(pm_level_existing != cur_ll && (on as u32 & PM_LOCAL) != 0); // c:2075-2088
6131            if usepm_c
6132                && (on as u32 & PM_HIDE) != 0
6133                && pm_flags.is_some_and(|f| (f & (PM_SPECIAL | PM_AUTOLOAD)) != 0)
6134                && pm_level_existing <= cur_ll
6135            {
6136                zerrnam(name, &format!("{}: can't change parameter attribute", arg_name)); // c:2219
6137                returnval = 1;
6138                continue; // c:2220 return NULL
6139            }
6140        }
6141        let first_is_digit = arg_name
6142            .as_bytes()
6143            .first()
6144            .is_some_and(|b| b.is_ascii_digit());
6145        let pname_valid =
6146            (isident(arg_name) || pname_in_tab) && (!first_is_digit || arg_name == "0");
6147        if !pname_valid {
6148            if first_is_digit {
6149                zerrnam(
6150                    name, // c:2548
6151                    &format!("not an identifier: {}", arg_name),
6152                );
6153            } else {
6154                zerrnam(
6155                    name, // c:2550
6156                    &format!("not valid in this context: {}", arg_name),
6157                );
6158            }
6159            // c:3153-3156 — `if (!typeset_single(...)) returnval = 1;`
6160            // — the NULL return from the validation gate makes
6161            // bin_typeset's per-arg loop record failure.
6162            returnval = 1;
6163            continue; // c:2551 return NULL
6164        }
6165
6166        // c:1961-1995 typeset_setbase — the `-i N` / `-E N` / `-F N`
6167        // argument is validated by every typeset_single arm that reaches it
6168        // (c:2294-2296 reuse, c:2444-2446 type change, c:2534-2538 new
6169        // param), before the value is assigned:
6170        //     int base = (int)zstrtol(arg, &eptr, 10);
6171        //     if (*eptr) { zwarnnam(name, "bad base value: %s" /
6172        //                  "bad precision value: %s", arg); return 1; }
6173        //     if ((on & PM_INTEGER) && (base < 2 || base > 36)) {
6174        //         zwarnnam(name, "invalid base (must be 2 to 36 inclusive): %d",
6175        //                  base); return 1; }
6176        // The check depends only on the options, so it runs once here, ahead
6177        // of the arms' mutations. On failure the reuse arm (usepm: the param
6178        // keeps its type) returns NULL with the old value intact; the type-
6179        // change arm has already deleted the old param (c:2378) and the new-
6180        // param arm calls `unsetparam_pm(pm, 0, 1)` (c:2536) — so outside
6181        // the reuse arm the name ends up unset.
6182        {
6183            let setbase_arg: Option<&str> = if (on as u32 & PM_INTEGER) != 0
6184                && OPT_HASARG(&ops, b'i')
6185            {
6186                OPT_ARG(&ops, b'i') // c:1966-1967
6187            } else if (on as u32 & PM_EFLOAT) != 0 && OPT_HASARG(&ops, b'E') {
6188                OPT_ARG(&ops, b'E') // c:1968-1969
6189            } else if (on as u32 & PM_FFLOAT) != 0 && OPT_HASARG(&ops, b'F') {
6190                OPT_ARG(&ops, b'F') // c:1970-1971
6191            } else {
6192                None
6193            };
6194            let setbase_err = setbase_arg.and_then(|a| {
6195                let (base, rest) = crate::ported::utils::zstrtol(a, 10); // c:1976
6196                if !rest.is_empty() {
6197                    // c:1977-1983
6198                    Some(if (on as u32 & PM_INTEGER) != 0 {
6199                        format!("bad base value: {}", a)
6200                    } else {
6201                        format!("bad precision value: {}", a)
6202                    })
6203                } else if (on as u32 & PM_INTEGER) != 0 && !(2..=36).contains(&(base as i32)) {
6204                    // c:1984-1988
6205                    Some(format!("invalid base (must be 2 to 36 inclusive): {}", base as i32))
6206                } else {
6207                    None
6208                }
6209            });
6210            if let Some(msg) = setbase_err {
6211                zwarnnam(name, &msg);
6212                // c:2114-2118 — chflags/tc decide reuse vs recreate.
6213                let keeps_type = paramtab()
6214                    .read()
6215                    .ok()
6216                    .and_then(|t| {
6217                        t.get(arg_name).map(|pm| {
6218                            let f = pm.node.flags as u32;
6219                            let chflags = ((off as u32 & f) | (on as u32 & !f))
6220                                & (PM_INTEGER
6221                                    | PM_EFLOAT
6222                                    | PM_FFLOAT
6223                                    | PM_HASHED
6224                                    | PM_ARRAY
6225                                    | PM_TIED
6226                                    | PM_AUTOLOAD);
6227                            !(chflags != 0 && chflags != (PM_EFLOAT | PM_FFLOAT))
6228                        })
6229                    })
6230                    .unwrap_or(false);
6231                if usepm_existing && keeps_type {
6232                    // c:2285-2288 — the reuse arm has already merged the
6233                    // attributes when typeset_setbase fails, so `float x=3;
6234                    // typeset -F 1x x` leaves x a -F float.
6235                    if let Ok(mut tab) = paramtab().write() {
6236                        if let Some(pm) = tab.get_mut(arg_name) {
6237                            pm.node.flags = (pm.node.flags | (on as i32 & !(PM_READONLY as i32)))
6238                                & !(off as i32 | PM_UNSET as i32);
6239                        }
6240                    }
6241                } else if pname_in_tab {
6242                    unsetparam(arg_name); // c:2378 / c:2536
6243                }
6244                returnval = 1; // c:3153-3156 typeset_single NULL
6245                continue;
6246            }
6247        }
6248
6249        // c:3117-3150 — `typeset -n NAME[=refname]` arm.
6250        if (on as u32 & PM_NAMEREF) != 0 {
6251            // c:Src/builtin.c:3117-3150 — the `-n` literal-name arm,
6252            // INLINE in C's bin_typeset (no separate C function; the
6253            // former vm_helper::typeset_nameref_arg helper relocated
6254            // here 2026-06-12 per the no-fake-fns-in-ported rule).
6255            // Combined with typeset_single's nameref pieces:
6256            // subscripted-name reject (c:2452-2456), read-only
6257            // reference guard (c:2249-2256), fresh-start unset
6258            // (c:3127-3141), scalar→nameref conversion carrying the
6259            // value as refname (c:3132-3135), creation + refname
6260            // assignment (assignsparam → PM_NAMEREF arm → setscope).
6261            let nameref_arm = |on: u32, off: u32| -> i32 {
6262                let value: Option<&str> = arg.find('=').map(|i| &arg[i + 1..]);
6263
6264                // c:2452-2456 — `typeset -n ptr[1]=...` is invalid.
6265                if arg_name.contains('[') {
6266                    zerrnam(
6267                        name,
6268                        &format!("{}: reference variable cannot be an array", arg_name),
6269                    );
6270                    unqueue_signals();
6271                    return 1;
6272                }
6273
6274                let cur_ll = locallevel_param.load(Relaxed) as i32;
6275                let existing = paramtab().read().ok().and_then(|t| {
6276                    t.get(arg_name)
6277                        .map(|pm| (pm.node.flags as u32, pm.level, pm.u_str.clone()))
6278                });
6279
6280                let mut carried_value: Option<String> = value.map(String::from);
6281                let mut reuse_existing = false;
6282                // c:2359-2363 — converting a readonly NON-reference to a
6283                // reference is a type change (`tc`, PM_NAMEREF is in chflags):
6284                // the readonly bit is carried over (`on |= ~off & PM_READONLY &
6285                // pm->node.flags`) and the old parameter recreated.
6286                let mut carry_readonly = false;
6287
6288                let existing_level: Option<i32> = existing.as_ref().map(|(_, l, _)| *l);
6289                if let Some((eflags, elevel, estr)) = existing {
6290                    if (eflags & PM_READONLY) != 0 && (eflags & PM_NAMEREF) == 0 {
6291                        carry_readonly = (off & PM_READONLY) == 0; // c:2361
6292                    }
6293                    // c:2249-2256 — read-only guard (typeset_single). Fires when
6294                    // the existing pm is readonly, +r wasn't given, and either the
6295                    // nameref-ness changes or a nameref gets a new value.
6296                    // Only a reference reaches it: for a non-reference the -n is a
6297                    // type change, which clears usepm (c:2119-2126) first.
6298                    if (eflags & PM_READONLY) != 0
6299                        && (eflags & PM_NAMEREF) != 0
6300                        && (off & PM_READONLY) == 0
6301                        && !OPT_ISSET(&ops, b'p')
6302                    {
6303                        let kind = if (eflags & PM_NAMEREF) != 0 {
6304                            "reference"
6305                        } else {
6306                            "variable"
6307                        };
6308                        zerrnam(name, &format!("{}: read-only {}", arg_name, kind)); // c:2254
6309                        unqueue_signals();
6310                        return 1; // c:2256
6311                    }
6312                    // c:3127-3141 — namerefs always start over fresh.
6313                    if elevel >= cur_ll || ((on & PM_LOCAL) == 0 && elevel < cur_ll) {
6314                        // c:3132-3135 — converting a scalar: its value becomes
6315                        // the refname.
6316                        if carried_value.is_none()
6317                            && PM_TYPE(eflags) == PM_SCALAR
6318                            && estr.as_deref().map(|s| !s.is_empty()).unwrap_or(false)
6319                        {
6320                            carried_value = estr.clone();
6321                        }
6322                        // c:3136-3140 — `if (!(hn->flags & PM_READONLY)) {
6323                        //   unsetparam_pm(oldpm, 0, 1); hn = NULL; }` — only a
6324                        // non-readonly ref starts over fresh; a readonly one is
6325                        // KEPT and handed to typeset_single (c:3148-3149), where
6326                        // `typeset +r -n ref[=val]` clears the flag in place and
6327                        // any =val ASSIGNS THROUGH the surviving refname.
6328                        if (eflags & (PM_READONLY | PM_NAMEREF)) == (PM_READONLY | PM_NAMEREF) {
6329                            reuse_existing = true;
6330                        } else if let Some(mut old) =
6331                            paramtab().write().ok().and_then(|mut t| t.remove(arg_name))
6332                        {
6333                            // keep the shadowed outer binding for re-chaining
6334                            if let Some(prev) = old.old.take() {
6335                                if let Ok(mut tab) = paramtab().write() {
6336                                    tab.insert(arg_name.to_string(), prev);
6337                                }
6338                            }
6339                        }
6340                    } else if (eflags & (PM_READONLY | PM_NAMEREF)) == (PM_READONLY | PM_NAMEREF) {
6341                        // c:3142-3149 — only a READONLY ref survives as the pm
6342                        // handed to typeset_single (so `typeset -rn ref=var` can
6343                        // error properly); everything else gets `hn = NULL` and
6344                        // a fresh local SHADOW is created below (c:3148-3149).
6345                        reuse_existing = true;
6346                    }
6347                }
6348
6349                // (Re)create the nameref param (typeset_single c:2577+ createparam
6350                // with PM_NAMEREF type).
6351                if !reuse_existing {
6352                    let shadowed = paramtab().write().ok().and_then(|mut t| {
6353                        if (on & PM_LOCAL) != 0 && cur_ll > 0 {
6354                            t.remove(arg_name)
6355                        } else {
6356                            None
6357                        }
6358                    });
6359                    let mut flags = PM_NAMEREF as i32;
6360                    // c:2544 — TYPESET_TO_UNSET: declared-but-unassigned.
6361                    if carried_value.is_none() && isset(crate::ported::zsh_h::TYPESETTOUNSET) {
6362                        flags |= crate::ported::zsh_h::PM_DEFAULTED as i32;
6363                    }
6364                    // crate::ported::zsh_h::PM_UPPER on a nameref marks the -u upscope variant (c:2698
6365                    // allows -u with -n); crate::ported::zsh_h::PM_HIDEVAL likewise.
6366                    // 55030 lets -h (PM_HIDE) through as well.
6367                    flags |= (on
6368                        & (crate::ported::zsh_h::PM_UPPER
6369                            | crate::ported::zsh_h::PM_HIDEVAL
6370                            | crate::ported::zsh_h::PM_HIDE))
6371                        as i32;
6372                    // c:1108-1132 — createparam REUSES the just-unset node at its
6373                    // own level when !PM_LOCAL (`typeset -gn` rebind of a local
6374                    // ref keeps the ref's level; it does NOT hoist to level 0).
6375                    let level = if (on & PM_LOCAL) != 0 {
6376                        cur_ll
6377                    } else if let Some(elevel) = existing_level {
6378                        elevel
6379                    } else {
6380                        0
6381                    };
6382                    let pm = Box::new(crate::ported::zsh_h::param {
6383                        node: crate::ported::zsh_h::hashnode {
6384                            next: None,
6385                            nam: arg_name.to_string(),
6386                            flags,
6387                        },
6388                        u_data: 0,
6389                        u_tied: None,
6390                        u_arr: None,
6391                        u_str: if carried_value.is_none() && value.is_some() {
6392                            Some(String::new()) // `typeset -n ptr=` placeholder
6393                        } else {
6394                            None
6395                        },
6396                        u_val: 0,
6397                        u_dval: 0.0,
6398                        u_hash: None,
6399                        gsu_s: None,
6400                        gsu_i: None,
6401                        gsu_f: None,
6402                        gsu_a: None,
6403                        gsu_h: None,
6404                        base: 0,
6405                        width: 0,
6406                        env: None,
6407                        ename: None,
6408                        old: shadowed,
6409                        level,
6410                    });
6411                    if let Ok(mut tab) = paramtab().write() {
6412                        tab.insert(arg_name.to_string(), pm);
6413                    }
6414                } else {
6415                    // reuse path (read-only refs surviving the fresh-start gate,
6416                    // c:3148-3149): apply on/off bits in place. A readonly
6417                    // SCALAR + `-n` converts to a nameref here — its current
6418                    // value becomes the refname (c:2117+ type-conversion inside
6419                    // typeset_single with the pm kept; the `typeset +r -n
6420                    // ref=RW` shape: "assignment occurs after type change").
6421                    if let Ok(mut tab) = paramtab().write() {
6422                        if let Some(pm) = tab.get_mut(arg_name) {
6423                            pm.node.flags |= (on
6424                                & (crate::ported::zsh_h::PM_UPPER
6425                                    | crate::ported::zsh_h::PM_HIDEVAL
6426                                    | crate::ported::zsh_h::PM_HIDE))
6427                                as i32;
6428                            pm.node.flags |= PM_NAMEREF as i32;
6429                            pm.node.flags &= !((off
6430                                & (crate::ported::zsh_h::PM_UPPER
6431                                    | crate::ported::zsh_h::PM_HIDEVAL
6432                                    | crate::ported::zsh_h::PM_HIDE
6433                                    | PM_READONLY))
6434                                as i32);
6435                        }
6436                    }
6437                }
6438
6439                // Assign the refname (typeset_single c:2326 assignsparam →
6440                // PM_NAMEREF assignstrvalue arm + valid_refname + setscope).
6441                let mut rc = 0;
6442                if reuse_existing {
6443                    // c:2235 + c:2330 — the surviving (previously-readonly) ref
6444                    // is assigned with `flags = (on & PM_NAMEREF) ?
6445                    // ASSPM_NONAMEREF : 0`, so `typeset +r -n ref=RW` sets the
6446                    // REFERENCE's refname (fetchvalue under SCANPM_NONAMEREF,
6447                    // c:Src/params.c:3163), not the referent.
6448                    if let Some(v) = value {
6449                        let _no_deref = crate::ported::params::NamerefSuppressGuard::new();
6450                        if crate::ported::params::setsparam(arg_name, v).is_none() {
6451                            rc = 1;
6452                        }
6453                    }
6454                } else if let Some(v) = carried_value.as_deref() {
6455                    if v.is_empty() {
6456                        // `typeset -n ptr=` — empty placeholder, no setscope error.
6457                        if let Ok(mut tab) = paramtab().write() {
6458                            if let Some(pm) = tab.get_mut(arg_name) {
6459                                pm.u_str = Some(String::new());
6460                                pm.node.flags &= !(crate::ported::zsh_h::PM_DEFAULTED as i32);
6461                            }
6462                        }
6463                    } else if crate::ported::params::setsparam(arg_name, v).is_none() {
6464                        // c:2326 — typeset_single's assignsparam; the fresh
6465                        // PM_NAMEREF pm routes to assignstrvalue's nameref arm
6466                        // (c:2690-2720: valid_refname + SETREFNAME + setscope).
6467                        rc = 1;
6468                    }
6469                } else if !reuse_existing && crate::ported::params::is_nameref(arg_name) {
6470                    // bare placeholder — still run setscope for parity (no-op).
6471                    let _ = crate::ported::params::setscope_by_name(arg_name, None);
6472                }
6473
6474                // c:2618 — `pm->node.flags |= (on & PM_READONLY);` AFTER the
6475                // assignment so `typeset -rn ref=var` can set its initial value.
6476                if (on & PM_READONLY) != 0 || carry_readonly {
6477                    if let Ok(mut tab) = paramtab().write() {
6478                        if let Some(pm) = tab.get_mut(arg_name) {
6479                            pm.node.flags |= PM_READONLY as i32;
6480                        }
6481                    }
6482                }
6483                rc
6484            };
6485            if nameref_arm(on as u32, off as u32) != 0 {
6486                returnval = 1; // c:3153-3156
6487            }
6488            continue;
6489        }
6490
6491        // c:2032-2050 — existing pm is a nameref and ±n was not
6492        // requested: resolve the chain and operate on the target.
6493        let mut nameref_rewrite: Option<String> = None;
6494        if (off as u32 & PM_NAMEREF) == 0
6495            && pname_in_tab
6496            && crate::ported::params::is_nameref(arg_name)
6497        {
6498            let cur_ll = locallevel.load(Relaxed) as i32;
6499            let (pm_level, pm_refname) = paramtab()
6500                .read()
6501                .ok()
6502                .and_then(|t| t.get(arg_name).map(|p| (p.level, p.u_str.clone())))
6503                .unwrap_or((0, None));
6504            if pm_level == cur_ll || (on as u32 & PM_LOCAL) == 0 {
6505                // c:2033
6506                let type_change = (on as u32 & !(PM_NAMEREF | PM_LOCAL | PM_READONLY)) != 0; // c:2038
6507                use crate::ported::params::nameref_resolution;
6508                match crate::ported::params::resolve_nameref_name(arg_name, None) {
6509                    nameref_resolution::SelfRef | nameref_resolution::OutOfScope => {
6510                        returnval = 1;
6511                        continue;
6512                    }
6513                    nameref_resolution::Placeholder(last, _) => {
6514                        // c:2033-2034 — `if ((pm = resolve_nameref(pm)))
6515                        //                     pname = pm->node.nam;`
6516                        // The chain endpoint becomes the name the rest of
6517                        // typeset_single operates on, EVEN when that
6518                        // endpoint is itself a reference.
6519                        //
6520                        // c:2036-2037 — the reject is additionally gated on
6521                        //     (!(pm->node.flags & PM_UNSET) ||
6522                        //      (pm->node.flags & PM_DECLARED))
6523                        // so a chain ending on a reference that `unset -n`
6524                        // left `PM_UNSET` with PM_DECLARED cleared (c:3798
6525                        // in `unsetparam_pm`) does NOT error: it falls
6526                        // through to createparam, whose c:1120-1123 arm
6527                        // retargets `oldpm` to that endpoint and c:1176
6528                        // overwrites its flags — `typeset -n ref1=ref0;
6529                        // typeset -n ref0; unset -n ref0; typeset -i
6530                        // ref1=42` yields an ordinary local integer.
6531                        let endpoint_visible = paramtab()
6532                            .read()
6533                            .ok()
6534                            .and_then(|t| {
6535                                t.get(&last).map(|p| {
6536                                    let f = p.node.flags as u32;
6537                                    (f & PM_UNSET) == 0 || (f & PM_DECLARED) != 0
6538                                })
6539                            })
6540                            .unwrap_or(true); // c:2037
6541                        if !endpoint_visible {
6542                            // c:2034 — `pname = pm->node.nam;`
6543                            let tail = arg.find('=').map(|i| &arg[i..]).unwrap_or("");
6544                            nameref_rewrite = Some(format!("{}{}", last, tail));
6545                        } else if type_change {
6546                            zwarnnam(
6547                                name, // c:2046
6548                                &format!("{}: can't change type of a named reference", last),
6549                            );
6550                            returnval = 1;
6551                            continue; // c:2048 return NULL
6552                        }
6553                        // plain `typeset ref=value` on a placeholder
6554                        // falls through — the scalar-assign arm routes
6555                        // through assignsparam which writes the refname.
6556                    }
6557                    nameref_resolution::Target {
6558                        name: t, subscript, ..
6559                    } => {
6560                        if subscript.is_some() && type_change {
6561                            // c:2041-2044 — pm->width set: subscripted ref.
6562                            zwarnnam(
6563                                name,
6564                                &format!(
6565                                    "{}: can't change type via subscript reference",
6566                                    pm_refname.as_deref().unwrap_or(arg_name)
6567                                ),
6568                            );
6569                            returnval = 1;
6570                            continue;
6571                        }
6572                        // c:2034-2035 — pname = pm->node.nam (resolved).
6573                        let tail = arg.find('=').map(|i| &arg[i..]).unwrap_or("");
6574                        let mut new_arg = t.clone();
6575                        if let Some(sub) = &subscript {
6576                            new_arg.push('[');
6577                            new_arg.push_str(sub);
6578                            new_arg.push(']');
6579                        }
6580                        new_arg.push_str(tail);
6581                        nameref_rewrite = Some(new_arg);
6582                    }
6583                    nameref_resolution::NotRef => {}
6584                }
6585            }
6586        }
6587        let arg: &String = nameref_rewrite.as_ref().unwrap_or(arg);
6588        let arg_name: &str = match arg.find('=') {
6589            Some(i) => &arg[..i],
6590            None => arg.as_str(),
6591        };
6592        // Recompute the existing-pm snapshots for the rewritten name.
6593        let (pname_in_tab, usepm_existing) = if nameref_rewrite.is_some() {
6594            let tab = paramtab().read().unwrap();
6595            match tab.get(arg_name.split('[').next().unwrap_or(arg_name)) {
6596                Some(pm) => (
6597                    true,
6598                    (pm.node.flags as u32 & PM_UNSET) == 0
6599                        || (pm.node.flags as u32 & PM_DECLARED) != 0, // c:2058-2059
6600                ),
6601                None => (false, false),
6602            }
6603        } else {
6604            (pname_in_tab, usepm_existing)
6605        };
6606
6607        // c:2930 — `else if (pm)` reuse decision for the bin_typeset
6608        // literal-name loop: `if ((!(pm->node.flags & PM_UNSET) ||
6609        //                       pm->node.flags & PM_DECLARED)
6610        //                  && (locallevel == pm->level || !(on & PM_LOCAL)))`.
6611        // Decides whether the existing pm is reusable in place or
6612        // shadowed by a new local. The Rust per-arg loop short-circuits
6613        // through `createparam`'s reuse arm (params.rs:1975) which
6614        // already encodes this rule, but the literal C predicate
6615        // belongs here so the parity is visible at the call site.
6616        let cur_locallevel = locallevel.load(Relaxed) as i32;
6617        let pm_reuse_local: bool = if pname_in_tab {
6618            let tab = paramtab().read().unwrap();
6619            let pm = tab.get(arg_name).unwrap();
6620            let f = pm.node.flags as u32;
6621            ((f & PM_UNSET) == 0 || (f & PM_DECLARED) != 0)
6622                && (cur_locallevel == pm.level || (on as u32 & PM_LOCAL) == 0) // c:2930
6623        } else {
6624            true
6625        };
6626        let _ = pm_reuse_local;
6627
6628        // c:3127-3132 — PM_NAMEREF literal-name branch. When
6629        // `(on & PM_NAMEREF)` and an existing `hn` is present:
6630        // `if (((Param)hn)->level >= locallevel ||
6631        //     (!(on & PM_LOCAL) && ((Param)hn)->level < locallevel)) {
6632        //     unsetparam_pm(oldpm, 0, 1); hn = NULL; }`.
6633        // Namerefs always start over fresh when redeclared.
6634        if (on as u32 & PM_NAMEREF) != 0 && pname_in_tab {
6635            let level_compare = paramtab()
6636                .read()
6637                .ok()
6638                .and_then(|t| t.get(arg_name).map(|pm| pm.level))
6639                .unwrap_or(0);
6640            if level_compare >= cur_locallevel
6641                || ((on as u32 & PM_LOCAL) == 0 && level_compare < cur_locallevel)
6642            // c:3130
6643            {
6644                // unsetparam_pm + hn = NULL would happen here. The
6645                // simplified PM_NAMEREF path leaves the reset to
6646                // typeset_single's name-resolution branch at
6647                // typeset_single c:2750.
6648            }
6649        }
6650
6651        // c:2469-2510 — `typeset_single` createparam dispatch for new
6652        // PM_LOCAL declarations. Inside a function scope (`local x` or
6653        // `typeset x` from a fn body), C calls createparam(name,
6654        // on|PM_LOCAL) which chains pm.old = oldpm at the current
6655        // locallevel — the c:2575 `pm->level = locallevel` stamp that
6656        // endparamscope unwinds. Without this, `local x=inside`
6657        // modifies the outer-scope x instead of installing a shadow.
6658        // c:Src/builtin.c:2469 — `if ((on & PM_LOCAL) && (!pm ||
6659        // pm->level < locallevel)) { ...createparam... }`. The
6660        // condition is: PM_LOCAL set AND (no existing pm OR existing
6661        // pm is at a LOWER scope than the current locallevel). At
6662        // TOP scope (locallevel=0), `pm->level < 0` is never true,
6663        // so C doesn't call createparam — it reuses the existing
6664        // param in place. The previous Rust port called createparam
6665        // unconditionally, which routed through the reuse arm at
6666        // params.rs:2062-2068 and then clobbered pm.node.flags via
6667        // params.rs:2132 (`pm->node.flags = flags & ~PM_LOCAL`).
6668        // That wiped PM_INTEGER, leaving u_val=42 with PM_SCALAR
6669        // flags → getsparam returned empty. Bug #326 in
6670        // docs/BUGS.md. Mirror the C guard so the createparam call
6671        // only fires when we genuinely need to allocate a new shadow.
6672        let needs_new_shadow = if pname_in_tab {
6673            paramtab()
6674                .read()
6675                .ok()
6676                .and_then(|t| t.get(arg_name).map(|pm| pm.level < cur_locallevel))
6677                .unwrap_or(true)
6678        } else {
6679            true
6680        };
6681        // c:Src/builtin.c:3085 — on the `-m` re-entry the loop is fed
6682        // MATCHED PARAMETER NAMES, never option words, so the `-`/`+`
6683        // prefix guard (which only exists to keep a stray flag word out
6684        // of createparam on the literal path) must not fire there: `-`
6685        // IS a real special parameter, and `typeset -h +g -m '*'` has
6686        // to localize it like any other or the `unset -m '*'` that
6687        // follows hits the still-global readonly `-` and aborts the
6688        // shell (E03posix.ztst:1, `fn:2: read-only variable: -`).
6689        let m_apply_names = TYPESET_M_APPLY.with(|c| c.get());
6690        // c:2448-2499 — a subscripted name (`g[2]=x`, `g[2,4]=(…)`) is settled
6691        // in typeset_single's `strchr(pname, '[')` arm by assignsparam /
6692        // assignaparam on the base parameter and never reaches createparam.
6693        // PM_LOCAL is set for a plain top-level `typeset` too (c:2799-2810), so
6694        // without this test the shadow block created a stray parameter
6695        // literally named `g[2]`.
6696        if (on as u32 & PM_LOCAL) != 0                                       // c:2469
6697            && !arg_name.is_empty()
6698            && !arg_name.contains('[')
6699            && (m_apply_names
6700                || (!arg_name.starts_with('-') && !arg_name.starts_with('+')))
6701            && needs_new_shadow
6702        {
6703            let kind = if is_hashed {
6704                PM_HASHED
6705            } else if is_array {
6706                PM_ARRAY
6707            } else {
6708                0
6709            };
6710            // c:2083-2085 — `newspecial = NS_NORMAL`:
6711            //     if ((pm->node.flags & PM_SPECIAL)
6712            //         && !(on & PM_HIDE) && !(pm->node.flags & PM_HIDE & ~off))
6713            //         newspecial = NS_NORMAL;
6714            // The apply branch (c:2386-2425, "For specials, we keep the same
6715            // struct but zero everything") then re-uses the special's OWN
6716            // Param — accessors and all — so the local still reads through
6717            // the special's getfn. Note the `& ~off` term: a HIDDEN special
6718            // (`${(t)commands}` is `association-hide-hideval-special`) is
6719            // normally NOT preserved by `local -A commands`, but `+h` puts
6720            // PM_HIDE into `off`, which cancels the test and preserves it.
6721            // That is exactly what `_command_names` relies on:
6722            //     local -a +h path
6723            //     local -A +h commands
6724            //     path=( $_saved_path )
6725            // Reference zsh reports `association-local-special` / 5531 keys
6726            // there; dropping the `& ~off` term made zshrs build a plain
6727            // empty local assoc (`association-local` / 0 keys), so
6728            // `compadd -k commands` had nothing to offer and every command
6729            // completion routed through that idiom produced no matches.
6730            //
6731            // The lookup is C's `paramtab->getnode2()` — "getnode2() to avoid
6732            // autoloading" (c:2460-2462). An untouched module-parameter stub
6733            // is PM_AUTOLOAD with none of the real node's type/special flags
6734            // (`loadparamnode`, Src/params.c:563-585, only swaps in the real
6735            // node once something FETCHES the name), so `local -A +h commands`
6736            // on a still-unloaded `zsh/parameter` stub creates a plain local
6737            // assoc — `${(t)commands}` reads `association-local` and
6738            // `$#commands` is 0. Touch `$commands` first and the very same
6739            // statement yields `association-local-special` / the live table.
6740            // zshrs installs the partab placeholders eagerly and models
6741            // PM_AUTOLOAD as the MATERIALIZED_MODULE_PARAMS side-set, so the
6742            // stub test has to be asked for explicitly here; without it,
6743            // command-position completion (`pr<TAB>` → `_autocd` →
6744            // `_command_names`, which never reads `$commands` beforehand)
6745            // preserved a special that reference zsh does not.
6746            let keep_special = !crate::vm_helper::module_param_is_autoload_stub(arg_name)
6747                && paramtab()
6748                    .read()
6749                    .ok()
6750                    .and_then(|t| t.get(arg_name).map(|pm| pm.node.flags as u32))
6751                    .is_some_and(|f| {
6752                        (f & PM_SPECIAL) != 0 && (on & PM_HIDE) == 0 && (f & PM_HIDE & !off) == 0
6753                    });
6754            // c:2475-2487 — C calls `assignsparam(pname, value, 0)`
6755            // which creates the pm via the assignsparam → createparam
6756            // path WITHOUT propagating PM_READONLY/PM_EXPORTED flags
6757            // (that path uses PM_SCALAR / PM_ARRAY / PM_HASHED only).
6758            // Post-assign attribute stamps add PM_READONLY/PM_EXPORTED
6759            // later. Mirror by passing ONLY the type-kind + PM_LOCAL
6760            // (not the full `on` mask) so the freshly-created pm
6761            // doesn't error on its own first assignment. PM_SPECIAL rides
6762            // along only for the newspecial case above — c:2425 stamps it
6763            // on the preserved struct (`… | on | PM_SPECIAL) & ~off`) and
6764            // createparam keys its accessor inheritance off it.
6765            // c:2410 — `copyparam(tpm, pm, 1)` saves the special's value
6766            // before the local takes over, and for a hash that is a COPY of
6767            // the table (c:Src/params.c:1288-1289 `tpm->u.hash =
6768            // copyparamtable(pm->gsu.h->getfn(pm), pm->node.nam)`).
6769            // scanendscope hands that copy back to the special's own setfn on
6770            // return (c:Src/params.c:5962 `pm->gsu.h->setfn(pm,
6771            // tpm->u.hash)`), which is how `local -A +h functions; unset
6772            // "functions[f]"` leaves `f` defined after the function returns.
6773            // Must be taken HERE: once createparam installs the shadow, the
6774            // scan below no longer reaches the special. PM_READONLY rows are
6775            // not copied because c:5945 skips their restore anyway.
6776            let mut special_hash_snapshot: Option<Vec<String>> = if keep_special {
6777                crate::ported::modules::parameter::PARTAB
6778                    .iter()
6779                    .find(|e| e.name == arg_name && (e.flags as u32 & PM_READONLY) == 0)
6780                    .and_then(|e| {
6781                        let keys = crate::vm_helper::partab_scan_keys(arg_name)?; // c:1289 getfn
6782                        let mut flat = Vec::with_capacity(keys.len() * 2);
6783                        for k in keys {
6784                            // c:621 scancopyparams → c:609 copyparam per element
6785                            let v = (e.getfn)(std::ptr::null_mut(), &k)
6786                                .and_then(|p| p.u_str)
6787                                .unwrap_or_default();
6788                            flat.push(k);
6789                            flat.push(v);
6790                        }
6791                        Some(flat)
6792                    })
6793            } else {
6794                None
6795            };
6796            // c:2608-2609 — set below, inside the newspecial block, when the
6797            // declaration carries no value and the saved node is neither
6798            // PM_NORESTORE nor PM_READONLY; acted on once the paramtab lock
6799            // is released, because every special setfn re-enters paramtab.
6800            let mut empty_special_type: Option<u32> = None;
6801            let hash_row_writable = special_hash_snapshot.is_some();
6802            let _ = createparam(
6803                arg_name,
6804                kind as i32 | PM_LOCAL as i32 | if keep_special { PM_SPECIAL as i32 } else { 0 },
6805            );
6806            // c:2575 — `else if (on & PM_LOCAL) pm->level = locallevel;`
6807            // — stamp the just-created pm at the current scope so
6808            // endparamscope (params.c) unwinds the shadow when the
6809            // enclosing function returns. createparam at params.rs:2014
6810            // already sets `level: cur_locallevel` on the fresh pm;
6811            // re-stamp here against the post-createparam pm to mirror
6812            // C's explicit assignment, AND to catch the reuse-arm path
6813            // (params.rs:1975-1986) where the existing pm's level was
6814            // pre-set by a prior scope.
6815            if let Ok(mut tab) = paramtab().write() {
6816                if let Some(pm) = tab.get_mut(arg_name) {
6817                    pm.level = cur_locallevel; // c:2575
6818                                               // c:2691 + c:4087 arrsetfn — flags that affect the
6819                                               // VALUE store (PM_UNIQUE dedup, PM_LEFT/RIGHT_B/Z
6820                                               // padding width) must land on pm.flags BEFORE
6821                                               // assignaparam → arrsetfn runs, else those
6822                                               // setfns see the un-flagged pm. C zsh applies
6823                                               // these as part of the pre-assignment stamp at
6824                                               // typeset_single c:2476-2479. Mirror by
6825                                               // pre-stamping the value-affecting subset of
6826                                               // `on`. The full attribute mask (PM_READONLY,
6827                                               // PM_EXPORTED, etc.) still lands in the
6828                                               // post-assign block below since those don't
6829                                               // change the value at write time.
6830                    let pre_assign_mask: u32 =
6831                        PM_UNIQUE | PM_LEFT | PM_RIGHT_B | PM_RIGHT_Z | PM_LOWER | PM_UPPER;
6832                    pm.node.flags |= (on as u32 & pre_assign_mask) as i32;
6833                    pm.node.flags &= !((off as u32 & pre_assign_mask) as i32);
6834                    // c:Src/builtin.c:2544 — `if (isset(TYPESETTOUNSET))
6835                    //     pm->node.flags |= PM_DEFAULTED;`. Under
6836                    // `setopt typeset_to_unset`, bare `typeset NAME`
6837                    // (no `=`) creates a "declared but not assigned"
6838                    // entry. Only fires when the arg has no `=`
6839                    // (assignment paths get PM_UNSET cleared as a side
6840                    // effect of the value write). Bug #280 in
6841                    // docs/BUGS.md.
6842                    if !arg.contains('=') && isset(crate::ported::zsh_h::TYPESETTOUNSET) {
6843                        pm.node.flags |= PM_DEFAULTED as i32;
6844                    }
6845                    // c:2412-2413 + c:2426 — the newspecial preserve block.
6846                    // C REUSES the special's own Param struct for the local
6847                    // ("For specials, we keep the same struct but zero
6848                    // everything", c:2385-2388): it copies the saved values
6849                    // into `tpm` —
6850                    //     tpm->base  = pm->base;
6851                    //     tpm->width = pm->width;
6852                    // — and leaves `pm->base` / `pm->width` untouched, then
6853                    // restores the readonly bit explicitly:
6854                    //     /*
6855                    //      * Readonlyness of special parameters must be preserved.
6856                    //      */
6857                    //     pm->node.flags |= tpm->node.flags & PM_READONLY;
6858                    // zshrs allocates a FRESH node for the shadow, so both
6859                    // have to be copied off `pm.old` (the node the special
6860                    // was pushed down to). Without them `() { local status;
6861                    // typeset -p status }` printed `typeset -i status=0`
6862                    // instead of zsh's `typeset -i10 -r status=0`
6863                    // (B02typeset.ztst:67) — base 0 suppressed the radix and
6864                    // the missing PM_READONLY dropped the `-r` letter.
6865                    if keep_special {
6866                        if let Some(old) = pm.old.as_mut() {
6867                            pm.base = old.base; // c:2412
6868                            pm.width = old.width; // c:2413
6869                            pm.node.flags |= (old.node.flags as u32 & PM_READONLY) as i32;
6870                            // c:2426
6871                            // !!! WARNING: RUST-ONLY REPRESENTATION !!!
6872                            // C keeps the copied table in `tpm->u.hash`. zshrs's
6873                            // `u_hash` is not populated for the magic rows, so the
6874                            // snapshot rides on the saved node as a flat
6875                            // key/value list in `u_arr`; endparamscope takes it
6876                            // back out and replays it through the whole-hash
6877                            // setfn.
6878                            if special_hash_snapshot.is_some() {
6879                                old.u_arr = special_hash_snapshot.take(); // c:1289
6880                            }
6881                            // c:2608-2609 — `else if (newspecial != NS_NONE &&
6882                            //     !(pm->old->node.flags & (PM_NORESTORE|PM_READONLY)))`,
6883                            // the arm taken when `ASG_VALUEP(asg)` is false.
6884                            if !arg.contains('=')
6885                                && (old.node.flags as u32
6886                                    & (crate::ported::zsh_h::PM_NORESTORE | PM_READONLY))
6887                                    == 0
6888                            {
6889                                empty_special_type = Some(PM_TYPE(old.node.flags as u32));
6890                            }
6891                        }
6892                    }
6893                }
6894            }
6895            // c:2610-2632 — "We need to use the special setting function to
6896            // re-initialise the special parameter to empty." The local keeps the
6897            // special's accessors, so each type is written through the
6898            // name-routed setter that reaches them: `local -a path` empties
6899            // $PATH as well, `local +h IFS` clears the field separators.
6900            match empty_special_type {
6901                Some(t) if t == PM_SCALAR || t == PM_NAMEREF => {
6902                    let _ = setsparam(arg_name, ""); // c:2617 gsu.s->setfn(pm, "")
6903                }
6904                Some(t) if t == PM_INTEGER => {
6905                    let _ = setiparam(arg_name, 0); // c:2620 gsu.i->setfn(pm, 0)
6906                }
6907                Some(t) if t == PM_EFLOAT || t == PM_FFLOAT => {
6908                    // c:2624 gsu.f->setfn(pm, 0.0)
6909                    let zero = mnumber {
6910                        l: 0,
6911                        d: 0.0,
6912                        type_: crate::ported::zsh_h::MN_FLOAT,
6913                    };
6914                    let _ = crate::ported::params::setnparam(arg_name, zero);
6915                }
6916                Some(t) if t == PM_ARRAY => {
6917                    let _ = setaparam(arg_name, Vec::new()); // c:2627 mkarray(NULL)
6918                }
6919                // c:2630 gsu.h->setfn(pm, newparamtable(17, ...)) — an empty table
6920                // through the whole-hash setfn (setpmraliases flushes the class,
6921                // setpmfunctions adds nothing). Read-only rows never get here in
6922                // C (c:2609); zshrs's seeded rows do not all carry PM_READONLY,
6923                // so gate on the same writable-row test the snapshot uses.
6924                Some(t) if t == PM_HASHED && hash_row_writable => {
6925                    let _ = crate::ported::params::assignaparam(arg_name, Vec::new(), 0);
6926                }
6927                _ => {}
6928            }
6929        }
6930
6931        // c:2462-2467 — subscripted-name PM_LOCAL guard: `else if
6932        //   ((on & PM_LOCAL) && locallevel) { ... if (!pm || pm->level
6933        //   != locallevel) zerrnam("can't create local array elements") }`.
6934        // Refuses to create a NEW local for `local arr[N]=val` when
6935        // the outer-scope pm at a different level exists. The Rust
6936        // per-arg loop treats subscripted names as the eq-branch's
6937        // `name[key]=val` shape inside assignsparam; the guard fires
6938        // here BEFORE the assignment so we emit the C error message.
6939        if let Some(br) = arg_name.find('[') {
6940            let base = &arg_name[..br];
6941            if (on as u32 & PM_LOCAL) != 0 && cur_locallevel != 0 {
6942                // c:2462
6943                let pm_level = paramtab()
6944                    .read()
6945                    .ok()
6946                    .and_then(|t| t.get(base).map(|pm| pm.level));
6947                if pm_level.is_none() || pm_level != Some(cur_locallevel) {
6948                    // c:2466
6949                    // c:2463-2467 — C blanks the `[` only for the getnode
6950                    // lookup (`*subscript = 0; … *subscript = '['`), so the
6951                    // message carries the FULL `pname` INCLUDING the
6952                    // subscript: `array[2]: can't create local array
6953                    // elements`. Passing the bare base name dropped `[2]`.
6954                    zerrnam(
6955                        name,
6956                        &format!("{}: can't create local array elements", arg_name), // c:2466
6957                    );
6958                    continue; // c:2467
6959                }
6960            }
6961            // c:Src/builtin.c:2500-2502 — `else { zerrnam(cname,
6962            //   "%s: inconsistent array element or slice assignment",
6963            //   pname); return NULL; }`. C's typeset_single rejects
6964            // type-flag attributes on subscripted names: when the
6965            // name has a `[…]` subscript AND the requested `on` mask
6966            // carries a non-scalar PM_TYPE bit (PM_INTEGER, PM_EFLOAT,
6967            // PM_FFLOAT, PM_ARRAY, PM_HASHED), it's
6968            // `typeset -i h[k]` / `-A h[k]` / etc. — attribute
6969            // applies to whole array, not element. zshrs's
6970            // typeset path silently accepted these and applied
6971            // the attribute to the element. Bug #219 in
6972            // docs/BUGS.md. Plain `h[k]=value` (no `-` flag) has
6973            // PM_TYPE(on) == PM_SCALAR and stays valid.
6974            let pm_type_bits =
6975                on as u32 & (PM_INTEGER | PM_EFLOAT | PM_FFLOAT | PM_ARRAY | PM_HASHED);
6976            if pm_type_bits != 0 {
6977                zerrnam(
6978                    name,
6979                    &format!(
6980                        "{}: inconsistent array element or slice assignment",
6981                        arg_name
6982                    ),
6983                );
6984                continue;
6985            }
6986        }
6987
6988        if let Some(eq) = arg.find('=') {
6989            let n = &arg[..eq];
6990            // c:Src/builtin.c:2289 — `pm->node.flags = ... & ~off` must
6991            //   clear `off` bits in value-affecting flags BEFORE the
6992            //   assignment runs (params.rs assignaparam:5526 snapshots
6993            //   PM_UNIQUE and dedups in setfn). The PM_LOCAL pre_assign
6994            //   block below only fires when on & PM_LOCAL; top-level
6995            //   `typeset +U arr=(...)` has on=0 and would otherwise
6996            //   leave the stale PM_UNIQUE in place. Bug #234.
6997            let pre_assign_off_mask =
6998                (PM_UNIQUE | PM_LEFT | PM_RIGHT_B | PM_RIGHT_Z | PM_LOWER | PM_UPPER) as i32;
6999            let off_in_pre_mask = (off as i32) & pre_assign_off_mask;
7000            if off_in_pre_mask != 0 {
7001                if let Ok(mut tab) = paramtab().write() {
7002                    if let Some(pm) = tab.get_mut(n) {
7003                        pm.node.flags &= !off_in_pre_mask;
7004                    }
7005                }
7006            }
7007            let raw_v = &arg[eq + 1..];
7008            // c:2945-3050 — `=(elem elem ...)` array-init syntax.
7009            // The parser hands the whole `(...)` body in as one arg
7010            // when typeset's BINF_MAGICEQUALS is set; the `(` / `)` are
7011            // literal first/last bytes. Strip them and split on
7012            // whitespace to recover the element list.
7013            //
7014            // c:Src/parse.c par_simple/intypeset — the array-vs-scalar
7015            // distinction is SYNTACTIC (the `asg->flags & ASG_ARRAY`
7016            // bit), set when the `(` is an UNQUOTED paren at parse time,
7017            // NOT a heuristic on the expanded value. zshrs encodes that
7018            // bit as the `\u{1f}` REJOIN_SEP marker inserted by
7019            // compile_zsh's BUILTIN_TYPESET_PAREN_PACK, which only fires
7020            // for syntactic (unquoted) paren-inits. A double-quoted
7021            // value that happens to expand to `(...)` —
7022            // `typeset out="(${x:gs/%/%%})"` — reaches here as a plain
7023            // dequoted scalar with NO marker and must stay a SCALAR
7024            // (zsh: `out=(ruby-3.2%%@1.0)`). Keying on `starts_with('(')`
7025            // alone mis-stored it as an array. The `()`-empty and
7026            // `=(\u{1f}elem…)` pack forms always carry the marker.
7027            let is_paren_init = raw_v.starts_with('(')
7028                && raw_v.ends_with(')')
7029                && raw_v.len() >= 2
7030                && raw_v.contains('\u{1f}');
7031            // c:Src/builtin.c:2095-2097 — `if (ASG_ARRAYP(asg) &&
7032            // PM_TYPE(on) == PM_SCALAR && ...) on |= PM_ARRAY;`. Auto-
7033            // promote to array when the assignment shape is array but
7034            // no explicit type was requested. Required so plain
7035            // `local arr=(a b c)` (which carries on=PM_LOCAL with
7036            // no PM_TYPE bits) reaches the array-init path. Without
7037            // it the c:2342 inconsistency check below errors on every
7038            // bare array assign.
7039            let mut on = on;
7040            // c:Src/builtin.c:2091-2093 — the promotion is skipped when the
7041            // parameter is being reused and is ALREADY an array or hash:
7042            //     if (ASG_ARRAYP(asg) && PM_TYPE(on) == PM_SCALAR &&
7043            //         !(usepm && (PM_TYPE(pm->node.flags) & (PM_ARRAY|PM_HASHED))))
7044            //         on |= PM_ARRAY;
7045            // The reuse arm then hands the list to assignaparam (c:2324-2329),
7046            // which keeps a hashed pm hashed (c:Src/params.c:3341 resets only
7047            // non-array types), so `typeset -A h=(a 1); typeset h=(b 2)` is
7048            // still an assoc. usepm is cleared first for a new local level
7049            // (c:2078-2086). The inline paths below key on `on`'s type, so
7050            // the reused pm's type stands in for the untouched PM_SCALAR.
7051            let reused_type = paramtab().read().ok().and_then(|t| {
7052                t.get(arg_name).and_then(|pm| {
7053                    let f = pm.node.flags as u32;
7054                    let same_scope = (on as u32 & PM_LOCAL) == 0
7055                        || pm.level == locallevel_param.load(Relaxed) as i32;
7056                    let typ = crate::ported::zsh_h::PM_TYPE(f);
7057                    ((f & PM_UNSET) == 0 && same_scope && (typ == PM_ARRAY || typ == PM_HASHED))
7058                        .then_some(typ)
7059                })
7060            });
7061            if is_paren_init && crate::ported::zsh_h::PM_TYPE(on) == PM_SCALAR {
7062                on |= reused_type.unwrap_or(PM_ARRAY);
7063            }
7064            let is_hashed = (on & PM_HASHED) != 0;
7065            let is_array = (on & PM_ARRAY) != 0;
7066            // c:Src/builtin.c:2342-2347 — `inconsistent type for
7067            // assignment`: when the user types an array RHS
7068            // (`x=(...)`) but the resolved type flags don't include
7069            // PM_ARRAY|PM_HASHED, error out. C source:
7070            // ```
7071            // if ((asg->flags & ASG_ARRAY) ?
7072            //     !(on & (PM_ARRAY|PM_HASHED)) :
7073            //     (asg->value.scalar && (on & (PM_ARRAY|PM_HASHED)))) {
7074            //     zerrnam(cname, "%s: inconsistent type for assignment", pname);
7075            //     return NULL;
7076            // }
7077            // ```
7078            // The C conflict-resolution pass at c:2718-2742 clears
7079            // PM_ARRAY/PM_HASHED from `on` when a conflicting type
7080            // (`-i` / `-E` / `-F`) is requested (`on & PM_INTEGER →
7081            // off |= PM_ARRAY; on &= ~off`), so `-ia x=(1 2 3)` lands
7082            // here with PM_INTEGER set + PM_ARRAY cleared. The
7083            // c:2095 auto-promote above doesn't fire either (PM_TYPE
7084            // is non-zero from PM_INTEGER), so the inconsistency
7085            // error fires correctly. Bug #250.
7086            if is_paren_init && (on & (PM_ARRAY | PM_HASHED)) == 0 {
7087                zerrnam(
7088                    name, // cname
7089                    &format!("{}: inconsistent type for assignment", n),
7090                );
7091                unqueue_signals();
7092                return 1;
7093            }
7094            if is_paren_init {
7095                let inner = &raw_v[1..raw_v.len() - 1]; // c:2950
7096                                                        // c:Src/builtin.c:2555-2556 globlist — C glob-expands each
7097                                                        // element working on the TOKENIZED value where quoted
7098                                                        // metachars keep their Bnull escape, so a quoted `(` / `*`
7099                                                        // is inert. zshrs's compile path (compile_zsh's
7100                                                        // BUILTIN_TYPESET_PAREN_PACK) already runs the per-element
7101                                                        // glob via compile_word_str — identical to the bare
7102                                                        // `arr=(…)` path (compile_zsh.rs ~3294) — and hands
7103                                                        // bin_typeset the FULLY-EXPANDED, DEQUOTED elements via the
7104                                                        // `\u{1f}` sentinel form. Re-globbing those dequoted values
7105                                                        // here is a double-glob that loses the original quoting:
7106                                                        // `typeset -a p=("*.txt")` globbed the quoted star, and
7107                                                        // `typeset -a p=("a=\$((1+2))")` parsed the literal `((` as
7108                                                        // a bareglobqual ("unknown file attribute: ("). Skip the
7109                                                        // re-glob for the pack (`\u{1f}`) form; only the legacy
7110                                                        // whitespace form (elements not pre-expanded) needs it.
7111                let from_pack = inner.contains('\u{1f}');
7112                // c:2952 globlist — each list node is one element. When
7113                // the multi-arg rejoin loop above ran, elements are
7114                // separated by the `\u{1f}` REJOIN_SEP sentinel
7115                // (consecutive sentinels preserve quoted-empty args).
7116                // Otherwise (single-arg paren-init form like `a=(1 2 3)`)
7117                // the inner is plain-whitespace separated.
7118                // Bug #93 in docs/BUGS.md.
7119                let split_elems: Vec<String> = if inner.contains('\u{1f}') {
7120                    // c:Src/builtin.c:2952 globlist — each REJOIN_SEP
7121                    // segment is ONE already-parsed array element (the
7122                    // parser split argv and quote-removed each element),
7123                    // so split EXACTLY on the sentinel. Do NOT trim or
7124                    // drop all-whitespace segments — that would corrupt
7125                    // quoted leading/trailing whitespace, e.g.
7126                    // `local a=( "  name: x" )` (the `  name:` prefix is
7127                    // significant and must survive for `${a[(i)  name: …]}`
7128                    // to match). Boundary empties from the `=(` / `)` argv
7129                    // fragments are removed by the first/last-empty trim
7130                    // immediately below.
7131                    inner.split('\u{1f}').map(String::from).collect::<Vec<_>>()
7132                } else {
7133                    inner.split_whitespace().map(String::from).collect()
7134                };
7135                let split_elems = if inner.contains('\u{1f}') {
7136                    let mut v: Vec<String> = split_elems;
7137                    // Trim only the FIRST leading empty (from the open
7138                    // `=(` arg's tail after stripping `(`) and the LAST
7139                    // trailing empty (from the close `)` arg's head),
7140                    // preserving any genuine empties in the middle.
7141                    if v.first().is_some_and(|s| s.is_empty()) {
7142                        v.remove(0);
7143                    }
7144                    if v.last().is_some_and(|s| s.is_empty()) {
7145                        v.pop();
7146                    }
7147                    v
7148                } else {
7149                    split_elems
7150                };
7151                // c:Src/subst.c:2558-2571 — `$=var` IFS-split operator.
7152                // The full paramsubst path in subst.rs doesn't yet
7153                // handle the `=` flag (spbreak=2 in C) when invoked
7154                // outside the canonical addvars→prefork→stringsubst
7155                // chain. typeset's array-init reaches bin_typeset
7156                // AFTER the unified prefork has already run (against
7157                // the entire `arr=( ... )` string, treating the parens
7158                // as literal), so any `$=name` inside the parens
7159                // stayed untouched.
7160                //
7161                // Mirror the C `spbreak=2` semantics inline: for each
7162                // element of the form `$=NAME`, look up NAME, split on
7163                // IFS, and substitute the resulting fields in place.
7164                // Other elements (already paramsubst'd by the outer
7165                // prefork, or literal) pass through unchanged.
7166                let raw_elems: Vec<String> = {
7167                    // c:Src/params.c IFS — the canonical IFS source is
7168                    // the IFS shell param (PM_SPECIAL gsu reads the
7169                    // live $IFS in the current scope). Reading the
7170                    // OS env var would miss `local IFS=` overrides and
7171                    // catch stale values left behind by earlier scopes.
7172                    let ifs_chars: Vec<char> = crate::ported::params::getsparam("IFS")
7173                        .unwrap_or_else(|| " \t\n".to_string())
7174                        .chars()
7175                        .collect();
7176                    let mut out: Vec<String> = Vec::new();
7177                    for se in split_elems {
7178                        if let Some(stripped) = se.strip_prefix("$=") {
7179                            // c:2562 — `s++; spbreak = 2; nojoin =
7180                            // !(ifs && *ifs);`. Look up the variable;
7181                            // if unset, treat as empty string and
7182                            // produce no fields.
7183                            let val =
7184                                crate::ported::params::getsparam(stripped).unwrap_or_default();
7185                            if ifs_chars.is_empty() {
7186                                if !val.is_empty() {
7187                                    out.push(val);
7188                                }
7189                            } else {
7190                                for field in val
7191                                    .split(|c: char| ifs_chars.contains(&c))
7192                                    .filter(|s| !s.is_empty())
7193                                {
7194                                    out.push(field.to_string());
7195                                }
7196                            }
7197                        } else {
7198                            out.push(se);
7199                        }
7200                    }
7201                    out
7202                };
7203                // c:2555-2556 — `globlist(vl, prefork_ret)` glob-expands
7204                // each element when it contains wildcards. Skipped for the
7205                // pre-expanded pack form (see `from_pack` above): those
7206                // elements were already glob-expanded (quote-aware) at
7207                // compile time, so re-globbing the dequoted values is wrong.
7208                let mut elems: Vec<String> = Vec::with_capacity(raw_elems.len());
7209                for re in raw_elems {
7210                    if from_pack {
7211                        elems.push(re);
7212                        continue;
7213                    }
7214                    // c:Src/glob.c:1230 zglob — `if (... !haswilds(ostr)
7215                    // ...) return;` short-circuits before patcompile.
7216                    // In C, lexer/prefork preserves Bnull-escape on
7217                    // quoted metachars (`'('` becomes `Bnull (`), so
7218                    // haswilds returns false on a quoted-paren key.
7219                    // zshrs's upstream prefork strips Bnull before
7220                    // typeset args land here (see TRACE_TS_RAWV =
7221                    // `[40, 31, 40, 31, ...]` — bare `(` without the
7222                    // Bnull escape), so haswilds spuriously returns
7223                    // true on a literal `(` from `T=( '(' v )`. Guard
7224                    // by pre-compiling: if patcompile fails, the
7225                    // element can't be a real glob — keep it literal
7226                    // (matches the C zglob no-wild short-circuit and
7227                    // suppresses the spurious "bad pattern" zerr).
7228                    // haswilds scans TOKENIZED strings — tokenize a
7229                    // local copy of the untokenized element first
7230                    // (c:Src/glob.c:3548), as C does for runtime-built
7231                    // strings (compcore.c:2231); patcompile consumes
7232                    // the same tokenized form.
7233                    let mut re_tok = re.to_string();
7234                    crate::ported::glob::tokenize(&mut re_tok);
7235                    if crate::ported::pattern::haswilds(&re_tok) {
7236                        let compilable = crate::ported::pattern::patcompile(
7237                            &re_tok,
7238                            crate::ported::zsh_h::PAT_HEAPDUP as i32,
7239                            None,
7240                        )
7241                        .is_some();
7242                        if !compilable {
7243                            elems.push(re);
7244                            continue;
7245                        }
7246                        let expanded = crate::ported::glob::glob_path(&re);
7247                        if expanded.is_empty() || (expanded.len() == 1 && expanded[0] == re) {
7248                            elems.push(re);
7249                        } else {
7250                            elems.extend(expanded);
7251                        }
7252                    } else {
7253                        elems.push(re);
7254                    }
7255                }
7256                // c:2448 + c:2486-2494 — a subscripted name with an array value is
7257                // a slice assignment on the existing parameter:
7258                //     } else if (PM_TYPE(on) == PM_ARRAY && ASG_ARRAYP(asg)) {
7259                //         int flags = (asg->flags & ASG_KEY_VALUE) ? ASSPM_KEY_VALUE : 0;
7260                //         if (!(pm = assignaparam(pname, asg->value.array ?
7261                //                 zlinklist2array(asg->value.array, 1) : mkarray(NULL), flags)))
7262                //             return NULL;
7263                //         dont_set = 1;
7264                // The array-init path below stored the element list under the
7265                // literal name `array[2,4]` and left `array` untouched.
7266                if n.contains('[') {
7267                    let flags = if elems.iter().any(|e| e.starts_with(crate::ported::zsh_h::Marker)) {
7268                        crate::ported::zsh_h::ASSPM_KEY_VALUE
7269                    } else {
7270                        0
7271                    };
7272                    if crate::ported::params::assignaparam(n, elems, flags).is_none() {
7273                        returnval = 1; // c:2491 return NULL
7274                    }
7275                    continue;
7276                }
7277                // c:Src/builtin.c:2355-2378 — tc (type-conversion)
7278                // branch: the requested type differs from the
7279                // existing param's type. zsh keeps readonly/exported
7280                // status across the conversion (`on |= ~off &
7281                // (PM_READONLY|PM_EXPORTED) & pm->node.flags`) but
7282                // turns PM_READONLY OFF on the old pm so the delete/
7283                // recreate succeeds — `typeset -r h2=(); typeset -A
7284                // h2=(k v)` is legal in zsh. The Rust assignment
7285                // funnel (sethparam/setaparam) performs the type
7286                // rewrite in place, so clearing PM_READONLY here is
7287                // the c:2359 step and the post-assign stamp below
7288                // restores it from `on` (c:2357 carry).
7289                {
7290                    let requested = if is_hashed { PM_HASHED } else { PM_ARRAY };
7291                    let existing_flags = paramtab()
7292                        .read()
7293                        .ok()
7294                        .and_then(|t| t.get(n).map(|pm| pm.node.flags as u32));
7295                    if let Some(f) = existing_flags {
7296                        if (f & PM_UNSET) == 0 && crate::ported::zsh_h::PM_TYPE(f) != requested {
7297                            // c:2357 — carry readonly/exported into `on`.
7298                            on |= !off & (PM_READONLY | PM_EXPORTED) & f;
7299                            // c:2359 — `pm->node.flags &= ~PM_READONLY;`
7300                            if let Ok(mut tab) = paramtab().write() {
7301                                if let Some(pm) = tab.get_mut(n) {
7302                                    pm.node.flags &= !(PM_READONLY as i32);
7303                                }
7304                            }
7305                        }
7306                    }
7307                }
7308                // c:Src/builtin.c:2476-2479 + c:2691 + c:4087 arrsetfn —
7309                // pre-stamp value-affecting flags on the pm BEFORE the
7310                // array-init setarrvalue runs, so PM_UNIQUE drives dedup
7311                // inside assignaparam (params.rs:5571 reads pm.flags &
7312                // PM_UNIQUE to gate simple_arrayuniq). Without this,
7313                // `typeset -aUx u=(/bin /usr/bin /bin)` stamped PM_UNIQUE
7314                // only AFTER setarrvalue ran, so dedup never fired.
7315                // `typeset -aU u=(...)` happened to work due to a side
7316                // path; this makes both consistent. Bug #272.
7317                if is_array {
7318                    let pre_assign_mask: u32 = PM_UNIQUE;
7319                    if (on as u32 & pre_assign_mask) != 0 {
7320                        let exists = paramtab()
7321                            .read()
7322                            .map(|t| t.contains_key(n))
7323                            .unwrap_or(false);
7324                        if !exists {
7325                            let _ = createparam(n, PM_ARRAY as i32);
7326                        }
7327                        if let Ok(mut tab) = paramtab().write() {
7328                            if let Some(pm) = tab.get_mut(n) {
7329                                pm.node.flags |= (on as u32 & pre_assign_mask) as i32;
7330                            }
7331                        }
7332                    }
7333                }
7334                // c:2357 — a scalar → array/hashed conversion is a TYPE change,
7335                // which recreates the param carrying only READONLY|EXPORTED, so
7336                // the old scalar padding flags (-L/-R/-Z) must NOT survive it:
7337                // `typeset -Z 3 x=7; typeset -a x=(1 2)` is `array`, not
7338                // `array-right_zeros`. Capture whether the param was ALREADY an
7339                // array/hashed (in which case it is NOT a type change and the
7340                // padding is preserved by the merge stamp below).
7341                let prior_was_arraylike = paramtab().read().ok().and_then(|t| {
7342                    t.get(n).map(|pm| {
7343                        let typ = PM_TYPE(pm.node.flags as u32);
7344                        typ == PM_ARRAY || typ == PM_HASHED
7345                    })
7346                }) == Some(true);
7347                if is_hashed && elems.iter().any(|e| e.starts_with(crate::ported::zsh_h::Marker)) {
7348                    // c:Src/params.c:3485-3502 — once the compiler marked a
7349                    // `[key]=value` element (c:Src/subst.c:49-79), EVERY element
7350                    // must be a Marker / key / value triad.
7351                    if elems.len() % 3 != 0
7352                        || elems
7353                            .chunks(3)
7354                            .any(|t| !t[0].starts_with(crate::ported::zsh_h::Marker))
7355                    {
7356                        zerr("bad [key]=value syntax for associative array"); // c:3499
7357                        // c:Src/params.c:3500 `return NULL` -> typeset_single c:2326-2329 returns
7358                        // NULL -> bin_typeset c:3153-3156 `returnval = 1`; no attribute stamps.
7359                        returnval = 1;
7360                        continue;
7361                    } else {
7362                        // c:Src/params.c:4094-4117 arrhashsetfn — a `Marker +` triad
7363                        // appends onto the key's current value.
7364                        let mut map: IndexMap<String, String> = IndexMap::new();
7365                        for t in elems.chunks(3) {
7366                            if t[0].ends_with('+') {
7367                                map.entry(t[1].clone()).or_default().push_str(&t[2]);
7368                            } else {
7369                                map.insert(t[1].clone(), t[2].clone());
7370                            }
7371                        }
7372                        crate::ported::exec::set_assoc(n, map);
7373                    }
7374                } else if is_hashed {
7375                    // c:2960-2975 — `setdataparam(..., PM_HASHED, …)`.
7376                    // Two assoc-init shapes accepted by zsh:
7377                    //  1. flat alternating k/v: `m=(k1 v1 k2 v2)`
7378                    //  2. per-element [K]=V:    `m=([k1]=v1 [k2]=v2)`
7379                    // The parser hands all elements as one `(…)` body,
7380                    // so we detect shape 2 when every element starts
7381                    // with `[` and contains `]=`. Otherwise fall back
7382                    // to alternating pairs.
7383                    let bracket_shape = !elems.is_empty()
7384                        && elems.iter().all(|e| e.starts_with('[') && e.contains("]="));
7385                    // c:Src/params.c:4076-4085 arrhashsetfn — the element list is
7386                    // counted first and an ODD total is refused outright:
7387                    //     for (aptr = val; *aptr; ++aptr)
7388                    //         if (**aptr != Marker) ++alen;
7389                    //     if (alen % 2) {
7390                    //         freearray(val);
7391                    //         zerr("bad set of key/value pairs for associative array");
7392                    //         return;
7393                    //     }
7394                    // The flat `m=(k1 v1 k2 v2)` form IS that list, so a trailing
7395                    // key with no value is an error and the parameter is left
7396                    // untouched. The pair walk below invented an empty value with
7397                    // `it.next().unwrap_or_default()`, so `typeset -A a=(k)`
7398                    // silently defined `a[k]=""` where zsh refuses the assignment.
7399                    // In C's `[k]=v` form every element carries a Marker, so its
7400                    // `alen` is always even and the gate cannot fire — which is why
7401                    // the test is scoped to the flat shape here.
7402                    let odd_pairs = !bracket_shape && elems.len() % 2 != 0; // c:4081
7403                                                                            // c:Src/params.c:3485-3502 — when the parser marked ANY
7404                                                                            // element as a `[key]=value` triad, EVERY element must be
7405                                                                            // one:
7406                                                                            //     /*
7407                                                                            //      * We strictly enforce [key]=value syntax for associative
7408                                                                            //      * arrays.  Marker can only indicate a Marker / key / value
7409                                                                            //      * triad; it cannot be there by accident.
7410                                                                            //      */
7411                                                                            //     for (aptr = val; *aptr; aptr += 3)
7412                                                                            //         if (**aptr != Marker) {
7413                                                                            //             ... zerr("bad [key]=value syntax for associative array");
7414                                                                            //             return NULL;
7415                                                                            //         }
7416                                                                            // So a MIXED list (`local -A h=(1 one [2]=two 3 three)`)
7417                                                                            // fails with THAT message, not with arrhashsetfn's
7418                                                                            // odd-element-count one (B02typeset.ztst:63). zshrs's
7419                                                                            // parser hands the elements down as plain strings, so the
7420                                                                            // `[k]=v` shape is recognised exactly the way
7421                                                                            // `bracket_shape` above recognises it.
7422                    let any_bracket = elems.iter().any(|e| e.starts_with('[') && e.contains("]=")); // c:3495
7423                    if any_bracket && !bracket_shape {
7424                        zerr("bad [key]=value syntax for associative array"); // c:3499
7425                        // c:Src/params.c:3500 `return NULL` -> typeset_single c:2326-2329 returns
7426                        // NULL -> bin_typeset c:3153-3156 `returnval = 1`; no attribute stamps.
7427                        returnval = 1;
7428                        continue;
7429                                                                              // c:3500 `return NULL;` — the parameter is NOT set.
7430                    } else if odd_pairs {
7431                        // c:4083 — `zerr(...)`; zshrs's zerr raises ERRFLAG_ERROR
7432                        // itself (c:Src/utils.c:194), so the shell aborts the rest
7433                        // of the input exactly as C does.
7434                        zerr("bad set of key/value pairs for associative array");
7435                    // c:4083
7436                    // c:4084 `return;` — the parameter is NOT set.
7437                    } else {
7438                        let mut map: IndexMap<String, String> = IndexMap::new();
7439                        if bracket_shape {
7440                            for e in &elems {
7441                                let close = e.find("]=").unwrap();
7442                                let k = e[1..close].to_string();
7443                                let v = e[close + 2..].to_string();
7444                                map.insert(k, v);
7445                            }
7446                        } else {
7447                            let mut it = elems.into_iter(); // c:2960 pair walk
7448                            while let Some(k) = it.next() {
7449                                let v = it.next().unwrap_or_default();
7450                                map.insert(k, v); // c:2964 hashtab insert
7451                            }
7452                        }
7453                        crate::ported::exec::set_assoc(n, map.clone());
7454                    }
7455                } else if crate::dash_mode::bash_mode()
7456                    && !elems.is_empty()
7457                    && elems.iter().all(|e| e.starts_with('[') && e.contains("]="))
7458                    && elems.iter().all(|e| {
7459                        e.find("]=")
7460                            .map(|c| e[1..c].trim().parse::<usize>().is_ok())
7461                            .unwrap_or(false)
7462                    })
7463                {
7464                    // !!! BASH-MODE GATE !!! bash `declare -a a=([5]=x [10]=y)`
7465                    // — explicit 0-based indices in an indexed-array literal
7466                    // (the assoc branch above handles the `-A` form). Place each
7467                    // value at its index and record the un-indexed slots as
7468                    // sparse holes, mirroring the plain `a=([i]=v)` path.
7469                    let mut pairs: Vec<(usize, String)> = Vec::new();
7470                    for e in &elems {
7471                        let close = e.find("]=").unwrap();
7472                        if let Ok(idx) = e[1..close].trim().parse::<usize>() {
7473                            pairs.push((idx, e[close + 2..].to_string()));
7474                        }
7475                    }
7476                    let len = pairs.iter().map(|(i, _)| *i + 1).max().unwrap_or(0);
7477                    let mut dense = vec![String::new(); len];
7478                    let mut explicit: std::collections::BTreeSet<usize> =
7479                        std::collections::BTreeSet::new();
7480                    for (i, v) in pairs {
7481                        if i < dense.len() {
7482                            dense[i] = v;
7483                            explicit.insert(i);
7484                        }
7485                    }
7486                    crate::ported::exec::set_array(n, dense.clone());
7487                    crate::bash_arrays::clear(n);
7488                    crate::bash_arrays::note_holes_outside(n, &explicit, dense.len());
7489                } else if elems.iter().any(|e| e.starts_with(crate::ported::zsh_h::Marker)) {
7490                    // c:3019-3020 — the compiler emitted `[k]=v` elements as
7491                    // Marker triads (c:Src/subst.c:49-79), so the value goes to
7492                    // assignaparam with ASSPM_KEY_VALUE, which places each one
7493                    // by index (c:Src/params.c:3436-3541).
7494                    let _ = crate::ported::params::assignaparam(
7495                        n,
7496                        elems.clone(),
7497                        crate::ported::zsh_h::ASSPM_KEY_VALUE,
7498                    );
7499                    // !!! BASH-MODE GATE !!! and then the same sparse marking
7500                    // the two literal forms above do. This is the shape the
7501                    // compiler actually produces for `declare -a a=([5]=x
7502                    // [10]=y)` — the bracket-form branch above never sees it,
7503                    // because by then the elements are Marker triads and not
7504                    // `[5]=x` strings — so the declare form came out DENSE:
7505                    // eleven elements with nine empties where bash reports two.
7506                    // Triad layout is [Marker, index, value] (c:Src/subst.c:49-79),
7507                    // matching the `a=(…)` path in fusevm_bridge.
7508                    if crate::dash_mode::sparse_arrays() {
7509                        let marker = crate::ported::zsh_h::Marker;
7510                        let pure_indexed = !elems.is_empty()
7511                            && elems.len() % 3 == 0
7512                            && elems.chunks(3).all(|ch| ch[0].starts_with(marker));
7513                        if pure_indexed {
7514                            let mut explicit: std::collections::BTreeSet<usize> =
7515                                std::collections::BTreeSet::new();
7516                            for ch in elems.chunks(3) {
7517                                if let Ok(i) = ch[1].trim().parse::<usize>() {
7518                                    explicit.insert(i);
7519                                }
7520                            }
7521                            let len = crate::ported::params::getaparam(n)
7522                                .map(|a| a.len())
7523                                .unwrap_or(0);
7524                            crate::bash_arrays::clear(n);
7525                            crate::bash_arrays::note_holes_outside(n, &explicit, len);
7526                        }
7527                    }
7528                } else {
7529                    // c:2980-2995 — plain array.
7530                    crate::ported::exec::set_array(n, elems.clone());
7531                }
7532                // c:2357 — on a genuine type change (was NOT array/hashed), drop
7533                // the old scalar padding flags; re-add only what THIS command's
7534                // `on` requests (so `typeset -aZ 3 arr=(...)` still zero-pads).
7535                if !prior_was_arraylike {
7536                    let pad = (PM_LEFT | PM_RIGHT_B | PM_RIGHT_Z) as i32;
7537                    if let Ok(mut tab) = paramtab().write() {
7538                        if let Some(pm) = tab.get_mut(n) {
7539                            pm.node.flags = (pm.node.flags & !pad) | (on as i32 & pad);
7540                        }
7541                    }
7542                }
7543                // c:2330-2337 (typeset_single) — `if (!(pm =
7544                // assignaparam(pname, ..., flags))) return NULL;
7545                // ... if (errflag) return NULL;` A readonly
7546                // rejection inside setarrvalue (c:Src/params.c:2900)
7547                // sets errflag and refuses the value; typeset_single
7548                // returns NULL and bin_typeset records `returnval = 1`
7549                // (c:3153-3156). Skip the attribute stamps — C never
7550                // reaches them on this path. Gated on usepm: the
7551                // c:2336 check lives ONLY in the reuse-existing-pm
7552                // branch.
7553                if usepm_existing && (errflag.load(Relaxed) & ERRFLAG_ERROR) != 0 {
7554                    returnval = 1; // c:3156
7555                    continue; // c:2337 return NULL
7556                }
7557                // c:2510-2520 — `on = pm->node.flags;` then stamp the
7558                // attribute bits on the just-assigned param. The
7559                // scalar-assign arm below does the same; the array /
7560                // assoc `=(...)` init path was missing this, so
7561                // `typeset -ax ARR=(a b)` left PM_EXPORTED unset on
7562                // the paramtab entry. `(t)ARR` then read `array`
7563                // instead of `array-export`, and `typeset -p ARR`
7564                // emitted `typeset -a ARR=...` instead of `-ax`.
7565                let post_assign_mask = (PM_READONLY
7566                    | PM_EXPORTED
7567                    | PM_LEFT
7568                    | PM_RIGHT_B
7569                    | PM_RIGHT_Z
7570                    | PM_TAGGED
7571                    | PM_HIDE
7572                    | PM_HIDEVAL
7573                    | PM_UNIQUE) as i32;
7574                let post_assign_to_set = (on
7575                    & (PM_READONLY
7576                        | PM_EXPORTED
7577                        | PM_LEFT
7578                        | PM_RIGHT_B
7579                        | PM_RIGHT_Z
7580                        | PM_TAGGED
7581                        | PM_HIDE
7582                        | PM_HIDEVAL
7583                        | PM_UNIQUE)) as i32;
7584                if post_assign_to_set != 0 {
7585                    if let Ok(mut tab) = paramtab().write() {
7586                        if let Some(pm) = tab.get_mut(n) {
7587                            // c:2289 MERGE — preserve untouched attributes.
7588                            pm.node.flags |= post_assign_to_set;
7589                        }
7590                    }
7591                }
7592            } else {
7593                // c:2232-2238 (typeset_single) — a SCALAR assignment to an
7594                // existing ARRAY/HASHED param WITHOUT a type flag to convert it
7595                // is inconsistent: `typeset -A x=(k v); typeset x=scalar` errors.
7596                // (An explicit type flag — `typeset -i x=9` — is a type change
7597                // handled above and does NOT reach here.)
7598                let target_is_arraylike = paramtab().read().ok().and_then(|t| {
7599                    t.get(n).map(|pm| {
7600                        let typ = PM_TYPE(pm.node.flags as u32);
7601                        typ == PM_ARRAY || typ == PM_HASHED
7602                    })
7603                }) == Some(true);
7604                let requesting_type =
7605                    (on as u32 & (PM_INTEGER | PM_EFLOAT | PM_FFLOAT | PM_ARRAY | PM_HASHED)) != 0;
7606                // c:2115-2126 — `chflags = ((off & pm->node.flags) | (on &
7607                // ~pm->node.flags)) & (PM_INTEGER|...|PM_AUTOLOAD)`; a type
7608                // change (`typeset +a arr=s` removes PM_ARRAY) sets tc and
7609                // clears usepm, so the c:2233 inconsistency test never runs.
7610                let removing_type = paramtab().read().ok().and_then(|t| {
7611                    t.get(n).map(|pm| {
7612                        let pmf = pm.node.flags as u32;
7613                        let chflags = ((off as u32 & pmf) | (on as u32 & !pmf))
7614                            & (PM_INTEGER
7615                                | PM_EFLOAT
7616                                | PM_FFLOAT
7617                                | PM_HASHED
7618                                | PM_ARRAY
7619                                | PM_TIED
7620                                | PM_AUTOLOAD);
7621                        chflags != 0 && chflags != (PM_EFLOAT | PM_FFLOAT) // c:2119
7622                    })
7623                }) == Some(true);
7624                let requesting_type = requesting_type || removing_type;
7625                // c:2216 — the whole c:2233-2237 inconsistency test lives INSIDE
7626                // `if (usepm) {`, i.e. it only applies when typeset_single is
7627                // RE-USING the existing pm. `usepm` is seeded at c:2062-2064
7628                // (`pm && !(pm->node.flags & PM_UNSET) || ...`) and then CLEARED
7629                // at c:2078-2090 when we are localizing a parameter that lives
7630                // at an outer scope:
7631                //     if (usepm && locallevel != pm->level && (on & PM_LOCAL)) {
7632                //         if ((pm->node.flags & PM_SPECIAL) && !(on & PM_HIDE)
7633                //             && !(pm->node.flags & PM_HIDE & ~off))
7634                //             newspecial = NS_NORMAL;
7635                //         usepm = 0;
7636                //     }
7637                // So `local path=/a/b` in a function NEVER reaches c:2236 in C:
7638                // `path` is PM_ARRAY|PM_TIED|PM_SPECIAL at level 0 while
7639                // locallevel is 1, usepm drops to 0, and control falls through
7640                // to the fresh-parameter path at c:2340, which compares the
7641                // value shape only against the REQUESTED `on` flags (handled by
7642                // the c:2345 test just below). zshrs evaluated c:2236
7643                // unconditionally against whatever was in paramtab, so EVERY
7644                // `local <tied-or-array-special>=<scalar>` was rejected —
7645                // `path`, `cdpath`, `fignore`, `manpath`, `fpath`, ... Bug #1110.
7646                // `usepm_existing` / `pm_level_existing` are the PRE-createparam
7647                // snapshots taken at the top of this arg's iteration, which is
7648                // exactly the state c:2062-2090 reads.
7649                let usepm = usepm_existing
7650                    && !(pm_level_existing != cur_locallevel && (on as u32 & PM_LOCAL) != 0); // c:2078
7651                // c:2355-2378 — `if (tc && !OPT_ISSET(ops,'p'))`: removing
7652                // the array/hash type (`typeset +a arr=s`) keeps only
7653                // READONLY|EXPORTED (`on |= ~off & (PM_READONLY|PM_EXPORTED)
7654                // & pm->node.flags`, c:2357) and `unsetparam_pm(pm, 0, 1)`
7655                // (c:2378); the scalar path below then creates it afresh.
7656                // A numeric type REMOVED with no numeric type requested
7657                // (`typeset +i n=s`) is the same tc; a numeric type requested
7658                // is converted by the pre-assign arm below.
7659                let off_removes_type = paramtab().read().ok().and_then(|t| {
7660                    t.get(n).map(|pm| {
7661                        (off as u32 & pm.node.flags as u32 & (PM_INTEGER | PM_EFLOAT | PM_FFLOAT))
7662                            != 0
7663                            && (on as u32 & (PM_INTEGER | PM_EFLOAT | PM_FFLOAT)) == 0
7664                    })
7665                }) == Some(true);
7666                let mut on = if usepm
7667                    && removing_type
7668                    && (target_is_arraylike || off_removes_type)
7669                    && !OPT_ISSET(&ops, b'p')
7670                {
7671                    let pmf = paramtab()
7672                        .read()
7673                        .ok()
7674                        .and_then(|t| t.get(n).map(|pm| pm.node.flags as u32))
7675                        .unwrap_or(0);
7676                    let carried = !(off as u32) & (PM_READONLY | PM_EXPORTED) & pmf; // c:2357
7677                    if let Ok(mut tab) = paramtab().write() {
7678                        if let Some(pm) = tab.get_mut(n) {
7679                            pm.node.flags &= !(PM_READONLY as i32); // c:2359
7680                        }
7681                    }
7682                    crate::ported::params::unsetparam(n); // c:2378
7683                    // c:2521 — `createparam(pname, on & ~PM_READONLY)` re-types the
7684                    // struct a kept local left behind (Src/params.c:1132 `pm = oldpm`).
7685                    let _ = createparam(n, ((on | carried) & !(PM_READONLY | PM_LOCAL)) as i32);
7686                    on | carried
7687                } else {
7688                    on
7689                };
7690                // c:2071 `usepm = 2` joins the c:2233 test (same c:2078 level rule).
7691                let usepm_cmp = usepm
7692                    || (special_unset_existing
7693                        && !(pm_level_existing != cur_locallevel && (on as u32 & PM_LOCAL) != 0));
7694                if usepm_cmp && target_is_arraylike && !requesting_type {
7695                    zerrnam(name, &format!("{}: inconsistent type for assignment", n)); // c:2236
7696                    returnval = 1;
7697                    continue;
7698                }
7699                // c:2062-2064 — `usepm = pm && (!(pm->node.flags & PM_UNSET) || ...)`:
7700                // an UNSET non-special pm (e.g. `typeset -a g` under
7701                // TYPESET_TO_UNSET, c:2540-2541) is not reused, so control reaches
7702                // the fresh-parameter path whose c:2521 `createparam(pname, on &
7703                // ~PM_READONLY)` takes over the unset struct (Src/params.c:1132
7704                // `pm = oldpm`) and re-types it from `on` alone — `typeset g=s`
7705                // leaves a SCALAR, not the old declared array.
7706                // The same holds for every type, not just arrays: `integer y; unset
7707                // y; typeset -u y=abc` re-types the struct from `-u` alone, and
7708                // assignsparam (whose fetchvalue treats an unset, undeclared node
7709                // as absent, c:Src/params.c:2264-2265) then finds it set. Only the
7710                // REUSE arm of createparam (c:Src/params.c:1130 `oldpm->level ==
7711                // locallevel || !(flags & PM_LOCAL)`) is modelled here; a shadow
7712                // of an outer-scope name was already created above.
7713                if !usepm {
7714                    let unset_plain = paramtab().read().ok().and_then(|t| {
7715                        t.get(n).map(|pm| {
7716                            (pm.node.flags as u32 & PM_UNSET) != 0
7717                                && (pm.node.flags as u32 & PM_SPECIAL) == 0
7718                                && (pm.level == cur_locallevel || (on as u32 & PM_LOCAL) == 0)
7719                        })
7720                    }) == Some(true);
7721                    if unset_plain {
7722                        let _ = createparam(n, (on as u32 & !(PM_READONLY | PM_LOCAL)) as i32); // c:2521
7723                    }
7724                }
7725                // c:Src/builtin.c:2342-2345 (second half of the inconsistency
7726                // test) — a SCALAR value assigned to an explicitly-REQUESTED
7727                // array/hashed declaration (`typeset -a g=1`, `typeset -A h=1`,
7728                // `typeset -aU u=1`) is inconsistent. The check above only
7729                // caught a scalar assigned to a pre-EXISTING array with no type
7730                // flag; this catches the `-a`/`-A` decl form (paren-RHS to a
7731                // non-array is the mirror half at builtin.rs:5687). zsh errors
7732                // and leaves the param UNSET (a pre-existing value dropped too).
7733                // Bug #1028.
7734                if (on as u32 & (PM_ARRAY | PM_HASHED)) != 0 {
7735                    zerrnam(name, &format!("{}: inconsistent type for assignment", n)); // c:2345
7736                    crate::ported::params::unsetparam(n);
7737                    returnval = 1;
7738                    continue;
7739                }
7740                // c:3010-3030 — `name=value` scalar assign. C-canonical
7741                // `setsparam` (Src/params.c:3350) writes paramtab; the
7742                // env mirror at `Src/params.c:3024 addenv` follows.
7743                // PM_LOWER / PM_UPPER do NOT fold at assignment. `setstrvalue`
7744                // (c:Src/params.c:2677) has no case arm at all — the only two
7745                // fold sites in the C source are `getstrvalue`'s VALFLAG_SUBST
7746                // tail (c:2497-2506, i.e. READ during substitution) and
7747                // `copyenvstr` (c:5434-5442, the env mirror). The stored value
7748                // stays verbatim, which is why removing the attribute restores
7749                // the original text and why `typeset -p` shows it unfolded:
7750                //     typeset -l v=ABC; typeset -p v   → typeset -l v=ABC
7751                //     typeset -l v=ABC; typeset +l v; print $v   → ABC
7752                // Only the env mirror below folds, per copyenvstr.
7753                let env_val: String = if (on & PM_LOWER) != 0 {
7754                    // c:5439-5440 — `if (flags & PM_LOWER) *s = tulower(*s)`.
7755                    raw_v.to_ascii_lowercase()
7756                } else if (on & PM_UPPER) != 0 {
7757                    // c:5441-5442 — `else if (flags & PM_UPPER) *s = tuupper(*s)`.
7758                    raw_v.to_ascii_uppercase()
7759                } else {
7760                    raw_v.to_string()
7761                };
7762                // c:typeset_single — createparam with the type flag
7763                // BEFORE assignsparam, so assignstrvalue's PM_TYPE
7764                // dispatch (params.c:2748) routes the value through
7765                // the correct setfn:
7766                //   - PM_INTEGER → intsetfn (mathevali → u_val)
7767                //   - PM_EFLOAT/PM_FFLOAT → floatsetfn (parsefloat → u_dval)
7768                //   - PM_SCALAR → strsetfn (u_str)
7769                // The previous Rust ordering (setsparam first, then
7770                // flip flags) wrote "5" to u_str then changed PM_TYPE
7771                // to PM_INTEGER without migrating u_str → u_val, so
7772                // getsparam(n) read u_val=0 instead of 5.
7773                // c:2748-2784 — pre-assign type flags only (PM_INTEGER
7774                // etc. — affect storage / setfn dispatch). Post-assign
7775                // attributes (PM_READONLY / PM_EXPORTED / justification
7776                // bits) are stamped AFTER setsparam since the C path
7777                // (c:2475 `assignsparam(pname, val, 0)` → c:2510
7778                // `on = pm->node.flags`) sets PM_READONLY only after
7779                // the value lands. Mixing them pre-assign caused
7780                // `readonly y=hello` to error "read-only variable: y"
7781                // — the freshly-created pm had PM_READONLY which
7782                // blocked its OWN initial assign.
7783                let pre_assign_mask =
7784                    (PM_INTEGER | PM_EFLOAT | PM_FFLOAT | PM_LOWER | PM_UPPER | PM_NAMEREF) as i32;
7785                let pre_assign_to_set = (on
7786                    & (PM_INTEGER | PM_EFLOAT | PM_FFLOAT | PM_LOWER | PM_UPPER | PM_NAMEREF))
7787                    as i32;
7788                if pre_assign_to_set != 0 {
7789                    let pname_in_tab = paramtab()
7790                        .read()
7791                        .map(|t| t.contains_key(n))
7792                        .unwrap_or(false);
7793                    if !pname_in_tab {
7794                        // c:1132+ createparam(name, type_flags) — fresh.
7795                        let _ = createparam(n, pre_assign_to_set);
7796                    } else {
7797                        // c:2355-2378 tc (type-conversion) — flip the
7798                        // PM_TYPE bits on the existing param BEFORE
7799                        // re-assigning so assignstrvalue routes through
7800                        // the new type's setfn.
7801                        //
7802                        // c:2357 — a TYPE change deletes and recreates the param
7803                        // carrying ONLY READONLY|EXPORTED, so the old scalar
7804                        // padding flags (-L/-R/-Z) do NOT survive it: after
7805                        // `typeset -Z 3 x=7; typeset -i x=1` the type is plain
7806                        // `integer`, not `integer-right_zeros`. When the new type
7807                        // is numeric (INTEGER/EFLOAT/FFLOAT), clear the padding
7808                        // flags from the old param too. (A single `typeset -iZ`
7809                        // command re-adds RIGHT_Z via the post-assign stamp since
7810                        // it is in `on`.)
7811                        let is_numeric_type =
7812                            (pre_assign_to_set as u32 & (PM_INTEGER | PM_EFLOAT | PM_FFLOAT)) != 0;
7813                        // On a numeric type change also drop the scalar padding
7814                        // flags AND — if the old param was an array/hashed —
7815                        // PM_ARRAY|PM_HASHED plus its stored data, since C deletes
7816                        // and recreates: `typeset -A x=(k v); typeset -F x=1.5`
7817                        // yields a plain `float`, not a hashed remnant.
7818                        let old_type = paramtab()
7819                            .read()
7820                            .ok()
7821                            .and_then(|t| t.get(n).map(|pm| PM_TYPE(pm.node.flags as u32)))
7822                            .unwrap_or(PM_SCALAR);
7823                        let was_arraylike = old_type == PM_ARRAY || old_type == PM_HASHED;
7824                        // c:2114-2118 chflags/tc, then c:2351-2359 — a type
7825                        // conversion carries readonly/exported into `on` and
7826                        // turns the old param's readonly off, so the value
7827                        // assignment below is not refused and the post-assign
7828                        // stamp restores the bit: `typeset -r x=ro;
7829                        // typeset -F x=1.5` is a readonly float in zsh. Same
7830                        // step the array-init arm above takes.
7831                        let old_flags = paramtab()
7832                            .read()
7833                            .ok()
7834                            .and_then(|t| t.get(n).map(|pm| pm.node.flags as u32))
7835                            .unwrap_or(0);
7836                        let chflags = ((off as u32 & old_flags) | (on as u32 & !old_flags))
7837                            & (PM_INTEGER
7838                                | PM_EFLOAT
7839                                | PM_FFLOAT
7840                                | PM_HASHED
7841                                | PM_ARRAY
7842                                | PM_TIED
7843                                | PM_AUTOLOAD); // c:2114-2116
7844                        let tc = chflags != 0 && chflags != (PM_EFLOAT | PM_FFLOAT); // c:2118
7845                        if tc && !OPT_ISSET(&ops, b'p') && (old_flags & PM_READONLY) != 0 {
7846                            on |= !off & (PM_READONLY | PM_EXPORTED) & old_flags; // c:2357
7847                            if let Ok(mut tab) = paramtab().write() {
7848                                if let Some(pm) = tab.get_mut(n) {
7849                                    pm.node.flags &= !(PM_READONLY as i32); // c:2359
7850                                }
7851                            }
7852                        }
7853                        let extra_clear = if is_numeric_type {
7854                            let mut c = (PM_LEFT | PM_RIGHT_B | PM_RIGHT_Z) as i32;
7855                            if was_arraylike {
7856                                c |= (PM_ARRAY | PM_HASHED) as i32;
7857                            }
7858                            c
7859                        } else {
7860                            0
7861                        };
7862                        if is_numeric_type && was_arraylike {
7863                            // c:2355-2378 — array/hashed → numeric is a full type
7864                            // conversion: delete the old aggregate (storage +
7865                            // paramtab entry) and recreate as the numeric type, so
7866                            // the value migrates through the new setfn and the
7867                            // result is a clean `integer`/`float`. Modifying flags
7868                            // in place left the hashed storage's setfn dispatch
7869                            // active, so the value stayed a scalar.
7870                            crate::ported::exec::unset_array(n);
7871                            crate::ported::exec::unset_assoc(n);
7872                            if let Ok(mut tab) = paramtab().write() {
7873                                tab.remove(n);
7874                            }
7875                            let _ = createparam(n, pre_assign_to_set);
7876                        } else if let Ok(mut tab) = paramtab().write() {
7877                            if let Some(pm) = tab.get_mut(n) {
7878                                pm.node.flags = (pm.node.flags & !(pre_assign_mask | extra_clear))
7879                                    | pre_assign_to_set;
7880                            }
7881                        }
7882                    }
7883                }
7884                // c:2289 — `pm->node.flags = (pm->node.flags | (on &
7885                // ~PM_READONLY)) & ~(off | PM_UNSET);` — `+n` / `+r`
7886                // WITH a value clear the bits BEFORE the assignment
7887                // runs (`typeset +rn ref=RW`: type change first, so
7888                // the = writes the now-plain scalar, not through the
7889                // old refname).
7890                let off_pre = (off as u32 & (PM_NAMEREF | PM_READONLY)) as i32;
7891                if off_pre != 0 {
7892                    if let Ok(mut tab) = paramtab().write() {
7893                        if let Some(pm) = tab.get_mut(n) {
7894                            pm.node.flags &= !off_pre;
7895                            if (off as u32 & PM_NAMEREF) != 0 {
7896                                pm.width = 0;
7897                                pm.base = 0;
7898                            }
7899                        }
7900                    }
7901                }
7902                // c:2322 — typeset assigns via `assignsparam(pname, ..., 0)`:
7903                // flags=0, NOT setsparam's ASSPM_WARN (params.c:3294), so an
7904                // explicit `typeset -g NAME=val` inside a function never
7905                // trips WARN_CREATE_GLOBAL. Routing through setsparam here
7906                // made zshrs warn where zsh is silent (f-sy-h's
7907                // `typeset -g _ZSH_HIGHLIGHT_PRIOR_BUFFER=...`).
7908                // c:2577-2604 — the `if (ASG_VALUEP(asg) && !dont_set)` tail of
7909                // typeset_single dispatches on the TYPE OF THE PARAM WE ENDED UP
7910                // WITH, not on the shape of the value:
7911                //     if (pm->node.flags & (PM_ARRAY|PM_HASHED)) { ...array... }
7912                //     else { ... assignsparam(pname, ztrdup(asg->value.scalar), 0); }
7913                // `local path=/a/b` arrives here holding an ARRAY pm — the
7914                // newspecial block (c:2381-2425) preserved `path`'s PM_TYPE
7915                // through `pm->node.flags = (PM_TYPE(pm->node.flags) | on |
7916                // PM_SPECIAL) & ~off` — so C takes the array arm. Recomputed
7917                // here rather than reusing the c:2236 snapshot because the
7918                // pre-assign type-flag block above can convert an array param
7919                // to a numeric scalar (`typeset -i path=5`).
7920                let pm_is_arraylike = paramtab().read().ok().and_then(|t| {
7921                    t.get(n).map(|pm| {
7922                        let typ = PM_TYPE(pm.node.flags as u32);
7923                        typ == PM_ARRAY || typ == PM_HASHED
7924                    })
7925                }) == Some(true);
7926                if pm_is_arraylike {
7927                    // c:2564-2576 — verbatim from the C:
7928                    //     /*
7929                    //      * Attempt to assign a scalar value to an array.
7930                    //      * This can happen if the array is special.
7931                    //      * We'll be lenient and guess what the user meant.
7932                    //      * This is how normal assignment works.
7933                    //      */
7934                    //     if (*asg->value.scalar) {
7935                    //         /* Array with one value */
7936                    //         arrayval = mkarray(ztrdup(asg->value.scalar));
7937                    //     } else {
7938                    //         /* Empty array */
7939                    //         arrayval = mkarray(NULL);
7940                    //     }
7941                    let arrayval: Vec<String> = if raw_v.is_empty() {
7942                        Vec::new() // c:2575 mkarray(NULL)
7943                    } else {
7944                        vec![raw_v.to_string()] // c:2572 mkarray(ztrdup(...))
7945                    };
7946                    crate::ported::exec::set_array(n, arrayval); // c:2578 assignaparam
7947                } else {
7948                    crate::ported::params::assignsparam(n, raw_v, 0); // c:2322
7949                }
7950                // c:2326-2328 + c:2336-2337 (typeset_single) —
7951                                                                  // `if (asg->value.scalar && !(pm = assignsparam(
7952                                                                  //     pname, ztrdup(asg->value.scalar), 0)))
7953                                                                  //      return NULL;
7954                                                                  //  ... if (errflag) return NULL;`
7955                                                                  // A readonly rejection inside assignstrvalue
7956                                                                  // (c:Src/params.c:2697) sets errflag and the value is
7957                                                                  // refused; typeset_single returns NULL and bin_typeset
7958                                                                  // records `returnval = 1` (c:3153-3156). Skip the
7959                                                                  // attribute stamps — C never reaches them on this path.
7960                                                                  // Gated on usepm: the c:2336 check lives ONLY in the
7961                                                                  // reuse-existing-pm branch.
7962                if usepm_existing && (errflag.load(Relaxed) & ERRFLAG_ERROR) != 0 {
7963                    returnval = 1; // c:3156
7964                    continue; // c:2337 return NULL
7965                }
7966                // c:2510-2520 — `on = pm->node.flags;` then stamp the
7967                // attribute bits on the just-assigned param.
7968                let post_assign_mask = (PM_READONLY
7969                    | PM_EXPORTED
7970                    | PM_LEFT
7971                    | PM_RIGHT_B
7972                    | PM_RIGHT_Z
7973                    | PM_TAGGED
7974                    | PM_HIDE
7975                    | PM_HIDEVAL
7976                    | PM_UNIQUE) as i32;
7977                let post_assign_to_set = (on
7978                    & (PM_READONLY
7979                        | PM_EXPORTED
7980                        | PM_LEFT
7981                        | PM_RIGHT_B
7982                        | PM_RIGHT_Z
7983                        | PM_TAGGED
7984                        | PM_HIDE
7985                        | PM_HIDEVAL
7986                        | PM_UNIQUE)) as i32;
7987                if post_assign_to_set != 0 {
7988                    // c:2289 — `pm->node.flags = (pm->node.flags | (on & ...)) & ~off`.
7989                    // This is a MERGE: OR in the requested attributes and clear
7990                    // the ones explicitly turned off, but PRESERVE existing
7991                    // attributes the user did not touch. The previous
7992                    // `(flags & !post_assign_mask) | to_set` cleared EVERY mask
7993                    // bit, so `typeset -Z 3 x=7; typeset -r x=ro` lost the -Z
7994                    // (RIGHT_Z is in the mask but not in `on`), yielding
7995                    // `scalar-readonly` where zsh keeps `scalar-right_zeros-readonly`.
7996                    let post_assign_to_clear = (off as u32 & post_assign_mask as u32) as i32;
7997                    if let Ok(mut tab) = paramtab().write() {
7998                        if let Some(pm) = tab.get_mut(n) {
7999                            pm.node.flags =
8000                                (pm.node.flags | post_assign_to_set) & !post_assign_to_clear;
8001                        }
8002                    }
8003                }
8004                // c:1973-1989 (Src/builtin.c, inside typeset_single)
8005                // — `if (arg) { int base = zstrtol(arg, ...) ;
8006                // pm->base = base; }`. The precision arg from `-i N`,
8007                // `-E N`, `-F N` (parsed by execbuiltin as
8008                // ops.args[<F-arg-slot>]) lands on the param's `base`
8009                // field, which convfloat reads as the format-digit
8010                // count. Without this stamp, `typeset -F 2 x=3.14`
8011                // ignored the `2` and printed at the default
8012                // 10-digit precision.
8013                {
8014                    let prec_arg: Option<&str> = if (on & PM_INTEGER) != 0 && OPT_HASARG(&ops, b'i')
8015                    {
8016                        OPT_ARG(&ops, b'i')
8017                    } else if (on & PM_EFLOAT) != 0 && OPT_HASARG(&ops, b'E') {
8018                        OPT_ARG(&ops, b'E')
8019                    } else if (on & PM_FFLOAT) != 0 && OPT_HASARG(&ops, b'F') {
8020                        OPT_ARG(&ops, b'F')
8021                    } else {
8022                        None
8023                    };
8024                    if let Some(s) = prec_arg {
8025                        if let Ok(b) = s.trim().parse::<i32>() {
8026                            if let Ok(mut tab) = paramtab().write() {
8027                                if let Some(pm) = tab.get_mut(n) {
8028                                    pm.base = b;
8029                                }
8030                            }
8031                        }
8032                    }
8033                }
8034                // c:2009-2014 typeset_setwidth — `-L N` / `-R N` / `-Z N`
8035                // install pm.width = N. The auto-fallback in assignsparam
8036                // (PM_INTEGER block at params.rs:3619) stamps width to
8037                // s.len() when PM_RIGHT_Z is set but width==0; that lands
8038                // BEFORE the post-assign PM_RIGHT_Z stamp here, so the
8039                // user's explicit `-Z 6` was being overwritten by the
8040                // value's char count. Set width AFTER setsparam so the
8041                // explicit option arg wins over the auto-fallback.
8042                {
8043                    let width_arg: Option<&str> =
8044                        if (on as u32 & PM_LEFT) != 0 && OPT_HASARG(&ops, b'L') {
8045                            OPT_ARG(&ops, b'L')
8046                        } else if (on as u32 & PM_RIGHT_B) != 0 && OPT_HASARG(&ops, b'R') {
8047                            OPT_ARG(&ops, b'R')
8048                        } else if (on as u32 & PM_RIGHT_Z) != 0 && OPT_HASARG(&ops, b'Z') {
8049                            OPT_ARG(&ops, b'Z')
8050                        } else {
8051                            None
8052                        };
8053                    if let Some(s) = width_arg {
8054                        if let Ok(w) = s.trim().parse::<i32>() {
8055                            if let Ok(mut tab) = paramtab().write() {
8056                                if let Some(pm) = tab.get_mut(n) {
8057                                    pm.width = w;
8058                                }
8059                            }
8060                        }
8061                    }
8062                }
8063                // c:Src/params.c:3024 addenv — only mirror to OS env
8064                // when PM_EXPORTED is in flags or already-exported.
8065                // c:2306 — `if (!(pm->node.flags & (PM_ARRAY|PM_HASHED)))`
8066                // guards the whole addenv/delenv block: an array/hashed param
8067                // never gets a scalar env entry of its own (a TIED array
8068                // reaches the environment through its scalar partner via
8069                // `arrfixenv`, Src/params.c). The lenient scalar-to-array arm
8070                // above must not write `path=/a/b` into the environment
8071                // alongside the tied `PATH`.
8072                let already_exported = env::var_os(n).is_some();
8073                if !pm_is_arraylike && ((on & PM_EXPORTED) != 0 || already_exported) {
8074                    env::set_var(n, &env_val); // c:3024 addenv (value via copyenvstr, c:5434)
8075                }
8076            }
8077        } else if is_hashed || is_array {
8078            // c:Src/builtin.c:2117-2119 — `chflags = ((off & pm->node.flags) |
8079            // (on & ~pm->node.flags)) & (…|PM_HASHED|PM_ARRAY|…); tc = chflags
8080            // && chflags != (PM_EFLOAT|PM_FFLOAT);`. Re-declaring an existing
8081            // association with `-a` turns PM_ARRAY on where the node has it
8082            // off, so `tc` is set and c:2351-2374 runs `unsetparam_pm(pm, 0,
8083            // 1)` on the old parameter before the new one is built. That
8084            // reaches `stdunsetfn`'s PM_HASHED arm (c:Src/params.c:3922-3925),
8085            // whose `pm->gsu.h->setfn(pm, NULL)` is `hashsetfn`
8086            // (c:Src/params.c:4045-4049) — `deleteparamtable(pm->u.hash)`.
8087            // Re-declaring with `-A` leaves `on & ~pm->node.flags` empty, so
8088            // `tc` stays 0 and the pairs survive; that is why only the
8089            // away-from-association direction discards.
8090            //
8091            // !!! WARNING: RUST-ONLY HAZARD (the structures do NOT match) !!!
8092            // In C the pairs live INSIDE the Param (`pm->u.hash`), so dropping
8093            // the Param inherently drops them. zshrs keeps them in the
8094            // parallel `paramtab_hashed_storage` map keyed by NAME, with no
8095            // type and no scope dimension, so flipping the node's type bits
8096            // left the row in place and every SUBSCRIPTED read kept resolving
8097            // through it:
8098            //     typeset -A H; H[x]=1; typeset -a H; H=(p q)
8099            // answered `${H[1]}` / `${H[-1]}` / `${H[1,2]}` empty and
8100            // `${(k)H}` / `${(v)H}` with the dead `x` / `1` pair, while
8101            // `${(t)H}`, `${#H}`, `${H[@]}` and plain `$H` were already
8102            // correct — the array was stored, it just could not be read back
8103            // by index. The row therefore has to be discarded by hand here.
8104            if is_array && !is_hashed {
8105                let was_hashed = paramtab()
8106                    .read()
8107                    .ok()
8108                    .and_then(|t| t.get(arg).map(|pm| PM_TYPE(pm.node.flags as u32) == PM_HASHED))
8109                    .unwrap_or(false);
8110                if was_hashed {
8111                    // c:Src/params.c:4048 deleteparamtable(pm->u.hash)
8112                    crate::ported::exec::unset_assoc(arg);
8113                    if let Ok(mut m) = crate::ported::params::paramtab_hashed_storage().lock() {
8114                        m.remove(arg);
8115                    }
8116                }
8117            }
8118            // c:3060-3070 — bare name + `-A`/`-a` declares an empty
8119            // assoc/array.
8120            if is_hashed {
8121                // c:2521 — C just calls `createparam(pname, on & ~PM_READONLY)`
8122                // here; it never installs an empty hash of its own. For the
8123                // newspecial shadow (c:2386-2425) the preserved Param keeps
8124                // pointing at the SPECIAL's table, so `local -A +h commands`
8125                // still enumerates the live command hash. zshrs's magic module
8126                // hashes (Src/Modules/parameter.c partab[]) are reached by
8127                // FALLING THROUGH `paramtab_hashed_storage` to the row's
8128                // scanfn (params.rs gethkparam), so materialising an empty bag
8129                // for one of them is not "an empty local assoc", it is a
8130                // blanked special: `$#commands` read 0 inside `_command_names`
8131                // and `compadd -k commands` offered nothing.
8132                let preserved_special_hash = paramtab()
8133                    .read()
8134                    .ok()
8135                    .and_then(|t| {
8136                        t.get(arg)
8137                            .map(|pm| (pm.node.flags as u32 & PM_SPECIAL) != 0)
8138                    })
8139                    .unwrap_or(false)
8140                    && crate::ported::modules::parameter::PARTAB
8141                        .iter()
8142                        .any(|e| e.name == arg);
8143                if !preserved_special_hash && crate::ported::exec::assoc(arg).is_none() {
8144                    // !!! RUST-ONLY: the empty bag is a declaration, not an
8145                    // assignment. set_assoc routes through sethparam, whose
8146                    // `flags &= ~PM_DEFAULTED` (c:Src/params.c:3590) would turn
8147                    // a TYPESET_TO_UNSET `typeset -A h` into a set, empty hash;
8148                    // C's createparam (c:2521) never assigns. Carry the
8149                    // declaration's PM_DEFAULTED bits across the store.
8150                    let defaulted = paramtab()
8151                        .read()
8152                        .ok()
8153                        .and_then(|t| t.get(arg).map(|pm| pm.node.flags & PM_DEFAULTED as i32))
8154                        .unwrap_or(0);
8155                    crate::ported::exec::set_assoc(arg, IndexMap::new());
8156                    if defaulted != 0 {
8157                        if let Ok(mut t) = paramtab().write() {
8158                            if let Some(pm) = t.get_mut(arg) {
8159                                pm.node.flags |= defaulted;
8160                            }
8161                        }
8162                    }
8163                }
8164            } else if crate::ported::exec::array(arg).is_none() {
8165                crate::ported::exec::set_array(arg, Vec::new());
8166            }
8167            // c:Src/params.c:4087 arrsetfn — when PM_UNIQUE is set on
8168            // an existing array, the canonical setfn applies
8169            // `uniqarray()` to the current contents. `typeset -aU arr`
8170            // on an existing arr must dedupe in place; without this,
8171            // the flag stamp lands on pm.flags but the value stays
8172            // un-deduped until the next assignment.
8173            if is_array && (on as u32 & PM_UNIQUE) != 0 {
8174                let current = crate::ported::exec::array(arg).unwrap_or_default();
8175                // simple_arrayuniq is the in-place dedupe used by
8176                // params.rs arrsetfn (PM_UNIQUE path).
8177                let deduped = {
8178                    let mut seen = std::collections::HashSet::new();
8179                    current
8180                        .into_iter()
8181                        .filter(|x| seen.insert(x.clone()))
8182                        .collect::<Vec<_>>()
8183                };
8184                crate::ported::exec::set_array(arg, deduped);
8185            }
8186            // Stamp attribute bits on paramtab entry — same set as
8187            // the `name=value` post-assign mask.
8188            let post_assign_mask = (PM_READONLY
8189                | PM_EXPORTED
8190                | PM_LEFT
8191                | PM_RIGHT_B
8192                | PM_RIGHT_Z
8193                | PM_TAGGED
8194                | PM_HIDE
8195                | PM_HIDEVAL
8196                | PM_UNIQUE
8197                | PM_HASHED
8198                | PM_ARRAY) as i32;
8199            let post_assign_to_set = (on
8200                & (PM_READONLY
8201                    | PM_EXPORTED
8202                    | PM_LEFT
8203                    | PM_RIGHT_B
8204                    | PM_RIGHT_Z
8205                    | PM_TAGGED
8206                    | PM_HIDE
8207                    | PM_HIDEVAL
8208                    | PM_UNIQUE
8209                    | PM_HASHED
8210                    | PM_ARRAY)) as i32;
8211            if post_assign_to_set != 0 {
8212                if let Ok(mut tab) = paramtab().write() {
8213                    if let Some(pm) = tab.get_mut(arg_name) {
8214                        // c:2289 MERGE — preserve attributes the user did not touch
8215                        // (e.g. `typeset -L 4 x=hi; typeset -r x` keeps -L). `off` bits
8216                        // are cleared separately below.
8217                        pm.node.flags |= post_assign_to_set;
8218                    }
8219                }
8220            }
8221            // c:Src/builtin.c:2289 — `pm->node.flags = ... & ~off`.
8222            // `off` bits in post_assign_mask must clear regardless of
8223            // `on` (e.g. `typeset +U arr=(...)` where on=0,
8224            // off=PM_UNIQUE). The post_assign_to_set gate above
8225            // short-circuits the on=0 case. Bug #234 in docs/BUGS.md.
8226            let off_in_mask = (off as i32) & post_assign_mask;
8227            if off_in_mask != 0 {
8228                if let Ok(mut tab) = paramtab().write() {
8229                    if let Some(pm) = tab.get_mut(arg_name) {
8230                        pm.node.flags &= !off_in_mask;
8231                    }
8232                }
8233            }
8234        } else {
8235            // c:2254-2272 — `if ((on & PM_UNIQUE) && !(pm->node.flags &
8236            //     PM_READONLY & ~off)) { … } else if (PM_TYPE(pm->node.flags)
8237            //     == PM_SCALAR && pm->ename &&
8238            //     (apm = (Param) paramtab->getnode(paramtab, pm->ename))) {
8239            //         x = (*apm->gsu.a->getfn)(apm);
8240            //         uniqarray(x);
8241            //         if (x) arrfixenv(pm->node.nam, x);
8242            //     }`
8243            // The PM_ARRAY half of that block is handled in the `is_array` arm
8244            // above; this is the TIED-SCALAR half. `typeset -T SCALAR=l:o:c:a:l
8245            // array; typeset -U SCALAR` must dedupe the partner ARRAY in place
8246            // (zsh: `SCALAR=l:o:c:a`), which zshrs never did because the flag
8247            // stamp landed on the scalar and nothing touched the peer.
8248            if (on as u32 & PM_UNIQUE) != 0 {
8249                let peer: Option<(String, Vec<String>)> = paramtab()
8250                    .read()
8251                    .ok()
8252                    .and_then(|t| t.get(arg.as_str()).cloned())
8253                    .filter(|pm| {
8254                        // c:2254 — `!(pm->node.flags & PM_READONLY & ~off)`
8255                        (pm.node.flags as u32 & PM_READONLY & !(off as u32)) == 0
8256                            // c:2263 — `PM_TYPE(pm->node.flags) == PM_SCALAR`
8257                            && PM_TYPE(pm.node.flags as u32) == PM_SCALAR
8258                    })
8259                    .and_then(|pm| pm.ename.clone()) // c:2263 `pm->ename`
8260                    .and_then(|ename| {
8261                        paramtab() // c:2265 getnode(paramtab, pm->ename)
8262                            .read()
8263                            .ok()
8264                            .and_then(|t| {
8265                                t.get(&ename).map(|apm| (ename.clone(), apm.u_arr.clone()))
8266                            })
8267                            .and_then(|(n, a)| a.map(|a| (n, a))) // c:2266 getfn
8268                    });
8269                if let Some((ename, x)) = peer {
8270                    let x = crate::ported::params::uniqarray(x); // c:2267
8271                    if let Ok(mut tab) = paramtab().write() {
8272                        if let Some(apm) = tab.get_mut(&ename) {
8273                            apm.u_arr = Some(x.clone());
8274                        }
8275                        // The tied scalar caches the joined string in u_str;
8276                        // refresh it so `$SCALAR` reads the deduped join
8277                        // without waiting for the next write.
8278                        if let Some(spm) = tab.get_mut(arg.as_str()) {
8279                            let sep = match spm.u_tied.as_deref() {
8280                                Some(td) if td.joinchar == 0 => "\0".to_string(),
8281                                Some(td) => ((td.joinchar as u8) as char).to_string(),
8282                                None => ":".to_string(),
8283                            };
8284                            spm.u_str = Some(x.join(&sep));
8285                        }
8286                    }
8287                    crate::ported::params::arrfixenv(arg.as_str(), Some(&x)); // c:2269
8288                }
8289            }
8290            // c:2355-2378 (typeset_single tc branch) — bare `typeset -i n`
8291            // / `-F n` / `-E n` / `-l n` / `-u n` / `-r n` / `export N`
8292            // / `readonly N` converts/stamps the existing param. Split
8293            // into pre-assign (type conversion) and post-assign
8294            // (attribute stamp) the same way the `name=value` arm does.
8295            let pre_assign_mask =
8296                (PM_INTEGER | PM_EFLOAT | PM_FFLOAT | PM_LOWER | PM_UPPER | PM_NAMEREF) as i32;
8297            let pre_assign_to_set = (on
8298                & (PM_INTEGER | PM_EFLOAT | PM_FFLOAT | PM_LOWER | PM_UPPER | PM_NAMEREF))
8299                as i32;
8300            let post_assign_mask = (PM_READONLY
8301                | PM_EXPORTED
8302                | PM_LEFT
8303                | PM_RIGHT_B
8304                | PM_RIGHT_Z
8305                | PM_TAGGED
8306                | PM_HIDE
8307                | PM_HIDEVAL
8308                | PM_UNIQUE) as i32;
8309            let post_assign_to_set = (on
8310                & (PM_READONLY
8311                    | PM_EXPORTED
8312                    | PM_LEFT
8313                    | PM_RIGHT_B
8314                    | PM_RIGHT_Z
8315                    | PM_TAGGED
8316                    | PM_HIDE
8317                    | PM_HIDEVAL
8318                    | PM_UNIQUE)) as i32;
8319            // c:2374 — `s = ztrdup(getsparam(pname));`. Capture the
8320            // pre-conversion scalar value so the re-assignment after
8321            // type flip preserves it through the new setfn.
8322            // `typeset +n ref`: the stored REFNAME (u_str raw) becomes
8323            // the scalar value — getsparam would deref through the
8324            // chain and return the target's value instead (c:2374 via
8325            // the +n type-conversion arm; K01 "remove nameref
8326            // attribute" expects `typeset ptr=var`).
8327            // c:Src/builtin.c:2062-2064 — C computes `usepm` at the TOP of
8328            // typeset_single, BEFORE the parameter is touched, and the bare-name
8329            // print at c:2246 lives inside that branch:
8330            //   usepm = pm && (!(pm->node.flags & PM_UNSET) || OPT_ISSET(ops,'p') ||
8331            //                  (isset(POSIXBUILTINS) &&
8332            //                   (pm->node.flags & (PM_READONLY|PM_EXPORTED))));
8333            // "Here we just avoid using it for the present tests if it's unset."
8334            // So `typeset NAME` on a declared-but-UNSET param prints NOTHING.
8335            // The snapshot MUST be taken here: later in this same iteration the
8336            // re-declaration clears PM_UNSET, so reading the flag at the print
8337            // site always saw it already cleared. TYPESET_TO_UNSET produces
8338            // exactly this state (PM_DEFAULTED = PM_DECLARED|PM_UNSET,
8339            // zsh.h:1934). Bug #1056.
8340            let usepm_at_entry = paramtab()
8341                .read()
8342                .ok()
8343                .and_then(|t| t.get(arg.as_str()).map(|p| p.node.flags as u32))
8344                .map(|f| {
8345                    (f & PM_UNSET) == 0
8346                        || (f & PM_DECLARED) != 0 // c:2059 (55020)
8347                        || (isset(crate::ported::zsh_h::POSIXBUILTINS)
8348                            && (f & (PM_READONLY | PM_EXPORTED)) != 0)
8349                })
8350                .unwrap_or(true);
8351            // c:2194-2206 — POSIXBUILTINS applies stricter readonly rules:
8352            //     if ((on & (PM_READONLY|PM_EXPORTED)) &&
8353            //         (!usepm || (pm->node.flags & PM_UNSET)) && !ASG_VALUEP(asg))
8354            //         on |= PM_UNSET;
8355            //     else if (usepm && (pm->node.flags & PM_READONLY) &&
8356            //              !(on & PM_READONLY) && func != BIN_EXPORT) {
8357            //         zerr("read-only variable: %s", pm->node.nam);
8358            //         return NULL;
8359            //     }
8360            // So `readonly NAME` leaves NAME unset, and `typeset +r NAME` on it
8361            // is refused.
8362            let entry_flags: Option<u32> = paramtab()
8363                .read()
8364                .ok()
8365                .and_then(|t| t.get(arg.as_str()).map(|p| p.node.flags as u32));
8366            let posix_strict = isset(POSIXBUILTINS) && !OPT_ISSET(&ops, b'p'); // c:2194
8367            let usepm_entry = entry_flags.is_some() && usepm_at_entry;
8368            let posix_keep_unset = posix_strict
8369                && (on as u32 & (PM_READONLY | PM_EXPORTED)) != 0
8370                && (!usepm_entry || entry_flags.is_some_and(|f| (f & PM_UNSET) != 0)); // c:2198-2201
8371            // c:2294-2296 (55020) — `off |= PM_UNSET` is skipped for a
8372            // PM_DECLARED parameter, so changing only the flags of a
8373            // TYPESET_TO_UNSET declaration (`typeset -a h; typeset -U h`)
8374            // leaves it unset.
8375            let keep_declared_unset = usepm_entry
8376                && entry_flags
8377                    .is_some_and(|f| (f & (PM_DECLARED | PM_UNSET)) == (PM_DECLARED | PM_UNSET));
8378            if posix_strict
8379                && !posix_keep_unset
8380                && usepm_entry
8381                && entry_flags.is_some_and(|f| (f & PM_READONLY) != 0)
8382                && (on as u32 & PM_READONLY) == 0
8383                && func != BIN_EXPORT
8384            {
8385                zerr(&format!("read-only variable: {}", arg)); // c:2204
8386                returnval = 1;
8387                continue; // c:2205 return NULL
8388            }
8389            // c:3104-3106 — bin_typeset fetches the node with getnode2 "to
8390            // avoid autoloading", so a PM_AUTOLOAD stub is reused as-is
8391            // (its u.str is the module name) and its module stays unloaded.
8392            let autoload_stub = entry_flags.is_some_and(|f| (f & PM_AUTOLOAD) != 0);
8393            let saved_val =
8394                if autoload_stub
8395                    || ((off as u32 & PM_NAMEREF) != 0 && crate::ported::params::is_nameref(arg))
8396                {
8397                    paramtab()
8398                        .read()
8399                        .ok()
8400                        .and_then(|t| t.get(arg.as_str()).and_then(|p| p.u_str.clone()))
8401                } else {
8402                    getsparam(arg)
8403                };
8404            // c:Src/builtin.c:3072 — `if (!getsparam(pname))
8405            //     setsparam(pname, "");`. C zsh's getsparam returns the
8406            // join-of-values for PM_HASHED and PM_ARRAY so this gate
8407            // fires only for truly-undeclared names. zshrs's getsparam
8408            // returns None for assoc/array (it routes through the
8409            // PM_SCALAR / PM_INTEGER / PM_*FLOAT arms only), so this
8410            // branch falsely fired for an existing hash AND triggered
8411            // assignsparam's PM_HASHED→PM_SCALAR coerce at params.rs:
8412            // 5368-5374 which removes the entry from
8413            // paramtab_hashed_storage. Bug #218 in docs/BUGS.md.
8414            //
8415            // Gate on the paramtab flag instead: if the param exists
8416            // and is PM_HASHED/PM_ARRAY, treat it as already-declared
8417            // even though getsparam returned None.
8418            let already_typed = paramtab()
8419                .read()
8420                .ok()
8421                .and_then(|t| {
8422                    t.get(arg).map(|pm| {
8423                        let f = pm.node.flags as u32;
8424                        let typ = PM_TYPE(f);
8425                        typ == PM_HASHED || typ == PM_ARRAY
8426                    })
8427                })
8428                .unwrap_or(false);
8429            // Capture the param's PRIOR numeric class (before the flag
8430            // change below) so the base-stamp arm can reset pm.base when
8431            // switching BETWEEN integer and float — the base field means
8432            // radix for integers but precision for floats, so a leftover
8433            // value leaks (float precision 3 → integer base 3 = "3#10";
8434            // int base 16 → float 16-digit precision). E↔F and same-type
8435            // re-declares keep it (both float, base=precision). zsh 5.9.1
8436            // verified.
8437            let (prior_is_integer, prior_is_float) = paramtab()
8438                .read()
8439                .ok()
8440                .and_then(|t| {
8441                    t.get(arg).map(|pm| {
8442                        let f = pm.node.flags as u32;
8443                        ((f & PM_INTEGER) != 0, (f & (PM_EFLOAT | PM_FFLOAT)) != 0)
8444                    })
8445                })
8446                .unwrap_or((false, false));
8447            // c:2058-2060 — under POSIXBUILTINS an UNSET readonly/exported
8448            // parameter is still reused (`usepm`), and the reuse arm never
8449            // assigns; creating it here would trip its own readonly bit.
8450            let was_fresh = saved_val.is_none()
8451                && !already_typed
8452                && !(posix_keep_unset && usepm_entry)
8453                && !keep_declared_unset; // c:2059 (55020): a declared pm is reused, not assigned
8454            if was_fresh {
8455                // c:3072 — `if (!getsparam(pname)) setsparam(pname, "")`.
8456                // flags=0: a typeset-driven create never trips
8457                // WARN_CREATE_GLOBAL (typeset_single assigns with
8458                // `assignsparam(pname, ..., 0)`, c:2322).
8459                crate::ported::params::assignsparam(arg, "", 0); // c:3074
8460                                                                 // c:Src/builtin.c:2544 — `if (isset(TYPESETTOUNSET))
8461                                                                 //     pm->node.flags |= PM_DEFAULTED;`. Under
8462                                                                 // `setopt typeset_to_unset`, bare `typeset NAME` (no
8463                                                                 // `=`) creates a "declared but not assigned" entry:
8464                                                                 // PM_DECLARED + PM_UNSET (= PM_DEFAULTED). `${+NAME}`
8465                                                                 // and `[[ -v NAME ]]` then return false until an
8466                                                                 // explicit assignment clears PM_UNSET. Bug #280 in
8467                                                                 // docs/BUGS.md.
8468                if isset(crate::ported::zsh_h::TYPESETTOUNSET) {
8469                    if let Ok(mut tab) = paramtab().write() {
8470                        if let Some(pm) = tab.get_mut(arg) {
8471                            pm.node.flags |= PM_DEFAULTED as i32;
8472                        }
8473                    }
8474                }
8475                let _ = was_fresh;
8476            }
8477            // c:Src/builtin.c:2117-2131 — `typeset +a`/`+A` removes the
8478            // array/hashed attribute, which is a TYPE CHANGE (`chflags` picks
8479            // up `off & PM_ARRAY`, `tc=1`, `usepm=0`). UNLIKE the +i/+E/+l
8480            // conversions below — which migrate the stored value back to a
8481            // scalar — an array/assoc has no scalar representation, so the
8482            // recreated scalar is EMPTY. `typeset -a a=(1 2 3); typeset +a a`
8483            // → `a` is a scalar with value "". Only fires when the param is
8484            // ACTUALLY array/hashed (a no-op `+a` on a scalar leaves it be).
8485            // Bug #1029.
8486            if (off as u32 & (PM_ARRAY | PM_HASHED)) != 0 {
8487                let is_arraylike = paramtab().read().ok().and_then(|t| {
8488                    t.get(arg).map(|pm| {
8489                        let typ = PM_TYPE(pm.node.flags as u32);
8490                        typ == PM_ARRAY || typ == PM_HASHED
8491                    })
8492                }) == Some(true);
8493                if is_arraylike {
8494                    // The assoc backing row goes with it. This used to be
8495                    // open-coded here ("unsetparam alone can leave the
8496                    // paramtab_hashed_storage entry, so a later scalar deref
8497                    // still saw the joined values") because `stdunsetfn`'s
8498                    // PM_HASHED arm only cleared `pm.u_hash`, which nothing
8499                    // populates. That arm now does what C's does — c:3922-3925
8500                    // → `hashsetfn` c:4047 `deleteparamtable(pm->u.hash)` — so
8501                    // `unsetparam` drops the row on the way through, the same
8502                    // way C's does, and the hand-rolled copy is gone.
8503                    crate::ported::params::unsetparam(arg);
8504                    crate::ported::params::assignsparam(arg, "", 0); // empty scalar
8505                }
8506            }
8507            // c:Src/builtin.c::typeset_single c:2374-2378 — the `+i`
8508            // / `+E` / `+F` / `+l` / `+u` / `+r` / `+n` paths
8509            // REMOVE the corresponding PM_* flag and migrate the
8510            // stored value back to u_str. C captures
8511            // `s = ztrdup(getsparam(pname))` BEFORE the type-conversion
8512            // unset and restores it after. Without this branch,
8513            // `typeset +i n` (where n was an integer with value 42)
8514            // cleared u_str without copying the integer value back —
8515            // result was `typeset n=''`. Bug #326 in docs/BUGS.md.
8516            //
8517            // ONLY a real type change migrates the value. C decides that with
8518            // `chflags` (c:2117-2119):
8519            //     chflags = ((off & pm->flags) | (on & ~pm->flags)) &
8520            //         (PM_INTEGER|PM_EFLOAT|PM_FFLOAT|PM_HASHED|
8521            //          PM_ARRAY|PM_TIED|PM_AUTOLOAD);
8522            //     tc = chflags && chflags != (PM_EFLOAT|PM_FFLOAT);
8523            // PM_LOWER / PM_UPPER are absent from that mask, so `typeset -l v`
8524            // / `typeset +l v` leave `tc == 0` and fall into the `usepm` arm at
8525            // c:2280, which is a pure flag flip:
8526            //     pm->node.flags = (pm->node.flags | (on & ~PM_READONLY)) & ~off;
8527            // The stored string is never re-read and never rewritten. Migrating
8528            // it here re-stored the case-FOLDED read (getsparam applies the
8529            // substitution-time fold), so removing the attribute could not
8530            // restore the original text.
8531            //
8532            // The `& pm->flags` / `& ~pm->flags` halves matter as much as the
8533            // mask: only bits that actually CHANGE count. `typeset +i v` on a
8534            // param that was never an integer leaves `off & pm->flags == 0`,
8535            // so `tc == 0` and the value is left alone. PM_NAMEREF is a
8536            // zshrs-local addition to the mask (the `+n` conversion at
8537            // builtin.rs restores the stored refname); C keeps namerefs out of
8538            // chflags, so this stays flagged as a deviation.
8539            const TYPE_CONV_BITS: u32 = PM_INTEGER | PM_EFLOAT | PM_FFLOAT | PM_NAMEREF; // c:2117
8540            let cur_flags = paramtab()
8541                .read()
8542                .ok()
8543                .and_then(|t| t.get(arg.as_str()).map(|p| p.node.flags as u32))
8544                .unwrap_or(0);
8545            // c:2117-2119 — chflags, then `tc = chflags && chflags != (PM_EFLOAT|PM_FFLOAT)`
8546            // (a plain E↔F swap keeps the parameter, so it is not a conversion).
8547            let chflags = ((off & cur_flags) | (on & !cur_flags)) & TYPE_CONV_BITS;
8548            let tc = chflags != 0 && chflags != (PM_EFLOAT | PM_FFLOAT); // c:2118
8549            // c:2362-2369 — "Try to carry over a value, but not when changing
8550            // from, to, or between non-scalar types":
8551            //     if (!ASG_VALUEP(asg) && !((pm->node.flags|on) & (PM_ARRAY|PM_HASHED)))
8552            //         asg->value.scalar = dupstring(getsparam(pname));
8553            // `typeset -a a=(1 2); typeset -i a` therefore starts the integer at
8554            // 0. getsparam here joins an array, so re-storing it unconditionally
8555            // fed `1 2` to the integer setfn: "bad math expression".
8556            // `pm->node.flags` is the type at ENTRY: by this point the `+a`/`+A`
8557            // arm above has already replaced an array with an empty scalar
8558            // (`-i` puts PM_ARRAY in `off`), so `cur_flags` no longer shows it.
8559            // `already_typed` was read before that arm ran.
8560            let carry = !already_typed && (on & (PM_ARRAY | PM_HASHED)) == 0;
8561            // c:2351-2359 — a type conversion recreates the parameter:
8562            //     /* Maintain existing readonly/exported status... */
8563            //     on |= ~off & (PM_READONLY|PM_EXPORTED) & pm->node.flags;
8564            //     /* ...but turn off existing readonly so we can delete it */
8565            //     pm->node.flags &= ~PM_READONLY;
8566            // and createparam(pname, on & ~PM_READONLY) (c:2518) gets the
8567            // carried value before the readonly bit returns. Without the
8568            // clear, the value migration below hit the readonly guard:
8569            // `typeset -r x=ro; typeset -F x` said "read-only variable: x"
8570            // where zsh converts it to a readonly float.
8571            let tc_keeps_readonly = tc
8572                && !OPT_ISSET(&ops, b'p')
8573                && (cur_flags & PM_READONLY) != 0
8574                && (off & PM_READONLY) == 0; // c:2357
8575            if tc && !OPT_ISSET(&ops, b'p') && (cur_flags & PM_READONLY) != 0 {
8576                if let Ok(mut tab) = paramtab().write() {
8577                    if let Some(pm) = tab.get_mut(arg.as_str()) {
8578                        pm.node.flags &= !(PM_READONLY as i32); // c:2359
8579                    }
8580                }
8581            }
8582            let pre_assign_to_clear = (off
8583                & (PM_INTEGER | PM_EFLOAT | PM_FFLOAT | PM_LOWER | PM_UPPER | PM_NAMEREF))
8584                as i32;
8585            if pre_assign_to_clear != 0 {
8586                if let Ok(mut tab) = paramtab().write() {
8587                    if let Some(pm) = tab.get_mut(arg.as_str()) {
8588                        pm.node.flags &= !pre_assign_to_clear;
8589                        // `+n` — drop the nameref scope/subscript info
8590                        // along with the flag (c:2374-2378 conversion).
8591                        if (off as u32 & PM_NAMEREF) != 0 {
8592                            pm.width = 0;
8593                            pm.base = 0;
8594                        }
8595                    }
8596                }
8597                // Restore the captured scalar so the cleared-type
8598                // param has its value as a string (PM_SCALAR
8599                // semantics now apply). flags=0 — typeset-internal
8600                // restore, never WARN_CREATE_GLOBAL (c:2322).
8601                if tc && carry {
8602                    if let Some(ref val) = saved_val {
8603                        crate::ported::params::assignsparam(arg, val, 0);
8604                    }
8605                }
8606            }
8607            if pre_assign_to_set != 0 {
8608                if let Ok(mut tab) = paramtab().write() {
8609                    if let Some(pm) = tab.get_mut(arg) {
8610                        // c:2280 — a pure case-attribute change keeps every
8611                        // other flag; only a type change rewrites the type
8612                        // bits. Masking the whole pre-assign set on `-l`/`-u`
8613                        // would silently drop PM_INTEGER from an integer param.
8614                        let clear_mask = if tc {
8615                            pre_assign_mask
8616                        } else {
8617                            (PM_LOWER | PM_UPPER) as i32
8618                        };
8619                        pm.node.flags = (pm.node.flags & !clear_mask) | pre_assign_to_set;
8620                    }
8621                }
8622                // c:2372-2378 — re-assign saved value through new type's
8623                // setfn so u_val (for PM_INTEGER) or u_dval (for PM_*FLOAT)
8624                // catches the value migration from u_str.
8625                if tc && carry {
8626                    if let Some(ref val) = saved_val {
8627                        setsparam(arg, val);
8628                    }
8629                }
8630            }
8631            if tc_keeps_readonly {
8632                // c:2357 — the kept readonly bit is part of `on` for the
8633                // recreated parameter, applied once its value is in.
8634                if let Ok(mut tab) = paramtab().write() {
8635                    if let Some(pm) = tab.get_mut(arg.as_str()) {
8636                        pm.node.flags |= PM_READONLY as i32;
8637                    }
8638                }
8639            }
8640            // c:1973-1989 (Src/builtin.c, inside typeset_single):
8641            //   if (arg) {
8642            //       int base = zstrtol(arg, ..., 10);
8643            //       pm->base = base;
8644            //   }
8645            // The precision arg from `-i N`, `-E N`, `-F N` (parsed
8646            // by execbuiltin as `ops.args[<F-arg-slot>]`) lands on
8647            // the param's `base` field, which `convfloat` (c:5689 in
8648            // params.c) reads as the format-digit count. The
8649            // `name=value` arm above (c:2009-2014 typeset_setwidth
8650            // companion path) already stamps `pm.base`; this bare-
8651            // declare arm was missing it, so `typeset -F 4 f` left
8652            // pm.base=0 and `convfloat` rendered with the default
8653            // 10-digit precision (parity bug #29). With this stamp
8654            // a subsequent `(( f = ... ))` re-assignment preserves
8655            // the `pm.base = 4` set here through `assignnparam`'s
8656            // re-assign path (params.rs:5340-5374, c:2874-2878).
8657            {
8658                let prec_arg: Option<&str> = if (on & PM_INTEGER) != 0 && OPT_HASARG(&ops, b'i') {
8659                    OPT_ARG(&ops, b'i') // c:1974 -i N
8660                } else if (on & PM_EFLOAT) != 0 && OPT_HASARG(&ops, b'E') {
8661                    OPT_ARG(&ops, b'E') // c:1977 -E N
8662                } else if (on & PM_FFLOAT) != 0 && OPT_HASARG(&ops, b'F') {
8663                    OPT_ARG(&ops, b'F') // c:1980 -F N
8664                } else {
8665                    None
8666                };
8667                if let Some(s) = prec_arg {
8668                    if let Ok(b) = s.trim().parse::<i32>() {
8669                        // c:1985 zstrtol
8670                        if let Ok(mut tab) = paramtab().write() {
8671                            if let Some(pm) = tab.get_mut(arg) {
8672                                pm.base = b; // c:1987 pm->base = base
8673                            }
8674                        }
8675                    }
8676                } else {
8677                    // No explicit base/precision arg. When an EXISTING
8678                    // numeric param switches BETWEEN integer and float,
8679                    // reset pm.base to the default (0) — the shared base
8680                    // field would otherwise leak the old type's value
8681                    // (createparam's base default is 0, c:Src/params.c:1155;
8682                    // a numeric type change re-creates the representation).
8683                    // Gated so E↔F and same-type re-declares keep their
8684                    // base/precision (`typeset -i16 f; typeset -i f` stays
8685                    // base 16). zsh 5.9.1 verified.
8686                    let switching_int_float = ((on & PM_INTEGER) != 0 && prior_is_float)
8687                        || ((on & (PM_EFLOAT | PM_FFLOAT)) != 0 && prior_is_integer);
8688                    if !was_fresh && switching_int_float {
8689                        if let Ok(mut tab) = paramtab().write() {
8690                            if let Some(pm) = tab.get_mut(arg) {
8691                                pm.base = 0;
8692                            }
8693                        }
8694                    }
8695                }
8696            }
8697            // c:2528-2533 — the createparam (valueless declare) branch
8698            // must ALSO stamp the `-L`/`-R`/`-Z` field width
8699            // (typeset_setwidth, c:2002-2011). The base block above
8700            // handled `-i`/`-E`/`-F` precision but the width was dropped,
8701            // so `typeset -L 10 f` (declare, then assign later) left
8702            // pm.width=0 and the value never left/right-justified — and
8703            // `typeset -p` rendered `-FL` with no number. The inline-value
8704            // arm (the `name=value` path) already stamps width; only this
8705            // bare-declare arm was missing it.
8706            {
8707                let width_arg: Option<&str> = if (on & PM_LEFT) != 0 && OPT_HASARG(&ops, b'L') {
8708                    OPT_ARG(&ops, b'L') // c:2003
8709                } else if (on & PM_RIGHT_B) != 0 && OPT_HASARG(&ops, b'R') {
8710                    OPT_ARG(&ops, b'R') // c:2005
8711                } else if (on & PM_RIGHT_Z) != 0 && OPT_HASARG(&ops, b'Z') {
8712                    OPT_ARG(&ops, b'Z') // c:2007
8713                } else {
8714                    None
8715                };
8716                if let Some(s) = width_arg {
8717                    if let Ok(w) = s.trim().parse::<i32>() {
8718                        // c:2011 zstrtol
8719                        if let Ok(mut tab) = paramtab().write() {
8720                            if let Some(pm) = tab.get_mut(arg) {
8721                                pm.width = w; // c:2011 pm->width = width
8722                            }
8723                        }
8724                    }
8725                }
8726            }
8727            // c:2510+ — stamp post-assign attributes (PM_EXPORTED,
8728            // PM_READONLY, etc.) on the (possibly newly-created) pm.
8729            if post_assign_to_set != 0 {
8730                if let Ok(mut tab) = paramtab().write() {
8731                    if let Some(pm) = tab.get_mut(arg) {
8732                        // c:2289 MERGE — preserve attributes the user did not touch
8733                        // (e.g. `typeset -L 4 x=hi; typeset -r x` keeps -L). `off` bits
8734                        // are cleared separately below.
8735                        pm.node.flags |= post_assign_to_set;
8736                    }
8737                }
8738                // c:Src/params.c — when PM_UNIQUE is freshly stamped on
8739                // an EXISTING array via bare `typeset -U arr` (no value
8740                // assignment), zsh dedups the current contents in-place
8741                // (verified vs /opt/homebrew/bin/zsh: `arr=(a b a c b);
8742                // typeset -U arr; echo "$arr[@]"` → "a b c"). Apply the
8743                // same dedup here.
8744                // A declared-but-unset array (TYPESET_TO_UNSET) has nothing to
8745                // dedup; C's uniqarray on its empty getfn result is a no-op,
8746                // whereas setaparam here would re-create it as a set array.
8747                let declared_unset = paramtab().read().ok().and_then(|t| {
8748                    t.get(arg).map(|pm| (pm.node.flags as u32 & PM_UNSET) != 0)
8749                }) == Some(true);
8750                if (post_assign_to_set as u32 & PM_UNIQUE) != 0 && !declared_unset {
8751                    let existing = crate::ported::params::getaparam(arg);
8752                    if let Some(arr) = existing {
8753                        let mut seen: std::collections::HashSet<String> =
8754                            std::collections::HashSet::new();
8755                        let deduped: Vec<String> =
8756                            arr.into_iter().filter(|e| seen.insert(e.clone())).collect();
8757                        crate::ported::params::setaparam(arg, deduped);
8758                    }
8759                }
8760                // c:Src/builtin.c:2302-2307 — `if (!(pm->node.flags &
8761                // (PM_ARRAY|PM_HASHED))) { ... addenv(pm, getsparam(pname)); }`.
8762                // Arrays and associative arrays are NOT mirrored to the
8763                // OS env even when PM_EXPORTED is set — env strings are
8764                // single name=value pairs and have no representation
8765                // for indexed or hashed data. zsh silently no-ops the
8766                // export request for these types; zshrs's prior code
8767                // wrote a malformed empty `h=` entry for `export h`
8768                // where h is an assoc. Bug #349.
8769                if (on as u32 & PM_EXPORTED) != 0 {
8770                    let is_array_or_hashed = paramtab()
8771                        .read()
8772                        .ok()
8773                        .and_then(|t| t.get(arg).map(|pm| pm.node.flags as u32))
8774                        .map_or(false, |f| (f & (PM_ARRAY | PM_HASHED)) != 0)
8775                        || crate::ported::params::paramtab_hashed_storage()
8776                            .lock()
8777                            .ok()
8778                            .map_or(false, |s| s.contains_key(arg))
8779                        || crate::ported::exec::array(arg).is_some()
8780                        || crate::ported::exec::assoc(arg).is_some();
8781                    if !is_array_or_hashed {
8782                        // c:Src/builtin.c:2299-2301 — a bare `export NAME` of a parameter that
8783                        // was never assigned (PM_UNSET) does not `addenv`; the environment
8784                        // entry appears with the first assignment.
8785                        if let Some(val) = saved_val.as_deref().or((!was_fresh).then_some("")) {
8786                            // c:Src/builtin.c typeset_single -> export_param
8787                            // (c:Src/params.c:2670) -> zputenv (c:5325), which
8788                            // hands the value to `setenv` as a NUL-TERMINATED C
8789                            // string: an embedded NUL simply ends it.
8790                            // `env::set_var` PANICS on one instead, and the
8791                            // panic is reachable from a bare `export IFS` —
8792                            // zsh's default $IFS is " \t\n\0" (c:Src/params.c
8793                            // ifs default), so the NUL is not an exotic value,
8794                            // it is the stock one. Route through the ported
8795                            // zputenv so this site inherits C's truncation
8796                            // instead of re-deriving it.
8797                            crate::ported::params::zputenv(&format!("{arg}={val}"));
8798                        }
8799                    }
8800                }
8801            }
8802            // c:Src/builtin.c::typeset_single — `typeset +x VAR`
8803            // clears PM_EXPORTED via the `off` mask. C zsh calls
8804            // removeenv on the param, which strips the var from the
8805            // OS environment so child processes no longer see it.
8806            // Must fire INDEPENDENTLY of post_assign_to_set since `+x`
8807            // alone sets `off` (PM_EXPORTED) but `on` is 0, so the
8808            // post_assign_to_set block above doesn't run. Also clear
8809            // the flag bit on the paramtab entry so `(t)V` no longer
8810            // reports `-export`. Bug #201 in docs/BUGS.md.
8811            if (off as u32 & PM_EXPORTED) != 0 {
8812                env::remove_var(arg);
8813                if let Ok(mut tab) = paramtab().write() {
8814                    if let Some(pm) = tab.get_mut(arg) {
8815                        pm.node.flags &= !(PM_EXPORTED as i32);
8816                    }
8817                }
8818            }
8819            // c:Src/builtin.c:2289 — `pm->node.flags = ... & ~off`.
8820            // `off` bits in post_assign_mask must clear regardless of
8821            // `on` (e.g. `typeset +U arr` where on=0, off=PM_UNIQUE).
8822            // The post_assign_to_set gate above short-circuits the
8823            // on=0 case. Same family as `+x` block immediately above.
8824            // Bug #234 in docs/BUGS.md.
8825            let off_in_mask = (off as i32) & post_assign_mask;
8826            if off_in_mask != 0 {
8827                if let Ok(mut tab) = paramtab().write() {
8828                    if let Some(pm) = tab.get_mut(arg) {
8829                        pm.node.flags &= !off_in_mask;
8830                    }
8831                }
8832            }
8833            // c:2201 `on |= PM_UNSET` / c:2283-2286 "Keep unset if using readonly
8834            // in POSIX mode" — the valueless declaration leaves the parameter
8835            // unset after its attributes are stamped.
8836            if posix_keep_unset || keep_declared_unset {
8837                if let Ok(mut tab) = paramtab().write() {
8838                    if let Some(pm) = tab.get_mut(arg) {
8839                        pm.node.flags |= PM_UNSET as i32;
8840                    }
8841                }
8842            }
8843            // c:Src/builtin.c::typeset_single c:2241-2246 — when no
8844            // type flags and no value assignment, `typeset NAME` is a
8845            // display request: print the existing param in the
8846            // canonical form. C uses
8847            // `printparamnode(&pm->node, PRINT_INCLUDEVALUE|with_ns)`.
8848            // Without this, `typeset h` (where h is an assoc) emitted
8849            // nothing. The earlier per-arg print at typeset_single
8850            // line 3181 isn't called from bin_typeset's per-arg loop
8851            // — the loop does its own inline work. Bug #218 in
8852            // docs/BUGS.md.
8853            // c:Src/builtin.c::typeset_single c:2241-2246 — bare
8854            // `typeset NAME` (no on/off flags except PM_LOCAL which
8855            // bin_typeset auto-applies at c:2808) prints the param.
8856            // PM_LOCAL is added implicitly when not -g / -x / -m so
8857            // mask it out when checking "are any user flags set".
8858            //
8859            // c:Src/builtin.c:2078-2090 — the bare form prints UNLESS it
8860            // is localizing a variable from an outer scope. zsh clears
8861            // `usepm` (→ no print) only when `locallevel != pm->level &&
8862            // (on & PM_LOCAL)` — i.e. the existing param lives at a
8863            // different (outer/global) level and we're making a local.
8864            // Re-declaring a param that ALREADY exists at the current
8865            // level (`f(){ typeset x=1; typeset x }`) keeps usepm and
8866            // DOES print `x=1`. The previous `at_top_scope` gate wrongly
8867            // suppressed that in-function re-declaration case.
8868            let user_on = (on as u32) & !PM_LOCAL;
8869            let cur_ll = locallevel.load(Relaxed);
8870            let not_localizing = pm_level_existing == cur_ll || (on as u32 & PM_LOCAL) == 0;
8871            // c:Src/builtin.c:2244 — `else if (!OPT_ISSET(ops,'g') &&
8872            // (unset(TYPESETSILENT) || OPT_ISSET(ops,'m')))`. The `-g`
8873            // (global-scope) flag SUPPRESSES the bare-name print so
8874            // `typeset -g ZPFX` is a silent declaration / no-op rather
8875            // than a list. zinit's plugin loader runs `typeset -g VAR`
8876            // for already-environment-exported names on every load;
8877            // without this gate zshrs spammed `VAR=value` for each on
8878            // startup.
8879            // c:Src/builtin.c:2058-2061 — the print at c:2249-2256 sits INSIDE
8880            // the `usepm` branch:
8881            //   usepm = pm && (!(pm->node.flags & PM_UNSET) ||
8882            //                  (pm->node.flags & PM_DECLARED) || OPT_ISSET(ops,'p') ||
8883            //                  (isset(POSIXBUILTINS) &&
8884            //                   (pm->node.flags & (PM_READONLY|PM_EXPORTED))));
8885            // Since 55020 a TYPESET_TO_UNSET declaration (PM_DEFAULTED =
8886            // PM_DECLARED|PM_UNSET) is reused, so `typeset NAME` on it prints
8887            // the bare name (printparamnode c:Src/params.c:6158-6167); an
8888            // unset, undeclared parameter still prints nothing.
8889            // The `-p` term of usepm is already covered by the `!OPT_ISSET(p)`
8890            // conjunct below. Bug #1056.
8891            if user_on == 0
8892                && off == 0
8893                && not_localizing
8894                && !OPT_ISSET(&ops, b'p')
8895                && !OPT_ISSET(&ops, b'g')
8896                && (!isset(TYPESETSILENT) || OPT_ISSET(&ops, b'm'))
8897                && pname_in_tab
8898                && usepm_at_entry
8899            {
8900                let with_ns = if OPT_ISSET(&ops, b'm') {
8901                    PRINT_WITH_NAMESPACE
8902                } else {
8903                    0
8904                };
8905                let _ = with_ns;
8906                // c:Src/params.c:6158-6167 — printparamnode shows a
8907                // PM_DEFAULTED (declared, still unset) parameter by NAME
8908                // only; since 55020 it reaches this print (usepm is set for
8909                // PM_DECLARED, c:2059).
8910                let defaulted_unset = paramtab().read().ok().and_then(|t| {
8911                    t.get(arg).map(|pm| {
8912                        (pm.node.flags as u32 & PM_DEFAULTED) == PM_DEFAULTED
8913                    })
8914                }) == Some(true);
8915                if defaulted_unset {
8916                    println!("{}", arg);
8917                    continue;
8918                }
8919                // c:Src/params.c:6286-6288 + Src/module.c:1218-1223 — a zsh/parameter
8920                // table nothing has loaded yet is a PM_AUTOLOAD stub, which
8921                // printparamnode shows by NAME only (`typeset aliases`).
8922                if crate::vm_helper::module_param_is_autoload_stub(arg) {
8923                    println!("{}", arg);
8924                    continue;
8925                }
8926                // The paramtab entry for assoc/array shapes set via
8927                // direct assignment (`a=(1 2 3)` / `h[k]=v`) doesn't
8928                // always have PM_ARRAY/PM_HASHED set on flags — the
8929                // values live in the executor's arrays/assoc_arrays
8930                // storage, separate from paramtab. Check the canonical
8931                // storage paths and emit the right form directly,
8932                // mirroring printparamvalue's PM_ARRAY/PM_HASHED arms
8933                // (params.rs:8819+).
8934                //
8935                // Order matters: assoc lookup falls back to
8936                // crate::ported::exec::assoc which reads the executor's
8937                // assoc_arrays storage. typeset -A populated via
8938                // `typeset -A h=(a 1 b 2)` lives there, not in
8939                // paramtab_hashed_storage. Bug #218 in docs/BUGS.md.
8940                // c:Src/params.c:6301-6312 — printparamvalue walks a PM_HASHED
8941                // parameter through its own scan; a SPECIALPMDEF module hash
8942                // (`aliases`, `options`, …) has no stored map, the scanfn IS the
8943                // contents. PM_HIDEVAL hides the value only without
8944                // PRINT_INCLUDEVALUE, which `typeset NAME` passes.
8945                let special_hash = paramtab().read().ok().is_some_and(|t| {
8946                    t.get(arg).is_some_and(|pm| {
8947                        pm.node.flags as u32 & (PM_SPECIAL | PM_HASHED) == (PM_SPECIAL | PM_HASHED)
8948                    })
8949                });
8950                if special_hash {
8951                    if let (Some(keys), Some(vals)) = (
8952                        crate::ported::params::gethkparam(arg),
8953                        crate::ported::params::gethparam(arg),
8954                    ) {
8955                        let body: String = keys
8956                            .iter()
8957                            .zip(vals.iter())
8958                            .map(|(k, v)| {
8959                                format!(
8960                                    "[{}]={} ",
8961                                    crate::ported::utils::quotedzputs(k),
8962                                    crate::ported::utils::quotedzputs(v)
8963                                )
8964                            })
8965                            .collect();
8966                        println!("{}=( {})", arg, body);
8967                        continue;
8968                    }
8969                }
8970                let assoc = crate::ported::params::paramtab_hashed_storage()
8971                    .lock()
8972                    .ok()
8973                    .and_then(|s| s.get(arg).cloned())
8974                    .or_else(|| crate::ported::exec::assoc(arg));
8975                if let Some(map) = assoc {
8976                    let mut entries: Vec<(&String, &String)> = map.iter().collect();
8977                    entries.sort_by(|(a, _), (b, _)| a.cmp(b));
8978                    let mut s = format!("{}=( ", arg);
8979                    let mut first = true;
8980                    for (k, v) in entries {
8981                        if !first {
8982                            s.push(' ');
8983                        }
8984                        first = false;
8985                        s.push_str(&format!(
8986                            "[{}]={}",
8987                            crate::ported::utils::quotedzputs(k), // c:Src/params.c:6301
8988                            crate::ported::utils::quotedzputs(v)
8989                        ));
8990                    }
8991                    s.push_str(" )");
8992                    println!("{}", s);
8993                } else if let Some(arr) = crate::ported::params::getaparam(arg)
8994                    .or_else(|| crate::ported::exec::array(arg))
8995                    .or_else(|| {
8996                        // Fallback: paramtab entry's u_arr may be set
8997                        // even when PM_TYPE doesn't include PM_ARRAY
8998                        // (direct `a=(1 2 3)` assignment doesn't always
8999                        // update the flag).
9000                        paramtab()
9001                            .read()
9002                            .ok()
9003                            .and_then(|t| t.get(arg).and_then(|pm| pm.u_arr.clone()))
9004                            .filter(|v| !v.is_empty())
9005                    })
9006                {
9007                    let parts: Vec<String> = arr
9008                        .iter()
9009                        .map(|v| crate::ported::utils::quotedzputs(v))
9010                        .collect();
9011                    println!("{}=( {} )", arg, parts.join(" "));
9012                } else if let Some(val) = getsparam(arg) {
9013                    println!("{}={}", arg, crate::ported::utils::quotedzputs(&val));
9014                }
9015            }
9016        }
9017    }
9018    unqueue_signals();
9019    returnval
9020}
9021
9022/// Port of `eval_autoload()` from `Src/builtin.c:3166`.
9023/// C decl: `eval_autoload(Shfunc shf, char *name, Options ops, int func)`
9024/// C: `int eval_autoload(Shfunc shf, char *name, Options ops, int func)`.
9025/// PM_UNDEFINED guard; -X spawns the eval-trampoline, otherwise loadautofn
9026/// resolves and installs the body.
9027/// WARNING: param names don't match C — Rust=(shf, name, func) vs C=(shf, name, ops, func)
9028pub fn eval_autoload(
9029    shf: *mut shfunc,
9030    name: &str, // c:3166
9031    ops: &options,
9032    func: i32,
9033) -> i32 {
9034    if shf.is_null() {
9035        return 1;
9036    }
9037    let shf_mut = unsafe { &mut *shf };
9038    // c:3168-3169 — `if (!(shf->node.flags & PM_UNDEFINED)) return 1;`
9039    if (shf_mut.node.flags as u32 & PM_UNDEFINED) == 0 {
9040        // c:3168
9041        return 1; // c:3169
9042    }
9043    // c:3171-3174 — `if (shf->funcdef) { freeeprog(shf->funcdef); shf->funcdef = &dummy_eprog; }`
9044    if shf_mut.funcdef.is_some() {
9045        // c:3171
9046        shf_mut.funcdef = None; // c:3173 freeeprog + dummy
9047    }
9048    // c:3175-3181 — `-X` spawns the autoload trampoline via bin_eval.
9049    if OPT_MINUS(ops, b'X') {
9050        // c:3175
9051        // c:3177 — `fargv[0] = quotestring(name, QT_SINGLE_OPTIONAL); fargv[1] = "\"$@\"";`
9052        let fargv = vec![
9053            // c:3177-3179
9054            quotedzputs(name),
9055            "\"$@\"".to_string(),
9056        ];
9057        // c:3180 — `shf->funcdef = mkautofn(shf);`
9058        let p = mkautofn(shf); // c:3180
9059        let _ = p; // funcdef writeback handled inside mkautofn at c:3801
9060        return bin_eval(name, &fargv, ops, func); // c:3181
9061    }
9062    // c:3184-3186 — `return !loadautofn(shf, (OPT_ISSET('k') ? 2 :
9063    //                                  (OPT_ISSET('z') ? 0 : 1)), 1,
9064    //                                   OPT_ISSET('d'));`
9065    let mode = if OPT_ISSET(ops, b'k') {
9066        2
9067    }
9068    // c:3184
9069    else if OPT_ISSET(ops, b'z') {
9070        0
9071    }
9072    // c:3185
9073    else {
9074        1
9075    };
9076    let _d = OPT_ISSET(ops, b'd');
9077    // c:3184 — `return !loadautofn(shf, ...)`. C's loadautofn returns
9078    // `Shfunc` (pointer): NULL = failure, non-NULL = success. C's
9079    // `!loadautofn` yields 1 (true) when the pointer is NULL —
9080    // i.e. eval_autoload returns 1 on failure, 0 on success.
9081    //
9082    // The Rust port's loadautofn returns an i32 with the OPPOSITE
9083    // sign convention: 0 = success, non-zero = failure (the
9084    // canonical Rust ShellExecutor-status convention used throughout
9085    // this file). The previous Rust port literally translated `!`
9086    // as `if r == 0 { 1 } else { 0 }` — but that double-inverts the
9087    // already-flipped convention, so file-not-found (Rust r=1)
9088    // returned eval_autoload=0 (zsh-success), and the broken
9089    // `autoload +X totally_fake` silently exited 0 instead of 1.
9090    // The C-faithful return for the Rust convention is just `r`.
9091    // Bug #107 in docs/BUGS.md.
9092    loadautofn(shf, mode, 1, _d as i32) // c:3184
9093}
9094
9095/// Port of `check_autoload()` from `Src/builtin.c:3193`.
9096/// C decl: `check_autoload(Shfunc shf, char *name, Options ops, int func)`
9097/// C: `static int check_autoload(Shfunc shf, char *name, Options ops,
9098///     int func)` — `OPT_ISSET(ops,'X')` ? eval_autoload : 0.
9099/// WARNING: param names don't match C — Rust=(shf, name, func) vs C=(shf, name, ops, func)
9100pub fn check_autoload(
9101    shf: *mut shfunc,
9102    name: &str, // c:3193
9103    ops: &options,
9104    func: i32,
9105) -> i32 {
9106    // c:3196-3199 — `if (OPT_ISSET(ops,'X')) return eval_autoload(...);`
9107    if OPT_ISSET(ops, b'X') {
9108        // c:3196
9109        return eval_autoload(shf, name, ops, func); // c:3197
9110    }
9111    // c:3200-3242 — -r / -R re-resolve: walk fpath for the function file.
9112    let want_r = OPT_ISSET(ops, b'r');
9113    let want_R = OPT_ISSET(ops, b'R');
9114    if (want_r || want_R) && !shf.is_null() {
9115        // c:3200
9116        let shf_mut = unsafe { &mut *shf };
9117        if (shf_mut.node.flags as u32 & PM_UNDEFINED) == 0 {
9118            return 0;
9119        }
9120        // c:3202-3216 — already has filename + PM_LOADDIR: try the cached
9121        // dir first via spec_path[].
9122        if (shf_mut.node.flags as u32 & PM_LOADDIR) != 0 && shf_mut.filename.is_some() {
9123            let spec = vec![shf_mut.filename.clone().unwrap_or_default()];
9124            if getfpfunc(
9125                &shf_mut.node.nam,
9126                &mut None, // c:3206
9127                Some(&spec),
9128                1,
9129                &mut None, // c:3206 `&ksh` — unused on the test-only path
9130            )
9131            .is_some()
9132            {
9133                return 0; // c:3209
9134            }
9135            // c:3211-3217 — `-d` not set: bail (with -R = error, with -r = silent).
9136            if !OPT_ISSET(ops, b'd') {
9137                // c:3211
9138                if want_R {
9139                    // c:3212
9140                    zerr(&format!(
9141                        "{}: function definition file not found",
9142                        shf_mut.node.nam
9143                    )); // c:3213
9144                    return 1; // c:3215
9145                }
9146                return 0; // c:3216
9147            }
9148        }
9149        // c:3219-3231 — fpath walk via getfpfunc + dircache_set install.
9150        let mut dir_path: Option<String> = None;
9151        if getfpfunc(&shf_mut.node.nam, &mut dir_path, None, 1, &mut None).is_some()    // c:3219
9152            && dir_path.is_some()
9153        {
9154            // c:3220-3228 — dircache_set + relative-path absolutize.
9155            let mut old_slot = shf_mut.filename.take();
9156            dircache_set(&mut old_slot, None); // c:3220
9157            let dp = dir_path.unwrap();
9158            let mut new_slot: Option<String> = None;
9159            dircache_set(&mut new_slot, Some(&dp)); // c:3228
9160            shf_mut.filename = new_slot;
9161            shf_mut.node.flags |= PM_LOADDIR as i32; // c:3229
9162            return 0; // c:3230
9163        }
9164        // c:3233-3239 — -R: error; -r: silent.
9165        if want_R {
9166            // c:3233
9167            zerr(&format!(
9168                "{}: function definition file not found",
9169                shf_mut.node.nam
9170            )); // c:3243
9171            return 1; // c:3243
9172        }
9173    }
9174    0 // c:3243
9175}
9176
9177/// Port of `listusermathfunc()` from `Src/builtin.c:3243`.
9178/// C decl: `listusermathfunc(MathFunc p)`
9179/// C: `static void listusermathfunc(MathFunc p)` — emit a `functions -M`
9180///   row for one user math function with arg counts and module name.
9181pub fn listusermathfunc(p: &mathfunc) {
9182    // c:3243
9183    // c:3247-3257 — pick `showargs` 0..3 based on module/min/max presence.
9184    let mut showargs: i32 = if p.module.is_some() {
9185        // c:3249
9186        3
9187    } else if p.maxargs != if p.minargs != 0 { p.minargs } else { -1 } {
9188        // c:3251
9189        2
9190    } else if p.minargs != 0 {
9191        // c:3253
9192        1
9193    } else {
9194        0 // c:3256
9195    };
9196
9197    // c:3259 — `printf("functions -M%s %s", (p->flags & MFF_STR) ? "s" : "", p->name);`
9198    let s_suffix = if (p.flags & MFF_STR) != 0 { "s" } else { "" }; // c:3259
9199    print!("functions -M{} {}", s_suffix, p.name); // c:3259
9200    if showargs != 0 {
9201        // c:3260
9202        print!(" {}", p.minargs); // c:3261
9203        showargs -= 1; // c:3262
9204    }
9205    if showargs != 0 {
9206        // c:3264
9207        print!(" {}", p.maxargs); // c:3265
9208        showargs -= 1; // c:3266
9209    }
9210    if showargs != 0 {
9211        // c:3268
9212        // c:3269-3274 — function names are not required to be ident chars,
9213        // so the module name goes through quotedzputs for safe printing.
9214        print!(" "); // c:3273
9215        print!("{}", quotedzputs(p.module.as_deref().unwrap_or(""))); // c:3274
9216        showargs -= 1; // c:3275
9217    }
9218    println!(); // c:3277
9219}
9220
9221/// Port of `add_autoload_function()` from `Src/builtin.c:3278`.
9222/// C decl: `add_autoload_function(Shfunc shf, char *funcname)`
9223/// C: `static void add_autoload_function(Shfunc shf, char *funcname)` —
9224///   two branches:
9225///     (a) funcname is absolute & shf is PM_UNDEFINED → split `/dir/nam`,
9226///         dircache_set(&shf->filename, dir), set PM_LOADDIR|PM_ABSPATH_USED,
9227///         shfunctab->addnode(nam, shf).
9228///     (b) otherwise → walk funcstack to find calling function; if it has
9229///         PM_LOADDIR|PM_ABSPATH_USED, build `"<calling-dir>/funcname"` and
9230///         access(R_OK); on success copy the dir into shf and set
9231///         PM_LOADDIR|PM_ABSPATH_USED. Then shfunctab->addnode(funcname, shf).
9232/// WARNING: param names don't match C — Rust=(shf) vs C=(shf, funcname)
9233pub fn add_autoload_function(
9234    shf: *mut shfunc, // c:3278
9235    funcname: &str,
9236) {
9237    if shf.is_null() || funcname.is_empty() {
9238        return;
9239    }
9240    let shf_ref = unsafe { &mut *shf };
9241
9242    let is_abs_path = funcname.starts_with('/')                              // c:3282
9243                      && funcname.len() > 1
9244                      && funcname[1..].contains('/')
9245                      && (shf_ref.node.flags as u32 & PM_UNDEFINED) != 0;
9246
9247    if is_abs_path {
9248        // c:3287 — `nam = strrchr(funcname, '/');`
9249        let nam_idx = funcname.rfind('/').unwrap(); // c:3287
9250        let (dir, nam) = if nam_idx == 0 {
9251            // c:3289
9252            ("/".to_string(), funcname[1..].to_string()) // c:3290
9253        } else {
9254            (
9255                funcname[..nam_idx].to_string(), // c:3293
9256                funcname[nam_idx + 1..].to_string(),
9257            )
9258        };
9259        // c:3296 — `dircache_set(&shf->filename, NULL); dircache_set(..., dir);`
9260        let mut old_slot = shf_ref.filename.take();
9261        dircache_set(&mut old_slot, None); // c:3296
9262        let mut new_slot: Option<String> = None;
9263        dircache_set(&mut new_slot, Some(&dir)); // c:3297
9264        shf_ref.filename = new_slot;
9265        // c:3298-3299 — `shf->node.flags |= PM_LOADDIR | PM_ABSPATH_USED;`
9266        shf_ref.node.flags |= (PM_LOADDIR | PM_ABSPATH_USED) as i32; // c:3298
9267                                                                     // c:3300 — `shfunctab->addnode(shfunctab, ztrdup(nam), shf);` — the
9268                                                                     // node is keyed on the BASENAME (`nam`), not the full path. zshrs's
9269                                                                     // addnode keys on `shf.node.nam`, so rename the node before adding;
9270                                                                     // otherwise `autoload -Uz /dir/.hist.format` registered a function
9271                                                                     // literally named `/dir/.hist.format` (plugins that autoload by full
9272                                                                     // path — zsh-hist's `autoload -Uz $fdir/.hist.*` — diverged from
9273                                                                     // zsh, which names it `.hist.format`).
9274        shf_ref.node.nam = nam;
9275        if let Ok(mut t) = shfunctab_lock().write() {
9276            t.addnode(shf); // c:3300
9277        }
9278    } else {
9279        // c:3304-3327 — walk funcstack, look up calling fn in shfunctab, if
9280        // it has PM_LOADDIR|PM_ABSPATH_USED build "<dir>/<funcname>" and
9281        // access(R_OK), inherit the dir on hit.
9282        let calling_f: Option<String> = {
9283            let stack = FUNCSTACK.lock().map(|s| s.clone()).unwrap_or_default();
9284            // c:3306 — `for (fs = funcstack; fs; fs = fs->prev)`
9285            stack
9286                .iter()
9287                .rev()
9288                .find(|fs| {
9289                    // c:3306
9290                    // c:3307 — `if (fs->tp == FS_FUNC && fs->name &&
9291                    //               (!shf->node.nam || strcmp(fs->name, shf->node.nam)))`
9292                    FS_FUNC != 0  // mirror struct doesn't expose tp directly;
9293                && !fs.name.is_empty()
9294                && (shf_ref.node.nam.is_empty() || fs.name != shf_ref.node.nam)
9295                })
9296                .map(|fs| fs.name.clone()) // c:3308
9297        };
9298        if let Some(cf) = calling_f {
9299            // c:3315
9300            // c:3316 — `shf2 = shfunctab->getnode2(shfunctab, calling_f);`
9301            let shf2_ptr = shfunctab_lock()
9302                .read()
9303                .map(|t| t.getnode2(&cf))
9304                .unwrap_or(std::ptr::null_mut());
9305            if !shf2_ptr.is_null() {
9306                let shf2 = unsafe { &*shf2_ptr };
9307                // c:3317-3318
9308                let needs = (PM_LOADDIR | PM_ABSPATH_USED) as i32;
9309                if (shf2.node.flags & needs) == needs {
9310                    // c:3317
9311                    if let Some(dir2) = &shf2.filename {
9312                        // c:3318
9313                        // c:3320 — `snprintf(buf, PATH_MAX, "%s/%s", dir2, funcname);`
9314                        let buf = format!("{}/{}", dir2, funcname); // c:3320
9315                        if buf.len() <= libc::PATH_MAX as usize {
9316                            // c:3320
9317                            // c:3324 — `if (!access(buf, R_OK))`
9318                            let buf_c = std::ffi::CString::new(buf.clone()).ok();
9319                            if let Some(bc) = buf_c {
9320                                if unsafe { libc::access(bc.as_ptr(), libc::R_OK) } == 0 {
9321                                    // c:3324
9322                                    let mut old_slot = shf_ref.filename.take();
9323                                    dircache_set(&mut old_slot, None); // c:3325
9324                                    let dir2c = dir2.clone();
9325                                    let mut new_slot: Option<String> = None;
9326                                    dircache_set(&mut new_slot, Some(&dir2c)); // c:3326
9327                                    shf_ref.filename = new_slot;
9328                                    shf_ref.node.flags |= (PM_LOADDIR | PM_ABSPATH_USED) as i32;
9329                                    // c:3327
9330                                }
9331                            }
9332                        }
9333                    }
9334                }
9335            }
9336        }
9337        // c:3334 — `shfunctab->addnode(shfunctab, ztrdup(funcname), shf);`
9338        // addnode keys by `shf->node.nam`; if the caller picked a different
9339        // funcname here, re-tag the node name first so the keyed insert
9340        // matches the C contract.
9341        unsafe {
9342            if !shf.is_null() {
9343                (*shf).node.nam = funcname.to_string();
9344            }
9345        }
9346        if let Ok(mut t) = shfunctab_lock().write() {
9347            t.addnode(shf); // c:3334
9348        }
9349    }
9350}
9351
9352/// Port of `bin_functions()` from `Src/builtin.c:3342`.
9353/// C decl: `bin_functions(char *name, char **argv, Options ops, int func)`
9354/// C: `int bin_functions(char *name, char **argv, Options ops, int func)`.
9355/// This is the canonical free-function port matching the C signature so
9356/// the dispatcher can call it. The earlier `ShellExecutor::bin_functions`
9357/// inherent method is an ad-hoc Rust-side helper kept for the existing
9358/// in-process executor; both should converge on this function.
9359/// WARNING: param names don't match C — Rust=(name, argv, _func) vs C=(name, argv, ops, func)
9360pub fn bin_functions(
9361    name: &str,
9362    argv: &[String], // c:3342
9363    ops: &options,
9364    _func: i32,
9365) -> i32 {
9366    // c:3346-3347 — `int returnval = 0; int on = 0, off = 0, pflags = 0,
9367    //                roff, expand = 0;`
9368    let mut returnval: i32 = 0; // c:3346
9369    let mut on: u32 = 0; // c:3347
9370    let mut off: u32 = 0; // c:3347
9371    let _pflags: i32 = 0; // c:3347
9372    let _expand: i32 = 0; // c:3347
9373
9374    // c:3350-3351 — `if (OPT_PLUS(ops,'u')) off |= PM_UNDEFINED; else if
9375    //                (OPT_MINUS(ops,'u') || OPT_ISSET(ops,'X')) on |= PM_UNDEFINED;`
9376    if OPT_PLUS(ops, b'u') {
9377        // c:3350
9378        off |= PM_UNDEFINED; // c:3351
9379    } else if OPT_MINUS(ops, b'u') || OPT_ISSET(ops, b'X') {
9380        // c:3352
9381        on |= PM_UNDEFINED; // c:3353
9382    }
9383    // c:3354-3357 — -U / +U toggle PM_UNALIASED|PM_UNDEFINED.
9384    if OPT_MINUS(ops, b'U') {
9385        // c:3354
9386        on |= PM_UNALIASED | PM_UNDEFINED; // c:3355
9387    } else if OPT_PLUS(ops, b'U') {
9388        // c:3356
9389        off |= PM_UNALIASED; // c:3357
9390    }
9391    // c:3358-3361 — -t / +t toggle PM_TAGGED.
9392    if OPT_MINUS(ops, b't') {
9393        // c:3358
9394        on |= PM_TAGGED; // c:3359
9395    } else if OPT_PLUS(ops, b't') {
9396        // c:3360
9397        off |= PM_TAGGED; // c:3361
9398    }
9399    // c:3362-3365 — -T / +T toggle PM_TAGGED_LOCAL.
9400    if OPT_MINUS(ops, b'T') {
9401        // c:3362
9402        on |= PM_TAGGED_LOCAL; // c:3363
9403    } else if OPT_PLUS(ops, b'T') {
9404        // c:3364
9405        off |= PM_TAGGED_LOCAL; // c:3365
9406    }
9407    // c:3366-3369 — -W / +W toggle PM_WARNNESTED.
9408    if OPT_MINUS(ops, b'W') {
9409        // c:3366
9410        on |= PM_WARNNESTED; // c:3367
9411    } else if OPT_PLUS(ops, b'W') {
9412        // c:3368
9413        off |= PM_WARNNESTED; // c:3369
9414    }
9415    // c:3370 — `roff = off;`
9416    let mut roff = off; // c:3370
9417                        // c:3371-3377 — -z / +z PM_ZSHSTORED|PM_KSHSTORED interaction.
9418    if OPT_MINUS(ops, b'z') {
9419        // c:3371
9420        on |= PM_ZSHSTORED; // c:3372
9421        off |= PM_KSHSTORED; // c:3373
9422    } else if OPT_PLUS(ops, b'z') {
9423        // c:3374
9424        off |= PM_ZSHSTORED; // c:3375
9425        roff |= PM_ZSHSTORED; // c:3376
9426    }
9427    // c:3379-3385 — -k / +k PM_KSHSTORED|PM_ZSHSTORED interaction.
9428    if OPT_MINUS(ops, b'k') {
9429        // c:3379
9430        on |= PM_KSHSTORED; // c:3380
9431        off |= PM_ZSHSTORED; // c:3381
9432    } else if OPT_PLUS(ops, b'k') {
9433        // c:3382
9434        off |= PM_KSHSTORED; // c:3383
9435        roff |= PM_KSHSTORED; // c:3384
9436    }
9437    // c:3386-3392 — -d / +d PM_CUR_FPATH toggle.
9438    if OPT_MINUS(ops, b'd') {
9439        // c:3386
9440        on |= PM_CUR_FPATH; // c:3387
9441        off |= PM_CUR_FPATH; // c:3388
9442    } else if OPT_PLUS(ops, b'd') {
9443        // c:3389
9444        off |= PM_CUR_FPATH; // c:3390
9445        roff |= PM_CUR_FPATH; // c:3391
9446    }
9447
9448    // c:3394-3400 — early-error validation: invalid flag combinations.
9449    // C: `(OPT_MINUS(ops,'X') && (OPT_ISSET(ops,'m') || !scriptname))` —
9450    // \`-X\` is only valid in a script context (autoload-from-fpath
9451    // dispatch). Previous Rust port dropped the \`|| !scriptname\` half
9452    // so \`functions -X foo\` from interactive shell silently
9453    // succeeded — divergent.
9454    let scriptname_missing = scriptname_get().is_none();
9455    if (off & PM_UNDEFINED) != 0                                             // c:3394
9456        || (OPT_ISSET(ops, b'k') && OPT_ISSET(ops, b'z'))                    // c:3394
9457        || (OPT_ISSET(ops, b'x') && !OPT_HASARG(ops, b'x'))                  // c:3395
9458        || (OPT_MINUS(ops, b'X')                                             // c:3396
9459            && (OPT_ISSET(ops, b'm') || scriptname_missing))                 // c:3396 !scriptname
9460        || (OPT_ISSET(ops, b'c')
9461            && (OPT_ISSET(ops, b'x') || OPT_ISSET(ops, b'X') || OPT_ISSET(ops, b'm')))
9462    {
9463        zwarnnam(name, "bad option(s)"); // c:3399
9464        return 1; // c:3400
9465    }
9466
9467    // c:3402-3452 — `-c` (clone) branch: copy named function under a new
9468    // name, optionally registering it as a TRAP* signal trap.
9469    if OPT_ISSET(ops, b'c') {
9470        // c:3402
9471        if argv.len() < 2 || argv.len() > 2 {
9472            // c:3405
9473            zwarnnam(name, "-c: requires two arguments"); // c:3406
9474            return 1;
9475        }
9476        let src_name = &argv[0];
9477        let dst_name = &argv[1];
9478        // c:3409 — `shf = shfunctab->getnode(shfunctab, *argv);`
9479        let src_ptr = shfunctab_lock()
9480            .read()
9481            .map(|t| t.getnode(src_name.as_str()))
9482            .unwrap_or(std::ptr::null_mut());
9483        if src_ptr.is_null() {
9484            // c:3410
9485            zwarnnam(name, &format!("no such function: {}", src_name)); // c:3411
9486            return 1;
9487        }
9488        // c:3414-3421 — autoload-trampoline expansion if PM_UNDEFINED.
9489        // C body: `if (shf->flags & PM_UNDEFINED) { freeeprog;
9490        // funcdef=dummy; shf = loadautofn(shf,1,0,0); if (!shf) return 1; }`.
9491        // Rust port routes through the local loadautofn helper at
9492        // builtin.rs:883 which walks $fpath via getfpfunc, reads the
9493        // file, stores the body text on the Rust-side ShFunc, and
9494        // clears PM_UNDEFINED.
9495        if (unsafe { (*src_ptr).node.flags } as u32 & PM_UNDEFINED) != 0 {
9496            // c:3415-3418 — `freeeprog(shf->funcdef); shf->funcdef =
9497            // &dummy_eprog;` clear out any stale autoload stub before
9498            // re-loading. Rust port: drop the Option<Eprog>.
9499            unsafe {
9500                (*src_ptr).funcdef = None;
9501            }
9502            // c:3419 — `loadautofn(shf, 1, 0, 0)`.
9503            if loadautofn(src_ptr, 1, 0, 0) != 0 {
9504                // c:3420-3421 — autoload failed.
9505                return 1;
9506            }
9507        }
9508        // c:3422-3430 — `newsh = zalloc + memcpy + filename rebuild`.
9509        let src_ref = unsafe { &*src_ptr };
9510        let new_filename =
9511            if (src_ref.node.flags as u32 & PM_UNDEFINED) == 0 && src_ref.filename.is_some() {
9512                src_ref.filename.clone() // c:3429
9513            } else {
9514                None
9515            };
9516        let _ = new_filename; // wired into shfunctab[dst_name] below
9517                              // c:3437-3447 — TRAP* prefix detection + signal trap registration.
9518        if dst_name.starts_with("TRAP") {
9519            // c:3437
9520            // c:3438 — `int sigidx = getsigidx(s + 4);`
9521            let sigidx = getsigidx(&dst_name[4..]); // c:3438
9522            if sigidx != -1 {
9523                // c:3439
9524                // c:3440 — `if (settrap(sigidx, NULL, ZSIG_FUNC))`.
9525                if settrap(sigidx, None, ZSIG_FUNC) != 0 {
9526                    // c:3440
9527                    // freeeprog(newsh->funcdef) — funcdef Drop covers it.
9528                    // dircache_set(&newsh->filename, NULL);
9529                    // zfree(newsh, sizeof(*newsh));
9530                    return 1; // c:3445
9531                }
9532                // c:3447 — `removetrapnode(sigidx);` — clear any prior trap.
9533                removetrapnode(sigidx); // c:3447
9534            }
9535        }
9536        // c:3422-3430 — C does `newsh = zalloc + memcpy(*newsh, *shf)` so
9537        // src and dst become independent copies. Box-clone the source body
9538        // (rather than aliasing src_ptr) so subsequent mutation through
9539        // `getnode(dst_name)` doesn't bleed into src.
9540        // c:3450 — `shfunctab->addnode(shfunctab, ztrdup(s), &newsh->node);`
9541        let newsh = unsafe {
9542            let mut copy = (*src_ptr).clone();
9543            copy.node.nam = dst_name.clone();
9544            // c:3424-3428 — `if (newsh->node.flags & PM_LOADDIR) {
9545            //     newsh->node.flags &= ~PM_LOADDIR;
9546            //     newsh->filename = tricat(shf->filename, "/", shf->node.nam);
9547            // } else newsh->filename = ztrdup(shf->filename);`
9548            // A PM_LOADDIR filename is the DIRECTORY the file came from, and
9549            // the file is found by appending the function's own name. The
9550            // copy has a different name, so the full path is fixed here from
9551            // the ORIGINAL name; keeping PM_LOADDIR made `%x` in `cp` report
9552            // `dir/cp` instead of `dir/af`.
9553            if (copy.node.flags as u32 & PM_LOADDIR) != 0 {
9554                copy.node.flags &= !(PM_LOADDIR as i32); // c:3425
9555                copy.filename = (*src_ptr)
9556                    .filename
9557                    .as_ref()
9558                    .map(|dir| format!("{}/{}", dir, (*src_ptr).node.nam)); // c:3426
9559            }
9560            Box::into_raw(Box::new(copy))
9561        };
9562        if let Ok(mut t) = shfunctab_lock().write() {
9563            t.addnode(newsh); // c:3450
9564        }
9565        return 0; // c:3451
9566    }
9567
9568    // c:3454-3463 — `-x N` indent override for printing.
9569    let mut expand: i32 = 0; // c:3454 (also c:3347)
9570    if OPT_ISSET(ops, b'x') {
9571        // c:3454
9572        let arg = OPT_ARG(ops, b'x').unwrap_or("");
9573        match arg.trim().parse::<i32>() {
9574            // c:3456
9575            Ok(n) => {
9576                expand = n; // c:3456
9577                if expand == 0 {
9578                    expand = -1;
9579                } // c:3461-3462
9580            }
9581            Err(_) => {
9582                zwarnnam(name, "number expected after -x"); // c:3458
9583                return 1; // c:3459
9584            }
9585        }
9586    }
9587
9588    // c:3465-3466 — `+f` / roff / `+` enables PRINT_NAMEONLY.
9589    let mut pflags: i32 = 0;
9590    if OPT_PLUS(ops, b'f') || roff != 0 || OPT_ISSET(ops, b'+') {
9591        // c:3465
9592        pflags |= PRINT_NAMEONLY; // c:3466
9593    }
9594
9595    // c:3468-3530 — `-M`/`+M` add/remove/list math function path.
9596    if OPT_MINUS(ops, b'M') || OPT_PLUS(ops, b'M') {
9597        // c:3468
9598        // c:3473-3477 — refuse incompatible flag combos.
9599        if on != 0
9600            || off != 0
9601            || pflags != 0
9602            || OPT_ISSET(ops, b'X')
9603            || OPT_ISSET(ops, b'u')
9604            || OPT_ISSET(ops, b'U')
9605            || OPT_ISSET(ops, b'w')
9606        {
9607            zwarnnam(name, "bad option(s)"); // c:3475
9608            return 1; // c:3476
9609        }
9610        if argv.is_empty() {
9611            // c:3478
9612            // c:3479-3484 — list user math ported.
9613            queue_signals(); // c:3480
9614            if let Ok(table) = MATHFUNCS.lock() {
9615                // c:3481
9616                for p in table.iter() {
9617                    // c:3481
9618                    if (p.flags & MFF_USERFUNC) != 0 {
9619                        // c:3482
9620                        listusermathfunc(p); // c:3483
9621                    }
9622                }
9623            }
9624            unqueue_signals(); // c:3484
9625            return returnval;
9626        } else if OPT_ISSET(ops, b'm') {
9627            // c:3485
9628            // c:3486-3515 — list/delete matching math ported by pattern.
9629            for arg in argv.iter() {
9630                queue_signals(); // c:3488
9631                                 // c:3489 — `tokenize(*argv)`; Rust patcompile handles it.
9632                if let Some(pprog) = patcompile(
9633                    &{
9634                        let mut __pat_tok = crate::pattern_data_escape::escape_data_backslashes(&arg); // a backslash tokenize leaves raw is DATA (c:Src/glob.c:3651)
9635                        crate::ported::glob::tokenize(&mut __pat_tok);
9636                        __pat_tok
9637                    },
9638                    PAT_STATIC,
9639                    None,
9640                ) {
9641                    // c:3490
9642                    if OPT_PLUS(ops, b'M') {
9643                        // c:3497
9644                        // Delete matching user ported.
9645                        if let Ok(mut table) = MATHFUNCS.lock() {
9646                            table.retain(|p| {
9647                                !((p.flags & MFF_USERFUNC) != 0 && pattry(&pprog, &p.name))
9648                            });
9649                        }
9650                    } else {
9651                        // c:3502 — listusermathfunc for matches.
9652                        if let Ok(table) = MATHFUNCS.lock() {
9653                            for p in table.iter() {
9654                                if (p.flags & MFF_USERFUNC) != 0 && pattry(&pprog, &p.name) {
9655                                    listusermathfunc(p);
9656                                }
9657                            }
9658                        }
9659                    }
9660                } else {
9661                    // c:3509
9662                    // c:3510-3512 — bad pattern.
9663                    zwarnnam(
9664                        name, // c:3511
9665                        &format!("bad pattern : {}", arg),
9666                    );
9667                    returnval = 1; // c:3512
9668                }
9669                unqueue_signals(); // c:3514
9670            }
9671            return returnval;
9672        } else if OPT_PLUS(ops, b'M') {
9673            // c:3516
9674            // c:3517-3533 — `+M name…` delete by exact name.
9675            for arg in argv.iter() {
9676                queue_signals(); // c:3519
9677                if let Ok(mut table) = MATHFUNCS.lock() {
9678                    let idx = table.iter().position(|p| p.name == *arg); // c:3520-3521
9679                    if let Some(i) = idx {
9680                        if (table[i].flags & MFF_USERFUNC) == 0 {
9681                            // c:3522-3527 — library function, refuse.
9682                            zwarnnam(
9683                                name, // c:3523
9684                                &format!("+M {}: is a library function", arg),
9685                            );
9686                            returnval = 1; // c:3525
9687                        } else {
9688                            table.remove(i); // c:3528
9689                        }
9690                    }
9691                }
9692                unqueue_signals(); // c:3532
9693            }
9694            return returnval;
9695        } else {
9696            // c:3535-3611 — `-M name [min [max [mod]]]` add a user math fn.
9697            let mut argv_iter = argv.iter();
9698            let funcname = argv_iter.next().unwrap(); // c:3537
9699            let mut minargs: i32;
9700            let mut maxargs: i32;
9701            if OPT_ISSET(ops, b's') {
9702                // c:3541
9703                minargs = 1; // c:3542
9704                maxargs = 1; // c:3542
9705            } else {
9706                minargs = 0; // c:3544
9707                maxargs = -1; // c:3545
9708            }
9709            // c:3548-3552 — bad math function name check.
9710            let bytes = funcname.as_bytes();
9711            let first_bad = bytes.is_empty()
9712                || (bytes[0] as char).is_ascii_digit()
9713                || !bytes
9714                    .iter()
9715                    .all(|&c| c.is_ascii_alphanumeric() || c == b'_');
9716            if first_bad {
9717                // c:3549
9718                zwarnnam(
9719                    name, // c:3550
9720                    &format!("-M {}: bad math function name", funcname),
9721                );
9722                return 1; // c:3551
9723            }
9724            if let Some(arg) = argv_iter.next() {
9725                // c:3554
9726                match arg.parse::<i32>() {
9727                    // c:3555 zstrtol
9728                    Ok(n) if n >= 0 => minargs = n, // c:3556
9729                    _ => {
9730                        zwarnnam(
9731                            name, // c:3557
9732                            &format!("-M: invalid min number of arguments: {}", arg),
9733                        );
9734                        return 1; // c:3559
9735                    }
9736                }
9737                if OPT_ISSET(ops, b's') && minargs != 1 {
9738                    // c:3561
9739                    zwarnnam(
9740                        name, // c:3562
9741                        "-Ms: must take a single string argument",
9742                    );
9743                    return 1; // c:3563
9744                }
9745                maxargs = minargs; // c:3565
9746            }
9747            if let Some(arg) = argv_iter.next() {
9748                // c:3568
9749                match arg.parse::<i32>() {
9750                    // c:3569
9751                    Ok(n) if n >= -1 && (n == -1 || n >= minargs) => maxargs = n,
9752                    _ => {
9753                        zwarnnam(
9754                            name, // c:3573
9755                            &format!("-M: invalid max number of arguments: {}", arg),
9756                        );
9757                        return 1; // c:3576
9758                    }
9759                }
9760                if OPT_ISSET(ops, b's') && maxargs != 1 {
9761                    // c:3578
9762                    zwarnnam(
9763                        name, // c:3579
9764                        "-Ms: must take a single string argument",
9765                    );
9766                    return 1; // c:3580
9767                }
9768            }
9769            let modname = argv_iter.next().cloned(); // c:3584-3585
9770            if argv_iter.next().is_some() {
9771                // c:3586
9772                zwarnnam(name, "-M: too many arguments"); // c:3587
9773                return 1; // c:3588
9774            }
9775            // c:3591-3598 — alloc and populate mathfunc.
9776            let mut flags = MFF_USERFUNC; // c:3593
9777            if OPT_ISSET(ops, b's') {
9778                // c:3594
9779                flags |= MFF_STR; // c:3595
9780            }
9781            let new_fn = mathfunc {
9782                next: None,             // c:3608 chain via Vec
9783                name: funcname.clone(), // c:3592
9784                flags,                  // c:3593
9785                nfunc: None,
9786                sfunc: None,
9787                module: modname, // c:3596
9788                minargs,         // c:3597
9789                maxargs,         // c:3598
9790                funcid: 0,
9791            };
9792            queue_signals(); // c:3600
9793            if let Ok(mut table) = MATHFUNCS.lock() {
9794                // c:3601-3606 — remove existing user entry with same name.
9795                if let Some(i) = table.iter().position(|p| p.name == new_fn.name) {
9796                    table.remove(i); // c:3603
9797                }
9798                // c:3608-3609 — prepend to mathfuncs head.
9799                table.insert(0, new_fn);
9800            }
9801            unqueue_signals(); // c:3610
9802            return returnval;
9803        }
9804    }
9805
9806    // c:3616-3655 — `-X` re-autoload from inside a function.
9807    if OPT_MINUS(ops, b'X') {
9808        // c:3616
9809        if argv.len() > 1 {
9810            // c:3620
9811            zwarnnam(name, "-X: too many arguments"); // c:3621
9812            return 1; // c:3622
9813        }
9814        queue_signals(); // c:3624
9815                         // c:3625-3633 — walk funcstack to find the enclosing FS_FUNC frame.
9816        let funcname: Option<String> = {
9817            let stack = FUNCSTACK.lock().map(|s| s.clone()).unwrap_or_default();
9818            stack
9819                .iter()
9820                .rev()
9821                // c:3623 — `if (fs->tp == FS_FUNC)`. C skips FS_EVAL and
9822                // FS_SOURCE frames and takes the innermost real FUNCTION.
9823                // Matching on "name is non-empty" instead picked the `(eval)`
9824                // frame for `f() { eval autoload -X }` (the zinit /
9825                // C04funcdef:29 idiom), so the load was attempted for a
9826                // function literally named `(eval)`.
9827                .find(|fs| fs.tp == crate::ported::zsh_h::FS_FUNC && !fs.name.is_empty()) // c:3623
9828                .map(|fs| fs.name.clone()) // c:3628
9829        };
9830        let ret;
9831        if funcname.is_none() {
9832            // c:3635
9833            // c:3637 — `zerrnam(name, "bad autoload");`. zerrnam, NOT zwarnnam:
9834            // in C the `zerr*` family sets `errflag`, which aborts the enclosing
9835            // command and the script, while `zwarn*` only prints. The comment
9836            // here already cited zerrnam but the call was the warning form, so
9837            // `autoload -X` outside a function printed the diagnostic and then
9838            // kept going — `autoload -X; echo AFTER` ran AFTER where zsh stops.
9839            // (A subshell still continues in both, because only the subshell
9840            // dies; inside a function `-X` takes the real autoload path and
9841            // never reaches here.)
9842            zerrnam(name, "bad autoload"); // c:3637
9843            ret = 1; // c:3638
9844        } else {
9845            let fname = funcname.unwrap();
9846            // c:3640-3647 — getnode(shfunctab, funcname) || addnode(new shf).
9847            let mut shf_ptr = shfunctab_lock()
9848                .read()
9849                .map(|t| t.getnode(fname.as_str()))
9850                .unwrap_or(std::ptr::null_mut());
9851            if !shf_ptr.is_null() { // c:3640
9852                 // exists already
9853            } else {
9854                // c:3645 — `shf = zshcalloc(sizeof *shf);`
9855                //          `shfunctab->addnode(shfunctab, ztrdup(funcname), shf);`
9856                let new_shf = Box::into_raw(Box::new(shfunc {
9857                    node: hashnode {
9858                        next: None,
9859                        nam: fname.clone(),
9860                        flags: 0,
9861                    },
9862                    filename: None,
9863                    lineno: 0,
9864                    funcdef: None,
9865                    redir: None,
9866                    sticky: None,
9867                    body: None,
9868                    redir_text: None,
9869                }));
9870                if let Ok(mut t) = shfunctab_lock().write() {
9871                    t.addnode(new_shf); // c:3646
9872                }
9873                shf_ptr = new_shf;
9874            }
9875            if !argv.is_empty() {
9876                // c:3648
9877                if !shf_ptr.is_null() {
9878                    let shf_mut = unsafe { &mut *shf_ptr };
9879                    let mut old_slot = shf_mut.filename.take();
9880                    dircache_set(&mut old_slot, None); // c:3649
9881                    let mut new_slot: Option<String> = None;
9882                    dircache_set(&mut new_slot, Some(&argv[0])); // c:3650
9883                    shf_mut.filename = new_slot;
9884                    on |= PM_LOADDIR; // c:3294 — `shf->node.flags |= PM_LOADDIR;`
9885                }
9886            }
9887            // c:3653 — `shf->node.flags = on;` — `on` carries
9888            // PM_UNDEFINED (set for -X at c:3352 / builtin.rs:6142).
9889            // This write was MISSING: eval_autoload's first gate is
9890            // `if (!(shf->node.flags & PM_UNDEFINED)) return 1;`
9891            // (c:3168), so zinit's NEW_AUTOLOAD=1 stubs —
9892            //   functions[add-zsh-hook]="local -a fpath; fpath=(…);
9893            //                            builtin autoload -X -U -z"
9894            // — silently returned 1 and every wrapped plugin autoload
9895            // (add-zsh-hook, regexp-replace, add-zle-hook-widget)
9896            // reported `function definition file not found` at first
9897            // call in the interactive session.
9898            if !shf_ptr.is_null() {
9899                unsafe {
9900                    (*shf_ptr).node.flags = on as i32; // c:3653
9901                }
9902            }
9903            // RUST-ONLY: drop the executor's compiled chunk for the
9904            // CURRENT (stub) definition. C's eval_autoload -X arm
9905            // replaces shf->funcdef with mkautofn (c:3180) so the
9906            // bin_eval re-call loads the real file; zshrs's dispatch
9907            // keeps a parallel functions_compiled chunk whose
9908            // presence skips the autoload prelude — re-dispatch ran
9909            // the STALE stub (zinit NEW_AUTOLOAD=1: `builtin
9910            // autoload -X` inside the stub) in an infinite mutual
9911            // recursion → stack overflow in the interactive session.
9912            let _ = crate::ported::exec::unregister_function(&fname);
9913            // c:3654 — `ret = eval_autoload(shf, funcname, ops, func);`
9914            ret = eval_autoload(shf_ptr, &fname, ops, _func); // c:3654
9915        }
9916        unqueue_signals(); // c:3656
9917        return ret;
9918    }
9919
9920    // c:3658-3669 — no-arg listing path: print all (non-DISABLED) shfuncs
9921    // matching `on|off` mask through scanshfunc + printnode.
9922    if argv.is_empty() {
9923        // c:3658
9924        queue_signals(); // c:3663
9925        if OPT_ISSET(ops, b'U') && !OPT_ISSET(ops, b'u') {
9926            // c:3664
9927            on &= !PM_UNDEFINED; // c:3665
9928        }
9929        // c:3666 — `scanshfunc(1, on|off, DISABLED, shfunctab->printnode,
9930        //              pflags, expand);` — C's scanhashtable takes a
9931        // `flags1` filter mask: only entries where `(flags & flags1) ==
9932        // flags1` are emitted. Rust's `scanshfunc` doesn't accept the
9933        // filter, so apply it inside the closure. Without this gate
9934        // `functions -t` (no args) walked every function and printed
9935        // its body — should be empty / name-only restricted to
9936        // PM_TAGGED entries. Bug #468.
9937        let filter_mask = on | off;
9938        scanshfunc(|_nm, entry| {
9939            let f = entry.node.flags as u32;
9940            if filter_mask != 0 && (f & filter_mask) != filter_mask {
9941                return;
9942            }
9943            // !!! RUST-ONLY GATE (no C counterpart) !!!
9944            // C's anonymous functions (`() { … } args`) are never entered into
9945            // shfunctab at all — execfuncdef runs the Eprog and frees it
9946            // (Src/exec.c). zshrs desugars them into a real shfunc named
9947            // `_zshrs_anon_N` (parse.rs:9528/9585) and deletes the entry after
9948            // the single call (fusevm_bridge.rs:14746), so the only window in
9949            // which one is visible is from INSIDE its own body — which is
9950            // exactly where B02typeset's `typeset -f` runs, since the ztst
9951            // harness evaluates every chunk inside `() { … }`. Skip them so
9952            // the listing matches zsh.
9953            if entry.node.nam.starts_with("_zshrs_anon_") {
9954                return;
9955            }
9956            printshfuncexpand(entry, pflags, expand);
9957        });
9958        unqueue_signals(); // c:3668
9959        return returnval;
9960    }
9961
9962    // c:3672-3708 — `-m` glob: treat each arg as a pattern, scan-and-print
9963    // matching shfuncs (no on/off → list) or apply on/off mask.
9964    if OPT_ISSET(ops, b'm') {
9965        // c:3673
9966        on &= !PM_UNDEFINED; // c:3674
9967        let mut returnval = returnval;
9968        for pat in argv {
9969            // c:3675
9970            queue_signals(); // c:3676
9971                             // c:3678 — `tokenize(*argv)` + `patcompile(...)`
9972            let pprog = patcompile(
9973                &{
9974                    let mut __pat_tok = crate::pattern_data_escape::escape_data_backslashes(&pat); // a backslash tokenize leaves raw is DATA (c:Src/glob.c:3651)
9975                    crate::ported::glob::tokenize(&mut __pat_tok);
9976                    __pat_tok
9977                }, // c:3680
9978                PAT_HEAPDUP,
9979                None,
9980            );
9981            if let Some(prog) = pprog {
9982                // c:3680-3683 — scan-and-print matching shfuncs.
9983                if (on | off) == 0 && !OPT_ISSET(ops, b'X') {
9984                    // c:3682
9985                    // c:3682-3683 — `scanmatchshfunc(pprog, 1, 0,
9986                    //   DISABLED, shfunctab->printnode, pflags, expand)`.
9987                    // Walk shfunctab via the hashtable.rs port and emit
9988                    // each match through `printshfuncexpand` so autoload
9989                    // stubs come out as `name () { # undefined; builtin
9990                    // autoload -XU }` and loaded funcs print their body.
9991                    scanmatchshfunc(Some(pat), |_nm, entry| {
9992                        printshfuncexpand(entry, pflags, expand);
9993                    });
9994                } else {
9995                    // c:3686-3699 — walk shfunctab, apply (on, off) and
9996                    // re-eval autoload for each matching shf.
9997                    let names: Vec<String> = shfunctab_lock()
9998                        .read()
9999                        .map(|t| t.iter().map(|(k, _)| k.clone()).collect())
10000                        .unwrap_or_default();
10001                    for nm in &names {
10002                        if !pattry(&prog, nm) {
10003                            // c:3690
10004                            continue;
10005                        }
10006                        let shf_ptr = shfunctab_lock()
10007                            .read()
10008                            .map(|t| t.getnode(nm.as_str()))
10009                            .unwrap_or(std::ptr::null_mut());
10010                        if shf_ptr.is_null() {
10011                            continue;
10012                        }
10013                        let shf_mut = unsafe { &mut *shf_ptr };
10014                        // c:3691 — `shf->node.flags = (... | (on & ~PM_UNDEFINED)) & ~off;`
10015                        shf_mut.node.flags =
10016                            (shf_mut.node.flags | ((on & !PM_UNDEFINED) as i32)) & !(off as i32); // c:3691
10017                        if check_autoload(shf_ptr, &shf_mut.node.nam, ops, _func) != 0 {
10018                            // c:3693
10019                            returnval = 1; // c:3695
10020                        }
10021                    }
10022                }
10023            } else {
10024                // c:3700-3702 — `untokenize + zwarnnam(name, "bad pattern")`.
10025                zwarnnam(name, &format!("bad pattern : {}", pat)); // c:3701
10026                returnval = 1; // c:3702
10027            }
10028            unqueue_signals(); // c:3704
10029        }
10030        return returnval;
10031    }
10032
10033    // c:3710-3735 — literal name list, no globbing.
10034    let mut returnval = returnval;
10035    queue_signals(); // c:3711
10036    for fname in argv {
10037        // c:3712
10038        // c:3713-3714 — `returnval = dump_autoload(name, *argv, on, ops, func);`
10039        // A missing dump is diagnosed by `load_dump_header` (parse.rs); a valid
10040        // one registers its functions for autoload.
10041        if OPT_ISSET(ops, b'w') {
10042            // c:3713
10043            returnval = crate::ported::parse::dump_autoload(name, fname, on as i32, ops, _func); // c:3714
10044            continue;
10045        }
10046        // c:3715 — `shf = shfunctab->getnode(shfunctab, *argv);`
10047        let shf_ptr = shfunctab_lock()
10048            .read()
10049            .map(|t| t.getnode(fname.as_str()))
10050            .unwrap_or(std::ptr::null_mut());
10051        if !shf_ptr.is_null() {
10052            // c:3715
10053            let shf_mut = unsafe { &mut *shf_ptr };
10054            if (on | off) != 0 {
10055                // c:3717
10056                // c:3719 — apply on/off mask, then check_autoload.
10057                shf_mut.node.flags =
10058                    (shf_mut.node.flags | ((on & !PM_UNDEFINED) as i32)) & !(off as i32); // c:3719
10059                if check_autoload(shf_ptr, &shf_mut.node.nam, ops, _func) != 0 {
10060                    // c:3720
10061                    returnval = 1; // c:3721
10062                }
10063            } else {
10064                // c:3723 — `printshfuncexpand(&shf->node, pflags, expand);`
10065                // C prints the function via shfunctab.printnode honoring
10066                // pflags (PRINT_NAMEONLY / verbose). The previous Rust
10067                // port just printed the name — `functions f` skipped
10068                // the `f () { ... body ... }` body listing entirely.
10069                printshfuncexpand(shf_mut, pflags, expand); // c:3723
10070            }
10071        } else if (on & PM_UNDEFINED) != 0 {
10072            // c:3725
10073            // c:3726-3782 — autoload-define path: TRAP* + abs-path + new shf.
10074            let mut sigidx: i32 = -1;
10075            let mut ok = true;
10076            // c:3728-3735 — TRAP* prefix → removetrapnode(sigidx).
10077            if fname.starts_with("TRAP") {
10078                // c:3728
10079                // c:3729 — `if ((sigidx = getsigidx(*argv + 4)) != -1)`
10080                sigidx = getsigidx(&fname[4..]); // c:3729
10081                if sigidx != -1 {
10082                    // c:3729
10083                    // c:3733 — `removetrapnode(sigidx);`
10084                    removetrapnode(sigidx); // c:3733
10085                }
10086            }
10087            // c:3737-3759 — absolute path /dir/base form: install dir on
10088            // existing matching base name with PM_UNDEFINED set.
10089            if fname.starts_with('/') {
10090                // c:3737
10091                let base = fname.rsplit('/').next().unwrap_or("");
10092                if !base.is_empty() {
10093                    let base_ptr = shfunctab_lock()
10094                        .read()
10095                        .map(|t| t.getnode(base))
10096                        .unwrap_or(std::ptr::null_mut());
10097                    if !base_ptr.is_null() {
10098                        let bs = unsafe { &mut *base_ptr };
10099                        // c:3742 — apply flag mask.
10100                        bs.node.flags =
10101                            (bs.node.flags | ((on & !PM_UNDEFINED) as i32)) & !(off as i32); // c:3742
10102                        if (bs.node.flags as u32 & PM_UNDEFINED) != 0 {
10103                            // c:3744
10104                            let dir = if fname.len() > 1 && base.len() == fname.len() - 1 {
10105                                "/".to_string() // c:3747
10106                            } else {
10107                                fname[..fname.len() - base.len() - 1].to_string()
10108                                // c:3749-3751
10109                            };
10110                            let mut old_slot = bs.filename.take();
10111                            dircache_set(&mut old_slot, None); // c:3753
10112                            let mut new_slot: Option<String> = None;
10113                            dircache_set(&mut new_slot, Some(&dir)); // c:3754
10114                            bs.filename = new_slot;
10115                        }
10116                        if check_autoload(base_ptr, &bs.node.nam, ops, _func) != 0 {
10117                            // c:3756
10118                            returnval = 1;
10119                        }
10120                        continue; // c:3758
10121                    }
10122                }
10123            }
10124            // c:3763-3766 — new undefined shf, mkautofn, add_autoload_function.
10125            let new_shf = Box::new(shfunc {
10126                node: hashnode {
10127                    next: None,
10128                    nam: fname.clone(),
10129                    flags: on as i32, // c:3764
10130                },
10131                filename: None,
10132                lineno: 0,
10133                funcdef: None,
10134                redir: None,
10135                sticky: None,
10136                body: None,
10137                redir_text: None,
10138            });
10139            let new_shf_ptr = Box::into_raw(new_shf);
10140            let _ = mkautofn(new_shf_ptr); // c:3765
10141            // c:3766 — `shfunc_set_sticky(shf);` stamps the pending
10142            // `emulate … -c` snapshot so the loaded body runs (and is
10143            // parsed) under that emulation (doshfunc c:5978).
10144            crate::ported::exec::shfunc_set_sticky(unsafe { &mut *new_shf_ptr });
10145            add_autoload_function(new_shf_ptr, fname); // c:3767
10146                                                       // PFA-SMR: an `autoload NAME` registers a function stub
10147                                                       // that fires on first call. Record it as a `function`
10148                                                       // kind so replay re-registers the autoload. C zsh has
10149                                                       // no recorder hook here; emit per Rust-only schema.
10150            #[cfg(feature = "recorder")]
10151            if crate::recorder::is_enabled() {
10152                let ctx = crate::recorder::recorder_ctx_global();
10153                crate::recorder::emit_function(fname, None, ctx);
10154            }
10155            if sigidx != -1 {
10156                // c:3769
10157                // c:3770 — `if (settrap(sigidx, NULL, ZSIG_FUNC)) { ... }`
10158                if settrap(sigidx, None, ZSIG_FUNC) != 0 {
10159                    // c:3770
10160                    // c:3771 — `shfunctab->removenode(shfunctab, *argv);`
10161                    if let Ok(mut t) = shfunctab_lock().write() {
10162                        t.remove(fname);
10163                    }
10164                    // c:3772 — `shfunctab->freenode(&shf->node);` Drop covers it.
10165                    returnval = 1; // c:3773
10166                    ok = false; // c:3774
10167                }
10168            }
10169            if ok && check_autoload(new_shf_ptr, &fname, ops, _func) != 0 {
10170                // c:3779
10171                returnval = 1; // c:3780
10172            }
10173        } else {
10174            // c:3783 — `returnval = 1;` (named function not found,
10175            //          no autoload requested).
10176            returnval = 1; // c:3783
10177        }
10178    }
10179    unqueue_signals(); // c:3785
10180    let _ = (expand, pflags);
10181    returnval
10182}
10183
10184/// Port of `mkautofn()` from `Src/builtin.c:3790`.
10185/// C decl: `mkautofn(Shfunc shf)`
10186/// C: `Eprog mkautofn(Shfunc shf)` — synthesize a 5-wordcode body that
10187///   re-fires the autoload mechanism when first called.
10188pub fn mkautofn(shf: *mut shfunc) -> *mut eprog {
10189    // c:3790
10190    // c:3793-3810 — alloc Eprog with 5 wordcode slots, set p->shf, p->npats=0,
10191    // p->nref=1 (permanent). Static-link path: synthesize a Box<eprog> that
10192    // satisfies the autoload trampoline contract.
10193    let p = Box::new(eprog {
10194        len: 5 * size_of::<u32>() as i32, // c:3796
10195        prog: Vec::new(),                 // c:3797
10196        strs: None,                       // c:3798
10197        shf: if shf.is_null() {
10198            None
10199        }
10200        // c:3799
10201        else {
10202            Some(unsafe { Box::from_raw(shf) })
10203        },
10204        npats: 0, // c:3800
10205        nref: 1,  // c:3801
10206        flags: 0,
10207        pats: Vec::new(),
10208        dump: None,
10209        strs_metafied: false, // no pool (strs: None) — flag unused
10210    });
10211    Box::into_raw(p)
10212}
10213
10214/// Port of `bin_unset()` from `Src/builtin.c:3818`.
10215/// C decl: `bin_unset(char *name, char **argv, Options ops, int func)`
10216/// C: `int bin_unset(char *name, char **argv, Options ops, int func)` —
10217///   `-f` delegates to `bin_unhash`; `-m` glob deletes matching params;
10218///   default literal-name unset with subscript handling.
10219/// WARNING: param names don't match C — Rust=(name, argv, func) vs C=(name, argv, ops, func)
10220pub fn bin_unset(
10221    name: &str,
10222    argv: &[String], // c:3818
10223    ops: &options,
10224    func: i32,
10225) -> i32 {
10226    let mut returnval = 0i32; // c:3823
10227    let mut match_count = 0i32; // c:3823
10228    // c:3884-3886 — every arm below acts on the node `getnode2` returns,
10229    // which is the VISIBLE binding. When `local -A commands` (or
10230    // `local -a aliases`) shadows a zsh/parameter row, that node is the
10231    // plain local createparam made at c:Src/params.c:1147-1156 with
10232    // `pm->old` holding the special, so neither the autoload-stub removal
10233    // (c:3874 applies to the GLOBAL PM_AUTOLOAD stub only) nor the magic
10234    // element dispatch (the local's gsu is stdhash_gsu, not the module's)
10235    // may run. zshrs keys both of those on the NAME (the stub side-set and
10236    // PARTAB), so ask what the visible node is. A `local -A +h commands`
10237    // kept the special struct (PM_SPECIAL, c:Src/builtin.c:2386-2425) and
10238    // still dispatches magically.
10239    let plain_local_shadow = |nm: &str| -> bool {
10240        paramtab().read().ok().is_some_and(|t| {
10241            t.get(nm)
10242                .is_some_and(|pm| pm.level > 0 && (pm.node.flags as u32 & PM_SPECIAL) == 0)
10243        })
10244    };
10245
10246    // PFA-SMR aspect: emit unset events for each named param. The
10247    // recorder tracks state-mutations across the shell session for
10248    // the zshrs-recorder binary's replay/inspect tooling.
10249    #[cfg(feature = "recorder")]
10250    if crate::recorder::is_enabled() {
10251        let ctx = crate::recorder::recorder_ctx_global();
10252        for a in argv {
10253            if a.starts_with('-') || a == "--" {
10254                continue;
10255            }
10256            crate::recorder::emit_unset(a, ctx.clone());
10257        }
10258    }
10259
10260    // c:3826 — `if (OPT_ISSET(ops,'f')) return bin_unhash(name, argv, ops, func);`
10261    if OPT_ISSET(ops, b'f') {
10262        // c:3826
10263        return bin_unhash(name, argv, ops, func); // c:3827
10264    }
10265
10266    // c:3830-3862 — `-m` glob.
10267    if OPT_ISSET(ops, b'm') {
10268        // c:3831
10269        for s in argv {
10270            // c:3832
10271            queue_signals(); // c:3833
10272            let pprog = patcompile(
10273                &{
10274                    let mut __pat_tok = crate::pattern_data_escape::escape_data_backslashes(&s); // a backslash tokenize leaves raw is DATA (c:Src/glob.c:3651)
10275                    crate::ported::glob::tokenize(&mut __pat_tok);
10276                    __pat_tok
10277                }, // c:3836
10278                PAT_HEAPDUP,
10279                None,
10280            );
10281            if let Some(prog) = pprog {
10282                // c:3838-3851 — walk paramtab (NOT env::vars), unset via
10283                // unsetparam (which respects PM_NAMEREF + readonly guards).
10284                //
10285                // The previous Rust port walked `std::env::vars()` — the
10286                // OS environment. This is a different name set:
10287                //   - Shell-internal vars (not exported) would survive
10288                //     `unset -m 'PATTERN'` even though they match.
10289                //   - Env vars not in paramtab would be removed without
10290                //     the PM_READONLY guard in unsetparam_pm.
10291                //
10292                // Same family of bug as the env::var vs paramtab fixes
10293                // earlier in the series.
10294                let names: Vec<String> = {
10295                    let tab = paramtab().read().unwrap();
10296                    tab.keys().cloned().collect()
10297                };
10298                for nm in &names {
10299                    if pattry(&prog, nm) {
10300                        // c:3842
10301                        // c:3846 — `unsetparam_pm(pm, 0, 1)` runs on the
10302                        // node the walk found. For a magic assoc/array
10303                        // nothing has read yet that node is still the
10304                        // PM_AUTOLOAD stub (Src/module.c:1218-1223): a
10305                        // plain PM_SCALAR with no PM_READONLY, so it is
10306                        // removed silently (c:3874). zshrs seeds the real
10307                        // special instead, whose PM_READONLY would reject
10308                        // the glob — `unset -m 'func*'` died on
10309                        // `functrace` where zsh unsets the lot. Same
10310                        // stub-state branch as the literal-name arm below.
10311                        if crate::vm_helper::module_param_is_autoload_stub(nm)
10312                            && !plain_local_shadow(nm)
10313                            && (crate::ported::modules::parameter::PARTAB
10314                                .iter()
10315                                .any(|e| e.name == nm.as_str())
10316                                || crate::ported::modules::parameter::PARTAB_ARRAY
10317                                    .iter()
10318                                    .any(|e| e.name == nm.as_str()))
10319                        {
10320                            let _ = paramtab().write().ok().map(|mut t| t.remove(nm)); // c:3874
10321                            let _ = crate::ported::params::paramtab_hashed_storage()
10322                                .lock()
10323                                .ok()
10324                                .as_deref_mut()
10325                                .map(|m| m.remove(nm.as_str()));
10326                            match_count += 1; // c:3848
10327                            continue;
10328                        }
10329                        // c:3843-3846 — `if (OPT_ISSET(ops,'n') || ((pm =
10330                        // resolve_nameref(pm)) && !(pm->node.flags &
10331                        // PM_NAMEREF))) unsetparam_pm(pm, 0, 1);` — the
10332                        // same test the literal-name arm applies at
10333                        // c:3949-3951. unsetparam() skips namerefs, so
10334                        // route a matched ref through that arm (the `-m`
10335                        // bit cleared, `-n` kept) instead of dropping it.
10336                        if crate::ported::params::is_nameref(nm) {
10337                            let mut lit_ops = ops.clone();
10338                            lit_ops.ind[b'm' as usize] = 0;
10339                            bin_unset(name, std::slice::from_ref(nm), &lit_ops, func);
10340                            match_count += 1; // c:3848
10341                            continue;
10342                        }
10343                        unsetparam(nm); // c:3847 (with guards)
10344                        match_count += 1; // c:3848
10345                    }
10346                }
10347            } else {
10348                zwarnnam(name, &format!("bad pattern : {}", s)); // c:3854
10349                returnval = 1; // c:3855
10350            }
10351            unqueue_signals(); // c:3857
10352        }
10353        if match_count == 0 {
10354            // c:3861
10355            returnval = 1; // c:3862
10356        }
10357        return returnval; // c:3863
10358    }
10359
10360    // c:3866-3915 — literal-name unset with optional subscript.
10361    queue_signals(); // c:3867
10362    for s in argv {
10363        // c:3868
10364        // c:3869-3878 — extract `name[subscript]` shape.
10365        let (nm, subscript) = match s.find('[') {
10366            // c:3869
10367            Some(start) if s.ends_with(']') => {
10368                // c:3873
10369                (&s[..start], Some(&s[start + 1..s.len() - 1])) // c:3875
10370            }
10371            Some(_) => {
10372                // c:3873-3879 — bracket without `]` close → invalid.
10373                zerrnam(name, &format!("{}: invalid parameter name", crate::ported::utils::nicedupstring(&s))); // c:3876 — `%s` is nicezputs (c:Src/utils.c:316)
10374                returnval = 1; // c:3877
10375                continue; // c:3878
10376            }
10377            None => (s.as_str(), None),
10378        };
10379        // c:3873 — `if ((ss && !subscript) || !isident(s))`. isident
10380        // (c:Src/params.c:1309) takes an all-digit name (`unset 1` is a
10381        // no-op, as no such parameter exists) and namespaced `.a.b`. The
10382        // diagnostic is zerrnam: errflag is set and the rest of the
10383        // command list is abandoned.
10384        if !isident(nm) {
10385            zerrnam(name, &format!("{}: invalid parameter name", crate::ported::utils::nicedupstring(&s))); // c:3876 — `%s` is nicezputs (c:Src/utils.c:316)
10386            returnval = 1; // c:3877
10387            continue; // c:3878
10388        }
10389        // c:3886-3905 — `if (!pm) continue;` then unset.
10390        // C `unsetparam_pm` dispatches on `pm->gsu` (the gsu_*
10391        // accessor for the param's type): assoc gets
10392        // `gsu_a->unset(pm, subscript)`, array gets
10393        // `gsu_arr->unset(pm, subscript)`, scalar gets `unsetparam`.
10394        match subscript {
10395            // c:3886
10396            Some(key) => {
10397                // c:Src/Modules/mapfile.c:126 unsetpmmapfile — unsetting
10398                // an element of the special $mapfile assoc UNLINKS the
10399                // file named by the key. It is not a regular assoc, so
10400                // route here before the generic assoc/array delete.
10401                if nm == "mapfile" {
10402                    crate::ported::modules::mapfile::unsetpmmapfile(key, false);
10403                    continue;
10404                }
10405                // c:3891-3895 — `if (PM_TYPE(pm->node.flags) == PM_HASHED) {
10406                //     HashTable tht = paramtab;
10407                //     if ((paramtab = pm->gsu.h->getfn(pm))) unsetparam(subscript);
10408                //     paramtab = tht; }`
10409                //
10410                // For the zsh/parameter magic associations the hash that
10411                // `gsu.h->getfn` hands back is NOT a value store: its
10412                // `getnode` is `getpmfunction` / `getpmralias` / … (each
10413                // synthesising a child Param whose scalar gsu carries the
10414                // element `unsetfn`), and that `unsetfn` mutates the REAL
10415                // shell table — `shfunctab`, `aliastab`, `cmdnamtab`,
10416                // `nameddirtab`, the option flags. Deleting a key from a
10417                // value snapshot and writing the snapshot back (the generic
10418                // assoc path below) cannot express that: the whole-hash
10419                // setfns (`setpmfunctions`, c:344-365) only ADD entries, so
10420                // `unset "functions[m]"` left the shell function defined.
10421                //
10422                // Mirror of the element-SET dispatch in `assignsparam`
10423                // (`src/ported/params.rs:6455`), which routes
10424                // `functions[m]=body` to `setpmfunction` for the same reason.
10425                if !plain_local_shadow(nm) {
10426                    use crate::ported::modules::parameter as pmod;
10427                    use crate::ported::zsh_h::Param;
10428                    // c:3884-3886 — `pm = (paramtab == realparamtab ?
10429                    // paramtab->getnode2(paramtab, s) : paramtab->getnode(...))`
10430                    // — the comment on that line is `getnode2() to avoid
10431                    // autoloading`. A magic assoc that has never been touched
10432                    // is still the PM_AUTOLOAD stub `zsh/parameter` planted
10433                    // (c:Src/module.c:1218-1219), and getnode2 does NOT run
10434                    // loadparamnode, so `PM_TYPE(pm->node.flags)` is PM_SCALAR,
10435                    // not PM_HASHED: control falls into the scalar arm at
10436                    // c:3896-3919, where `getindex` rejects the string
10437                    // subscript with `assignment to invalid subscript range`
10438                    // and the function/alias is left alone. Verified against
10439                    // zsh 5.9.2: `f(){ :; }; unset "functions[f]"; whence f`
10440                    // still prints `f`, while the same unset AFTER any read of
10441                    // `$functions` removes it.
10442                    if crate::vm_helper::module_param_is_autoload_stub(nm) {
10443                        // c:Src/params.c getindex — `zerr("%s: assignment to
10444                        // invalid subscript range", ...)`; bin_unset then does
10445                        // `returnval = errflag; errflag &= ~ERRFLAG_ERROR;`
10446                        // (c:3917-3918), so the status is 1 but the script is
10447                        // NOT aborted.
10448                        zwarn(&format!("{}: assignment to invalid subscript range", nm));
10449                        returnval = 1; // c:3917
10450                        continue;
10451                    }
10452                    // c:3893 `paramtab->getnode(paramtab, subscript)` (via
10453                    // unsetparam, c:3763-3768) paired with the element gsu's
10454                    // `unsetfn`. `None` for the unsetfn is C's NULL slot in
10455                    // `nullsetscalar_gsu` (`Src/params.c:168-169`), used by
10456                    // every PM_READONLY_SPECIAL hash — unreachable because
10457                    // `unsetparam_pm` rejects on PM_READONLY first (c:3785).
10458                    type ElemUnset = fn(Param, i32);
10459                    let nul: *mut crate::ported::zsh_h::HashTable = std::ptr::null_mut();
10460                    let hashed: Option<(Option<Param>, Option<ElemUnset>)> = match nm {
10461                        // c:2281 SPECIALPMDEF("functions", …, getpmfunction),
10462                        // c:394-395 pmfunction_gsu { …, unsetpmfunction }.
10463                        "functions" => Some((
10464                            pmod::getpmfunction(nul, key),
10465                            Some(pmod::unsetpmfunction as ElemUnset),
10466                        )),
10467                        // c:2261-2262 + c:396-397 pmdisfunction_gsu shares
10468                        // `unsetpmfunction`.
10469                        "dis_functions" => Some((
10470                            pmod::getpmdisfunction(nul, key),
10471                            Some(pmod::unsetpmfunction as ElemUnset),
10472                        )),
10473                        // c:2251 + c:1867-1868 pmralias_gsu { …, unsetpmalias }.
10474                        "aliases" => Some((
10475                            pmod::getpmralias(nul, key),
10476                            Some(pmod::unsetpmalias as ElemUnset),
10477                        )),
10478                        // c:2287-2288 + c:1869-1870 pmgalias_gsu.
10479                        "galiases" => Some((
10480                            pmod::getpmgalias(nul, key),
10481                            Some(pmod::unsetpmalias as ElemUnset),
10482                        )),
10483                        // c:2258-2259 + c:1873-1874 pmdisralias_gsu.
10484                        "dis_aliases" => Some((
10485                            pmod::getpmdisralias(nul, key),
10486                            Some(pmod::unsetpmalias as ElemUnset),
10487                        )),
10488                        // c:2266-2267 + c:1875-1876 pmdisgalias_gsu.
10489                        "dis_galiases" => Some((
10490                            pmod::getpmdisgalias(nul, key),
10491                            Some(pmod::unsetpmalias as ElemUnset),
10492                        )),
10493                        // c:2312-2313 + c:1871-1872 pmsalias_gsu { …,
10494                        // unsetpmsalias } (sufaliastab, not aliastab).
10495                        "saliases" => Some((
10496                            pmod::getpmsalias(nul, key),
10497                            Some(pmod::unsetpmsalias as ElemUnset),
10498                        )),
10499                        // c:2272-2273 + c:1877-1878 pmdissalias_gsu.
10500                        "dis_saliases" => Some((
10501                            pmod::getpmdissalias(nul, key),
10502                            Some(pmod::unsetpmsalias as ElemUnset),
10503                        )),
10504                        // c:2256 + c:209-210 pmcommand_gsu { …, unsetpmcommand }.
10505                        "commands" => Some((
10506                            pmod::getpmcommand(nul, key),
10507                            Some(pmod::unsetpmcommand as ElemUnset),
10508                        )),
10509                        // c:2301-2302 + c:1607-1608 pmnamedir_gsu.
10510                        "nameddirs" => Some((
10511                            pmod::getpmnameddir(nul, key),
10512                            Some(pmod::unsetpmnameddir as ElemUnset),
10513                        )),
10514                        // c:2303-2304 + c:997-998 pmoption_gsu { …,
10515                        // unsetpmoption } — turns the option OFF.
10516                        "options" => Some((
10517                            pmod::getpmoption(nul, key),
10518                            Some(pmod::unsetpmoption as ElemUnset),
10519                        )),
10520                        // PM_READONLY_SPECIAL hashes (c:2255, 2259-2260,
10521                        // 2264-2265, 2269-2270, 2290-2291, 2296-2300,
10522                        // 2305-2306, 2314-2317): their `getpm*` stamps
10523                        // PM_SCALAR|PM_READONLY, so the element unset is
10524                        // rejected by unsetparam_pm's readonly guard.
10525                        "parameters" => Some((pmod::getpmparameter(nul, key), None)),
10526                        "builtins" => Some((pmod::getpmbuiltin(nul, key), None)),
10527                        "dis_builtins" => Some((pmod::getpmdisbuiltin(nul, key), None)),
10528                        "functions_source" => Some((pmod::getpmfunction_source(nul, key), None)),
10529                        "dis_functions_source" => {
10530                            Some((pmod::getpmdisfunction_source(nul, key), None))
10531                        }
10532                        // c:1051-1056 — getpmmodule starts with
10533                        // `m = modulestab->getnode2(modulestab, name);
10534                        //  if (!m) return NULL;`, so a name that is not a
10535                        // known module yields NO node and `unset
10536                        // "modules[bogus]"` is a silent no-op (rc 0), while a
10537                        // KNOWN module hits the PM_READONLY rejection. zshrs's
10538                        // getpmmodule port never returns None — it folds the
10539                        // unknown-name case into the "no type string" arm
10540                        // (c:1068-1069, PM_UNSET|PM_SPECIAL), which is pinned
10541                        // by its unit test — so re-apply C's `!m` gate here:
10542                        // an empty type string only means NULL-in-C when the
10543                        // module is absent from modulestab.
10544                        "modules" => Some((
10545                            pmod::getpmmodule(nul, key).filter(|p| {
10546                                (p.node.flags as u32 & PM_UNSET) == 0
10547                                    || crate::ported::module::MODULESTAB
10548                                        .lock()
10549                                        .map(|t| t.modules.contains_key(key))
10550                                        .unwrap_or(false)
10551                            }),
10552                            None,
10553                        )),
10554                        "history" => Some((pmod::getpmhistory(nul, key), None)),
10555                        "jobdirs" => Some((pmod::getpmjobdir(nul, key), None)),
10556                        "jobstates" => Some((pmod::getpmjobstate(nul, key), None)),
10557                        "jobtexts" => Some((pmod::getpmjobtext(nul, key), None)),
10558                        "userdirs" => Some((pmod::getpmuserdir(nul, key), None)),
10559                        "usergroups" => Some((pmod::getpmusergroups(nul, key), None)),
10560                        _ => None,
10561                    };
10562                    if let Some((node, unsetfn)) = hashed {
10563                        // c:3765-3768 — `if ((pm = paramtab->getnode(...)))
10564                        // unsetparam_pm(pm, 0, 1);` — a NULL node (unknown
10565                        // module) is silently ignored.
10566                        if let Some(pm) = node {
10567                            let f = pm.node.flags as u32;
10568                            if (f & PM_READONLY) != 0 {
10569                                // c:3785-3790 — `zerr("read-only %s: %s", …)`.
10570                                zerr(&format!("read-only variable: {}", pm.node.nam));
10571                            } else if (f & PM_UNSET) == 0 {
10572                                // c:3805-3806 — `if (!(pm->node.flags &
10573                                // PM_UNSET) || (pm->node.flags &
10574                                // PM_REMOVABLE)) pm->gsu.s->unsetfn(pm, exp);`
10575                                // A missing key comes back PM_UNSET (e.g.
10576                                // getfunction c:437-440), so the unsetfn is
10577                                // skipped and `unset "functions[nope]"` is a
10578                                // silent no-op, as in zsh.
10579                                if let Some(uf) = unsetfn {
10580                                    uf(pm, 1); // c:3806 exp=1 from unsetparam
10581                                }
10582                            }
10583                        }
10584                        // c:3895 `paramtab = tht;` — nothing else to do for
10585                        // this argument; the generic assoc/array delete below
10586                        // must not also run.
10587                        continue;
10588                    }
10589                }
10590                // c:3893 assoc subscript: `m[key]` delete.
10591                if let Some(mut map) = crate::ported::exec::assoc(nm) {
10592                    map.shift_remove(key); // c:3893
10593                    crate::ported::exec::set_assoc(nm, map);
10594                } else if let Some(mut arr) = crate::ported::exec::array(nm) {
10595                    // c:3895 — array subscript: `arr[N]` sets element
10596                    // N to empty; `arr[N,M]` clears the inclusive
10597                    // range. The previous Rust port only handled the
10598                    // single-index form; `unset arr[2,3]` was a no-op.
10599                    if !crate::dash_mode::sparse_arrays() {
10600                        // c:3896-3916 — `vbuf.scanflags = SCANPM_ARRONLY; vbuf.start
10601                        // = 0; vbuf.end = -1; … if (getindex(&ss, &vbuf,
10602                        // SCANPM_ASSIGNING) == 0 && vbuf.pm && !(PM_UNSET))`: the
10603                        // subscript goes through getindex, so KSHARRAYS /
10604                        // KSHZEROSUBSCRIPT, arithmetic (`a[i]`, `a[1+1]`), ranges
10605                        // with negative ends and the (i)/(r) flags all resolve as
10606                        // in an assignment. The selected slice is then replaced
10607                        // by one empty element (setarrvalue, Src/params.c:2933-2990).
10608                        let pm = paramtab().read().ok().and_then(|t| t.get(nm).cloned());
10609                        let mut vbuf = crate::ported::zsh_h::value {
10610                            pm,
10611                            arr: Vec::new(),
10612                            scanflags: crate::ported::zsh_h::SCANPM_ARRONLY as i32, // c:3898
10613                            valflags: 0, // c:3900
10614                            start: 0,    // c:3901
10615                            end: -1,     // c:3902
10616                        };
10617                        // c:1736-1760 — on an array, getarg's (r)/(R) (and
10618                        // (k)/(K), c:1401/1406) return the matched INDEX, exactly
10619                        // what (i)/(I) return; this port's getindex hands the
10620                        // matched element back in `v->arr` instead, so ask for
10621                        // the index form.
10622                        let search_as_index = key
10623                            .strip_prefix('(')
10624                            .and_then(|r| r.split_once(')'))
10625                            .is_some_and(|(f, _)| f.contains(['r', 'R', 'k', 'K']) && !f.contains(['i', 'I']));
10626                        let key: std::borrow::Cow<str> = match key.strip_prefix('(').and_then(|r| r.split_once(')')) {
10627                            Some((flags, rest)) if !flags.contains(['i', 'I']) => format!(
10628                                "({}){}",
10629                                flags.replace(['r', 'k'], "i").replace(['R', 'K'], "I"),
10630                                rest
10631                            )
10632                            .into(),
10633                            _ => key.into(),
10634                        };
10635                        let bracketed = format!("[{}]", key); // c:3904 `*ss = '['`
10636                        let mut sp: &str = &bracketed;
10637                        if crate::ported::params::getindex(
10638                            &mut sp,
10639                            &mut vbuf,
10640                            crate::ported::zsh_h::SCANPM_ASSIGNING as i32,
10641                        ) == 0
10642                            && vbuf
10643                                .pm
10644                                .as_ref()
10645                                .is_some_and(|p| (p.node.flags as u32 & PM_UNSET) == 0)
10646                        {
10647                            if search_as_index && vbuf.start == 0 && (vbuf.valflags & crate::ported::zsh_h::VALFLAG_INV) != 0 {
10648                                // c:1758-1760 — a reverse (R) search that misses
10649                                // returns index 0. Without `ind` that reaches
10650                                // getindex's range tail as `start == end == 0`
10651                                // (c:2126-2150), not the (I) inverse arm.
10652                                vbuf.valflags &= !crate::ported::zsh_h::VALFLAG_INV;
10653                                if isset(crate::ported::zsh_h::KSHZEROSUBSCRIPT) {
10654                                    vbuf.end = 1; // c:2140 `end = startnextlen`
10655                                } else {
10656                                    vbuf.valflags |= crate::ported::zsh_h::VALFLAG_EMPTY; // c:2147
10657                                    vbuf.start = -1; // c:2148
10658                                    vbuf.end = 0;
10659                                }
10660                            }
10661                            let inv = (vbuf.valflags & crate::ported::zsh_h::VALFLAG_INV) != 0;
10662                            // c:3912-3914 — "start is after the element for
10663                            // reverse index"; only an existing element is unset.
10664                            let start = vbuf.start - inv as i32;
10665                            // `arrlen_gt(arr, start)` takes the bound UNSIGNED and
10666                            // tests `arrlen >= 1 + bound` (Src/utils.c:2384-2387),
10667                            // so -1 wraps to 0 (true: `unset 'a[-1]'` clears the
10668                            // last element) while every other negative start is
10669                            // a huge bound (false: `unset 'a[-2]'` is a no-op).
10670                            let in_range = start == -1 || (start >= 0 && arr.len() > start as usize);
10671                            if in_range && (vbuf.valflags & crate::ported::zsh_h::VALFLAG_EMPTY) != 0 {
10672                                // c:2913-2917 (setarrvalue) — the strict zero
10673                                // subscript (getindex c:2141-2150). zerr is a
10674                                // no-op once an error is already flagged
10675                                // (`a[1/0]` reported its own).
10676                                if (errflag.load(Relaxed) & ERRFLAG_ERROR) == 0 {
10677                                    zerr(&format!("{}: assignment to invalid subscript range", nm));
10678                                }
10679                            } else if in_range {
10680                                // c:2938-2955 (setarrvalue) — bounds normalisation.
10681                                let oldlen = arr.len() as i32;
10682                                let (mut s, mut e) = (vbuf.start, vbuf.end);
10683                                if inv && !isset(crate::ported::zsh_h::KSHARRAYS) {
10684                                    if s > 0 {
10685                                        s -= 1;
10686                                    }
10687                                    e -= 1;
10688                                }
10689                                if s < 0 {
10690                                    s = (s + oldlen).max(0);
10691                                }
10692                                if e < 0 {
10693                                    e = (e + oldlen + 1).max(0);
10694                                }
10695                                if e < s || s > oldlen {
10696                                    e = s;
10697                                } else if e > oldlen {
10698                                    e = oldlen;
10699                                }
10700                                // c:2957-2990 — splice `("")` over [s, e).
10701                                arr.splice(s as usize..e as usize, [String::new()]);
10702                                crate::ported::exec::set_array(nm, arr);
10703                            }
10704                        }
10705                        // c:3917-3918 — `returnval = errflag; errflag &=
10706                        // ~ERRFLAG_ERROR;`
10707                        if (errflag.load(Relaxed) & ERRFLAG_ERROR) != 0 {
10708                            returnval = 1;
10709                        }
10710                        errflag.fetch_and(!ERRFLAG_ERROR, Relaxed);
10711                    } else {
10712                    // !!! BASH-MODE GATE !!! bash's own index rules, unchanged.
10713                    let len_i = arr.len() as i32;
10714                    let resolve = |raw: i32| -> Option<usize> {
10715                        let pos = if raw < 0 { len_i + raw + 1 } else { raw };
10716                        if pos >= 1 && pos as usize <= arr.len() {
10717                            Some((pos - 1) as usize)
10718                        } else {
10719                            None
10720                        }
10721                    };
10722                    if let Some((s, e)) = key.split_once(',') {
10723                        // c:Src/params.c — `unset arr[N,M]` replaces
10724                        // the N..M range with ONE empty element. For
10725                        // (a b c d), `unset arr[2,3]` → (a "" d).
10726                        // Negative bounds aren't supported by zsh's
10727                        // unset (it leaves the array unchanged), so
10728                        // restrict to positive forms.
10729                        let s_i = s.trim().parse::<i32>().ok();
10730                        let e_i = e.trim().parse::<i32>().ok();
10731                        if let (Some(start), Some(end)) = (s_i, e_i) {
10732                            if start >= 1 && end >= 1 {
10733                                let from = (start - 1) as usize;
10734                                let to = (end - 1) as usize;
10735                                if from < arr.len() && from <= to {
10736                                    let cap_to = to.min(arr.len() - 1);
10737                                    let mut new_arr: Vec<String> = Vec::with_capacity(arr.len());
10738                                    new_arr.extend(arr[..from].iter().cloned());
10739                                    new_arr.push(String::new());
10740                                    new_arr.extend(arr[cap_to + 1..].iter().cloned());
10741                                    crate::ported::exec::set_array(nm, new_arr);
10742                                }
10743                            }
10744                        }
10745                    } else if crate::dash_mode::sparse_arrays() {
10746                        // bash sparse arrays: subscripts are 0-based and
10747                        // `unset a[i]` leaves a HOLE (index gap) rather than
10748                        // shifting or clearing to a dense empty. Negative
10749                        // indexes count from the end. The dense Vec keeps a
10750                        // "" placeholder; bash_arrays tracks the hole so
10751                        // `${a[@]}`/`${#a[@]}`/`${!a[@]}` skip it.
10752                        if let Ok(i) = key.parse::<i32>() {
10753                            let len_i = arr.len() as i32;
10754                            let idx0 = if i < 0 { len_i + i } else { i };
10755                            if idx0 >= 0 && (idx0 as usize) < arr.len() {
10756                                let idx = idx0 as usize;
10757                                arr[idx] = String::new();
10758                                crate::bash_arrays::note_unset(nm, idx);
10759                                crate::ported::exec::set_array(nm, arr);
10760                            }
10761                        }
10762                    } else if let Ok(i) = key.parse::<i32>() {
10763                        // c:Src/params.c — single-index unset only
10764                        // accepts positive subscripts and the special
10765                        // -1 form (clear last element). Other negative
10766                        // values are a no-op in zsh — verified
10767                        // empirically: `unset arr[-2]`, `arr[-3]` etc.
10768                        // leave the array unchanged.
10769                        // `unset arr[0]` is invalid (unless
10770                        // KSHZEROSUBSCRIPT) and emits a diagnostic.
10771                        if i == 0 {
10772                            zwarn(&format!("{}: assignment to invalid subscript range", nm));
10773                            returnval = 1;
10774                        } else if i == -1 {
10775                            if !arr.is_empty() {
10776                                let idx = arr.len() - 1;
10777                                arr[idx] = String::new();
10778                                crate::ported::exec::set_array(nm, arr);
10779                            }
10780                        } else if i > 0 {
10781                            if let Some(idx) = resolve(i) {
10782                                arr[idx] = String::new();
10783                                crate::ported::exec::set_array(nm, arr);
10784                            }
10785                        }
10786                        // Other negative values: no-op (zsh behavior).
10787                    }
10788                    }
10789                    // c:Src/params.c:2922 — a subscript unset of a tied
10790                    // colon-array (`unset path[2]`) re-derives the scalar
10791                    // side, just like the assignment paths in params.rs.
10792                    if let Some((_, scalar)) = crate::ported::params::TIED_COLON_ARRAYS
10793                        .iter()
10794                        .find(|(a, _)| *a == nm)
10795                    {
10796                        let cur = crate::ported::exec::array(nm).unwrap_or_default();
10797                        crate::ported::params::assignsparam(scalar, &cur.join(":"), 0);
10798                    }
10799                } else {
10800                    // c:3897-3901 — `if ((pm->node.flags & PM_NAMEREF) &&
10801                    // !(pm = resolve_nameref(pm))) continue;` — the element
10802                    // unset acts on the referent.
10803                    let (target, tflags) = if crate::ported::params::is_nameref(nm) {
10804                        use crate::ported::params::nameref_resolution;
10805                        match crate::ported::params::resolve_nameref_name(nm, None) {
10806                            nameref_resolution::Target {
10807                                name: t,
10808                                subscript: None,
10809                                pm: Some(p),
10810                                ..
10811                            } => {
10812                                let f = p.node.flags as u32;
10813                                (t, f)
10814                            }
10815                            // c:params.c:6341-6342 — a placeholder chain
10816                            // resolves to the (PM_NAMEREF) ref itself, which
10817                            // falls through to the c:3919 type check below.
10818                            nameref_resolution::Placeholder(last, _) => {
10819                                let f = paramtab()
10820                                    .read()
10821                                    .ok()
10822                                    .and_then(|t| t.get(&last).map(|p| p.node.flags as u32))
10823                                    .unwrap_or(PM_NAMEREF);
10824                                (last, f)
10825                            }
10826                            _ => continue,
10827                        }
10828                    } else {
10829                        // c:3886-3890 — `getnode2`; `if (!pm) continue;`
10830                        match paramtab()
10831                            .read()
10832                            .ok()
10833                            .and_then(|t| t.get(nm).map(|pm| pm.node.flags as u32))
10834                        {
10835                            Some(f) => (nm.to_string(), f),
10836                            None => continue,
10837                        }
10838                    };
10839                    if PM_TYPE(tflags) == PM_SCALAR {
10840                        // c:3902-3915 — `vbuf.pm = pm; vbuf.start = 0;
10841                        // vbuf.end = -1; … if (getindex(&ss, &vbuf,
10842                        // SCANPM_ASSIGNING) == 0 && vbuf.pm &&
10843                        // !(vbuf.pm->node.flags & PM_UNSET))
10844                        // setstrvalue(&vbuf, ztrdup(""));` — the subscripted
10845                        // character range is spliced out (`var=value;
10846                        // unset "var[2,3]"` → vue). C's vbuf aliases the
10847                        // table node; the Rust value owns a clone, so the
10848                        // mutated node is written back.
10849                        let pm = paramtab().read().ok().and_then(|t| t.get(&target).cloned());
10850                        let mut vbuf = crate::ported::zsh_h::value {
10851                            pm,
10852                            arr: Vec::new(),
10853                            scanflags: 0, // c:3903-3904 (PM_SCALAR)
10854                            valflags: 0,  // c:3906
10855                            start: 0,     // c:3907
10856                            end: -1,      // c:3908
10857                        };
10858                        let bracketed = format!("[{}]", key); // c:3910 `*ss = '['`
10859                        let mut sp: &str = &bracketed;
10860                        if crate::ported::params::getindex(
10861                            &mut sp,
10862                            &mut vbuf,
10863                            crate::ported::zsh_h::SCANPM_ASSIGNING as i32,
10864                        ) == 0
10865                            && vbuf
10866                                .pm
10867                                .as_ref()
10868                                .is_some_and(|p| (p.node.flags as u32 & PM_UNSET) == 0)
10869                        {
10870                            crate::ported::params::setstrvalue(Some(&mut vbuf), ""); // c:3914
10871                            if let Some(pm_back) = vbuf.pm {
10872                                if let Ok(mut tab) = paramtab().write() {
10873                                    tab.insert(target.clone(), pm_back);
10874                                }
10875                            }
10876                        }
10877                        // c:3917-3918 — `returnval = errflag; errflag &=
10878                        // ~ERRFLAG_ERROR;`
10879                        if (errflag.load(Relaxed) & ERRFLAG_ERROR) != 0 {
10880                            returnval = 1;
10881                        }
10882                        errflag.fetch_and(!ERRFLAG_ERROR, Relaxed);
10883                    } else if PM_TYPE(tflags) != PM_ARRAY && PM_TYPE(tflags) != PM_HASHED {
10884                        // c:3919-3921
10885                        zerrnam(name, &format!("{}: invalid element for unset", nm));
10886                        returnval = 1;
10887                    }
10888                }
10889            }
10890            None => {
10891                // c:3915-3919 (54722) — nameref handling. Without -n the
10892                // ref resolves and the TARGET is unset with unsetparam_pm,
10893                // whatever scope it lives in. With -n the ref itself is
10894                // removed.
10895                let mut nm: &str = nm;
10896                let mut resolved_target: Option<String> = None; // non-ref target to unset
10897                let mut ref_removal: Option<String> = None; // ref entry to remove
10898                let mut handled = false;
10899                if crate::ported::params::is_nameref(nm) {
10900                    if OPT_ISSET(ops, b'n') {
10901                        ref_removal = Some(nm.to_string()); // unset -n: the ref itself
10902                    } else {
10903                        use crate::ported::params::nameref_resolution;
10904                        match crate::ported::params::resolve_nameref_name(nm, None) {
10905                            // c:3942-3943 — `if (!(pm = resolve_nameref(pm))) continue;`
10906                            nameref_resolution::SelfRef | nameref_resolution::OutOfScope => {
10907                                continue
10908                            }
10909                            nameref_resolution::Placeholder(..) => {
10910                                // c:3949-3951 — `(pm = resolve_nameref(pm))
10911                                // && !(pm->node.flags & PM_NAMEREF)`: a chain
10912                                // ending at a placeholder ref resolves to that
10913                                // ref, which fails the PM_NAMEREF test, so
10914                                // nothing is unset.
10915                                continue;
10916                            }
10917                            nameref_resolution::Target {
10918                                name: t,
10919                                subscript,
10920                                pm,
10921                                level,
10922                            } => {
10923                                if subscript.is_some() {
10924                                    // c:3897 — subscripted ref: resolve_nameref
10925                                    // early-exits (pm->width); the REF itself
10926                                    // is the unset object.
10927                                    ref_removal = Some(nm.to_string());
10928                                } else if pm.is_none() {
10929                                    // dangling — nothing to unset (c:3942).
10930                                    continue;
10931                                } else {
10932                                    // c:3952 — `unsetparam_pm(pm, 0, 1)` on
10933                                    // the RESOLVED struct. When that struct
10934                                    // is the visible binding, the ordinary
10935                                    // unsetparam path below is exactly it.
10936                                    // When it is hidden by a local of the
10937                                    // same name (upscope picked an `old`
10938                                    // node), unset that node in place.
10939                                    let visible = paramtab()
10940                                        .read()
10941                                        .ok()
10942                                        .and_then(|tab| tab.get(&t).map(|p| p.level == level))
10943                                        .unwrap_or(true);
10944                                    if visible {
10945                                        resolved_target = Some(t);
10946                                    } else {
10947                                        // paramtab is keyed by name with the
10948                                        // chain owned by the visible node, so
10949                                        // take it out, walk to `level`, and
10950                                        // unset that node.
10951                                        let top = paramtab().write().ok().and_then(|mut tab| tab.remove(&t));
10952                                        if let Some(mut top) = top {
10953                                            let mut defer_global = false;
10954                                            let mut cur = top.old.as_deref_mut();
10955                                            while let Some(hpm) = cur {
10956                                                if hpm.level == level {
10957                                                    if crate::ported::params::unsetparam_pm(hpm, 0, 1) != 0 {
10958                                                        returnval = 1; // c:3953
10959                                                    } else if hpm.level == 0 {
10960                                                        // c:3859-3869 — a hidden
10961                                                        // global cannot be removed
10962                                                        // yet: `zpushnode(scoperefs[0],
10963                                                        // pm)`; endparamscope deletes
10964                                                        // it once uncovered. A local
10965                                                        // keeps its node (c:3851-3853).
10966                                                        defer_global = true;
10967                                                    }
10968                                                    break;
10969                                                }
10970                                                cur = hpm.old.as_deref_mut();
10971                                            }
10972                                            if let Ok(mut tab) = paramtab().write() {
10973                                                tab.insert(t.clone(), top);
10974                                            }
10975                                            if defer_global {
10976                                                crate::ported::params::SCOPEREFS.with(|sr| {
10977                                                    let mut sr = sr.borrow_mut();
10978                                                    if sr.is_empty() {
10979                                                        sr.resize(8, Vec::new()); // c:3865
10980                                                    }
10981                                                    sr[0].insert(0, t.clone()); // c:3868
10982                                                });
10983                                            }
10984                                        }
10985                                        handled = true;
10986                                    }
10987                                }
10988                            }
10989                            nameref_resolution::NotRef => {}
10990                        }
10991                    }
10992                    if handled {
10993                        continue;
10994                    }
10995                    if let Some(refnam) = ref_removal {
10996                        // unsetparam() deliberately skips namerefs, so
10997                        // do the table surgery here with the readonly
10998                        // guard (c:3850 via unsetparam_pm).
10999                        let ro = paramtab()
11000                            .read()
11001                            .ok()
11002                            .and_then(|t| {
11003                                t.get(&refnam)
11004                                    .map(|p| (p.node.flags as u32 & PM_READONLY) != 0)
11005                            })
11006                            .unwrap_or(false);
11007                        if ro {
11008                            zerr(&format!("read-only variable: {}", refnam)); // c:3850
11009                            returnval = 1;
11010                            continue;
11011                        }
11012                        // c:3911-3913 — a LOCAL ref is kept in the
11013                        // table marked PM_UNSET (scope-end removes it);
11014                        // only globals/level-0 refs drop out entirely.
11015                        let cur_ll = locallevel.load(Relaxed) as i32;
11016                        let keep_local = paramtab()
11017                            .read()
11018                            .ok()
11019                            .and_then(|t| t.get(&refnam).map(|p| p.level > 0 && cur_ll >= p.level))
11020                            .unwrap_or(false);
11021                        if keep_local {
11022                            if let Ok(mut tab) = paramtab().write() {
11023                                if let Some(p) = tab.get_mut(&refnam) {
11024                                    // c:3858 + c:3870 stdunsetfn
11025                                    p.node.flags &= !(PM_DECLARED as i32);
11026                                    p.node.flags |= PM_UNSET as i32;
11027                                    p.u_str = None;
11028                                    p.base = 0;
11029                                    p.width = 0;
11030                                }
11031                            }
11032                        } else if let Some(mut pm) =
11033                            paramtab().write().ok().and_then(|mut t| t.remove(&refnam))
11034                        {
11035                            if let Some(prev) = pm.old.take() {
11036                                if let Ok(mut tab) = paramtab().write() {
11037                                    tab.insert(refnam.clone(), prev);
11038                                }
11039                            }
11040                        }
11041                        continue;
11042                    }
11043                }
11044                let target_buf;
11045                if let Some(t) = resolved_target {
11046                    target_buf = t;
11047                    nm = &target_buf;
11048                }
11049                // c:3884-3886 — `pm = (paramtab == realparamtab ?
11050                // paramtab->getnode2(paramtab, s) : paramtab->getnode(
11051                // paramtab, s))` — the comment on that line is
11052                // `getnode2() to avoid autoloading`. A magic assoc /
11053                // array that nothing has READ yet is still the
11054                // `PM_AUTOLOAD` stub `add_autoparam` planted
11055                // (c:Src/module.c:1218-1223 `setsparam(pnam, module);
11056                // pm->node.flags |= PM_AUTOLOAD`), i.e. a PLAIN
11057                // `PM_SCALAR` node at level 0 whose value is the owning
11058                // module's name — NOT the `SPECIALPMDEF` row. So
11059                // `unsetparam_pm` on it:
11060                //   * skips the readonly guard (c:3786) — the stub has
11061                //     no PM_READONLY, which is why `unset parameters`
11062                //     succeeds in zsh where the materialized special
11063                //     would be rejected;
11064                //   * fails c:3851-3852's keep-the-node test
11065                //     `(flags & (PM_SPECIAL|PM_REMOVABLE)) == PM_SPECIAL`
11066                //     and falls through to c:3874
11067                //     `paramtab->removenode(paramtab, pm->node.nam)`.
11068                // The name is then unbound: `${#functions}` is 0,
11069                // `functions[k]=v` hits c:2700's "assignment to invalid
11070                // subscript range", and `functions=(a b)` makes an
11071                // ORDINARY array.
11072                //
11073                // zshrs seeds the real special eagerly
11074                // (`vm_helper::init_partab_params`) instead of planting
11075                // a stub, so reproduce C by consulting the same
11076                // stub-state predicate the subscript arm above uses and
11077                // dropping the node directly. Materialized names fall
11078                // through to `unsetparam` below, which is C's other
11079                // branch: node kept, `PM_UNSET` stamped, readonly rows
11080                // rejected.
11081                if crate::vm_helper::module_param_is_autoload_stub(nm)
11082                    && !plain_local_shadow(nm)
11083                    && (crate::ported::modules::parameter::PARTAB
11084                        .iter()
11085                        .any(|e| e.name == nm)
11086                        || crate::ported::modules::parameter::PARTAB_ARRAY
11087                            .iter()
11088                            .any(|e| e.name == nm))
11089                {
11090                    crate::ported::params::paramtab()
11091                        .write()
11092                        .ok()
11093                        .map(|mut t| t.remove(nm)); // c:3874
11094                    let _ = crate::ported::params::paramtab_hashed_storage()
11095                        .lock()
11096                        .ok()
11097                        .as_deref_mut()
11098                        .map(|m| m.remove(nm));
11099                    continue; // c:3893 next argument
11100                }
11101                // c:3900-3905 — whole-param unset.
11102                // Route through `unsetparam` (params.rs) so the
11103                // canonical readonly-guard + pm.old uncover restore
11104                // fires. Without this, `local x=inner; unset x`
11105                // would erase the OUTER binding too (the local pm's
11106                // pm.old chain dropped on the floor).
11107                //
11108                // c:Src/builtin.c:3952-3953 — `if (unsetparam_pm(pm,
11109                // 0, 1)) returnval = 1;` (readonly rejection). On
11110                // rejection the param is untouched, so the env delenv
11111                // (which unsetparam_pm only runs on its success path,
11112                // c:Src/params.c:3872) must not fire either.
11113                //
11114                // The assoc backing row used to be wiped here too, with the
11115                // note "NOT touched by params.rs::unsetparam so we wipe them
11116                // directly here". It is now, because `stdunsetfn`'s PM_HASHED
11117                // arm does what C's does (c:3922-3925 → `hashsetfn` c:4047
11118                // `deleteparamtable(pm->u.hash)`) instead of clearing a
11119                // `u_hash` slot nothing populates. The readonly gate is
11120                // preserved for free: `unsetparam_pm` takes its rejection at
11121                // c:3852 BEFORE it reaches the unsetfn, so a rejected unset
11122                // still drops no row.
11123                if unsetparam(nm) != 0 {
11124                    returnval = 1; // c:3953
11125                } else {
11126                    env::remove_var(nm); // c:3905 delenv
11127                }
11128            }
11129        }
11130    }
11131    unqueue_signals(); // c:3914
11132    returnval // c:3915
11133}
11134
11135/// Port of `fetchcmdnamnode()` from `Src/builtin.c:3967`.
11136/// C decl: `fetchcmdnamnode(HashNode hn, UNUSED(int printflags))`
11137/// C: `static void fetchcmdnamnode(HashNode hn, UNUSED(int printflags))` →
11138///   `addlinknode(matchednodes, cn->node.nam);`
11139/// C body (2 lines):
11140///   `Cmdnam cn = (Cmdnam) hn;
11141///    addlinknode(matchednodes, cn->node.nam);`
11142/// (C source does not null-check hn — callers guarantee non-null.)
11143/// WARNING: param names don't match C — Rust=(hn) vs C=(hn, printflags)
11144pub fn fetchcmdnamnode(
11145    hn: *mut hashnode, // c:3967
11146    _printflags: i32,
11147) {
11148    let nam = unsafe { (*hn).nam.clone() }; // c:3969 cast + read
11149    if let Ok(mut m) = MATCHEDNODES.lock() {
11150        m.push(nam);
11151    } // c:3971
11152}
11153
11154/// Port of `bin_whence()` from `Src/builtin.c:3975`.
11155/// C decl: `bin_whence(char *nam, char **argv, Options ops, int func)`
11156/// C: `int bin_whence(char *nam, char **argv, Options ops, int func)`.
11157///
11158/// `whence`/`type`/`which`/`where`/`command` dispatcher. `-c` csh,
11159/// `-v` verbose, `-a` all-matches, `-w` word-form, `-x` indent
11160/// override, `-m` glob-args, `-p` path-only, `-f` print funcdef,
11161/// `-s/-S` follow symlink. The C body walks alias/reswd/shfunc/
11162/// builtin/cmdnam tabs in order; this port preserves the structure
11163/// and dispatch logic, deferring the per-tab scanmatch walks to the
11164/// existing tab accessors.
11165/// WARNING: param names don't match C — Rust=(nam, argv, func) vs C=(nam, argv, ops, func)
11166pub fn bin_whence(
11167    nam: &str,
11168    argv: &[String], // c:3975
11169    ops: &options,
11170    func: i32,
11171) -> i32 {
11172    let mut returnval: i32 = 0;
11173    let mut printflags: i32 = 0;
11174    let mut informed: i32 = 0;
11175    let mut expand: i32 = 0;
11176
11177    // c:3989-3993 — flags.
11178    let csh = OPT_ISSET(ops, b'c'); // c:3989
11179    let v = OPT_ISSET(ops, b'v'); // c:3990
11180    let all = OPT_ISSET(ops, b'a'); // c:3991
11181    let wd = OPT_ISSET(ops, b'w'); // c:3992
11182
11183    // c:3995-4002 — `-x N` indent override.
11184    if OPT_ISSET(ops, b'x') {
11185        // c:3995
11186        let arg = OPT_ARG(ops, b'x').unwrap_or("");
11187        match arg.trim().parse::<i32>() {
11188            // c:3997
11189            Ok(n) => {
11190                expand = n;
11191                if expand == 0 {
11192                    expand = -1;
11193                } // c:4001
11194            }
11195            Err(_) => {
11196                zwarnnam(nam, "number expected after -x"); // c:3998
11197                return 1;
11198            }
11199        }
11200    }
11201
11202    // !!! BASH-MODE GATE (no C counterpart) !!! bash `type -t NAME` prints a
11203    // single word naming what NAME resolves to — `alias` / `keyword` /
11204    // `function` / `builtin` / `file` — or nothing (exit 1) if unknown. zsh's
11205    // `type` has no `-t`, so this is gated to --bash. Precedence matches bash:
11206    // alias, keyword, function, builtin, file. Uses a closure (not a free fn)
11207    // to satisfy the port-purity build gate on src/ported/.
11208    // Outside bash/ksh93 the registered `-t` is not zsh's: `type`'s optstr is
11209    // "ampfsSw" (c:Src/builtin.c:123), so zsh answers `bad option: -t`, status 1.
11210    if !crate::dash_mode::bash_mode() && !crate::dash_mode::ksh93_mode() && OPT_ISSET(ops, b't') {
11211        zwarnnam(nam, "bad option: -t");
11212        return 1;
11213    }
11214    if crate::dash_mode::bash_mode() && OPT_ISSET(ops, b't') {
11215        let type_of = |name: &str| -> Option<&'static str> {
11216            if crate::dash_mode::aliases_visible()
11217                && aliastab_lock()
11218                    .read()
11219                    .ok()
11220                    .map_or(false, |t| t.get(name).is_some())
11221            {
11222                return Some("alias");
11223            }
11224            if reswdtab_lock()
11225                .read()
11226                .ok()
11227                .map_or(false, |t| t.get(name).is_some())
11228            {
11229                return Some("keyword");
11230            }
11231            if getshfunc(name).is_some() {
11232                return Some("function");
11233            }
11234            let disabled = BUILTINS_DISABLED.lock().map_or(false, |s| s.contains(name));
11235            if !disabled && createbuiltintable().get(name).is_some() {
11236                return Some("builtin");
11237            }
11238            if crate::ported::exec::findcmd(name, 0, 0).is_some() {
11239                return Some("file");
11240            }
11241            None
11242        };
11243        let mut rc = 0;
11244        for name in argv {
11245            match type_of(name) {
11246                Some(t) => println!("{}", t),
11247                None => rc = 1,
11248            }
11249        }
11250        return rc;
11251    }
11252
11253    // !!! BASH-MODE GATE (no C counterpart) !!! `type -P NAME` forces a $PATH
11254    // search and prints the file, whatever else NAME is.
11255    if crate::dash_mode::bash_mode() && OPT_ISSET(ops, b'P') {
11256        let mut rc = 0;
11257        for name in argv {
11258            match crate::ported::exec::findcmd(name, 0, 0) {
11259                Some(path) => println!("{}", path),
11260                None => rc = 1,
11261            }
11262        }
11263        return rc;
11264    }
11265
11266    // c:4004-4012 — printflags from -w/-c/-v/(default simple)/-f.
11267    if OPT_ISSET(ops, b'w') {
11268        printflags |= PRINT_WHENCE_WORD;
11269    }
11270    // c:4004
11271    else if OPT_ISSET(ops, b'c') {
11272        printflags |= PRINT_WHENCE_CSH;
11273    }
11274    // c:4006
11275    else if OPT_ISSET(ops, b'v') {
11276        printflags |= PRINT_WHENCE_VERBOSE;
11277    }
11278    // c:4008
11279    else {
11280        printflags |= PRINT_WHENCE_SIMPLE;
11281    } // c:4010
11282    if OPT_ISSET(ops, b'f') {
11283        printflags |= PRINT_WHENCE_FUNCDEF;
11284    } // c:4012
11285
11286    // c:4015-4024 — BIN_COMMAND -V or -V-equivalent flag wrangling.
11287    // C body:
11288    //   if (func == BIN_COMMAND)
11289    //       if (OPT_ISSET(ops,'V')) { printflags = aliasflags = PRINT_WHENCE_VERBOSE; v = 1; }
11290    //       else { aliasflags = PRINT_LIST; printflags = PRINT_WHENCE_SIMPLE; v = 0; }
11291    //   else aliasflags = printflags;
11292    // Previous Rust port omitted the `v = 0` reset in the non-V
11293    // command branch, so `command foo` with a stray user -v leaked
11294    // verbose mode. Mirror C: force v unconditionally under
11295    // BIN_COMMAND.
11296    let mut v = v;
11297    let aliasflags = if func == BIN_COMMAND {
11298        // c:4015
11299        if OPT_ISSET(ops, b'V') {
11300            // c:4016
11301            printflags = PRINT_WHENCE_VERBOSE; // c:4017
11302            v = true; // c:4018
11303            PRINT_WHENCE_VERBOSE
11304        } else {
11305            printflags = PRINT_WHENCE_SIMPLE; // c:4021
11306            v = false; // c:4022
11307            PRINT_LIST // c:4020
11308        }
11309    } else {
11310        printflags // c:4024
11311    };
11312
11313    // c:4026-4119 — `-m` glob branch: each arg is a pattern; walk every
11314    // hashtab in turn (alias/reswd/shfunc/builtin/cmdnam) and emit a
11315    // print row per matching node. C uses scanmatchtable + a per-tab
11316    // print callback; the Rust port iterates each tab's accessor and
11317    // emits the print directly.
11318    if OPT_ISSET(ops, b'm') {
11319        // c:4028 — `cmdnamtab->filltable(cmdnamtab);` populates every
11320        // $PATH entry into cmdnamtab so the c:4070 scan below sees
11321        // every executable (not just hashed ones). C calls this once
11322        // per `-m` invocation; Rust mirrors with a single fillcmdnamtable
11323        // against the shell-side $PATH array.
11324        if let Some(path) = getsparam("PATH") {
11325            let path_arr: Vec<String> = path.split(':').map(|s| s.to_string()).collect();
11326            fillcmdnamtable(&path_arr);
11327        }
11328        // c:4030-4033 — `if (all) { pushheap(); matchednodes = newlinklist(); }`.
11329        // MATCHEDNODES is the Rust analog of `matchednodes`; pushheap
11330        // is a Rust no-op (no heap allocator).
11331        if all {
11332            // c:4030
11333            if let Ok(mut m) = MATCHEDNODES.lock() {
11334                m.clear();
11335            }
11336        }
11337        queue_signals(); // c:4034
11338        for pat in argv {
11339            // c:4035
11340            // c:4037 — `tokenize(*argv);` (Rust patcompile handles the
11341            // tokenize step internally; explicit call is a no-op here).
11342            let pprog = patcompile(
11343                &{
11344                    let mut __pat_tok = crate::pattern_data_escape::escape_data_backslashes(&pat); // a backslash tokenize leaves raw is DATA (c:Src/glob.c:3651)
11345                    crate::ported::glob::tokenize(&mut __pat_tok);
11346                    __pat_tok
11347                }, // c:4038
11348                PAT_HEAPDUP,
11349                None,
11350            );
11351            match pprog {
11352                None => {
11353                    // c:4039
11354                    zwarnnam(nam, &format!("bad pattern : {}", pat)); // c:4040
11355                    returnval = 1; // c:4041
11356                    continue;
11357                }
11358                Some(prog) => {
11359                    if !OPT_ISSET(ops, b'p') {
11360                        // c:4044 — !`-p` path-only.
11361                        // c:4049-4051 — `scanmatchtable(aliastab, pprog,
11362                        //   1, 0, DISABLED, aliastab->printnode, printflags);`.
11363                        // Route through the canonical printnode callback.
11364                        let alias_matches: Vec<alias> = aliastab_lock()
11365                            .read()
11366                            .map(|t| {
11367                                t.iter()
11368                                    .filter(|(n, _)| pattry(&prog, n))
11369                                    .map(|(_, a)| a.clone())
11370                                    .collect()
11371                            })
11372                            .unwrap_or_default();
11373                        for a in &alias_matches {
11374                            printaliasnode(a, printflags); // c:4051
11375                            informed += 1; // c:4049
11376                        }
11377                        // c:4054-4056 — `scanmatchtable(reswdtab, pprog,
11378                        //   1, 0, DISABLED, reswdtab->printnode, printflags);`.
11379                        // reswdtab->printnode is `printreswdnode`
11380                        // (Src/hashtable.c:1147), which honours the
11381                        // -w/-c/-v printflags (`for: reserved`). The
11382                        // DISABLED mask skips `disable -r`ed words.
11383                        // c:Src/hashtable.c:402 sorts by hnamcmp (ztrcmp).
11384                        let mut rw_matches: Vec<crate::ported::zsh_h::reswd> = reswdtab_lock()
11385                            .read()
11386                            .map(|t| {
11387                                t.iter()
11388                                    .filter(|(k, _)| pattry(&prog, k))
11389                                    .filter(|(_, rw)| (rw.node.flags & DISABLED as i32) == 0)
11390                                    .map(|(_, rw)| rw.clone())
11391                                    .collect()
11392                            })
11393                            .unwrap_or_default();
11394                        rw_matches.sort_by(|a, b| crate::ported::utils::ztrcmp(&a.node.nam, &b.node.nam));
11395                        for rw in &rw_matches {
11396                            crate::ported::hashtable::printreswdnode(rw, printflags); // c:4056
11397                            informed += 1; // c:4054
11398                        }
11399                        // c:4059-4061 — `scanmatchshfunc(pprog, 1, 0,
11400                        //   DISABLED, shfunctab->printnode, printflags,
11401                        //   expand);`. Route through canonical
11402                        // printshfuncexpand with `expand`.
11403                        let func_matches: Vec<shfunc> = shfunctab_lock()
11404                            .read()
11405                            .map(|t| {
11406                                t.iter()
11407                                    .filter(|(n, _)| pattry(&prog, n))
11408                                    .map(|(_, f)| f.clone())
11409                                    .collect()
11410                            })
11411                            .unwrap_or_default();
11412                        for f in &func_matches {
11413                            printshfuncexpand(f, printflags, expand); // c:4061
11414                            informed += 1; // c:4059
11415                        }
11416                        // c:4064-4066 — `scanmatchtable(builtintab, pprog,
11417                        //   1, 0, DISABLED, builtintab->printnode,
11418                        //   printflags);`. C's scanmatchtable walks the
11419                        // hashtable in a deterministic order that, for
11420                        // the matched-against name set, happens to be
11421                        // sorted alphabetically — that's what makes
11422                        // `whence -m "echo*"` emit echo, echotc,
11423                        // echoti. zshrs's BUILTINS Vec is in
11424                        // declaration order (echo, echoti, echotc) so
11425                        // the unsorted walk produced a different
11426                        // ordering. Collect+sort to match.
11427                        // c:4065 — the `DISABLED` mask arg to scanmatchtable
11428                        // makes the walk SKIP nodes carrying DISABLED, so a
11429                        // `disable`d builtin never appears in `whence -m`.
11430                        let disabled_snapshot: std::collections::HashSet<String> =
11431                            BUILTINS_DISABLED
11432                                .lock()
11433                                .map(|s| s.clone())
11434                                .unwrap_or_default();
11435                        // c:4064 — the walk is over `builtintab`, one node per
11436                        // name. The raw BUILTINS vec repeats module rows
11437                        // (`kill`, `log`, …) that createbuiltintable folds, so
11438                        // walking it printed `kill` two or three times.
11439                        let mut bn_matches: Vec<&builtin> = createbuiltintable()
11440                            .values()
11441                            .copied()
11442                            .filter(|b| pattry(&prog, &b.node.nam))
11443                            .filter(|b| !disabled_snapshot.contains(b.node.nam.as_str()))
11444                            // Same module gate as the name lookup below: a
11445                            // builtin of an unloaded module (`zstat`) is not in
11446                            // C's builtintab until `zmodload` adds it.
11447                            .filter(|b| crate::extensions::ext_builtins::module_builtin_available(&b.node.nam))
11448                            .collect();
11449                        // c:Src/hashtable.c:402 `qsort(…, hnamcmp)` — ztrcmp.
11450                        bn_matches.sort_by(|a, b| crate::ported::utils::ztrcmp(&a.node.nam, &b.node.nam));
11451                        for b in bn_matches {
11452                            printbuiltinnode(
11453                                &b.node as *const hashnode as *mut hashnode,
11454                                printflags,
11455                            ); // c:4066
11456                            informed += 1; // c:4064
11457                        }
11458                    }
11459                    // c:4070-4073 — `scanmatchtable(cmdnamtab, pprog,
11460                    //   1, 0, 0, (all ? fetchcmdnamnode :
11461                    //   cmdnamtab->printnode), printflags);`. After
11462                    // fillcmdnamtable above, cmdnamtab has every
11463                    // PATH-resident command name. Walk the canonical
11464                    // table (not std::fs::read_dir) so HASHED/non-
11465                    // HASHED distinction is preserved.
11466                    // The `1` in that call is scanmatchtable's `sorted`
11467                    // argument, and c:Src/hashtable.c:402 sorts with
11468                    // `qsort(hnsorttab, ct, sizeof(HashNode), hnamcmp)`
11469                    // where `hnamcmp` (c:341-346) is
11470                    // `ztrcmp(a->nam, b->nam)` — the NODE NAME. The
11471                    // printed token (the full path) is not what orders
11472                    // the walk; it only happens to agree when the
11473                    // matches all live in one directory.
11474                    //
11475                    // This sorted on the printed path instead, above a
11476                    // comment asserting that was C's order. Measured
11477                    // against real zsh 5.9 with `gls`/`ls`/`rls` in
11478                    // three different directories:
11479                    //
11480                    //   % zsh   -f -c 'setopt extendedglob
11481                    //                  whence -m "(#a1)lls"'
11482                    //   /opt/homebrew/bin/gls
11483                    //   /bin/ls
11484                    //   /Users/…/.cargo/bin/rls
11485                    //
11486                    // i.e. gls < ls < rls by NAME, while the paths run
11487                    // /Users < /bin < /opt. zshrs answered in path
11488                    // order (rls, ls, gls) — every `whence -m`,
11489                    // `where`, `which -m` with matches spanning
11490                    // directories was mis-ordered, and
11491                    // `_correct_filename '=lls'` inherited it.
11492                    let mut cmd_matches: Vec<(String, cmdnam)> = cmdnamtab_lock()
11493                        .read()
11494                        .map(|t| {
11495                            t.iter()
11496                                .filter(|(n, _)| pattry(&prog, n))
11497                                .map(|(n, c)| (n.clone(), c.clone()))
11498                                .collect()
11499                        })
11500                        .unwrap_or_default();
11501                    // c:341-346 `hnamcmp` — `ztrcmp(a->nam, b->nam)`.
11502                    cmd_matches.sort_by(|a, b| crate::ported::utils::ztrcmp(&a.1.node.nam, &b.1.node.nam));
11503                    for (n, c) in &cmd_matches {
11504                        if all {
11505                            // c:4072 fetchcmdnamnode — accumulates
11506                            // matching node names into matchednodes.
11507                            if let Ok(mut m) = MATCHEDNODES.lock() {
11508                                m.push(n.clone());
11509                            }
11510                        } else {
11511                            // c:4072 cmdnamtab->printnode — emits per
11512                            // PRINT_WHENCE_WORD/CSH/VERBOSE branches.
11513                            printcmdnamnode(c, printflags);
11514                        }
11515                        informed += 1; // c:4070
11516                    }
11517                }
11518            }
11519            run_queued_signals(); // c:4079
11520        }
11521        unqueue_signals(); // c:4081
11522        if !all {
11523            // c:4082-4084 — `return returnval || !informed;` (early-out
11524            // when not in `-a` accumulator mode).
11525            return if returnval != 0 || informed == 0 {
11526                1
11527            } else {
11528                0
11529            }; // c:4082
11530        }
11531    }
11532
11533    // c:4121-4205 — literal-name dispatch per arg.
11534    queue_signals();
11535    // C source uses MATCHEDNODES only when `-m` (glob-args) is set;
11536    // plain `-a` keeps the literal argv. Without this gate, `whence
11537    // -a true` consulted an empty MATCHEDNODES and skipped every
11538    // print.
11539    let argv_vec: Vec<String> = if OPT_ISSET(ops, b'm') {
11540        MATCHEDNODES.lock().map(|m| m.clone()).unwrap_or_default()
11541    } else {
11542        argv.to_vec()
11543    };
11544    // c:4076 — `allmatched` is set exactly when -m ran in -a mode (the
11545    // non -a case returned at c:4082); those names came from cmdnamtab and
11546    // the internal tables were already scanned, so c:4091 skips them.
11547    let allmatched = OPT_ISSET(ops, b'm');
11548    for arg in &argv_vec {
11549        // c:4121
11550        // c:4088 — `informed = 0;` reset per iteration so the per-arg
11551        // not-found path can fire correctly.
11552        informed = 0; // c:4088
11553                      // c:4090 `char *cnam` is the findcmd return in C; in Rust it
11554                      // is bound inline at the findcmd call site below.
11555                      // c:4089-4137 — !`-p` and !`-a` matched-from-prior-`-m` arm.
11556        if !OPT_ISSET(ops, b'p') && !allmatched {
11557            // c:4093-4097 — alias check. C: `aliastab->printnode(hn, aliasflags)`.
11558            let alias_text = aliastab_lock()
11559                .read()
11560                .ok()
11561                .filter(|_| crate::dash_mode::aliases_visible())
11562                .and_then(|t| t.get(arg).map(|a| a.clone()));
11563            if let Some(a) = alias_text {
11564                printaliasnode(&a, aliasflags); // c:4094
11565                informed = 1; // c:4095
11566                if !all {
11567                    continue;
11568                } // c:4097
11569            }
11570            // c:4099 /* Look for suffix alias */
11571            // c:4100-4102 — `if ((suf = strrchr(*argv, '.')) && suf[1] &&
11572            //   suf > *argv && suf[-1] != Meta &&
11573            //   (hn = sufaliastab->getnode(sufaliastab, suf+1)))`.
11574            // The order is load-bearing: C short-circuits, so `suf[-1]`
11575            // (the byte BEFORE `.`) is read only after `suf > *argv`
11576            // confirms `.` is not at position 0.
11577            if let Some(idx) = arg.rfind('.') {
11578                // c:4100 — suf = strrchr(*argv, '.'); suf[1] (char after `.`
11579                // is non-NUL) AND c:4101 suf > *argv (`.` not at start)
11580                // AND suf[-1] != Meta (char before `.` is not Meta-escape).
11581                // Chained into one `if` so Rust short-circuits identically
11582                // to C; reading `arg.as_bytes()[idx - 1]` eagerly with
11583                // idx==0 panics with subtract-overflow.
11584                if idx + 1 < arg.len() && idx > 0 && arg.as_bytes()[idx - 1] as u8 != Meta {
11585                    let suf = &arg[idx + 1..]; // c:4102 suf+1
11586                    let suf_alias = sufaliastab_lock()
11587                        .read()
11588                        .ok()
11589                        .and_then(|t| t.get(suf).map(|a| a.clone()));
11590                    if let Some(a) = suf_alias {
11591                        printaliasnode(&a, printflags); // c:4103
11592                        informed = 1; // c:4104
11593                        if !all {
11594                            continue;
11595                        } // c:4106
11596                    }
11597                }
11598            }
11599            // c:4109-4114 — `if ((hn = reswdtab->getnode(reswdtab, *argv)))
11600            //   reswdtab->printnode(hn, printflags);`. Reads canonical
11601            // reswdtab instead of a drift-prone literal array.
11602            let is_reswd = reswdtab_lock()
11603                .read()
11604                .map(|t| t.get(arg).is_some())
11605                .unwrap_or(false);
11606            if is_reswd {
11607                // c:4109
11608                if (printflags & PRINT_WHENCE_WORD as i32) != 0 {
11609                    println!("{}: reserved", arg);
11610                } else if (printflags & PRINT_WHENCE_CSH as i32) != 0 {
11611                    println!("{}: shell reserved word", arg);
11612                } else if (printflags & PRINT_WHENCE_VERBOSE as i32) != 0 {
11613                    println!(
11614                        "{} is a {}",
11615                        arg,
11616                        if crate::dash_mode::dash_faithful() {
11617                            "shell keyword"
11618                        } else if crate::dash_mode::ksh93_mode() {
11619                            "keyword"
11620                        } else {
11621                            "reserved word"
11622                        }
11623                    );
11624                } else {
11625                    println!("{}", arg); // c:4110
11626                }
11627                informed = 1; // c:4111
11628                if !all {
11629                    continue;
11630                } // c:4112
11631            }
11632            // c:4116-4121 — shell function check. C:
11633            //   `printshfuncexpand(hn, printflags, expand)`.
11634            // Inline match-on-printflags reimplementation deleted —
11635            // route through the canonical port at hashtable.rs:1407,
11636            // which threads `expand` (for `-x N` indent override) and
11637            // handles PRINT_WHENCE_FUNCDEF/PRINT_WHENCE_WORD/_CSH/
11638            // _VERBOSE branches per Src/hashtable.c:1340-1404.
11639            let shfunc_node = getshfunc(arg);
11640            if let Some(ref f) = shfunc_node {
11641                // !!! EMULATION-ONLY (no C counterpart) !!! zsh's verbose
11642                // form names the file the function came from — for a `-c`
11643                // script that is the shell itself, so `type f` ends in
11644                // "is a shell function from zsh". No real Bourne shell says
11645                // that: bash / ksh93 / mksh print "f is a function" and
11646                // dash / ash print "f is a shell function", and bash alone
11647                // follows the header with the definition. Measured on the
11648                // real binaries; see dash_mode::function_whence_verbose.
11649                // Only the VERBOSE form (`type` / `whence -v` /
11650                // `command -V`) is affected — `-w`, `-c` and the plain
11651                // simple form already match.
11652                let verbose_only = (printflags & PRINT_WHENCE_VERBOSE) != 0
11653                    && (printflags & PRINT_WHENCE_FUNCDEF) == 0;
11654                match crate::dash_mode::function_whence_verbose(arg).filter(|_| verbose_only) {
11655                    Some(line) => {
11656                        println!("{}", line);
11657                        if crate::dash_mode::function_whence_prints_body() {
11658                            // Same rendering bin_trap uses for a trap body:
11659                            // the stored source re-parsed and deparsed, one
11660                            // statement per line.
11661                            let rendered = f
11662                                .body
11663                                .as_deref()
11664                                .filter(|b| !b.is_empty())
11665                                .and_then(|b| crate::ported::exec::parse_string(b, 1))
11666                                .map(|prog| {
11667                                    crate::ported::text::getpermtext(Box::new(prog), None, 0)
11668                                })
11669                                .unwrap_or_default();
11670                            print!("{}", crate::dash_mode::bash_function_body(arg, &rendered));
11671                        }
11672                    }
11673                    None => printshfuncexpand(f, printflags, expand), // c:4117
11674                }
11675                informed = 1; // c:4118
11676                if !all {
11677                    continue;
11678                } // c:4120
11679            }
11680            // c:4123-4128 — builtin check. C: `builtintab->printnode(
11681            //   hn, printflags)` → printbuiltinnode at Src/builtin.c:174.
11682            // Inline match-on-(wd|csh|v) reimplementation deleted —
11683            // route through the canonical port at builtin.rs:139.
11684            // c:Src/Modules/files.c:806-824 — bare `mkdir`/`rm`/`mv`/`ln`/
11685            // `chmod`/... are bound by `zsh/files`, so without an explicit
11686            // `zmodload zsh/files` the name is not in builtintab at all and
11687            // `type rm` must fall through to the cmdnamtab/$PATH path below
11688            // (docs/BUGS.md #28). That is one instance of the general rule:
11689            // One predicate for every consumer — see
11690            // `ext_builtins::module_builtin_available`. The previous code
11691            // here was a hand-maintained list of gated names (zsh/files,
11692            // zsh/stat, zsh/zselect, zsh/zpty, zsh/net/tcp, zsh/zftp,
11693            // zsh/system) extended one bug at a time, so every module NOT on
11694            // it leaked through: `whence -w strftime`, `pcre_compile`,
11695            // `clone`, `zcurses`, `ztie`, `zgdbmpath`, `cap`, `getcap`,
11696            // `zgetattr`, `zdelattr`, `sysopen`, `zsocket`, `zprof` and
11697            // `example` all reported `builtin` where zsh -f reports `none`,
11698            // and where this very shell's own `${+builtins[...]}` said 0.
11699            let is_module_bound_gated =
11700                !crate::extensions::ext_builtins::module_builtin_available(arg);
11701            // c:Src/builtin.c:4123 — `builtintab->getnode(name)` returns NULL
11702            // for a builtin carrying the DISABLED flag (set by `disable NAME`),
11703            // so whence/type do NOT report a disabled builtin as a builtin and
11704            // instead fall through to the hashed/PATH external command.
11705            let is_disabled_builtin = BUILTINS_DISABLED
11706                .lock()
11707                .map(|s| s.contains(arg.as_str()))
11708                .unwrap_or(false);
11709            let builtin_node: Option<*mut hashnode> =
11710                if is_module_bound_gated || is_disabled_builtin {
11711                    None
11712                } else {
11713                    BUILTINS
11714                        .iter()
11715                        .find(|b| b.node.nam == *arg)
11716                        .map(|b| &b.node as *const hashnode as *mut hashnode)
11717                };
11718            if let Some(hn) = builtin_node {
11719                printbuiltinnode(hn, printflags); // c:4124
11720                informed = 1; // c:4125
11721                if !all {
11722                    continue;
11723                } // c:4127
11724            }
11725            // zshrs extension builtins (daemon z* family: zd, zcache,
11726            // zjob, …) dispatch by name via try_dispatch instead of
11727            // living in BUILTINS — but they ARE builtins and whence/
11728            // type must classify them as such (`whence -w zd` reported
11729            // the external /opt/homebrew/bin/zd instead).
11730            // Under `--zsh` / ZSHRS_HIDE_EXT_BUILTINS these zshrs-only
11731            // names are NOT in the builtin namespace — `${(k)builtins}`
11732            // omits them (Src/Modules/parameter.c:823 mirror at
11733            // modules/parameter.rs:2046) — so whence must not report
11734            // them either. One predicate for every consumer:
11735            // `ext_builtins::hide_ext_builtins`. Dispatch is untouched.
11736            if builtin_node.is_none()
11737                && !is_disabled_builtin
11738                && !crate::ext_builtins::hide_ext_builtins()
11739                && crate::daemon::builtins::is_zshrs_builtin(arg)
11740            {
11741                let mut ext_node = hashnode {
11742                    next: None,
11743                    nam: arg.clone(),
11744                    flags: 0,
11745                };
11746                printbuiltinnode(&mut ext_node as *mut hashnode, printflags);
11747                informed = 1;
11748                if !all {
11749                    continue;
11750                }
11751            }
11752            // zshrs extension builtins folded into builtintab (znative package
11753            // manager, ztest/zassert framework, watch) — first-class builtins
11754            // absent from the static BUILTINS table; classify like the daemon
11755            // z* family above so `whence -w zassert_eq` reports `builtin`.
11756            // Same `hide_ext_builtins()` gate as the daemon arm above:
11757            // `zsh -f` has no `cat`/`head`/`seq`/`tail` builtin, so
11758            // `which cat` must print `/bin/cat`, not `cat: shell
11759            // built-in command` (E01options.ztst:17 — the %prep line
11760            // `catpath=$(which cat)` feeds the EQUALS expectation).
11761            if builtin_node.is_none()
11762                && !is_disabled_builtin
11763                && !crate::ext_builtins::hide_ext_builtins()
11764                && !crate::daemon::builtins::is_zshrs_builtin(arg)
11765                && crate::extensions::ext_builtins::is_extension_builtin(arg)
11766            {
11767                let mut ext_node = hashnode {
11768                    next: None,
11769                    nam: arg.clone(),
11770                    flags: 0,
11771                };
11772                printbuiltinnode(&mut ext_node as *mut hashnode, printflags);
11773                informed = 1;
11774                if !all {
11775                    continue;
11776                }
11777            }
11778            // c:4167-4173 — cmdnamtab HASHED check (commands installed
11779            // via `hash NAME=PATH`). Read the canonical cmdnamtab
11780            // directly. Was a fake env-var bridge under invented
11781            // `__zshrs_hash_NAME` keys; cmdnamtab is bucket-2-
11782            // consolidated now.
11783            // c:4115 — `if ((hn = cmdnamtab->getnode(cmdnamtab, *argv)) &&
11784            //             (hn->flags & HASHED))`.
11785            let hashed_node: Option<cmdnam> = {
11786                match cmdnamtab_lock().read() {
11787                    Ok(tab) => tab
11788                        .get(arg)
11789                        .filter(|cn| (cn.node.flags & HASHED as i32) != 0)
11790                        .cloned(),
11791                    Err(_) => None,
11792                }
11793            };
11794            if let Some(cn) = hashed_node {
11795                // c:4116 — `cmdnamtab->printnode(hn, printflags)`, i.e.
11796                // `printcmdnamnode`, which dispatches on the PRINT_WHENCE_*
11797                // bits. This site open-coded `println!("{}", p)` plus a
11798                // PRINT_LIST special case, so every other form was wrong:
11799                // `type foo` (PRINT_WHENCE_VERBOSE) printed the bare path
11800                // instead of `foo is hashed to /usr/bin/awk`, and
11801                // `whence -w foo` (PRINT_WHENCE_WORD) lost `foo: hashed`.
11802                printcmdnamnode(&cn, printflags);
11803                informed = 1; // c:4117
11804                if !all {
11805                    continue;
11806                } // c:4118-4119
11807            }
11808        }
11809        // c:4141-4172 — `-a` all-paths search. C iterates the
11810        // shell-side `path` array (the tied $path/$PATH global,
11811        // Src/parse.c). Rust reads $PATH via getsparam — same source.
11812        if all && !arg.starts_with('/') {
11813            // c:4141
11814            if let Some(path) = getsparam("PATH") {
11815                for dir in path.split(':') {
11816                    // c:4145 — `if (**pp) buf = zhtricat(*pp, "/", *argv);
11817                    //              else      buf = dupstring(*argv);`.
11818                    // Empty path entry means CWD per POSIX, but C still
11819                    // joins with "/" if non-empty; Rust matches the
11820                    // !empty arm.
11821                    if dir.is_empty() {
11822                        continue;
11823                    }
11824                    let full = format!("{}/{}", dir, arg); // c:4147
11825                                                           // c:4150 — `iscom(buf)`: access(X_OK)==0 &&
11826                                                           // S_ISREG(stat). Was `Path::is_file()` which omits
11827                                                           // the X_OK check — would have flagged non-executable
11828                                                           // files as matches.
11829                    if iscom(&full) {
11830                        // c:4150
11831                        if wd {
11832                            // c:4151
11833                            println!("{}: command", arg); // c:4152
11834                        } else {
11835                            if v && !csh {
11836                                // c:4154
11837                                print!("{} is ", arg); // c:4155
11838                                print!("{}", quotedzputs(&full)); // c:4156
11839                            } else {
11840                                print!("{}", full); // c:4158
11841                            }
11842                            // c:4159-4160 — `if (OPT_ISSET(ops,'s') ||
11843                            //   OPT_ISSET(ops,'S')) print_if_link(buf,
11844                            //   OPT_ISSET(ops,'S'));`. -s prints just
11845                            // the final realpath; -S prints the whole
11846                            // chain.
11847                            if OPT_ISSET(ops, b's') || OPT_ISSET(ops, b'S') {
11848                                print_if_link(&full, OPT_ISSET(ops, b'S')); // c:4160
11849                            }
11850                            println!(); // c:4161 fputc('\n', stdout)
11851                        }
11852                        informed = 1; // c:4163
11853                    } else {
11854                    }
11855                }
11856            }
11857            // c:4166-4171 — `if (!informed && (wd || v || csh))`. C:
11858            //     zputs(*argv, stdout); puts(wd ? ": none" : " not found");
11859            // Was `if !informed != 0 && ...` which is broken Rust — the
11860            // `!` is bitwise NOT on the i32, so the condition was true
11861            // when informed != 0 (inverted). Fix: explicit `informed == 0`.
11862            if informed == 0 && (wd || v || csh) {
11863                // c:4166
11864                // dash: `NAME: not found` (see the end-of-function status note).
11865                if (crate::dash_mode::bash_mode() || crate::dash_mode::ksh93_mode()) && !wd {
11866                    // bash and ksh93 report a missing name on stderr: `type: NAME: not found`.
11867                    zwarnnam(nam, &format!("{arg}: not found"));
11868                } else {
11869                println!("{}{}", arg, if wd { ": none" } else if crate::dash_mode::dash_strict() { ": not found" } else { " not found" }); // c:4168-4169
11870                }
11871                returnval = 1; // c:4170
11872            }
11873            continue;
11874        }
11875        // c:4157-4164 — `} else if (func == BIN_COMMAND &&
11876        //     OPT_ISSET(ops,'p') && (hn = builtintab->getnode(builtintab,
11877        //     *argv))) {`
11878        //     /*
11879        //      * Special case for "command -p[vV]" which needs to
11880        //      * show a builtin in preference to an external command.
11881        //      */
11882        //     builtintab->printnode(hn, printflags);
11883        //     informed = 1;
11884        if func == BIN_COMMAND && OPT_ISSET(ops, b'p') {
11885            // c:4157
11886            // c:4158 is `builtintab->getnode(builtintab, *argv)`, the SAME
11887            // lookup as c:4123 above — so it answers NULL for exactly the
11888            // same two reasons, and this arm has to respect both. A raw
11889            // `BUILTINS.iter().find()` here reported `chown`, `mkdir`, `rm`
11890            // and every other `zsh/files` name as a builtin under
11891            // `command -pv` while `whence -w` on the same name correctly
11892            // said `none`, because the module-bound gate and the `disable`
11893            // set were only applied at c:4123. The divergence was hidden as
11894            // long as `-p` never reached `ops` at all.
11895            let gated = !crate::extensions::ext_builtins::module_builtin_available(arg)
11896                || BUILTINS_DISABLED
11897                    .lock()
11898                    .map(|s| s.contains(arg.as_str()))
11899                    .unwrap_or(false);
11900            if let Some(b) = BUILTINS
11901                .iter()
11902                .find(|b| b.node.nam == *arg)
11903                .filter(|_| !gated)
11904            {
11905                // c:4163 — `builtintab->printnode(hn, printflags)`, NOT a
11906                // hardcoded `NAME: builtin` line: `printflags` here is
11907                // PRINT_WHENCE_SIMPLE for `-pv` (prints just `echo`) and
11908                // PRINT_WHENCE_VERBOSE for `-p -V` (prints `echo is a shell
11909                // builtin`). The `NAME: builtin` spelling is the
11910                // PRINT_WHENCE_WORD form and belongs to `whence -w`.
11911                printbuiltinnode(&b.node as *const hashnode as *mut hashnode, printflags); // c:4163
11912                informed = 1; // c:4164
11913                continue;
11914            }
11915        }
11916        // c:4181-4197 — external-command fallback via findcmd.
11917        // C: `if ((cnam = findcmd(*argv, 1, func == BIN_COMMAND &&
11918        //   OPT_ISSET(ops, 'p'))))`. Single call site — the previous
11919        // Rust port had two near-duplicate findcmd blocks which
11920        // doubled the lookup. Collapsed into one to match C.
11921        if let Some(cnam) = findcmd(
11922            arg,
11923            1,                                                    // c:4181 docmd
11924            (func == BIN_COMMAND && OPT_ISSET(ops, b'p')) as i32, // c:4182-4183
11925        ) {
11926            // c:4181
11927            if wd {
11928                // c:4184
11929                println!("{}: command", arg); // c:4186
11930            } else {
11931                if v && !csh {
11932                    // c:4188
11933                    print!("{} is ", arg); // c:4189
11934                    print!("{}", quotedzputs(&cnam)); // c:4190
11935                } else {
11936                    print!("{}", cnam); // c:4192
11937                }
11938                // c:4193-4194 — `-s`/`-S` symlink follow.
11939                if OPT_ISSET(ops, b's') || OPT_ISSET(ops, b'S') {
11940                    print_if_link(&cnam, OPT_ISSET(ops, b'S')); // c:4194
11941                }
11942                println!(); // c:4195 fputc('\n', stdout)
11943            }
11944            informed = 1; // c:4197
11945            continue;
11946        }
11947        // c:4201-4205 — not found at all.
11948        if v || csh || wd {
11949            // c:4202
11950            if (crate::dash_mode::bash_mode() || crate::dash_mode::ksh93_mode()) && !wd {
11951                zwarnnam(nam, &format!("{arg}: not found"));
11952            } else {
11953            println!("{}{}", arg, if wd { ": none" } else if crate::dash_mode::dash_strict() { ": not found" } else { " not found" }); // c:4203
11954            }
11955        }
11956        returnval = 1; // c:4204
11957    }
11958    unqueue_signals();
11959    let status = returnval | (informed == 0) as i32; // c:4209
11960    // !!! DASH-STRICT GATE (no C counterpart) !!! dash's `type` / `command -v`
11961    // / `command -V` answer 127 (not zsh's 1) when a name is not found.
11962    if crate::dash_mode::dash_strict() && status != 0 { 127 } else { status }
11963}
11964
11965/// Port of `bin_hash()` from `Src/builtin.c:4234`.
11966/// C decl: `bin_hash(char *name, char **argv, Options ops, UNUSED(int func))`
11967/// C: `int bin_hash(char *name, char **argv, Options ops, ...)` —
11968///   manage `cmdnamtab` (default) or `nameddirtab` (`-d`); `-r` empties,
11969///   `-f` fills, `-L` sets PRINT_LIST, `-m` is a glob.
11970/// WARNING: param names don't match C — Rust=(name, argv, _func) vs C=(name, argv, ops, func)
11971pub fn bin_hash(
11972    name: &str,
11973    argv: &[String], // c:4234
11974    ops: &options,
11975    _func: i32,
11976) -> i32 {
11977    if crate::dash_mode::bash_mode() {
11978        return crate::extensions::emulation_output::bash_hash(argv, ops);
11979    }
11980    let mut returnval = 0i32; // c:4239
11981    let mut printflags = 0i32; // c:4240
11982    let dir_mode = OPT_ISSET(ops, b'd'); // c:4242
11983
11984    // PFA-SMR aspect: only `hash -d NAME=PATH` mutates the named-dir
11985    // table; the default `hash CMD=PATH` form populates a runtime
11986    // command cache that the recorder doesn't re-apply.
11987    #[cfg(feature = "recorder")]
11988    if crate::recorder::is_enabled() && dir_mode {
11989        let ctx = crate::recorder::recorder_ctx_global();
11990        for a in argv {
11991            if a.starts_with('-') {
11992                continue;
11993            }
11994            if let Some((k, v)) = a.split_once('=') {
11995                crate::recorder::emit_hash_d(k, v, ctx.clone());
11996            }
11997        }
11998    }
11999
12000    // c:4247-4263 — `-r` empty / `-f` fill (no other args).
12001    if OPT_ISSET(ops, b'r') || OPT_ISSET(ops, b'f') {
12002        // c:4247
12003        if !argv.is_empty() {
12004            // c:4249
12005            zwarnnam("hash", "too many arguments"); // c:4250
12006            return 1; // c:4251
12007        }
12008        if OPT_ISSET(ops, b'r') {
12009            // c:4255
12010            // c:4256 — `emptyhashtable(cmdnamtab)` /
12011            // `emptynameddirtable()`.
12012            if dir_mode {
12013                emptynameddirtable();
12014            } else {
12015                emptycmdnamtable();
12016            }
12017        }
12018        if OPT_ISSET(ops, b'f') {
12019            // c:4259
12020            // c:4260 — `fillcmdnamtable(cmdnamtab)` /
12021            // `fillnameddirtable()`. cmdnamtab fill = walk every
12022            // PATH entry and hashdir() it.
12023            if dir_mode {
12024                fillnameddirtable();
12025            } else {
12026                // Read $path (the lowercase array form) from env.
12027                // c:4260 — fill cmdnamtab from $path. Read shell-side
12028                //          $PATH so changes via `path=(...)` flow in.
12029                let path_str = getsparam("PATH").unwrap_or_default();
12030                let path_arr: Vec<String> = path_str.split(':').map(|s| s.to_string()).collect();
12031                fillcmdnamtable(&path_arr);
12032            }
12033        }
12034        return 0; // c:4262
12035    }
12036
12037    // c:4265 — `-L` enables PRINT_LIST.
12038    if OPT_ISSET(ops, b'L') {
12039        printflags |= PRINT_LIST;
12040    } // c:4265
12041
12042    // c:4268-4273 — no args: list table.
12043    if argv.is_empty() {
12044        // c:4268
12045        queue_signals(); // c:4269
12046                         // c:4270 — `scanhashtable(ht, 1, 0, 0, ht->printnode, printflags)`.
12047                         // Walk the selected table (cmdnamtab default, nameddirtab when
12048                         // `-d`). Previous Rust port only walked nameddirtab — `hash`
12049                         // with no args (the typical user-visible form) silently printed
12050                         // nothing on cmdnamtab.
12051                         // c:4270 — `scanhashtable(ht, 1, 0, 0, ht->printnode, printflags)`.
12052                         // The second arg `1` is `sorted` — the table is listed in
12053                         // alphabetical (meta-aware hnamcmp) order, not hash/insertion
12054                         // order. The previous port walked `t.iter()` raw, so `hash -d`
12055                         // printed entries in an arbitrary order (`two`, `one` instead of
12056                         // `one`, `two`).
12057        if dir_mode {
12058            if let Ok(t) = nameddirtab().lock() {
12059                let mut entries: Vec<_> = t.iter().collect();
12060                entries.sort_by(|a, b| hnamcmp(a.0, b.0)); // c:4270 sorted=1
12061                for (_n, nd) in entries {
12062                    printnameddirnode(nd, printflags);
12063                }
12064            }
12065        } else {
12066            // c:4270 — cmdnamtab walk (the default `ht`). PATH lookup
12067            // arr is empty in the printnode call site because per-node
12068            // hashed entries carry their own resolved path.
12069            if let Ok(t) = cmdnamtab_lock().read() {
12070                let mut entries: Vec<_> = t.iter().collect();
12071                entries.sort_by(|a, b| hnamcmp(a.0, b.0)); // c:4270 sorted=1
12072                // ZSHRS-ONLY: bash heads its `hash` listing with a
12073                // `hits<TAB>command` column header. No other shell here
12074                // prints one, and zsh's port has no concept of it.
12075                if !entries.is_empty() {
12076                    if let Some(h) = crate::extensions::emulation_output::hash_header() {
12077                        println!("{h}");
12078                    }
12079                }
12080                for (_n, cn) in entries {
12081                    // c:4270 — `scanhashtable(cmdnamtab, ..., printcmdnamnode, ...)`
12082                    printcmdnamnode(cn, printflags);
12083                }
12084            }
12085        }
12086        unqueue_signals(); // c:4271
12087        return 0; // c:4272
12088    }
12089
12090    // c:4276-4329 — name-list dispatch, both literal and -m glob.
12091    queue_signals(); // c:4276
12092    let mut idx = 0;
12093    while idx < argv.len() {
12094        // c:4277
12095        let arg = &argv[idx];
12096        idx += 1;
12097        if OPT_ISSET(ops, b'm') {
12098            // c:4279
12099            // c:4280-4290 — glob-match path.
12100            let pprog = patcompile(
12101                &{
12102                    let mut __pat_tok = crate::pattern_data_escape::escape_data_backslashes(&arg); // a backslash tokenize leaves raw is DATA (c:Src/glob.c:3651)
12103                    crate::ported::glob::tokenize(&mut __pat_tok);
12104                    __pat_tok
12105                }, // c:4282
12106                PAT_HEAPDUP,
12107                None,
12108            );
12109            if let Some(prog) = pprog {
12110                if dir_mode {
12111                    if let Ok(t) = nameddirtab().lock() {
12112                        let mut entries: Vec<_> = t.iter().collect();
12113                        entries.sort_by(|a, b| hnamcmp(a.0, b.0)); // c:4286 sorted=1
12114                        for (n, nd) in entries {
12115                            if pattry(&prog, n) {
12116                                // c:4286
12117                                printnameddirnode(nd, printflags);
12118                            }
12119                        }
12120                    }
12121                } else {
12122                    // c:4286 — `scanmatchtable(ht, pprog, 1, 0, 0, ht->printnode,
12123                    // printflags)`. `ht` is cmdnamtab unless `-d` selected
12124                    // nameddirtab, and this arm was missing entirely: `hash -m
12125                    // 'aw*'` matched nothing and exited 0, whatever the command
12126                    // table held. It went unnoticed while the table was always
12127                    // empty.
12128                    if let Ok(t) = cmdnamtab_lock().read() {
12129                        let mut entries: Vec<_> = t.iter().collect();
12130                        entries.sort_by(|a, b| hnamcmp(a.0, b.0)); // c:4286 sorted=1
12131                        for (n, cn) in entries {
12132                            if pattry(&prog, n) {
12133                                // c:4286
12134                                printcmdnamnode(cn, printflags);
12135                            }
12136                        }
12137                    }
12138                }
12139            } else {
12140                zwarnnam(name, &format!("bad pattern : {}", arg)); // c:4292
12141                returnval = 1; // c:4293
12142            }
12143            continue;
12144        }
12145        // c:4297-4317 — literal name=value or name-only.
12146        let (n, val) = match arg.find('=') {
12147            Some(eq) => (&arg[..eq], Some(&arg[eq + 1..])),
12148            None => (arg.as_str(), None),
12149        };
12150        if let Some(v) = val {
12151            // c:4302
12152            // Define entry.
12153            if dir_mode {
12154                // c:4302
12155                // c:4303-4310 — `itype_end(asg->name, IUSER, 0)` validates;
12156                // dir name must be all-IUSER chars. IUSER (Src/utils.c:
12157                // 4173-4191) = digits + alpha + `_` + `-` + `.` (plus
12158                // non-ASCII). The previous port allowed only alphanumeric
12159                // + `_`, so `hash -d t-t=/foo` was rejected with "invalid
12160                // character", which then broke `~t-t` named-dir expansion.
12161                if !n
12162                    .chars()
12163                    .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.')
12164                {
12165                    // c:4305
12166                    zwarnnam(name, &format!("invalid character in directory name: {}", n)); // c:4306
12167                    returnval = 1; // c:4308
12168                    continue; // c:4309
12169                }
12170                // c:Src/builtin.c:80 — `BUILTIN("hash", BINF_MAGICEQUALS,
12171                // …)`. The MAGICEQUALS flag causes the C parser to
12172                // apply tilde/equals expansion to `name=value` argv
12173                // entries via Src/utils.c::quotestring's MAGICEQUALSUBST
12174                // arm at c:6304. By the time argv reaches bin_hash, the
12175                // value has already been tilde-expanded.
12176                //
12177                // The Rust pipeline doesn't apply MAGICEQUALS at the
12178                // parser/compile level for `hash -d` args, so the
12179                // literal `~` reaches here untouched. Bug #32 in
12180                // docs/BUGS.md: `hash -d zh=~` stored `'~'` instead
12181                // of $HOME. Apply tilde expansion locally by routing
12182                // the value through filesubstr — re-tokenize a
12183                // leading `~` to the Tilde TOKEN (\u{e198}) which
12184                // filesubstr keys on per its strict-token
12185                // contract (subst.rs:1761).
12186                let expanded_v: String = if v.starts_with('~') {
12187                    let rest = &v[1..];
12188                    let tokenized = format!("\u{e198}{}", rest);
12189                    crate::ported::subst::filesubstr(&tokenized, false)
12190                        .unwrap_or_else(|| v.to_string())
12191                } else {
12192                    v.to_string()
12193                };
12194                let nd = nameddir {
12195                    node: hashnode {
12196                        next: None,
12197                        nam: n.to_string(),
12198                        flags: 0,
12199                    },
12200                    dir: expanded_v,
12201                    diff: 0,
12202                };
12203                addnameddirnode(n, nd); // c:4314
12204            } else {
12205                // c:4313-4318 — `Cmdnam cn = zshcalloc(sizeof *cn);
12206                //                 cn->node.flags = HASHED;
12207                //                 cn->u.cmd = ztrdup(asg->value.scalar);
12208                //                 ht->addnode(ht, ztrdup(asg->name), hn);`
12209                // Insert into cmdnamtab so `hash myc` lookup hits it
12210                // (was storing in `__zshrs_hash_*` env var — fakery
12211                // that the user-facing `hash myc` query never read).
12212                let cn = cmdnam {
12213                    node: hashnode {
12214                        next: None,
12215                        nam: n.to_string(),
12216                        flags: HASHED as i32, // c:4316
12217                    },
12218                    name: None,
12219                    cmd: Some(v.to_string()), // c:4316
12220                };
12221                if let Ok(mut tab) = cmdnamtab_lock().write() {
12222                    tab.add(cn); // c:4318 addnode
12223                }
12224            }
12225            if OPT_ISSET(ops, b'v') {
12226                // c:4321
12227                if dir_mode {
12228                    if let Ok(t) = nameddirtab().lock() {
12229                        if let Some(nd) = t.get(n) {
12230                            // c:4322
12231                            printnameddirnode(nd, 0);
12232                        }
12233                    }
12234                } else if let Some(cn) =
12235                    cmdnamtab_lock().read().ok().and_then(|t| t.get(n).cloned())
12236                {
12237                    // c:4301-4302 — `if (OPT_ISSET(ops, 'v')) ht->printnode(hn, 0);`
12238                    // cmdnamtab's printnode is printcmdnamnode (hashtable.c:739).
12239                    crate::ported::hashtable::printcmdnamnode(&cn, 0);
12240                }
12241            }
12242        } else {
12243            // c:4323-4334 — display existing entry / look up.
12244            if dir_mode {
12245                let snapshot = nameddirtab().lock().ok().and_then(|t| t.get(n).cloned());
12246                match snapshot {
12247                    Some(nd) => {
12248                        if OPT_ISSET(ops, b'v') {
12249                            // c:4337
12250                            printnameddirnode(&nd, 0);
12251                        }
12252                    }
12253                    None => {
12254                        zwarnnam(name, &format!("no such directory name: {}", n)); // c:4327
12255                        returnval = 1; // c:4328
12256                    }
12257                }
12258            } else {
12259                // c:4319-4334 — `else if (!(hn = ht->getnode2(ht,
12260                //   asg->name))) { ... if (!hashcmd(asg->name, path))
12261                //   zwarnnam("no such command"); }`. C path: first
12262                // check cmdnamtab for an existing entry; only fall
12263                // back to hashcmd's PATH walk when not present. The
12264                // previous Rust port skipped the cmdnamtab check, so
12265                // a prior `hash myc=/path` insert was invisible to
12266                // the matching `hash myc` query.
12267                let in_cmdnamtab = cmdnamtab_lock()
12268                    .read()
12269                    .map(|t| t.get(n).is_some())
12270                    .unwrap_or(false);
12271                if !in_cmdnamtab {
12272                    // c:4319 hn == NULL → try hashcmd.
12273                    let path: Vec<String> = getsparam("PATH")
12274                        .map(|p| p.split(':').map(String::from).collect())
12275                        .unwrap_or_default();
12276                    if crate::ported::exec::hashcmd(n, &path).is_none() {
12277                        // c:4332
12278                        zwarnnam(name, &format!("no such command: {}", n)); // c:4333
12279                        returnval = 1; // c:4334
12280                    }
12281                }
12282                // c:4317-4321 — `if(OPT_ISSET(ops,'v') && (hn = ht->getnode2(ht,
12283                // asg->name))) ht->printnode(hn, 0); } else if(OPT_ISSET(ops,'v'))
12284                // ht->printnode(hn, 0);` — both the freshly-hashed (hashcmd added
12285                // the node, exec.rs hashcmd c:1036) and the already-present arm print.
12286                if OPT_ISSET(ops, b'v') {
12287                    if let Some(cn) = cmdnamtab_lock().read().ok().and_then(|t| t.get(n).cloned())
12288                    {
12289                        crate::ported::hashtable::printcmdnamnode(&cn, 0);
12290                    }
12291                }
12292            }
12293        }
12294    }
12295    unqueue_signals(); // c:4346
12296    returnval // c:4346
12297}
12298
12299/// Port of `bin_unhash()` from `Src/builtin.c:4346`.
12300/// C decl: `bin_unhash(char *name, char **argv, Options ops, int func)`
12301/// C: `int bin_unhash(char *name, char **argv, Options ops, int func)` —
12302///   remove entries from cmdnamtab/aliastab/sufaliastab/nameddirtab/
12303///   shfunctab. `-a` clears all, `-m` is a glob.
12304/// WARNING: param names don't match C — Rust=(name, argv, func) vs C=(name, argv, ops, func)
12305pub fn bin_unhash(
12306    name: &str,
12307    argv: &[String], // c:4346
12308    ops: &options,
12309    func: i32,
12310) -> i32 {
12311    let mut returnval = 0i32; // c:4351
12312    let mut all = 0i32; // c:4351
12313    let mut match_count = 0i32; // c:4351
12314
12315    // PFA-SMR aspect: when invoked as `unalias`, record the un-alias
12316    // events so the replay can suppress earlier `alias` calls.
12317    #[cfg(feature = "recorder")]
12318    if crate::recorder::is_enabled() && func == crate::ported::builtin::BIN_UNALIAS {
12319        let ctx = crate::recorder::recorder_ctx_global();
12320        for a in argv {
12321            if a.starts_with('-') && a != "-" {
12322                continue;
12323            }
12324            crate::recorder::emit_unalias(a, ctx.clone());
12325        }
12326    }
12327
12328    // c:4355-4373 — table-pick dispatch.
12329    enum Tab {
12330        CmdNam,
12331        NamedDir,
12332        Shfunc,
12333        Alias,
12334        SufAlias,
12335    }
12336    let tab: Tab;
12337    if func == BIN_UNALIAS {
12338        // c:4356
12339        tab = if OPT_ISSET(ops, b's') {
12340            Tab::SufAlias
12341        } else {
12342            Tab::Alias
12343        }; // c:4357
12344        if OPT_ISSET(ops, b'a') {
12345            // c:4361
12346            if !argv.is_empty() {
12347                // c:4362
12348                zwarnnam(name, "-a: too many arguments"); // c:4363
12349                return 1; // c:4364
12350            }
12351            all = 1; // c:4366
12352        } else if argv.is_empty() {
12353            // c:4367
12354            zwarnnam(name, "not enough arguments"); // c:4368
12355            return 1; // c:4369
12356        }
12357    } else if OPT_ISSET(ops, b'd') {
12358        tab = Tab::NamedDir; // c:4370
12359    } else if OPT_ISSET(ops, b'f') {
12360        tab = Tab::Shfunc; // c:4372
12361    } else if OPT_ISSET(ops, b's') {
12362        tab = Tab::SufAlias; // c:4374
12363    } else if func == BIN_UNHASH && OPT_ISSET(ops, b'a') {
12364        tab = Tab::Alias; // c:4376
12365    } else {
12366        tab = Tab::CmdNam;
12367    } // c:4378
12368
12369    // Helper: clear entire table.
12370    let clear_all = |t: &Tab| match t {
12371        Tab::Alias => {
12372            let _ = aliastab_lock().write().map(|mut g| g.clear());
12373        }
12374        Tab::SufAlias => {
12375            let _ = sufaliastab_lock().write().map(|mut g| g.clear());
12376        }
12377        Tab::NamedDir => {
12378            emptynameddirtable();
12379        }
12380        Tab::Shfunc => {
12381            // c:4388 — empty whole shfunctab (`unhash -af` etc.). C uses
12382            // `emptyhashtable(shfunctab)` GSU; Rust port iterates names
12383            // and removes each (no `clear` method on shfunc_table).
12384            if let Ok(mut t) = shfunctab_lock().write() {
12385                let names: Vec<String> = t.iter().map(|(k, _)| k.clone()).collect();
12386                for nm in names {
12387                    let _ = t.remove(&nm);
12388                }
12389            }
12390        }
12391        Tab::CmdNam => {
12392            emptycmdnamtable();
12393        } // c:4389
12394    };
12395    let remove_one = |t: &Tab, nm: &str| -> bool {
12396        match t {
12397            Tab::Alias => aliastab_lock()
12398                .write()
12399                .map(|mut g| g.remove(nm).is_some())
12400                .unwrap_or(false),
12401            Tab::SufAlias => sufaliastab_lock()
12402                .write()
12403                .map(|mut g| g.remove(nm).is_some())
12404                .unwrap_or(false),
12405            Tab::NamedDir => crate::ported::hashnameddir::removenameddirnode(nm).is_some(),
12406            Tab::Shfunc => {
12407                // Lineage tap: the removal is the last op on the
12408                // function's chain, the way `unset` is on a parameter's.
12409                if crate::provenance::active() {
12410                    crate::provenance::on_func_unset(nm);
12411                }
12412                // c:4405 — `if ((hn = ht->removenode(ht, *argv)))`. For
12413                // shfunctab `removenode` IS `removeshfuncnode`
12414                // (c:Src/hashtable.c:825 `shfunctab->removenode =
12415                // removeshfuncnode;`), which routes a `TRAP<SIG>` name
12416                // through `removetrap` so the LOCAL_TRAPS save/restore and
12417                // the sigtrapped[] teardown happen. Calling the raw table
12418                // remove here skipped both: `unfunction TRAPZERR` inside a
12419                // `setopt localtraps` function left the trap armed with a
12420                // dangling function and never restored the outer handler
12421                // (C03traps:13,14).
12422                let from_tab = crate::ported::hashtable::removeshfuncnode(nm).is_some();
12423                // Also remove from the executor's compiled-function /
12424                // source maps. Without this, `unset -f f` cleared
12425                // shfunctab but dispatch_function_call still found the
12426                // compiled chunk and ran the old body. Routed via the
12427                // exec accessors unregister_function fn-ptr installed by
12428                // fusevm_bridge at startup (no ShellExecutor reach-in
12429                // from src/ported/).
12430                let from_exec = crate::ported::exec::unregister_function(nm);
12431                from_tab || from_exec
12432            }
12433            // c:4405 — `if ((hn = ht->removenode(ht, *argv)))`.
12434            // Removal returns truthy only when the entry actually
12435            // existed. Previous Rust port hardcoded `true` after a
12436            // void-return `freecmdnamnode` call, so `unhash badname`
12437            // silently succeeded instead of emitting the canonical
12438            // "no such hash table element" error.
12439            Tab::CmdNam => cmdnamtab_lock()
12440                .write()
12441                .map(|mut g| g.remove(nm).is_some())
12442                .unwrap_or(false),
12443        }
12444    };
12445
12446    if all != 0 {
12447        // c:4382
12448        queue_signals(); // c:4383
12449        clear_all(&tab); // c:4384-4389
12450        unqueue_signals(); // c:4390
12451        return 0; // c:4391
12452    }
12453
12454    // c:4395-4421 — `-m` glob branch.
12455    if OPT_ISSET(ops, b'm') {
12456        // c:4395
12457        for arg in argv {
12458            // c:4396
12459            queue_signals(); // c:4397
12460            let pprog = patcompile(
12461                &{
12462                    let mut __pat_tok = crate::pattern_data_escape::escape_data_backslashes(&arg); // a backslash tokenize leaves raw is DATA (c:Src/glob.c:3651)
12463                    crate::ported::glob::tokenize(&mut __pat_tok);
12464                    __pat_tok
12465                }, // c:4400
12466                PAT_HEAPDUP,
12467                None,
12468            );
12469            if let Some(prog) = pprog {
12470                // Collect names then remove (avoid iterator/mutation conflict).
12471                // c:4408 — `scanmatchtable(ht, pprog, ...)` walks every
12472                // entry in the selected table. Previous Rust port left
12473                // Tab::CmdNam returning an empty Vec, so `unhash -m PAT`
12474                // (default cmd-hash table) silently matched zero entries.
12475                let names: Vec<String> = match &tab {
12476                    Tab::Alias => aliastab_lock()
12477                        .read()
12478                        .map(|t| t.iter().map(|(n, _)| n.clone()).collect())
12479                        .unwrap_or_default(),
12480                    Tab::SufAlias => sufaliastab_lock()
12481                        .read()
12482                        .map(|t| t.iter().map(|(n, _)| n.clone()).collect())
12483                        .unwrap_or_default(),
12484                    Tab::NamedDir => nameddirtab()
12485                        .lock()
12486                        .map(|t| t.keys().cloned().collect())
12487                        .unwrap_or_default(),
12488                    Tab::Shfunc => shfunctab_lock()
12489                        .read()
12490                        .map(|t| t.iter().map(|(k, _)| k.clone()).collect())
12491                        .unwrap_or_default(),
12492                    // c:4408 — cmdnamtab walk via `cmdnamtab_lock().iter()`.
12493                    Tab::CmdNam => cmdnamtab_lock()
12494                        .read()
12495                        .map(|t| t.iter().map(|(n, _)| n.clone()).collect())
12496                        .unwrap_or_default(),
12497                };
12498                for nm in &names {
12499                    if pattry(&prog, nm) {
12500                        // c:4408
12501                        if remove_one(&tab, nm) {
12502                            match_count += 1; // c:4410
12503                        }
12504                    }
12505                }
12506            } else {
12507                zwarnnam(name, &format!("bad pattern : {}", arg)); // c:4416
12508                returnval = 1; // c:4417
12509            }
12510            unqueue_signals(); // c:4419
12511        }
12512        if match_count == 0 {
12513            // c:4424
12514            returnval = 1; // c:4425
12515        }
12516        return returnval; // c:4426
12517    }
12518
12519    // c:4429-4439 — literal-name removals.
12520    queue_signals(); // c:4430
12521    for arg in argv {
12522        // c:4431
12523        if remove_one(&tab, arg) { // c:4432
12524             // freed
12525        } else if func == BIN_UNSET && isset(POSIXBUILTINS) {
12526            // c:4434 — POSIX: unset of nonexistent isn't an error.
12527            returnval = 0; // c:4435
12528        } else {
12529            zwarnnam(name, &format!("no such hash table element: {}", arg)); // c:4437
12530            returnval = 1; // c:4450
12531        }
12532    }
12533    unqueue_signals(); // c:4450
12534    returnval // c:4450
12535}
12536
12537/// Port of `bin_alias()` from `Src/builtin.c:4450`.
12538/// C decl: `bin_alias(char *name, char **argv, Options ops, UNUSED(int func))`
12539/// C: `int bin_alias(char *name, char **argv, Options ops, ...)` — list,
12540///   define, glob-list, or display aliases. `-r`/`-g`/`-s` filter type;
12541///   `-L` prints definitions; `-m` treats args as patterns.
12542/// WARNING: param names don't match C — Rust=(name, argv, _func) vs C=(name, argv, ops, func)
12543pub fn bin_alias(
12544    name: &str,
12545    argv: &[String], // c:4450
12546    ops: &options,
12547    _func: i32,
12548) -> i32 {
12549    let mut returnval = 0i32; // c:4455
12550    let mut flags1 = 0u32; // c:4456
12551    let mut flags2 = DISABLED as u32; // c:4456
12552    let mut printflags = 0i32; // c:4457
12553    let mut use_suffix = false; // tracks ht switch
12554
12555    // ZSHRS-ONLY. `alias -t` lists the Korn shells' TRACKED aliases,
12556    // which are the command hash table — mksh defines its `hash` alias as
12557    // `\builtin alias -t`, so without this `hash` in `--mksh` failed with
12558    // "bad option: -t". zsh has no `-t`, so every other personality keeps
12559    // rejecting it exactly as before.
12560    if OPT_ISSET(ops, b't') {
12561        use crate::extensions::emulation_startup::{personality, Personality};
12562        if !matches!(
12563            personality(),
12564            Personality::Ksh93 | Personality::Mksh | Personality::Pdksh
12565        ) {
12566            zwarnnam(name, "bad option: -t");
12567            return 1;
12568        }
12569        // With NAME operands `alias -t` tracks those commands, which is
12570        // exactly `hash NAME` — mksh's `hash` alias relies on both halves
12571        // (`hash ls` becomes `alias -t ls`, bare `hash` becomes
12572        // `alias -t`). Delegate rather than re-implement the PATH search.
12573        if !argv.is_empty() {
12574            let hash_ops = crate::ported::zsh_h::options {
12575                ind: [0u8; crate::ported::zsh_h::MAX_OPS],
12576                args: Vec::new(),
12577                argscount: 0,
12578                argsalloc: 0,
12579            };
12580            return bin_hash(name, argv, &hash_ops, 0);
12581        }
12582        queue_signals();
12583        if let Ok(t) = cmdnamtab_lock().read() {
12584            let mut entries: Vec<_> = t.iter().collect();
12585            entries.sort_by(|a, b| hnamcmp(a.0, b.0));
12586            for (_n, cn) in entries {
12587                printcmdnamnode(cn, 0);
12588            }
12589        }
12590        unqueue_signals();
12591        return 0;
12592    }
12593
12594    // c:4461-4485 — type-flag parsing.
12595    let type_opts = (OPT_ISSET(ops, b'r') as i32)                            // c:4461
12596                  + (OPT_ISSET(ops, b'g') as i32)
12597                  + (OPT_ISSET(ops, b's') as i32);
12598    if type_opts != 0 {
12599        // c:4464
12600        if type_opts > 1 {
12601            // c:4465
12602            zwarnnam(name, "invalid combination of options"); // c:4466
12603            return 1; // c:4467
12604        }
12605        if OPT_ISSET(ops, b'g') {
12606            // c:4469
12607            flags1 |= ALIAS_GLOBAL as u32; // c:4470
12608        } else {
12609            flags2 |= ALIAS_GLOBAL as u32; // c:4472
12610        }
12611        if OPT_ISSET(ops, b's') {
12612            // c:4473
12613            flags1 |= ALIAS_SUFFIX as u32; // c:4480
12614            use_suffix = true; // c:4481
12615        } else {
12616            flags2 |= ALIAS_SUFFIX as u32; // c:4483
12617        }
12618    }
12619
12620    // c:4486-4490 — printflags from -L / + suffix.
12621    if OPT_ISSET(ops, b'L') {
12622        // c:4486
12623        printflags |= PRINT_LIST; // c:4487
12624    } else if OPT_PLUS(ops, b'g')
12625        || OPT_PLUS(ops, b'r')
12626        || OPT_PLUS(ops, b's')
12627        || OPT_PLUS(ops, b'm')
12628        || OPT_ISSET(ops, b'+')
12629    // c:4488
12630    {
12631        printflags |= PRINT_NAMEONLY; // c:4490
12632    }
12633
12634    // C bin_alias dispatches printing via `ht->printnode` (set to
12635    // `printaliasnode` at hashtable.c:1208) — `scanhashtable`,
12636    // `scanmatchtable`, and the single-name branch all call
12637    // `ht->printnode(&a->node, printflags)`. The Rust port routes
12638    // through the canonical `printaliasnode` (hashtable.rs:1477) for
12639    // the same dispatch. No local closure.
12640
12641    // c:4495-4500 — no args: list all (filtered by flags).
12642    if argv.is_empty() {
12643        // c:4495
12644        queue_signals(); // c:4496
12645        let lock = if use_suffix {
12646            sufaliastab_lock()
12647        } else {
12648            aliastab_lock()
12649        };
12650        if let Ok(t) = lock.read() {
12651            // c:Src/builtin.c:4496 — `scanhashtable(aliastab, 1, ...)`
12652            // sets the SORT flag (1). The C source walks the table
12653            // sorted by `hnamcmp` (byte-wise ASCII compare); the
12654            // Rust port iterated in arbitrary hash order, so
12655            // `alias` output sequence diverged from zsh's
12656            // (uppercase-before-lowercase) ASCII ordering.
12657            let mut entries: Vec<_> = t.iter().collect();
12658            entries.sort_by(|(a, _), (b, _)| a.cmp(b));
12659            for (_n, a) in entries {
12660                // c:4497
12661                if (a.node.flags & flags1 as i32) == flags1 as i32
12662                    && (a.node.flags & flags2 as i32) == 0
12663                {
12664                    printaliasnode(a, printflags);
12665                }
12666            }
12667        }
12668        unqueue_signals(); // c:4498
12669        return 0; // c:4499
12670    }
12671
12672    // c:4503-4519 — `-m` glob branch.
12673    if OPT_ISSET(ops, b'm') {
12674        // c:4503
12675        for pat in argv {
12676            // c:4504
12677            queue_signals(); // c:4505
12678                             // c:4506 — `tokenize + patcompile`.
12679            let pprog = patcompile(
12680                &{
12681                    let mut __pat_tok = crate::pattern_data_escape::escape_data_backslashes(&pat); // a backslash tokenize leaves raw is DATA (c:Src/glob.c:3651)
12682                    crate::ported::glob::tokenize(&mut __pat_tok);
12683                    __pat_tok
12684                }, // c:4507
12685                PAT_HEAPDUP,
12686                None,
12687            );
12688            if let Some(prog) = pprog {
12689                let lock = if use_suffix {
12690                    sufaliastab_lock()
12691                } else {
12692                    aliastab_lock()
12693                };
12694                if let Ok(t) = lock.read() {
12695                    for (_n, a) in t.iter() {
12696                        // c:4509
12697                        if (a.node.flags & flags1 as i32) == flags1 as i32
12698                            && (a.node.flags & flags2 as i32) == 0
12699                            && pattry(&prog, &a.node.nam)
12700                        {
12701                            printaliasnode(a, printflags);
12702                        }
12703                    }
12704                }
12705            } else {
12706                zwarnnam(name, &format!("bad pattern : {}", pat)); // c:4514
12707                returnval = 1; // c:4515
12708            }
12709            unqueue_signals(); // c:4517
12710        }
12711        return returnval; // c:4518
12712    }
12713
12714    // c:4521-4540 — literal args: define `name=value` or display a single name.
12715    queue_signals(); // c:4522
12716                     // PFA-SMR: capture per-definition for replay. Dispatch the
12717                     // right subkind (galias/salias/alias) based on the parsed
12718                     // flag bits set above. Same per-name loop the C code walks
12719                     // — one record per `name=value` argv slot. Without this,
12720                     // the recorder harness saw zero `alias` captures.
12721    #[cfg(feature = "recorder")]
12722    let recorder_active = crate::recorder::is_enabled();
12723    #[cfg(feature = "recorder")]
12724    let recorder_ctx = if recorder_active {
12725        Some(crate::recorder::recorder_ctx_global())
12726    } else {
12727        None
12728    };
12729    let mut idx = 0;
12730    while idx < argv.len() {
12731        // c:4523
12732        let arg = &argv[idx];
12733        idx += 1;
12734        // c:Src/builtin.c:4523 `while ((asg = getasg(&argv, NULL)))`
12735        // — empty-name assignment makes getasg call `zerr("bad
12736        // assignment")` (c:1927, sets errflag) and return NULL. The
12737        // C while-loop exits without setting returnval, so bin_alias
12738        // returns 0. /bin/zsh's outer execution checks errflag and
12739        // aborts subsequent commands but keeps lastval=0.
12740        //
12741        // Rust port divergence: the canonical `zerr` here would set
12742        // ERRFLAG_ERROR, which zshrs's execcmd_exec promotes to
12743        // lastval=1 (different shape from C's "abort but keep
12744        // lastval"). Route through `zwarnnam` so the diagnostic
12745        // emits without flipping errflag; returnval stays 0 and the
12746        // shell rc matches /bin/zsh's observed behavior.
12747        if arg.starts_with('=') {
12748            zwarnnam(name, "bad assignment");
12749            break;
12750        }
12751        if let Some(eq) = arg.find('=') {
12752            // c:4524 (asg->value.scalar)
12753            if !OPT_ISSET(ops, b'L') {
12754                // c:4524
12755                let n = &arg[..eq];
12756                let v = &arg[eq + 1..];
12757                let lock = if use_suffix {
12758                    sufaliastab_lock()
12759                } else {
12760                    aliastab_lock()
12761                };
12762                if let Ok(mut t) = lock.write() {
12763                    let a = createaliasnode(n, v, flags1); // c:4527
12764                    t.add(a);
12765                }
12766                #[cfg(feature = "recorder")]
12767                if let Some(ref ctx) = recorder_ctx {
12768                    if (flags1 & ALIAS_GLOBAL as u32) != 0 {
12769                        crate::recorder::emit_galias(n, Some(v), ctx.clone());
12770                    } else if (flags1 & ALIAS_SUFFIX as u32) != 0 {
12771                        crate::recorder::emit_salias(n, Some(v), ctx.clone());
12772                    } else {
12773                        crate::recorder::emit_alias(n, Some(v), ctx.clone());
12774                    }
12775                }
12776                continue;
12777            }
12778        }
12779        let n = if let Some(eq) = arg.find('=') {
12780            &arg[..eq]
12781        } else {
12782            arg.as_str()
12783        };
12784        let lock = if use_suffix {
12785            sufaliastab_lock()
12786        } else {
12787            aliastab_lock()
12788        };
12789        // c:4530 — `(a = (Alias) ht->getnode(ht, asg->name))`. C
12790        // `getnode` is `gethashnode` (Src/hashtable.c:231) which
12791        // returns NULL for DISABLED entries; skipping disabled at
12792        // lookup time is what makes `alias FOO` print nothing after
12793        // `disable -a FOO` (bug #227). Must use `.get()` not
12794        // `.get_including_disabled()` here.
12795        let found = lock.read().ok().and_then(|t| {
12796            t.get(n)
12797                .map(|a| (a.node.nam.clone(), a.node.flags as u32, a.text.clone()))
12798        });
12799        match found {
12800            Some((nm, fl, txt)) => {
12801                // c:4530
12802                // c:4532-4537 — type-filter check.
12803                let show = type_opts == 0
12804                    || use_suffix
12805                    || (OPT_ISSET(ops, b'r') && (fl & (ALIAS_GLOBAL | ALIAS_SUFFIX) as u32) == 0)
12806                    || (OPT_ISSET(ops, b'g') && (fl & ALIAS_GLOBAL as u32) != 0);
12807                if show {
12808                    let a = createaliasnode(&nm, &txt, fl);
12809                    printaliasnode(&a, printflags);
12810                }
12811            }
12812            None => {
12813                // c:4538
12814                returnval = 1; // c:4539
12815            }
12816        }
12817    }
12818    unqueue_signals(); // c:4541
12819    returnval // c:4542
12820}
12821
12822/// Port of `bin_true()` from `Src/builtin.c:4550`.
12823/// C decl: `bin_true(UNUSED(char *name), UNUSED(char **argv), UNUSED(Options ops), UNUSED(int func))`
12824/// C: `int bin_true(UNUSED(char *name), UNUSED(char **argv),
12825///                  UNUSED(Options ops), UNUSED(int func))` → `return 0;`
12826/// WARNING: param names don't match C — Rust=(_name, _argv, _func) vs C=(name, argv, ops, func)
12827pub fn bin_true(
12828    _name: &str,
12829    _argv: &[String], // c:4550
12830    _ops: &options,
12831    _func: i32,
12832) -> i32 {
12833    0 // c:4559
12834}
12835
12836/// Port of `bin_false()` from `Src/builtin.c:4559`.
12837/// C decl: `bin_false(UNUSED(char *name), UNUSED(char **argv), UNUSED(Options ops), UNUSED(int func))`
12838/// C: `int bin_false(UNUSED(char *name), UNUSED(char **argv),
12839///                   UNUSED(Options ops), UNUSED(int func))` → `return 1;`
12840/// WARNING: param names don't match C — Rust=(_name, _argv, _func) vs C=(name, argv, ops, func)
12841pub fn bin_false(
12842    _name: &str,
12843    _argv: &[String], // c:4559
12844    _ops: &options,
12845    _func: i32,
12846) -> i32 {
12847    1 // c:4562
12848}
12849
12850/// Port of `bin_print()` from `Src/builtin.c:4587`.
12851/// C decl: `bin_print(char *name, char **args, Options ops, int func)`
12852/// C: `int bin_print(char *name, char **args, Options ops, int func)`.
12853///
12854/// The C body is ~1000 lines: `print` / `echo` / `printf` / `pushln`
12855/// dispatcher with -n/-N/-c/-r/-R/-l/-D/-i/-f/-v/-s/-S/-z/-e/-E etc.
12856/// The structural port handles the script-friendly subset that the
12857/// daily-driver hits: print/echo plain emission with -n, -l (one per
12858/// line), -r raw, -E newline-only, -- end-of-options. The full -f
12859/// printf format-spec engine and ZLE/history wireups defer to the
12860/// expand_printf_escapes helpers.
12861/// WARNING: param names don't match C — Rust=(name, args, func) vs C=(name, args, ops, func)
12862pub fn bin_print(
12863    name: &str,
12864    args: &[String], // c:4587
12865    ops: &options,
12866    func: i32,
12867) -> i32 {
12868    // c:4659-4684 — "Error check option combinations and option arguments".
12869    // The FIRST thing bin_print does, before any argument work:
12870    //     if (OPT_ISSET(ops,'z') + OPT_ISSET(ops,'s') + OPT_ISSET(ops,'S') +
12871    //         OPT_ISSET(ops,'v') > 1) {
12872    //         zwarnnam(name, "only one of -s, -S, -v, or -z allowed"); return 1; }
12873    //     if ((OPT_ISSET(ops,'z') | OPT_ISSET(ops,'s') | OPT_ISSET(ops,'S')) +
12874    //         (OPT_ISSET(ops,'c') | OPT_ISSET(ops,'C')) > 1) {
12875    //         zwarnnam(name, "-c or -C not allowed with -s, -S, or -z"); return 1; }
12876    //     if ((OPT_ISSET(ops,'z') | OPT_ISSET(ops,'v') | OPT_ISSET(ops,'s') |
12877    //          OPT_ISSET(ops,'S')) + (OPT_ISSET(ops,'p') | OPT_ISSET(ops,'u')) > 1) {
12878    //         zwarnnam(name, "-p or -u not allowed with -s, -S, -v, or -z"); return 1; }
12879    //
12880    // None of the three were ported, so every conflicting combination was
12881    // silently accepted: `print -s -v v a b`, `print -C 2 -s a b` and
12882    // `print -u1 -v v a b` all returned 0 having done something other than what
12883    // zsh does. Note the mixed operators are deliberate — `+` counts DISTINCT
12884    // options in the first test (so `-s -S` is two), while `|` collapses each
12885    // GROUP in the other two before counting, so the test is "one from each
12886    // group", not "two options total".
12887    //
12888    // C's fourth check ("-f not allowed with -c, -C, or -S", c:4679-4683) is
12889    // COMMENTED OUT and must NOT be reinstated: `print -f %s -c a b` is legal
12890    // and prints, as verified against the oracle. The string exists in the
12891    // source but the code is dead — the reason to read the C rather than grep
12892    // its message table.
12893    //
12894    // These precede the `-C` argument validation below, matching C, so
12895    // `print -C 0 -s` reports the conflict rather than the bad column count.
12896    {
12897        let n_sv = i32::from(OPT_ISSET(ops, b'z'))
12898            + i32::from(OPT_ISSET(ops, b's'))
12899            + i32::from(OPT_ISSET(ops, b'S'))
12900            + i32::from(OPT_ISSET(ops, b'v'));
12901        if n_sv > 1 {
12902            zwarnnam(name, "only one of -s, -S, -v, or -z allowed"); // c:4664
12903            return 1; // c:4665
12904        }
12905        let g_zsS = OPT_ISSET(ops, b'z') || OPT_ISSET(ops, b's') || OPT_ISSET(ops, b'S');
12906        let g_cC = OPT_ISSET(ops, b'c') || OPT_ISSET(ops, b'C');
12907        if i32::from(g_zsS) + i32::from(g_cC) > 1 {
12908            zwarnnam(name, "-c or -C not allowed with -s, -S, or -z"); // c:4670
12909            return 1; // c:4671
12910        }
12911        let g_zvsS = OPT_ISSET(ops, b'z')
12912            || OPT_ISSET(ops, b'v')
12913            || OPT_ISSET(ops, b's')
12914            || OPT_ISSET(ops, b'S');
12915        let g_pu = OPT_ISSET(ops, b'p') || OPT_ISSET(ops, b'u');
12916        if i32::from(g_zvsS) + i32::from(g_pu) > 1 {
12917            zwarnnam(name, "-p or -u not allowed with -s, -S, -v, or -z"); // c:4677
12918            return 1; // c:4678
12919        }
12920    }
12921    let nonewline = OPT_ISSET(ops, b'n'); // c:4595
12922    let raw = OPT_ISSET(ops, b'r') || OPT_ISSET(ops, b'R'); // c:4596
12923                                                            // c:5126 — `-l` puts one arg per line; `-c`/`-C` columns are
12924                                                            // handled by the `columnate` block below.
12925    let one_per_line = OPT_ISSET(ops, b'l');
12926    let nul_sep = OPT_ISSET(ops, b'N'); // c:5114/5127/5132 — NUL separator
12927    let dirify_d = OPT_ISSET(ops, b'D');
12928    let _printf_mode = func == BIN_PRINTF || OPT_HASARG(ops, b'f'); // c:4604
12929    let echo_mode = func == BIN_ECHO;
12930    let _ = (name, raw);
12931
12932    // c:Src/builtin.c:5095-5106 — `-x N` (and `-X N`) require the
12933    // argument to be a positive integer (zstrtol parse with no
12934    // trailing garbage AND value > 0). zsh emits
12935    // `positive integer expected after -x: <arg>` and returns 1
12936    // when either gate fails. The previous Rust port silently
12937    // accepted any -x argument and proceeded to print the rest of
12938    // argv. Bug #525.
12939    if OPT_HASARG(ops, b'x') || OPT_HASARG(ops, b'X') {
12940        let which = if OPT_HASARG(ops, b'X') { b'X' } else { b'x' };
12941        let xarg = OPT_ARG(ops, which).unwrap_or("");
12942        // c:Src/builtin.c:5101 — `expand = zstrtol(xarg, &eptr, 10);
12943        //   if (*eptr || expand <= 0) zwarnnam(...positive integer
12944        //   expected...); return 1;`. zstrtol parses optional
12945        //   leading sign + digits and stops at the first non-digit.
12946        //   Reject when eptr isn't end-of-string (trailing garbage)
12947        //   OR the signed result is <= 0 (zero / negative).
12948        let (expand, eptr) = crate::ported::utils::zstrtol(xarg, 10); // c:5085
12949        let valid = eptr.is_empty() && expand > 0; // c:5086
12950        if !valid {
12951            zwarnnam(
12952                name,
12953                &format!(
12954                    "positive integer expected after -{}: {}",
12955                    'x', xarg // c:5087 — C always names 'x', even for -X
12956                ),
12957            );
12958            return 1;
12959        }
12960    }
12961
12962    // c:4633-4685 — destination dispatch. -u FD writes to fd, -s pushes
12963    // to history, -z to ZLE buffer, -v VAR assigns to scalar.
12964    let dest_var: Option<String> = if OPT_HASARG(ops, b'v') {
12965        OPT_ARG(ops, b'v').map(String::from)
12966    } else {
12967        None
12968    };
12969    // c:Src/builtin.c:4827-4828 — `-p` writes to the coprocess fd
12970    // (the canonical `coprocout` global; Src/exec.c:430). When a
12971    // coproc is live, write to that fd; otherwise emit the same
12972    // diagnostic + rc=1 zsh does (Src/builtin.c:5050
12973    // `zwarnnam(name, "-p: no coprocess")`). Bug #388.
12974    let print_dash_p_fd: Option<fs::File> = if OPT_ISSET(ops, b'p') {
12975        let coprocout =
12976            crate::ported::modules::clone::coprocout.load(std::sync::atomic::Ordering::Relaxed);
12977        if coprocout < 0 {
12978            zwarnnam(name, "-p: no coprocess");
12979            return 1;
12980        }
12981        // c:4843 — dup for an owned writer that close-on-drop
12982        // doesn't close the user's original fd.
12983        let dup_fd = unsafe { libc::dup(coprocout) };
12984        if dup_fd < 0 {
12985            zwarnnam(name, "-p: no coprocess");
12986            return 1;
12987        }
12988        use std::os::unix::io::FromRawFd;
12989        Some(unsafe { fs::File::from_raw_fd(dup_fd) })
12990    } else {
12991        None
12992    };
12993    // c:4815-4851 — `-u FD` (and `-p` coprocess) dispatch. Parses FD,
12994    // dup's it for an owned descriptor, opens as a File for writes.
12995    // The previous Rust port silently dropped `-u`, so `print -u 2
12996    // hello` went to stdout instead of stderr.
12997    let dest_fd: Option<fs::File> = if OPT_HASARG(ops, b'u') {
12998        // c:4826
12999        let argptr = OPT_ARG(ops, b'u').unwrap_or("");
13000        // c:4827-4835 — undocumented `-up` aliases to coprocout.
13001        let parsed: Result<i32, ()> = if argptr == "p" {
13002            let cout =
13003                crate::ported::modules::clone::coprocout.load(std::sync::atomic::Ordering::Relaxed);
13004            if cout < 0 {
13005                zwarnnam(name, "-p: no coprocess"); // c:4830
13006                return 1; // c:4831
13007            }
13008            Ok(cout) // c:4828
13009        } else {
13010            argptr.parse::<i32>().map_err(|_| ())
13011        };
13012        match parsed {
13013            // c:4835 zstrtol
13014            Ok(fdarg) => {
13015                // DELIBERATE DEVIATION from c:4843-4851, which has no
13016                // fdtable check here: it just `dup()`s and `fdopen()`s,
13017                // and reports "bad file number" only because the `dup`
13018                // of a descriptor zsh does not have fails with EBADF.
13019                //
13020                // That is incidental safety. It holds in C because the
13021                // descriptors zsh keeps for itself above 9 are read-only
13022                // (its `/dev/null`, its sourced script), so `-u` on one
13023                // fails at the `fdopen(fd, "w")` a line later instead —
13024                // "bad mode on fd N". zshrs keeps something C never
13025                // keeps: a long-lived READ-WRITE SQLite handle to the
13026                // user's history database, plus an append handle to the
13027                // log. `print -u 11` therefore returned 0 and wrote into
13028                // the history database — measured, it overwrote the
13029                // SQLite header of a 665 MB file, and `pragma
13030                // quick_check` then reported it was not a database.
13031                //
13032                // So the fdtable is consulted here. This can only make
13033                // zshrs stricter, never looser, and it moves toward zsh
13034                // rather than away from it: the descriptors it refuses
13035                // are ones a real zsh does not have open at all, where
13036                // zsh answers `bad file number: N` with status 1 — which
13037                // is exactly what is produced below. A descriptor the
13038                // SCRIPT owns is `FDT_EXTERNAL` (c:Src/exec.c:2409) or
13039                // one of 0/1/2 (c:Src/init.c:1900) and is unaffected, and
13040                // anything past `max_zsh_fd` is left alone per
13041                // c:Src/exec.c:3886-3891.
13042                let shell_owned = argptr != "p" && fdarg > 9 && {
13043                    let max_fd = crate::ported::utils::MAX_ZSH_FD
13044                        .load(std::sync::atomic::Ordering::Relaxed);
13045                    fdarg <= max_fd && {
13046                        let kind = crate::ported::utils::fdtable_get(fdarg)
13047                            & crate::ported::zsh_h::FDT_TYPE_MASK;
13048                        kind != crate::ported::zsh_h::FDT_UNUSED
13049                            && kind != crate::ported::zsh_h::FDT_EXTERNAL
13050                    }
13051                };
13052                if shell_owned {
13053                    zwarnnam(name, &format!("bad file number: {}", fdarg)); // c:4844
13054                    return 1; // c:4845
13055                }
13056                // c:4843 — `dup(fdarg)` for an owned writer that
13057                // close-on-drop doesn't close the user's original fd.
13058                let dup_fd = unsafe { libc::dup(fdarg) };
13059                if dup_fd < 0 {
13060                    zwarnnam(name, &format!("bad file number: {}", fdarg)); // c:4844
13061                    return 1; // c:4845
13062                }
13063                use std::os::unix::io::FromRawFd;
13064                Some(unsafe { fs::File::from_raw_fd(dup_fd) }) // c:4847
13065            }
13066            Err(_) => {
13067                zwarnnam(name, &format!("number expected after -u: {}", argptr)); // c:4837
13068                return 1; // c:4838
13069            }
13070        }
13071    } else {
13072        None
13073    };
13074
13075    // c:4604-4612 — printf format-string handling.
13076    if _printf_mode {
13077        // c:Src/builtin.c:4701-4706 — `if (func == BIN_PRINTF) { if
13078        // (!strcmp(*args, "--") && !*++args) { ...not enough args...
13079        // } fmt = *args++; }`. Consume a leading `--` end-of-options
13080        // marker (printf is BINF_SKIPDASH but NOT BINF_DASHDASHVALID,
13081        // so the generic flag-parser doesn't strip `--` — bin_print
13082        // does the strip itself for BIN_PRINTF). Without this,
13083        // `printf -- "%s\n" hi` used `--` as the format string. Bug
13084        // #284.
13085        let args_owned: Vec<String>;
13086        let args: &[String] = if func == BIN_PRINTF
13087            && !OPT_HASARG(ops, b'f')
13088            && !args.is_empty()
13089            && args[0] == "--"
13090        {
13091            // Slice past the `--` for the rest of this arm only.
13092            if args.len() == 1 {
13093                // c:4703 `not enough arguments`.
13094                zwarnnam(name, "not enough arguments");
13095                return 1;
13096            }
13097            args_owned = args[1..].to_vec();
13098            &args_owned
13099        } else {
13100            args
13101        };
13102        let fmt = if let Some(f) = OPT_ARG(ops, b'f') {
13103            f.to_string()
13104        } else if !args.is_empty() {
13105            args[0].clone()
13106        } else {
13107            return 0;
13108        };
13109        let rest: &[String] = if OPT_HASARG(ops, b'f') {
13110            args
13111        } else {
13112            &args[1..]
13113        };
13114        // c:4712-4741 — the -m filter runs BEFORE format rendering:
13115        // first remaining arg is the pattern, later args are kept iff
13116        // pattry matches, and `if (fmt && !*args) return 0;` short-
13117        // circuits an empty post-filter list. The fmt fast-branch
13118        // here previously skipped the filter entirely, so
13119        // `print -m -f '%s|' 'a*' apple banana` rendered every arg.
13120        let m_filtered: Vec<String>;
13121        let rest: &[String] = if OPT_ISSET(ops, b'm') {
13122            if rest.is_empty() {
13123                zwarnnam(name, "no pattern specified"); // c:4723
13124                return 1;
13125            }
13126            let pat = &rest[0];
13127            let mut pat_tok = crate::pattern_data_escape::escape_data_backslashes(pat); // a backslash tokenize leaves raw is DATA (c:Src/glob.c:3651)
13128            crate::ported::glob::tokenize(&mut pat_tok); // c:4727
13129            let Some(pprog) = crate::ported::pattern::patcompile(&pat_tok, PAT_STATIC, None) else {
13130                zwarnnam(name, &format!("bad pattern: {}", pat)); // c:4730
13131                return 1;
13132            };
13133            m_filtered = rest[1..]
13134                .iter()
13135                .filter(|a| crate::ported::pattern::pattry(&pprog, a)) // c:4735-4737
13136                .cloned()
13137                .collect();
13138            if m_filtered.is_empty() {
13139                return 0; // c:4741 `if (fmt && !*args) return 0;`
13140            }
13141            &m_filtered
13142        } else {
13143            rest
13144        };
13145        // c:4744-4764 — the per-argument interpretation loop runs over the
13146        // FORMAT ARGUMENTS (C consumed `fmt` at c:4692/4694, so `first`/
13147        // `args` no longer contain it) after the `-m` filter and before the
13148        // `-o`/`-O` sort. Only the `-P` arm matters here; `-D` has no
13149        // fmt-specific behaviour and the `\`-escape arm is handled by
13150        // printf_format. This `-f` fast-branch returned before bin_print's
13151        // shared `-P` loop below, so `print -P -f '%s' %W` emitted the
13152        // literal `%W` instead of the date.
13153        let p_expanded: Vec<String>;
13154        let rest: &[String] = if OPT_ISSET(ops, b'P') {
13155            // c:4745-4746 — `if (OPT_ISSET(ops, 'P'))
13156            //   txtunknownattrs = TXT_ATTR_ALL;`
13157            crate::ported::prompt::txtunknownattrs.store(
13158                crate::ported::zsh_h::TXT_ATTR_ALL,
13159                std::sync::atomic::Ordering::SeqCst,
13160            );
13161            p_expanded = rest
13162                .iter()
13163                .map(|a| {
13164                    // c:4757-4759 — `promptexpand(metafy(args[n], …), 0, …)`.
13165                    let mut s = crate::ported::prompt::expand_prompt(a);
13166                    // c:Src/prompt.c:236-247 — `if (!ns) { … chuck(Inpar/
13167                    // Outpar/Nularg); }`: the ns=0 caller must not leak the
13168                    // SGR-wrapping markers. See the same strip in the non-fmt
13169                    // `-P` loop below.
13170                    s.retain(|c| c != '\x01' && c != '\x02');
13171                    s
13172                })
13173                .collect();
13174            &p_expanded
13175        } else {
13176            rest
13177        };
13178        // c:4799-4807 — the `-o`/`-O`/`-i` argument sort runs BEFORE
13179        // format rendering. The non-fmt path sorts at the tail of
13180        // bin_print (strmetasort), but this `-f` fast-branch returns
13181        // first, so `print -of '%s\n' foo bar baz` rendered the args
13182        // unsorted. Apply the same strmetasort to the format args here.
13183        let rest_sorted: Vec<String>;
13184        let rest: &[String] = if OPT_ISSET(ops, b'o') || OPT_ISSET(ops, b'O') {
13185            let mut v = rest.to_vec();
13186            let mut sflags: u32 = if OPT_ISSET(ops, b'i') {
13187                SORTIT_IGNORING_CASE as u32 // c:4805
13188            } else {
13189                0
13190            };
13191            if OPT_ISSET(ops, b'O') {
13192                sflags |= SORTIT_BACKWARDS as u32; // c:4806
13193            }
13194            strmetasort(&mut v, sflags, None); // c:4807
13195            rest_sorted = v;
13196            &rest_sorted
13197        } else {
13198            rest
13199        };
13200        // c:5464 — reset the per-run `%d`/`%i` math-error flag; a bad
13201        // math operand during formatting sets it (parse_int_arg) and turns
13202        // the builtin's exit status into 1 without aborting output.
13203        PRINTF_MATH_ERR.with(|c| c.set(false));
13204        // c:builtin.c:5430-5443 — printf returns 1 on unknown
13205        // directive after `zwarnnam(name, "%s: invalid directive",
13206        // start)`. The partial output produced before the bad
13207        // directive is still printed (C emits via fwrite/fprintf
13208        // throughout the format walk).
13209        let (out, bounds, n_targets) = match printf_format(&fmt, rest) {
13210            Ok(s) => s,
13211            Err((partial, msg)) => {
13212                print!("{}", partial); // c: partial output already in fout
13213                use std::io::Write;
13214                let _ = std::io::stdout().flush();
13215                // c:5435 invalid directive / c:5204 argument specifier
13216                // out of range — `msg` carries the full zwarnnam text.
13217                crate::ported::utils::zwarnnam(name, &msg);
13218                // dash's printf reports a bad directive as status 2.
13219                if crate::dash_mode::dash_faithful() {
13220                    return 2;
13221                }
13222                return 1; // c:5443
13223            }
13224        };
13225        // c:Src/builtin.c:5427-5429 — apply the `%n` stores. C runs setiparam
13226        // inside the format loop; printf_format has no shell handle, so it
13227        // collected (name, byte_count) and the assignment happens here. Done
13228        // before the -z/-s output branches because in C the variable is set
13229        // during formatting regardless of where the text is routed.
13230        for (nv, cnt) in &n_targets {
13231            crate::ported::params::setiparam(nv, *cnt as i64); // c:5428
13232        }
13233        // c:4854-4856 — `if (OPT_ISSET(ops, 'v') || (fmt && (OPT_ISSET
13234        //   (ops, 'z') || OPT_ISSET(ops, 's')))) ASSIGN_MSTREAM(...)`.
13235        // For -f combined with -z or -s, capture output then route
13236        // through the same dispatch as the non-fmt path.
13237        if OPT_ISSET(ops, b'z') {
13238            // c:5548-5549 — `zpushnode(bufstack, stringval)`. zpushnode
13239            // inserts at the list HEAD (`zsh.h:591`) and the consumers —
13240            // `zleread`'s restore (zle_main.c:1297), `read -z`
13241            // (builtin.c:6762) and `get-line` (zle_hist.c:901) — all take
13242            // the head back off. Appending made the stack FIFO, so two
13243            // queued lines came back in the wrong order.
13244            crate::ported::zle::zle_main::BUFSTACK
13245                .lock()
13246                .unwrap()
13247                .insert(0, out);
13248            return 0;
13249        }
13250        if OPT_ISSET(ops, b's') {
13251            // c:5569-5574 — push captured output as a history entry.
13252            let event_id = crate::ported::hist::prepnexthistent();
13253            crate::ported::hashtable::addhistnode(&out, event_id as i32);
13254            // c:hist.c:1399-1410 — C's `prepnexthistent` ALLOCATES the entry
13255            // and links it into `hist_ring` itself. zshrs's only evicts and
13256            // bumps `curhist`, so every caller has to do the link — which
13257            // `hist.rs:2282` and the non-fmt `-s` arm below both do, and this
13258            // arm did not. `addhistnode` alone populates `histtab` but leaves
13259            // the ring empty, and `fc` reads the RING, so `print -f '%s\n' -s X`
13260            // stored an entry that `fc -ln -1` could not see. It is the same
13261            // omission the non-fmt arm's own comment describes.
13262            let ent = crate::ported::hist::make_histent(event_id, out.clone());
13263            if let Ok(mut ring) = crate::ported::hist::hist_ring.lock() {
13264                ring.insert(0, ent);
13265                crate::ported::hist::histlinect.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
13266            }
13267            return 0;
13268        }
13269        if let Some(ref v) = dest_var {
13270            // c:builtin.c:5162 — `visarr = v && PM_TYPE(...) == PM_ARRAY`.
13271            // When the -v target is an existing array, the output is
13272            // split into one element per format-reuse cycle (c:5552-5560
13273            // `if (visarr && splits) { ... setaparam(...) }`); otherwise
13274            // the whole output is one scalar (c:5567 `setsparam(...)`).
13275            let visarr = {
13276                use crate::ported::zsh_h::{PM_ARRAY, PM_TYPE};
13277                paramtab()
13278                    .read()
13279                    .ok()
13280                    .and_then(|tab| {
13281                        tab.get(v)
13282                            .map(|pm| PM_TYPE(pm.node.flags as u32) == PM_ARRAY)
13283                    })
13284                    .unwrap_or(false)
13285            };
13286            // c:5169-5171 — `splits` is only allocated on the SECOND and
13287            // later cycles (`argp > args`); a single-cycle format leaves
13288            // `splits` NULL, so `if (visarr && splits)` (c:5552) is false
13289            // and the whole output becomes one scalar. `bounds.len() > 1`
13290            // mirrors `splits != NULL`.
13291            if visarr && bounds.len() > 1 {
13292                // c:5553-5559 — slice `out` at each cycle boundary; one
13293                // array element per format application. `bounds` holds the
13294                // byte offset where each cycle began (the first is 0).
13295                let mut arrayval: Vec<String> = Vec::with_capacity(bounds.len());
13296                for (i, &start) in bounds.iter().enumerate() {
13297                    let end = bounds.get(i + 1).copied().unwrap_or(out.len());
13298                    arrayval.push(out[start..end].to_string());
13299                }
13300                setaparam(v, arrayval);
13301            } else {
13302                setsparam(v, &out);
13303            }
13304        } else {
13305            // c:Src/builtin.c — C printf goes through libc fwrite to
13306            // stdout, unbuffered when fd 1 is the redirect target.
13307            // Rust's `print!` is line-buffered; without a trailing
13308            // newline the bytes stay in the buffer past the redirect
13309            // restore and land on the original stdout. Route through
13310            // stdout().write_all + flush so the bytes hit fd 1 (the
13311            // redirect target) immediately. Bug #397.
13312            use std::io::Write as _;
13313            let stdout = io::stdout();
13314            let mut lk = stdout.lock();
13315            // c:Src/builtin.c:5408 — `print_val(unmetafy(stringval,
13316            // &curlen))`: printf's values are unmetafied before the
13317            // write. Decode Meta-char pairs to raw bytes. Bug #127.
13318            crate::stdout_ferror::fwrite_stdout(&mut lk, &[&crate::ported::utils::unmetafy_str(&out)]);
13319        }
13320        // c:5464 — a `%d`/`%i` math-operand error makes printf exit 1
13321        // (output already emitted).
13322        return if PRINTF_MATH_ERR.with(|c| c.get()) {
13323            1
13324        } else {
13325            0
13326        };
13327    }
13328
13329    // c:4718-4741 — `-m PATTERN args...` glob-filter. First arg is
13330    // the pattern; remaining args are kept iff `pattry(pat, arg)`.
13331    // Previously absent — `print -m 'foo*' foo1 bar foo2` emitted
13332    // all four args instead of just foo1/foo2.
13333    let mut processed_args: Vec<String> = if OPT_ISSET(ops, b'm') {
13334        // c:4718
13335        if args.is_empty() {
13336            // c:4722
13337            zwarnnam(name, "no pattern specified"); // c:4723
13338            return 1; // c:4724
13339        }
13340        // c:4728 — `patcompile(*args, PAT_STATIC, NULL)`.
13341        let pat = &args[0];
13342        let pprog = patcompile(
13343            &{
13344                let mut __pat_tok = crate::pattern_data_escape::escape_data_backslashes(&pat); // a backslash tokenize leaves raw is DATA (c:Src/glob.c:3651)
13345                crate::ported::glob::tokenize(&mut __pat_tok);
13346                __pat_tok
13347            },
13348            PAT_STATIC,
13349            None,
13350        );
13351        match pprog {
13352            None => {
13353                zwarnnam(name, &format!("bad pattern: {}", pat)); // c:4730
13354                return 1; // c:4732
13355            }
13356            Some(prog) => {
13357                // c:4734-4737 — `for (t = p = ++args; *p; p++) if
13358                // (pattry(pprog, *p)) *t++ = *p;`. Keep matching args.
13359                args[1..]
13360                    .iter()
13361                    .filter(|a| pattry(&prog, a))
13362                    .cloned()
13363                    .collect()
13364            }
13365        }
13366    } else {
13367        args.to_vec()
13368    };
13369
13370    // c:4860+ — main print loop.
13371    // c:5126-5127 — separator priority: `-l` ('\n') > `-N` ('\0') > ' '.
13372    let sep = if one_per_line {
13373        "\n"
13374    } else if nul_sep {
13375        "\0"
13376    } else {
13377        " "
13378    };
13379    // c:4731-4781 — per-argument interpretation runs in C order: backslash
13380    //   escapes (getkeystring), then `-P`, then `-D`; the `-o`/`-O` sort
13381    //   (c:4784-4792) comes after all three, so it orders the INTERPRETED
13382    //   strings (`print -lo 'a\tb' 'a\nc'` sorts on the TAB/LF bytes).
13383    // c:Src/builtin.c:4866-4886 — when `-r` is NOT set, each arg goes
13384    // through `getkeystring` to interpret backslash escapes (`\n`,
13385    // `\t`, `\\`, escaped space `\ `, etc.). `echo` follows the same
13386    // path when `BSD_ECHO`/`SH_OPTION_LETTERS`-style isn't in effect;
13387    // BIN_ECHO with `-E` keeps escapes literal. Without this, `print
13388    // -- ${(q)a}` for `a="he llo"` emitted `he\ llo` instead of zsh's
13389    // `he llo` (the (q) flag's backslash gets consumed by print).
13390    // c:builtin.c:4747-4767 — escape interpretation dispatch.
13391    //   - `fmt` (printf format already chosen via -f) or
13392    //     `(!-e && (-R || -r || -E))` → unmetafy only, NO escape
13393    //     interpretation (raw passthrough).
13394    //   - Otherwise pick `escape_how` per c:4754-4760:
13395    //       `-b`                            → GETKEYS_BINDKEY (bindkey
13396    //                                          escapes: `\C-`/`\M-`/`^X`)
13397    //       func != BIN_ECHO && !`-e`       → GETKEYS_PRINT (with EMACS:
13398    //                                          unknown `\<c>` → `<c>`)
13399    //       else (BIN_ECHO or `-e`)         → GETKEYS_ECHO (preserves
13400    //                                          unknown `\<c>` as `\<c>`)
13401    //
13402    // Previous Rust port unconditionally used GETKEYS_PRINT for both
13403    // `echo` and `print` — `echo "${(qq)s}"` for `s="a'b"` stripped
13404    // the `\` from the `(qq)`-emitted `'a'\''b'` because GETKEYS_PRINT
13405    // includes GETKEY_EMACS. zsh keeps the `\` (echo uses GETKEYS_ECHO,
13406    // no EMACS).
13407    let dash_e = OPT_ISSET(ops, b'e');
13408    // c:Src/builtin.c:4754 — BSD_ECHO option flips echo's default:
13409    // escape processing is OFF unless `-e` is explicitly passed.
13410    // Without bsd_echo (the SysV default), escapes process unless
13411    // `-E`/`-R`/`-r` is set.
13412    // !!! DASH-STRICT GATE (no C counterpart) !!! dash's `echo` is the
13413    // XSI/SysV variant: it interprets `\t`, `\n`, `\c`, … by default.
13414    // EMULATE_SH sets BSDECHO (escapes OFF without -e), the opposite of
13415    // dash, so force bsd_echo inactive under dash_strict → escapes process
13416    // by default, matching `/bin/dash`. (Note: dash's echo also does not
13417    // consume -e/-E as flags; that flag-parsing nuance is not handled here
13418    // — only the default escape behavior is aligned.)
13419    // !!! POSIX-FAITHFUL GATE (no C counterpart) !!! The same divergence one
13420    // level up: `zshrs --sh` must match the REAL `/bin/sh`, not zsh's
13421    // approximation of it. Every POSIX `sh` this parity matrix references
13422    // interprets the escapes — macOS `/bin/sh` (bash built with
13423    // xpg_echo-by-default) and Linux `/bin/sh` (dash) both print a TAB for
13424    // `echo "a\tb"` — while zsh's `emulate sh` sets BSDECHO and prints the
13425    // two characters literally. `--bash` is excluded: bash's own `echo`
13426    // requires `-e`, which is what BSDECHO already gives it. The `--X --zsh`
13427    // zsh-STYLE legs clear posix_faithful, so they keep zsh's BSDECHO
13428    // behavior and stay byte-identical to `zsh -c 'emulate sh; …'`.
13429    let posix_faithful_echo = crate::dash_mode::posix_faithful() && !crate::dash_mode::bash_mode();
13430    let bsd_echo_active =
13431        echo_mode && isset(BSDECHO) && !crate::dash_mode::dash_strict() && !posix_faithful_echo;
13432    let suppress_escapes = OPT_ISSET(ops, b'R')
13433        || OPT_ISSET(ops, b'r')
13434        || (echo_mode && OPT_ISSET(ops, b'E'))
13435        || (bsd_echo_active && !dash_e);
13436    let mut backslash_c_truncated = false;
13437    if !suppress_escapes || dash_e {
13438        // c:builtin.c:4754-4760 — `-b` (bindkey escapes) takes precedence,
13439        // then GETKEYS_PRINT for print (EMACS), else GETKEYS_ECHO.
13440        let escape_how: u32 = if OPT_ISSET(ops, b'b') {
13441            GETKEYS_BINDKEY // c:4755
13442        } else if !echo_mode && !dash_e {
13443            GETKEYS_PRINT // c:4758
13444        } else if crate::dash_mode::dash_faithful() {
13445            // !!! DASH-FAITHFUL GATE (no C counterpart) !!! dash's echo
13446            // (conv_escape_str) takes `\NNN` as well as `\0NNN`; zsh's ECHO
13447            // set only knows the `\0` introducer.
13448            GETKEYS_ECHO | crate::ported::zsh_h::GETKEY_OCTAL_ESC as u32
13449        } else {
13450            GETKEYS_ECHO // c:4760
13451        };
13452        // Clear any stale TLS flag before the loop.
13453        let _ = crate::ported::utils::getkey_truncated_take();
13454        let mut new_args: Vec<String> = Vec::with_capacity(processed_args.len());
13455        for a in processed_args.iter() {
13456            // !!! KSH93-ONLY (no C counterpart) !!! ksh93 has its own, narrower
13457            // escape set (dash_mode::ksh93_print_escapes); `-b` keeps bindkey escapes.
13458            if crate::dash_mode::ksh93_mode() && !OPT_ISSET(ops, b'b') {
13459                let (s, truncated) = crate::dash_mode::ksh93_print_escapes(a);
13460                new_args.push(s);
13461                if truncated {
13462                    backslash_c_truncated = true;
13463                    break;
13464                }
13465                continue;
13466            }
13467            let a = &if echo_mode && crate::dash_mode::dash_faithful() {
13468                crate::dash_mode::dash_echo_octal_normalize(a)
13469            } else {
13470                a.clone()
13471            };
13472            let (s, _) = getkeystring_with(a, escape_how, None);
13473            new_args.push(s);
13474            if crate::ported::utils::getkey_truncated_take() {
13475                // c:utils.c:7045 — `\c` truncated; drop remaining
13476                // args entirely AND suppress trailing newline.
13477                backslash_c_truncated = true;
13478                break;
13479            }
13480        }
13481        processed_args = new_args;
13482    }
13483    // c:4598-4600 — `-P` prompt-style percent expansion (`%n`, `%d`,
13484    // `%?`, `%h`, `%%`, etc.). Routes through `expand_prompt`
13485    // (canonical port of `Src/prompt.c:182 promptexpand`).
13486    if OPT_ISSET(ops, b'P') {
13487        // c:Src/builtin.c:4745-4746 — `if (OPT_ISSET(ops, 'P'))
13488        //   txtunknownattrs = TXT_ATTR_ALL;`. Marks every attribute
13489        // as initially "unknown" so the first `%b`/`%u`/`%s`/`%f`/`%k`
13490        // off-escape in this print invocation emits its terminfo cap
13491        // (via tunsetattrs at prompt.c:1758 — `txtcurrentattrs |=
13492        // newattrs & txtunknownattrs`). Without this seed, applying
13493        // an off-escape against zero current attrs produced no diff
13494        // and zshrs emitted nothing — `%b` / `%u` / `%s` looked like
13495        // no-ops in fresh prompts. Bug #38 in docs/BUGS.md.
13496        crate::ported::prompt::txtunknownattrs.store(
13497            crate::ported::zsh_h::TXT_ATTR_ALL,
13498            std::sync::atomic::Ordering::SeqCst,
13499        );
13500        // c:4598-4600 — `-P` prompt-style percent expansion.
13501        for a in processed_args.iter_mut() {
13502            *a = crate::ported::prompt::expand_prompt(a); // c:Src/prompt.c:182
13503                                                          // c:Src/prompt.c:236-247 — `if (!ns) { ... chuck(Inpar/
13504                                                          // Outpar/Nularg); }`. When `ns=0` (non-stripping flag
13505                                                          // off), zsh REMOVES the Inpar/Outpar/Nularg marker bytes
13506                                                          // from the output. `print -P` calls promptexpand with
13507                                                          // `ns=0` per Src/builtin.c:4598, so the SGR-wrapping
13508                                                          // markers MUST NOT leak into stdout. The Rust port's
13509                                                          // expand_prompt uses ad-hoc `\x01`/`\x02` (readline
13510                                                          // RL_PROMPT_*_IGNORE) markers instead of canonical
13511                                                          // Inpar/Outpar, but the strip rule applies identically:
13512                                                          // for non-prompt-render callers, scrub them. Parity bug
13513                                                          // #17 — without this, `print -P "%F{red}red%f"` emitted
13514                                                          // `\x01\E[31m\x02red\x01\E[39m\x02` instead of zsh's
13515                                                          // `\E[31mred\E[39m`.
13516            a.retain(|c| c != '\x01' && c != '\x02');
13517        }
13518    }
13519    // c:Src/builtin.c:4783-4795 — `-D`: interpret each arg as a
13520    // directory and abbreviate via `finddir(args[n])` — longest-prefix
13521    // match against the full named-dir table ($HOME → `~`, `hash -d`
13522    // entries → `~name`, plus the `zsh_directory_name` hook). C rewrites
13523    // the matched prefix to `~name` and keeps the trailing path. The
13524    // canonical `finddir` port already returns the `~name/rest` form.
13525    if dirify_d {
13526        for a in processed_args.iter_mut() {
13527            if let Some(abbrev) = crate::ported::utils::finddir(a) {
13528                *a = abbrev; // c:4791 — `~%s%s`
13529            }
13530        }
13531    }
13532    // c:4799-4808 — `-o` / `-O` / `-i` sort flags.
13533    //
13534    // C body:
13535    // ```c
13536    // if (OPT_ISSET(ops,'o') || OPT_ISSET(ops,'O')) {
13537    //     flags = OPT_ISSET(ops,'i') ? SORTIT_IGNORING_CASE : 0;
13538    //     if (OPT_ISSET(ops,'O'))
13539    //         flags |= SORTIT_BACKWARDS;
13540    //     strmetasort(args, flags, len);
13541    // }
13542    // ```
13543    //
13544    // Meaning: `-i` sets `SORTIT_IGNORING_CASE` (case-INSENSITIVE).
13545    // Without `-i`, sort is case-SENSITIVE.
13546    //
13547    // The previous Rust port had this INVERTED — it bound
13548    // `case_sensitive = OPT_ISSET(ops, b'i')`, then case-sensitive-
13549    // sorted under `-i` and case-insensitive-sorted without `-i`.
13550    // The doc-comment for the block claimed "-o → case-insensitive
13551    // ascending" which is also wrong. Result: `print -o foo Bar BAZ`
13552    // emitted `BAZ Bar foo` (case-insensitive) when zsh emits
13553    // `BAZ Bar foo` only WITH `-i`; without it, zsh emits
13554    // `BAZ Bar foo` ordered by ASCII (caps first).
13555    if OPT_ISSET(ops, b'o') || OPT_ISSET(ops, b'O') {
13556        // c:4800
13557        // c:4801-4807 — `flags = OPT_ISSET(ops,'i') ? SORTIT_IGNORING_CASE
13558        // : 0; if (OPT_ISSET(ops,'O')) flags |= SORTIT_BACKWARDS;
13559        // strmetasort(args, flags, len);`. The previous Rust port used an
13560        // ad-hoc `processed_args.sort()` (byte/ordinal order) plus a
13561        // `.reverse()`, bypassing the faithful comparator. zsh's eltpcmp
13562        // sorts via `strcoll(as, bs)` (sort.c:134) — locale collation,
13563        // case-insensitive in UTF-8 locales — and SORTIT_BACKWARDS flips
13564        // `sortdir` inside the comparator (not a post-sort reverse).
13565        let mut flags: u32 = if OPT_ISSET(ops, b'i') {
13566            SORTIT_IGNORING_CASE as u32 // c:4805
13567        } else {
13568            0
13569        };
13570        if OPT_ISSET(ops, b'O') {
13571            flags |= SORTIT_BACKWARDS as u32; // c:4806
13572        }
13573        // c:4792 — `strmetasort(args, flags, len);`. C passes the REAL
13574        // per-argument length array, never NULL (contrast c:694, the
13575        // `typeset` sort, which does pass NULL). That is what selects
13576        // `eltpcmp`'s embedded-NUL branch (`ae->len != -1`), so arguments
13577        // holding NUL bytes compare over their FULL byte extent instead of
13578        // being truncated at the first NUL by `strcoll`. Passing `None`
13579        // here made every `$'a\0...'` argument compare equal to plain
13580        // `a`, so `print -lO $'a' $'a\0' $'a\0b' …` came back in input
13581        // order instead of sorted (Test/B03print.ztst "sorting with
13582        // embedded nulls"). Rust `String` stores the NULs faithfully, so
13583        // the length is simply each argument's byte length.
13584        let mut lens: Vec<usize> = processed_args.iter().map(|a| a.len()).collect(); // c:4792 len
13585        strmetasort(&mut processed_args, flags, Some(&mut lens)); // c:4792
13586    }
13587    // c:4842-4990 — "-c -- output in columns". `-c` sizes the grid from the
13588    // terminal width, `-C N` fixes the column count. Each ROW is written
13589    // with its own terminator (c:4989 `fputc(OPT_ISSET(ops,'N') ? '\0' :
13590    // '\n', fout)`) and the block returns straight after, so `-n` does not
13591    // apply and `-v` captures the final row's newline too.
13592    let columnate = !_printf_mode && (OPT_ISSET(ops, b'c') || OPT_ISSET(ops, b'C'));
13593    let body = if columnate {
13594        let mut nc: i32 = 0;
13595        if OPT_HASARG(ops, b'C') {
13596            // c:4687-4698 — `-C` validates its argument two ways and FAILS;
13597            // it does not fall back:
13598            //     nc = (int)zstrtol(argptr, &eptr, 10);
13599            //     if (*eptr) {
13600            //         zwarnnam(name, "number expected after -%c: %s", 'C', argptr);
13601            //         return 1;
13602            //     }
13603            //     if (nc <= 0) {
13604            //         zwarnnam(name, "invalid number of columns: %s", argptr);
13605            //         return 1;
13606            //     }
13607            let argptr = OPT_ARG(ops, b'C').unwrap_or("");
13608            let (nc_l, rest) = crate::ported::utils::zstrtol_underscore(argptr, 10, false); // c:4689
13609            if !rest.is_empty() {
13610                zwarnnam(name, &format!("number expected after -C: {argptr}")); // c:4691
13611                return 1; // c:4692
13612            }
13613            if nc_l <= 0 {
13614                zwarnnam(name, &format!("invalid number of columns: {argptr}")); // c:4695
13615                return 1; // c:4696
13616            }
13617            nc = nc_l as i32;
13618        }
13619        let args = &processed_args;
13620        let argc = args.len() as i32;
13621        // c:4846-4903 — "We need the character widths to align output in
13622        // columns."  Under MULTIBYTE each arg's width is the sum of its
13623        // printable WCWIDTHs; otherwise the byte length (`widths = len`).
13624        let widths: Vec<i32> = args
13625            .iter()
13626            .map(|a| {
13627                if !isset(crate::ported::zsh_h::MULTIBYTE) {
13628                    return a.len() as i32; // c:4901 widths = len
13629                }
13630                let mut width = 0i32;
13631                let mut it = a.chars();
13632                while let Some(c) = it.next() {
13633                    // c:4870-4883 — "Prevent misaligned columns due to
13634                    // escape sequences by skipping over them. Octals \033
13635                    // and \233 are the possible escape characters
13636                    // recognized by ANSI."  Skip through the first
13637                    // alphabetic character.
13638                    if c == '\u{1b}' || c == '\u{9b}' {
13639                        for d in it.by_ref() {
13640                            if d.is_ascii_alphabetic() {
13641                                break;
13642                            }
13643                        }
13644                        continue;
13645                    }
13646                    let wcw = crate::ported::zsh_h::WCWIDTH(c); // c:4892
13647                    // c:4893-4895 — "treat unprintable as 0"
13648                    if wcw > 0 {
13649                        width += wcw;
13650                    }
13651                }
13652                width
13653            })
13654            .collect();
13655        let across = OPT_ISSET(ops, b'a');
13656        let (nr, sc);
13657        if OPT_ISSET(ops, b'C') {
13658            // c:4907-4934 — n: number of elements, nc: number of columns,
13659            // nr: number of rows. "Ignore lengths in last column since
13660            // they don't affect the separation."
13661            let n = argc;
13662            nr = (n + nc - 1) / nc;
13663            let mut l = 0i32;
13664            for i in 0..argc {
13665                if across {
13666                    if (i % nc) == nc - 1 {
13667                        continue; // c:4925
13668                    }
13669                } else if i >= nr * (nc - 1) {
13670                    break; // c:4928
13671                }
13672                if l < widths[i as usize] {
13673                    l = widths[i as usize];
13674                }
13675            }
13676            sc = l + 2; // c:4933
13677        } else {
13678            // c:4937-4961 — l: maximum length seen; sc: column width;
13679            // nc: number of columns (at least one).
13680            let l = widths.iter().copied().max().unwrap_or(0);
13681            sc = l + 2; // c:4953
13682            nc = (crate::ported::utils::ZTERM_COLUMNS.load(Ordering::SeqCst) + 1) / sc; // c:4954
13683            if nc == 0 {
13684                nc = 1; // c:4956
13685            }
13686            nr = (argc + nc - 1) / nc; // c:4957
13687        }
13688        let mut out = String::new();
13689        let mut n: i32 = 0; // c:4960 — "print across, i.e. columns first"
13690        for i in 0..nr {
13691            if across {
13692                // c:4962-4972
13693                let mut ic = 0;
13694                while ic < nc && n < argc {
13695                    out.push_str(&args[n as usize]);
13696                    let mut l = widths[n as usize];
13697                    if n < argc && ic < nc - 1 {
13698                        while l < sc {
13699                            out.push(' ');
13700                            l += 1;
13701                        }
13702                    }
13703                    ic += 1;
13704                    n += 1;
13705                }
13706            } else {
13707                // c:4974-4983
13708                n = i;
13709                loop {
13710                    out.push_str(&args[n as usize]);
13711                    let mut l = widths[n as usize];
13712                    let mut t = nr;
13713                    while t != 0 && n < argc {
13714                        t -= 1;
13715                        n += 1;
13716                    }
13717                    if n < argc {
13718                        while l < sc {
13719                            out.push(' ');
13720                            l += 1;
13721                        }
13722                    }
13723                    if n >= argc {
13724                        break;
13725                    }
13726                }
13727            }
13728            out.push(if nul_sep { '\0' } else { '\n' }); // c:4985
13729        }
13730        out
13731    } else if OPT_HASARG(ops, b'x') || OPT_HASARG(ops, b'X') {
13732        // c:Src/builtin.c:5095-5125 — `print -x N` / `-X N` expand tabs
13733        // to spaces at tab stops of width N (`-x` only leading tabs,
13734        // `-X` all tabs), threading the column position across args and
13735        // resetting it on the `-l` newline. zexpandtabs (utils.c:5975)
13736        // was ported but never wired into print output.
13737        let all = OPT_HASARG(ops, b'X');
13738        let which = if all { b'X' } else { b'x' };
13739        let width: i32 = OPT_ARG(ops, which)
13740            .map(|a| crate::ported::utils::zstrtol(a, 10).0 as i32) // c:5085
13741            .unwrap_or(8);
13742        let n = processed_args.len();
13743        let mut out = String::new();
13744        let mut startpos = 0i32;
13745        for (i, arg) in processed_args.iter().enumerate() {
13746            startpos = crate::ported::utils::zexpandtabs(arg, width, startpos, all, &mut out);
13747            if i + 1 < n {
13748                if OPT_ISSET(ops, b'l') {
13749                    out.push('\n');
13750                    startpos = 0;
13751                } else if OPT_ISSET(ops, b'N') {
13752                    out.push('\0');
13753                } else {
13754                    out.push(' ');
13755                    startpos += 1;
13756                }
13757            }
13758        }
13759        out
13760    } else {
13761        processed_args.join(sep)
13762    };
13763    // c:5564-5575 — destination dispatch order:
13764    //   -z   → zpushnode(bufstack, stringval)
13765    //   -v   → setsparam(VAR, stringval)
13766    //   -s   → prepnexthistent() + addhistnode(histtab, stringval)
13767    //   else → fwrite to fout
13768    if OPT_ISSET(ops, b'z') {
13769        // c:5564-5565 — `zpushnode(bufstack, stringval)`. The ZLE
13770        // bufstack is consumed by the next zleread call so the
13771        // string is presented at the prompt — `print -z 'echo foo'`
13772        // queues `echo foo` for the user to press Enter on.
13773        // c:5026 — `zpushnode(bufstack, sepjoin(args, NULL, 0))`, which
13774        // inserts at the list HEAD (`zsh.h:591`); every consumer pops the
13775        // head, so appending reversed the order of two queued lines.
13776        crate::ported::zle::zle_main::BUFSTACK
13777            .lock()
13778            .unwrap()
13779            .insert(0, body); // c:5026
13780        return 0;
13781    }
13782    if OPT_ISSET(ops, b's') || OPT_ISSET(ops, b'S') {
13783        // c:5047-5093 — `-s` / `-S` push captured output to the history
13784        // list. `-S` requires exactly one arg (c:5058-5062
13785        // `if (nwords > 1) zwarnnam(name, "option -S takes a single
13786        // argument"); return 1`); the words array is then populated by
13787        // `histsplitwords` (c:5065). `-s` joins all args with spaces
13788        // and stores them as a single history entry. Both paths call
13789        // `prepnexthistent` + `addhistnode(histtab, ent->node.nam, ent)`.
13790        if OPT_ISSET(ops, b'S') && processed_args.len() > 1 {
13791            // c:5059
13792            zwarnnam(name, "option -S takes a single argument"); // c:5059
13793            return 1; // c:5061
13794        }
13795        let event_id = crate::ported::hist::prepnexthistent(); // c:5066/5072
13796        crate::ported::hashtable::addhistnode(&body, event_id as i32); // c:5090
13797                                                                       // c:Src/builtin.c — C's `addhistnode(histtab, …)` uses the
13798                                                                       // ent ALREADY linked into hist_ring by prepnexthistent. zshrs's
13799                                                                       // prepnexthistent only bumps curhist and doesn't insert into
13800                                                                       // the ring, so `fc -l` finds no events. Push the ent into the
13801                                                                       // ring here so the gethistent lookup in fc/history sees it.
13802                                                                       // Without this, `print -s X; fc -l` reported "no such event: 1"
13803                                                                       // even though the histtab had the entry.
13804        let mut ent = crate::ported::hist::make_histent(event_id, body.clone());
13805        // c:5032-5058 — record the word boundaries so `!!`, `!$` and `!:N`
13806        // can pick words out of the entry. `-S` splits its single argument
13807        // with `histsplitwords`; `-s` takes each argument as one word.
13808        let spans: Vec<(usize, usize)> = if OPT_ISSET(ops, b'S') && !processed_args.is_empty() {
13809            crate::ported::hist::histsplitwords(&processed_args[0], true) // c:5049
13810        } else {
13811            let mut nlen = 0usize;
13812            processed_args
13813                .iter()
13814                .map(|arg| {
13815                    let span = (nlen, nlen + arg.len()); // c:5060-5062
13816                    nlen += arg.len() + 1;
13817                    span
13818                })
13819                .collect()
13820        };
13821        ent.words = spans.iter().flat_map(|&(b, e)| [b as i16, e as i16]).collect();
13822        ent.nwords = spans.len() as i32; // c:5054, c:5058
13823        if let Ok(mut ring) = crate::ported::hist::hist_ring.lock() {
13824            ring.insert(0, ent);
13825            crate::ported::hist::histlinect.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
13826        }
13827        return 0;
13828    }
13829    if let Some(ref v) = dest_var {
13830        // c:Src/builtin.c:544-546 — the trailing separator is part of the
13831        // captured `-v` value too: it is written to the same stream and read
13832        // back into `buf`. It is suppressed only when `-n`, a `\c` escape
13833        // (`nnl`), OR `-v` WITHOUT `-l` apply. So `print -v x -l a b c` stores
13834        // `a\nb\nc\n` (with the trailing newline) where `print -v x a b c`
13835        // stores `a b c` (none). The port previously suppressed it for every
13836        // `-v`, dropping the `-l` trailing newline.
13837        // c:4990-4999 — the columnate block already wrote every row's
13838        // terminator into the stream, and READ_MSTREAM captures all of it.
13839        let suppress_term =
13840            columnate || nonewline || backslash_c_truncated || !OPT_ISSET(ops, b'l');
13841        let mut val = body.clone();
13842        if !suppress_term {
13843            val.push(if nul_sep { '\0' } else { '\n' }); // c:546
13844        }
13845        setsparam(v, &val);
13846    } else {
13847        // c:5130-5132 — final terminator: `-n` suppresses; `-N` emits
13848        // NUL instead of newline; else newline. `\c` truncation
13849        // (c:utils.c:7045) also suppresses — matches zsh's
13850        // `echo "a\cb"; echo END` → `aEND`.
13851        //
13852        // c:Src/builtin.c:4982-5025 — the columnate (`-c`/`-C`) block emits its
13853        // newline PER ROW (already in `body`) and then `return ret`s, never
13854        // reaching this shared terminator: `-n` does not suppress it, and with
13855        // ZERO args there are zero rows, so `print -c` outputs nothing.
13856        let final_term: &[u8] = if columnate || nonewline || backslash_c_truncated {
13857            b""
13858        } else if nul_sep {
13859            b"\0"
13860        } else {
13861            b"\n"
13862        };
13863        // c:Bug #388 — `-p` and `-u` are mutually exclusive but both
13864        // route to the same write-to-fd path. Prefer the `-p`-resolved
13865        // coprocout (when present) over the `-u`-resolved fd. If
13866        // neither, fall through to stdout below.
13867        let dest_fd_active = print_dash_p_fd.or(dest_fd);
13868        // c:Src/builtin.c:4752 — `unmetafy(args[n], &len[n])`: print's
13869        // args are unmetafied before the fwrite. zshrs keeps values
13870        // metafied (Meta-char pairs, vm_helper::meta_encode_byte)
13871        // until the byte boundary, so decode here — `print $'\xff'`
13872        // writes the single raw byte 0xff. Bug #127.
13873        let body_bytes = crate::ported::utils::unmetafy_str(&body);
13874        if let Some(mut f) = dest_fd_active {
13875            // c:4847 — write to dup'd file descriptor.
13876            use std::io::Write as _;
13877            let _ = f.write_all(&body_bytes); // c:5124 fwrite
13878            let _ = f.write_all(final_term); // c:5132
13879                                             // f closes on drop (close(dup_fd)) — user's original fd
13880                                             // remains open per c:4843 dup semantics.
13881        } else {
13882            // stdout path. -N writes NUL via raw stdout; print!/println!
13883            // would mangle a NUL inside a String literal via format
13884            // machinery, so route through stdout().write_all directly.
13885            use std::io::Write as _;
13886            let stdout = io::stdout();
13887            let mut lk = stdout.lock();
13888            crate::stdout_ferror::fwrite_stdout(&mut lk, &[&body_bytes, final_term]); // c:5124, c:5132
13889                                              // c:Src/builtin.c — C printf goes through libc fwrite to
13890                                              // stdout (fd 1) which is unbuffered when the builtin runs
13891                                              // under a redirect (because dup2 to fd 1 puts the file
13892                                              // there). Rust's `io::stdout()` is LINE-buffered, so
13893                                              // `printf "abcde"` (no `\n`) leaves bytes pending in the
13894                                              // userspace buffer; when the surrounding `> file` redirect
13895                                              // closes the dup'd fd, those bytes flush AFTER the restore
13896                                              // — landing on the original stdout (terminal) AND missing
13897                                              // the file. Flush here so the bytes hit fd 1 (the redirect
13898                                              // target) before bin_print returns. Bug #397.
13899            let _ = lk.flush();
13900        }
13901    }
13902    0
13903}
13904
13905/// Port of `bin_shift()` from `Src/builtin.c:5593`.
13906/// C decl: `bin_shift(char *name, char **argv, Options ops, UNUSED(int func))`
13907/// C: `int bin_shift(char *name, char **argv, Options ops, UNUSED(int func))`
13908/// — shift positional params (or named arrays) by `num` positions; `-p`
13909/// pops from the right end.
13910/// WARNING: param names don't match C — Rust=(name, argv, _func) vs C=(name, argv, ops, func)
13911pub fn bin_shift(
13912    name: &str,
13913    argv: &[String], // c:5593
13914    ops: &options,
13915    _func: i32,
13916) -> i32 {
13917    let mut num: i32 = 1; // c:5595
13918    let mut ret: i32 = 0; // c:5595
13919    let mut idx = 0usize;
13920    queue_signals(); // c:5599
13921                     // c:5600-5605 — first arg parsed as math expr unless it's an array name.
13922    if !argv.is_empty() {
13923        // !!! DASH-FAITHFUL GATE (no C counterpart) !!! dash's shift takes only a
13924        // plain number (`number()`); anything else is a fatal "Illegal number".
13925        if crate::dash_mode::dash_faithful() {
13926            match crate::dash_mode::dash_number(&argv[0]) {
13927                Some(n) => num = n,
13928                None => {
13929                    unqueue_signals();
13930                    zerrnam(name, &format!("Illegal number: {}", argv[0]));
13931                    return crate::dash_mode::fatal_error_status().unwrap_or(2);
13932                }
13933            }
13934        }
13935        // c:5600
13936        let first = &argv[0];
13937        // c:5600 — `if (!getaparam(*argv))` decides whether the arg is
13938        //          a numeric shift-count vs an array name. getaparam
13939        //          dispatches `argv` to the live positional vector.
13940        let is_array = crate::ported::params::getaparam(first).is_some();
13941        if !is_array {
13942            // c:5600
13943            // c:5601 — `num = mathevali(*argv++);`. The previous Rust port
13944            // used `parse::<i32>()` which rejects any non-trivial
13945            // arithmetic: `shift 1+2` would silently return ret=1
13946            // instead of shifting by 3. Route through mathevali.
13947            num = mathevali(first).unwrap_or_else(|_| {
13948                ret = 1;
13949                0
13950            }) as i32; // c:5601
13951            idx = 1;
13952            // c:5602-5605 — `if (errflag) return 1;`.
13953            if ret != 0 || errflag.load(Relaxed) != 0 {
13954                unqueue_signals(); // c:5604
13955                return 1;
13956            }
13957        }
13958    }
13959
13960    // c:5608-5611 — `if (num < 0)` reject.
13961    if num < 0 {
13962        // c:5608
13963        unqueue_signals(); // c:5609
13964        zwarnnam(name, "argument to shift must be non-negative"); // c:5610
13965        return 1; // c:5611
13966    }
13967
13968    // c:5614-5635 — named-array shift loop.
13969    if idx < argv.len() {
13970        // c:5614
13971        for arr_name in &argv[idx..] {
13972            // c:5615
13973            // c:5616 — `if ((s = getaparam(*argv)))` else silent skip.
13974            let s: Vec<String> = match crate::ported::params::getaparam(arr_name) {
13975                Some(arr) => arr,
13976                None => continue,
13977            };
13978            // c:5617-5621 — arrlen_lt check.
13979            if (s.len() as i32) < num {
13980                // c:5617
13981                zwarnnam(name, &format!("shift count must be <= ${{#{}}}", arr_name)); // c:5602
13982                ret += 1; // c:5619
13983                continue; // c:5620
13984            }
13985            // c:5622-5634 — -p shifts off the right end, otherwise the left.
13986            let s2: Vec<String> = if OPT_ISSET(ops, b'p') {
13987                // c:5622
13988                s[..s.len() - num as usize].to_vec() // c:5625-5628
13989            } else {
13990                s[num as usize..].to_vec() // c:5631
13991            };
13992            // c:5633 — `setaparam(*argv, s);`. Write the shifted array
13993            //          back to paramtab as a proper PM_ARRAY. Was a
13994            //          fake: `env::set_var` + colon-joined fake-array
13995            //          which neither carries array structure nor
13996            //          reaches subsequent `${arr_name[@]}` expansions.
13997            setaparam(arr_name, s2);
13998        }
13999    } else {
14000        // c:5636-5654 — shift positional parameters ($1..$N).
14001        // Static-link path: positional params live in src/ported/vm_helper;
14002        // expose via PPARAMS Mutex<Vec<String>>.
14003        let mut pp = PPARAMS.lock().unwrap_or_else(|e| {
14004            PPARAMS.clear_poison();
14005            e.into_inner()
14006        });
14007        let l = pp.len() as i32;
14008        if num > l {
14009            // c:5636
14010            zwarnnam(name, "shift count must be <= $#"); // c:5637
14011            // ZSHRS-ONLY. `shift` is a POSIX special builtin and shifting
14012            // past `$#` is an ERROR, so ksh93u+m, mksh and dash all abort
14013            // the script on it (`shift 99; echo AFTER` never echoes).
14014            // Not a usage error, so ksh93 exits with the builtin's own
14015            // status (1) while dash still uses 2 — see
14016            // `special_builtin_abort_status`.
14017            if !crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE)
14018                && crate::extensions::emulation_output::special_builtin_error_aborts()
14019            {
14020                use std::sync::atomic::Ordering;
14021                EXIT_VAL.store(
14022                    crate::extensions::emulation_output::special_builtin_abort_status(false, 1),
14023                    Ordering::Relaxed,
14024                );
14025                EXIT_PENDING.store(1, Ordering::Relaxed);
14026            }
14027            ret = 1; // c:5638
14028        } else if OPT_ISSET(ops, b'p') {
14029            // c:5641
14030            pp.truncate((l - num) as usize); // c:5642-5644
14031        } else {
14032            pp.drain(..num as usize); // c:5646-5650
14033        }
14034        // PPARAMS is the single source of truth. fusevm-side reads
14035        // route through exec.pparams() which reads PPARAMS, so the
14036        // shift is immediately visible — no exec.positional_params
14037        // mirror needed.
14038        drop(pp);
14039    }
14040    unqueue_signals(); // c:5658
14041    ret // c:5659
14042}
14043
14044/// Port of `bin_getopts()` from `Src/builtin.c:5672`.
14045/// C decl: `bin_getopts(UNUSED(char *name), char **argv, UNUSED(Options ops), UNUSED(int func))`
14046/// C: `int bin_getopts(UNUSED(char *name), char **argv, UNUSED(Options ops),
14047///                     UNUSED(int func))`.
14048///
14049/// POSIX getopts. Maintains state in $OPTIND (zoptind) and an internal
14050/// per-arg cursor (optcind). Reads from the script's positional params
14051/// when no extra args supplied, otherwise from the trailing argv.
14052/// WARNING: param names don't match C — Rust=(_name, argv, _func) vs C=(name, argv, ops, func)
14053pub fn bin_getopts(
14054    _name: &str,
14055    argv: &[String], // c:5672
14056    ops: &options,
14057    _func: i32,
14058) -> i32 {
14059    if argv.len() < 2 {
14060        // !!! DASH-FAITHFUL GATE (no C counterpart) !!! dash: usage error, status 2.
14061        if crate::dash_mode::dash_faithful() {
14062            zwarnnam(_name, "Usage: getopts optstring var [arg...]");
14063            return 2;
14064        }
14065        return 1;
14066    }
14067    // c:5675 — `char *optstr = unmetafy(*argv++, &lenoptstr); char *var = *argv++;`
14068    let optstr_full = argv[0].clone();
14069    let var = argv[1].clone();
14070    // c:5676 — `char **args = (*argv) ? argv : pparams;`
14071    let argv_rest: Vec<String> = argv[2..].to_vec();
14072    let args: Vec<String> = if !argv_rest.is_empty() {
14073        argv_rest
14074    } else {
14075        PPARAMS.lock().map(|p| p.clone()).unwrap_or_default()
14076    };
14077
14078    // c:Src/builtin.c:5680 — at entry, re-sync the internal zoptind
14079    // tracker against the user-visible $OPTIND param. zsh exposes
14080    // OPTIND as a writable param so scripts can reset it to 1 to
14081    // re-parse positionals; the Rust port had `ZOPTIND` as an atomic
14082    // that was the AUTHORITY (writes to $OPTIND didn't propagate
14083    // back), so `OPTIND=1` between two getopts loops left zoptind at
14084    // the post-loop value and the second pass returned immediately.
14085    // !!! EMULATION-ONLY (no C counterpart) !!! In the real-shell drop-in
14086    // modes the value written to $OPTIND carries a bias over zsh's internal
14087    // `zoptind` (see extensions::dash_mode::getopts_optind_report); undo it
14088    // here so the argument index below is zsh's own. Identity in --zsh and
14089    // in native zshrs, and a script's own `OPTIND=1` still passes through.
14090    let raw_optind = getiparam("OPTIND");
14091    let paramtab_oi = crate::dash_mode::getopts_optind_internal(raw_optind);
14092    // c:Src/params.c:370 — `IPDEF6("OPTIND", &zoptind, varinteger_gsu)`:
14093    // `zoptind` IS $OPTIND's value slot, so a script's `OPTIND=0` (or a
14094    // fresh `local OPTIND`, which reads 0) is what the reset below sees.
14095    // `unset OPTIND` only flags the special PM_UNSET (stdunsetfn leaves a
14096    // special's storage alone), so the counter carries on in ZOPTIND, and
14097    // getopts' writes to `zoptind` never make $OPTIND set again.
14098    let optind_set = paramtab()
14099        .read()
14100        .unwrap()
14101        .get("OPTIND")
14102        .is_some_and(|pm| (pm.node.flags as u32 & PM_UNSET) == 0);
14103    let write_optind = |v: i64| {
14104        if optind_set {
14105            setiparam("OPTIND", v);
14106        }
14107    };
14108    let mut zoptind = if optind_set {
14109        paramtab_oi as i32
14110    } else {
14111        ZOPTIND.load(Relaxed)
14112    };
14113    // c:5681-5685 — `if (zoptind < 1) { zoptind = 1; optcind = 0; }`
14114    if zoptind < 1 {
14115        // c:5681
14116        zoptind = 1;
14117        OPTCIND.store(0, Relaxed);
14118        // c:5683 — the reset lands in $OPTIND itself, so it is visible
14119        // even when the no-more-options return (c:5686) follows.
14120        write_optind(1);
14121    }
14122    // c:Src/builtin.c — when $OPTIND was just reset to 1 (i.e. the
14123    // user-visible param disagrees with the previous internal
14124    // pointer), reset optcind so the new pass starts at byte 0 of
14125    // the first option arg.
14126    // !!! EMULATION-ONLY second disjunct (no C counterpart) !!! bash, ksh93,
14127    // mksh and /bin/sh rewind the within-argument cursor on ANY script write
14128    // to $OPTIND, not just on a write of 1 from elsewhere; dash and ash never
14129    // do. See dash_mode::getopts_optind_user_reset — false in --zsh, so the
14130    // c:5681-5685 rule on the left stands alone there.
14131    let mut optcind = if (optind_set && paramtab_oi == 1 && ZOPTIND.load(Relaxed) != 1)
14132        || crate::dash_mode::getopts_optind_user_reset(raw_optind)
14133    {
14134        0
14135    } else {
14136        OPTCIND.load(Relaxed)
14137    };
14138
14139    // c:5686-5688 — `if (arrlen_lt(args, zoptind)) return 1;`
14140    if (args.len() as i32) < zoptind {
14141        // c:5686
14142        ZOPTIND.store(zoptind, Relaxed);
14143        // !!! EMULATION-ONLY (no C counterpart) !!! XCU getopts requires
14144        // `name` to be set to `?` (and, in bash/ksh93/mksh, $OPTARG to be
14145        // cleared) on the end-of-options return; zsh leaves both alone.
14146        // See dash_mode::getopts_end_of_options. No-op in --zsh.
14147        crate::dash_mode::getopts_end_of_options(&var);
14148        return 1;
14149    }
14150
14151    // c:5691-5693 — `quiet = *optstr == ':'; optstr += quiet; lenoptstr -= quiet;`
14152    // c:5648 (54471) — `posix = isset(POSIXBUILTINS) || OPT_ISSET(ops, 'p');`
14153    let posix = isset(POSIXBUILTINS) || OPT_ISSET(ops, b'p');
14154
14155    let (quiet, optstr) = if optstr_full.starts_with(':') {
14156        // c:5691
14157        (true, &optstr_full[1..])
14158    } else {
14159        (false, optstr_full.as_str())
14160    };
14161
14162    // c:5696 — `str = unmetafy(dupstring(args[zoptind - 1]), &lenstr);`
14163    let mut str_buf = args[(zoptind - 1) as usize].clone();
14164    let mut lenstr = str_buf.len() as i32;
14165    if lenstr == 0 {
14166        // !!! EMULATION-ONLY (no C counterpart) !!! XCU getopts requires
14167        // `name` to be set to `?` (and, in bash/ksh93/mksh, $OPTARG to be
14168        // cleared) on the end-of-options return; zsh leaves both alone.
14169        // See dash_mode::getopts_end_of_options. No-op in --zsh.
14170        crate::dash_mode::getopts_end_of_options(&var);
14171        return 1;
14172    } // c:5697
14173
14174    // c:5699-5703 — bump to next arg if optcind exhausted current.
14175    if optcind >= lenstr {
14176        // c:5699
14177        optcind = 0;
14178        zoptind += 1;
14179        if zoptind as usize > args.len() {
14180            // c:5701
14181            ZOPTIND.store(zoptind, Relaxed);
14182            OPTCIND.store(optcind, Relaxed);
14183            // !!! EMULATION-ONLY (no C counterpart) !!! XCU getopts requires
14184            // `name` to be set to `?` (and, in bash/ksh93/mksh, $OPTARG to be
14185            // cleared) on the end-of-options return; zsh leaves both alone.
14186            // See dash_mode::getopts_end_of_options. No-op in --zsh.
14187            crate::dash_mode::getopts_end_of_options(&var);
14188            write_optind(crate::dash_mode::getopts_optind_report(zoptind, optcind, lenstr) as i64); // c:5702
14189            return 1;
14190        }
14191        str_buf = args[(zoptind - 1) as usize].clone();
14192        lenstr = str_buf.len() as i32;
14193    }
14194
14195    // c:5705-5712 — first option char checks: not `-`/`+` → done; `--` → done.
14196    if optcind == 0 {
14197        // c:5705
14198        // c:5677 (54471) — `+` introduces options only without posix.
14199        if lenstr < 2 || (!str_buf.starts_with('-') && (posix || !str_buf.starts_with('+'))) {
14200            ZOPTIND.store(zoptind, Relaxed);
14201            OPTCIND.store(optcind, Relaxed);
14202            // c:5707 — mirror to $OPTIND so callers see the post-loop
14203            // pointer. Previous Rust port skipped this write on the
14204            // "no more options" exit; OPTIND stayed at the last
14205            // option arg index (-b) instead of advancing past it.
14206            write_optind(crate::dash_mode::getopts_optind_report(zoptind, optcind, lenstr) as i64);
14207            // !!! EMULATION-ONLY (no C counterpart) !!! XCU getopts requires
14208            // `name` to be set to `?` (and, in bash/ksh93/mksh, $OPTARG to be
14209            // cleared) on the end-of-options return; zsh leaves both alone.
14210            // See dash_mode::getopts_end_of_options. No-op in --zsh.
14211            crate::dash_mode::getopts_end_of_options(&var);
14212            return 1;
14213        }
14214        if lenstr == 2 && &str_buf[..2] == "--" {
14215            // c:5708
14216            zoptind += 1;
14217            ZOPTIND.store(zoptind, Relaxed);
14218            OPTCIND.store(0, Relaxed);
14219            // !!! EMULATION-ONLY (no C counterpart) !!! XCU getopts requires
14220            // `name` to be set to `?` (and, in bash/ksh93/mksh, $OPTARG to be
14221            // cleared) on the end-of-options return; zsh leaves both alone.
14222            // See dash_mode::getopts_end_of_options. No-op in --zsh.
14223            crate::dash_mode::getopts_end_of_options(&var);
14224            write_optind(crate::dash_mode::getopts_optind_report(zoptind, optcind, lenstr) as i64); // c:5711
14225            return 1;
14226        }
14227        optcind += 1;
14228    }
14229    // c:5685-5687 (54711) — `opch = str[optcind]; optcind += MB_CHARLENCONV(str + optcind, …, &wopch);`
14230    let opch = str_buf.as_bytes()[optcind as usize];
14231    let wopch = str_buf
14232        .get(optcind as usize..)
14233        .and_then(|s| s.chars().next())
14234        .unwrap_or(opch as char);
14235    optcind += wopch.len_utf8() as i32;
14236
14237    // c:5716-5721 — `lenoptbuf = (str[0] == '+') ? 2 : 1; optbuf[lenoptbuf-1] = opch;`
14238    let plus = str_buf.starts_with('+');
14239    let optbuf: String = if plus {
14240        format!("+{}", opch as char)
14241    } else {
14242        format!("{}", opch as char)
14243    };
14244
14245    // c:5724-5740 — illegal option: `?` reply, OPTIND fixed under posix.
14246    let found = optstr.bytes().position(|b| b == opch);
14247    if opch == b':' || found.is_none() {
14248        // c:5724
14249        if posix {
14250            // c:5728
14251            optcind = 0;
14252            zoptind += 1;
14253        }
14254        // c:5731 — `setsparam(var, ztrdup(p));` where p = "?"
14255        setsparam(&var, "?");
14256        if quiet {
14257            // c:5733
14258            setsparam("OPTARG", &optbuf); // c:5734
14259        } else {
14260            let prefix = if plus { "+" } else { "-" };
14261            zwarn(&format!("bad option: {}{}", prefix, wopch)); // c:5736
14262            setsparam("OPTARG", "");
14263        }
14264        ZOPTIND.store(zoptind, Relaxed);
14265        OPTCIND.store(optcind, Relaxed);
14266        // Sync OPTIND env var so callers can read.
14267        write_optind(crate::dash_mode::getopts_optind_report(zoptind, optcind, lenstr) as i64);
14268        return 0;
14269    }
14270
14271    // c:5744 — `if (p[1] == ':')` — required argument.
14272    let p = found.unwrap();
14273    let optstr_bytes = optstr.as_bytes();
14274    if p + 1 < optstr_bytes.len() && optstr_bytes[p + 1] == b':' {
14275        // c:5744
14276        if optcind == lenstr {
14277            // c:5745
14278            // c:5746 — argument in next arg.
14279            if zoptind as usize >= args.len() {
14280                // c:5747
14281                if posix {
14282                    optcind = 0;
14283                    zoptind += 1;
14284                }
14285                if quiet {
14286                    // c:5754
14287                    setsparam(&var, ":");
14288                    setsparam("OPTARG", &optbuf);
14289                } else {
14290                    setsparam(&var, "?");
14291                    setsparam("OPTARG", "");
14292                    let prefix = if plus { "+" } else { "-" };
14293                    zwarn(&format!(
14294                        "argument expected after {}{} option",
14295                        prefix, wopch
14296                    )); // c:5760
14297                }
14298                ZOPTIND.store(zoptind, Relaxed);
14299                OPTCIND.store(optcind, Relaxed);
14300                write_optind(crate::dash_mode::getopts_optind_report(zoptind, optcind, lenstr) as i64);
14301                return 0;
14302            }
14303            // c:5763 — `p = ztrdup(args[zoptind++]);` — read args[zoptind]
14304            // then post-increment. zoptind now points one past the
14305            // arg-bearing flag's index.
14306            let p_arg = args[zoptind as usize].clone(); // c:5763
14307            zoptind += 1; // c:5763 post-increment
14308            setsparam("OPTARG", &p_arg); // c:5765
14309                                         // c:5771 — `optcind = 0; zoptind++;` — bump past the
14310                                         // consumed value arg too, so the NEXT getopts call sees
14311                                         // the arg AFTER the value. Previous Rust port skipped this
14312                                         // second increment, so the next iter re-read the consumed
14313                                         // value as if it were a new flag.
14314            optcind = 0; // c:5771
14315            zoptind += 1; // c:5772
14316        } else {
14317            // c:5774 — `p = metafy(str+optcind, lenstr-optcind, META_DUP);`
14318            let p_arg = str_buf[(optcind as usize)..].to_string();
14319            setsparam("OPTARG", &p_arg);
14320            optcind = 0;
14321            zoptind += 1;
14322        }
14323    } else {
14324        // c:5784 — `zsfree(zoptarg); zoptarg = ztrdup("");`
14325        setsparam("OPTARG", "");
14326    }
14327
14328    // c:5788 — `setsparam(var, metafy(optbuf, lenoptbuf, META_DUP));`
14329    setsparam(&var, &optbuf);
14330    ZOPTIND.store(zoptind, Relaxed);
14331    OPTCIND.store(optcind, Relaxed);
14332    write_optind(crate::dash_mode::getopts_optind_report(zoptind, optcind, lenstr) as i64);
14333    0 // c:5790
14334}
14335
14336/// Port of `bin_break()` from `Src/builtin.c:5809`.
14337/// C decl: `bin_break(char *name, char **argv, UNUSED(Options ops), int func)`
14338/// C: `int bin_break(char *name, char **argv, UNUSED(Options ops), int func)`
14339/// — handles BIN_BREAK / BIN_CONTINUE / BIN_RETURN / BIN_LOGOUT / BIN_EXIT.
14340/// WARNING: param names don't match C — Rust=(name, argv, func) vs C=(name, argv, ops, func)
14341pub fn bin_break(
14342    name: &str,
14343    argv: &[String], // c:5809
14344    _ops: &options,
14345    func: i32,
14346) -> i32 {
14347    // BIN_BREAK/CONTINUE/RETURN/EXIT/LOGOUT live at the top of this file
14348    // (c:5707-5712 in Src/builtin.c via the BUILTIN(...) table).
14349    // c:5811 — `int num = lastval, nump = 0, implicit;`
14350    let mut num: i32 = LASTVAL.load(Relaxed); // c:5811
14351    let mut nump = 0i32; // c:5811
14352    let implicit = argv.is_empty(); // c:5814
14353    // !!! DASH-ONLY (no C counterpart) !!! dash's `number()` takes a plain
14354    // `[+]digits` that fits an int; anything else (`abc`, `-1`, `1+1`, `0x10`,
14355    // empty) is `sh_error("Illegal number: %s")`, which unwinds the whole
14356    // command list with status 2 (see dash_mode::fatal_error_status). zsh
14357    // instead evaluates the argument as arithmetic.
14358    let mut dash_num: Option<i32> = None;
14359    if !implicit && crate::dash_mode::dash_faithful() {
14360        dash_num = crate::dash_mode::dash_number(&argv[0]);
14361        if dash_num.is_none() {
14362            zerrnam(name, &format!("Illegal number: {}", argv[0]));
14363            return crate::dash_mode::fatal_error_status().unwrap_or(2);
14364        }
14365    }
14366    // c:5815-5818 — first arg parsed as math expr.
14367    if let Some(n) = dash_num {
14368        num = n;
14369        nump = 1;
14370    } else if !implicit {
14371        // c:5815
14372        // c:5816 — mathevali reports a bad expression itself (zerr, which
14373        // sets errflag), so the `argument is not positive` zerrnam below is
14374        // then suppressed; the Rust mathevali hands the message back instead.
14375        num = match mathevali(&argv[0]) {
14376            Ok(n) => n as i32,
14377            Err(msg) => {
14378                zerr(&msg);
14379                0
14380            }
14381        }; // c:5816
14382        nump = 1; // c:5817
14383    }
14384    // !!! EMULATION-ONLY (no C counterpart) !!! bash and the pdksh line keep a
14385    // function's status in 8 bits (`f() { return 300; }; f; echo $?` -> 44,
14386    // `return -1` -> 255); zsh and ksh93 hand back the full value.
14387    if func == BIN_RETURN && (crate::dash_mode::bash_mode() || crate::dash_mode::pdksh_family()) {
14388        num &= 0xff;
14389    }
14390
14391    let loops = LOOPS.load(Relaxed);
14392    match func {
14393        // c:5793-5808 (55061) — BIN_CONTINUE / BIN_BREAK share one arm.
14394        // Both errors are `zerrnam`, which sets errflag so the calling
14395        // function or script stops after the command (Bug #616).
14396        x if x == BIN_CONTINUE || x == BIN_BREAK => {
14397            let num = if nump != 0 { num } else { 1 }; // c:5795
14398            // !!! DASH-FAITHFUL GATE (no C counterpart) !!! dash's breakcmd clamps the
14399            // count to the loop nest and returns 0: `break` / `continue` outside a
14400            // loop (or with count 0) is a silent no-op, not an error.
14401            if crate::dash_mode::dash_faithful() && (num == 0 || loops == 0) {
14402                return 0;
14403            }
14404            if num <= 0 {
14405                // c:5796
14406                zerrnam(name, &format!("argument is not positive: {}", num)); // c:5797
14407                return 1; // c:5798
14408            }
14409            if loops == 0 {
14410                // c:5800 — break/continue only permitted in loops
14411                zerrnam(
14412                    name,
14413                    if ANCESTOR_LOOPS.load(Relaxed) != 0 {
14414                        "not in same subshell as first enclosing loop" // c:5802
14415                    } else {
14416                        "not in while, until, select, or repeat loop" // c:5803 (5.9.2; the dev tree adds "for")
14417                    },
14418                );
14419                return 1; // c:5804
14420            }
14421            CONTFLAG.store((func == BIN_CONTINUE) as i32, Relaxed); // c:5806
14422            BREAKS.store(num.min(loops), Relaxed); // c:5807
14423        }
14424        // c:5839-5860 — BIN_RETURN.
14425        x if x == BIN_RETURN => {
14426            let interactive = isset(INTERACTIVE);
14427            let shinstdin = isset(SHINSTDIN);
14428            let ll_v = locallevel_param.load(Relaxed);
14429            let sourcelevel = crate::ported::init::sourcelevel.load(Relaxed);
14430            // c:5840-5841 — `if ((interactive && shinstdin) || locallevel || sourcelevel)`
14431            if (interactive && shinstdin) || ll_v != 0 || sourcelevel != 0 {
14432                // c:5840
14433                RETFLAG.store(1, Relaxed); // c:5842
14434                BREAKS.store(loops, Relaxed); // c:5843
14435                LASTVAL.store(num, Relaxed); // c:5844
14436                                             // c:5845-5854 — inside a primed trap with the sentinel
14437                                             // `trap_return == -2`, promote to TRAP_STATE_FORCE_RETURN
14438                                             // and carry `lastval`. POSIXTRAPS + `implicit` opts out:
14439                                             // POSIX semantics keep $? from before the trap fired.
14440                let posixtraps = isset(POSIXTRAPS);
14441                let cur_state = TRAP_STATE.load(Relaxed);
14442                let cur_return = TRAP_RETURN.load(Relaxed);
14443                if cur_state == TRAP_STATE_PRIMED      // c:5845
14444                    && cur_return == -2                                      // c:5845
14445                    && !(posixtraps && implicit)
14446                // c:5851
14447                {
14448                    TRAP_STATE.store(
14449                        // c:5852
14450                        TRAP_STATE_FORCE_RETURN,
14451                        Relaxed,
14452                    );
14453                    TRAP_RETURN.store(num, Relaxed);
14454                    // c:5853
14455                }
14456                return num; // c:5855
14457            }
14458            // !!! BASH-MODE (no C counterpart) !!! bash refuses `return` outside
14459            // a function / sourced script (status 2) and keeps running.
14460            if crate::dash_mode::bash_mode() {
14461                zwarnnam(name, "can only `return\u{27} from a function or sourced script");
14462                return 2;
14463            }
14464            // c:5858 — fallthrough: treat as logout/exit.
14465            zexit(num, ZEXIT_NORMAL); // c:5858
14466        }
14467        // c:5864-5869 — BIN_LOGOUT: refuse if not LOGINSHELL, then
14468        // FALLTHROUGH into the BIN_EXIT body. The previous Rust port
14469        // called \`zexit(num, ZEXIT_NORMAL)\` directly instead of
14470        // entering the BIN_EXIT defer-guard, so \`logout\` from inside
14471        // a function would skip EXIT traps + function unwind +
14472        // \"you have running jobs\" warning — same gap as the prior
14473        // BIN_EXIT fix.
14474        x if x == BIN_LOGOUT => {
14475            // c:5865 — `if (unset(LOGINSHELL))`. The previous Rust port
14476            // called `optlookup("login")` — but "login" is the
14477            // SHELL-LETTER-FLAG name (zshletters table letter 'l'),
14478            // not an option name. Option name canonicalization maps
14479            // LOGINSHELL → "loginshell" (Src/options.c index_to_name
14480            // at line 1682 in Rust port).
14481            //
14482            // \`optlookup(\"login\")\` returns OPT_INVALID (0), so
14483            // \`isset(0)\` always returns false — bin_logout always
14484            // saw \"not login shell\" and rejected with that error
14485            // regardless of whether the shell was actually started
14486            // with \`-l\`.
14487            let loginshell = isset(LOGINSHELL);
14488            if !loginshell {
14489                // c:5865
14490                // c:Src/builtin.c:5861 — `zerrnam(name, "not login
14491                // shell");`. C uses zerrnam (sets ERRFLAG_ERROR via
14492                // utils.c:203), NOT zwarnnam. The errflag set aborts
14493                // the remainder of the current command list — `logout
14494                // 2>&1; print ex:$?` in a non-login shell prints only
14495                // the error message because the `;` separator's next
14496                // command is gated on errflag-clear. Previous Rust
14497                // port used zwarnnam (no errflag) so `print` still
14498                // ran. Pinned by logout_builtin_stderr parity probe.
14499                zerrnam(name, "not login shell"); // c:5861
14500                return 1; // c:5862
14501            }
14502            // c:5869 — `/*FALLTHROUGH*/` into BIN_EXIT body.
14503            // Reusing the BIN_EXIT branch below by setting `func` to
14504            // BIN_EXIT isn't possible mid-match; inline the same
14505            // guard logic here.
14506            let cur_locallevel = locallevel_param.load(Relaxed);
14507            let forklevel = FORKLEVEL.load(Relaxed);
14508            let shell_exiting = SHELL_EXITING.load(Relaxed);
14509            if cur_locallevel > forklevel && shell_exiting != -1 {
14510                // c:5871
14511                if STOPMSG.load(Relaxed) == 0 {
14512                    zexit(0, ZEXIT_DEFERRED); // c:5884
14513                }
14514                if STOPMSG.load(Relaxed) == 0 {
14515                    // c:5884
14516                    let trap_state = TRAP_STATE.load(Relaxed);
14517                    if trap_state != 0 {
14518                        // c:5885
14519                        TRAP_STATE.store(
14520                            // c:5886
14521                            TRAP_STATE_FORCE_RETURN,
14522                            Relaxed,
14523                        );
14524                    }
14525                    RETFLAG.store(1, Relaxed); // c:5887
14526                    BREAKS.store(
14527                        LOOPS.load(Relaxed), // c:5888
14528                        Relaxed,
14529                    );
14530                    EXIT_PENDING.store(1, Relaxed); // c:5889
14531                    EXIT_LEVEL.store(cur_locallevel, Relaxed); // c:5890 — exit_level = locallevel;
14532                    EXIT_VAL.store(num, Relaxed); // c:5891
14533                }
14534            } else {
14535                zexit(num, ZEXIT_NORMAL); // c:5894
14536            }
14537        }
14538        // c:5870-5894 — BIN_EXIT: function-context guard. C body:
14539        //   if (locallevel > forklevel && shell_exiting != -1) {
14540        //       if (stopmsg || (zexit(0, ZEXIT_DEFERRED), !stopmsg)) {
14541        //           if (trap_state) trap_state = TRAP_STATE_FORCE_RETURN;
14542        //           retflag = 1; breaks = loops;
14543        //           exit_pending = 1; exit_level = locallevel; exit_val = num;
14544        //       }
14545        //   } else zexit(num, ZEXIT_NORMAL);
14546        //
14547        // Inside a function (locallevel > forklevel) the shell can't
14548        // exit directly — EXIT traps still need to run. The probe
14549        // path zexit(0, ZEXIT_DEFERRED) calls checkjobs; if no
14550        // stopmsg triggered, we defer: set retflag + breaks +
14551        // exit_pending so the function unwind takes us out.
14552        //
14553        // The previous Rust port skipped this entire guard, always
14554        // calling zexit(num, ZEXIT_NORMAL) directly. `exit` inside
14555        // a function would terminate without running EXIT traps or
14556        // unwinding the function stack.
14557        x if x == BIN_EXIT => {
14558            let cur_locallevel = locallevel_param.load(Relaxed);
14559            let forklevel = FORKLEVEL.load(Relaxed);
14560            let shell_exiting = SHELL_EXITING.load(Relaxed);
14561            if cur_locallevel > forklevel && shell_exiting != -1 {
14562                // c:5871
14563                // Probe via ZEXIT_DEFERRED — may set stopmsg.
14564                if STOPMSG.load(Relaxed) == 0 {
14565                    zexit(0, ZEXIT_DEFERRED); // c:5884
14566                }
14567                if STOPMSG.load(Relaxed) == 0 {
14568                    // c:5884 still no stopmsg → defer
14569                    let trap_state = TRAP_STATE.load(Relaxed);
14570                    if trap_state != 0 {
14571                        // c:5885
14572                        TRAP_STATE.store(
14573                            // c:5886
14574                            TRAP_STATE_FORCE_RETURN,
14575                            Relaxed,
14576                        );
14577                    }
14578                    RETFLAG.store(1, Relaxed); // c:5887
14579                    BREAKS.store(
14580                        LOOPS.load(Relaxed), // c:5888
14581                        Relaxed,
14582                    );
14583                    EXIT_PENDING.store(1, Relaxed); // c:5889
14584                    EXIT_LEVEL.store(cur_locallevel, Relaxed); // c:5890 — exit_level = locallevel;
14585                    EXIT_VAL.store(num, Relaxed); // c:5891
14586                }
14587            } else {
14588                zexit(num, ZEXIT_NORMAL); // c:5894
14589            }
14590        }
14591        _ => {}
14592    }
14593    0
14594}
14595
14596/// Port of `checkjobs()` from Src/builtin.c:5899.
14597/// C: `static void checkjobs(void)` — walk `jobtab[1..maxjob]`; for each
14598///   non-current job that's STAT_LOCKED, not STAT_NOPRINT, and either
14599///   running (when CHECKRUNNINGJOBS is set) or STAT_STOPPED, emit
14600///   "you have running/stopped jobs" + set `stopmsg = 1`.
14601pub fn checkjobs() {
14602    // c:5899
14603    let checkrunning = isset(CHECKRUNNINGJOBS);
14604    // c:5901 — read the canonical jobs.rs THISJOB/MAXJOB globals.
14605    // The previous builtin.rs duplicate AtomicI32s for both never
14606    // synced with the jobs.rs Mutex<i32> values that the spawn /
14607    // wait paths actually update — checkjobs would see stale 0s
14608    // regardless of how many jobs were active.
14609    let thisjob: i32 = *crate::ported::jobs::THISJOB
14610        .get_or_init(|| Mutex::new(-1_i32))
14611        .lock()
14612        .expect("THISJOB poisoned");
14613    // jobs::MAXJOB is stored as `Mutex<usize>` (Rust adaptation for
14614    // Vec-index semantics); cast to i32 for comparison with `thisjob`.
14615    let maxjob: i32 = *crate::ported::jobs::MAXJOB
14616        .get_or_init(|| Mutex::new(0_usize))
14617        .lock()
14618        .expect("MAXJOB poisoned") as i32;
14619
14620    // c:5903 — `for (i = 1; i <= maxjob; i++)`
14621    let mut found: Option<i32> = None;
14622    let mut found_stat: i32 = 0;
14623    for i in 1..=maxjob {
14624        // c:5903
14625        // c:5904 `jobtab[i].stat` — the canonical table. The JOBSTATS
14626        // mirror this used to read is never written, so every job looked
14627        // unused and `exit` never warned about running or stopped jobs.
14628        let stat = crate::ported::jobs::JOBTAB
14629            .get_or_init(|| Mutex::new(Vec::new()))
14630            .lock()
14631            .ok()
14632            .and_then(|t| t.get(i as usize).map(|j| j.stat))
14633            .unwrap_or(0);
14634        // c:5904-5906 — `i != thisjob && (stat & STAT_LOCKED) &&
14635        //                !(stat & STAT_NOPRINT) &&
14636        //                (CHECKRUNNINGJOBS || stat & STAT_STOPPED)`
14637        if i != thisjob                                                      // c:5904
14638            && (stat & STAT_LOCKED) != 0                                     // c:5904
14639            && (stat & STAT_NOPRINT) == 0                                    // c:5905
14640            && (checkrunning || (stat & STAT_STOPPED) != 0)
14641        // c:5906
14642        {
14643            found = Some(i); // c:5907
14644            found_stat = stat;
14645            break;
14646        }
14647    }
14648    // c:5908 — `if (i <= maxjob)`
14649    if found.is_some() {
14650        // c:5908
14651        if (found_stat & STAT_STOPPED) != 0 {
14652            // c:5909
14653            // c:5903-5908 — USE_SUSPENDED is always defined (config_h.rs).
14654            zerr("you have suspended jobs."); // c:5904
14655        } else {
14656            // c:5917 — `zerr("you have running jobs.");`
14657            zerr("you have running jobs."); // c:5917
14658        }
14659        STOPMSG.store(1, Relaxed); // c:5919
14660    }
14661}
14662
14663/// Port of `realexit()` from Src/builtin.c:5953.
14664/// C body (single statement):
14665///     `exit((shell_exiting || exit_pending) ? exit_val : lastval);`
14666pub fn realexit() -> ! {
14667    // c:5953
14668    std::process::exit(
14669        if SHELL_EXITING.load(Relaxed) != 0 || EXIT_PENDING.load(Relaxed) != 0 {
14670            EXIT_VAL.load(Relaxed)
14671        } else {
14672            LASTVAL.load(Relaxed)
14673        },
14674    );
14675}
14676
14677/// Port of `_realexit()` from Src/builtin.c:5962.
14678/// C body (single statement):
14679///     `_exit((shell_exiting || exit_pending) ? exit_val : lastval);`
14680pub fn _realexit() -> ! {
14681    // c:5962
14682    unsafe {
14683        libc::_exit(
14684            if SHELL_EXITING.load(Relaxed) != 0 || EXIT_PENDING.load(Relaxed) != 0 {
14685                EXIT_VAL.load(Relaxed)
14686            } else {
14687                LASTVAL.load(Relaxed)
14688            },
14689        )
14690    }
14691}
14692
14693/// Port of `zexit()` from `Src/builtin.c:5977`.
14694/// C decl: `zexit(int val, enum zexit_t from_where)`
14695/// C: `void zexit(int val, enum zexit_t from_where)` — record exit
14696///   value, fire EXIT trap unless already exiting, then realexit.
14697#[allow(unused_variables)]
14698pub fn zexit(val: i32, from_where: i32) {
14699    // c:5977
14700    // c:5989 — `exit_val = val;`
14701    EXIT_VAL.store(val, Relaxed); // c:5989
14702                                  // c:5990 — `if (shell_exiting == -1) { retflag = 1; breaks = loops; return; }`
14703    if SHELL_EXITING.load(Relaxed) == -1 {
14704        // c:5990
14705        RETFLAG.store(1, Relaxed); // c:5991
14706        BREAKS.store(LOOPS.load(Relaxed), Relaxed); // c:5992
14707        return; // c:5993
14708    }
14709
14710    // c:5996-6004 — `if (isset(MONITOR) && !stopmsg && from_where != ZEXIT_SIGNAL)`:
14711    // run scanjobs + checkjobs; if stopmsg got set (running jobs warned),
14712    // mark stopmsg=2 and DEFER the exit. The previous Rust port skipped
14713    // this entire block, so `exit` with running jobs would terminate
14714    // immediately rather than emitting the standard
14715    // \"zsh: you have running jobs\" + waiting for a confirmation exit.
14716    if isset(MONITOR)                                  // c:5996
14717        && STOPMSG.load(Relaxed) == 0
14718        && from_where != ZEXIT_SIGNAL
14719    {
14720        // c:5997 — `scanjobs();    /* check if jobs need printing */`
14721        if let Some(jt) = crate::ported::jobs::JOBTAB.get() {
14722            let mut guard = jt.lock().unwrap();
14723            crate::ported::jobs::scanjobs(&mut guard); // c:5997
14724        }
14725        // c:5998 — `if (isset(CHECKJOBS))`. Without the gate
14726        // `setopt no_check_jobs` still refused the first `exit` with
14727        // "you have running jobs." (W02jobs.ztst "running job with
14728        // no_check_jobs").
14729        if isset(CHECKJOBS) {
14730            checkjobs(); // c:5999
14731        }
14732        if STOPMSG.load(Relaxed) != 0 {
14733            // c:6000
14734            STOPMSG.store(2, Relaxed); // c:6001
14735            return; // c:6002 defer
14736        }
14737    }
14738    // c:6006-6008 — `if (from_where == ZEXIT_DEFERRED || (shell_exiting++
14739    //                 && from_where != ZEXIT_NORMAL)) return;`. Probe path:
14740    // ZEXIT_DEFERRED callers only want the checkjobs gate to fire; if
14741    // it didn't trip, return without actually exiting.
14742    if from_where == ZEXIT_DEFERRED {
14743        // c:6006
14744        return;
14745    }
14746    let prev_exiting = SHELL_EXITING.fetch_add(1, Relaxed);
14747    if prev_exiting != 0 && from_where != ZEXIT_NORMAL {
14748        // c:6007
14749        return;
14750    }
14751    // c:6014 — `shell_exiting = -1;`
14752    SHELL_EXITING.store(-1, Relaxed); // c:6014
14753                                      // c:6019 — `errflag = 0;`
14754    errflag.store(0, Relaxed); // c:6019
14755    // !!! WARNING: RUST-ONLY — NO C COUNTERPART !!!
14756    // Exit is committed from here, so drain the buffered autoload
14757    // bytecode entries. `preprompt()` is the batch boundary for an
14758    // interactive shell; a script never reaches a prompt, so without
14759    // this its compiles would never reach the cache at all.
14760    crate::autoload_cache::try_flush_pending();
14761    crate::deparse_cache::try_flush_pending();
14762                               // c:6021-6024 — MONITOR → killrunjobs.
14763    if isset(MONITOR) {
14764        // c:6021
14765        crate::ported::signals::killrunjobs(if from_where == ZEXIT_SIGNAL { 1 } else { 0 });
14766        // c:6023
14767    }
14768    // !!! RUST-ONLY GATE: see SUBSHELL_DEPTH declaration above for
14769    // rationale. C zsh's realexit at c:5953 unconditionally calls
14770    // process::exit because the subshell was forked; in zshrs the
14771    // subshell runs in-process, so process::exit would kill the
14772    // whole shell. Defer: set EXIT_PENDING + EXIT_VAL + reset
14773    // SHELL_EXITING so the subshell_end unwind in fusevm_bridge
14774    // catches and propagates the status to the parent.
14775    if SUBSHELL_DEPTH.load(Relaxed) > 0 {
14776        SHELL_EXITING.store(0, Relaxed);
14777        EXIT_VAL.store(val, Relaxed);
14778        EXIT_PENDING.store(1, Relaxed);
14779        RETFLAG.store(1, Relaxed);
14780        BREAKS.store(LOOPS.load(Relaxed), Relaxed);
14781        return;
14782    }
14783    // c:6012 — `cleanfilelists();` — delete per-job temp-file lists
14784    // before exit. jobs.rs::cleanfilelists is a Rust-only-signature
14785    // adapter over the C global jobtab (see its WARNING block).
14786    // C runs it unconditionally. zshrs allocates JOBTAB lazily (a `-c`
14787    // command that never started a job has none), and skipping the call
14788    // then also skipped the `=(cmd)` temp files cleanfilelists drains from
14789    // the VM's pending list: `() { exit } =(true)` left the file behind
14790    // (D03procsubst.ztst "exit in shell function cleans up tempfiles").
14791    match crate::ported::jobs::JOBTAB.get() {
14792        Some(tab) => {
14793            let mut tab = tab.lock().unwrap_or_else(|e| e.into_inner());
14794            crate::ported::jobs::cleanfilelists(&mut tab); // c:6012
14795        }
14796        None => crate::ported::jobs::cleanfilelists(&mut []), // c:6012
14797    }
14798    // ZSHRS-ONLY, no C counterpart — the selected drop-in's logout file
14799    // (`~/.bash_logout` for `--bash`, `~/.logout` + `/etc/csh.logout` for
14800    // `--csh`; the Korn and Bourne shells document none).
14801    //
14802    // NOT gated on `interact()` the way zsh's `.zlogout` below is: bash
14803    // reads `~/.bash_logout` when "an interactive login shell exits, or a
14804    // non-interactive login shell executes the exit builtin" (bash(1)
14805    // INVOCATION) — verified against bash 5.3.15, where both `bash -l -i`
14806    // on exit and `bash -l -c exit` source it, while `bash -l -c true`
14807    // (fall-through, no exit builtin) does not. zshrs reaches this
14808    // function only through the exit path, matching that distinction.
14809    // History saving below still runs for every drop-in; only the
14810    // `.zlogout` sourcing is theirs to replace.
14811    if crate::extensions::emulation_startup::overrides_zsh_startup() {
14812        crate::extensions::emulation_startup::run_logout_scripts();
14813    }
14814    // c:6013-6028 — `if (isset(RCS) && interact)`: save the history
14815    // file and run logout scripts. This is THE write-at-exit path for
14816    // interactive history (`-f`/NO_RCS shells intentionally skip it,
14817    // matching C).
14818    if isset(RCS) && interact() {
14819        // c:6013
14820        // c:6014-6020 — `if (!nohistsave) { ... }`.
14821        if crate::ported::exec::nohistsave.load(Relaxed) == 0 {
14822            let mut writeflags = HFILE_USE_OPTIONS as i32; // c:6015
14823            if from_where == ZEXIT_SIGNAL {
14824                // c:6016
14825                writeflags |= HFILE_NO_REWRITE as i32; // c:6017
14826            }
14827            saveandpophiststack(1, writeflags); // c:6018
14828            savehistfile(None, writeflags); // c:6019
14829        }
14830                                        // c:6021-6027 — `if (islogin && !subsh) { sourcehome(".zlogout");
14831                                        // ... source(GLOBAL_ZLOGOUT); }`. The C `subsh` check is covered
14832                                        // by the RUST-ONLY SUBSHELL_DEPTH gate above (in-process
14833                                        // subshells returned before reaching here).
14834        // `&& !overrides_zsh_startup()`: only a zsh shell has a
14835        // `.zlogout`; every drop-in's logout file was sourced above.
14836        if islogin() && !crate::extensions::emulation_startup::overrides_zsh_startup() {
14837            // c:6021
14838            crate::ported::init::sourcehome(".zlogout"); // c:6022
14839            if isset(RCS) && isset(GLOBALRCS) {
14840                // c:6024
14841                let _ = crate::ported::init::source(&crate::extensions::global_rc::global_rc_path(
14842                    crate::ported::config_h::GLOBAL_ZLOGOUT,
14843                ));
14844                // c:6025
14845            }
14846        }
14847    }
14848    // c:Src/builtin.c:6075-6079 — fire EXIT trap (SIGEXIT) before
14849    // calling realexit. The trap body sees $? = val (carried via
14850    // LASTVAL below) and runs in the shell process. Remove the
14851    // entry from traps_table first so the trap body's own commands
14852    // don't re-trigger it recursively.
14853    //
14854    // Two trap-storage paths: (a) `trap 'cmd' EXIT` stores raw
14855    // body text in `traps_table` (no settrap), handled by the
14856    // direct execute_script below; (b) `TRAPEXIT() { ... }`
14857    // function-named form goes through settrap(SIGEXIT, None,
14858    // ZSIG_FUNC) at funcdef time and lives in the
14859    // shfunctab+sigtrapped pair, dispatched via `dotrap(SIGEXIT)`.
14860    // C zsh's dotrap handles BOTH paths internally; Rust splits
14861    // them. Call dotrap to cover the function-named form. Bug
14862    // #157 in docs/BUGS.md.
14863    let exit_trap = traps_table().lock().ok().and_then(|mut t| t.remove("EXIT"));
14864    LASTVAL.store(val, Relaxed); // c:6076 — $? inside trap = val
14865    crate::ported::signals::in_exit_trap.store(1, Relaxed);
14866    if let Some(body) = exit_trap {
14867        // Set LASTVAL to the requested exit value so `$?` inside
14868        // the trap body sees the right number (matches `(exit 7)`
14869        // → trap body reads $?=7).
14870        let _ = crate::ported::exec::execute_script(&body);
14871    }
14872    // c:6043-6044 — `if (sigtrapped[SIGEXIT]) dotrap(SIGEXIT);`.
14873    //
14874    // dotrap DOES reach its ZSIG_FUNC arm here, but the body cannot run: by
14875    // this point we are outside the VM, and dotrapargs' doshfunc path has no
14876    // executor to run the compiled function chunk in, so it returns silently.
14877    // The natural end-of-script path already knows this and invokes the
14878    // function through the script pipeline instead (vm_helper.rs:2850-2854);
14879    // the explicit `exit` path did not, so
14880    //     TRAPEXIT() { print BYE }; exit 3
14881    // printed nothing where zsh prints BYE — while the string form
14882    // (`trap "print S" EXIT; exit 3`) worked, because that one is dispatched
14883    // from traps_table just above.
14884    let exit_fn_trapped = crate::ported::signals::sigtrapped
14885        .lock()
14886        .ok()
14887        .and_then(|g| g.get(crate::signals_h::SIGEXIT as usize).copied())
14888        .unwrap_or(0)
14889        & crate::ported::zsh_h::ZSIG_FUNC as i32
14890        != 0;
14891    if exit_fn_trapped {
14892        let _ = crate::ported::exec::execute_script("TRAPEXIT");
14893    } else {
14894        let _ = crate::ported::signals::dotrap(crate::signals_h::SIGEXIT);
14895    }
14896    // c:Src/init.c::zexit — `callhookfunc("zshexit", NULL, 1, NULL)`.
14897    // The hook fires both a `zshexit` function (if defined) AND
14898    // walks the `zshexit_functions` array. Distinct from SIGEXIT
14899    // trap dispatch above — zshexit is a zsh-specific shell-event
14900    // hook that operates parallel to trap. Bug #215 in docs/BUGS.md.
14901    let _ = crate::ported::utils::callhookfunc("zshexit", None, 1, std::ptr::null_mut());
14902    crate::ported::signals::in_exit_trap.store(0, Relaxed);
14903    realexit(); // c:6082
14904}
14905
14906/// Port of `bin_dot()` from `Src/builtin.c:6060`.
14907/// C decl: `bin_dot(char *name, char **argv, UNUSED(Options ops), UNUSED(int func))`
14908/// C: `int bin_dot(char *name, char **argv, ...)` — `.` / `source`
14909///   builtin: locate script (cwd → first `/`-bearing path → $path search)
14910///   and execute it; positional params shift to argv[1..].
14911/// WARNING: param names don't match C — Rust=(name, argv, _func) vs C=(name, argv, ops, func)
14912pub fn bin_dot(
14913    name: &str,
14914    argv: &[String], // c:6060
14915    _ops: &options,
14916    _func: i32,
14917) -> i32 {
14918    if argv.is_empty() {
14919        // c:6068
14920        return 0; // c:6069
14921    }
14922
14923    // PFA-SMR aspect: record the source path so the replay tool can
14924    // re-apply the same source/dot at the same call site.
14925    #[cfg(feature = "recorder")]
14926    if crate::recorder::is_enabled() && !argv[0].is_empty() {
14927        let ctx = crate::recorder::recorder_ctx_global();
14928        crate::recorder::emit_source(&argv[0], ctx);
14929    }
14930    // c:6071-6074 — save pparams, install argv[1..] as new pparams.
14931    // !!! DASH-ONLY (no C counterpart) !!! dash's `.` takes no positional
14932    // arguments: `. file a b` runs file with the CALLER's $1.. intact.
14933    let saved_pparams: Option<Vec<String>> = if argv.len() > 1 && !crate::dash_mode::dash_faithful() {
14934        // c:6072
14935        let mut pp = PPARAMS.lock().unwrap_or_else(|e| {
14936            PPARAMS.clear_poison();
14937            e.into_inner()
14938        });
14939        let saved = pp.clone();
14940        *pp = argv[1..].to_vec(); // c:6073
14941        Some(saved)
14942    } else {
14943        None
14944    };
14945
14946    let arg0 = argv[0].clone(); // c:6076
14947    let _enam = arg0.clone(); // c:6076
14948                              // c:6077-6080 — `if (isset(FUNCTIONARGZERO)) { old0 = argzero;
14949                              //                                              argzero = ztrdup(arg0); }`.
14950                              // Save the prior argzero so it can be restored at the end of
14951                              // bin_dot; under FUNCTIONARGZERO, the sourced file becomes the
14952                              // active $0 for the duration of the source.
14953    let saved_argzero: Option<Option<String>> = if isset(FUNCTIONARGZERO) {
14954        let prev = argzero();
14955        set_argzero(Some(arg0.clone()));
14956        Some(prev)
14957    } else {
14958        None
14959    };
14960    let mut diddot = 0i32; // c:6064
14961    let mut dotdot = 0i32; // c:6064
14962
14963    // c:6087-6093 — for `source`, try cwd first.
14964    let mut found_path: Option<String> = None;
14965    if !name.starts_with('.') {
14966        // c:6087
14967        let p = Path::new(&arg0);
14968        if p.exists() && !p.is_dir() {
14969            // c:6088-6089
14970            diddot = 1; // c:6090
14971            found_path = Some(arg0.clone()); // c:6091 (effective)
14972        }
14973    }
14974
14975    // c:6094-6101 — try literal path with `/` in it.
14976    if found_path.is_none() && arg0.contains('/') {
14977        // c:6096
14978        if arg0.starts_with("./") {
14979            diddot += 1;
14980        }
14981        // c:6097
14982        else if arg0.starts_with("../") {
14983            dotdot += 1;
14984        } // c:6098
14985        let p = Path::new(&arg0);
14986        if p.exists() && !p.is_dir() {
14987            found_path = Some(arg0.clone()); // c:6100
14988        } else if Path::new(&format!("{}.zwc", arg0)).is_file() {
14989            // c:6100 — C calls `source(arg0)` UNCONDITIONALLY for a
14990            // slash path; `source()` (init.c:1566) then loads the
14991            // sibling `<arg0>.zwc` via try_source_file even when the
14992            // plain file is gone. zshrs's pre-resolved found_path
14993            // gate must therefore accept the compiled form too —
14994            // the try_source_file hook at the read site below
14995            // supplies the body.
14996            found_path = Some(arg0.clone());
14997        }
14998    }
14999
15000    // c:6102-6121 — $path search (with PATHDIRS guard).
15001    let pathdirs = isset(PATHDIRS);
15002    if found_path.is_none() && (!arg0.contains('/') || (pathdirs && diddot < 2 && dotdot == 0)) {
15003        // c:6102
15004        // c:6103 — `for (pp = path; *pp; pp++)`. C walks the `path[]`
15005        //          array (the shell-side $path), not the colon-joined
15006        //          $PATH env. Read $PATH from paramtab (the shell
15007        //          string view); the colon-split below mirrors the C
15008        //          path[] iteration.
15009        let path_env = getsparam("PATH").unwrap_or_default();
15010        for dir in path_env.split(':') {
15011            // c:6107
15012            let buf = if dir.is_empty() || dir == "." {
15013                // c:6108
15014                if diddot != 0 {
15015                    continue;
15016                }
15017                diddot = 1; // c:6111
15018                arg0.clone() // c:6112
15019            } else {
15020                format!("{}/{}", dir, arg0) // c:6114
15021            };
15022            let p = Path::new(&buf);
15023            if p.exists() && !p.is_dir() {
15024                // c:6117-6118
15025                found_path = Some(buf); // c:6119
15026                break;
15027            }
15028        }
15029    }
15030
15031    // c:6125-6128 — restore pparams. C does this AFTER source() runs
15032    // so the sourced script sees the new positional args; bug #459 in
15033    // docs/BUGS.md — the previous Rust port restored here BEFORE the
15034    // file body executed, so `source script.zsh hello world` had the
15035    // script see $#=0 / $@="". Defer the restore until after
15036    // execute_script returns (the success path below + the error path
15037    // for file-not-found).
15038
15039    // c:6130-6137 — error path. C: `if (ret == SOURCE_NOT_FOUND)`
15040    // emits via zerrnam (POSIX) / zwarnnam (default). The Rust port
15041    // uses zwarnnam unconditionally because the POSIX hard-error
15042    // path also calls zerrnam which behaves identically here (both
15043    // route through zwarning); the only difference C makes is
15044    // promoting errflag to ERRFLAG_ERROR which already happens
15045    // inside zwarnnam.
15046    let path = match found_path {
15047        Some(p) => p,
15048        None => {
15049            // c:6149 — restore argzero on the error path BEFORE
15050            // emitting the diagnostic and returning. C's structure is
15051            // `source(); if (isset(FUNCTIONARGZERO)) restore`; for the
15052            // file-not-found early-return, the restore still has to
15053            // happen so the outer shell's $0 doesn't keep the dropped
15054            // arg0. Bug #103 in docs/BUGS.md: the previous port did
15055            // this unconditional pre-check restore which ran BEFORE
15056            // `execute_script` on the file-found branch — clobbering
15057            // the just-set arg0 so the sourced script saw the shell
15058            // binary as $0 instead of its own path.
15059            if let Some(prev) = saved_argzero.clone() {
15060                set_argzero(prev);
15061            }
15062            // c:6126-6128 — also restore pparams on the not-found path,
15063            // matching C's c:6125 unconditional cleanup before return.
15064            if let Some(saved) = saved_pparams.clone() {
15065                let mut pp = PPARAMS.lock().unwrap_or_else(|e| {
15066                    PPARAMS.clear_poison();
15067                    e.into_inner()
15068                });
15069                *pp = saved;
15070            }
15071            // c:6130-6137 — `if (isset(POSIXBUILTINS)) zerrnam(...)
15072            // else zwarnnam(...)`. zerrnam sets ERRFLAG_ERROR ("hard
15073            // error in POSIX (we'll exit later)" — the exec.c done:
15074            // gate at c:4378 turns it into exit(1) for the PSPECIAL
15075            // `.`); zwarnnam does NOT touch errflag, so without
15076            // POSIX_BUILTINS the script continues.
15077            let msg = format!("{}: {}", "no such file or directory", arg0); // c:6135
15078            // !!! BASH-MODE (no C counterpart) !!! non-posix bash reports the
15079            // missing file, returns 1 and keeps running (only `set -o posix`
15080            // makes it fatal); zsh's POSIX_BUILTINS aborts and returns 127.
15081            if crate::dash_mode::bash_mode() {
15082                zwarnnam(name, &msg);
15083                return 1;
15084            }
15085            if isset(crate::ported::zsh_h::POSIXBUILTINS) {
15086                crate::ported::utils::zerrnam(name, &msg); // c:6133
15087            } else {
15088                zwarnnam(name, &msg); // c:6135
15089            }
15090            // c:6143 — `return ret == SOURCE_OK ? lastval : 128 - ret`.
15091            // SOURCE_NOT_FOUND = 1 (Src/zsh.h:2214) → 128 - 1 = 127.
15092            return 128 - 1;
15093        }
15094    };
15095
15096    // c:6140 — `ret = source(enam = buf);`
15097    // C `source()` lives at Src/init.c:1550. It opens the file, sets
15098    // up sourcelevel + scriptname + funcstack, parses + executes via
15099    // the wordcode walker, then unwinds. Rust port reads the file
15100    // and routes the body through fusevm's `execute_script` — the
15101    // VM's parse + compile + run loop is the analog of C's
15102    // loop/execlist tree walk. Errors during execution propagate
15103    // through `lastval`; missing read returns SOURCE_ERROR (128-2 =
15104    // 126) per c:6143.
15105    //
15106    // crate::ported::init::sourcelevel bump (Src/init.c:1606 `sourcelevel++;` /
15107    // c:1644 `sourcelevel--;`) is REQUIRED for `return` inside the
15108    // sourced file to unwind correctly. bin_break (Src/builtin.c:5840)
15109    // checks `(interactive && shinstdin) || locallevel || sourcelevel`
15110    // — without the bump, `return N` falls through to `zexit(num,
15111    // ZEXIT_NORMAL)` (c:5858) and kills the entire shell instead of
15112    // unwinding to the source caller. Also clear RETFLAG after the
15113    // sourced script returns so the unwind doesn't propagate to the
15114    // outer compile unit.
15115    // c:Src/init.c:1557-1558 — `char *old_scriptname = scriptname;
15116    // char *old_scriptfilename = scriptfilename;`
15117    // c:1591-1592 — `scriptname = s; scriptfilename = s;`
15118    // For the duration of source execution, both scriptname and
15119    // scriptfilename point to the sourced file path. zerr/zwarnnam
15120    // diagnostics emitted from inside the sourced file then use this
15121    // path as the prefix (`/path/to/foo:N: ...` instead of `zsh:1:`).
15122    // Bug #139 in docs/BUGS.md. The `path` here is the resolved
15123    // absolute path; C uses the user-supplied `s` (the bin_dot
15124    // argv[0]) but `arg0` (the user-supplied name) is what zsh
15125    // actually shows in diagnostics, so use arg0.
15126    // c:Src/init.c:1566 — `source()` tries the compiled form FIRST:
15127    // `!(prog = try_source_file((us = unmeta(s)))) && (tempfd =
15128    // open(us, ...)) == -1` — a sibling `<file>.zwc` newer than the
15129    // file (or `s` itself being a `.zwc`) short-circuits the plain
15130    // read. zshrs executes through the fusevm text pipeline, so the
15131    // dump's wordcode bridges via getpermtext (same bridge as the
15132    // `.zwc` autoload path in exec.rs::loadautofn).
15133    // It runs BEFORE `scriptname = s` (c:1591), so a load diagnostic
15134    // such as parse.c:3746 check_dump_file's "invalid description" is
15135    // prefixed with the CALLER's scriptname (`zsh:21:`), not the file's.
15136    let zwc_src = crate::ported::parse::try_source_file(&path)
15137        .map(|prog| crate::ported::text::getpermtext(Box::new(prog), None, 0));
15138    let old_scriptname = crate::ported::utils::scriptname_get(); // c:1557
15139    let old_scriptfilename = crate::ported::utils::scriptfilename_get(); // c:1558
15140    crate::ported::utils::set_scriptname(Some(arg0.clone())); // c:1591
15141                                                              // c:1592 — `scriptfilename = s;` — PS4 `%x` reads this; without
15142                                                              // the store the bottom of an `xtrace` line stack stays "zsh"
15143                                                              // (the executor seed) even inside the sourced file. Bug shared
15144                                                              // with source_from_memory in bins/zshrs.rs.
15145    crate::ported::utils::set_scriptfilename(Some(arg0.clone())); // c:1592
15146
15147    // c:Src/init.c:1609 — `oloops = loops;` — and c:1622 `loops = 0;`.
15148    // `source` is a full loop-scope FIREWALL: the sourced file starts
15149    // with a zero nesting count no matter how deep the caller's loops
15150    // are, and the caller's count is put back at c:1694 below.
15151    //
15152    // Both halves are observable:
15153    //   * `loops = 0` on entry — `for i in 1 2; do source f; done` where
15154    //     `f` does a top-level `return` must still run BOTH iterations.
15155    //     `bin_break`'s BIN_RETURN arm sets `breaks = loops` (c:5835),
15156    //     so without the zeroing the return would set `breaks = 1` and
15157    //     the caller's `for` would drain it and stop after iteration 1.
15158    //     It is also why a bare `break` at a sourced file's top level
15159    //     errors with "not in while, until, select, or repeat loop"
15160    //     even when the `source` itself sits inside a loop.
15161    //   * `loops = oloops` on exit — the caller's own `break` keeps
15162    //     working after the source returns.
15163    let oloops = LOOPS.load(Relaxed); // c:1609
15164    LOOPS.store(0, Relaxed); // c:1622
15165
15166    // c:Src/init.c:1627-1630 — `if (isset(SOURCETRACE)) { printprompt4();
15167    //     fprintf(xtrerr ? xtrerr : stderr, "<sourcetrace>\n"); }`
15168    if isset(crate::ported::zsh_h::SOURCETRACE) {
15169        // c:1621 — `lineno = 1;` precedes the trace, so PS4 `%i` reads 1.
15170        // The file's own statements set it again as they run; put the
15171        // caller's value back for the frame push below.
15172        let oldlineno = crate::ported::params::getsparam("LINENO")
15173            .and_then(|s| s.parse::<i64>().ok())
15174            .unwrap_or(0);
15175        crate::fusevm_bridge::set_lineno_impl(1); // c:1621
15176        crate::ported::utils::printprompt4(); // c:1628
15177        crate::fusevm_bridge::xtrerr_fputs("<sourcetrace>\n"); // c:1629
15178        crate::fusevm_bridge::xtrerr_flush();
15179        crate::fusevm_bridge::set_lineno_impl(oldlineno);
15180    }
15181
15182    crate::ported::init::sourcelevel.fetch_add(1, Relaxed); // c:1606
15183
15184    // c:Src/init.c:1608-1616 — push a funcstack frame with
15185    // `tp = FS_SOURCE` so prompt %x / xtrace / `$funcfiletrace`
15186    // resolve to the sourced file, not the calling function's
15187    // defining file. Without this push, sourcing a script from
15188    // inside a function left the funcstack TOP as the function's
15189    // own FS_FUNC frame; the prompt %x handler then reported the
15190    // function's `filename` (where the function was DEFINED) as
15191    // the current source file. Repro: powerlevel10k.zsh-theme's
15192    // `__p9k_root_dir=${${(%):-%x}:A:h}` resolved to BIN_DIR
15193    // (zinit.zsh's dir) instead of the theme's plugin dir, so the
15194    // theme then sourced `$BIN_DIR/internal/p10k.zsh` (no such
15195    // file) instead of `$THEME_DIR/internal/p10k.zsh`.
15196    // c:Src/init.c:1612 — `fstack.lineno = oldlineno;` where `oldlineno`
15197    // was captured from the ONE `lineno` global (c:1608). zshrs mirrors
15198    // that global in several places; the counter `BUILTIN_SET_LINENO`
15199    // actually drives per statement is `lex::LEX_LINENO` — the same one
15200    // `init::source` (init.rs:1614) and `doshfunc` (exec.rs:6938) read for
15201    // their frames. Reading `input::lineno` here instead picked up a stale
15202    // mirror, so `$functrace` inside a sourced file reported the caller as
15203    // `file:1` instead of the line the `.` was on.
15204    let prev_funcstack_lineno = crate::ported::lex::lineno() as i64;
15205    let pushed_frame = {
15206        let mut stack = crate::ported::modules::parameter::FUNCSTACK
15207            .lock()
15208            .unwrap_or_else(|e| {
15209                crate::ported::modules::parameter::FUNCSTACK.clear_poison();
15210                e.into_inner()
15211            });
15212        let prev_caller = stack
15213            .last()
15214            .map(|fs| fs.name.clone())
15215            .or_else(|| old_scriptfilename.clone())
15216            .or_else(|| Some("zsh".to_string()));
15217        let frame = crate::ported::zsh_h::funcstack {
15218            prev: None,
15219            name: arg0.clone(),            // c:1608 fstack.name = scriptfilename
15220            filename: Some(arg0.clone()),  // c:1613 fstack.filename = scriptfilename
15221            caller: prev_caller,           // c:1609
15222            flineno: 0,                    // c:1611
15223            lineno: prev_funcstack_lineno, // c:1612
15224            tp: crate::ported::zsh_h::FS_SOURCE, // c:1615
15225        };
15226        stack.push(frame);
15227        true
15228    };
15229
15230    // c:Src/init.c:1620 — `errflag &= ~ERRFLAG_ERROR;` before
15231    // executing the sourced body, so a stale flag from the caller's
15232    // context can't abort the file's first list.
15233    crate::ported::utils::errflag.fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Relaxed);
15234    // Recursion backstop — c:Src/jobs.c:1878-1884. zsh runs the sourced
15235    // list through execpline, whose per-pipeline `initjob()` caps recursion
15236    // at MAX_MAXJOBS: `zerr("job table full or recursion limit exceeded")`
15237    // then bails. The fusevm pipeline path (execute_script below) doesn't
15238    // allocate a job per pipeline, so without this guard runaway
15239    // `. self`-style recursion — invisible to FUNCNEST, which counts only
15240    // FS_FUNC frames (doshfunc, exec.rs:5684) — grew the FS_SOURCE stack
15241    // unbounded and overflowed the 256 MB main-thread stack → uncatchable
15242    // SIGBUS. Total FUNCSTACK depth (incl. the FS_SOURCE frame just pushed)
15243    // is the proxy for zsh's concurrently-held job slots; at/over the
15244    // ceiling, raise the zsh-identical error and refuse the deeper body
15245    // (SOURCE_ERROR → 126, as zsh's bail returns). The frame is popped below.
15246    let over_limit = crate::ported::modules::parameter::FUNCSTACK
15247        .lock()
15248        .map(|s| s.len())
15249        .unwrap_or(0)
15250        >= crate::ported::jobs::MAX_MAXJOBS;
15251    // c:Src/init.c:220 — `execode(prog, 0, 0, toplevel ? "toplevel" : "file");`
15252    // A SOURCED file is the `"file"` arm, so its body runs with `file`
15253    // appended to `zsh_eval_context`: `toplevel:file` at top level,
15254    // `toplevel:shfunc:file` inside a function, `toplevel:eval:file` inside an
15255    // eval. zshrs pushed nothing here, so the component was missing at every
15256    // depth. funcstack was already correct (the sourced path pushes its own
15257    // FS_SOURCE frame just below) — only the eval-context entry was absent.
15258    // Popped on every return path by the guard. Bug #1067.
15259    let sync_eval_ctx = |stack: &[String]| {
15260        crate::ported::exec::EvalContextFrame::sync(stack);
15261    };
15262    if let Ok(mut ctx) = crate::ported::exec::zsh_eval_context.lock() {
15263        ctx.push("file".to_string());
15264        sync_eval_ctx(&ctx);
15265    }
15266    struct SourceCtxGuard<F: Fn(&[String])>(F);
15267    impl<F: Fn(&[String])> Drop for SourceCtxGuard<F> {
15268        fn drop(&mut self) {
15269            if let Ok(mut ctx) = crate::ported::exec::zsh_eval_context.lock() {
15270                ctx.pop();
15271                (self.0)(&ctx);
15272            }
15273        }
15274    }
15275    let _source_ctx_guard = SourceCtxGuard(sync_eval_ctx);
15276    let mut result = if over_limit {
15277        crate::ported::utils::zerr("job table full or recursion limit exceeded");
15278        128 - 2 // c:6143 — SOURCE_ERROR = 2 → 126
15279    } else {
15280        // c:Src/init.c:1618-1641 — source() has TWO arms and they are not
15281        // interchangeable:
15282        //   * `if (prog) { … execode(prog, 1, 0, "filecode"); … }` (c:1618-1622)
15283        //     — the file was ALREADY COMPILED (`try_source_file` found a
15284        //     `.zwc` the user made with `zcompile`), so there is nothing left
15285        //     to lex and the whole program runs at once.
15286        //   * `else { /* loop through the file to be sourced */ switch
15287        //     (loop(0, 0)) … }` (c:1625-1641) — a plain text file is parsed
15288        //     and executed ONE EVENT AT A TIME.
15289        // The second arm is load-bearing: `loop()` re-enters the lexer after
15290        // every command, so an `alias` (or `setopt rcquotes`, c:Src/lex.c:1326)
15291        // that a line INSTALLS is in force when the NEXT line is lexed.
15292        // Compiling the whole file first — which is what `execute_script`
15293        // does — lexes every line with the state the file started with, so
15294        // `alias greet=…` on line 1 followed by `greet` on line 2 reported
15295        // `command not found`. Bug #1087.
15296        match zwc_src {
15297            // c:1621 prog path — `execode(prog, 1, 0, "filecode")`. The
15298            // deparse is re-lexed, so it goes through the zwc entry point
15299            // that pins the lexer to the spelling `untokenize` produced.
15300            Some(src) => crate::fusevm_bridge::execute_zwc_program(&src).unwrap_or(1),
15301            // c:1626 — the sourced file is read as RAW BYTES; a non-UTF-8
15302            // byte in it is metafied (Src/utils.c:4856), never an error.
15303            // `read_to_string` failed the whole read instead, and this arm
15304            // turned that into a SILENT `return 126` — a completion file
15305            // with one legacy byte sourced to nothing at all.
15306            None => match crate::script_bytes::read_script_file(&path) {
15307                // c:1626-1627 — `switch (loop(0, 0))`
15308                Ok(src) => crate::fusevm_bridge::source_file_per_command(&src).unwrap_or(1),
15309                // c:6143 — SOURCE_ERROR = 2 (Src/zsh.h:2216) → 128 - 2 = 126.
15310                Err(_) => 128 - 2,
15311            },
15312        }
15313    };
15314    // c:Src/init.c:1623-1624 — `if (errflag) ret = SOURCE_ERROR;`
15315    // c:Src/init.c:1663 — `errflag &= ~ERRFLAG_ERROR;` on the restore
15316    // path. source is a CONTAINMENT boundary: an errflag abort inside
15317    // the sourced file breaks ITS lists, then source() clears the
15318    // flag, bin_dot returns 128 - SOURCE_ERROR = 126 (c:Src/builtin.c
15319    // :6143), and the CALLER continues. zsh 5.9: parent script
15320    // sourcing a file that hits `readonly` reassign prints its next
15321    // line with $? = 126.
15322    if (crate::ported::utils::errflag.load(Relaxed) & crate::ported::zsh_h::ERRFLAG_ERROR) != 0 {
15323        crate::ported::utils::errflag.fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Relaxed);
15324        result = 128 - 2; // c:6143 — 128 - SOURCE_ERROR
15325    }
15326    if pushed_frame {
15327        // c:1643 — `funcstack = funcstack->prev;`
15328        let mut stack = crate::ported::modules::parameter::FUNCSTACK
15329            .lock()
15330            .unwrap_or_else(|e| {
15331                crate::ported::modules::parameter::FUNCSTACK.clear_poison();
15332                e.into_inner()
15333            });
15334        stack.pop();
15335    }
15336    crate::ported::init::sourcelevel.fetch_sub(1, Relaxed); // c:1644
15337                                                            // c:1666 — `scriptname = old_scriptname;` and matching
15338                                                            // scriptfilename restore from the c:1667 line.
15339    crate::ported::utils::set_scriptname(old_scriptname);
15340    crate::ported::utils::set_scriptfilename(old_scriptfilename);
15341    // c:Src/init.c:1694 — `loops = oloops;` — put the caller's loop
15342    // nesting count back (see the c:1609/1622 note at the entry half).
15343    LOOPS.store(oloops, Relaxed); // c:1694
15344
15345    // c:Src/init.c:1697-1698 — `if (!exit_pending) retflag = 0;`
15346    // RETFLAG is set by bin_break's BIN_RETURN arm (c:5834). Once the
15347    // sourced file's execute_script unwinds, the return has been
15348    // serviced; clear the flag so the outer compile unit's main loop
15349    // (init.rs:1252's `if retflag break` guard) doesn't see a stale
15350    // request and abort `echo done` after `source foo`.
15351    //
15352    // The `exit_pending` guard is C's, not an approximation: `exit`
15353    // inside a function sets retflag ALONG WITH exit_pending
15354    // (c:5874-5878) precisely so the unwind keeps propagating past
15355    // every enclosing scope until the deferred exit is honoured.
15356    // Clearing retflag unconditionally would strand that exit at the
15357    // `source` boundary.
15358    if EXIT_PENDING.load(Relaxed) == 0 {
15359        // c:1697
15360        RETFLAG.store(0, Relaxed); // c:1698
15361    }
15362
15363    // c:6149 again — restore argzero on the success path as well.
15364    if let Some(prev) = saved_argzero {
15365        set_argzero(prev);
15366    }
15367    // c:6125-6128 — restore pparams on the success path AFTER the
15368    // source body has finished executing. Bug #459.
15369    if let Some(saved) = saved_pparams {
15370        let mut pp = PPARAMS.lock().unwrap_or_else(|e| {
15371            PPARAMS.clear_poison();
15372            e.into_inner()
15373        });
15374        *pp = saved;
15375    }
15376    result
15377}
15378
15379/// Port of `eval()` from `Src/builtin.c:6151`.
15380/// C decl: `eval(char **argv)`
15381pub fn eval(argv: &[String]) -> i32 {
15382    // c:6151
15383    // c:6153 — `Eprog prog;` (declared inline below)
15384    // c:6154 — `char *oscriptname = scriptname;`
15385    let oscriptname: Option<String> = scriptname_get();
15386    // c:6155 — `int oineval = ineval, fpushed;`
15387    let oineval: i32 = INEVAL.load(Relaxed);
15388    let fpushed: bool;
15389    // c:6156 — `struct funcstack fstack;`
15390
15391    // c:6163 — `ineval = !isset(EVALLINENO);`
15392    INEVAL.store(
15393        if !isset(crate::ported::zsh_h::EVALLINENO) {
15394            1
15395        } else {
15396            0
15397        },
15398        Relaxed,
15399    );
15400    let ineval_now = INEVAL.load(Relaxed) != 0;
15401
15402    if !ineval_now {
15403        // c:6164
15404        // c:6165 — `scriptname = "(eval)";`
15405        crate::ported::utils::set_scriptname(Some("(eval)".to_string()));
15406        // c:6166-6196 — funcstack push: build a fstack frame describing
15407        // this eval, link it to the head of FUNCSTACK.
15408        let prev_frame = {
15409            let stack = FUNCSTACK.lock().unwrap_or_else(|e| e.into_inner());
15410            stack.last().cloned()
15411        };
15412        let lineno_now = crate::ported::input::lineno.with(|c| c.get()) as i64;
15413        let caller = match &prev_frame {
15414            Some(p) => Some(p.name.clone()),
15415            None => argzero(), // c:6168 dupstring(argzero)
15416        };
15417
15418        // c:6182-6196 — flineno/filename derivation. Three cases:
15419        //   1. no prev frame OR prev tp == FS_SOURCE: flineno=lineno,
15420        //      filename=caller (the source name)
15421        //   2. prev tp == FS_EVAL: flineno = prev.flineno + lineno - 1
15422        //   3. otherwise (function): flineno = prev.flineno + lineno,
15423        //      filename = prev.filename or ""
15424        let (flineno, filename): (i64, Option<String>) = match &prev_frame {
15425            None => (lineno_now, caller.clone()), // c:6183-6184
15426            Some(p) if p.tp == crate::ported::zsh_h::FS_SOURCE => {
15427                (lineno_now, caller.clone()) // c:6183-6184
15428            }
15429            Some(p) => {
15430                let mut fl = p.flineno + lineno_now; // c:6186
15431                if p.tp == crate::ported::zsh_h::FS_EVAL {
15432                    // c:6191
15433                    fl -= 1; // c:6192
15434                }
15435                let fname = p.filename.clone().or_else(|| Some(String::new())); // c:6193-6195
15436                (fl, fname)
15437            }
15438        };
15439        let frame = crate::ported::zsh_h::funcstack {
15440            prev: None,                        // c:1349 — linked via FUNCSTACK vec
15441            name: "(eval)".to_string(),        // c:6167
15442            filename,                          // c:6184 / c:6193
15443            caller,                            // c:6168
15444            flineno,                           // c:6183 / c:6186
15445            lineno: lineno_now,                // c:6169
15446            tp: crate::ported::zsh_h::FS_EVAL, // c:6170
15447        };
15448        {
15449            let mut stack = FUNCSTACK.lock().unwrap_or_else(|e| e.into_inner());
15450            stack.push(frame); // c:6197 funcstack = &fstack
15451        }
15452        fpushed = true; // c:6199
15453    } else {
15454        fpushed = false; // c:6201
15455    }
15456
15457    // c:6203 — `prog = parse_string(zjoin(argv, ' ', 1), 1);`
15458    let joined = crate::ported::utils::zjoin(argv, ' ');
15459    let prog = crate::ported::exec::parse_string(&joined, 1);
15460
15461    if let Some(prog) = prog {
15462        // c:6205 — `if (wc_code(*prog->prog) != WC_LIST)`
15463        let head = prog.prog.first().copied().unwrap_or(0);
15464        if crate::ported::zsh_h::wc_code(head) != crate::ported::zsh_h::WC_LIST as u32 {
15465            /* No code to execute */
15466            // c:6206
15467            LASTVAL.store(0, Relaxed); // c:6207
15468        } else {
15469            // c:6209 — `execode(prog, 1, 0, "eval");`. Routes through
15470            // the executor; in-process equivalent.
15471            //
15472            // PREVIOUSLY called run_command_substitution which captures
15473            // stdout into a String and returns it — bin_eval threw the
15474            // capture away, so `eval 'echo hi'` produced no output.
15475            // execute_script_zsh_pipeline runs the script with stdout
15476            // flowing to the caller (no capture) which is what eval
15477            // wants. Same routing the eval-via-execstring path uses
15478            // (vm_helper.rs:1518 EXIT-trap fire).
15479            //
15480            // Recursion backstop — c:Src/jobs.c:1878-1884. zsh's per-pipeline
15481            // `initjob()` caps eval recursion at MAX_MAXJOBS ("job table full
15482            // or recursion limit exceeded"); the fusevm pipeline path doesn't
15483            // allocate a job per pipeline, so runaway `eval`-string recursion
15484            // (invisible to FUNCNEST, which counts FS_FUNC frames only, AND to
15485            // the FS_EVAL frame count — nested evals suppress the funcstack
15486            // push via INEVAL, c:6164, exactly like zsh) is otherwise unbounded
15487            // → main-thread stack overflow → SIGBUS. Track eval re-entry depth
15488            // directly (the Rust proxy for zsh's concurrently-held job slots,
15489            // one per nested eval pipeline) and cap at the same MAX_MAXJOBS
15490            // ceiling. Requested as a parity fix by the maintainer.
15491            let eval_depth = crate::vm_helper::EVAL_RECURSION_DEPTH.with(|d| {
15492                let v = d.get() + 1;
15493                d.set(v);
15494                v
15495            });
15496            if eval_depth >= crate::ported::jobs::MAX_MAXJOBS {
15497                crate::ported::utils::zerr("job table full or recursion limit exceeded");
15498                LASTVAL.store(1, Relaxed);
15499            } else {
15500                // c:6209 — `execode(prog, 1, 0, "eval")`. The `"eval"`
15501                // frame is pushed by the live BUILTIN_EVAL path
15502                // (fusevm_bridge.rs, `EvalContextFrame::push("eval")`)
15503                // that `execute_script_zsh_pipeline` routes through;
15504                // pushing it here as well would double it.
15505                let _ = crate::ported::exec::execute_script_zsh_pipeline(&joined);
15506            }
15507            crate::vm_helper::EVAL_RECURSION_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
15508            // c:6211-6212 — `if (errflag && !lastval) lastval = errflag;`
15509            let ef = errflag.load(Relaxed);
15510            let lv = LASTVAL.load(Relaxed);
15511            if ef != 0 && lv == 0 {
15512                LASTVAL.store(ef, Relaxed);
15513            }
15514        }
15515    } else {
15516        LASTVAL.store(1, Relaxed); // c:6215
15517    }
15518
15519    if fpushed {
15520        // c:6218
15521        // c:6219 — `funcstack = funcstack->prev;`
15522        let mut stack = FUNCSTACK.lock().unwrap_or_else(|e| e.into_inner());
15523        stack.pop();
15524    }
15525
15526    // c:6221 — `errflag &= ~ERRFLAG_ERROR;`
15527    errflag.fetch_and(!ERRFLAG_ERROR, Relaxed);
15528    // The executor's NOMATCH cell is the Rust half of that error state
15529    // (set beside `zerr` for c:Src/glob.c:1877); clear it with the bit, or
15530    // an `x=( *nomatch )` in the body skips the caller's NEXT command.
15531    let _ = crate::fusevm_bridge::try_with_executor(|exec| exec.current_command_glob_failed.set(false));
15532    // c:6222 — `scriptname = oscriptname;`
15533    crate::ported::utils::set_scriptname(oscriptname);
15534    // c:6223 — `ineval = oineval;`
15535    INEVAL.store(oineval, Relaxed);
15536
15537    LASTVAL.load(Relaxed) // c:6225
15538}
15539
15540/// Port of `bin_emulate()` from `Src/builtin.c:6232`.
15541/// C decl: `bin_emulate(char *nam, char **argv, Options ops, UNUSED(int func))`
15542/// C: `int bin_emulate(char *nam, char **argv, Options ops, ...)` —
15543///   no-args print current emulation; single-arg switch emulation;
15544///   `-l` list, `-L` set LOCAL*, `-R` reset to defaults.
15545/// WARNING: param names don't match C — Rust=(nam, argv, _func) vs C=(nam, argv, ops, func)
15546pub fn bin_emulate(
15547    nam: &str,
15548    argv: &[String], // c:6232
15549    ops: &options,
15550    _func: i32,
15551) -> i32 {
15552    let opt_l = OPT_ISSET(ops, b'l'); // c:6236
15553    let opt_l_arg = OPT_ISSET(ops, b'L'); // c:6234
15554    let opt_r = OPT_ISSET(ops, b'R'); // c:6235
15555
15556    // c:6249-6275 — no args: print current emulation name.
15557    if argv.is_empty() {
15558        // c:6249
15559        if opt_l_arg || opt_r {
15560            // c:6250
15561            zwarnnam(nam, "not enough arguments"); // c:6251
15562            return 1; // c:6252
15563        }
15564        // c:6255-6271 — `switch(SHELL_EMULATION())` → name dispatch.
15565        let bits = emulation.load(Relaxed) as i32;
15566        let shname = if (bits & EMULATE_CSH) != 0 {
15567            "csh"
15568        }
15569        // c:6255
15570        else if (bits & EMULATE_KSH) != 0 {
15571            "ksh"
15572        }
15573        // c:6259
15574        else if (bits & EMULATE_SH) != 0 {
15575            "sh"
15576        }
15577        // c:6263
15578        else {
15579            "zsh"
15580        }; // c:6268
15581        println!("{}", shname); // c:6273
15582        return 0; // c:6274
15583    }
15584
15585    // c:6278-6295 — single-arg form: `emulate <shname>`.
15586    let shname = &argv[0];
15587    if argv.len() == 1 {
15588        // c:6278
15589        // c:6280-6285 — `if (opt_l) cmdopts = zhalloc(...); else cmdopts = opts;`
15590        // In our static-link port, the live option table IS the
15591        // "real opts"; under -l we build a snapshot HashMap and
15592        // mutate THAT instead of touching global state. Under
15593        // !-l we apply emulate semantics to the live table.
15594        // c:537-549 — C `emulate(zsh_name, ...)` reads ONLY the first
15595        // char (after stripping a leading `r` for rcsh/rksh): 'c'
15596        // → CSH, 'k' → KSH, 's'/'b' → SH (so `bash` aliases to sh),
15597        // else ZSH. Previous Rust port did full-string equality so
15598        // `emulate rcsh` / `emulate bash` silently fell back to ZSH.
15599        let bytes = shname.as_bytes();
15600        let mut ch = if !bytes.is_empty() { bytes[0] } else { 0 };
15601        if ch == b'r' && bytes.len() >= 2 {
15602            // c:539
15603            ch = bytes[1]; // c:540
15604        }
15605        let bits = match ch {
15606            // c:543
15607            b'c' => EMULATE_CSH,       // c:544
15608            b'k' => EMULATE_KSH,       // c:546
15609            b's' | b'b' => EMULATE_SH, // c:548
15610            _ => EMULATE_ZSH,          // c:550
15611        };
15612        // c:6286 — `emulate(shname, opt_R, &emulation, cmdopts)`.
15613        emulation.store(bits, Relaxed);
15614
15615        // c:6270-6276 — `if (opt_l) { cmdopts = zhalloc(OPT_SIZE);
15616        // memcpy(cmdopts, opts, OPT_SIZE); } else cmdopts = opts;`. Only -l
15617        // works on a COPY; every other form applies the emulation to the live
15618        // `opts[]` and never reads a copy back. The copy below (two walks of
15619        // every option, one before and one after emulate) is therefore built
15620        // only for -l. Building it unconditionally made each `emulate -L zsh`
15621        // — the first line of compdef, run once per `#compdef` file by
15622        // compinit — pay two full option-table walks for nothing.
15623        if !opt_l {
15624            crate::ported::options::emulate(shname.as_str(), opt_r); // c:6277
15625            if opt_l_arg {
15626                // c:6278-6280 — `cmdopts[LOCALOPTIONS] = cmdopts[LOCALTRAPS] =
15627                // cmdopts[LOCALPATTERNS] = 1;` on the live array.
15628                for nm in ["localoptions", "localtraps", "localpatterns"] {
15629                    crate::ported::options::opt_state_set(nm, true);
15630                }
15631            }
15632            crate::ported::pattern::clearpatterndisables(); // c:6285
15633            return 0; // c:6286
15634        }
15635
15636        // Build the cmdopts view that c:6286-6292 manipulates (-l only).
15637        let mut cmdopts: HashMap<String, bool> = HashMap::new();
15638        for n in ZSH_OPTIONS_SET.iter() {
15639            cmdopts.insert(
15640                n.to_string(),
15641                crate::ported::options::opt_state_get(n).unwrap_or(false),
15642            );
15643        }
15644        // c:6286 — `emulate(shname, opt_R, &emulation, cmdopts)` ALSO
15645        // populates `cmdopts` (when !opt_l, `cmdopts` aliases the live
15646        // `opts[]`). The Rust port has a per-emulation defaults walker
15647        // at options::emulate() that writes through to OPTS_LIVE — call
15648        // it here so `emulate -L sh` flips KSH_ARRAYS / SH_NULLCMD /
15649        // POSIX_ALIASES / etc. to their target-emulation defaults.
15650        //
15651        // Bug #26 in docs/BUGS.md — without this, `emulate -L sh`
15652        // changed the emulation tag but left every option at its zsh
15653        // default, so `a=(1 2 3); echo ${a[0]}` stayed 1-indexed
15654        // (empty result) instead of switching to KSH_ARRAYS 0-indexed.
15655        // c:6285 — `emulate(shname, opt_R, &emulation, cmdopts);` runs
15656        // UNCONDITIONALLY. C can do that because it passes the target array in:
15657        // under -l cmdopts is a COPY of opts (c:6281-6282) and the emulation is
15658        // applied to the copy; otherwise cmdopts aliases the live opts[].
15659        //
15660        // options::emulate() has no such parameter — it writes through to
15661        // OPTS_LIVE — so the -l case is expressed by applying it to the live
15662        // state, snapshotting the result, and restoring. Same effect as C's
15663        // copy: `emulate -l sh` reports what the options WOULD be, and the
15664        // shell is left untouched.
15665        //
15666        // Skipping the call under -l (as this did) meant cmdopts stayed the
15667        // CURRENT options, so `emulate -l sh` listed zsh's defaults, not sh's —
15668        // ~38 options wrong. It looked right only for `emulate -l zsh`, where
15669        // the current options ARE the answer.
15670        let saved_opts = if opt_l {
15671            Some(crate::ported::options::opt_state_snapshot())
15672        } else {
15673            None
15674        };
15675        crate::ported::options::emulate(shname.as_str(), opt_r); // c:6285
15676                                                                 // Re-sync cmdopts from OPTS_LIVE so it reflects the emulation's
15677                                                                 // defaults rather than the pre-emulate state captured above.
15678        for n in ZSH_OPTIONS_SET.iter() {
15679            cmdopts.insert(
15680                n.to_string(),
15681                crate::ported::options::opt_state_get(n).unwrap_or(false),
15682            );
15683        }
15684        if let Some(saved) = saved_opts {
15685            // Undo the live apply — C never touched opts[] in the -l path.
15686            crate::ported::options::opt_state_restore(saved);
15687        }
15688
15689        // c:6287-6289 — opt_L: set LOCALOPTIONS/LOCALTRAPS/LOCALPATTERNS=1
15690        // in cmdopts. In the !opt_l live-apply case we also set them in
15691        // OPTS_LIVE; in the opt_l snapshot case we only set them in
15692        // cmdopts (the snapshot the list call walks).
15693        if opt_l_arg {
15694            // c:6287
15695            for nm in ["localoptions", "localtraps", "localpatterns"] {
15696                cmdopts.insert(nm.to_string(), true);
15697                if !opt_l {
15698                    crate::ported::options::opt_state_set(nm, true);
15699                }
15700            }
15701        }
15702        if opt_l {
15703            // c:6290
15704            // c:6291 — `list_emulate_options(cmdopts, opt_R);`
15705            crate::ported::options::list_emulate_options(&cmdopts, opt_r);
15706            return 0; // c:6292
15707        }
15708        // c:6294 — `clearpatterndisables();` resets the per-pattern
15709        // disabled-feature bitset that a previous emulation may have
15710        // left in place.
15711        crate::ported::pattern::clearpatterndisables();
15712        return 0; // c:6295
15713    }
15714
15715    // c:6297-6300 — too many args under -l.
15716    if opt_l {
15717        // c:6297
15718        zwarnnam(nam, "too many arguments for -l"); // c:6298
15719        return 1; // c:6299
15720    }
15721
15722    // c:6302-6342 — `emulate <shname> [-o OPT|+o OPT|-LR ...]` per-command
15723    // form. C: `argv++; emulate(shname, opt_R, ...); parseopts(...);` —
15724    // switch emulation, then parse the remaining tokens as long-form
15725    // options that apply ON TOP of the emulation defaults. zinit's
15726    // +zi-log (zinit.zsh:2178) calls
15727    //   `emulate -LR zsh -o extendedglob`
15728    // which requires extendedglob to be set after the zsh-emulation
15729    // reset, so the (#b)/(#m) flags in subsequent pattern subst calls
15730    // are recognized. The previous Rust port returned 0 without
15731    // applying ANY of the trailing options, so extendedglob stayed off
15732    // and `${msg//(#b)…/…}` errored with `bad pattern: …`.
15733    //
15734    let _ = opt_r;
15735    // c:6303 — `memcpy(saveopts, opts, sizeof(opts));` + c:6326
15736    // `saveemulation = emulation;` — snapshot the live option table
15737    // and both emulation cells BEFORE applying the new emulation.
15738    // C builds the new state in a LOCAL `new_opts` and only commits
15739    // it at c:6328; the Rust port writes the live table directly, so
15740    // this snapshot is the rollback for the `-c` eval (restore2 at
15741    // c:6377-6379) and the parse-error path (goto restore, c:6311).
15742    // Without the restore, `emulate sh -c 'f() { ...; }'` left the
15743    // shell PERMANENTLY in sh-emulation option state — a later
15744    // `bad substitution` zerr then aborted the whole non-interactive
15745    // shell (D04parameter chunk 159 poisoned chunk 225 into a
15746    // shell-exit).
15747    let saveopts = crate::ported::options::opt_state_snapshot(); // c:6303
15748    let saveemulation = emulation.load(Relaxed); // c:6326
15749    let saveemulation_live = crate::ported::options::EMULATION.load(Relaxed); // c:6326 (port keeps 2 cells)
15750    let savehackchar = *crate::ported::params::keyboardhack_lock().lock().unwrap(); // c:6305
15751    // c:6381-6382 `restore:` — `keyboardhackchar = savehackchar; inittyptab();`
15752    let restore_hackchar = || {
15753        *crate::ported::params::keyboardhack_lock().lock().unwrap() = savehackchar; // c:6381
15754        crate::ported::utils::inittyptab(); // c:6382 restore banghist
15755    };
15756                                                                              // c:6306 — `emulate(shname, opt_R, &new_emulation, new_opts);` —
15757                                                                              // apply emulation defaults to the live opts table.
15758    crate::ported::options::emulate(shname.as_str(), opt_r);
15759
15760    // c:6308 — `parseopts(nam, &argv, new_opts, &cmd, optlist, 0)`.
15761    // C body at Src/init.c:390+:
15762    //   while (!optionbreak && *argv && (**argv == '-' || **argv == '+')) {
15763    //       action = (**argv == '-');
15764    //       while (*++*argv) {
15765    //           if (**argv == '-') { /* `--` ends options; `--NAME` long form */ }
15766    //           else if (**argv == 'c') { *cmdp = *argv; }
15767    //           else if (**argv == 'o') {
15768    //               if (!*++*argv) argv++;   // `-o NAME` separate-arg
15769    //               if (!*argv) { WARN("string expected after -o"); return 1; }
15770    //               longoptions: optno = optlookup(*argv);
15771    //               if (!optno) { WARN("no such option: %s", *argv); return 1; }
15772    //               dosetopt(optno, action, ...);
15773    //               break;
15774    //           }
15775    //           else {  /* single-char option letter, e.g. 'L', 'R' */
15776    //               optno = optlookupc(**argv);
15777    //               dosetopt(optno, action, ...);
15778    //           }
15779    //       }
15780    //       argv++;
15781    //   }
15782    //
15783    // Faithful port: walk each arg, parse `-`/`+` action sign, then
15784    // walk char-by-char. `--` ends options; `-o NAME` / `+o NAME`
15785    // long-form (NAME inline-attached or separate arg); single-char
15786    // option letters set/unset via `dosetopt` (Rust:
15787    // `opt_state_set`). `-c command` records the cmd-eval mode (not
15788    // wired through here — bin_emulate uses opt_L for localoptions
15789    // only).
15790    let mut i = 1; // skip shname (argv[0])
15791    let mut optionbreak = false;
15792    let mut cmd_body: Option<String> = None; // c:6310 — `-c command` capture.
15793                                             // c:6308 `optlist` — parseopts records each explicitly-set option
15794                                             // (as an index into new_opts) so the `-c` arm can build the
15795                                             // sticky struct's on_opts/off_opts at c:6347-6373. Rust tracks
15796                                             // (canonical-name, on?) pairs.
15797    let mut optlist: Vec<(String, bool)> = Vec::new();
15798    // c:6309-6311 — `if (parseopts(...)) { ret = 1; goto restore; }`. C's
15799    // parseopts wrote only the local new_opts; this port writes the live
15800    // table, so the failure path rolls the snapshot back.
15801    let parse_fail = || -> i32 {
15802        emulation.store(saveemulation, Relaxed);
15803        crate::ported::options::EMULATION.store(saveemulation_live, Relaxed);
15804        crate::ported::options::opt_state_restore(saveopts.clone());
15805        restore_hackchar();
15806        1
15807    };
15808    // !!! RUST-ONLY ADAPTER !!! — C's `optletters` (c:Src/options.c:287)
15809    // tests SHOPTIONLETTERS in the LIVE opts, which parseopts never writes
15810    // (it fills new_opts); this port has already applied the emulation to
15811    // the live table, so letters are looked up with the value it had
15812    // before: `emulate sh -L` resolves `L` through zsh's letters, not sh's.
15813    let pre_sol = saveopts.get("shoptionletters").copied().unwrap_or(false);
15814    let optlookupc_pre = |c: char| -> i32 {
15815        let now = crate::ported::zsh_h::isset(crate::ported::zsh_h::SHOPTIONLETTERS);
15816        crate::ported::options::opt_state_set("shoptionletters", pre_sol);
15817        let r = crate::ported::options::optlookupc(c);
15818        crate::ported::options::opt_state_set("shoptionletters", now);
15819        r
15820    };
15821    while !optionbreak && i < argv.len() {
15822        let arg = &argv[i];
15823        // c:Src/init.c:418 — only `-` / `+` start an option arg.
15824        let first = arg.chars().next().unwrap_or('\0');
15825        if first != '-' && first != '+' {
15826            break;
15827        }
15828        let action = first == '-'; // c:420
15829                                   // c:421-422 — bare `-` / `+` treated as `--` (end-of-options
15830                                   // marker preserved as-is for caller).
15831        if arg.len() == 1 {
15832            i += 1;
15833            break;
15834        }
15835        // Walk chars after the leading +/-.
15836        let bytes: Vec<char> = arg.chars().collect();
15837        let mut j = 1;
15838        let mut consumed_next_arg = false;
15839        while j < bytes.len() {
15840            let ch = bytes[j];
15841            if ch == '-' {
15842                // c:Src/init.c:424-429 — "The pseudo-option `--' signifies
15843                // the end of options."
15844                if j == 1 && bytes.len() == 2 {
15845                    optionbreak = true; // the `i += 1` after this walk steps past it
15846                    break;
15847                }
15848                // c:Src/init.c:430-431 — `if (!toplevel || …) goto
15849                // badoptionstring;`: GNU-style long options belong to the
15850                // shell's own command line only.
15851                zwarnnam(nam, &format!("bad option string: '{}'", arg)); // c:520
15852                return parse_fail(); // c:521 `return 1` → c:6311 `goto restore`
15853            }
15854            if ch == 'b' && !pre_sol {
15855                // c:Src/init.c:480-486 — "-b ends options at the end of this
15856                // argument".
15857                optionbreak = true;
15858                j += 1;
15859                continue;
15860            }
15861            if ch == 'c' {
15862                // c:6310 — `-c command`. Capture the command body from
15863                // either the rest of the current arg (e.g. `-c'body'`)
15864                // or the next argv slot. C's sticky-emulation evaluator
15865                // (c:6332-6373) wraps the cmd in saved/restored
15866                // emulation state; we mirror by running the captured
15867                // body through eval after the option-parse loop exits.
15868                let body = if j + 1 < bytes.len() {
15869                    bytes[j + 1..].iter().collect::<String>()
15870                } else {
15871                    if i + 1 >= argv.len() {
15872                        zwarnnam(nam, "string expected after -c");
15873                        return parse_fail();
15874                    }
15875                    consumed_next_arg = true;
15876                    argv[i + 1].clone()
15877                };
15878                cmd_body = Some(body);
15879                break;
15880            }
15881            if ch == 'o' {
15882                // c:Src/init.c:496-501 — `-o NAME` / `+o NAME`: NAME is the
15883                // rest of this word, else the next word.
15884                let name = if j + 1 < bytes.len() {
15885                    bytes[j + 1..].iter().collect::<String>()
15886                } else {
15887                    if i + 1 >= argv.len() {
15888                        zwarnnam(nam, "string expected after -o"); // c:499
15889                        return parse_fail(); // c:500
15890                    }
15891                    consumed_next_arg = true;
15892                    argv[i + 1].clone()
15893                };
15894                // c:506-518. optlookup resolves the `no`/`no_` negation
15895                // prefix (a NEGATIVE optno) and dosetopt inverts it and runs
15896                // the option's side effects.
15897                let optno = crate::ported::options::optlookup(&name);
15898                if optno == 0 {
15899                    zwarnnam(nam, &format!("no such option: {}", name)); // c:507
15900                    return parse_fail(); // c:508
15901                } else if optno == crate::ported::zsh_h::EMACSMODE || optno == crate::ported::zsh_h::VIMODE {
15902                    zwarnnam(nam, &format!("can't change option: {}", name)); // c:510
15903                } else if crate::ported::options::dosetopt(optno, action as i32, 0) != 0 {
15904                    zwarnnam(nam, &format!("can't change option: {}", name)); // c:514
15905                } else {
15906                    // c:515-516 — parseopts_insert(optlist, new_opts, optno)
15907                    let canon = crate::ported::zsh_h::opt_name(optno.abs()).to_string();
15908                    let effective_on = if optno < 0 { !action } else { action };
15909                    optlist.push((canon, effective_on));
15910                }
15911                break; // c:519
15912            }
15913            if ch.is_ascii_whitespace() {
15914                // c:Src/init.c:520-527 — trailing blanks are allowed, any
15915                // other character after one is a bad option string.
15916                if bytes[j + 1..].iter().any(|c| !c.is_ascii_whitespace()) {
15917                    zwarnnam(nam, &format!("bad option string: '{}'", arg)); // c:525
15918                    return parse_fail(); // c:526
15919                }
15920                break; // c:528
15921            }
15922            // c:Src/init.c:529-545 — a single option letter.
15923            let optno = optlookupc_pre(ch);
15924            if optno == 0 {
15925                zwarnnam(nam, &format!("bad option: -{}", ch)); // c:535
15926                return parse_fail(); // c:536
15927            } else if optno == crate::ported::zsh_h::EMACSMODE || optno == crate::ported::zsh_h::VIMODE {
15928                let rest: String = bytes[j..].iter().collect();
15929                zwarnnam(nam, &format!("can't change option: {}", rest)); // c:539
15930            } else if crate::ported::options::dosetopt(optno, action as i32, 0) != 0 {
15931                zwarnnam(nam, &format!("can't change option: -{}", ch)); // c:543
15932            } else {
15933                // c:544-545 — parseopts_insert(optlist, new_opts, optno)
15934                let canon = crate::ported::zsh_h::opt_name(optno.abs()).to_string();
15935                let effective_on = if optno < 0 { !action } else { action };
15936                optlist.push((canon, effective_on));
15937            }
15938            j += 1;
15939        }
15940        i += 1;
15941        if consumed_next_arg {
15942            i += 1;
15943        }
15944    }
15945    // c:6332-6377 — `-c command` evaluator: save patterns, install
15946    // the pending `sticky` struct (functions defined in the body get
15947    // stamped via shfunc_set_sticky at c:5402), eval the body, then
15948    // restore sticky + emulation + opts + patterns (restore2: at
15949    // c:6377-6379).
15950    if let Some(body) = cmd_body {
15951        if opt_l_arg {
15952            // c:6333-6336 — `-L` is incompatible with `-c`.
15953            zwarnnam(nam, "option -L incompatible with -c");
15954            // c:6336 `goto restore2` — undo the emulation switch.
15955            emulation.store(saveemulation, Relaxed);
15956            crate::ported::options::EMULATION.store(saveemulation_live, Relaxed);
15957            crate::ported::options::opt_state_restore(saveopts);
15958            restore_hackchar();
15959            return 1;
15960        }
15961        // c:6319 — `savepatterns = savepatterndisables();`
15962        let savepatterns = crate::ported::pattern::savepatterndisables();
15963        // c:6324 — `clearpatterndisables();` — every emulation starts
15964        // with an empty pattern-disable set.
15965        crate::ported::pattern::clearpatterndisables();
15966        // c:6344-6373 — `save_sticky = sticky; sticky = hcalloc(...);`
15967        // Build the sticky struct: target emulation + the on/off
15968        // option indices the optlist recorded.
15969        let save_sticky = crate::ported::options::sticky
15970            .lock()
15971            .unwrap_or_else(|e| e.into_inner())
15972            .take(); // c:6344
15973        {
15974            let mut on_opts: Vec<crate::ported::zsh_h::OptIndex> = Vec::new();
15975            let mut off_opts: Vec<crate::ported::zsh_h::OptIndex> = Vec::new();
15976            for (name, on) in &optlist {
15977                // c:6366-6372 — Data is index into new_opts.
15978                let optno = optlookup(name);
15979                if optno > 0 {
15980                    if *on {
15981                        on_opts.push(optno as crate::ported::zsh_h::OptIndex);
15982                    } else {
15983                        off_opts.push(optno as crate::ported::zsh_h::OptIndex);
15984                    }
15985                }
15986            }
15987            *crate::ported::options::sticky
15988                .lock()
15989                .unwrap_or_else(|e| e.into_inner()) =
15990                Some(Box::new(crate::ported::zsh_h::emulation_options {
15991                    // c:6346 — `sticky->emulation = emulation;`. C's
15992                    // `emulation` global carries EMULATE_FULLY (set by
15993                    // `emulate()` at Src/options.c:551 when `-R` is
15994                    // given), and doshfunc feeds the whole value back
15995                    // to `installemulation` (c:5993), which is what
15996                    // makes `emulate -R sh -c 'f(){...}'` re-apply the
15997                    // FULL emulation (banghist off) inside `f`. The
15998                    // Rust port keeps that bit in the separate
15999                    // FULLY_EMULATING cell, so OR it back in here —
16000                    // otherwise the sticky snapshot silently degrades
16001                    // `-R` to a plain `emulate sh`.
16002                    emulation: crate::ported::options::EMULATION.load(Relaxed)
16003                        | if crate::ported::options::FULLY_EMULATING.load(Relaxed) {
16004                            crate::ported::zsh_h::EMULATE_FULLY
16005                        } else {
16006                            0
16007                        }, // c:6346
16008                    n_on_opts: on_opts.len() as i32,   // c:6351
16009                    n_off_opts: off_opts.len() as i32, // c:6353
16010                    on_opts,                           // c:6356-6358
16011                    off_opts,                          // c:6360-6362
16012                }));
16013        }
16014        let r = eval(&[body]); // c:6374 — `ret = eval(argv);`
16015                               // c:6375 — `sticky = save_sticky;`
16016        *crate::ported::options::sticky
16017            .lock()
16018            .unwrap_or_else(|e| e.into_inner()) = save_sticky;
16019        // c:6377-6379 restore2: — emulation + opts + patterns.
16020        emulation.store(saveemulation, Relaxed); // c:6377
16021        crate::ported::options::EMULATION.store(saveemulation_live, Relaxed); // c:6377
16022        crate::ported::options::opt_state_restore(saveopts); // c:6378
16023        crate::ported::pattern::restorepatterndisables(savepatterns); // c:6379
16024        restore_hackchar(); // c:6381-6382 restore:
16025        return r;
16026    }
16027
16028    // c:6314-6317 — `if (*argv) zwarnnam(nam, "unknown argument %s",
16029    // *argv);`. Anything left after the option-parse loop is unknown.
16030    // zinit's `emulate -LR zsh -o extendedglob` exhausts argv, so this
16031    // arm doesn't fire — but mirror the C behavior for other callers.
16032    if i < argv.len() {
16033        zwarnnam(nam, &format!("unknown argument {}", argv[i])); // c:6314
16034        // c:6315 `goto restore` — C's LIVE opts were never touched on
16035        // this path (parseopts wrote the local new_opts); the Rust
16036        // loop wrote live state, so roll back the snapshot to match.
16037        emulation.store(saveemulation, Relaxed);
16038        crate::ported::options::EMULATION.store(saveemulation_live, Relaxed);
16039        crate::ported::options::opt_state_restore(saveopts);
16040        restore_hackchar();
16041        return 1;
16042    }
16043
16044    // c:6339-6340 — `if (opt_L) opts[LOCALOPTIONS] = opts[LOCALTRAPS] =
16045    //                opts[LOCALPATTERNS] = 1;`.
16046    if opt_l_arg {
16047        for nm in ["localoptions", "localtraps", "localpatterns"] {
16048            crate::ported::options::opt_state_set(nm, true);
16049        }
16050    }
16051    0
16052}
16053
16054/// Port of `bin_eval()` from `Src/builtin.c:6393`.
16055/// C decl: `bin_eval(UNUSED(char *nam), char **argv, UNUSED(Options ops), UNUSED(int func))`
16056/// C: `int bin_eval(UNUSED args)` → `return eval(argv);`
16057/// WARNING: param names don't match C — Rust=(_name, argv, _func) vs C=(nam, argv, ops, func)
16058pub fn bin_eval(
16059    _name: &str,
16060    argv: &[String], // c:6393
16061    _ops: &options,
16062    _func: i32,
16063) -> i32 {
16064    // c:Src/builtin.c:407-411 — generic `--` end-of-options strip
16065    // applied by `execbuiltin` for builtins that:
16066    //   1. Have NULL optstr (so the `if (optstr) { option parse }`
16067    //      loop didn't run), AND
16068    //   2. Don't have BINF_HANDLES_OPTS in their flags.
16069    // ```c
16070    // } else if (!(flags & BINF_HANDLES_OPTS) && *argv &&
16071    //            !strcmp(*argv, "--")) {
16072    //     ops.ind['-'] = 1;
16073    //     argv++;
16074    // }
16075    // ```
16076    // `eval` has optstr=NULL (Src/builtin.c:65) and no
16077    // BINF_HANDLES_OPTS, so the strip applies. The Rust dispatch
16078    // path bypasses execbuiltin, so we need to do the strip
16079    // explicitly here. Without it `eval -- "echo hi"` joined to
16080    // `-- echo hi` and tried to dispatch `--` as a command. Bug
16081    // #319 in docs/BUGS.md.
16082    let stripped: Vec<String>;
16083    let argv: &[String] = if let Some(first) = argv.first() {
16084        if first == "--" {
16085            stripped = argv[1..].to_vec();
16086            &stripped
16087        } else {
16088            argv
16089        }
16090    } else {
16091        argv
16092    };
16093    eval(argv) // c:6396
16094}
16095
16096/// Port of `bin_read()` from `Src/builtin.c:6412`.
16097/// C decl: `bin_read(char *name, char **args, Options ops, UNUSED(int func))`
16098/// C: `int bin_read(char *name, char **args, Options ops, UNUSED(int func))`.
16099///
16100/// The C body is ~720 lines covering the whole `read` builtin matrix:
16101/// `-A` array, `-k N` raw chars, `-q` yes/no, `-r` raw, `-s` silent,
16102/// `-t TIMEOUT`, `-u FD` input FD, `-p` coproc, `-d DELIM` delimiter,
16103/// `-e` echo, `-E` echo-stdout-only, `-l`/`-c` compctl. The structural
16104/// port below handles the script-friendly subset: VAR= default,
16105/// `read -p PROMPT VAR`, `read -t TIMEOUT VAR`, `read -A ARRAY`,
16106/// `read -k N VAR`. Terminal-mode (-q/-s/-e) and ZLE plumbing defer
16107/// to the existing zle/io accessors.
16108/// WARNING: param names don't match C — Rust=(name, args, _func) vs C=(name, args, ops, func)
16109pub fn bin_read(
16110    name: &str,
16111    args: &[String], // c:6412
16112    ops: &options,
16113    _func: i32,
16114) -> i32 {
16115    // !!! DASH-ONLY (no C counterpart) !!! dash has no implicit REPLY: a
16116    // `read` naming no variable is `sh_error("arg count")` (status 2, input
16117    // not consumed, REPLY untouched). zsh reads into $REPLY. `read` is a
16118    // regular builtin in dash, so the error does not abort the script.
16119    if args.is_empty() && crate::dash_mode::dash_faithful() {
16120        zwarnnam(name, "arg count");
16121        return 2;
16122    }
16123    let mut args = args.to_vec();
16124    let mut nchars: i32 = 1; // c:6415
16125    let mut partial_eof = false;
16126
16127    // c:6432-6438 — `-k N` raw-char count.
16128    if OPT_HASARG(ops, b'k') {
16129        // c:6432
16130        let optarg = OPT_ARG(ops, b'k').unwrap_or("");
16131        match optarg.trim().parse::<i32>() {
16132            Ok(n) => nchars = n,
16133            Err(_) => {
16134                zwarnnam(name, &format!("number expected after -k: {}", optarg)); // c:6437
16135                return 1;
16136            }
16137        }
16138    }
16139
16140    // !!! BASH-MODE GATE (no C counterpart) !!! bash `read -n N` / `read -N N`
16141    // read chars from the input (not a terminal): `-n` reads AT MOST N,
16142    // stopping early at the delimiter (newline); `-N` reads EXACTLY N, ignoring
16143    // the delimiter. zsh's optstr treats both as boolean no-ops, so the count N
16144    // is left as the first positional; pull it out and route through the
16145    // char-read path (also skips the tty requirement).
16146    // ksh93 spells the same two reads `-n N` / `-N N` (ksh93u+m `read -n 2 a <<< abcd`
16147    // -> `ab`), so the gate covers it too.
16148    let read_counts = crate::dash_mode::bash_mode() || crate::dash_mode::ksh93_mode();
16149    // !!! SHARED-OPTSTR GUARD (no C counterpart) !!! the registered optstr
16150    // carries bash's `-a` / `-N`; zsh's own is "cd:ek:%lnpqrst:%zu:AE"
16151    // (c:Src/builtin.c:109), where both are `bad option` (status 1).
16152    if !read_counts {
16153        for (flag, c) in [(b'a', 'a'), (b'N', 'N')] {
16154            if OPT_ISSET(ops, flag) {
16155                zwarnnam(name, &format!("bad option: -{c}"));
16156                return 1;
16157            }
16158        }
16159    }
16160    let bash_n = read_counts && OPT_ISSET(ops, b'n');
16161    let bash_bign = read_counts && OPT_ISSET(ops, b'N');
16162    let bash_stop_at_nl = bash_n && !bash_bign; // -N ignores the delimiter
16163    let bash_nchars = (bash_n || bash_bign)
16164        && args
16165            .first()
16166            .map(|a| a.trim().parse::<i32>().is_ok())
16167            .unwrap_or(false);
16168    if bash_nchars {
16169        nchars = args.remove(0).trim().parse::<i32>().unwrap_or(1);
16170    }
16171
16172    // c:6445-6446 — `firstarg = (*args && **args == '?' ? *args++ :
16173    // *args); reply = *args ? *args++ : ...`. When the first arg
16174    // starts with `?` it is a prompt-only arg (consumed; the NEXT arg
16175    // is the reply name). Otherwise firstarg ALIASES the reply name —
16176    // and the prompt block at c:6534-6543 scans it for an embedded
16177    // `?`, prints the tail as the prompt, and truncates the name in
16178    // place (`readpmpt[-1] = '\0'`). So `read v?prompt: ` reads into
16179    // `v` with prompt `prompt: `. Bug #387 follow-up: the leading-`?`
16180    // form was handled but the `var?prompt` split was missing, so
16181    // zshrs errored "not an identifier: v?prompt".
16182    let mut argi = 0usize;
16183    let mut prompt: Option<String> = None;
16184    if argi < args.len() && args[argi].starts_with('?') {
16185        // c:6445 — prompt-only first arg.
16186        prompt = Some(args[argi][1..].to_string());
16187        argi += 1;
16188    }
16189    // `-A` is the zsh/ksh array-read flag; bash uses `-a`. Honor `-a` as an
16190    // array read only in bash mode so `read -a arr <<< "x y z"` works like
16191    // /bin/bash (in other modes `-a` parses but is inert, matching nothing).
16192    let want_array =
16193        OPT_ISSET(ops, b'A') || (read_counts && OPT_ISSET(ops, b'a'));
16194    let reply = if argi < args.len() {
16195        let mut r = args[argi].clone();
16196        argi += 1;
16197        // c:6534-6543 — firstarg (== this arg when no leading-`?` arg
16198        // was consumed) splits at its first `?`: name before, prompt
16199        // after.
16200        if prompt.is_none() {
16201            if let Some(q) = r.find('?') {
16202                prompt = Some(r[q + 1..].to_string());
16203                r.truncate(q); // c:6542 readpmpt[-1] = '\0'
16204            }
16205        }
16206        r
16207    } else if want_array {
16208        "reply".to_string() // c:6446
16209    } else {
16210        "REPLY".to_string() // c:6446
16211    };
16212
16213    if want_array && argi < args.len() {
16214        // c:6448
16215        zwarnnam(name, "only one array argument allowed"); // c:6449
16216        return 1;
16217    }
16218
16219    // c:6408-6413 — `haso` (true if /dev/tty has been opened specially),
16220    // `oshout`, `keys`, `resettty`, `saveti`, `readchar`, `izle`.
16221    let mut haso = false;
16222    let mut oshout: usize = 0;
16223    let mut keys = false;
16224    let mut resettty = false;
16225    let mut saveti: Option<libc::termios> = None;
16226    let readchar = std::cell::Cell::new(-1i32);
16227    let mut izle = crate::ported::builtins::sched::zleactive.load(Relaxed) != 0;
16228    let isem = getsparam("TERM").as_deref() == Some("emacs"); // c:6407
16229    let mut tty_readfd: Option<i32> = None; // C's `readfd = SHTTY`
16230    if (OPT_ISSET(ops, b'k') || OPT_ISSET(ops, b'q'))
16231        && !bash_nchars
16232        && !OPT_ISSET(ops, b'u')
16233        && !OPT_ISSET(ops, b'p')
16234    {
16235        // c:6457
16236        if !izle {
16237            // c:6458
16238            let shtty = crate::ported::init::SHTTY.load(Relaxed);
16239            if shtty == -1 {
16240                // c:6459 — need to open /dev/tty specially.
16241                let fd = unsafe {
16242                    libc::open(
16243                        c"/dev/tty".as_ptr(),
16244                        libc::O_RDWR | libc::O_NOCTTY,
16245                    )
16246                }; // c:6460
16247                crate::ported::init::SHTTY.store(fd, Relaxed);
16248                if fd != -1 {
16249                    haso = true; // c:6461
16250                    oshout = *crate::ported::init::shout.lock().unwrap(); // c:6462
16251                    crate::ported::init::init_shout(); // c:6463
16252                }
16253            } else if *crate::ported::init::shout.lock().unwrap() == 0 {
16254                // c:6465 — we need an output FILE* on the tty.
16255                crate::ported::init::init_shout(); // c:6467
16256            }
16257            // c:6469 — we should have a SHTTY opened by now.
16258            if crate::ported::init::SHTTY.load(Relaxed) == -1 {
16259                // c:6470 — unfortunately, we didn't.
16260                eprintln!("not interactive and can't open terminal"); // c:6471
16261                return 1; // c:6473
16262            }
16263            if crate::ported::zsh_h::unset(INTERACTIVE) {
16264                // c:6475
16265                if let Some(ti) = crate::ported::utils::gettyinfo() {
16266                    // c:6476 gettyinfo(&shttyinfo)
16267                    *crate::ported::utils::SHTTYINFO.lock().unwrap() = Some(ti);
16268                }
16269            }
16270            // c:6478 — attach to the tty.
16271            crate::ported::utils::attachtty(
16272                crate::ported::modules::clone::mypgrp.load(Relaxed),
16273            );
16274            if !isem {
16275                crate::ported::utils::setcbreak(); // c:6480
16276            }
16277            tty_readfd = Some(crate::ported::init::SHTTY.load(Relaxed)); // c:6481
16278        }
16279        keys = true; // c:6483
16280    } else {
16281        izle = false; // c:6507/c:6514/c:6515 `izle = 0` for -u, -p, or neither
16282    }
16283
16284    // c:Src/builtin.c:6510-6515 — `else if (OPT_ISSET(ops,'p')) {
16285    // readfd = coprocin; if (readfd < 0) { zwarnnam(name, "-p: no
16286    // coprocess"); return 1; } izle = 0; }`. C emits this BEFORE
16287    // identifier validation of the reply name, so a bare
16288    // `read -p "var name" REPLY` (no coproc started) errors with
16289    // "-p: no coprocess" rather than "not an identifier". Bug #387.
16290    // The `-u`-with-arg path falls through and shares this same gate
16291    // when its arg is "p" (c:6494) — also handled.
16292    if OPT_ISSET(ops, b'p') || (OPT_HASARG(ops, b'u') && OPT_ARG(ops, b'u').as_deref() == Some("p"))
16293    {
16294        let coprocin =
16295            crate::ported::modules::clone::coprocin.load(std::sync::atomic::Ordering::Relaxed);
16296        if coprocin < 0 {
16297            zwarnnam(name, "-p: no coprocess");
16298            return 1;
16299        }
16300    }
16301
16302    // c:6453-6455 — `return compctlreadptr(name, args, ops, reply)`.
16303    // The compctlreadptr function pointer defaults to
16304    // `fallback_compctlread` (init.c:1834) and gets reassigned to the
16305    // real `compctlread` when zsh/compctl loads via the module's
16306    // boot_ hook. zshrs auto-loads zsh/compctl in default mode so the
16307    // direct call to `compctlread` is correct there. In --zsh parity
16308    // mode zsh/compctl isn't loaded until explicit `zmodload`, so
16309    // route through `fallback_compctlread` instead — matching
16310    // `zsh -fc 'getln -c x'` which emits "no loaded module provides
16311    // read for completion context".
16312    if OPT_ISSET(ops, b'l') || OPT_ISSET(ops, b'c') {
16313        // c:6453
16314        let zsh_mode = crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed);
16315        // In --zsh mode, regardless of MODULESTAB's "loaded" flag
16316        // (which we set true at startup for QoL), route through
16317        // fallback_compctlread because zsh -fc's reported behavior
16318        // for `getln -c` is the fallback message. The MODULESTAB-
16319        // load-flag check below would defeat parity: zshrs marks
16320        // zsh/compctl as default-loaded so its compctl/compcall
16321        // builtins work without explicit zmodload. zsh -fc has only
16322        // zsh/main loaded and only auto-loads compctl when the
16323        // compctl/compcall builtins themselves are called — `read -c`
16324        // does NOT trigger that auto-load (zsh calls
16325        // `compctlreadptr` which defaults to `fallback_compctlread`
16326        // until something explicitly zmodload's zsh/compctl).
16327        //
16328        // The simplest C-faithful parity: in --zsh mode, always emit
16329        // the fallback message. Default zshrs mode keeps calling the
16330        // real compctlread.
16331        if zsh_mode {
16332            return crate::ported::init::fallback_compctlread(name);
16333        }
16334        return crate::ported::zle::compctl::compctlread(name, &args[argi..], ops, &reply);
16335    }
16336
16337    // Optional explicit input FD via -u, or coprocin via -p. When
16338    // unspecified, fall back to fd 0 (stdin). All read paths below
16339    // route bytes through `read_byte_from_fd` so `read -u 3 var`
16340    // after `exec 3< file` correctly pulls from the user fd; with
16341    // `-p`, the same path reads from coprocin (set by the BUILTIN_
16342    // RUN_COPROC handler at fusevm_bridge.rs around the
16343    // `coproc CMD` launch). Bug #388.
16344    let ufd: i32 = if OPT_ISSET(ops, b'p') {
16345        crate::ported::modules::clone::coprocin.load(std::sync::atomic::Ordering::Relaxed)
16346    } else if OPT_HASARG(ops, b'u') {
16347        // c:Src/builtin.c:6494-6500 — `fdarg = (int)zstrtol(argptr,
16348        // &eptr, 10); if (*eptr) { zwarnnam(name, "number expected
16349        // after -u: %s", argptr); return 1; }`. zshrs previously
16350        // `unwrap_or(0)`d, silently dropping `read -u abc v` errors.
16351        let argptr = OPT_ARG(ops, b'u').unwrap_or("");
16352        match argptr.parse::<i32>() {
16353            Ok(n) => {
16354                // Same deviation, and the same reason, as the `-u` arm of
16355                // `print` above: c:Src/builtin.c:6494-6500 has no fdtable
16356                // check, because in C a descriptor the shell keeps for
16357                // itself above 9 is not readable by the script — there is
16358                // nothing there to read. In zshrs there is: `read -u 11
16359                // line` returned 0 and put `SQLite format 3` — the header
16360                // of the user's history database — into a shell variable.
16361                //
16362                // Refusing yields status 1 and no diagnostic, which is
16363                // byte-for-byte what zsh 5.9.2 does for a descriptor it
16364                // does not have open. Script-owned descriptors are
16365                // `FDT_EXTERNAL` (c:Src/exec.c:2409) or 0/1/2
16366                // (c:Src/init.c:1900) and are untouched; anything past
16367                // `max_zsh_fd` is left alone per c:Src/exec.c:3886-3891.
16368                let shell_owned = n > 9 && {
16369                    let max_fd = crate::ported::utils::MAX_ZSH_FD
16370                        .load(std::sync::atomic::Ordering::Relaxed);
16371                    n <= max_fd && {
16372                        let kind = crate::ported::utils::fdtable_get(n)
16373                            & crate::ported::zsh_h::FDT_TYPE_MASK;
16374                        kind != crate::ported::zsh_h::FDT_UNUSED
16375                            && kind != crate::ported::zsh_h::FDT_EXTERNAL
16376                    }
16377                };
16378                if shell_owned {
16379                    return 1;
16380                }
16381                n
16382            }
16383            Err(_) => {
16384                zwarnnam(name, &format!("number expected after -u: {}", argptr));
16385                return 1;
16386            }
16387        }
16388    } else {
16389        0
16390    };
16391    let ufd = tty_readfd.unwrap_or(ufd); // c:6481 `readfd = SHTTY`
16392    readfd.store(ufd, Relaxed);
16393
16394    // c:6511-6522 — `-s`: turn echo off on a tty input fd.
16395    if OPT_ISSET(ops, b's') && unsafe { libc::isatty(ufd) } != 0 {
16396        if let Ok(ti) = crate::ported::utils::fdgettyinfo(ufd) {
16397            saveti = Some(ti); // c:6516
16398            resettty = true;
16399            let mut ti = ti;
16400            ti.c_lflag &= !libc::ECHO; // c:6519
16401            let _ = crate::ported::utils::fdsettyinfo(ufd, &ti); // c:6523
16402        }
16403    }
16404
16405    // c:Src/builtin.c:6418 — single-byte reader bound to `ufd`.
16406    // libc::read with len=1 keeps the file position advancing across
16407    // successive calls (matches zsh's per-byte read loop). Returns
16408    // Some(byte) on success, None on EOF, error sentinel on syscall
16409    // failure (caller maps to return 2).
16410    let izle_timeout_cell = std::cell::Cell::new(0i64); // c:6416 izle_timeout
16411    let read_byte = |fd: i32| -> io::Result<Option<u8>> {
16412        // c:6638-6660 — ZLE input and the byte `read_poll` already consumed.
16413        if izle || readchar.get() >= 0 {
16414            let mut rc = readchar.replace(-1);
16415            let c = zread(izle as i32, &mut rc, izle_timeout_cell.get());
16416            return Ok(if c < 0 { None } else { Some(c as u8) });
16417        }
16418        let mut b = [0u8; 1];
16419        loop {
16420            let n = unsafe { libc::read(fd, b.as_mut_ptr() as *mut libc::c_void, 1) };
16421            // c:6671-6674 — `while ((val = read(readfd, bptr, nchars)) < 0) {
16422            // if (errno != EINTR || errflag || …) break; }`. errno must be
16423            // taken straight after the read: a diagnostic `Path::exists()`
16424            // used to sit between the two, and its failing stat(2) replaced
16425            // EINTR with ENOENT, so any signal landing during the read (a
16426            // SIGCHLD, a trapped USR1) ended it as a read error with status 1.
16427            let err = io::Error::last_os_error();
16428            match n {
16429                1 => return Ok(Some(b[0])),
16430                0 => return Ok(None),
16431                -1 => {
16432                    if err.kind() == io::ErrorKind::Interrupted {
16433                        continue;
16434                    }
16435                    return Err(err);
16436                }
16437                _ => return Ok(None),
16438            }
16439        }
16440    };
16441
16442    // c:6534-6543 — `read "?prompt"`: the prompt is written only when the
16443    // input is a tty (`keys || isatty(0)`), to `shout` when it is open.
16444    if let Some(ref p) = prompt {
16445        if keys || unsafe { libc::isatty(0) } != 0 {
16446            let shout_fd = *crate::ported::init::shout.lock().unwrap();
16447            if shout_fd > 0 {
16448                let bytes = p.as_bytes();
16449                unsafe {
16450                    libc::write(
16451                        shout_fd as i32 - 1,
16452                        bytes.as_ptr() as *const libc::c_void,
16453                        bytes.len(),
16454                    )
16455                };
16456            } else {
16457                eprint!("{}", p);
16458                let _ = Write::flush(&mut io::stderr());
16459            }
16460        }
16461    }
16462
16463    // c:6560-6577 — `-d DELIM` on a tty: leave canonical mode so the
16464    // delimiter can end the read.
16465    if OPT_HASARG(ops, b'd') && unsafe { libc::isatty(ufd) } != 0 {
16466        if let Ok(ti) = crate::ported::utils::fdgettyinfo(ufd) {
16467            if !resettty {
16468                saveti = Some(ti); // c:6566
16469                resettty = true;
16470            }
16471            let mut ti = ti;
16472            ti.c_lflag &= !libc::ICANON; // c:6572
16473            ti.c_cc[libc::VMIN] = 1;
16474            ti.c_cc[libc::VTIME] = 0;
16475            let _ = crate::ported::utils::fdsettyinfo(ufd, &ti); // c:6577
16476        }
16477    }
16478
16479    // c:6579-6617 — `-t TIMEOUT`.
16480    if OPT_ISSET(ops, b't') {
16481        let mut timeout: i64 = 0; // c:6580
16482        if OPT_HASARG(ops, b't') {
16483            // c:6581
16484            let mn = match crate::ported::math::matheval(OPT_ARG(ops, b't').unwrap_or("")) {
16485                Ok(mn) => mn,
16486                Err(_) => return 1, // c:6584 `if (errflag) return 1;`
16487            };
16488            if errflag.load(Relaxed) != 0 {
16489                return 1;
16490            }
16491            if (mn.type_ & crate::ported::zsh_h::MN_FLOAT) != 0 {
16492                timeout = (mn.d * 1e6) as i64; // c:6587-6588
16493            } else {
16494                timeout = mn.l * 1_000_000; // c:6590
16495            }
16496        }
16497        if izle {
16498            // c:6593 — timeout is in 100ths of a second rather than us.
16499            izle_timeout_cell.set(-(timeout / 10000 + 1)); // c:6598
16500        } else {
16501            let mut rc = readchar.get();
16502            let polled = ufd != -1
16503                && crate::ported::utils::read_poll(ufd, &mut rc, keys && !izle, timeout);
16504            readchar.set(rc);
16505            if !polled {
16506                // c:6606
16507                if keys && !izle && !isem {
16508                    if let Some(ti) = *crate::ported::utils::SHTTYINFO.lock().unwrap() {
16509                        crate::ported::utils::settyinfo(&ti); // c:6608
16510                    }
16511                } else if resettty {
16512                    if let (Some(ti), true) = (saveti.as_ref(), ufd >= 0) {
16513                        let _ = crate::ported::utils::fdsettyinfo(ufd, ti); // c:6610
16514                    }
16515                }
16516                if haso {
16517                    *crate::ported::init::shout.lock().unwrap() = oshout; // c:6614
16518                    crate::ported::init::SHTTY.store(-1, Relaxed); // c:6615
16519                }
16520                return if OPT_ISSET(ops, b'q') { 2 } else { 1 }; // c:6617
16521            }
16522        }
16523    }
16524
16525    // c:Src/builtin.c:6769-6770 — `-z` pops the front of the ZLE
16526    // bufstack into `zbuf` and reads from THAT instead of the file
16527    // descriptor. When the bufstack is empty the popped value is the
16528    // empty string, which makes `zread` return EOF immediately;
16529    // c:7110-7115 then returns 1 after the reply assignment.
16530    //
16531    //     zbuforig = zbuf = (!OPT_ISSET(ops,'z')) ? NULL :
16532    //         (nonempty(bufstack)) ? (char *)getlinknode(bufstack) :
16533    //         ztrdup("");
16534    //     ...
16535    //     if (zbuforig) { char first = *zbuforig; ... if (!first) return 1; }
16536    //
16537    // The default delim handling (newline-terminated, raw or
16538    // backslash-cooked) still applies — read up to the first newline
16539    // in the popped buffer or to its end if no newline.
16540    let mut buf = String::new();
16541    if OPT_ISSET(ops, b'z') {
16542        let popped: Option<String> = {
16543            let mut stack = crate::ported::zle::zle_main::BUFSTACK.lock().unwrap();
16544            if stack.is_empty() {
16545                None
16546            } else {
16547                Some(stack.remove(0))
16548            }
16549        };
16550        let zbuf_text = popped.clone().unwrap_or_default();
16551        let raw_mode = OPT_ISSET(ops, b'r') || OPT_ISSET(ops, b'R');
16552        if OPT_HASARG(ops, b'd') {
16553            let arg = OPT_ARG(ops, b'd').unwrap_or("");
16554            let delim = arg.as_bytes().first().copied().unwrap_or(b'\0');
16555            let mut out = Vec::<u8>::new();
16556            for &b in zbuf_text.as_bytes() {
16557                if b == delim {
16558                    break;
16559                }
16560                out.push(b);
16561            }
16562            buf = crate::script_bytes::decode_script_bytes(&out);
16563        } else {
16564            let mut out = Vec::<u8>::new();
16565            let bytes = zbuf_text.as_bytes();
16566            let mut i = 0;
16567            while i < bytes.len() {
16568                let b = bytes[i];
16569                if !raw_mode && b == b'\\' && i + 1 < bytes.len() {
16570                    let nx = bytes[i + 1];
16571                    if nx == b'\n' {
16572                        i += 2;
16573                        continue;
16574                    }
16575                    out.push(nx);
16576                    i += 2;
16577                    continue;
16578                }
16579                if b == b'\n' {
16580                    break;
16581                }
16582                out.push(b);
16583                i += 1;
16584            }
16585            buf = crate::script_bytes::decode_script_bytes(&out);
16586        }
16587        // c:7102-7109 — -e/-E echo, -e suppresses assignment.
16588        let opt_e = OPT_ISSET(ops, b'e');
16589        let opt_big_e = OPT_ISSET(ops, b'E');
16590        if opt_e || opt_big_e {
16591            println!("{}", buf);
16592        }
16593        if !opt_e {
16594            setsparam(&reply, &buf);
16595        }
16596        // c:7110-7115 — empty bufstack pop → return 1 even after assign.
16597        return if popped.is_none() || popped.as_deref() == Some("") {
16598            1
16599        } else {
16600            0
16601        };
16602    }
16603    if OPT_ISSET(ops, b'k') || OPT_ISSET(ops, b'q') || bash_nchars {
16604        // c:Src/builtin.c:6630 — `if (OPT_ISSET(ops,'k') ||
16605        // OPT_ISSET(ops,'q'))` — both read raw chars (no line/IFS
16606        // processing). `-q` reads exactly one char (nchars defaults to
16607        // 1) for the yes/no decision applied below. The previous port
16608        // gated only on `-k`, so `-q` fell through to the line reader
16609        // and never applied the y/Y check (always returned 0).
16610        // c:Src/builtin.c — `-k 0` (zero chars requested) is a no-op
16611        // read that zsh treats as failure (returns 1) because no
16612        // bytes can be consumed. Mirror so `read -k 0` exits 1
16613        // instead of "succeeding" with an empty buffer.
16614        // bash `-n N` (bash_nchars) joins this raw-char path but STOPS at the
16615        // delimiter (newline): `read -n 10 <<< "ab"` gets "ab", not 10 bytes.
16616        if nchars <= 0 {
16617            return 1;
16618        }
16619        // c:6633-6634 — `bptr = buf = zalloc(nchars*MB_CUR_MAX+1)`: `nchars`
16620        // counts CHARACTERS, so the byte budget is nchars * MB_CUR_MAX.
16621        // c:6683-6705 — `mbrlen` drives the countdown: MB_INCOMPLETE keeps
16622        // reading without decrementing, MB_INVALID is "treat as single
16623        // byte" (c:6690-6693), and only a completed character does
16624        // `nchars--`. The old port decremented per BYTE, so `read -k2`
16625        // over `«»` returned the two bytes of `«` alone.
16626        let mb_on = crate::ported::options::opt_state_get("multibyte").unwrap_or(true);
16627        let mut got = Vec::<u8>::with_capacity(nchars as usize * 4);
16628        let mut nleft = nchars as usize; // c:6715 `while (nchars > 0)`
16629        let mut pending = Vec::<u8>::new();
16630        while nleft > 0 {
16631            match read_byte(ufd) {
16632                Ok(Some(b)) => {
16633                    if bash_stop_at_nl && b == b'\n' {
16634                        break; // bash -n stops early at the delimiter
16635                    }
16636                    if !mb_on {
16637                        // c:6656-6659 — byte mode: one byte is one character.
16638                        got.push(b);
16639                        nleft -= 1;
16640                        continue;
16641                    }
16642                    pending.push(b);
16643                    match std::str::from_utf8(&pending) {
16644                        // c:6701 — a complete character: `nchars--`.
16645                        Ok(_) => {
16646                            got.extend_from_slice(&pending);
16647                            pending.clear();
16648                            nleft -= 1;
16649                        }
16650                        // c:6686-6688 — MB_INCOMPLETE: keep reading.
16651                        Err(e) if e.error_len().is_none() && pending.len() < 4 => {}
16652                        // c:6690-6693 — MB_INVALID: treat as a single byte.
16653                        Err(_) => {
16654                            got.extend_from_slice(&pending);
16655                            pending.clear();
16656                            nleft -= 1;
16657                        }
16658                    }
16659                }
16660                // c:6674-6677 — `if (val <= 0) { eof = 1; break; }`.
16661                _ => {
16662                    partial_eof = true;
16663                    break;
16664                }
16665            }
16666        }
16667        // EOF mid-character: keep the debris, as c:6892-6898 does.
16668        got.extend_from_slice(&pending);
16669        // "Keep the debris" is only true if the conversion keeps it:
16670        // `String::from_utf8_lossy` collapsed every MB_INVALID byte the loop
16671        // above deliberately preserved into a single U+FFFD, which is the
16672        // opposite of what c:6690-6693 asks for. `read -k` on a latin-1
16673        // stream now yields the byte itself.
16674        buf = crate::script_bytes::decode_script_bytes(&got);
16675    } else if OPT_HASARG(ops, b'd') {
16676        // c:Src/builtin.c:6538 — `-d DELIM`: read until the first
16677        // CHARACTER of DELIM. EOF mid-record returns what was read so
16678        // far, exit 1 like the default path.
16679        let arg = OPT_ARG(ops, b'd').unwrap_or("");
16680        // c:6538-6555 — the delimiter is ONE CHARACTER, not one byte:
16681        //     if (isset(MULTIBYTE)) { mb_charinit();
16682        //                             (void)mb_metacharlenconv(delimstr, &wi); }
16683        //     else wi = WEOF;
16684        //     if (wi != WEOF) delim = (wchar_t)wi;
16685        //     else { delim = (wchar_t)(unsigned char)delimstr[0]; rawbyte = 1; }
16686        // The old port compared the FIRST BYTE, so `read -d £` (0xc2 0xa3)
16687        // stopped at the 0xc2 of any other Latin-1 character (`¤`, `«`) and
16688        // left the trailing byte in the next record.
16689        let dbytes = crate::ported::utils::unmetafy_str(arg);
16690        // c:6543 — the wide decode only happens under MULTIBYTE.
16691        let mb_on = crate::ported::options::opt_state_get("multibyte").unwrap_or(true);
16692        let (delim_wc, rawbyte) = if dbytes.is_empty() {
16693            // c:Src/builtin.c:6418 — empty `-d` arg means NUL delimiter.
16694            // zsh's `delimstr[0]` reads the arg buffer's NUL terminator.
16695            (0u32, true)
16696        } else if mb_on {
16697            match crate::ported::utils::mb_metacharlenconv(&dbytes) {
16698                (_, Some(wc), _) => (wc as u32, false), // c:6549-6550
16699                _ => (dbytes[0] as u32, true),          // c:6551-6554 (WEOF)
16700            }
16701        } else {
16702            (dbytes[0] as u32, true) // c:6547-6548 `wi = WEOF`
16703        };
16704        let mut buf_bytes = Vec::<u8>::new();
16705        // c:6983-6998 — one byte at a time into `bptr`, then
16706        // `ret = mbrtowc(&wc, bptr, 1, &mbs)`: MB_INCOMPLETE keeps reading,
16707        // MB_INVALID resets the state and treats THIS byte as the character
16708        // (the incomplete bytes already staged stay in the buffer).
16709        let mut pending = Vec::<u8>::new();
16710        // c:7045 — the loop breaks on `c == EOF || (c == delim && !zbuf)`, and
16711        // c:7116 keys the exit status off WHICH of the two ended it. "Did any
16712        // byte arrive" can't tell an EOF-terminated record from a
16713        // delimiter-terminated one, so track the delimiter itself.
16714        let mut saw_delim = false;
16715        // c:7055 — `bslash = c == '\\' && !bslash && !OPT_ISSET(ops,'r')`.
16716        // In C there is ONE read loop and `-d` only swaps the `delim` variable,
16717        // so the backslash rules apply to `-d` exactly as they do to a plain
16718        // `read`. zshrs split the loop in two and the `-d` copy dropped them,
16719        // so `read -d ' ' x <<< 'esc\ttab'` kept the backslash (`esc\ttab`)
16720        // where zsh yields `escttab`.
16721        let raw_mode = OPT_ISSET(ops, b'r') || OPT_ISSET(ops, b'R');
16722        let mut bslash = false;
16723        loop {
16724            match read_byte(ufd) {
16725                Ok(Some(b)) => {
16726                    // c:6984-6998 — assemble ONE character before any of the
16727                    // delimiter / backslash tests run. `rawbyte` (c:6554) and
16728                    // NO_MULTIBYTE both short-circuit to the byte itself.
16729                    let (wc, unit): (u32, Vec<u8>) = if rawbyte || !mb_on {
16730                        (b as u32, vec![b]) // c:6988-6991
16731                    } else {
16732                        pending.push(b);
16733                        match std::str::from_utf8(&pending) {
16734                            // c:6992 — `ret != MB_INCOMPLETE`, a whole character.
16735                            Ok(v) => {
16736                                let c = v.chars().next().unwrap_or('\0');
16737                                (c as u32, std::mem::take(&mut pending))
16738                            }
16739                            // c:6792 — `MB_INCOMPLETE`: keep reading bytes.
16740                            Err(e) if e.error_len().is_none() && pending.len() < 4 => {
16741                                continue;
16742                            }
16743                            // c:6993-6997 — `MB_INVALID`: reset the shift state
16744                            // and treat this as a single character.
16745                            Err(_) => (b as u32, std::mem::take(&mut pending)),
16746                        }
16747                    };
16748                    // c:7003-7005 — a backslash-escaped DELIMITER is a line
16749                    // continuation: both are dropped and the record keeps
16750                    // going. `read -d t x <<< 'esc\ttab'` therefore yields `esc`
16751                    // (the escaped `t` vanishes; the NEXT `t` terminates).
16752                    if bslash && wc == delim_wc {
16753                        bslash = false; // c:7004
16754                        continue; // c:7005
16755                    }
16756                    if wc == delim_wc {
16757                        saw_delim = true; // c:7007 (`wc == delim`)
16758                        break;
16759                    }
16760                    // c:7017-7019 — an unescaped backslash is consumed and
16761                    // escapes the next character (unless `-r`).
16762                    let was_escaped = bslash; // this char follows a backslash
16763                    bslash = wc == u32::from(b'\\') && !bslash && !raw_mode; // c:7017
16764                    if bslash {
16765                        continue; // c:7019
16766                    }
16767                    // Mark a backslash-escaped byte literal so IFS splitting
16768                    // skips it (see the default-line loop above for the rule).
16769                    if was_escaped {
16770                        buf_bytes.extend_from_slice("\u{e199}".as_bytes());
16771                    }
16772                    buf_bytes.extend_from_slice(&unit); // c:7021-7027
16773                }
16774                Ok(None) => break,
16775                // c:Src/builtin.c:7162-7188 zread — a read(2) error
16776                // (EBADF from `read foo <&-`, EIO, ...) returns EOF,
16777                // not a distinct error status; bin_read then reports
16778                // plain eof (exit 1), with no diagnostic.
16779                Err(_) => break,
16780            }
16781        }
16782        // c:6892-6898 — "We can only get here if there is an EOF in the
16783        // middle of a character... safest to keep the debris, I suppose."
16784        buf_bytes.extend_from_slice(&pending);
16785        // Lossless, for the same reason as the `-k` arm: the bytes this loop
16786        // preserved on purpose must survive the String conversion.
16787        buf = crate::script_bytes::decode_script_bytes(&buf_bytes);
16788        // c:Src/builtin.c:6418 — `read -d ''` (NUL delimiter) strips
16789        // trailing newlines from the captured content. This matches
16790        // the `find -print0 | while read -d ''` idiom which expects
16791        // path entries without trailing whitespace. zsh's read body
16792        // applies this trim only for the empty-delim case; non-empty
16793        // delimiters keep the raw bytes.
16794        if arg.is_empty() {
16795            while buf.ends_with('\n') {
16796                buf.pop();
16797            }
16798        }
16799        // c:7107 + c:7116-7122 — C assigns the parameter FIRST (`setsparam(reply,
16800        // buf)`) and only then returns 1 if the loop ended at EOF. So an
16801        // unterminated record still lands in the variable but reports failure:
16802        // `read -d : x <<< 'abc'` sets x=abc and returns 1, which is what makes
16803        // `while read -d : x` terminate on a final field with no trailing
16804        // delimiter. The old early `return 1` fired only when NOTHING was read,
16805        // so every EOF-terminated record wrongly reported success (rc=0) — and
16806        // it also skipped the assignment entirely, leaving the variable at its
16807        // previous value instead of clearing it.
16808        //
16809        // `partial_eof` is the same carrier the default line branch below uses;
16810        // the shared `if partial_eof { return 1 }` after the assignment block
16811        // applies the status.
16812        partial_eof = !saw_delim; // c:7116 (`else if (c == EOF)`)
16813    } else {
16814        // Read a line (default behaviour). c:Src/builtin.c:6505
16815        // — without `-r`, backslash-X eats the backslash and keeps
16816        // the literal X (backslash-newline is line continuation).
16817        let raw_mode = OPT_ISSET(ops, b'r') || OPT_ISSET(ops, b'R');
16818        let mut buf_bytes = Vec::<u8>::new();
16819        let mut got_any = false;
16820        let mut saw_newline = false;
16821        loop {
16822            match read_byte(ufd) {
16823                Ok(Some(b)) => {
16824                    got_any = true;
16825                    if !raw_mode && b == b'\\' {
16826                        match read_byte(ufd) {
16827                            Ok(Some(nx)) => {
16828                                if nx == b'\n' {
16829                                    // Line continuation — drop both.
16830                                    continue;
16831                                }
16832                                // A backslash-escaped char is literal and must
16833                                // NOT act as an IFS separator: dash/ksh/bash/zsh
16834                                // all read `a\ b` as the single field `a b`.
16835                                // Prefix it with the Bnull mark (\u{e199}, UTF-8
16836                                // C2 99) so the IFS split below skips it; the
16837                                // mark is stripped before assignment.
16838                                buf_bytes.extend_from_slice("\u{e199}".as_bytes());
16839                                buf_bytes.push(nx);
16840                                continue;
16841                            }
16842                            Ok(None) => {
16843                                buf_bytes.push(b'\\');
16844                                break;
16845                            }
16846                            // c:7162-7188 zread — read error == EOF.
16847                            Err(_) => break,
16848                        }
16849                    }
16850                    if b == b'\n' {
16851                        saw_newline = true;
16852                        break;
16853                    }
16854                    buf_bytes.push(b);
16855                }
16856                Ok(None) => break,
16857                // c:7162-7188 zread — read error == EOF (exit 1, no
16858                // message): `exec 3<&-; read foo <&-` exits 1 in zsh.
16859                Err(_) => break,
16860            }
16861        }
16862        if !got_any {
16863            // c:Src/builtin.c — immediate EOF (no bytes read at all).
16864            // zsh clears the target variable(s) BEFORE returning 1
16865            // so `while read line; do …; done < file` leaves `\$line`
16866            // empty after the loop ends (not the last value).
16867            // Previously zshrs returned 1 without touching the var,
16868            // so the loop body's final iteration value persisted.
16869            buf = String::new();
16870            partial_eof = true;
16871            // Fall through to the assignment block which will write
16872            // "" to reply / vars / array. The trailing `if
16873            // partial_eof { return 1; }` (line 8628) preserves the
16874            // EOF status code.
16875        } else {
16876            // `read line < latin1-file` handed the variable a U+FFFD where
16877            // zsh assigns the raw byte; lossless decode keeps them equal.
16878            buf = crate::script_bytes::decode_script_bytes(&buf_bytes);
16879            partial_eof = !saw_newline;
16880        }
16881    }
16882
16883    // c:6718-6730 — dispose of the tty state the `-k`/`-q` read set up.
16884    if (OPT_ISSET(ops, b'k') || OPT_ISSET(ops, b'q'))
16885        && !izle
16886        && !OPT_ISSET(ops, b'u')
16887        && !OPT_ISSET(ops, b'p')
16888    {
16889        if isem {
16890            // c:6720 — `while (val > 0 && read(SHTTY, &d, 1) == 1 && d != '\n');`
16891            let mut d = 0u8;
16892            while !partial_eof
16893                && unsafe {
16894                    libc::read(
16895                        crate::ported::init::SHTTY.load(Relaxed),
16896                        &mut d as *mut u8 as *mut libc::c_void,
16897                        1,
16898                    )
16899                } == 1
16900                && d != b'\n'
16901            {}
16902        } else if resettty {
16903            if let Some(ti) = saveti.as_ref() {
16904                let _ = crate::ported::utils::fdsettyinfo(ufd, ti); // c:6722
16905            }
16906            resettty = false; // c:6723
16907        }
16908        if haso {
16909            // c:6725
16910            *crate::ported::init::shout.lock().unwrap() = oshout; // c:6727
16911            crate::ported::init::SHTTY.store(-1, Relaxed); // c:6728
16912        }
16913    }
16914    // c:6751/c:6962/c:7091 — `if (resettty) fdsettyinfo(readfd, &saveti);`
16915    if resettty {
16916        if let Some(ti) = saveti.as_ref() {
16917            let _ = crate::ported::utils::fdsettyinfo(ufd, ti);
16918        }
16919    }
16920
16921    // c:Src/builtin.c:6730-6742 — `-q` (read yes/no): the single char
16922    // read above is "yes" iff it is exactly 'y' or 'Y'; the reply var is
16923    // set to "y"/"n" and the exit status is 0 (yes) / 1 (no). (A timeout is status 2, returned by the `-t` poll above.) This must run BEFORE the
16924    // IFS/array assignment dispatch — `-q` never does line splitting.
16925    if OPT_ISSET(ops, b'q') {
16926        // c:6737-6740 — "Keep eof as status but status is now whether we read
16927        // 'y' or 'Y'.  If we timed out, status is 2."
16928        //     if (eof) eof = 2;
16929        //     else eof = (bptr - buf != 1 || (buf[0] != 'y' && buf[0] != 'Y'));
16930        let eof = if partial_eof { 2 } else if buf == "y" || buf == "Y" { 0 } else { 1 };
16931        setsparam(&reply, if eof == 0 { "y" } else { "n" }); // c:6742
16932        return eof;
16933    }
16934
16935    // Backslash-escaped chars were tagged with the Bnull mark (\u{e199}) by
16936    // the read loop so the IFS splitting below treats them as literal.
16937    // `rmark` is that tag; `unmark` strips it (keeping the escaped char),
16938    // applied to every field/value just before assignment. Both are no-ops
16939    // on the common markerless line (fast `contains` guard). No C
16940    // counterpart — C's read splits inline during the char loop, so it
16941    // never needs a post-hoc mark; the port splits in a second pass.
16942    let rmark = '\u{e199}';
16943    let unmark = |s: &str| -> String {
16944        if !s.contains(rmark) {
16945            return s.to_string();
16946        }
16947        let mut out = String::with_capacity(s.len());
16948        let mut m = false;
16949        for c in s.chars() {
16950            if !m && c == rmark {
16951                m = true;
16952                continue;
16953            }
16954            m = false;
16955            out.push(c);
16956        }
16957        out
16958    };
16959
16960    // c:6855 / c:7039 — `isep(c)` / `iwsep(c)` classify each input
16961    // character against the typtab `inittyptab` builds from $IFS
16962    // (c:Src/utils.c:4216-4230). With MULTIBYTE unset a raw byte >= 0x80
16963    // reaches this walk as zshrs's character-level Meta pair (U+0083 +
16964    // `B ^ 32`, the form `$IFS` holds too), and it must be classified as the
16965    // ONE byte C sees. Testing the pair's two chars against the IFS string
16966    // split at the Meta char and kept the second half:
16967    // `unsetopt multibyte; IFS=$'\xe9'; read -r x y <<< $'p\xe9q'` gave
16968    // `p` / `Éq` where zsh gives `p` / `q`. Every other unit goes through
16969    // `wcsitype(c, ISEP)` (c:Src/utils.c:4321), which reads the typtab for
16970    // ASCII and `ifs_wide` for a multibyte char; only a single byte can be
16971    // IWSEP (c:Src/utils.c:3686).
16972    let read_multibyte = isset(crate::ported::zsh_h::MULTIBYTE);
16973    // Byte length of the unit at the front of `s`: a Meta pair is one unit.
16974    let unit_len = |s: &str| -> usize {
16975        let mut it = s.chars();
16976        match it.next() {
16977            None => 0,
16978            Some(c) if !read_multibyte && c == '\u{83}' => match it.next() {
16979                Some(n) if (0x80..=0xff).contains(&(n as u32)) => c.len_utf8() + n.len_utf8(),
16980                _ => c.len_utf8(),
16981            },
16982            Some(c) => c.len_utf8(),
16983        }
16984    };
16985    // (is separator, is whitespace separator) for one unit.
16986    let unit_sep = |u: &str| -> (bool, bool) {
16987        let mut it = u.chars();
16988        let c = it.next().unwrap_or('\0');
16989        if !read_multibyte && c == '\u{83}' {
16990            if let Some(n) = it.next() {
16991                let b = (n as u32 as u8) ^ 32; // c:4217 `*s == Meta ? *++s ^ 32`
16992                return (
16993                    crate::ported::ztype_h::isep(b),  // c:6855
16994                    crate::ported::ztype_h::iwsep(b), // c:6856
16995                );
16996            }
16997        }
16998        let s = crate::ported::utils::wcsitype(c, crate::ported::ztype_h::ISEP as u32); // c:7039
16999        (s, s && (c as u32) < 128 && crate::ported::ztype_h::iwsep(c as u8)) // c:3686
17000    };
17001    // Byte length of the leading whitespace-IFS run of `s`.
17002    let ws_prefix_len = |s: &str| -> usize {
17003        let mut i = 0;
17004        while i < s.len() {
17005            let l = unit_len(&s[i..]);
17006            if !unit_sep(&s[i..i + l]).1 {
17007                break;
17008            }
17009            i += l;
17010        }
17011        i
17012    };
17013    // Byte offset where the trailing whitespace-IFS run of `s` starts.
17014    let ws_suffix_start = |s: &str| -> usize {
17015        let mut i = 0;
17016        let mut end = 0;
17017        while i < s.len() {
17018            let l = unit_len(&s[i..]);
17019            let ws = unit_sep(&s[i..i + l]).1;
17020            i += l;
17021            if !ws {
17022                end = i;
17023            }
17024        }
17025        end
17026    };
17027    // c:Src/builtin.c:6772-6830 — read one word starting at byte `*cur`,
17028    // updating C's `first` (a non-whitespace separator was seen, so empty
17029    // fields count) and `gotnl` (the delimiter or EOF was reached). The
17030    // returned word keeps its Bnull marks; callers unmark before assigning.
17031    let read_word = |cur: &mut usize, first: &mut bool, gotnl: &mut bool| -> String {
17032        let mut word = String::new();
17033        loop {
17034            if *cur >= buf.len() {
17035                *gotnl = true; // c:6898 `if (wc == delim) gotnl = 1` / EOF
17036                break;
17037            }
17038            let l = unit_len(&buf[*cur..]);
17039            let unit = &buf[*cur..*cur + l];
17040            *cur += l;
17041            // c:6823-6826 — a backslash-escaped unit is stored literally and
17042            // clears `first`; it is never a separator.
17043            if unit.starts_with(rmark) {
17044                word.push_str(unit);
17045                if *cur < buf.len() {
17046                    let nl = unit_len(&buf[*cur..]);
17047                    word.push_str(&buf[*cur..*cur + nl]);
17048                    *cur += nl;
17049                }
17050                *first = false;
17051                continue;
17052            }
17053            let (sep, ws) = unit_sep(unit);
17054            if sep {
17055                // c:6815 `bptr != buf` — with MULTIBYTE set, zread hands
17056                // mbrtowc one byte at a time and every leading byte of a
17057                // multibyte character is stored before it decodes (c:6827),
17058                // so a multibyte separator always ends the word, even an
17059                // empty one; c:6887 `*laststart = '\0'` then drops those bytes.
17060                // `IFS=" 日"; read -rA a <<< " a 日 b"` is (a "" b).
17061                let bptr_ne_buf = !word.is_empty() || (read_multibyte && l > 1);
17062                if bptr_ne_buf || (!ws && *first) {
17063                    *first |= !ws; // c:6817
17064                    break;
17065                }
17066                *first |= !ws; // c:6820
17067                continue;
17068            }
17069            *first = false; // c:6826
17070            word.push_str(unit);
17071        }
17072        word
17073    };
17074
17075    // Assign to scalar reply, multi-var split, or array.
17076    // c:6685-6735 — `read x y z` splits buf by IFS, fills the first
17077    // N-1 vars with one IFS-separated field each, and stores the
17078    // REST of the line (including embedded IFS chars) into the last
17079    // var. zsh's read is stable on `print "a b c d" | read x y z`:
17080    // x="a", y="b", z="c d".
17081    if want_array {
17082        // c:Src/builtin.c:6685-6735 — `read -A arr` splits on $IFS
17083        // (whitespace-IFS coalesces; non-whitespace-IFS each acts as
17084        // a single delimiter). The previous port hardcoded
17085        // split_whitespace(), which ignored custom IFS like `:` and
17086        // produced a single-element array for `IFS=: read -A arr
17087        // <<< "a:b:c"`. Mirror the multi-var path's IFS handling.
17088        // c:6863-6869 — the FIRST word's char loop skips leading
17089        // whitespace-IFS (bptr==buf && iwsep → continue), so leading
17090        // whitespace produces no empty element.
17091        // c:Src/builtin.c:6764 — `while (*args || (OPT_ISSET(ops,'A') && !gotnl))`:
17092        // one word per pass until the delimiter (or EOF) sets gotnl.
17093        let mut parts: Vec<String> = Vec::new();
17094        let mut cur = 0usize;
17095        let mut first = true; // c:6763
17096        let mut gotnl = false;
17097        while !gotnl {
17098            let word = read_word(&mut cur, &mut first, &mut gotnl);
17099            // c:6929 — `if (!OPT_ISSET(ops,'e') && (*buf || first || gotnl))`
17100            if !word.is_empty() || first || gotnl {
17101                parts.push(unmark(&word)); // c:6931 addlinknode
17102            }
17103        }
17104        // c:Src/builtin.c:6910-6961 — `-A` with `-e`/`-E`. The word is
17105        // echoed (one per line) when `-e` (main per-word loop, c:6910)
17106        // OR `-E` (array-display loop, c:6949). The array is SET only
17107        // when `-e` is NOT set (c:6921 collects words, c:6945 allocates
17108        // p only when !-e). So `read -AE` echoes AND assigns; `read -Ae`
17109        // echoes but leaves the array untouched. The previous port
17110        // always assigned and never echoed.
17111        let opt_e = OPT_ISSET(ops, b'e');
17112        if opt_e || OPT_ISSET(ops, b'E') {
17113            for w in &parts {
17114                println!("{}", w);
17115            }
17116        }
17117        if !opt_e {
17118            setaparam(&reply, parts); // c:6960 setaparam
17119        }
17120        // c:6964 — `return c == EOF;`: the array arm returns before the
17121        // c:7121 `return errflag` tail, so a failed assignment (odd pairs
17122        // into an assoc) still exits 0 unless the line hit EOF.
17123        return partial_eof as i32;
17124    } else if argi < args.len() {
17125        // Multi-var: `read x y [z]`. First var = reply (already
17126        // consumed); rest are args[argi..]. Split with at most
17127        // `vars.len()` chunks using IFS.
17128        let mut vars: Vec<String> = Vec::with_capacity(args.len() - argi + 1);
17129        vars.push(reply);
17130        for n in &args[argi..] {
17131            vars.push(n.clone());
17132        }
17133        // Trim leading IFS-whitespace per zsh's read semantics
17134        // (`a   b c` → x=a, y="b c", not x="" y=…).
17135        // c:Src/builtin.c — `-E` echoes each field to stdout as it is read,
17136        // one per line (c:6957 in the -A path, and the equivalent per-word
17137        // display on the multi-var path); `-e` does the same but ASSIGNS
17138        // NOTHING (c:7106 `if (!OPT_ISSET(ops,'e')) setsparam(...)`). This path
17139        // did neither, so `read -E a b` split correctly but printed no echo
17140        // (the whole-line -E echo worked only for a single variable).
17141        let opt_e = OPT_ISSET(ops, b'e');
17142        let opt_echo = opt_e || OPT_ISSET(ops, b'E');
17143        // Strip escape marks just before assignment so `read x y` on `a\ b`
17144        // assigns x="a b" (one field), matching dash/ksh/bash/zsh.
17145        let emit = |var: &str, val: &str| {
17146            let val = unmark(val);
17147            if opt_echo {
17148                println!("{val}"); // c:6958 zputs + putchar('\n')
17149            }
17150            if !opt_e {
17151                setsparam(var, &val); // c:7106
17152            }
17153        };
17154        let mut cur = 0usize;
17155        let mut first = true; // c:6763
17156        let mut gotnl = false;
17157        let (last, leading) = vars.split_last().expect("vars holds reply");
17158        // c:6764 — every variable but the last takes one word.
17159        for var in leading {
17160            let word = read_word(&mut cur, &mut first, &mut gotnl);
17161            // c:6929 — `(*buf || first || gotnl)`; otherwise the word is
17162            // dropped and the variable left untouched.
17163            if !word.is_empty() || first || gotnl {
17164                emit(var, &word);
17165            } else if opt_echo {
17166                println!("{}", unmark(&word)); // c:6926 echo precedes the test
17167            }
17168        }
17169        // c:6993-7060 — any remaining part of the line goes into the last
17170        // parameter. Only a separator met while nothing is stored yet
17171        // (`bptr == buf`, c:7009) is skipped: whitespace always, one
17172        // non-whitespace separator when the word loop did not end on one.
17173        let mut rest = String::new();
17174        if !gotnl {
17175            while cur < buf.len() {
17176                let l = unit_len(&buf[cur..]);
17177                let unit = &buf[cur..cur + l];
17178                cur += l;
17179                if unit.starts_with(rmark) {
17180                    rest.push_str(unit);
17181                    if cur < buf.len() {
17182                        let nl = unit_len(&buf[cur..]);
17183                        rest.push_str(&buf[cur..cur + nl]);
17184                        cur += nl;
17185                    }
17186                    continue;
17187                }
17188                // c:7009 — a multibyte character's leading bytes are already
17189                // stored by the time it decodes, so `bptr == buf` is false.
17190                let bptr_is_buf = rest.is_empty() && !(read_multibyte && l > 1);
17191                let (sep, ws) = unit_sep(unit);
17192                if bptr_is_buf && sep {
17193                    if ws {
17194                        continue; // c:7011
17195                    } else if !first {
17196                        first = true; // c:7013
17197                        continue;
17198                    }
17199                }
17200                rest.push_str(unit);
17201            }
17202        }
17203        // c:7078-7091 — strip trailing IFS whitespace.
17204        emit(last, &rest[..ws_suffix_start(&rest)]);
17205    } else {
17206        // c:Src/builtin.c:6843 — `read VAR` single-var path strips
17207        // leading/trailing IFS-whitespace exactly like the multi-var
17208        // last-var fallback above. C's read loop discards leading
17209        // whitespace-IFS via `if (c == sep && IS_SPACE(c)) ... loop`
17210        // (c:6843) before building each word, then on word-end it
17211        // either emits the word or, on the last var, keeps building
17212        // until newline — at which point the trailing whitespace-IFS
17213        // gets discarded by the same skip. Without this, `read line`
17214        // on `   hello   ` set `line` to `   hello   ` (with spaces)
17215        // instead of `hello` — bug #247.
17216        let lead = &buf[ws_prefix_len(&buf)..];
17217        let trimmed = &lead[..ws_suffix_start(lead)];
17218        // Strip backslash-escape marks: `read x` on `a\ b` → x="a b".
17219        let trimmed = unmark(trimmed);
17220        // c:Src/builtin.c:7102-7109 — `-e` / `-E` flags. Both echo
17221        // the read content to stdout (`zputs(buf, stdout); putchar
17222        // ('\n')`); `-e` ALSO skips the setsparam (echo-only, no
17223        // assign), while `-E` echoes AND assigns. Bug #434.
17224        let opt_e = OPT_ISSET(ops, b'e');
17225        let opt_big_e = OPT_ISSET(ops, b'E');
17226        if opt_e || opt_big_e {
17227            println!("{}", trimmed);
17228        }
17229        if !opt_e {
17230            setsparam(&reply, &trimmed);
17231        }
17232    }
17233    // c:Src/builtin.c:6534 — partial-EOF post-assign exit.
17234    if partial_eof {
17235        return 1;
17236    }
17237    // c:Src/builtin.c:7116-7121 — "The following is to ensure a failure to
17238    // set the parameter causes a non-zero status return."
17239    //     return errflag;
17240    // A readonly target makes setsparam zerr (errflag set); returning 0
17241    // left `(typeset -r foo; read foo) <<<bar` exiting 0.
17242    errflag.load(Relaxed)
17243}
17244
17245/// Port of `static char *zbuf;` from `Src/builtin.c:6397` — the buffer
17246/// `read -z` takes its input from (`None` is C's NULL). Unmetafied bytes
17247/// are not stored: the buffer keeps C's metafied text, and `zread` decodes
17248/// the `Meta` escapes as C does.
17249pub static zbuf: Mutex<Option<(Vec<u8>, usize)>> = Mutex::new(None); // c:6397
17250
17251/// Port of `static int readfd;` from `Src/builtin.c:6398` — the descriptor
17252/// `zread` reads from.
17253pub static readfd: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0); // c:6398
17254
17255/// Port of `zread()` from `Src/builtin.c:7134`.
17256/// C decl: `zread(int izle, int *readchar, long izle_timeout)`
17257/// C: `static int zread(int izle, int *readchar, long izle_timeout)` —
17258///   read one byte from ZLE, `zbuf` or `readfd`, honouring a pushed-back
17259///   `readchar`. Returns `EOF` (-1) at end of input.
17260pub fn zread(izle: i32, readchar: &mut i32, izle_timeout: i64) -> i32 {
17261    // c:7134
17262    let mut retry = false; // c:7136 `int retry = 0;`
17263
17264    if izle != 0 {
17265        // c:7141
17266        let mut c: i32 = 0; // c:7142
17267        let mut timeout_unused: i32 = 0; // c:7143 NULL
17268        crate::ported::zle::zle_main::zle_main_entry(
17269            crate::ported::zsh_h::ZLE_CMD_GET_KEY,
17270            &mut crate::ported::zle::zle_main::zle_main_entry_args::GetKey {
17271                do_keytmout: izle_timeout,
17272                timeout: &mut timeout_unused,
17273                chrp: &mut c,
17274            },
17275        ); // c:7143
17276        return if c < 0 { -1 } else { c }; // c:7145
17277    }
17278    // c:7147 — use zbuf if possible.
17279    if let Some((buf, pos)) = zbuf.lock().unwrap().as_mut() {
17280        // c:7151-7154 — `*zbuf == Meta` → next byte ^ 32.
17281        if buf.get(*pos).copied() == Some(Meta as u8) {
17282            *pos += 1;
17283            let b = buf.get(*pos).copied().unwrap_or(0) ^ 32;
17284            *pos += 1;
17285            return b as i32; // c:7152
17286        }
17287        return match buf.get(*pos).copied() {
17288            Some(b) if b != 0 => {
17289                *pos += 1;
17290                b as i32 // c:7154
17291            }
17292            _ => -1, // c:7154 EOF
17293        };
17294    }
17295    if *readchar >= 0 {
17296        // c:7156
17297        let cc = *readchar as u8; // c:7157
17298        *readchar = -1; // c:7158
17299        return cc as i32; // c:7159
17300    }
17301    loop {
17302        // c:7161 — read a character from readfd.
17303        let fd = readfd.load(Relaxed);
17304        let mut cc = [0u8; 1];
17305        let ret = unsafe { libc::read(fd, cc.as_mut_ptr() as *mut libc::c_void, 1) }; // c:7163
17306        match ret {
17307            1 => return cc[0] as i32, // c:7166
17308            -1 => {
17309                let e = io::Error::last_os_error().raw_os_error().unwrap_or(0);
17310                if !retry
17311                    && fd == 0
17312                    && (e == libc::EAGAIN || e == libc::EWOULDBLOCK)
17313                    && crate::ported::utils::setblock_stdin() != 0
17314                {
17315                    // c:7170-7181
17316                    retry = true;
17317                    continue;
17318                } else if e == libc::EINTR
17319                    && !(errflag.load(Relaxed) != 0
17320                        || RETFLAG.load(Relaxed) != 0
17321                        || BREAKS.load(Relaxed) != 0
17322                        || CONTFLAG.load(Relaxed) != 0)
17323                {
17324                    continue; // c:7183
17325                }
17326            }
17327            _ => {}
17328        }
17329        return -1; // c:7187 EOF
17330    }
17331}
17332
17333/// Port of `testlex()` from Src/builtin.c:7200.
17334/// C: `void testlex(void)` — advance the test-builtin lexer one token
17335///   from `testargs` into `tok`/`tokstr`. Maps `-o`→DBAR, `-a`→DAMPER,
17336///   `!`→BANG, `(`→INPAR, `)`→OUTPAR, `<`→INANG, `>`→OUTANG, otherwise STRING.
17337///   `parse_cond` reaches it through the `condlex` pointer (parse.rs
17338///   `CONDLEX_TESTLEX`).
17339pub fn testlex() {
17340    use crate::ported::lex::{set_tok, set_tokstr, tok};
17341    use crate::ported::zsh_h::{
17342        BANG_TOK, DAMPER, DBAR, INANG_TOK, INPAR_TOK, LEXERR, NULLTOK, OUTANG_TOK, OUTPAR_TOK,
17343        STRING_LEX,
17344    };
17345    // c:7203
17346    if tok() == LEXERR {
17347        return;
17348    }
17349
17350    TESTARGS.with_borrow(|testargs| {
17351        let i = TESTARGS_IDX.get();
17352        CURTESTARG.set(i); // c:7205 — `curtestarg = testargs`
17353        set_tokstr(testargs.get(i).cloned()); // c:7205 — `tokstr = *curtestarg`
17354        let Some(arg) = testargs.get(i) else {
17355            // c:7206 — `if (!*testargs)`
17356            // c:7208 — if tok is already zero, reading past the end: error
17357            set_tok(if tok() != NULLTOK { NULLTOK } else { LEXERR });
17358            return;
17359        };
17360        set_tok(match arg.as_str() {
17361            "-o" => DBAR,       // c:7211
17362            "-a" => DAMPER,     // c:7213
17363            "!" => BANG_TOK,    // c:7215
17364            "(" => INPAR_TOK,   // c:7217
17365            ")" => OUTPAR_TOK,  // c:7219
17366            "<" => INANG_TOK,   // c:7221
17367            ">" => OUTANG_TOK,  // c:7223
17368            _ => STRING_LEX,    // c:7225
17369        });
17370        TESTARGS_IDX.set(i + 1); // c:7226 — `testargs++`
17371    });
17372}
17373
17374/// Port of `bin_test()` from `Src/builtin.c:7231`.
17375/// C decl: `bin_test(char *name, char **argv, UNUSED(Options ops), int func)`
17376/// C: `int bin_test(char *name, char **argv, UNUSED(Options ops), int func)`
17377/// — the `test` / `[` builtin: when invoked as `[`, requires a trailing
17378///   `]`; XSI-extension paren-stripping for 3/4-arg forms; the argv is
17379///   compiled to wordcode by `parse_cond` and run through `evalcond`,
17380///   returning 0/1/2.
17381/// WARNING: param names don't match C — Rust=(name, argv, func) vs C=(name, argv, ops, func)
17382pub fn bin_test(
17383    name: &str,
17384    argv: &[String], // c:7231
17385    _ops: &options,
17386    func: i32,
17387) -> i32 {
17388    use crate::ported::lex::tok;
17389    use crate::ported::parse::{parse_cond, CONDLEX_TESTLEX};
17390    use crate::ported::zsh_h::{estate, LEXERR, NULLTOK};
17391    // !!! DASH-FAITHFUL GATE (no C counterpart) !!! dash runs its own
17392    // `testcmd` grammar (src/bltin/test.c), not zsh's par_cond.
17393    if crate::dash_mode::dash_faithful() {
17394        return crate::dash_mode::dash_test(name, argv);
17395    }
17396    let mut argv = argv.to_vec();
17397    let mut sense = 0i32; // c:7236
17398
17399    // c:7239-7247 — `[` requires trailing `]`.
17400    if func == BIN_BRACKET {
17401        // c:7239
17402        if argv.is_empty() || argv.last().map(|s| s.as_str()) != Some("]") {
17403            // c:7241
17404            zwarnnam(name, "']' expected"); // c:7243
17405            return 2; // c:7244
17406        }
17407        argv.pop(); // c:7246 (s[-1] = NULL)
17408    }
17409
17410    // c:7249-7250 — empty argv → false (1).
17411    if argv.is_empty() {
17412        // c:7249
17413        return 1; // c:7250
17414    }
17415
17416    // !!! BASH/KSH-MODE (no C counterpart) !!! bash/ksh two-argument `test -a FILE`
17417    // (exists, same as `-e`) and `test -o OPTION` (option is on). In zsh's
17418    // grammar (c:7276+) both `-a` and `-o` are the binary AND / OR, so a lone
17419    // operand is "too many arguments".
17420    if (crate::dash_mode::bash_mode() || crate::dash_mode::korn_mode()) && argv.len() == 2 {
17421        if argv[0] == "-a" {
17422            argv[0] = "-e".to_string();
17423        } else if argv[0] == "-o" {
17424            let r = crate::ported::cond::optison(Some(name), &argv[1]);
17425            return if r == 0 { 0 } else { 1 };
17426        }
17427    }
17428
17429    // c:7257-7274 — XSI 3/4-arg parens + 4-arg `!` extension.
17430    let nargs = argv.len(); // c:7257
17431    if nargs == 3 || nargs == 4 {
17432        // c:7258
17433        // c:7264-7269 — strip `(` ... `)` parens unless the 3-arg middle
17434        // would be a binary op (which takes priority).
17435        if argv[0] == "(" && argv[nargs - 1] == ")"                          // c:7264
17436            && (nargs != 3 || crate::ported::text::is_cond_binary_op(&argv[1]) == 0)
17437        // c:7265
17438        {
17439            argv.pop(); // c:7266
17440            argv.remove(0); // c:7267
17441        }
17442        if nargs == 4 && argv[0] == "!" {
17443            // c:7270 — the `!` negation short-circuit fires ONLY at FOUR args
17444            // (`[ ! a = b ]` → negate the 3-arg binary test). At THREE args the
17445            // leading `!` is NOT stripped: it falls through to the grammar,
17446            // which applies the POSIX rule "if $2 is a binary operator, test $1
17447            // op $3" — so `[ ! = x ]` is the string comparison "!" = "x".
17448            sense = 1; // c:7272
17449            argv.remove(0); // c:7273
17450        }
17451    }
17452
17453    // c:7276-7281 — `zcontext_save(); testargs = argv; tok = NULLTOK;
17454    //                condlex = testlex; testlex(); prog = parse_cond();
17455    //                condlex = zshlex;`
17456    crate::ported::context::zcontext_save(); // c:7276
17457    let nargv = argv.len();
17458    TESTARGS.with_borrow_mut(|a| *a = argv); // c:7277 — testargs = argv
17459    TESTARGS_IDX.set(0);
17460    crate::ported::lex::set_tok(NULLTOK); // c:7278
17461    CONDLEX_TESTLEX.set(true); // c:7279 — condlex = testlex
17462    testlex(); // c:7280
17463    let prog = parse_cond(); // c:7281
17464    CONDLEX_TESTLEX.set(false); // c:7282 — condlex = zshlex
17465
17466    // c:7284-7288 — a COND_ERROR already printed its diagnostic.
17467    if errflag.load(Relaxed) != 0 {
17468        // c:7284
17469        errflag.fetch_and(!ERRFLAG_ERROR, Relaxed); // c:7285
17470        crate::ported::context::zcontext_restore(); // c:7286
17471        return 2; // c:7287
17472    }
17473    // c:7290-7294 — a bare YYERROR prints here. `tokstr` is NULL exactly when
17474    // the lexer ran off the end of the argument list.
17475    if prog.is_none() || tok() == LEXERR {
17476        // c:7290
17477        zwarnnam(
17478            name,
17479            if crate::ported::lex::tokstr().is_some() {
17480                "parse error"
17481            } else {
17482                "argument expected"
17483            },
17484        ); // c:7291
17485        crate::ported::context::zcontext_restore(); // c:7292
17486        return 2; // c:7293
17487    }
17488    crate::ported::context::zcontext_restore(); // c:7295
17489
17490    // c:7297-7300 — `if (*curtestarg)`: the grammar stopped before consuming
17491    // every argument.
17492    if CURTESTARG.get() < nargv {
17493        // c:7297
17494        zwarnnam(name, "too many arguments"); // c:7298
17495        return 2; // c:7299
17496    }
17497
17498    // c:7302-7308 — syntax is OK, so evaluate.
17499    let prog = prog.unwrap();
17500    let strs = prog.strs.clone();
17501    let mut state = estate {
17502        // c:7303 — `state.prog = prog; state.pc = prog->prog;`
17503        prog: Box::new(prog),
17504        pc: 0,
17505        strs, // c:7305 — `state.strs = prog->strs;`
17506        strs_offset: 0,
17507    };
17508
17509    // c:7307 — `ret = evalcond(&state, name);`. `name` is C's `fromtest`
17510    // signal — non-NULL means "called from test/[", which enables the strict
17511    // integer-expression error path (c:Src/cond.c:236-251). Bug #411.
17512    let mut ret = crate::ported::cond::evalcond(&mut state, Some(name)); // c:7307
17513
17514    // c:7308-7309 — `if (ret < 2 && sense) ret = !ret;`
17515    if ret < 2 && sense != 0 {
17516        // c:7308
17517        ret = if ret == 0 { 1 } else { 0 }; // c:7309
17518    }
17519    ret // c:7310
17520}
17521
17522/// Port of `bin_times()` from `Src/builtin.c:7324`.
17523/// C decl: `bin_times(UNUSED(char *name), UNUSED(char **argv), UNUSED(Options ops), UNUSED(int func))`
17524/// C: `int bin_times(UNUSED args)` — `times(&buf)`; print user/system
17525///   for self then for children, separated by spaces and newlines.
17526/// WARNING: param names don't match C — Rust=(_name, _argv, _func) vs C=(name, argv, ops, func)
17527pub fn bin_times(
17528    _name: &str,
17529    _argv: &[String], // c:7328
17530    _ops: &options,
17531    _func: i32,
17532) -> i32 {
17533    let mut buf: libc::tms = unsafe { std::mem::zeroed() }; // c:7331
17534                                                            // c:7332 — `long clktck = get_clktck();`. The previous Rust port
17535                                                            // inlined a `sysconf(_SC_CLK_TCK)` call here. Route through the
17536                                                            // canonical `get_clktck()` port at jobs.rs:567 so any future
17537                                                            // hardening (caching, error fallback) propagates to every caller.
17538    let clktck = crate::ported::jobs::get_clktck() as f64; // c:7332
17539    let clktck = if clktck <= 0.0 { 100.0 } else { clktck };
17540    // c:7335 — `if (times(&buf) == -1) return 1;`
17541    if unsafe { libc::times(&mut buf) } == (-1i64) as libc::clock_t {
17542        // c:7335
17543        return 1; // c:7336
17544    }
17545    let pttime = |t: libc::clock_t| {
17546        // c:Src/builtin.c:7315-7318 — `printf("%ldm%ld.%02lds", X/(60*clktck),
17547        //   X/clktck%clktck, X*100/clktck%100)`. Integer-arithmetic
17548        // breakdown: minutes (no zero-pad), seconds (no zero-pad), and
17549        // centiseconds (2-digit zero-pad). Bug #499 — previous Rust
17550        // port used floating-point `%.3f` with 3-decimal precision,
17551        // diverging from zsh's `0m0.00s`.
17552        // ZSHRS-ONLY dispatch: the fraction width and seconds padding
17553        // differ per shell — bash prints milliseconds, dash microseconds,
17554        // mksh zero-pads the seconds — so a drop-in cannot use zsh's
17555        // centisecond form. `emulation_output::times_field` carries the
17556        // captured table; the zsh arm there is this c:7315-7318 formula.
17557        print!(
17558            "{}",
17559            crate::extensions::emulation_output::times_field(t as i64, clktck as i64)
17560        );
17561    };
17562    pttime(buf.tms_utime); // c:7332
17563    print!(" "); // c:7333
17564    pttime(buf.tms_stime); // c:7334
17565    println!(); // c:7335
17566    pttime(buf.tms_cutime); // c:7336
17567    print!(" "); // c:7337
17568    pttime(buf.tms_cstime); // c:7338
17569    println!(); // c:7339
17570    0 // c:7340
17571}
17572
17573/// Port of `bin_trap()` from `Src/builtin.c:7347`.
17574/// C decl: `bin_trap(char *name, char **argv, UNUSED(Options ops), UNUSED(int func))`
17575/// C: `int bin_trap(char *name, char **argv, ...)` — list, clear, or
17576///   set signal traps.
17577/// WARNING: param names don't match C — Rust=(name, argv, _func) vs C=(name, argv, ops, func)
17578pub fn bin_trap(
17579    name: &str,
17580    argv: &[String], // c:7347
17581    _ops: &options,
17582    _func: i32,
17583) -> i32 {
17584    // PFA-SMR aspect: record `trap HANDLER SIG...` calls. Skip
17585    // listing-only forms (`trap`, `trap -l`, `trap -p`) — those don't
17586    // mutate state.
17587    #[cfg(feature = "recorder")]
17588    if crate::recorder::is_enabled() {
17589        let listing = argv.is_empty() || (argv.len() == 1 && (argv[0] == "-l" || argv[0] == "-p"));
17590        if !listing && argv.len() >= 2 {
17591            let ctx = crate::recorder::recorder_ctx_global();
17592            let handler = &argv[0];
17593            for sig in &argv[1..] {
17594                crate::recorder::emit_trap(sig, handler, ctx.clone());
17595            }
17596        }
17597    }
17598
17599    if crate::dash_mode::bash_mode() {
17600        if let Some(rc) = crate::extensions::emulation_output::bash_trap_return(argv) {
17601            return rc;
17602        }
17603    }
17604
17605    // !!! PDKSH-FAMILY-ONLY (no C counterpart) !!! mksh's `trap` takes no options
17606    // at all (`--` excepted), and `trap` is a POSIX special builtin, so a usage
17607    // error aborts a non-interactive shell:
17608    //   mksh -c 'trap -x; echo after'  -> "trap: -x: unknown option", rc 1, no `after`
17609    // (also `-l`, `-p`, clusters like `-ZZ`, which report the first letter).
17610    if crate::dash_mode::posix_faithful() && crate::dash_mode::pdksh_family() {
17611        if let Some(bad) = argv
17612            .first()
17613            .filter(|a| a.len() > 1 && a.starts_with('-') && *a != "--")
17614        {
17615            zerrnam(name, &format!("-{}: unknown option", bad.chars().nth(1).unwrap_or('-')));
17616            return 1;
17617        }
17618    }
17619
17620    // !!! DASH-FAITHFUL GATE (no C counterpart) !!! dash's trap takes no
17621    // options (`trap -p`/`-x` -> "Illegal option", status 2) and a lone `-` is
17622    // an action with no signal ("bad trap", status 1).
17623    if crate::dash_mode::dash_faithful() {
17624        match argv.first().map(String::as_str) {
17625            Some("-") if argv.len() == 1 => {
17626                zwarnnam(name, "-: bad trap");
17627                return 1;
17628            }
17629            Some(a) if a.len() >= 2 && a.starts_with('-') && a != "--" => {
17630                zwarnnam(name, &format!("Illegal option {}", &a[..2]));
17631                // `trap` is a POSIX special builtin: the error ends a script.
17632                if !crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE) {
17633                    EXIT_VAL.store(2, std::sync::atomic::Ordering::Relaxed);
17634                    EXIT_PENDING.store(1, std::sync::atomic::Ordering::Relaxed);
17635                }
17636                return 2;
17637            }
17638            _ => {}
17639        }
17640    }
17641
17642    // ZSHRS-ONLY. `trap -l` is bash's spelling of `kill -l` and lists the
17643    // signals; the Korn and Bourne shells REJECT it ("trap: -l: unknown
17644    // option", exit 2) and zsh treats `-l` as a trap body with no signal
17645    // (c:Src/builtin.c:7382-7388: `signal expected`, rc 1). zshrs matched
17646    // zsh in every mode, so `--bash` printed nothing where bash prints
17647    // the signal table, and the POSIX drop-ins succeeded where they
17648    // should fail.
17649    if argv.len() == 1 && argv[0] == "-l" {
17650        use crate::extensions::emulation_startup::{personality, Personality};
17651        match personality() {
17652            Personality::Bash => {
17653                let sigs = crate::extensions::emulation_output::kill_l_signals();
17654                if let Some(rendered) = crate::extensions::emulation_output::kill_list(&sigs) {
17655                    print!("{rendered}");
17656                }
17657                return 0;
17658            }
17659            // Both Korn lines reject it; they differ only in the status
17660            // (ksh93u+m exits 2, mksh exits 1).
17661            Personality::Ksh93 => {
17662                zwarnnam(name, "-l: unknown option");
17663                return 2;
17664            }
17665            Personality::Mksh | Personality::Pdksh => {
17666                zwarnnam(name, "-l: unknown option");
17667                return 1;
17668            }
17669            Personality::Dash | Personality::Sh => {
17670                zwarnnam(name, "Illegal option -l");
17671                return 2;
17672            }
17673            // zsh and csh fall through to C's `signal expected`.
17674            _ => {}
17675        }
17676    }
17677
17678    // !!! EMULATION-ONLY (no C counterpart) !!! ksh93 and bash spell the trap
17679    // listing `trap -p`; zsh has no such option (it reaches the bare-body path
17680    // below). Measured: `ksh -c 'trap "print a" INT; trap -p'` lists the trap.
17681    // mksh has no listing option at all: `mksh -c 'trap -p'` -> "trap: -p: unknown option", 1.
17682    if argv.len() == 1
17683        && argv[0] == "-p"
17684        && matches!(
17685            crate::extensions::emulation_startup::personality(),
17686            crate::extensions::emulation_startup::Personality::Mksh
17687                | crate::extensions::emulation_startup::Personality::Pdksh
17688        )
17689    {
17690        zwarnnam(name, "-p: unknown option");
17691        return 1;
17692    }
17693    // !!! EMULATION-ONLY (no C counterpart) !!! ksh93 `trap -p SIG...` prints just the
17694    // ACTION text of each named signal's trap, one per line (nothing for an unset one):
17695    //   ksh -c 'trap "print a" INT; trap -p INT'  ->  print a
17696    if argv.len() > 1
17697        && argv[0] == "-p"
17698        && crate::extensions::emulation_startup::personality()
17699            == crate::extensions::emulation_startup::Personality::Ksh93
17700    {
17701        let traps = traps_table().lock().map(|t| t.clone()).unwrap_or_default();
17702        for sig in &argv[1..] {
17703            let idx = getsigidx(sig);
17704            if let Some((_, body)) = traps.iter().find(|(k, _)| getsigidx(k) == idx) {
17705                println!("{body}");
17706            }
17707        }
17708        return 0;
17709    }
17710    let list_via_p = argv.len() == 1
17711        && argv[0] == "-p"
17712        && matches!(
17713            crate::extensions::emulation_startup::personality(),
17714            crate::extensions::emulation_startup::Personality::Ksh93
17715                | crate::extensions::emulation_startup::Personality::Bash
17716        );
17717    // bash `trap -p SIG...` lists only the named signals' traps.
17718    let p_filter: Option<Vec<i32>> = (argv.len() > 1
17719        && argv[0] == "-p"
17720        && matches!(
17721            crate::extensions::emulation_startup::personality(),
17722            crate::extensions::emulation_startup::Personality::Bash
17723        ))
17724    .then(|| {
17725        argv[1..]
17726            .iter()
17727            .map(|s| if s == "RETURN" { i32::MAX } else { getsigidx(s) })
17728            .collect()
17729    });
17730    let argv: &[String] = if list_via_p || p_filter.is_some() {
17731        &[]
17732    } else {
17733        argv
17734    };
17735    let mut argv = argv.to_vec();
17736    // c:7353 — `if (*argv && !strcmp(*argv, "--")) argv++;`
17737    if !argv.is_empty() && argv[0] == "--" {
17738        // c:7353
17739        argv.remove(0); // c:7354
17740    }
17741
17742    // c:7357-7380 — no args: list current traps.
17743    if argv.is_empty() {
17744        // c:7357
17745        // Local enum for the merged function-form / string-form
17746        // listing walker below. RUST-ONLY scoping helper for the
17747        // c:Src/builtin.c:7359-7375 if/else-if structure.
17748        enum TrapEntry {
17749            Func(String),
17750            Str(String, String),
17751        }
17752        queue_signals(); // c:7358
17753        let traps = traps_table().lock().map(|t| t.clone()).unwrap_or_default();
17754        // c:Src/builtin.c:7359-7375 — C walks `sigtrapped[]` by signal
17755        // number index, so the listing is naturally sorted by sig num
17756        // (HUP=1, INT=2, …, USR1=30, …). zshrs stores traps in a
17757        // HashMap<name, body> whose iteration order is non-deterministic
17758        // — bug #68 in docs/BUGS.md: `trap` output came out in random
17759        // order vs zsh's stable signum-sorted view. Sort by getsigidx
17760        // before printing so the iteration matches C's array walk.
17761        //
17762        // c:Src/builtin.c:7360-7365 — `if (sigtrapped[sig] & ZSIG_FUNC)
17763        // { hn = gettrapnode(sig, 0); shfunctab->printnode(hn, 0); }`.
17764        // Walk shfunctab for `TRAP<signame>` functions and emit their
17765        // full definition alongside the string-form traps so the
17766        // listing matches zsh. Bug #461 in docs/BUGS.md.
17767        let trap_funcs: Vec<(String, i32, String)> = {
17768            let mut acc: Vec<(String, i32, String)> = Vec::new();
17769            if let Ok(tab) = crate::ported::hashtable::shfunctab_lock().read() {
17770                for (fname, _) in tab.iter() {
17771                    if let Some(sig_name) = fname.strip_prefix("TRAP") {
17772                        let idx = getsigidx(sig_name);
17773                        if idx != -1 {
17774                            acc.push((fname.clone(), idx, sig_name.to_string()));
17775                        }
17776                    }
17777                }
17778            }
17779            acc
17780        };
17781        // Merge function-form + string-form, sort by sig idx
17782        // (function-form FIRST per zsh ordering — c:Src/builtin.c:7359
17783        // walks each signal once; ZSIG_FUNC takes precedence within a
17784        // single signum slot since the C code uses `else if` between
17785        // the two arms).
17786        let mut combined: Vec<(i32, TrapEntry)> = Vec::new();
17787        for (fname, idx, _sig) in &trap_funcs {
17788            combined.push((*idx, TrapEntry::Func(fname.clone())));
17789        }
17790        for (sig, body) in traps.iter() {
17791            let idx = getsigidx(sig);
17792            // Skip if a function-form already exists for this sig
17793            // (C's `else if` semantics — function-form wins).
17794            if trap_funcs.iter().any(|(_, i, _)| *i == idx) {
17795                continue;
17796            }
17797            combined.push((
17798                if idx == -1 { i32::MAX } else { idx },
17799                TrapEntry::Str(sig.clone(), body.clone()),
17800            ));
17801        }
17802        // c:7358-7361 — `} else if (sigtrapped[sig]) { if (!siglists[sig])
17803        //   printf("trap -- '' %s\n", name); ... }`
17804        // C walks the sigtrapped[] ARRAY, so a signal whose trap state is
17805        // set but which has NO body still lists, as `trap -- '' SIG`. The
17806        // canonical case is an INHERITED SIG_IGN recorded at startup
17807        // (c:Src/init.c:1444-1445 sets sigtrapped[SIGQUIT] = ZSIG_IGNORED
17808        // under nohup / a `trap '' QUIT` parent). zshrs builds this listing
17809        // from the name->body table plus shfunctab, neither of which holds a
17810        // body-less entry, so those signals were invisible: `nohup zshrs -fc
17811        // trap` printed nothing where zsh printed `trap -- '' QUIT`.
17812        {
17813            let states: Vec<(usize, i32)> = match crate::ported::signals::sigtrapped.lock() {
17814                Ok(g) => g.iter().copied().enumerate().collect(),
17815                Err(_) => Vec::new(),
17816            };
17817            for (sig, state) in states {
17818                // c:7358 `else if (sigtrapped[sig])`
17819                if state == 0 || (state & crate::ported::zsh_h::ZSIG_FUNC) != 0 {
17820                    continue;
17821                }
17822                let idx = sig as i32;
17823                // Already covered by a function-form or a stored body.
17824                if combined.iter().any(|(i, _)| *i == idx) {
17825                    continue;
17826                }
17827                let name = crate::ported::jobs::getsigname(idx); // c:7359
17828                if name.is_empty() {
17829                    continue;
17830                }
17831                if traps.contains_key(&name) {
17832                    continue;
17833                }
17834                // c:7360-7361 — no siglists[sig] body → empty-body entry.
17835                combined.push((idx, TrapEntry::Str(name, String::new())));
17836            }
17837        }
17838        if let Some(f) = &p_filter {
17839            combined.retain(|(i, _)| f.contains(i));
17840        }
17841        combined.sort_by_key(|(idx, _)| *idx);
17842        // !!! EMULATION-ONLY (no C counterpart) !!! ksh93 walks the trap
17843        // table downwards; c:7358 and every other shell walk it upwards.
17844        // See dash_mode::trap_listing_descending (false in --zsh).
17845        if crate::dash_mode::trap_listing_descending() {
17846            combined.reverse();
17847        }
17848        for (_idx, entry) in &combined {
17849            match entry {
17850                TrapEntry::Func(fname) => {
17851                    if let Ok(tab) = crate::ported::hashtable::shfunctab_lock().read() {
17852                        if let Some(shf) = tab.get(fname) {
17853                            crate::ported::hashtable::printshfuncnode(shf, 0);
17854                        }
17855                    }
17856                }
17857                TrapEntry::Str(sig, body) => {
17858                    // !!! EMULATION-ONLY (no C counterpart) !!! bash, dash,
17859                    // ash, /bin/sh, ksh93 and mksh all echo the RAW trap body
17860                    // instead of re-rendering the compiled Eprog, quote it
17861                    // unconditionally (except the Korn pair), and — bash only
17862                    // — print the signal name with a `SIG` prefix. Measured on
17863                    // the real binaries; see dash_mode::trap_listing_line.
17864                    if let Some(line) = crate::dash_mode::trap_listing_line(sig, body) {
17865                        println!("{}", line);
17866                        continue;
17867                    }
17868                    // c:7371 — `s = getpermtext(siglists[sig], NULL, 0);`. C
17869                    // holds the body as a compiled Eprog and renders it back
17870                    // to source for the listing, so what prints is CANONICAL
17871                    // text rather than the string the user typed:
17872                    // `trap 'print a; print b' EXIT` lists as
17873                    // `trap -- $'print a\nprint b' EXIT` (separators become
17874                    // newlines, `if`/`for`/`(` bodies get re-indented).
17875                    // zshrs stores the body as raw text, so deparse it here
17876                    // the same way. Rendering only at listing time keeps the
17877                    // stored text authoritative for execution — a getpermtext
17878                    // rendering bug then costs a wrong listing, not a wrong
17879                    // trap body.
17880                    //
17881                    // An empty body takes C's `!siglists[sig]` branch
17882                    // (c:7368-7369, prints `trap -- '' SIG`) and is never
17883                    // rendered. parse_string is the wordcode parser, whose
17884                    // coverage is narrower than the AST parser that executes
17885                    // the body (e.g. `(pat)` case arms — see the pin test in
17886                    // text.rs), so fall back to the raw text on a parse
17887                    // failure rather than dropping the entry.
17888                    let rendered = if body.is_empty() {
17889                        body.clone()
17890                    } else {
17891                        crate::ported::exec::parse_string(body, 1)
17892                            .map(|p| crate::ported::text::getpermtext(Box::new(p), None, 0))
17893                            .unwrap_or_else(|| body.clone())
17894                    };
17895                    // c:7370-7375 — `printf("trap -- "); quotedzputs(...); printf(" %s\n", name);`
17896                    print!("trap -- "); // c:7372
17897                    print!("{}", quotedzputs(&rendered)); // c:7373
17898                    println!(" {}", sig); // c:7374
17899                }
17900            }
17901        }
17902        unqueue_signals(); // c:7378
17903        return 0; // c:7379
17904    }
17905
17906    // c:7384-7400 — first arg is signal number / single `-` → clear.
17907    let first = &argv[0];
17908    if getsigidx(first) != -1 || first == "-" {
17909        // c:7384
17910        let start = if first == "-" { 1 } else { 0 }; // c:7385
17911                                                      // c:7399 — `return *argv != NULL;`. After a successful loop
17912                                                      // *argv is the trailing NULL (Rust: idx == len after the
17913                                                      // walk); on `break` due to an undefined signal *argv is the
17914                                                      // bad arg (idx < len). Previous Rust port hardcoded
17915                                                      // `return 0`, so `trap - INVALID` would silently report
17916                                                      // success and downstream scripts couldn't detect the bad
17917                                                      // signal name.
17918        let mut had_error = 0i32;
17919        // c:7386-7398 — build the signal list, then run ONE
17920        // `unsettrap` body over it. `trap -` clears every slot
17921        // (c:7387-7388: `for (sig = 0; sig < TRAPCOUNT; sig++)
17922        // unsettrap(sig);`); `trap - SIG…` clears the named ones and
17923        // stops at the first undefined name (c:7390-7398), leaving
17924        // the already-walked signals cleared exactly as C does.
17925        let mut to_clear: Vec<i32> = Vec::new();
17926        if start >= argv.len() {
17927            // c:7386
17928            to_clear.extend(0..crate::ported::signals_h::TRAPCOUNT); // c:7387
17929        } else {
17930            for arg in &argv[start..] {
17931                // c:7390
17932                let sig = getsigidx(arg);
17933                if sig == -1 {
17934                    // c:7392
17935                    zwarnnam(name, &format!("undefined signal: {}", arg)); // c:7393
17936                    had_error = 1; // c:7399 *argv non-NULL on break
17937                    break; // c:7394
17938                }
17939                to_clear.push(sig);
17940            }
17941        }
17942        for sig in to_clear {
17943            // c:7388 / c:7397 — `unsettrap(sig)`. C's unsettrap
17944            // (c:Src/signals.c:759) → removetrap (c:Src/signals.c:772)
17945            // does three things the previous zshrs clear path skipped
17946            // by only doing `traps_table().remove()`:
17947            //   - c:Src/signals.c:800 `sigtrapped[sig] = 0`, so
17948            //     handletrap stops claiming the signal;
17949            //   - c:Src/signals.c:815 `signal_default(sig)`, which
17950            //     RESTORES THE DEFAULT DISPOSITION — this is why
17951            //     `trap 'print caught' USR2; kill -USR2 $$; trap - USR2;
17952            //     kill -USR2 $$` kills the shell in zsh instead of
17953            //     silently ignoring the second signal;
17954            //   - c:Src/signals.c:836-843 `removehashnode(shfunctab,
17955            //     node->nam)`, dropping the `TRAP<sig>` function-form
17956            //     trap (freed by unsettrap at c:765).
17957            // unsettrap MUST run FIRST: removetrap → dosavetrap (c:774)
17958            // snapshots the CURRENT body for the LOCAL_TRAPS restore.
17959            crate::ported::signals::unsettrap(sig);
17960            // c:Src/signals.c:846 `siglists[sig] = NULL` — the body
17961            // drop. zshrs keeps trap bodies in `traps_table` keyed by
17962            // canonical signal NAME (C indexes siglists[] by number),
17963            // so the number has to be mapped back to the name bin_trap
17964            // installed it under (`0` → `EXIT`).
17965            let key = if sig == 0 {
17966                "EXIT".to_string()
17967            } else {
17968                crate::ported::signals_h::sigs_name(sig)
17969                    .map(|s| s.to_string())
17970                    .unwrap_or_else(|| sig.to_string())
17971            };
17972            if let Ok(mut t) = traps_table().lock() {
17973                t.remove(&key);
17974            }
17975            // c:Src/signals.c:836-843 — ZSIG_FUNC arm.
17976            if let Ok(mut tab) = crate::ported::hashtable::shfunctab_lock().write() {
17977                tab.remove(&format!("TRAP{}", key));
17978            }
17979        }
17980        return had_error; // c:7399
17981    }
17982
17983    // c:7404-7411 — first arg is the trap body.
17984    let arg = argv.remove(0); // c:7404
17985                              // c:7405-7409 — `if (!*arg) prog = &dummy_eprog; else if (!(prog =
17986                              // parse_string(arg, 1))) { zwarnnam(name, "couldn't parse trap
17987                              // command"); return 1; }`. The body is parsed when the trap is
17988                              // installed, so an unparseable body is rejected here rather than
17989                              // deferred to signal delivery. An empty body (`trap '' SIG`) takes
17990                              // C's dummy_eprog branch and is never parsed. This gate precedes
17991                              // the signal checks below, so `trap 'for' BOGUS` reports the parse
17992                              // failure, not "undefined signal".
17993                              //
17994                              // C reaches its parser through parse_string (ported at
17995                              // exec.rs:parse_string over the ported lexer). The trap body is
17996                              // stored as text and later run through parse_isolated by
17997                              // execute_script_zsh_pipeline (vm_helper.rs:2249), so validation
17998                              // goes through the same parser that will execute it — otherwise a
17999                              // body could pass here and still fail at delivery. Save/restore
18000                              // errflag around the probe exactly as vm_helper.rs:2240-2251 does,
18001                              // so a rejected body doesn't leave the error flag set and abort the
18002                              // enclosing shell. The parser emits its own `parse error` via zerr
18003                              // first; this adds C's second diagnostic.
18004    if !arg.is_empty() {
18005        let saved_errflag = errflag.load(Ordering::Relaxed);
18006        errflag.fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
18007        let _ = crate::vm_helper::parse_isolated(&arg);
18008        let parse_failed =
18009            (errflag.load(Ordering::Relaxed) & crate::ported::zsh_h::ERRFLAG_ERROR) != 0;
18010        errflag.store(saved_errflag, Ordering::Relaxed);
18011        if parse_failed {
18012            zwarnnam(name, "couldn't parse trap command"); // c:7407
18013            return 1; // c:7408
18014        }
18015    }
18016    if argv.is_empty() {
18017        // c:7382-7388 (upstream a3547fd4c1, 54013) — a body with no
18018        // signal after it is an error: `undefined signal: ARG` when
18019        // the lone arg looks like a signal (numeric or SIG-prefixed),
18020        // `signal expected` otherwise. zsh 5.9.2 accepted the bare
18021        // body silently; master rejects it (`trap -l`, `trap -p` and
18022        // `trap cmd` all take this branch).
18023        if arg.starts_with("SIG") || arg.chars().next().is_some_and(|c| c.is_ascii_digit()) {
18024            zwarnnam(name, &format!("undefined signal: {}", arg)); // c:7384
18025        } else {
18026            zwarnnam(name, "signal expected"); // c:7386
18027        }
18028        return 1; // c:7387
18029    }
18030
18031    // c:7421-7448 — install trap on each named signal.
18032    let mut trap_install_error = 0i32;
18033    for sigarg in &argv {
18034        // c:7421
18035        let sig = getsigidx(sigarg);
18036        if sig == -1 {
18037            // c:7426
18038            zwarnnam(name, &format!("undefined signal: {}", sigarg)); // c:7427
18039            trap_install_error = 1; // c:7445 *argv non-NULL on break
18040            break; // c:7428
18041        }
18042        // !!! DASH-STRICT GATE (no C counterpart) !!! dash's trap table is the
18043        // real signals plus EXIT/0: the zsh pseudo-signals ERR/ZERR/DEBUG and the
18044        // `SIG`-prefixed spellings are "bad trap" (status 1, rest of the list skipped).
18045        if crate::dash_mode::dash_strict()
18046            && (sig == crate::ported::signals_h::SIGZERR
18047                || sig == crate::ported::signals_h::SIGDEBUG
18048                || sigarg.to_ascii_uppercase().starts_with("SIG"))
18049        {
18050            zwarnnam(name, &format!("{}: bad trap", sigarg));
18051            trap_install_error = 1;
18052            break;
18053        }
18054        // c:Src/signals.c — C zsh stores traps in a fixed array
18055        // indexed by signal number. Aliases (`0`, `EXIT`, `SIGEXIT`)
18056        // all resolve to index 0 and share the same slot. The Rust
18057        // port stores by name string, so we must normalize the key
18058        // to the canonical signal name (or "EXIT" for the 0 alias)
18059        // — otherwise `trap 'echo bye' 0` lands in a `"0"` slot
18060        // that nothing else looks up.
18061        let canonical = if sig == 0 {
18062            "EXIT".to_string()
18063        } else if let Some(name) = crate::ported::signals_h::sigs_name(sig) {
18064            // c:Src/builtin.c — when sigarg is numeric (`trap "" 2`),
18065            // the listing path must still emit the SYMBOLIC name
18066            // (`INT`) so the canonical name lookup downstream works.
18067            // Previously zshrs's `to_uppercase()` of "2" left the key
18068            // as "2", so `trap "" 2; trap` listed `trap -- '' 2`
18069            // instead of `trap -- '' INT`. Resolve to the canonical
18070            // signal name via sigs_name(idx).
18071            //
18072            // c:Src/jobs.c:2740 alt_sigs + jobs.c:3089 getsigname — a trap
18073            // set via an ALIAS name lists under the ALIAS, not the
18074            // canonical sigs[] name (C flags it ZSIG_ALIAS). The only
18075            // unconditional alt_sigs entry is `{ "ERR", SIGZERR }`, so
18076            // `trap … ERR; trap` must print `… ERR`, not `… ZERR`. The
18077            // Rust port stores traps by name string; preserve the alias
18078            // the user typed as the key. The dotrap dispatch already
18079            // resolves SIGZERR through both "ZERR" and "ERR"
18080            // (signals.rs:1505), so firing is unaffected.
18081            let normalized = sigarg
18082                .strip_prefix("SIG")
18083                .or_else(|| sigarg.strip_prefix("sig"))
18084                .unwrap_or(sigarg.as_str())
18085                .to_uppercase();
18086            if sig == crate::ported::signals_h::SIGZERR && normalized == "ERR" {
18087                "ERR".to_string()
18088            } else {
18089                name.to_string()
18090            }
18091        } else {
18092            // Strip SIG/sig prefix and uppercase so `SIGINT` / `int`
18093            // / `INT` all map to the same key.
18094            sigarg
18095                .strip_prefix("SIG")
18096                .or_else(|| sigarg.strip_prefix("sig"))
18097                .unwrap_or(sigarg.as_str())
18098                .to_uppercase()
18099        };
18100        // c:Src/signals.c settrap — register both the libc signal
18101        // handler AND the sigtrapped[idx] flag. Without setting
18102        // sigtrapped, handletrap() early-returns 0 (sees the slot
18103        // as "not trapped") and the dotrap dispatch never fires.
18104        // The traps_table entry alone isn't enough — handletrap
18105        // gates on sigtrapped[idx] != 0.
18106        //
18107        // c:Src/signals.c:693 settrap flag semantics:
18108        //   ZSIG_FUNC → trap body is a shell function (TRAPSIG)
18109        //   no flag   → trap body is an Eprog in siglists[sig]
18110        // zshrs stores the body STRING in `traps_table` instead of
18111        // either, but the runtime's `endtrapscope`/`dotrap` dispatch
18112        // (signals.rs:1249) requires the !ZSIG_FUNC arm to fire so
18113        // the traps_table lookup happens. Passing ZSIG_FUNC sent it
18114        // down the shfunc-dispatch arm which fails silently when no
18115        // TRAPSIG function exists — bug #80 in docs/BUGS.md.
18116        //
18117        // SIGEXIT (sig=0) must also reach settrap so locallevel
18118        // tagging lands and `endtrapscope` fires the function-local
18119        // EXIT trap at function exit (the previous `sig > 0` gate
18120        // skipped it entirely).
18121        //
18122        // CRITICAL ORDER: settrap calls unsettrap → dosavetrap which
18123        // SNAPSHOTS the current traps_table body for the save-stack.
18124        // settrap MUST run BEFORE inserting the new body so the saved
18125        // entry captures the OUTER scope's body, not the one we're
18126        // about to install. (Reversed order led to the save loop
18127        // restoring the just-installed body back into traps_table
18128        // after the function exit, causing the script-end trap fire
18129        // to re-run the inner body.)
18130        // c:7429 — C calls `settrap(sig, prog, 0)` for EVERY valid trap
18131        // index, pseudo-signals included: `sig < TRAPCOUNT` is the only
18132        // bound (c:7421-7448). The `sig <= SIGCOUNT` cap here excluded
18133        // ZERR (SIGCOUNT+1) and DEBUG (SIGCOUNT+2), so those two never
18134        // reached settrap → unsettrap → removetrap → dosavetrap and were
18135        // therefore never saved/restored by the LOCAL_TRAPS scope stack:
18136        // `f() { setopt localtraps; trap '…' DEBUG }; f` left the DEBUG
18137        // trap armed for the rest of the shell (C05debug:1,5,6).
18138        // settrap itself already gates install_handler/signal_ignore on
18139        // `sig != 0 && sig <= SIGCOUNT` (c:714-736), so widening the
18140        // bound here touches no libc disposition.
18141        if sig >= 0 && sig < crate::ported::signals_h::TRAPCOUNT && sig != libc::SIGCHLD as i32 {
18142            // c:Src/signals.c:712 — `if (!(flags & ZSIG_FUNC) && empty_eprog(l))`.
18143            // settrap treats an `l == None` (or empty Eprog) body as
18144            // `trap '' SIG` — i.e. ZSIG_IGNORED + signal_ignore(sig).
18145            // That suppresses the signal entirely AND skips
18146            // install_handler, so a non-empty `trap "echo TRAP" SIG`
18147            // would never reach handletrap → dotrap. C-zsh's bin_trap
18148            // parses the body string into an Eprog via parse_string()
18149            // before passing to settrap. The zshrs port stores the
18150            // body as plain text in `traps_table` and dispatches via
18151            // execute_script (the Eprog parser isn't on the
18152            // critical path), so we need a placeholder Eprog whose
18153            // only purpose is to make empty_eprog() return false and
18154            // steer settrap down the ZSIG_TRAPPED + install_handler
18155            // branch. The placeholder body is never executed — dotrap
18156            // reads the dispatch text from traps_table.
18157            //
18158            // Bug #104 in docs/BUGS.md: signal sent from inside a
18159            // function via `kill -X $$` was lost because settrap had
18160            // routed USR1 to signal_ignore at trap-install time, so
18161            // when the signal hit while doshfunc had queue_signals()
18162            // active, nothing in the queue → nothing to dispatch on
18163            // unqueue. Empty-body path (`trap '' SIG`) still passes
18164            // None as before so the ZSIG_IGNORED branch fires.
18165            let body_eprog: Option<crate::ported::zsh_h::Eprog> = if arg.is_empty() {
18166                None
18167            } else {
18168                let mut prog = crate::ported::zsh_h::eprog::default();
18169                // c:Src/parse.c:584 — empty_eprog returns true on
18170                // `prog[0] == WCB_END`; push any non-WCB_END value
18171                // (1 is arbitrary, matches the existing
18172                // `empty_eprog_non_empty_non_end_returns_false`
18173                // pin test in parse.rs).
18174                prog.prog.push(1);
18175                Some(Box::new(prog))
18176            };
18177            settrap(sig, body_eprog, 0);
18178        }
18179        // c:Src/signals.c:707 — settrap → unsettrap → removetrap
18180        // (c:836-843 removeshfuncnode) drops any pre-existing
18181        // FUNCTION-form trap for this signal BEFORE the new (string-form)
18182        // body is installed. zshrs's removetrap does not touch shfunctab,
18183        // so a leftover `TRAPZERR() { … }` would shadow a later `trap
18184        // 'body' ERR` — the dispatch prefers the function form, so the OLD
18185        // function fired instead of the new string body. Remove it here,
18186        // mirroring the `trap -` unset path above. Keyed by the CANONICAL
18187        // signal name (sigs_name), so both `ERR` and `ZERR` spellings drop
18188        // the same `TRAPZERR` node. Runs for VIRTUAL signals too (ZERR /
18189        // DEBUG / EXIT sit beyond SIGCOUNT, so the settrap block above is
18190        // skipped for them — the earlier placement inside that block never
18191        // fired for ZERR).
18192        let func_key = if sig == 0 {
18193            "EXIT".to_string()
18194        } else {
18195            crate::ported::signals_h::sigs_name(sig)
18196                .map(|s| s.to_string())
18197                .unwrap_or_else(|| sig.to_string())
18198        };
18199        if let Ok(mut tab) = crate::ported::hashtable::shfunctab_lock().write() {
18200            tab.remove(&format!("TRAP{}", func_key));
18201        }
18202        if let Ok(mut t) = traps_table().lock() {
18203            t.insert(canonical.clone(), arg.clone()); // c:7448 (effective)
18204        }
18205        // c:Src/signals.c::settrap — when a string-form trap
18206        // replaces an existing trap, settrap calls unsettrap which
18207        // clears the C `sigfuncs[sig]` shfunc slot. The zshrs port
18208        // dispatches function-form TRAPxxx through shfunctab
18209        // independently of sigfuncs, so a pre-existing TRAPxxx
18210        // function survives the string-form install and BOTH fire
18211        // on signal delivery. Remove any matching TRAPxxx shfunc
18212        // here so dotrap dispatches the just-installed string body
18213        // only. Bug #541 in docs/BUGS.md.
18214        let trap_fn_name = format!("TRAP{}", canonical);
18215        if let Ok(mut tab) = crate::ported::hashtable::shfunctab_lock().write() {
18216            tab.remove(&trap_fn_name);
18217        }
18218    }
18219    trap_install_error
18220}
18221
18222/// Port of `bin_ttyctl()` from `Src/builtin.c:7454`.
18223/// C decl: `bin_ttyctl(UNUSED(char *name), UNUSED(char **argv), Options ops, UNUSED(int func))`
18224/// C: `int bin_ttyctl(UNUSED args, Options ops, ...)` — `-f` freezes the
18225///   tty, `-u` unfreezes; otherwise emit `"tty is [not ]frozen"`.
18226/// WARNING: param names don't match C — Rust=(_name, _argv, _func) vs C=(name, argv, ops, func)
18227pub fn bin_ttyctl(
18228    _name: &str,
18229    _argv: &[String], // c:7454
18230    ops: &options,
18231    _func: i32,
18232) -> i32 {
18233    use std::sync::Mutex;
18234    // c:7456-7461 — route through the canonical jobs::TTYFROZEN
18235    // global. The previous builtin.rs duplicate AtomicI32 NEVER synced
18236    // with jobs.rs's Mutex<i32> store; `ttyctl -f` set the local
18237    // Atomic but didn't freeze the tty from the perspective of the
18238    // job-control wait path that reads jobs::TTYFROZEN.
18239    let cell = crate::ported::jobs::TTYFROZEN.get_or_init(|| Mutex::new(0_i32));
18240    if OPT_ISSET(ops, b'f') {
18241        // c:7456
18242        *cell.lock().expect("TTYFROZEN poisoned") = 1; // c:7457
18243    } else if OPT_ISSET(ops, b'u') {
18244        // c:7458
18245        *cell.lock().expect("TTYFROZEN poisoned") = 0; // c:7459
18246    } else {
18247        let f = *cell.lock().expect("TTYFROZEN poisoned");
18248        // c:7461 — `printf("tty is %sfrozen\n", ttyfrozen ? "" : "not ");`
18249        println!("tty is {}frozen", if f != 0 { "" } else { "not " }); // c:7461
18250    }
18251    0 // c:7463
18252}
18253
18254/// Port of `bin_let()` from `Src/builtin.c:7469`.
18255/// C decl: `bin_let(UNUSED(char *name), char **argv, UNUSED(Options ops), UNUSED(int func))`
18256/// C: `int bin_let(UNUSED(char *name), char **argv, UNUSED(Options ops),
18257///     UNUSED(int func))` — evaluate each arg as a math expression;
18258///   return 1 if the final value is zero (success/false), 0 if non-zero
18259///   (true), 2 on math error.
18260/// WARNING: param names don't match C — Rust=(_name, argv, _func) vs C=(name, argv, ops, func)
18261pub fn bin_let(
18262    _name: &str,
18263    argv: &[String], // c:7469
18264    _ops: &options,
18265    _func: i32,
18266) -> i32 {
18267    use crate::ported::utils::{errflag, ERRFLAG_ERROR};
18268    use std::sync::atomic::Ordering;
18269
18270    // c:7472 — `mnumber val = zero_mnumber;`
18271    let mut val: mnumber = mnumber {
18272        l: 0,
18273        d: 0.0,
18274        type_: MN_INTEGER,
18275    }; // c:7472
18276       // c:7474-7475 — `while (*argv) val = matheval(*argv++);` — DO walk
18277       // every arg even if one fails. C doesn't break on error mid-loop;
18278       // it just lets errflag accumulate. Previously the Rust port broke
18279       // on first failure, leaving later args unevaluated.
18280    for expr in argv {
18281        // c:7474
18282        match matheval(expr) {
18283            Ok(v) => {
18284                // c:7475 — `val = matheval(*argv++)`; matheval returns zero when
18285                // the evaluation raised an error (math.c: `if (errflag) { xyval.type
18286                // = MN_INTEGER; xyval.u.l = 0; }`), e.g. an unset parameter under
18287                // NO_UNSET still yields a value from the expression walker here.
18288                val = if (errflag.load(Relaxed) & ERRFLAG_ERROR) != 0 {
18289                    mnumber { l: 0, d: 0.0, type_: MN_INTEGER }
18290                } else {
18291                    v
18292                };
18293            }
18294            Err(msg) => {
18295                // c:Src/math.c:checkunary zerr side-effect — the C
18296                // path writes the parse-error string to stderr via
18297                // mathevali → checkunary → zerr. Rust's matheval
18298                // captures the message in Err and bin_let was
18299                // discarding it via `if let Ok(...)`. Surface it.
18300                zerr(&msg);
18301                // c:7475 — a failed matheval yields zero_mnumber.
18302                val = mnumber { l: 0, d: 0.0, type_: MN_INTEGER };
18303            }
18304        }
18305    }
18306    // c:7476-7478 (5.9.2) — `/* Errors in math evaluation in let are
18307    // non-fatal. */ errflag &= ~ERRFLAG_ERROR;` then the status comes from
18308    // the value below; the dev tree's upstream 54285 returns 2 here, 5.9.2
18309    // returns 1 because the failed evaluation yields zero.
18310    errflag.fetch_and(!ERRFLAG_ERROR, Relaxed); // c:7478
18311    // c:7482 — `return (val.type == MN_INTEGER) ? val.u.l == 0 : val.u.d == 0.0;`
18312    if val.type_ == MN_INTEGER {
18313        // c:7482
18314        (val.l == 0) as i32
18315    } else {
18316        (val.d == 0.0) as i32
18317    }
18318}
18319
18320/// Port of `bin_umask()` from `Src/builtin.c:7491`.
18321/// C decl: `bin_umask(char *nam, char **args, Options ops, UNUSED(int func))`
18322/// C: `int bin_umask(char *nam, char **args, Options ops, ...)` —
18323///   set/show file-creation mask. No args → show; numeric arg → octal
18324///   parse; symbolic `[ugoa]+[+-=][rwx]+,...` → walk and apply.
18325/// WARNING: param names don't match C — Rust=(nam, args, _func) vs C=(nam, args, ops, func)
18326pub fn bin_umask(
18327    nam: &str,
18328    args: &[String], // c:7491
18329    ops: &options,
18330    _func: i32,
18331) -> i32 {
18332    // c:7497-7500 — read current umask.
18333    queue_signals(); // c:7497
18334    let mut um: u32 = unsafe { libc::umask(0o777) } as u32; // c:7498
18335    unsafe {
18336        libc::umask(um as libc::mode_t);
18337    } // c:7499
18338    unqueue_signals(); // c:7500
18339
18340    // c:7503-7521 — no args: display.
18341    if args.is_empty() {
18342        // c:7503
18343        if OPT_ISSET(ops, b'S') {
18344            // c:7504
18345            let who_chars = ['u', 'g', 'o']; // c:7505
18346            for (i, who) in who_chars.iter().enumerate() {
18347                // c:7507
18348                print!("{}=", who); // c:7510
18349                let mut what_iter = ['r', 'w', 'x'].iter(); // c:7511
18350                while let Some(w) = what_iter.next() {
18351                    // c:7512
18352                    if (um & 0o400) == 0 {
18353                        // c:7513
18354                        print!("{}", w); // c:7514
18355                    }
18356                    um <<= 1; // c:7515
18357                }
18358                if i < 2 {
18359                    print!(",");
18360                } else {
18361                    println!();
18362                } // c:7518
18363            }
18364        } else if crate::dash_mode::umask_four_digit() {
18365            // !!! EMULATION-ONLY (no C counterpart) !!! bash/ksh93/dash/ash
18366            // and /bin/sh print a FIXED four-digit mask; zsh's conditional
18367            // leading zero below is a zsh-ism. mksh keeps zsh's form, which
18368            // is why `umask_four_digit` excludes the pdksh line. Measured on
18369            // the real binaries — see extensions::dash_mode::umask_four_digit.
18370            println!("{:04o}", um);
18371        } else {
18372            // c:7522-7524 — `if (um & 0700) putchar('0'); printf("%03o\n", um);`
18373            if (um & 0o700) != 0 {
18374                // c:7522
18375                print!("0"); // c:7523
18376            }
18377            println!("{:03o}", um); // c:7524
18378        }
18379        return 0; // c:7526
18380    }
18381
18382    // c:7528 — `if (idigit(*s))` numeric form.
18383    let s = &args[0];
18384    if s.chars().next().is_some_and(|c| c.is_ascii_digit()) {
18385        // c:7528
18386        // c:7530 — `um = zstrtol(s, &s, 8);`
18387        match u32::from_str_radix(s, 8) {
18388            // c:7530
18389            Ok(n) => um = n, // c:7530
18390            Err(_) => {
18391                zwarnnam(nam, "bad umask"); // c:7532
18392                return 1; // c:7533
18393            }
18394        }
18395    } else {
18396        // c:7536-7585 — symbolic notation walker.
18397        let bytes = s.as_bytes();
18398        let mut i = 0;
18399        loop {
18400            // c:7544 — `whomask = 0;`
18401            let mut whomask: u32 = 0; // c:7544
18402                                      // c:7545-7553 — collect ugoa.
18403            while i < bytes.len() {
18404                // c:7545
18405                match bytes[i] {
18406                    b'u' => {
18407                        whomask |= 0o700;
18408                        i += 1;
18409                    } // c:7547
18410                    b'g' => {
18411                        whomask |= 0o070;
18412                        i += 1;
18413                    } // c:7549
18414                    b'o' => {
18415                        whomask |= 0o007;
18416                        i += 1;
18417                    } // c:7551
18418                    b'a' => {
18419                        whomask |= 0o777;
18420                        i += 1;
18421                    } // c:7553
18422                    _ => break,
18423                }
18424            }
18425            // c:7555 — default whomask = 0777.
18426            if whomask == 0 {
18427                whomask = 0o777;
18428            } // c:7555
18429              // c:7557-7565 — op +/-/=.
18430            let umaskop = if i < bytes.len() { bytes[i] } else { 0 }; // c:7557
18431            if !(umaskop == b'+' || umaskop == b'-' || umaskop == b'=') {
18432                // c:7558
18433                if umaskop != 0 {
18434                    // c:7559
18435                    zwarnnam(
18436                        nam,
18437                        &format!("bad symbolic mode operator: {}", umaskop as char),
18438                    ); // c:7560
18439                } else {
18440                    zwarnnam(nam, "bad umask"); // c:7562
18441                }
18442                return 1; // c:7564
18443            }
18444            i += 1;
18445            // c:7567-7577 — collect rwx.
18446            let mut mask: u32 = 0; // c:7567
18447            while i < bytes.len() && bytes[i] != b',' {
18448                // c:7568
18449                match bytes[i] {
18450                    b'r' => mask |= 0o444 & whomask, // c:7570
18451                    b'w' => mask |= 0o222 & whomask, // c:7572
18452                    b'x' => mask |= 0o111 & whomask, // c:7574
18453                    other => {
18454                        zwarnnam(
18455                            nam,
18456                            &format!("bad symbolic mode permission: {}", other as char),
18457                        ); // c:7576
18458                        return 1; // c:7577
18459                    }
18460                }
18461                i += 1;
18462            }
18463            // c:7580-7585 — apply.
18464            match umaskop {
18465                b'+' => um &= !mask,              // c:7581
18466                b'-' => um |= mask,               // c:7583
18467                _ => um = (um | whomask) & !mask, // c:7585 (=)
18468            }
18469            if i < bytes.len() && bytes[i] == b',' {
18470                // c:7586
18471                i += 1; // c:7587
18472            } else {
18473                break; // c:7589
18474            }
18475        }
18476        if i < bytes.len() {
18477            // c:7591
18478            zwarnnam(
18479                nam,
18480                &format!("bad character in symbolic mode: {}", bytes[i] as char),
18481            ); // c:7592
18482            return 1; // c:7593
18483        }
18484    }
18485    // c:7598 — `umask(um);`
18486    unsafe {
18487        libc::umask(um as libc::mode_t);
18488    } // c:7598
18489    0 // c:7599
18490}
18491
18492/// Port of `bin_notavail()` from `Src/builtin.c:7604`.
18493/// C decl: `bin_notavail(char *nam, UNUSED(char **argv), UNUSED(Options ops), UNUSED(int func))`
18494/// C: `int bin_notavail(char *nam, UNUSED(char **argv),
18495///                      UNUSED(Options ops), UNUSED(int func))`
18496///   → `zwarnnam(nam, "not available on this system"); return 1;`
18497/// WARNING: param names don't match C — Rust=(nam, _argv, _func) vs C=(nam, argv, ops, func)
18498pub fn bin_notavail(
18499    nam: &str,
18500    _argv: &[String], // c:7604
18501    _ops: &options,
18502    _func: i32,
18503) -> i32 {
18504    zwarnnam(nam, "not available on this system"); // c:7607
18505    1 // c:7608
18506}
18507// ---------------------------------------------------------------------------
18508// Builtin descriptor.
18509// Port of `struct builtin` from `Src/zsh.h` (the one expanded by the
18510// `BUILTIN` / `BIN_PREFIX` macros at line 1452 of zsh.h).
18511// ---------------------------------------------------------------------------
18512// ---------------------------------------------------------------------------
18513// The master registration table.
18514//
18515// Direct, line-for-line port of `static struct builtin builtins[]`
18516// at `Src/builtin.c:40-137`. Entries appear in the same order so
18517// any diff against the C source stays trivial. The `handler_name`
18518// column points at the canonical Rust port that the dispatcher in
18519// `Executor::register_builtins` (`src/ported/vm_helper`) wires up.
18520// ---------------------------------------------------------------------------
18521/// `BUILTINS` static.
18522pub static BUILTINS: std::sync::LazyLock<Vec<builtin>> = std::sync::LazyLock::new(|| {
18523    vec![
18524        BIN_PREFIX("-", BINF_DASH),
18525        BIN_PREFIX("builtin", BINF_BUILTIN),
18526        BIN_PREFIX("command", BINF_COMMAND),
18527        BIN_PREFIX("exec", BINF_EXEC),
18528        BIN_PREFIX("noglob", BINF_NOGLOB),
18529        BUILTIN(
18530            "[",
18531            BINF_HANDLES_OPTS,
18532            Some(bin_test as HandlerFunc),
18533            0,
18534            -1,
18535            BIN_BRACKET,
18536            None,
18537            None,
18538        ),
18539        BUILTIN(
18540            ".",
18541            BINF_PSPECIAL,
18542            Some(bin_dot as HandlerFunc),
18543            1,
18544            -1,
18545            0,
18546            None,
18547            None,
18548        ),
18549        BUILTIN(
18550            ":",
18551            BINF_PSPECIAL,
18552            Some(bin_true as HandlerFunc),
18553            0,
18554            -1,
18555            0,
18556            None,
18557            None,
18558        ),
18559        // !!! RUST-ONLY — NO C COUNTERPART !!! Inline Rust FFI: the
18560        // `rust { ... }` desugar (src/rust_ffi.rs) emits `__rust_compile
18561        // '<base64>' <line>`. min=1/max=2: the base64 body plus the optional
18562        // source line.
18563        BUILTIN(
18564            "__rust_compile",
18565            0,
18566            Some(crate::rust_ffi::bin_rust_compile as HandlerFunc),
18567            1,
18568            2,
18569            0,
18570            None,
18571            None,
18572        ),
18573        BUILTIN(
18574            "alias",
18575            BINF_MAGICEQUALS | BINF_PLUSOPTS,
18576            Some(bin_alias as HandlerFunc),
18577            0,
18578            -1,
18579            0,
18580            // `t` is ZSHRS-ONLY: the Korn shells' tracked-alias listing
18581            // (`alias -t`), which is how mksh's own `hash` alias is
18582            // defined. zsh has no such option, so bin_alias rejects it
18583            // outside the Korn drop-ins with zsh's own diagnostic.
18584            Some("Lgmrst"),
18585            None,
18586        ),
18587        BUILTIN(
18588            "autoload",
18589            BINF_PLUSOPTS,
18590            Some(bin_functions as HandlerFunc),
18591            0,
18592            -1,
18593            0,
18594            Some("dmktrRTUwWXz"),
18595            Some("u"),
18596        ),
18597        BUILTIN(
18598            "bg",
18599            0,
18600            Some(bin_fg as HandlerFunc),
18601            0,
18602            -1,
18603            BIN_BG,
18604            None,
18605            None,
18606        ),
18607        BUILTIN(
18608            "break",
18609            BINF_PSPECIAL,
18610            Some(bin_break as HandlerFunc),
18611            0,
18612            1,
18613            BIN_BREAK,
18614            None,
18615            None,
18616        ),
18617        BUILTIN(
18618            "bye",
18619            0,
18620            Some(bin_break as HandlerFunc),
18621            0,
18622            1,
18623            BIN_EXIT,
18624            None,
18625            None,
18626        ),
18627        BUILTIN(
18628            "cd",
18629            BINF_SKIPINVALID | BINF_SKIPDASH | BINF_DASHDASHVALID,
18630            Some(bin_cd as HandlerFunc),
18631            0,
18632            2,
18633            BIN_CD,
18634            Some("qsPL"),
18635            None,
18636        ),
18637        BUILTIN(
18638            "chdir",
18639            BINF_SKIPINVALID | BINF_SKIPDASH | BINF_DASHDASHVALID,
18640            Some(bin_cd as HandlerFunc),
18641            0,
18642            2,
18643            BIN_CD,
18644            Some("qsPL"),
18645            None,
18646        ),
18647        BUILTIN(
18648            "continue",
18649            BINF_PSPECIAL,
18650            Some(bin_break as HandlerFunc),
18651            0,
18652            1,
18653            BIN_CONTINUE,
18654            None,
18655            None,
18656        ),
18657        BUILTIN(
18658            "declare",
18659            BINF_PLUSOPTS | BINF_MAGICEQUALS | BINF_PSPECIAL | BINF_ASSIGN,
18660            Some(bin_typeset as HandlerFunc),
18661            0,
18662            -1,
18663            0,
18664            Some("AE:%F:%HL:%R:%TUZ:%afghi:%klmnp:%rtuxz"),
18665            None,
18666        ),
18667        BUILTIN(
18668            "dirs",
18669            0,
18670            Some(bin_dirs as HandlerFunc),
18671            0,
18672            -1,
18673            0,
18674            Some("clpv"),
18675            None,
18676        ),
18677        BUILTIN(
18678            "disable",
18679            0,
18680            Some(bin_enable as HandlerFunc),
18681            0,
18682            -1,
18683            BIN_DISABLE,
18684            Some("afmprs"),
18685            None,
18686        ),
18687        BUILTIN(
18688            "disown",
18689            0,
18690            Some(bin_fg as HandlerFunc),
18691            0,
18692            -1,
18693            BIN_DISOWN,
18694            Some("a"),
18695            None,
18696        ),
18697        BUILTIN(
18698            "echo",
18699            BINF_SKIPINVALID,
18700            Some(bin_print as HandlerFunc),
18701            0,
18702            -1,
18703            BIN_ECHO,
18704            Some("neE"),
18705            Some("-"),
18706        ),
18707        BUILTIN(
18708            "emulate",
18709            0,
18710            Some(bin_emulate as HandlerFunc),
18711            0,
18712            -1,
18713            0,
18714            Some("lLR"),
18715            None,
18716        ),
18717        BUILTIN(
18718            "enable",
18719            0,
18720            Some(bin_enable as HandlerFunc),
18721            0,
18722            -1,
18723            BIN_ENABLE,
18724            Some("afmprs"),
18725            None,
18726        ),
18727        BUILTIN(
18728            "eval",
18729            BINF_PSPECIAL,
18730            Some(bin_eval as HandlerFunc),
18731            0,
18732            -1,
18733            BIN_EVAL,
18734            None,
18735            None,
18736        ),
18737        BUILTIN(
18738            "exit",
18739            BINF_PSPECIAL,
18740            Some(bin_break as HandlerFunc),
18741            0,
18742            1,
18743            BIN_EXIT,
18744            None,
18745            None,
18746        ),
18747        BUILTIN(
18748            "export",
18749            BINF_PLUSOPTS | BINF_MAGICEQUALS | BINF_PSPECIAL | BINF_ASSIGN,
18750            Some(bin_typeset as HandlerFunc),
18751            0,
18752            -1,
18753            BIN_EXPORT,
18754            Some("E:%F:%HL:%R:%TUZ:%afhi:%lp:%rtu"),
18755            Some("xg"),
18756        ),
18757        BUILTIN(
18758            "false",
18759            0,
18760            Some(bin_false as HandlerFunc),
18761            0,
18762            -1,
18763            0,
18764            None,
18765            None,
18766        ),
18767        // C source (Src/builtin.c:69-73): the argument to -e used to be
18768        // optional; making it required is more consistent.
18769        BUILTIN(
18770            "fc",
18771            0,
18772            Some(bin_fc as HandlerFunc),
18773            0,
18774            -1,
18775            BIN_FC,
18776            Some("aAdDe:EfiIlLmnpPrRst:W"),
18777            None,
18778        ),
18779        BUILTIN(
18780            "fg",
18781            0,
18782            Some(bin_fg as HandlerFunc),
18783            0,
18784            -1,
18785            BIN_FG,
18786            None,
18787            None,
18788        ),
18789        BUILTIN(
18790            "float",
18791            BINF_PLUSOPTS | BINF_MAGICEQUALS | BINF_PSPECIAL | BINF_ASSIGN,
18792            Some(bin_typeset as HandlerFunc),
18793            0,
18794            -1,
18795            0,
18796            Some("E:%F:%HL:%R:%Z:%ghlp:%rtux"),
18797            Some("E"),
18798        ),
18799        BUILTIN(
18800            "functions",
18801            BINF_PLUSOPTS,
18802            Some(bin_functions as HandlerFunc),
18803            0,
18804            -1,
18805            0,
18806            Some("ckmMstTuUWx:z"),
18807            None,
18808        ),
18809        BUILTIN(
18810            "getln",
18811            0,
18812            Some(bin_read as HandlerFunc),
18813            0,
18814            -1,
18815            0,
18816            Some("ecnAlE"),
18817            Some("zr"),
18818        ),
18819        BUILTIN(
18820            "getopts",
18821            0,
18822            Some(bin_getopts as HandlerFunc),
18823            2,
18824            -1,
18825            0,
18826            Some("p"), // c:79 (54471)
18827            None,
18828        ),
18829        BUILTIN(
18830            "hash",
18831            BINF_MAGICEQUALS,
18832            Some(bin_hash as HandlerFunc),
18833            0,
18834            -1,
18835            0,
18836            Some("Ldfmrv"),
18837            None,
18838        ),
18839        // Src/builtin.c — `#ifdef ZSH_HASH_DEBUG`
18840        //   BUILTIN("hashinfo", 0, bin_hashinfo, 0, 0, 0, NULL, NULL)
18841        BUILTIN(
18842            "hashinfo",
18843            0,
18844            Some(crate::ported::hashtable::bin_hashinfo as HandlerFunc),
18845            0,
18846            0,
18847            0,
18848            None,
18849            None,
18850        ),
18851        BUILTIN(
18852            "history",
18853            0,
18854            Some(bin_fc as HandlerFunc),
18855            0,
18856            -1,
18857            BIN_FC,
18858            Some("adDEfiLmnpPrt:"),
18859            Some("l"),
18860        ),
18861        BUILTIN(
18862            "integer",
18863            BINF_PLUSOPTS | BINF_MAGICEQUALS | BINF_PSPECIAL | BINF_ASSIGN,
18864            Some(bin_typeset as HandlerFunc),
18865            0,
18866            -1,
18867            0,
18868            Some("HL:%R:%Z:%ghi:%lp:%rtux"),
18869            Some("i"),
18870        ),
18871        BUILTIN(
18872            "jobs",
18873            0,
18874            Some(bin_fg as HandlerFunc),
18875            0,
18876            -1,
18877            BIN_JOBS,
18878            Some("dlpZrs"),
18879            None,
18880        ),
18881        BUILTIN(
18882            "kill",
18883            BINF_HANDLES_OPTS,
18884            Some(crate::ported::jobs::bin_kill as HandlerFunc),
18885            0,
18886            -1,
18887            0,
18888            None,
18889            None,
18890        ),
18891        BUILTIN("let", 0, Some(bin_let as HandlerFunc), 1, -1, 0, None, None),
18892        BUILTIN(
18893            "local",
18894            BINF_PLUSOPTS | BINF_MAGICEQUALS | BINF_PSPECIAL | BINF_ASSIGN,
18895            Some(bin_typeset as HandlerFunc),
18896            0,
18897            -1,
18898            0,
18899            Some("AE:%F:%HL:%R:%TUZ:%ahi:%lnp:%rtux"),
18900            None,
18901        ),
18902        BUILTIN(
18903            "logout",
18904            0,
18905            Some(bin_break as HandlerFunc),
18906            0,
18907            1,
18908            BIN_LOGOUT,
18909            None,
18910            None,
18911        ),
18912        // Src/builtin.c — `#if defined(ZSH_MEM) & defined(ZSH_MEM_DEBUG)`
18913        //   BUILTIN("mem", 0, bin_mem, 0, 0, 0, "v", NULL)
18914        BUILTIN(
18915            "mem",
18916            0,
18917            Some(crate::ported::mem::bin_mem as HandlerFunc),
18918            0,
18919            0,
18920            0,
18921            Some("v"),
18922            None,
18923        ),
18924        BUILTIN(
18925            "popd",
18926            BINF_SKIPINVALID | BINF_SKIPDASH | BINF_DASHDASHVALID,
18927            Some(bin_cd as HandlerFunc),
18928            0,
18929            1,
18930            BIN_POPD,
18931            Some("q"),
18932            None,
18933        ),
18934        // Src/builtin.c — `#if defined(ZSH_PAT_DEBUG)`
18935        //   BUILTIN("patdebug", 0, bin_patdebug, 1, -1, 0, "p", NULL)
18936        BUILTIN("patdebug", 0, None, 1, -1, 0, Some("p"), None),
18937        BUILTIN(
18938            "print",
18939            BINF_PRINTOPTS,
18940            Some(bin_print as HandlerFunc),
18941            0,
18942            -1,
18943            BIN_PRINT,
18944            Some("abcC:Df:ilmnNoOpPrRsSu:v:x:X:z-"),
18945            None,
18946        ),
18947        BUILTIN(
18948            "printf",
18949            BINF_SKIPINVALID | BINF_SKIPDASH,
18950            Some(bin_print as HandlerFunc),
18951            1,
18952            -1,
18953            BIN_PRINTF,
18954            Some("v:"),
18955            None,
18956        ),
18957        BUILTIN(
18958            "pushd",
18959            BINF_SKIPINVALID | BINF_SKIPDASH | BINF_DASHDASHVALID,
18960            Some(bin_cd as HandlerFunc),
18961            0,
18962            2,
18963            BIN_PUSHD,
18964            Some("qsPL"),
18965            None,
18966        ),
18967        BUILTIN(
18968            "pushln",
18969            0,
18970            Some(bin_print as HandlerFunc),
18971            0,
18972            -1,
18973            BIN_PRINT,
18974            None,
18975            Some("-nz"),
18976        ),
18977        BUILTIN(
18978            "pwd",
18979            0,
18980            Some(bin_pwd as HandlerFunc),
18981            0,
18982            0,
18983            0,
18984            Some("rLP"),
18985            None,
18986        ),
18987        BUILTIN(
18988            "r",
18989            0,
18990            Some(bin_fc as HandlerFunc),
18991            0,
18992            -1,
18993            BIN_R,
18994            Some("IlLnr"),
18995            None,
18996        ),
18997        BUILTIN(
18998            "read",
18999            0,
19000            Some(bin_read as HandlerFunc),
19001            0,
19002            -1,
19003            0,
19004            // `a` is bash's array-read flag (zsh/ksh use `A`); accepted here
19005            // so `read -a arr` parses, and treated as array-read only in
19006            // bash mode (see bin_read's want_array).
19007            Some("acd:ek:%lnNpqrst:%zu:AE"),
19008            None,
19009        ),
19010        BUILTIN(
19011            "readonly",
19012            BINF_PLUSOPTS | BINF_MAGICEQUALS | BINF_PSPECIAL | BINF_ASSIGN,
19013            Some(bin_typeset as HandlerFunc),
19014            0,
19015            -1,
19016            BIN_READONLY,
19017            Some("AE:%F:%HL:%R:%TUZ:%afghi:%lptux"),
19018            Some("r"),
19019        ),
19020        BUILTIN(
19021            "rehash",
19022            0,
19023            Some(bin_hash as HandlerFunc),
19024            0,
19025            0,
19026            0,
19027            Some("df"),
19028            Some("r"),
19029        ),
19030        BUILTIN(
19031            "return",
19032            BINF_PSPECIAL,
19033            Some(bin_break as HandlerFunc),
19034            0,
19035            1,
19036            BIN_RETURN,
19037            None,
19038            None,
19039        ),
19040        BUILTIN(
19041            "set",
19042            BINF_PSPECIAL | BINF_HANDLES_OPTS,
19043            Some(bin_set as HandlerFunc),
19044            0,
19045            -1,
19046            0,
19047            None,
19048            None,
19049        ),
19050        BUILTIN(
19051            "setopt",
19052            0,
19053            Some(crate::ported::options::bin_setopt as HandlerFunc),
19054            0,
19055            -1,
19056            BIN_SETOPT,
19057            None,
19058            None,
19059        ),
19060        // c:Src/Builtins/sched.c:375 — sched is a Builtins module
19061        // builtin (zsh/sched). bintab has only one entry. The Rust
19062        // port at builtins/sched.rs::bin_sched (325 lines) was not
19063        // registered, so `sched 09:00 echo morning` returned
19064        // "command not found".
19065        BUILTIN(
19066            "sched",
19067            0,
19068            Some(crate::ported::builtins::sched::bin_sched as HandlerFunc),
19069            0,
19070            -1,
19071            0,
19072            None,
19073            None,
19074        ),
19075        BUILTIN(
19076            "shift",
19077            BINF_PSPECIAL,
19078            Some(bin_shift as HandlerFunc),
19079            0,
19080            -1,
19081            0,
19082            Some("p"),
19083            None,
19084        ),
19085        BUILTIN(
19086            "source",
19087            BINF_PSPECIAL,
19088            Some(bin_dot as HandlerFunc),
19089            1,
19090            -1,
19091            0,
19092            None,
19093            None,
19094        ),
19095        BUILTIN(
19096            "suspend",
19097            0,
19098            Some(crate::ported::jobs::bin_suspend as HandlerFunc),
19099            0,
19100            0,
19101            0,
19102            Some("f"),
19103            None,
19104        ),
19105        BUILTIN(
19106            "test",
19107            BINF_HANDLES_OPTS,
19108            Some(bin_test as HandlerFunc),
19109            0,
19110            -1,
19111            BIN_TEST,
19112            None,
19113            None,
19114        ),
19115        BUILTIN(
19116            "ttyctl",
19117            0,
19118            Some(bin_ttyctl as HandlerFunc),
19119            0,
19120            0,
19121            0,
19122            Some("fu"),
19123            None,
19124        ),
19125        // c:Src/Builtins/rlimits.c:868-870 — limit/ulimit/unlimit are
19126        // declared in the rlimits Builtins-module's bintab. zshrs has the
19127        // free-fn ports at src/ported/builtins/rlimits.rs but never
19128        // registered them; the BUILTIN_NAMES derivation missed them and
19129        // `type limit` etc. returned empty.
19130        BUILTIN(
19131            "limit",
19132            0,
19133            Some(crate::ported::builtins::rlimits::bin_limit as HandlerFunc),
19134            0,
19135            -1,
19136            0,
19137            Some("sh"),
19138            None,
19139        ), // c:rlimits.c:868
19140        BUILTIN(
19141            "ulimit",
19142            0,
19143            Some(crate::ported::builtins::rlimits::bin_ulimit as HandlerFunc),
19144            0,
19145            -1,
19146            0,
19147            None,
19148            None,
19149        ), // c:rlimits.c:869
19150        BUILTIN(
19151            "unlimit",
19152            0,
19153            Some(crate::ported::builtins::rlimits::bin_unlimit as HandlerFunc),
19154            0,
19155            -1,
19156            0,
19157            Some("hs"),
19158            None,
19159        ), // c:rlimits.c:870
19160        BUILTIN(
19161            "times",
19162            BINF_PSPECIAL,
19163            Some(bin_times as HandlerFunc),
19164            0,
19165            0,
19166            0,
19167            None,
19168            None,
19169        ),
19170        BUILTIN(
19171            "trap",
19172            BINF_PSPECIAL | BINF_HANDLES_OPTS,
19173            Some(bin_trap as HandlerFunc),
19174            0,
19175            -1,
19176            0,
19177            None,
19178            None,
19179        ),
19180        BUILTIN(
19181            "true",
19182            0,
19183            Some(bin_true as HandlerFunc),
19184            0,
19185            -1,
19186            0,
19187            None,
19188            None,
19189        ),
19190        BUILTIN(
19191            "type",
19192            0,
19193            Some(bin_whence as HandlerFunc),
19194            0,
19195            -1,
19196            0,
19197            // `t` (bash `type -t`) accepted only meaningfully in --bash;
19198            // bin_whence gates the behavior on bash_mode().
19199            Some("ampfsStw"),
19200            Some("v"),
19201        ),
19202        BUILTIN(
19203            "typeset",
19204            BINF_PLUSOPTS | BINF_MAGICEQUALS | BINF_PSPECIAL | BINF_ASSIGN,
19205            Some(bin_typeset as HandlerFunc),
19206            0,
19207            -1,
19208            0,
19209            Some("AE:%F:%HL:%R:%TUZ:%afghi:%klp:%rtuxmnz"),
19210            None,
19211        ),
19212        // `nameref` — zsh/ksh93 module's named-reference declaration
19213        // builtin. Same handler as `typeset` with a tighter opt-string
19214        // (`-g`/`-p`/`-r`/`-u`) and the assign-style invocation. The
19215        // canonical Src/Modules/ksh93.c:bintab entry:
19216        //   BUILTIN("nameref", BINF_ASSIGN, bin_typeset, 0, -1, 0,
19217        //           "gpru", "n")
19218        // The 9th arg ("n") is the default-flag — typeset_flags inherits
19219        // PM_NAMEREF when the builtin is invoked under this name. zshrs's
19220        // bin_typeset wrapper doesn't read the 9th field yet, but the
19221        // registration still surfaces `nameref` in the tool window /
19222        // completion / hover docs.
19223        BUILTIN(
19224            "nameref",
19225            BINF_ASSIGN,
19226            Some(bin_typeset as HandlerFunc),
19227            0,
19228            -1,
19229            0,
19230            Some("gpru"),
19231            Some("n"),
19232        ),
19233        BUILTIN(
19234            "umask",
19235            0,
19236            Some(bin_umask as HandlerFunc),
19237            0,
19238            1,
19239            0,
19240            Some("S"),
19241            None,
19242        ),
19243        BUILTIN(
19244            "unalias",
19245            0,
19246            Some(bin_unhash as HandlerFunc),
19247            0,
19248            -1,
19249            BIN_UNALIAS,
19250            Some("ams"),
19251            None,
19252        ),
19253        BUILTIN(
19254            "unfunction",
19255            0,
19256            Some(bin_unhash as HandlerFunc),
19257            1,
19258            -1,
19259            BIN_UNFUNCTION,
19260            Some("m"),
19261            Some("f"),
19262        ),
19263        BUILTIN(
19264            "unhash",
19265            0,
19266            Some(bin_unhash as HandlerFunc),
19267            1,
19268            -1,
19269            BIN_UNHASH,
19270            Some("adfms"),
19271            None,
19272        ),
19273        BUILTIN(
19274            "unset",
19275            BINF_PSPECIAL,
19276            Some(bin_unset as HandlerFunc),
19277            1,
19278            -1,
19279            BIN_UNSET,
19280            Some("fmvn"),
19281            None,
19282        ),
19283        BUILTIN(
19284            "unsetopt",
19285            0,
19286            Some(crate::ported::options::bin_setopt as HandlerFunc),
19287            0,
19288            -1,
19289            BIN_UNSETOPT,
19290            None,
19291            None,
19292        ),
19293        BUILTIN(
19294            "wait",
19295            0,
19296            Some(bin_fg as HandlerFunc),
19297            0,
19298            -1,
19299            BIN_WAIT,
19300            None,
19301            None,
19302        ),
19303        BUILTIN(
19304            "whence",
19305            0,
19306            Some(bin_whence as HandlerFunc),
19307            0,
19308            -1,
19309            0,
19310            Some("acmpvfsSwx:"),
19311            None,
19312        ),
19313        BUILTIN(
19314            "where",
19315            0,
19316            Some(bin_whence as HandlerFunc),
19317            0,
19318            -1,
19319            0,
19320            Some("pmsSwx:"),
19321            Some("ca"),
19322        ),
19323        BUILTIN(
19324            "which",
19325            0,
19326            Some(bin_whence as HandlerFunc),
19327            0,
19328            -1,
19329            0,
19330            Some("ampsSwx:"),
19331            Some("c"),
19332        ),
19333        BUILTIN(
19334            "zmodload",
19335            0,
19336            Some(crate::ported::module::bin_zmodload as HandlerFunc),
19337            0,
19338            -1,
19339            0,
19340            Some("AFRILP:abcfdilmpsue"),
19341            None,
19342        ),
19343        BUILTIN(
19344            "zcompile",
19345            0,
19346            Some(crate::ported::parse::bin_zcompile as HandlerFunc),
19347            0,
19348            -1,
19349            0,
19350            Some("tUMRcmzka"),
19351            None,
19352        ),
19353        // Module builtins (zsh/zutil, zsh/cap, zsh/pcre, etc.) — these
19354        // live in src/ported/modules/* and src/ported/zle/* but their
19355        // canonical pub fn signatures match HandlerFunc, so they can be
19356        // dispatched via execbuiltin alongside the main builtins.
19357        BUILTIN(
19358            "zstyle",
19359            0,
19360            Some(crate::ported::modules::zutil::bin_zstyle as HandlerFunc),
19361            0,
19362            -1,
19363            0,
19364            // c:Src/Modules/zutil.c:2139 — `BUILTIN("zstyle", 0,
19365            // bin_zstyle, 0, -1, 0, NULL, NULL)`. NULL optstr: bin_zstyle
19366            // parses args[0] directly (see c:493-512 + c:587-600) so the
19367            // diagnostic format for unknown flags matches zsh's
19368            // "invalid option: -X" (the execbuiltin generic parser would
19369            // have emitted "bad option: -X" which diverges from zsh).
19370            // Bug surfaced via test_zstyle_unknown_flag_errors.
19371            None,
19372            None,
19373        ),
19374        BUILTIN(
19375            "zformat",
19376            0,
19377            Some(crate::ported::modules::zutil::bin_zformat as HandlerFunc),
19378            2,
19379            -1,
19380            0,
19381            // c:Src/Modules/zutil.c:2151 — `BUILTIN("zformat", 0,
19382            // bin_zformat, 2, -1, 0, "afFqQ", NULL)`.
19383            Some("afFqQ"),
19384            None,
19385        ),
19386        BUILTIN(
19387            "zparseopts",
19388            0,
19389            Some(crate::ported::modules::zutil::bin_zparseopts as HandlerFunc),
19390            0,
19391            -1,
19392            0,
19393            // c:src/zsh/Src/Modules/zutil.c:2137 —
19394            //   BUILTIN("zparseopts", 0, bin_zparseopts, 1, -1, 0, NULL, NULL)
19395            // NULL optstring: in zsh 5.9.2 the generic parser must NOT eat
19396            // zparseopts's leading `-` words, because a word it does not
19397            // recognise is an option DESCRIPTION, not an error. Handing it
19398            // 5.9.999's optstring "a:A:DEFGKMn:v:" (c:2150, upstream commit
19399            // 88d51a2400) turns `zparseopts -D -E -move=opt_move` into
19400            // "bad option: -m" at Src/builtin.c:385-390 — the regression that
19401            // broke ~/.zinit/bin/zinit-install.zsh:1528. zshrs reports
19402            // $ZSH_VERSION=5.9.2, so it takes the 5.9.2 rule; the switch is
19403            // `LONG_SPEC_NEEDS_GUARD` in src/ported/modules/zutil.rs, and
19404            // retargeting means flipping it AND restoring the optstring here.
19405            // bin_zparseopts scans the flag words itself (stacking and cuddled
19406            // optargs included) and reads them back with OPT_ISSET/OPT_ARG.
19407            //
19408            // minargs stays 0, NOT c:2137's 1: bare `zparseopts` must reach
19409            // the builtin to report "missing option descriptions"
19410            // (c:1885-1888), and `zparseopts -a ''` must reach it to report
19411            // "missing array name for -a" (c:1844).
19412            None,
19413            None,
19414        ),
19415        BUILTIN(
19416            "zregexparse",
19417            0,
19418            Some(crate::ported::modules::zutil::bin_zregexparse as HandlerFunc),
19419            0,
19420            -1,
19421            0,
19422            Some("c"),
19423            None,
19424        ),
19425        BUILTIN(
19426            "cap",
19427            0,
19428            Some(crate::ported::modules::cap::bin_cap as HandlerFunc),
19429            0,
19430            1,
19431            0,
19432            None,
19433            None,
19434        ),
19435        BUILTIN(
19436            "getcap",
19437            0,
19438            Some(crate::ported::modules::cap::bin_getcap as HandlerFunc),
19439            1,
19440            -1,
19441            0,
19442            None,
19443            None,
19444        ),
19445        BUILTIN(
19446            "setcap",
19447            0,
19448            Some(crate::ported::modules::cap::bin_setcap as HandlerFunc),
19449            1,
19450            -1,
19451            0,
19452            None,
19453            None,
19454        ),
19455        BUILTIN(
19456            "pcre_compile",
19457            0,
19458            Some(crate::ported::modules::pcre::bin_pcre_compile as HandlerFunc),
19459            1,
19460            1,
19461            0,
19462            // c:Src/Modules/pcre.c:521 — `"aimxs"`. The `s` (PCRE2_DOTALL,
19463            // handled at pcre.rs:109) was missing, so `pcre_compile -s`
19464            // errored "bad option: -s".
19465            Some("aimxs"),
19466            None,
19467        ),
19468        BUILTIN(
19469            "pcre_study",
19470            0,
19471            Some(crate::ported::modules::pcre::bin_pcre_study as HandlerFunc),
19472            0,
19473            0,
19474            0,
19475            None,
19476            None,
19477        ),
19478        BUILTIN(
19479            "pcre_match",
19480            0,
19481            Some(crate::ported::modules::pcre::bin_pcre_match as HandlerFunc),
19482            1,
19483            -1,
19484            0,
19485            // c:Src/Modules/pcre.c:522 — `"A:a:v:n:bd"`. `A`(named assoc),
19486            // `a`(capture array), `v`(match var), `n`(start offset) take
19487            // args; `b`(offset pair) and `d`(dfa) are flags. The prior
19488            // "ab:nv:" made `-a` a flag (so `-a arr` ate the subject),
19489            // `-b` arg-taking, and dropped `-A`/`-n:`/`-d` entirely.
19490            Some("A:a:v:n:bd"),
19491            None,
19492        ),
19493        BUILTIN(
19494            "ztcp",
19495            0,
19496            Some(crate::ported::modules::tcp::bin_ztcp as HandlerFunc),
19497            0,
19498            -1,
19499            0,
19500            // c:Src/Modules/tcp.c — `"acd:flLtv"`. `-d` (target fd) takes
19501            // an argument; zshrs had "acdflLtv" (d a flag), so `ztcp -d 9`
19502            // treated 9 as a positional and OPT_ARG(d) was empty.
19503            Some("acd:flLtv"),
19504            None,
19505        ),
19506        BUILTIN(
19507            "ztie",
19508            0,
19509            Some(crate::ported::modules::db_gdbm::bin_ztie as HandlerFunc),
19510            0,
19511            -1,
19512            0,
19513            Some("d:f:r"),
19514            None,
19515        ),
19516        BUILTIN(
19517            "zuntie",
19518            0,
19519            Some(crate::ported::modules::db_gdbm::bin_zuntie as HandlerFunc),
19520            1,
19521            -1,
19522            0,
19523            Some("u"),
19524            None,
19525        ),
19526        BUILTIN(
19527            "zgdbmpath",
19528            0,
19529            Some(crate::ported::modules::db_gdbm::bin_zgdbmpath as HandlerFunc),
19530            1,
19531            1,
19532            0,
19533            None,
19534            None,
19535        ),
19536        BUILTIN(
19537            "echoti",
19538            0,
19539            Some(crate::ported::modules::terminfo::bin_echoti as HandlerFunc),
19540            1,
19541            -1,
19542            0,
19543            None,
19544            None,
19545        ),
19546        BUILTIN(
19547            "fg",
19548            0,
19549            Some(bin_fg as HandlerFunc),
19550            0,
19551            -1,
19552            BIN_FG,
19553            None,
19554            None,
19555        ),
19556        BUILTIN(
19557            "kill",
19558            BINF_HANDLES_OPTS,
19559            Some(crate::ported::jobs::bin_kill as HandlerFunc),
19560            0,
19561            -1,
19562            0,
19563            None,
19564            None,
19565        ),
19566        BUILTIN(
19567            "suspend",
19568            0,
19569            Some(crate::ported::jobs::bin_suspend as HandlerFunc),
19570            0,
19571            0,
19572            0,
19573            Some("f"),
19574            None,
19575        ),
19576        BUILTIN(
19577            "bindkey",
19578            0,
19579            Some(crate::ported::zle::zle_keymap::bin_bindkey as HandlerFunc),
19580            0,
19581            -1,
19582            0,
19583            // c:Src/Zle/zle_main.c:2209 — `"evaM:ldDANmrsLRp"`. The
19584            // `:` after `M` marks it as taking a required arg
19585            // (`-M <keymap>`); without it the parser consumes the
19586            // keymap name as a positional and `-M` falls back to
19587            // "main" → wrong keymap returned for every `-M X` call.
19588            Some("evaM:ldDANmrsLRp"),
19589            None,
19590        ),
19591        BUILTIN(
19592            "vared",
19593            0,
19594            Some(crate::ported::zle::zle_main::bin_vared as HandlerFunc),
19595            1,
19596            1,
19597            0,
19598            Some("aAcef:ghi:M:m:p:r:t:"),
19599            None,
19600        ),
19601        // Registration ORDER note (zshrs-only): C adds these two through
19602        // compctl.mdd's `autofeatures="b:compctl b:compcall"`, which the
19603        // generated `Src/bltinmods.list` walks in MODULE order — and
19604        // `Src/Zle/compctl.mdd` sorts before `complete.mdd` / `computil.mdd`,
19605        // so zsh/compctl reaches `builtintab` BEFORE zsh/complete and
19606        // zsh/computil do. `BUILTINTAB_NODES` front-inserts
19607        // (c:Src/hashtable.c:217-218), so this order decides the chain order
19608        // `${(k)builtins}` prints: with the block left below the computil
19609        // batch, bucket 29 came out `compctl comptags compdescribe` where
19610        // `zsh -f` prints `comptags compdescribe compctl`.
19611        // c:Src/Zle/compctl.c:4000-4001 — zsh/compctl module.
19612        BUILTIN(
19613            "compcall",
19614            0,
19615            Some(crate::ported::zle::compctl::bin_compcall as HandlerFunc),
19616            0,
19617            0,
19618            0,
19619            Some("TD"),
19620            None,
19621        ), // c:4000
19622        BUILTIN(
19623            "compctl",
19624            0,
19625            Some(crate::ported::zle::compctl::bin_compctl as HandlerFunc),
19626            0,
19627            -1,
19628            0,
19629            None,
19630            None,
19631        ), // c:4001
19632        // C: `BUILTIN("compadd", BINF_HANDLES_OPTS, bin_compadd, 0, -1, 0,
19633        // NULL, NULL)` (complete.c:1693). compadd parses ALL its own
19634        // options (the char-by-char loop in bin_compadd_body). The earlier
19635        // registration gave it an incomplete getopt spec ("J:V:1X:fnqQF:Wsi")
19636        // WITHOUT BINF_HANDLES_OPTS, so the dispatcher pre-parsed and
19637        // STRIPPED -J/-V/-X/… into `ops` and handed bin_compadd only the
19638        // positional words — every `compadd -J group -X expl …` lost its
19639        // group name and explanation (second `compadd -J` groups vanished,
19640        // no group headers). Must be HANDLES_OPTS + no optspec so argv
19641        // reaches bin_compadd raw.
19642        BUILTIN(
19643            "compadd",
19644            BINF_HANDLES_OPTS,
19645            Some(crate::ported::zle::complete::bin_compadd as HandlerFunc),
19646            0,
19647            -1,
19648            0,
19649            None,
19650            None,
19651        ),
19652        // C: `BUILTIN("compset", 0, bin_compset, 1, 3, 0, NULL, NULL)`
19653        // (complete.c:1694). bin_compset parses `argv[0]` (-n/-p/-q/-P/-S)
19654        // ITSELF; the earlier Rust registration added a getopt spec
19655        // ("npqPS:") that C does NOT have, so the dispatcher pre-parsed and
19656        // STRIPPED those options from argv before bin_compset ran —
19657        // `argv[0].starts_with('-')` then failed and every shell-invoked
19658        // `compset -p/-q/-n …` no-opped (word-splitting completers broke).
19659        // Must be NULL optspec (+ max 3) so argv reaches bin_compset raw.
19660        BUILTIN(
19661            "compset",
19662            0,
19663            Some(crate::ported::zle::complete::bin_compset as HandlerFunc),
19664            1,
19665            3,
19666            0,
19667            None,
19668            None,
19669        ),
19670        // c:Src/Zle/computil.c:5103-5110 — zsh/computil module's 8
19671        // builtins drive compsys (the canonical completion system).
19672        // All have HandlerFunc-compatible signatures already; just
19673        // need BUILTINS-table registration. Without these,
19674        // _describe / _arguments / _values / _files / _groups / etc.
19675        // (compsys's primary entry points) silently no-op.
19676        BUILTIN(
19677            "comparguments",
19678            0,
19679            Some(crate::ported::zle::computil::bin_comparguments as HandlerFunc),
19680            1,
19681            -1,
19682            0,
19683            None,
19684            None,
19685        ), // c:5103
19686        BUILTIN(
19687            "compdescribe",
19688            0,
19689            Some(crate::ported::zle::computil::bin_compdescribe as HandlerFunc),
19690            3,
19691            -1,
19692            0,
19693            None,
19694            None,
19695        ), // c:5104
19696        BUILTIN(
19697            "compfiles",
19698            0,
19699            Some(crate::ported::zle::computil::bin_compfiles as HandlerFunc),
19700            1,
19701            -1,
19702            0,
19703            None,
19704            None,
19705        ), // c:5105
19706        BUILTIN(
19707            "compgroups",
19708            0,
19709            Some(crate::ported::zle::computil::bin_compgroups as HandlerFunc),
19710            1,
19711            -1,
19712            0,
19713            None,
19714            None,
19715        ), // c:5106
19716        BUILTIN(
19717            "compquote",
19718            0,
19719            Some(crate::ported::zle::computil::bin_compquote as HandlerFunc),
19720            1,
19721            -1,
19722            0,
19723            Some("p"),
19724            None,
19725        ), // c:5107
19726        BUILTIN(
19727            "comptags",
19728            0,
19729            Some(crate::ported::zle::computil::bin_comptags as HandlerFunc),
19730            1,
19731            -1,
19732            0,
19733            None,
19734            None,
19735        ), // c:5108
19736        BUILTIN(
19737            "comptry",
19738            0,
19739            Some(crate::ported::zle::computil::bin_comptry as HandlerFunc),
19740            0,
19741            -1,
19742            0,
19743            None,
19744            None,
19745        ), // c:5109
19746        BUILTIN(
19747            "compvalues",
19748            0,
19749            Some(crate::ported::zle::computil::bin_compvalues as HandlerFunc),
19750            1,
19751            -1,
19752            0,
19753            None,
19754            None,
19755        ), // c:5110
19756        // c:Src/Modules/system.c:819-824 — zsh/system module builtins.
19757        BUILTIN(
19758            "syserror",
19759            0,
19760            Some(crate::ported::modules::system::bin_syserror as HandlerFunc),
19761            0,
19762            1,
19763            0,
19764            Some("e:p:"),
19765            None,
19766        ), // c:819
19767        BUILTIN(
19768            "sysread",
19769            0,
19770            Some(crate::ported::modules::system::bin_sysread as HandlerFunc),
19771            0,
19772            1,
19773            0,
19774            Some("c:i:o:s:t:"),
19775            None,
19776        ), // c:820
19777        BUILTIN(
19778            "syswrite",
19779            0,
19780            Some(crate::ported::modules::system::bin_syswrite as HandlerFunc),
19781            1,
19782            1,
19783            0,
19784            Some("c:o:"),
19785            None,
19786        ), // c:821
19787        BUILTIN(
19788            "sysopen",
19789            0,
19790            Some(crate::ported::modules::system::bin_sysopen as HandlerFunc),
19791            1,
19792            1,
19793            0,
19794            Some("rwau:o:m:"),
19795            None,
19796        ), // c:822
19797        BUILTIN(
19798            "sysseek",
19799            0,
19800            Some(crate::ported::modules::system::bin_sysseek as HandlerFunc),
19801            1,
19802            1,
19803            0,
19804            Some("u:w:"),
19805            None,
19806        ), // c:823
19807        BUILTIN(
19808            "zsystem",
19809            0,
19810            Some(crate::ported::modules::system::bin_zsystem as HandlerFunc),
19811            1,
19812            -1,
19813            0,
19814            None,
19815            None,
19816        ), // c:824
19817        // c:Src/Modules/zselect.c:272 — zsh/zselect module.
19818        BUILTIN(
19819            "zselect",
19820            0,
19821            Some(crate::ported::modules::zselect::bin_zselect as HandlerFunc),
19822            0,
19823            -1,
19824            0,
19825            None,
19826            None,
19827        ), // c:272
19828        // c:Src/Modules/socket.c:276 — zsh/socket module.
19829        BUILTIN(
19830            "zsocket",
19831            0,
19832            Some(crate::ported::modules::socket::bin_zsocket as HandlerFunc),
19833            0,
19834            3,
19835            0,
19836            Some("ad:ltv"),
19837            None,
19838        ), // c:276
19839        // c:Src/Modules/stat.c:637 — zsh/stat module.
19840        BUILTIN(
19841            "stat",
19842            0,
19843            Some(crate::ported::modules::stat::bin_stat as HandlerFunc),
19844            0,
19845            -1,
19846            0,
19847            None,
19848            None,
19849        ), // c:637
19850        // c:Src/Modules/stat.c:638 — `zstat` alias, same handler.
19851        BUILTIN(
19852            "zstat",
19853            0,
19854            Some(crate::ported::modules::stat::bin_stat as HandlerFunc),
19855            0,
19856            -1,
19857            0,
19858            None,
19859            None,
19860        ), // c:638
19861        // c:Src/Modules/watch.c:694 — zsh/watch module's `log`.
19862        BUILTIN(
19863            "log",
19864            0,
19865            Some(crate::ported::modules::watch::bin_log as HandlerFunc),
19866            0,
19867            0,
19868            0,
19869            None,
19870            None,
19871        ), // c:694
19872        // c:Src/Modules/zprof.c:315 — zsh/zprof module.
19873        BUILTIN(
19874            "zprof",
19875            0,
19876            Some(crate::ported::modules::zprof::bin_zprof as HandlerFunc),
19877            0,
19878            0,
19879            0,
19880            Some("c"),
19881            None,
19882        ), // c:315
19883        // c:Src/Modules/datetime.c:239 — zsh/datetime module.
19884        BUILTIN(
19885            "strftime",
19886            0,
19887            Some(crate::ported::modules::datetime::bin_strftime as HandlerFunc),
19888            1,
19889            3,
19890            0,
19891            Some("nqrs:"),
19892            None,
19893        ), // c:239
19894        // c:Src/Modules/zftp.c:189 — zsh/zftp module.
19895        BUILTIN(
19896            "zftp",
19897            0,
19898            Some(crate::ported::modules::zftp::bin_zftp as HandlerFunc),
19899            1,
19900            -1,
19901            0,
19902            None,
19903            None,
19904        ), // c:189
19905        // c:Src/Modules/zpty.c:882 — zsh/zpty module.
19906        BUILTIN(
19907            "zpty",
19908            0,
19909            Some(crate::ported::modules::zpty::bin_zpty as HandlerFunc),
19910            0,
19911            -1,
19912            0,
19913            Some("ebdmrwLnt"),
19914            None,
19915        ), // c:882
19916        // c:Src/Modules/curses.c:1632 — zsh/curses module.
19917        BUILTIN(
19918            "zcurses",
19919            0,
19920            Some(crate::ported::modules::curses::bin_zcurses as HandlerFunc),
19921            1,
19922            -1,
19923            0,
19924            Some(""),
19925            None,
19926        ), // c:1632
19927        // c:Src/Modules/clone.c:110 — zsh/clone module (Linux only;
19928        // bin_clone on non-Linux is the "not available" stub).
19929        BUILTIN(
19930            "clone",
19931            0,
19932            Some(crate::ported::modules::clone::bin_clone as HandlerFunc),
19933            1,
19934            1,
19935            0,
19936            None,
19937            None,
19938        ), // c:110
19939        // c:Src/Modules/example.c — zsh/example module (template).
19940        BUILTIN(
19941            "example",
19942            0,
19943            Some(crate::ported::modules::example::bin_example as HandlerFunc),
19944            0,
19945            -1,
19946            0,
19947            Some("flags"),
19948            None,
19949        ),
19950        // c:Src/Modules/param_private.c:652 — zsh/param/private module.
19951        // BINF_PLUSOPTS | BINF_MAGICEQUALS | BINF_PSPECIAL | BINF_ASSIGN.
19952        // The option string is zsh 5.9.2's `...%ahi:%lmprtux`
19953        // (param_private.c:581 in the 5.9.1/5.9.2 sources), which accepts
19954        // `private -p NAME`; the dev tree's `%lnmrtux` dropped `p` when it
19955        // added `n`. `n` stays because zshrs carries named references.
19956        BUILTIN(
19957            "private",
19958            BINF_PLUSOPTS | BINF_MAGICEQUALS | BINF_PSPECIAL | BINF_ASSIGN,
19959            Some(crate::ported::modules::param_private::bin_private as HandlerFunc),
19960            0,
19961            -1,
19962            0,
19963            Some("AE:%F:%HL:%PR:%TUZ:%ahi:%lmnprtux"),
19964            Some("P"),
19965        ), // c:652
19966        // c:Src/Modules/termcap.c:139 — zsh/termcap module.
19967        BUILTIN(
19968            "echotc",
19969            0,
19970            Some(crate::ported::modules::termcap::bin_echotc as HandlerFunc),
19971            1,
19972            -1,
19973            0,
19974            None,
19975            None,
19976        ), // c:139
19977        // c:Src/Modules/attr.c:220-223 — zsh/attr module (4 builtins).
19978        BUILTIN(
19979            "zgetattr",
19980            0,
19981            Some(crate::ported::modules::attr::bin_getattr as HandlerFunc),
19982            2,
19983            3,
19984            0,
19985            Some("h"),
19986            None,
19987        ), // c:220
19988        BUILTIN(
19989            "zsetattr",
19990            0,
19991            Some(crate::ported::modules::attr::bin_setattr as HandlerFunc),
19992            3,
19993            3,
19994            0,
19995            Some("h"),
19996            None,
19997        ), // c:221
19998        BUILTIN(
19999            "zdelattr",
20000            0,
20001            Some(crate::ported::modules::attr::bin_delattr as HandlerFunc),
20002            2,
20003            -1,
20004            0,
20005            Some("h"),
20006            None,
20007        ), // c:222
20008        BUILTIN(
20009            "zlistattr",
20010            0,
20011            Some(crate::ported::modules::attr::bin_listattr as HandlerFunc),
20012            1,
20013            2,
20014            0,
20015            Some("h"),
20016            None,
20017        ), // c:223
20018        BUILTIN(
20019            "zle",
20020            0,
20021            Some(crate::ported::zle::zle_thingy::bin_zle as HandlerFunc),
20022            0,
20023            -1,
20024            0,
20025            // c:Src/Zle/zle_main.c:2212 — exact C optstr. The previous
20026            // string dropped g/G/r/w, so `zle -w -F fd handler`
20027            // (zzcomplete's zz-deploy-code:27 widget-mode fd watcher)
20028            // died with "bad option: -w" before bin_zle ever ran.
20029            Some("aAcCDfFgGIKlLmMNrRTUw"),
20030            None,
20031        ),
20032        // zsh/files module — file-manipulation builtins. All have
20033        // HandlerFunc-compatible signatures already.
20034        BUILTIN(
20035            "mkdir",
20036            0,
20037            Some(crate::ported::modules::files::bin_mkdir as HandlerFunc),
20038            1,
20039            -1,
20040            0,
20041            Some("pm:"),
20042            None,
20043        ),
20044        BUILTIN(
20045            "rmdir",
20046            0,
20047            Some(crate::ported::modules::files::bin_rmdir as HandlerFunc),
20048            1,
20049            -1,
20050            0,
20051            None,
20052            None,
20053        ),
20054        BUILTIN(
20055            "ln",
20056            0,
20057            Some(crate::ported::modules::files::bin_ln as HandlerFunc),
20058            1,
20059            -1,
20060            0,
20061            Some("dfins"),
20062            None,
20063        ),
20064        // `mv` — zsh/files. Same handler as `ln` with BIN_MV dispatch.
20065        // c:Src/Modules/files.c — `BUILTIN("mv", 0, bin_ln, 2, -1, BIN_MV, "fi", NULL)`.
20066        BUILTIN(
20067            "mv",
20068            0,
20069            Some(crate::ported::modules::files::bin_ln as HandlerFunc),
20070            2,
20071            -1,
20072            crate::ported::modules::files::BIN_MV,
20073            Some("fi"),
20074            None,
20075        ),
20076        BUILTIN(
20077            "rm",
20078            0,
20079            Some(crate::ported::modules::files::bin_rm as HandlerFunc),
20080            1,
20081            -1,
20082            0,
20083            Some("dfiRrs"),
20084            None,
20085        ),
20086        BUILTIN(
20087            "chmod",
20088            0,
20089            Some(crate::ported::modules::files::bin_chmod as HandlerFunc),
20090            2,
20091            -1,
20092            0,
20093            Some("Rs"),
20094            None,
20095        ),
20096        // c:Src/Modules/files.c:806 — BUILTIN("chgrp", 0, bin_chown, 2, -1, BIN_CHGRP, "hRs", NULL)
20097        BUILTIN(
20098            "chgrp",
20099            0,
20100            Some(crate::ported::modules::files::bin_chown as HandlerFunc),
20101            2,
20102            -1,
20103            crate::ported::modules::files::BIN_CHGRP,
20104            Some("hRs"),
20105            None,
20106        ),
20107        // c:Src/Modules/files.c:808 — BUILTIN("chown", 0, bin_chown, 2, -1, BIN_CHOWN, "hRs", NULL)
20108        BUILTIN(
20109            "chown",
20110            0,
20111            Some(crate::ported::modules::files::bin_chown as HandlerFunc),
20112            2,
20113            -1,
20114            crate::ported::modules::files::BIN_CHOWN,
20115            Some("hRs"),
20116            None,
20117        ),
20118        BUILTIN(
20119            "sync",
20120            0,
20121            Some(crate::ported::modules::files::bin_sync as HandlerFunc),
20122            0,
20123            0,
20124            0,
20125            None,
20126            None,
20127        ),
20128        // c:Src/Modules/files.c:816-824 — zf_* aliases. Same handlers as
20129        // chmod/chown/ln/mkdir/rm/rmdir/sync but separate BUILTIN entries
20130        // so `autoload -U zf_*` resolves and `zsh -f` sees them all.
20131        BUILTIN(
20132            "zf_chgrp",
20133            0,
20134            Some(crate::ported::modules::files::bin_chown as HandlerFunc),
20135            2,
20136            -1,
20137            crate::ported::modules::files::BIN_CHGRP,
20138            Some("hRs"),
20139            None,
20140        ), // c:816
20141        BUILTIN(
20142            "zf_chmod",
20143            0,
20144            Some(crate::ported::modules::files::bin_chmod as HandlerFunc),
20145            2,
20146            -1,
20147            0,
20148            Some("Rs"),
20149            None,
20150        ), // c:817
20151        BUILTIN(
20152            "zf_chown",
20153            0,
20154            Some(crate::ported::modules::files::bin_chown as HandlerFunc),
20155            2,
20156            -1,
20157            crate::ported::modules::files::BIN_CHOWN,
20158            Some("hRs"),
20159            None,
20160        ), // c:818
20161        BUILTIN(
20162            "zf_ln",
20163            0,
20164            Some(crate::ported::modules::files::bin_ln as HandlerFunc),
20165            1,
20166            -1,
20167            crate::ported::modules::files::BIN_LN,
20168            Some("dfins"),
20169            None,
20170        ), // c:819
20171        BUILTIN(
20172            "zf_mkdir",
20173            0,
20174            Some(crate::ported::modules::files::bin_mkdir as HandlerFunc),
20175            1,
20176            -1,
20177            0,
20178            Some("pm:"),
20179            None,
20180        ), // c:820
20181        BUILTIN(
20182            "zf_mv",
20183            0,
20184            Some(crate::ported::modules::files::bin_ln as HandlerFunc),
20185            2,
20186            -1,
20187            crate::ported::modules::files::BIN_MV,
20188            Some("fi"),
20189            None,
20190        ), // c:821
20191        BUILTIN(
20192            "zf_rm",
20193            0,
20194            Some(crate::ported::modules::files::bin_rm as HandlerFunc),
20195            1,
20196            -1,
20197            0,
20198            Some("dfiRrs"),
20199            None,
20200        ), // c:822
20201        BUILTIN(
20202            "zf_rmdir",
20203            0,
20204            Some(crate::ported::modules::files::bin_rmdir as HandlerFunc),
20205            1,
20206            -1,
20207            0,
20208            None,
20209            None,
20210        ), // c:823
20211        BUILTIN(
20212            "zf_sync",
20213            0,
20214            Some(crate::ported::modules::files::bin_sync as HandlerFunc),
20215            0,
20216            0,
20217            0,
20218            None,
20219            None,
20220        ), // c:824
20221    ]
20222});
20223// hash table containing builtin commands                                   // c:143
20224/// Process-wide builtin lookup table. Filled lazily the first time
20225/// `builtintab()` is called; mirrors the C `mod_export HashTable
20226/// builtintab` exposed at `Src/builtin.c:146`.
20227static builtintab: OnceLock<HashMap<String, &'static builtin>> = OnceLock::new();
20228
20229/// The BUCKET-ARRAY form of `builtintab` — the storage C actually uses, kept
20230/// alongside the `HashMap` above (which stays the lookup fast path).
20231///
20232/// c:Src/builtin.c:152 `builtintab = newhashtable(85, "builtintab", NULL);`
20233/// then c:167 `addbuiltins("zsh", builtins, sizeof(builtins)/sizeof(*builtins))`,
20234/// whose loop (c:Src/module.c:547-557) walks the `builtins[]` array in
20235/// DECLARATION order calling `addbuiltin` → `builtintab->addnode` (= the
20236/// `addhashnode` / `addhashnode2` front-insert at c:Src/hashtable.c:168-222).
20237///
20238/// Enumeration order matters beyond cosmetics: `scanhashtable` walks buckets
20239/// 0..hsize-1, each chain head→tail (c:Src/hashtable.c:420-434), and that is
20240/// the order `${(k)builtins}` prints AND the order `compadd -k builtins` feeds
20241/// `join_clines`, whose ambiguous-prefix fold is not commutative. A `HashMap`
20242/// (or a sorted list) produces a different, wrong sequence.
20243///
20244/// 85 buckets vs 103 nodes never trips the c:183/219 `ct >= hsize * 2`
20245/// expansion, so the table keeps its original `hsize`.
20246/// Iterate it with `.iter()` to get C's `scanhashtable` traversal — bucket
20247/// 0..hsize-1, each chain head→tail (c:Src/hashtable.c:420-434).
20248///
20249/// Only entries that are actually IN `builtintab` are added: zshrs's
20250/// `BUILTINS` slice is the static union of every statically-linked module's
20251/// bintab, while C's table holds the core `builtins[]` plus whatever the
20252/// currently-loaded modules added. `builtin_in_builtintab` is the existing
20253/// predicate for that membership; feeding the same set through C's hash makes
20254/// the bucket walk match `zsh -f` name for name.
20255pub static BUILTINTAB_NODES: std::sync::LazyLock<
20256    crate::ported::hashtable::hashtable_nodes<&'static builtin>, // c:152
20257> = std::sync::LazyLock::new(|| {
20258    // c:152 — `newhashtable(85, "builtintab", NULL)`.
20259    let mut ht = crate::ported::hashtable::hashtable_nodes::newhashtable(85);
20260    // c:167 → c:Src/module.c:547 `for (n = 0; n < size; n++)`, i.e. the
20261    // `builtins[]` DECLARATION order. A duplicate name re-adds in place
20262    // (c:Src/hashtable.c:189-204 `replacing:`), leaving it at the same chain
20263    // position C's `BINF_ADDED` skip would.
20264    for b in BUILTINS.iter() {
20265        if !crate::ext_builtins::builtin_in_builtintab(&b.node.nam) {
20266            continue;
20267        }
20268        ht.addhashnode2(&b.node.nam, b); // c:Src/module.c:551 addbuiltin
20269    }
20270    ht
20271});
20272
20273/// Names whose `node.flags & DISABLED` is set in C. The Rust port's
20274/// `builtintab` is an immutable static, so the disabled bit lives
20275/// in this parallel set; `bin_enable` toggles it via builtin.c:587.
20276/// Dispatch sites check `is_builtin_disabled(name)` before calling
20277/// `handlerfunc` to mirror C's "skip nodes with DISABLED set" walk.
20278pub static BUILTINS_DISABLED: std::sync::LazyLock<
20279    // c:587 (Src/builtin.c)
20280    Mutex<std::collections::HashSet<String>>,
20281> = std::sync::LazyLock::new(|| Mutex::new(std::collections::HashSet::new()));
20282
20283// `shfunctab` is the canonical singleton at `hashtable::shfunctab_lock()`
20284// (RwLock<shfunc_table>); the prior parallel `shfunctab_table()` /
20285// `SHFUNCTAB_INNER` (usize-pointer Mutex<HashMap>) was deleted so the
20286// `bin_functions` C-port and the bytecode function-def path both write
20287// through one table. C-faithful access via `addnode`/`getnode`/`getnode2`
20288// methods (Src/zsh.h:281+ HashTable GSU pointers).
20289
20290// `matchednodes` global from Src/builtin.c:4550.
20291/// `MATCHEDNODES` static.
20292pub static MATCHEDNODES: Mutex<Vec<String>> = Mutex::new(Vec::new());
20293
20294// `stopmsg` global from Src/jobs.c — non-zero when checkjobs() printed.
20295/// `STOPMSG` static.
20296pub static STOPMSG: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
20297// `sfcontext` global from Src/exec.c:239 — current shell-function
20298// dispatch context (SFC_NONE / SFC_BUILTIN / SFC_FUNC / SFC_SUBST...).
20299/// `SFCONTEXT` static.
20300pub static SFCONTEXT: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0); // c:exec.c:239
20301                                                                                           // `maxjob` / `thisjob` globals from Src/jobs.c:62/63 — canonical
20302                                                                                           // storage lives in jobs.rs (`OnceLock<Mutex<i32>>`). The previous
20303                                                                                           // builtin.rs duplicate `AtomicI32` stores NEVER synced with the
20304                                                                                           // jobs.rs Mutex<i32> values that the spawn/wait paths actually
20305                                                                                           // update; `checkjobs` (line 5092) read stale 0s no matter how many
20306                                                                                           // jobs were active. Callers route through jobs::MAXJOB / jobs::THISJOB
20307                                                                                           // directly now.
20308                                                                                           // `jobstats` mirror — flat per-slot stat bits (STAT_*). Real jobtab
20309                                                                                           // lives in src/ported/jobs.rs's JobTable; this mirror is updated by
20310                                                                                           // the spawn/wait paths that already touch STOPMSG. Empty → no jobs,
20311                                                                                           // matching the post-init state of `jobtab[]`.
20312/// `JOBSTATS` static.
20313pub static JOBSTATS: Mutex<Vec<i32>> = Mutex::new(Vec::new());
20314
20315// File-static globals for [_]realexit/zexit — c:5945+, init.c, signals.c.
20316/// `SHELL_EXITING` static.
20317pub static SHELL_EXITING: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
20318/// `EXIT_PENDING` static.
20319pub static EXIT_PENDING: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
20320/// `EXIT_VAL` static.
20321pub static EXIT_VAL: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
20322/// Port of `mod_export volatile int exit_level;` from `Src/builtin.c:5796`.
20323///
20324/// Records the `locallevel` at the moment a deferred `exit` was issued
20325/// inside a function. The `exec.c:6141` gate
20326/// `if (exit_pending && exit_level >= locallevel+1 && !in_exit_trap)`
20327/// fires only when the unwind has reached a scope at or above the
20328/// deferral point — preventing premature exit while the deferred
20329/// status walks back through nested function frames. C uses
20330/// `volatile int` because the value is read from a signal-touching
20331/// context; Rust's AtomicI32 with Relaxed ordering matches the same
20332/// no-fence read shape.
20333pub static EXIT_LEVEL: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
20334// ====================================================================
20335// !!! WARNING: RUST-ONLY ATOMIC — NO DIRECT C COUNTERPART !!!
20336// ====================================================================
20337// C zsh forks for `(...)` subshells; the child runs to completion
20338// then exits via process::exit, and the parent (post-fork) continues.
20339// zshrs runs subshells in-process via fusevm_bridge::subshell_begin/
20340// subshell_end (no fork), so `exit N` inside a subshell would call
20341// realexit() → process::exit(N) and terminate the WHOLE shell —
20342// breaking `(exit 7); echo $?` (expected: `7\n`, observed: shell
20343// dies with code 7).
20344//
20345// This counter is bumped by subshell_begin / decremented by
20346// subshell_end. zexit() at c:5977 checks it before realexit and,
20347// when > 0, sets EXIT_VAL + EXIT_PENDING and returns — letting the
20348// subshell unwinder catch the deferred exit at its boundary
20349// (mirroring what the deferred-exit path at c:5871-5891 does for
20350// function-scope exit).
20351//
20352// In C zsh, equivalent state is `forklevel` AT EXACTLY the subshell
20353// depth that fork would create — but the C check `locallevel >
20354// forklevel` is FALSE at subshell-top precisely so the fork can
20355// exit the child via realexit. Without fork, we need this extra
20356// gate.
20357/// `SUBSHELL_DEPTH` static.
20358pub static SUBSHELL_DEPTH: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
20359/// `LASTVAL` static.
20360pub static LASTVAL: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
20361
20362// `char **testargs, **curtestarg` from Src/builtin.c:7186 — the argv cursor
20363// `bin_test` seeds and `testlex()` advances. C holds pointers into the argv
20364// array; here `TESTARGS` owns the argv and `TESTARGS_IDX` / `CURTESTARG` are
20365// the offsets of `testargs` / `curtestarg` into it. Thread-local because the
20366// lexer state `testlex` writes (`tok`, `tokstr`) is thread-local too.
20367thread_local! {
20368    /// The argv `testargs` points into.
20369    pub static TESTARGS: std::cell::RefCell<Vec<String>> = const { std::cell::RefCell::new(Vec::new()) };
20370    /// Offset of `testargs` into `TESTARGS`.
20371    pub static TESTARGS_IDX: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
20372    /// Offset of `curtestarg` into `TESTARGS`.
20373    pub static CURTESTARG: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
20374}
20375
20376// int doprintdir = 0; set in exec.c (for autocd, cdpath, etc.)            // c:722
20377// `doprintdir` from Src/exec.c — set when an autocd'd command should
20378// echo the new directory before executing.
20379/// `DOPRINTDIR` static.
20380pub static DOPRINTDIR: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
20381// set if we are resolving links to their true paths                       // c:829
20382// `chasinglinks` from Src/exec.c — non-zero when CHASELINKS / -P
20383// resolution is active.
20384/// `CHASINGLINKS` static.
20385pub static CHASINGLINKS: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
20386
20387// `pparams` global from Src/init.c — positional parameters $1..$N.
20388/// `PPARAMS` static.
20389pub static PPARAMS: Mutex<Vec<String>> = Mutex::new(Vec::new());
20390
20391// `zoptind` (Src/builtin.c:5667) and `optcind` (c:5670) — the two
20392// pieces of getopts state. zoptind backs the user-visible $OPTIND.
20393/// `ZOPTIND` static.
20394pub static ZOPTIND: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(1);
20395/// `OPTCIND` static.
20396pub static OPTCIND: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
20397
20398// `ttyfrozen` global lives canonically in jobs.rs (`OnceLock<Mutex<i32>>`
20399// at jobs.rs:2625). The previous AtomicI32 duplicate here NEVER
20400// synced with the jobs.rs store — same desync hazard as the prior
20401// MAXJOB / THISJOB fix. Callers route through jobs::TTYFROZEN.
20402
20403/// Port of `mod_export int ineval` from `Src/builtin.c:6389`. Set
20404/// while `eval` is dispatching its body (incremented before
20405/// `execode(prog, 1, 0, "eval")`, decremented after). Tested by
20406/// `IN_EVAL_TRAP()` in zsh.h:2962 to determine trap-context state.
20407pub static INEVAL: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0); // c:6389
20408
20409// `loops` / `breaks` / `contflag` / `retflag` / `locallevel` / `sourcelevel`
20410// globals from Src/loop.c + Src/init.c — control-flow state consulted by
20411// the bin_break dispatcher.
20412/// Port of `int ancestor_loops;` from `Src/loop.c:36` (55061) — the
20413/// number of nested loops started in ANCESTOR subshells; `entersubsh`
20414/// moves `loops` here (c:Src/exec.c:1263-1264) so `break`/`continue`
20415/// can tell "not in a loop" from "loop is in a parent process".
20416pub static ANCESTOR_LOOPS: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
20417/// `LOOPS` static — `int loops;` (`Src/loop.c:41`), loops started in the
20418/// current subshell.
20419pub static LOOPS: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
20420/// `BREAKS` static.
20421pub static BREAKS: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
20422/// `CONTFLAG` static.
20423pub static CONTFLAG: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
20424/// `RETFLAG` static.
20425pub static RETFLAG: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
20426// Same single-storage rationale as locallevel_param above — C zsh has
20427// only ONE `int sourcelevel;` global (Src/init.c:60). The canonical
20428// Rust port is `sourcelevel_init` (lowercase,
20429// matches C name). Re-export that single storage so the bin_break
20430// reader and the bin_dot bumps address the same atomic; without
20431// this, `bin_dot` could increment one global while `bin_break`
20432// inspected the other and `return` inside a sourced file would
20433// fall through to `zexit` (Src/builtin.c:5858).
20434// `locallevel_param` was previously a SEPARATE AtomicI32 here, but C
20435// zsh has only ONE `int locallevel;` global (Src/params.c:54).
20436// The canonical Rust port is `locallevel_param`
20437// (lowercase, matches C name). Re-export that single storage so
20438// every reader and writer addresses the same atomic — without
20439// this, `locallevel_param.store(0)` in zle/computil.rs would zero one
20440// global while `params::locallevel.fetch_add(1)` in vm_helper
20441// incremented a DIFFERENT global, leaving the two views out of
20442// sync indefinitely.
20443// `ZEXIT_NORMAL` re-exported from canonical zsh_h.rs (port of the
20444// `enum { ZEXIT_NORMAL, ZEXIT_SIGNAL, ZEXIT_DEFERRED }` in Src/zsh.h).
20445// Same single-source-of-truth pattern as TERM_UNKNOWN / HISTFLAG_*
20446// / etc — duplicate const declarations are a drift hazard.
20447
20448// Local builders that construct C-shape `builtin` rows for the
20449// static registration table below. They mirror the
20450// `BUILTIN(...)` / `BIN_PREFIX(...)` macros in `Src/zsh.h:1450-1452`,
20451// taking `u32` flag bitsets (BINF_*) and a `&str` handler-name
20452// column used only for documentation/wiring lookup — handler
20453// function pointers themselves are wired up later in
20454// `Executor::register_builtins` (`src/ported/vm_helper`).
20455//
20456// The `handler` arg was previously a `_handler_name: &'static str` that
20457// was discarded — `handlerfunc` always ended up `NULLBINCMD`, so
20458// `execbuiltin`'s c:506 `(*handlerfunc)(...)` dispatch was unreachable.
20459// Now the descriptor carries the actual port-side `HandlerFunc` so
20460// `execbuiltin` can parse flags and call through to the real builtin.
20461/// `BUILTIN` — see implementation.
20462#[allow(non_snake_case)]
20463/// Port of `BUILTIN()` from `Src/zsh.h:1450`. — C decl `BUILTIN(name, flags, handler, min, max, funcid, optstr, defopts)`
20464pub fn BUILTIN(
20465    name: &str,
20466    flags: u32,
20467    handler: Option<HandlerFunc>,
20468    min: i32,
20469    max: i32,
20470    funcid: i32,
20471    optstr: Option<&str>,
20472    defopts: Option<&str>,
20473) -> builtin {
20474    builtin {
20475        node: hashnode {
20476            next: None,
20477            nam: name.to_string(),
20478            flags: flags as i32,
20479        },
20480        handlerfunc: handler,
20481        minargs: min,
20482        maxargs: max,
20483        funcid,
20484        optstr: optstr.map(|s| s.to_string()),
20485        defopts: defopts.map(|s| s.to_string()),
20486    }
20487}
20488
20489// `traps` mirror — sig name → body. Real `sigtrapped[]`/`siglists[]`
20490// arrays live in src/ported/signals.rs; this Mutex is the static-link
20491// shim that bin_trap reads/writes.
20492static TRAPS_INNER: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
20493
20494#[allow(non_snake_case)]
20495/// Port of `BIN_PREFIX()` from `Src/zsh.h:1452`. — C decl `BIN_PREFIX(name, flags)`
20496fn BIN_PREFIX(name: &str, flags: u32) -> builtin {
20497    BUILTIN(name, flags | BINF_PREFIX, None, 0, 0, 0, None, None)
20498}
20499
20500/// Printf format engine used by bin_print's -f/printf mode: the spec walk of
20501/// Src/builtin.c:4691-5500 (flags, width, precision, `*`, `%n$` positional
20502/// specifiers, `%b`, `%q`, `%n`, `\c` truncation and format reuse).
20503///
20504/// Returns `Ok(output)` on success, or `Err((output_so_far, bad_char))`
20505/// when an unknown `%X` directive is hit. The C source emits
20506/// `zwarnnam(name, "%s: invalid directive", start)` at builtin.c:5435
20507/// then `return 1` — partial output already written stays written. The
20508/// Rust caller (bin_print, c:4854+) mirrors that: print the partial
20509/// output, emit `zwarnnam`, return 1.
20510/// Returns the formatted output plus the byte offset at which each
20511/// format-reuse CYCLE began (c:Src/builtin.c `splits`). `printf -v` to
20512/// an array assigns one element per cycle, so the caller slices `out`
20513/// at these boundaries.
20514/// !!! WARNING: RUST-ONLY HELPER !!!
20515///
20516/// No upstream C counterpart: this name does not exist anywhere in
20517/// `Src/`. C has no printf-spec helper functions at all: `bin_print`
20518/// (`Src/builtin.c:4587`) walks the format string inline and hands the
20519/// reconstructed spec to libc `fprintf` through the `print_val` macro
20520/// (`Src/builtin.c:4571`). Rust has no runtime format-spec engine, so
20521/// that walk is split into these hand-written pieces.
20522/// Deliberately left uncited so the port audit keeps reporting it.
20523fn printf_format(
20524    fmt: &str,
20525    args: &[String],
20526) -> Result<(String, Vec<usize>, Vec<(String, usize)>), (String, String)> {
20527    // c:Src/builtin.c:4711 — `fmt = getkeystring(fmt, &flen, ...,
20528    // GETKEYS_PRINTF_FMT, ...);`. The format string is first run
20529    // through getkeystring to interpret backslash escapes (`\n`,
20530    // `\t`, `\xNN`, etc.) before %-format substitution. The shim
20531    // `getkeystring(s)` defaults flags to 0, which (a) drops the
20532    // backslash on `\'`/`\"` (wrong for printf — zsh keeps unknown
20533    // `\<c>` literal because PRINTF_FMT excludes GETKEY_EMACS) and
20534    // (b) treats `\c` as a control-char escape rather than the
20535    // truncation marker. Route through `_with(GETKEYS_PRINTF_FMT)`
20536    // to match C exactly. `_with` takes a u32 `how` mask; the
20537    // canonical i32 const lives in `zsh_h` (Src/zsh.h:3180-3181).
20538    // !!! DASH-STRICT GATE (no C counterpart) !!! dash's printf format has no
20539    // `\c` escape: the two characters are printed as written.
20540    let fmt = if crate::dash_mode::dash_strict() {
20541        crate::dash_mode::dash_printf_keep_backslash_c(fmt)
20542    } else {
20543        fmt.to_string()
20544    };
20545    let fmt = fmt.as_str();
20546    let (fmt, _) = getkeystring_with(fmt, crate::ported::zsh_h::GETKEYS_PRINTF_FMT as u32, None); // c:builtin.c:4711
20547                                                                                                  // c:Src/builtin.c:4696/5382/5527 — a `\c` in the FORMAT (or, below, in
20548                                                                                                  // a `%b` arg) sets `fmttrunc`, which (a) truncates output here and
20549                                                                                                  // (b) stops the format-reuse loop entirely (no reapplication over the
20550                                                                                                  // remaining args). getkeystring_with already cut `fmt` at the `\c`;
20551                                                                                                  // take the TLS flag it set so the reuse loop knows to stop.
20552    let mut fmttrunc = crate::ported::utils::getkey_truncated_take();
20553    let mut out = String::new();
20554    let mut arg_i: usize = 0;
20555    // c:Src/builtin.c:5166/5176 — `first` is the base of the current
20556    // format-reuse cycle's argument window. `%n$` positional specs index
20557    // `first + n - 1`; at the start of each new cycle `first += maxarg`
20558    // (the highest positional used in the prior cycle). `first_off` is
20559    // the Rust analog (absolute offset into `args`).
20560    let mut first_off: usize = 0;
20561    // c:Src/builtin.c — `splits`: byte offset where each format-reuse
20562    // cycle starts (the first is 0). Used by `printf -v ARRAY`.
20563    let mut bounds: Vec<usize> = Vec::new();
20564    // c:Src/builtin.c:5427-5429 — `case 'n': if (curarg) setiparam(curarg,
20565    // count - rcount);`. `%n` consumes an argument (the variable NAME) and
20566    // stores the number of bytes emitted so far into it, printing nothing.
20567    // printf_format is a pure formatter with no shell handle, so it records
20568    // (name, byte_count_at_this_point) and the caller does the setiparam —
20569    // out.len() here is exactly C's `count - rcount`.
20570    let mut n_targets: Vec<(String, usize)> = Vec::new();
20571    // c:Src/builtin.c:4914-4923 — printf reapplies the format string
20572    // until ALL args are consumed. `printf '%s,' a b c` → `a,b,c,`,
20573    // not `a,`. The outer loop reapplies; the inner do-while body
20574    // mirrors C's per-arg conversion loop directly.
20575    loop {
20576        bounds.push(out.len());
20577        // c:Src/builtin.c:5168 `rcount = count;` reset the `%n` byte count
20578        // at the start of each format-reuse cycle, so `%n` reports bytes
20579        // emitted SINCE this cycle began, not cumulatively. `printf 'x%n' a b`
20580        // stores 1 in both a and b, not 1 and 2.
20581        let cycle_start = out.len();
20582        let prev = arg_i;
20583        // c:Src/builtin.c:5175-5178 — `maxarg` is the highest positional
20584        // (`%n$`) referenced in THIS cycle; reset per cycle and folded
20585        // into `first_off` at cycle end.
20586        let mut cycle_maxarg: usize = 0;
20587        let mut iter = fmt.chars().peekable();
20588        while let Some(c) = iter.next() {
20589            if c != '%' {
20590                out.push(c);
20591                continue;
20592            }
20593            // c:Src/builtin.c:4791+ — parse width/precision/flag chars
20594            // between `%` and the conversion. Capture them so `printf
20595            // "%-10s" hi` and `printf "%.3f" 3.14159` render correctly.
20596            let mut spec = String::from("%");
20597            // c:Src/builtin.c:5170 — `start = c++;`. `start` is the RAW
20598            // format text from the `%` onward; the invalid-directive
20599            // diagnostic (c:5414-5419) prints it after planting a temporary
20600            // NUL at `c[1]`, i.e. "% .. <bad conversion char>". `spec` is a
20601            // REWRITTEN spec (`*` width already substituted by its numeric
20602            // value), so it cannot serve as `start`; accumulate the raw text
20603            // separately as each character is consumed.
20604            let mut raw = String::from("%");
20605            // c:Src/builtin.c:5199-5219 — `%n$` positional argument
20606            // specifier. A leading 1-9 digit run followed by `$` selects
20607            // the n-th argument (1-based) for this conversion instead of
20608            // the next sequential one. A leading digit run NOT followed
20609            // by `$` is an ordinary field width (handled below), so peek
20610            // the whole run before deciding. (`0` can't start a
20611            // positional — it's a flag — so C tests `'1'..'9'`.)
20612            let mut positional: Option<usize> = None;
20613            if matches!(iter.peek(), Some(&d) if ('1'..='9').contains(&d)) {
20614                let mut digits = String::new();
20615                while let Some(&d) = iter.peek() {
20616                    if d.is_ascii_digit() {
20617                        digits.push(d);
20618                        raw.push(d);
20619                        iter.next();
20620                    } else {
20621                        break;
20622                    }
20623                }
20624                if iter.peek() == Some(&'$') {
20625                    iter.next(); // c:5202 — consume `$`
20626                    raw.push('$');
20627                    let narg: usize = digits.parse().unwrap_or(0);
20628                    // c:5203-5212 — out-of-range positional is a hard
20629                    // error (`zwarnnam(...); return 1`). `argc` in C is
20630                    // the per-cycle remaining count (total minus the
20631                    // already-consumed `first_off`).
20632                    if narg == 0 || first_off + narg > args.len() {
20633                        return Err((
20634                            out,
20635                            format!("{}: argument specifier out of range", narg), // c:5204
20636                        ));
20637                    }
20638                    // c:5214-5216 — `if (narg > maxarg) maxarg = narg;`
20639                    //               `curarg = *(first + narg - 1);`
20640                    if narg > cycle_maxarg {
20641                        cycle_maxarg = narg;
20642                    }
20643                    positional = Some(first_off + narg - 1);
20644                } else {
20645                    // Not a positional: the digits are an ordinary field
20646                    // width — emit them into the spec and fall through.
20647                    spec.push_str(&digits);
20648                }
20649            }
20650            loop {
20651                match iter.peek() {
20652                    // c:Src/builtin.c:4791+ — printf flag chars. The `'`
20653                    // (thousands-grouping) flag is passed straight to libc
20654                    // by C zsh; the Rust port groups the digit run itself
20655                    // in format_spec_int/uint per localeconv. Accept it here
20656                    // so it isn't mistaken for the conversion char (which
20657                    // produced "%': invalid directive").
20658                    Some(&c) if matches!(c, '-' | '+' | ' ' | '#' | '0' | '\'') => {
20659                        spec.push(c);
20660                        raw.push(c);
20661                        iter.next();
20662                    }
20663                    _ => break,
20664                }
20665            }
20666            // c:Src/builtin.c:5205-5215 — width can be either a
20667            // digit literal (`width = strtoul(c, &endptr, 0); c = endptr;`)
20668            // or `*` (consume an arg as the width).
20669            // Without `*` handling, `printf '%*d' 4 7` rendered the
20670            // literal `%*d` because the iter saw `*` and aborted the
20671            // spec walk before reaching the conversion char.
20672            if iter.peek() == Some(&'*') {
20673                iter.next(); // c:5207 — consume the `*` marker
20674                raw.push('*');
20675                // c:5208-5231 — `if (idigit(*++c))`: a `*` width may itself
20676                // carry a positional specifier (`%*n$d`), naming which
20677                // argument supplies the width. Unlike the leading `%n$`
20678                // (c:5183, which tests `'1'..'9'`), `idigit` here accepts
20679                // `0` too, so `%*0$d` reaches the range check and errors.
20680                if matches!(iter.peek(), Some(&d) if d.is_ascii_digit()) {
20681                    let raw_before_digits = raw.len();
20682                    let mut digits = String::new();
20683                    while let Some(&d) = iter.peek() {
20684                        if d.is_ascii_digit() {
20685                            digits.push(d);
20686                            raw.push(d);
20687                            iter.next();
20688                        } else {
20689                            break;
20690                        }
20691                    }
20692                    if iter.peek() == Some(&'$') {
20693                        iter.next(); // c:5211 — `c = endptr + 1;`
20694                        raw.push('$');
20695                        let narg: usize = digits.parse().unwrap_or(0);
20696                        // c:5212-5224 — `if (narg > argc || narg <= 0)`.
20697                        // `argc` is the remaining window (total minus the
20698                        // already-folded `first_off`).
20699                        if narg == 0 || first_off + narg > args.len() {
20700                            return Err((
20701                                out,
20702                                format!("{}: argument specifier out of range", narg), // c:5214
20703                            ));
20704                        }
20705                        // c:5227-5228 — `if (narg > maxarg) maxarg = narg;`
20706                        //               `argp = first + narg - 1;`
20707                        // NOTE: unlike the leading `%n$` (which only sets
20708                        // `curarg`), a `*n$` width MOVES the sequential
20709                        // cursor `argp`, and the `argp++` below leaves it
20710                        // past the width argument.
20711                        if narg > cycle_maxarg {
20712                            cycle_maxarg = narg;
20713                        }
20714                        arg_i = first_off + narg - 1;
20715                    } else {
20716                        // c:5208-5210 — digits NOT followed by `$` are left
20717                        // unconsumed by C (`c` still points at the first of
20718                        // them). They therefore fall through the `.`/size
20719                        // -modifier tests and land on the conversion switch,
20720                        // where a digit is an unknown directive. Peekable
20721                        // cannot push the run back, so raise the identical
20722                        // diagnostic here: `start` truncated after the first
20723                        // digit (c:5416-5418 plants the NUL at `c[1]`).
20724                        let mut msg = raw[..raw_before_digits].to_string();
20725                        msg.push(digits.as_bytes()[0] as char);
20726                        return Err((out, format!("{}: invalid directive", msg)));
20727                        // c:5419
20728                    }
20729                }
20730                // c:5232-5238 — `if (*argp) { width = mathevali(...); argp++;
20731                // ... }`. The `*` width arg is MATH-EVALUATED, not a plain
20732                // integer parse: `0x1f`->31, ` 4`->4, `'A`->65, `2+3`->5.
20733                // parse_int_arg is the shared getnum->mathevali path (the same
20734                // one the %d/%i arm uses), and on a math error it zeroes the
20735                // value and flags ret=1 (c:5235 `errflag -> ret = 1`).
20736                // When the args have run out `width` stays at its 0 init
20737                // (c:5180) and `argp` is NOT advanced.
20738                if let Some(a) = args.get(arg_i) {
20739                    let w: i64 = parse_int_arg(a);
20740                    arg_i += 1; // c:5234
20741                    spec.push_str(&w.to_string());
20742                }
20743            } else {
20744                while let Some(&c) = iter.peek() {
20745                    if c.is_ascii_digit() {
20746                        spec.push(c);
20747                        raw.push(c);
20748                        iter.next();
20749                    } else {
20750                        break;
20751                    }
20752                }
20753            }
20754            if iter.peek() == Some(&'.') {
20755                iter.next(); // c:5248 — consume the `.`
20756                raw.push('.');
20757                // c:5249 — `if (*++c == '*')`: precision accepts `*` exactly
20758                // like width, positional specifier included.
20759                if iter.peek() == Some(&'*') {
20760                    iter.next();
20761                    raw.push('*');
20762                    // c:5250-5273 — `if (idigit(*++c))`: `%.*n$d` names the
20763                    // argument that supplies the precision.
20764                    if matches!(iter.peek(), Some(&d) if d.is_ascii_digit()) {
20765                        let raw_before_digits = raw.len();
20766                        let mut digits = String::new();
20767                        while let Some(&d) = iter.peek() {
20768                            if d.is_ascii_digit() {
20769                                digits.push(d);
20770                                raw.push(d);
20771                                iter.next();
20772                            } else {
20773                                break;
20774                            }
20775                        }
20776                        if iter.peek() == Some(&'$') {
20777                            iter.next(); // c:5253 — `c = endptr + 1;`
20778                            raw.push('$');
20779                            let narg: usize = digits.parse().unwrap_or(0);
20780                            // c:5254-5266 — `if (narg > argc || narg <= 0)`
20781                            if narg == 0 || first_off + narg > args.len() {
20782                                return Err((
20783                                    out,
20784                                    format!("{}: argument specifier out of range", narg), // c:5256
20785                                ));
20786                            }
20787                            // c:5269-5270 — `if (narg > maxarg) maxarg = narg;`
20788                            //               `argp = first + narg - 1;`
20789                            if narg > cycle_maxarg {
20790                                cycle_maxarg = narg;
20791                            }
20792                            arg_i = first_off + narg - 1;
20793                        } else {
20794                            // c:5250-5252 — see the width case above: an
20795                            // un-`$`-terminated digit run is left for the
20796                            // conversion switch, i.e. an invalid directive.
20797                            let mut msg = raw[..raw_before_digits].to_string();
20798                            msg.push(digits.as_bytes()[0] as char);
20799                            return Err((out, format!("{}: invalid directive", msg)));
20800                            // c:5419
20801                        }
20802                    }
20803                    // c:Src/builtin.c:5275-5288 — the `*` precision arg is
20804                    // math-evaluated identically to the `*` width arg
20805                    // (`prec = (int)mathevali(metafy(*argp, …))`, error →
20806                    // ret=1) — BUT only when the arg actually EXISTS
20807                    // (c:5275 `if (*argp)`). If args have run out, `prec`
20808                    // stays at its -1 init (c:5178) and NO precision is
20809                    // emitted (c:5288 `if (prec >= 0) *d++ = '.', *d++ =
20810                    // '*'`). A negative result is unset the same way.
20811                    // Unlike width (init 0), a MISSING `*` precision must
20812                    // leave the conversion at default precision, not force
20813                    // `.0`: `printf '%.*d' ` prints `0` (default), not the
20814                    // truncated-to-empty `%.0d`. Previously `unwrap_or(0)`
20815                    // forced `.0` and dropped the digit.
20816                    if let Some(a) = args.get(arg_i) {
20817                        let p = parse_int_arg(a);
20818                        arg_i += 1;
20819                        if p >= 0 {
20820                            spec.push('.');
20821                            spec.push_str(&p.to_string());
20822                        }
20823                    }
20824                } else {
20825                    // Literal precision: `.` then optional digits (an empty
20826                    // digit run is precision 0, c:5283-5287 `else prec = 0`).
20827                    spec.push('.');
20828                    while let Some(&c) = iter.peek() {
20829                        if c.is_ascii_digit() {
20830                            spec.push(c);
20831                            raw.push(c);
20832                            iter.next();
20833                        } else {
20834                            break;
20835                        }
20836                    }
20837                }
20838            }
20839            // c:Src/builtin.c:5290 — `/* ignore any size modifier */
20840            // if (*c == 'l' || *c == 'L' || *c == 'h') c++;`. zsh accepts
20841            // and discards a single length modifier (`%ld`, `%lu`, `%hd`)
20842            // — the conversion char that follows determines the type and
20843            // C rebuilds the libc format as `%l<conv>` itself. NOT pushed
20844            // into `spec`: it would corrupt the format passed to the
20845            // format_spec_* helpers. Only one modifier char is skipped,
20846            // matching C (so `%lld` then errors on the second `l`).
20847            if let Some(&m) = iter.peek() {
20848                if matches!(m, 'l' | 'L' | 'h') && !crate::dash_mode::dash_strict() {
20849                    raw.push(m); // c:5290 — `c++` past the modifier; `start` still covers it
20850                    iter.next();
20851                }
20852            }
20853            // c:Src/builtin.c:5215/5310 — a `%n$` positional spec sets
20854            // `curarg` directly; the conversion arms below read from
20855            // `arg_i`, so point it at the positional index for the
20856            // duration of the conversion, then restore. Restoring means
20857            // the positional reference does NOT advance the sequential
20858            // cursor (C only advances `argp` for non-positional args).
20859            let saved_argi = arg_i;
20860            let is_positional = positional.is_some();
20861            if let Some(p) = positional {
20862                arg_i = p;
20863            }
20864            match iter.next() {
20865                // c:Src/builtin.c:5414-5419 — a bare `%%` prints `%`, but a
20866                // `%` directive carrying any flag / width / precision / `*` is
20867                // an "invalid directive": zsh handles doubled `%%` in the
20868                // literal scan (c:5172) and only reaches the conversion switch
20869                // (which has no `%` case → default) once modifiers intervened.
20870                // The test is on the SOURCE text (`raw`, C's `start`), not on
20871                // the rebuilt `spec`: a `*` width whose argument list is
20872                // exhausted adds nothing to `spec` (c:5232 `if (*argp)`), yet
20873                // `%*%` is still a directive with a modifier, and the message
20874                // prints `start` verbatim (`%*%`, not the substituted `%3%`).
20875                Some('%') if raw != "%" => {
20876                    return Err((out, format!("{raw}%: invalid directive"))); // c:5419
20877                }
20878                Some('%') => out.push('%'),
20879                Some('n') => {
20880                    // c:5427-5428 — `if (curarg) setiparam(curarg, count -
20881                    // rcount);`. The arg is the target variable's NAME; nothing
20882                    // is emitted. `if (curarg)` tests that an arg SLOT exists,
20883                    // not that it is non-empty — an empty or malformed name is
20884                    // still handed to setiparam, which errors `not an
20885                    // identifier`. Only a wholly ABSENT arg is the no-op.
20886                    // `count - rcount` is bytes since the cycle start
20887                    // (c:5168), i.e. out.len() - cycle_start here.
20888                    if let Some(name) = args.get(arg_i) {
20889                        n_targets.push((name.clone(), out.len() - cycle_start));
20890                        // c:5428
20891                    }
20892                    arg_i += 1;
20893                }
20894                Some('s') => {
20895                    let a = args.get(arg_i).cloned().unwrap_or_default();
20896                    spec.push('s');
20897                    out.push_str(&format_spec_str(&spec, &a, false));
20898                    arg_i += 1;
20899                }
20900                // bash/ksh `printf '%(FMT)T' TS` — strftime(FMT, TS). The
20901                // timestamp is epoch seconds; a NEGATIVE value or a MISSING
20902                // arg means "now" (bash also defines -2 = shell start, treated
20903                // as now here). zsh's printf has NO such directive (it uses the
20904                // `strftime` builtin), so it is gated to bash / ksh emulation;
20905                // under --zsh it stays an "invalid directive" like real zsh.
20906                Some('(') => {
20907                    // korn_mode() (a bare --ksh/--mksh/--pdksh), not EMULATION(KSH): zsh itself
20908                    // rejects the directive under `emulate ksh` (5.9.2: "%(: invalid directive").
20909                    let allow = crate::dash_mode::bash_mode() || crate::dash_mode::korn_mode();
20910                    // Collect the strftime format up to the matching ')'.
20911                    let mut tfmt = String::new();
20912                    let mut closed = false;
20913                    for c in iter.by_ref() {
20914                        if c == ')' {
20915                            closed = true;
20916                            break;
20917                        }
20918                        tfmt.push(c);
20919                    }
20920                    // The conversion char after ')' must be 'T'.
20921                    let conv_t = iter.next() == Some('T');
20922                    if !allow || !closed || !conv_t {
20923                        return Err((out, format!("{}(: invalid directive", spec)));
20924                    }
20925                    let now = crate::ported::modules::datetime::getcurrenttime()
20926                        .first()
20927                        .and_then(|s| s.parse::<i64>().ok())
20928                        .unwrap_or(0);
20929                    let ts: i64 = match args.get(arg_i) {
20930                        Some(a) => {
20931                            arg_i += 1;
20932                            let v = parse_int_arg(a);
20933                            if v < 0 {
20934                                now
20935                            } else {
20936                                v
20937                            }
20938                        }
20939                        None => now,
20940                    };
20941                    let formatted = (|| -> Option<String> {
20942                        let mut tm: libc::tm = unsafe { std::mem::zeroed() };
20943                        let t = ts as libc::time_t;
20944                        let cfmt = std::ffi::CString::new(tfmt.as_str()).ok()?;
20945                        unsafe {
20946                            if libc::localtime_r(&t, &mut tm).is_null() {
20947                                return None;
20948                            }
20949                            let mut buf = vec![0u8; 256];
20950                            let n = libc::strftime(
20951                                buf.as_mut_ptr() as *mut libc::c_char,
20952                                buf.len(),
20953                                cfmt.as_ptr(),
20954                                &tm,
20955                            );
20956                            buf.truncate(n);
20957                            String::from_utf8(buf).ok()
20958                        }
20959                    })()
20960                    .unwrap_or_default();
20961                    // Apply any field width/flags to the rendered string, the
20962                    // same way `%s` does.
20963                    spec.push('s');
20964                    out.push_str(&format_spec_str(&spec, &formatted, false));
20965                }
20966                Some('d') | Some('i') => {
20967                    if crate::extensions::dash_mode::bash_printf_empty_numeric_error(
20968                        args.get(arg_i),
20969                    ) {
20970                        PRINTF_MATH_ERR.with(|c| c.set(true));
20971                    }
20972                    let a = args.get(arg_i).cloned().unwrap_or_default();
20973                    let n = parse_int_arg(&a);
20974                    spec.push('d');
20975                    out.push_str(&format_spec_int(&spec, n));
20976                    arg_i += 1;
20977                }
20978                Some('u') => {
20979                    if crate::extensions::dash_mode::bash_printf_empty_numeric_error(
20980                        args.get(arg_i),
20981                    ) {
20982                        PRINTF_MATH_ERR.with(|c| c.set(true));
20983                    }
20984                    let a = args.get(arg_i).cloned().unwrap_or_default();
20985                    // c:Src/builtin.c:5511 — `if (!zstrtoul_underscore(
20986                    // curarg, &zulongval)) zulongval = mathevali(...)`.
20987                    // Unsigned conversions first try a direct unsigned
20988                    // parse (so a full-range literal like
20989                    // 18446744073709551615 is NOT truncated by the
20990                    // signed-math path), then fall back to math eval
20991                    // (which wraps a negative result into u64).
20992                    let n = crate::ported::utils::zstrtoul_underscore(a.trim())
20993                        .unwrap_or_else(|| parse_int_arg(&a) as u64);
20994                    spec.push('u');
20995                    out.push_str(&format_spec_uint(&spec, n));
20996                    arg_i += 1;
20997                }
20998                Some('x') => {
20999                    if crate::extensions::dash_mode::bash_printf_empty_numeric_error(
21000                        args.get(arg_i),
21001                    ) {
21002                        PRINTF_MATH_ERR.with(|c| c.set(true));
21003                    }
21004                    let a = args.get(arg_i).cloned().unwrap_or_default();
21005                    // c:Src/builtin.c:5511 — `if (!zstrtoul_underscore(
21006                    // curarg, &zulongval)) zulongval = mathevali(...)`.
21007                    // Unsigned conversions first try a direct unsigned
21008                    // parse (so a full-range literal like
21009                    // 18446744073709551615 is NOT truncated by the
21010                    // signed-math path), then fall back to math eval
21011                    // (which wraps a negative result into u64).
21012                    let n = crate::ported::utils::zstrtoul_underscore(a.trim())
21013                        .unwrap_or_else(|| parse_int_arg(&a) as u64);
21014                    spec.push('x');
21015                    out.push_str(&format_spec_radix(&spec, n, 'x'));
21016                    arg_i += 1;
21017                }
21018                Some('X') => {
21019                    if crate::extensions::dash_mode::bash_printf_empty_numeric_error(
21020                        args.get(arg_i),
21021                    ) {
21022                        PRINTF_MATH_ERR.with(|c| c.set(true));
21023                    }
21024                    let a = args.get(arg_i).cloned().unwrap_or_default();
21025                    // c:Src/builtin.c:5511 — `if (!zstrtoul_underscore(
21026                    // curarg, &zulongval)) zulongval = mathevali(...)`.
21027                    // Unsigned conversions first try a direct unsigned
21028                    // parse (so a full-range literal like
21029                    // 18446744073709551615 is NOT truncated by the
21030                    // signed-math path), then fall back to math eval
21031                    // (which wraps a negative result into u64).
21032                    let n = crate::ported::utils::zstrtoul_underscore(a.trim())
21033                        .unwrap_or_else(|| parse_int_arg(&a) as u64);
21034                    spec.push('X');
21035                    out.push_str(&format_spec_radix(&spec, n, 'X'));
21036                    arg_i += 1;
21037                }
21038                Some('o') => {
21039                    if crate::extensions::dash_mode::bash_printf_empty_numeric_error(
21040                        args.get(arg_i),
21041                    ) {
21042                        PRINTF_MATH_ERR.with(|c| c.set(true));
21043                    }
21044                    let a = args.get(arg_i).cloned().unwrap_or_default();
21045                    // c:Src/builtin.c:5511 — `if (!zstrtoul_underscore(
21046                    // curarg, &zulongval)) zulongval = mathevali(...)`.
21047                    // Unsigned conversions first try a direct unsigned
21048                    // parse (so a full-range literal like
21049                    // 18446744073709551615 is NOT truncated by the
21050                    // signed-math path), then fall back to math eval
21051                    // (which wraps a negative result into u64).
21052                    let n = crate::ported::utils::zstrtoul_underscore(a.trim())
21053                        .unwrap_or_else(|| parse_int_arg(&a) as u64);
21054                    spec.push('o');
21055                    out.push_str(&format_spec_radix(&spec, n, 'o'));
21056                    arg_i += 1;
21057                }
21058                // c:Src/builtin.c:5398-5403 — the float `type=2` case list is
21059                // exactly `e E f g G`; `%F` is NOT in it and falls to the
21060                // c:5413 default arm ("%F: invalid directive", rc 1). bash and
21061                // POSIX `sh` DO accept `%F` (verified: `/bin/sh -c "printf
21062                // '%F\n' 1"` → 1.000000), so keep it valid in those modes.
21063                Some(conv @ ('f' | 'F' | 'g' | 'G' | 'e' | 'E'))
21064                    if conv != 'F'
21065                        || crate::dash_mode::bash_mode()
21066                        || crate::dash_mode::posix_faithful() =>
21067                {
21068                    let a = args.get(arg_i).cloned().unwrap_or_default();
21069                    // c:Src/builtin.c:5479-5488 — `doubleval = strtod(curarg,
21070                    // &eptr); if (*eptr) mnumval = matheval(curarg)`. The arg
21071                    // is parsed as a numeric constant first (handles "2.5",
21072                    // "nan", "inf"); if that leaves leftover (a variable name
21073                    // like `f`, or an expression like "1.5+1"), it is
21074                    // evaluated as a math expression. The previous port did
21075                    // only the constant parse, so `printf %g f` / `%f 1.5+1`
21076                    // yielded 0.
21077                    let n: f64 =
21078                        if let Some(rest) = a.strip_prefix('\'').or_else(|| a.strip_prefix('"')) {
21079                            // c:Src/builtin.c:5431-5447 — a leading `'`/`"` makes
21080                            // the value the numeric char code of the next char,
21081                            // for FLOAT conversions too (shared with int):
21082                            // `printf '%.1E' \'B` → 66 → 6.6E+01. The int arm
21083                            // (parse_int_arg) already did this; the float arm
21084                            // skipped it, yielding 0.
21085                            // c:5433-5442 — decode one character of the unmetafied
21086                            // arg; on WEOF use the raw byte (as parse_int_arg).
21087                            {
21088                                let raw = crate::ported::utils::unmetafy_str(rest);
21089                                if raw.is_empty() {
21090                                    0.0
21091                                } else {
21092                                    match crate::ported::utils::mb_metacharlenconv(&raw).1 {
21093                                        Some(c) => c as i64 as f64,
21094                                        None => raw[0] as f64,
21095                                    }
21096                                }
21097                            }
21098                        } else {
21099                            a.parse::<f64>().unwrap_or_else(|_| match matheval(&a) {
21100                                Ok(m) if m.type_ == crate::ported::math::MN_FLOAT => m.d,
21101                                Ok(m) => m.l as f64,
21102                                // c:Src/builtin.c:5488 — a math-eval failure on
21103                                // a FLOAT operand is a SOFT error, same as the
21104                                // integer path: emit the diagnostic, clear the
21105                                // errflag so later args still evaluate, and flag
21106                                // ret=1. The float arm previously swallowed it,
21107                                // so `printf '%g' '%d'` exited 0 where zsh exits
21108                                // 1. (Empty/missing args math-eval to Ok(0), so
21109                                // they do NOT trip this.)
21110                                Err(msg) => {
21111                                    crate::ported::utils::zerr(&msg);
21112                                    crate::ported::utils::errflag.fetch_and(
21113                                        !crate::ported::utils::ERRFLAG_ERROR,
21114                                        std::sync::atomic::Ordering::Relaxed,
21115                                    );
21116                                    PRINTF_MATH_ERR.with(|c| c.set(true));
21117                                    0.0
21118                                }
21119                            })
21120                        };
21121                    // c:Src/builtin.c printf %g/%G uses libc snprintf
21122                    // which strips trailing zeros; %e/%E uses scientific.
21123                    out.push_str(&format_spec_float_conv(&spec, n, conv));
21124                    arg_i += 1;
21125                }
21126                Some('c') => {
21127                    // c:5300-5306 — `%c` outputs the arg's first char with
21128                    // WIDTH/justify applied (`%3c`→"  x", `%-3c`→"x  ") but
21129                    // precision IGNORED (`%.0c`→"x"). Strip precision from the
21130                    // spec, then format as a one-char string. The previous port
21131                    // pushed the char raw, dropping width.
21132                    let a = args.get(arg_i).cloned().unwrap_or_default();
21133                    // c:5300-5305 — `intval = curarg ? *curarg : 0`. An
21134                    // empty or missing arg yields `*curarg == '\0'`, so
21135                    // `%c` emits a NUL byte (not nothing). zsh:
21136                    // `printf "%c" "" | od` → 00.
21137                    // `*curarg` is the first BYTE of the unmetafied arg, not
21138                    // its first character: `printf %c é` writes the lone
21139                    // lead byte 0xc3. Keep it in the port's metafied form
21140                    // (Meta + byte^32 for 0x80..) so the final unmetafy
21141                    // writes that one raw byte; libc's `%*c` then pads by
21142                    // one byte.
21143                    let byte = crate::ported::utils::unmetafy_str(&a)
21144                        .first()
21145                        .copied()
21146                        .unwrap_or(0);
21147                    let mut ch_str = String::new();
21148                    if byte < 0x80 {
21149                        ch_str.push(byte as char);
21150                    } else {
21151                        ch_str.push('\u{83}');
21152                        ch_str.push(char::from(byte ^ 32));
21153                    }
21154                    let (left_align, zero_pad, width, _) = parse_flags_width_prec(&spec);
21155                    let zero_pad = zero_pad && crate::host_libc::printf_zero_flag_pads_strings();
21156                    let pad = width.saturating_sub(1);
21157                    if left_align {
21158                        out.push_str(&ch_str);
21159                        out.push_str(&" ".repeat(pad));
21160                    } else {
21161                        out.push_str(&(if zero_pad { "0" } else { " " }).repeat(pad));
21162                        out.push_str(&ch_str);
21163                    }
21164                    arg_i += 1;
21165                }
21166                // c:builtin.c:5403-5409 %q — shell-quote the arg using
21167                // QT_BACKSLASH_SHOWNULL (backslash-escape form), NOT
21168                // QT_QUOTEDZPUTS (single-quote form).
21169                //
21170                //   c: stringval = quotestring(metafy(curarg, …),
21171                //                              QT_BACKSLASH_SHOWNULL);
21172                //
21173                // Symptom of the previous quotedzputs choice:
21174                //   printf "%q\n" "a b c"
21175                //   zshrs (before): 'a b c'        zsh: a\ b\ c
21176                //
21177                // p10k uses `printf "%q "` for shell-quoting cached
21178                // command lines; the difference makes those caches
21179                // unreadable in zsh-syntax debuggers expecting the
21180                // backslash form.
21181                // !!! DASH-STRICT GATE (no C counterpart) !!! dash's printf
21182                // has no `%q` directive; it errors "invalid directive". Under
21183                // dash_strict, skip this arm so `%q` falls to the `Some(other)`
21184                // arm below, which produces exactly that error like /bin/dash.
21185                Some('q') if !crate::dash_mode::dash_strict() => {
21186                    // c:Src/builtin.c:5387-5391 — `stringval = curarg ?
21187                    // quotestring(...) : &nullstr`. A MISSING argument (curarg
21188                    // == NULL) is the empty nullstr and is NOT quoted, so
21189                    // `printf '%q'` prints nothing — not `''`. A PRESENT arg,
21190                    // even an empty string, IS quoted (→ `''`).
21191                    let quoted = match args.get(arg_i) {
21192                        Some(a) => if crate::dash_mode::ksh93_mode() {
21193                            crate::dash_mode::ksh93_printf_q(a)
21194                        } else if crate::dash_mode::bash_mode()
21195                            && crate::dash_mode::bash_ansic_shouldquote(a)
21196                        {
21197                            // bash: a value with control characters prints as one `$'…'`.
21198                            crate::dash_mode::bash_ansic_quote(a)
21199                        } else {
21200                            crate::ported::utils::quotestring(
21201                                a,
21202                                crate::ported::zsh_h::QT_BACKSLASH_SHOWNULL,
21203                            )
21204                        },
21205                        None => String::new(),
21206                    };
21207                    // c:Src/builtin.c:5405-5407 — `%q` sets `*d = 's'`
21208                    // and runs the quoted value through the normal string
21209                    // output (`print_val`), so width/precision/flags from
21210                    // the spec apply (`printf "%-5q" a` → "a    ").
21211                    spec.push('s');
21212                    out.push_str(&format_spec_str(&spec, &quoted, true));
21213                    arg_i += 1;
21214                }
21215                // c:builtin.c:5332-5336 — `%b` uses GETKEYS_PRINTF_ARG
21216                // (Src/zsh.h:3183 = GETKEY_BACKSLASH_C alone), NOT
21217                // GETKEYS_PRINT. The distinction:
21218                //   GETKEYS_PRINT      = OCTAL_ESC | EMACS | BACKSLASH_C
21219                //     `\NNN` → octal, `\<c>` → drop backslash.
21220                //   GETKEYS_PRINTF_ARG = BACKSLASH_C
21221                //     `\NNN` → literal, `\0NNN` → octal, `\<c>` → keep both.
21222                // Bourne-style `printf '%b' '\141'` keeps `\141` literal
21223                // (only `\0NNN` is octal in the arg interpretation),
21224                // matching zsh. Previous Rust port used GETKEYS_PRINT,
21225                // so `\141` was octal-eval'd to `a` — diverged.
21226                Some('b') => {
21227                    let a = args.get(arg_i).cloned().unwrap_or_default();
21228                    // !!! BASH-MODE (no C counterpart) !!! bash `%b` also reads a
21229                    // bare `\NNN` (up to 3 digits) as octal: `printf %b '\101'` -> A,
21230                    // '\1' -> ^A. Respell each as zsh's `\0NNN` introducer form (which
21231                    // GETKEYS_PRINTF_ARG already decodes) so `\0101`, `\x41` and
21232                    // `\\` keep their getkeystring meaning.
21233                    let a = if crate::dash_mode::bash_mode() {
21234                        const BS: char = '\u{5c}';
21235                        let cs: Vec<char> = a.chars().collect();
21236                        let mut o = String::with_capacity(a.len());
21237                        let mut i = 0;
21238                        while i < cs.len() {
21239                            if cs[i] != BS || i + 1 >= cs.len() {
21240                                o.push(cs[i]);
21241                                i += 1;
21242                            } else if cs[i + 1] == '0' || !cs[i + 1].is_digit(8) {
21243                                // `\0NNN`, `\\`, `\x..`, `\n` ...: pass the pair through.
21244                                o.push(BS);
21245                                o.push(cs[i + 1]);
21246                                i += 2;
21247                            } else {
21248                                let digits: String =
21249                                    cs[i + 1..].iter().take(3).take_while(|c| c.is_digit(8)).collect();
21250                                o.push(BS);
21251                                o.push('0');
21252                                o.push_str(&digits);
21253                                i += 1 + digits.len();
21254                            }
21255                        }
21256                        o
21257                    } else {
21258                        a
21259                    };
21260                    // !!! KSH93-ONLY (no C counterpart) !!! ksh93 `%b` decodes the same
21261                    // narrower escape set as its `print` (dash_mode::ksh93_print_escapes).
21262                    let (s, arg_truncated) = if crate::dash_mode::ksh93_mode() {
21263                        crate::dash_mode::ksh93_print_escapes(&a)
21264                    } else {
21265                        let (s, _) = getkeystring_with(
21266                            &a,
21267                            crate::ported::zsh_h::GETKEYS_PRINTF_ARG as u32,
21268                            None,
21269                        );
21270                        (s, crate::ported::utils::getkey_truncated_take())
21271                    };
21272                    // c:5380-5383 — a `\c` inside the `%b` arg truncates:
21273                    // emit the expansion up to `\c`, then stop the whole
21274                    // printf (no rest-of-format, no reuse). getkeystring_with
21275                    // already cut `s` at the `\c` and set the TLS flag.
21276                    // c:5307-5360 — `%b` shares the `%s` width+precision
21277                    // handling (`%5b`→"   ab", `%3.1b`→"  a"), applied to the
21278                    // escape-expanded string. The previous port pushed the
21279                    // expansion raw, dropping width/precision.
21280                    spec.push('s');
21281                    out.push_str(&format_spec_str(&spec, &s, false));
21282                    arg_i += 1;
21283                    if arg_truncated {
21284                        fmttrunc = true; // c:5382
21285                        break; // c:5383 — truncate the format here (stop this cycle)
21286                    }
21287                }
21288                // c:builtin.c:5420 — `%n` consumes its arg but writes
21289                // nothing. C printf writes the byte-count-so-far to
21290                // the int pointer; zsh has no pointer to write to, so
21291                // it silently drops the directive. Previous Rust port
21292                // fell to the unknown-arm and emitted literal `%n`,
21293                // breaking `printf "%n" x; echo y` (zsh emits `y`,
21294                // zshrs emitted `%ny`).
21295                Some('n') => {
21296                    arg_i += 1;
21297                }
21298                // c:builtin.c:5430-5443 — unknown directive in C is
21299                // a hard error: `zwarnnam(name, "%s: invalid
21300                // directive", start); ...; return 1;`. Bubble the
21301                // bad char up via Err; the caller emits the warning
21302                // and returns rc=1. Partial output already written
21303                // is preserved (matches C — the warning fires AFTER
21304                // earlier output bytes have already been emitted).
21305                Some(other) => {
21306                    // c:5414-5419 — the message echoes `start`: the RAW
21307                    // format text from `%` through the bad conversion char
21308                    // inclusive.
21309                    //   c:5415-5417 — `if (*c) { save = c[1]; c[1] = '\0'; }`
21310                    // i.e. the temporary NUL lands AFTER the bad char, but
21311                    // only when the bad char is itself non-NUL. A conversion
21312                    // char that IS a NUL (`printf $'%\0'`) leaves `start`
21313                    // terminated at the `%` itself, so zsh prints just "%".
21314                    // `start` is a C string either way, so it also stops at
21315                    // any earlier embedded NUL.
21316                    let mut msg = raw.clone();
21317                    if other != '\0' {
21318                        msg.push(other); // c:5416
21319                    }
21320                    if let Some(nul) = msg.find('\0') {
21321                        msg.truncate(nul);
21322                    }
21323                    return Err((out, format!("{}: invalid directive", msg)));
21324                    // c:5419
21325                }
21326                // c:Src/builtin.c:5430-5436 — a bare `%` with nothing
21327                // after it (end of format) is an invalid directive, same
21328                // as an unknown conversion char: emit any preceding output
21329                // then error (rc 1). The previous port pushed a literal
21330                // `%`, so `printf '%'` printed `%` with rc 0.
21331                None => return Err((out, format!("{}: invalid directive", spec))),
21332            }
21333            // c:Src/builtin.c — restore the sequential cursor after a
21334            // positional conversion (see the swap above).
21335            if is_positional {
21336                arg_i = saved_argi;
21337            }
21338        }
21339        // c:Src/builtin.c:5521-5522 — `if (maxarg && (argp - first > maxarg))
21340        //     maxarg = argp - first;`
21341        // C runs this at the tail of every conversion; `argp` only ever
21342        // grows within a cycle and `maxarg` is not read again until the
21343        // cycle ends, so evaluating it once here is equivalent.
21344        if cycle_maxarg > 0 && arg_i.saturating_sub(first_off) > cycle_maxarg {
21345            cycle_maxarg = arg_i - first_off;
21346        }
21347        // c:Src/builtin.c:5525 — `if (maxarg) argp = first + maxarg;`
21348        if cycle_maxarg > 0 {
21349            arg_i = first_off + cycle_maxarg;
21350        }
21351        let _ = prev;
21352        // c:Src/builtin.c:5527 — `} while (*argp && argp != first &&
21353        //     !fmttrunc && !OPT_ISSET(ops,'r'));`. A `\c` in the format or
21354        // in a `%b` arg stops the reuse loop. `argp != first` is C's
21355        // "did this cycle consume anything" guard — note `first` only ever
21356        // advances by `maxarg`, so with no positional specs it stays at
21357        // args[0] and the test is simply "argp moved".
21358        if fmttrunc || arg_i >= args.len() || arg_i == first_off {
21359            break;
21360        }
21361        // c:Src/builtin.c:5158-5162 (top of the next iteration) —
21362        //   `if (maxarg) { first += maxarg; argc -= maxarg; maxarg = 0; }`
21363        first_off += cycle_maxarg;
21364    }
21365    Ok((out, bounds, n_targets))
21366}
21367
21368/// Apply a printf-style `%[-flag][width][.prec]s` spec to a string.
21369/// Mirrors C `printf "%-10s" str` formatting; the Rust `format!` macro
21370/// doesn't accept runtime-parsed specs so we hand-parse.
21371/// !!! WARNING: RUST-ONLY HELPER !!!
21372///
21373/// No upstream C counterpart: this name does not exist anywhere in
21374/// `Src/`. C has no printf-spec helper functions at all: `bin_print`
21375/// (`Src/builtin.c:4587`) walks the format string inline and hands the
21376/// reconstructed spec to libc `fprintf` through the `print_val` macro
21377/// (`Src/builtin.c:4571`). Rust has no runtime format-spec engine, so
21378/// that walk is split into these hand-written pieces.
21379/// Deliberately left uncited so the port audit keeps reporting it.
21380fn format_spec_str(spec: &str, s: &str, honor_zero: bool) -> String {
21381    // zsh's `%c` and `%q` route through `print_val`, which HONORS the `0` flag
21382    // (`printf %04c x` → `000x`, `%04q a` → `000a`), so those callers pass
21383    // honor_zero=true. `%s`/`%b` are handled inline with FORCED space padding
21384    // (Src/builtin.c:5375 `fprintf(fout, "%*c", …, ' ')` → `%04s ab` = "  ab"),
21385    // so they pass honor_zero=false.
21386    let (left_align, zero_pad_flag, width, prec) = parse_flags_width_prec(spec);
21387    let honor_zero = honor_zero && crate::host_libc::printf_zero_flag_pads_strings();
21388    if !honor_zero {
21389        // c:Src/builtin.c:5335-5372 — the inline `%s`/`%b` arm counts the
21390        // argument's characters with `mbrlen` over its raw bytes (an invalid
21391        // or incomplete sequence counts as one character, c:5346-5360; with
21392        // MULTIBYTE off every byte is one, c:5363), truncates at `prec`
21393        // characters (c:5343-5346) and pads by that character count
21394        // (c:5368-5372). Counting the port's metafied `char`s made an
21395        // undecodable byte two columns wide and cut a `%.1s` between the
21396        // `Meta` byte and its payload.
21397        let _ = *crate::ported::utils::MB_LOCALE_READY;
21398        let b = crate::ported::utils::unmetafy_str(s);
21399        let multibyte = crate::ported::options::opt_state_get("multibyte").unwrap_or(true);
21400        let mut mbs: crate::ported::utils::MbStateBuf = crate::ported::utils::MBSTATE_ZERO; // c:5338
21401        let mut lbytes = b.len();
21402        let mut lchars = 0usize;
21403        let mut ptr = 0usize;
21404        while ptr < b.len() {
21405            // c:5341
21406            if Some(lchars) == prec {
21407                lbytes = ptr; // c:5343-5346
21408                break;
21409            }
21410            let chars = if multibyte {
21411                // c:5349 `chars = mbrlen(ptr, lleft, &mbs)`; mbrlen is
21412                // mbrtowc with a NULL destination.
21413                let r = unsafe {
21414                    crate::ported::utils::mbrtowc(
21415                        std::ptr::null_mut(),
21416                        b[ptr..].as_ptr() as *const libc::c_char,
21417                        b.len() - ptr,
21418                        &mut mbs as *mut _ as *mut libc::c_void,
21419                    )
21420                };
21421                if r == 0
21422                    || r == crate::ported::utils::MB_INVALID
21423                    || r == crate::ported::utils::MB_INCOMPLETE
21424                {
21425                    1 // c:5360-5361 `if (chars <= 0) chars = 1;`
21426                } else {
21427                    r
21428                }
21429            } else {
21430                1 // c:5364
21431            };
21432            ptr += chars; // c:5366
21433            lchars += 1;
21434        }
21435        // The printed prefix, back in the port's String form: the
21436        // `mb_metacharlenconv` units of the kept bytes (c:5370 `fwrite(b, 1,
21437        // lbytes, fout)`).
21438        let mut kept = String::with_capacity(lbytes);
21439        let mut i = 0usize;
21440        while i < lbytes {
21441            let (n, _, unit) = crate::ported::utils::mb_metacharlenconv(&b[i..lbytes]);
21442            kept.push_str(&unit);
21443            i += n.max(1);
21444        }
21445        let pad = " ".repeat(width.saturating_sub(lchars)); // c:5368-5372
21446        return if left_align {
21447            format!("{}{}", kept, pad)
21448        } else {
21449            format!("{}{}", pad, kept)
21450        };
21451    }
21452    let truncated: &str = if let Some(p) = prec {
21453        let end: usize = s.chars().take(p).map(|c| c.len_utf8()).sum();
21454        &s[..end.min(s.len())]
21455    } else {
21456        s
21457    };
21458    let pad = width.saturating_sub(truncated.chars().count());
21459    if left_align {
21460        format!("{}{}", truncated, " ".repeat(pad))
21461    } else {
21462        let padch = if honor_zero && zero_pad_flag {
21463            '0'
21464        } else {
21465            ' '
21466        };
21467        format!("{}{}", padch.to_string().repeat(pad), truncated)
21468    }
21469}
21470
21471/// c:Src/builtin.c — printf integer-argument parser. zsh accepts:
21472/// - Decimal (`42`, `-7`) — leading 0 stays decimal, NOT octal.
21473/// - Hex (`0x10`, `0X10`)
21474/// - Single character prefix `'A'` or `"A"` → ASCII code
21475/// - `BASE#NNN` radix literal
21476///
21477/// c:Src/builtin.c:5460-5464 — printf evaluates the `%d`/`%i` operand with
21478/// `mathevali`, which zerr's a "bad math expression" diagnostic AND sets
21479/// errflag on a parse error (`printf %d 12abc` → "operator expected at
21480/// `abc'"). C then clears ERRFLAG_ERROR and sets the builtin's `ret = 1`,
21481/// keeping the value at 0 but continuing to format the remaining args.
21482/// zshrs's `mathevali` returns the message in `Err` instead of emitting, so
21483/// surface it here (matching the `$((…))` path at subst.rs:2562), clear the
21484/// error so following args still evaluate, and record the soft failure in
21485/// `PRINTF_MATH_ERR` for bin_print to turn into exit status 1.
21486thread_local! {
21487    static PRINTF_MATH_ERR: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
21488}
21489
21490/// !!! WARNING: RUST-ONLY HELPER !!!
21491///
21492/// No upstream C counterpart: this name does not exist anywhere in
21493/// `Src/`. C has no printf-spec helper functions at all: `bin_print`
21494/// (`Src/builtin.c:4587`) walks the format string inline and hands the
21495/// reconstructed spec to libc `fprintf` through the `print_val` macro
21496/// (`Src/builtin.c:4571`). Rust has no runtime format-spec engine, so
21497/// that walk is split into these hand-written pieces.
21498/// Deliberately left uncited so the port audit keeps reporting it.
21499fn parse_int_arg(s: &str) -> i64 {
21500    // c:Src/builtin.c bin_print c:5447 — character-constant check
21501    // operates on the RAW arg, not after trim. `*curarg == '\''`
21502    // → take `curarg[1]` as a byte regardless of whitespace.
21503    // Otherwise `printf "%d" "' "` returns 0 instead of 32 because
21504    // the trailing space gets stripped before the leading-quote test.
21505    if let Some(rest) = s.strip_prefix('\'').or_else(|| s.strip_prefix('"')) {
21506        // c:5433-5442 — `mb_metacharlenconv(metafy(curarg+1, …), &cc)`; on
21507        // WEOF, `cc = (unsigned char) curarg[1]`. The arg is metafied, so the
21508        // first char of `rest` is the Meta byte for any high byte: `printf %x
21509        // "'"$'\xf0'` printed 83 where zsh prints f0.
21510        let raw = crate::ported::utils::unmetafy_str(rest);
21511        if raw.is_empty() {
21512            return 0; // c:5442 `(curlen > 1) ? … : 0`
21513        }
21514        return match crate::ported::utils::mb_metacharlenconv(&raw).1 {
21515            Some(c) => c as i64,
21516            None => raw[0] as i64, // c:5441-5442
21517        };
21518    }
21519    // !!! POSIX-FAITHFUL GATE (no C counterpart) !!!
21520    // zsh's printf `%d` math-EVALUATES its operand, so `printf %d A` treats
21521    // `A` as a math variable (→ 0, exit 0) and `printf %d 1+1` → 2. dash /
21522    // POSIX sh instead do `strtoimax` NUMERIC parsing: they use the leading
21523    // parsed value and exit non-zero on any non-numeric junk (`A` → 0 exit 1,
21524    // `1+1` → 1 exit 1, `12x` → 12 exit 1, `  7  ` → 7 exit 1), while an
21525    // empty operand is a clean 0. Under `zshrs --sh`/`--dash`
21526    // (posix_faithful + EMULATE_SH) parse numerically to match the real
21527    // shell exactly. `--... --zsh` and runtime `emulate sh` leave
21528    // posix_faithful() false and keep zsh's math-eval semantics.
21529    if crate::dash_mode::posix_faithful()
21530        && crate::ported::zsh_h::EMULATION(crate::ported::zsh_h::EMULATE_SH)
21531    {
21532        if s.is_empty() {
21533            return 0; // dash: empty operand → 0, exit 0 (no error)
21534        }
21535        // strtoimax: skip leading whitespace, optional sign, base prefix.
21536        let t = s.trim_start_matches([' ', '\t', '\n']);
21537        let (neg, body) = match t.strip_prefix('-') {
21538            Some(r) => (true, r),
21539            None => (false, t.strip_prefix('+').unwrap_or(t)),
21540        };
21541        let (radix, digits) =
21542            if let Some(h) = body.strip_prefix("0x").or_else(|| body.strip_prefix("0X")) {
21543                (16u32, h)
21544            } else if body.starts_with('0') {
21545                (8u32, body) // leading 0 → octal ("0" itself is a clean 0)
21546            } else {
21547                (10u32, body)
21548            };
21549        let ndig = digits.chars().take_while(|c| c.is_digit(radix)).count();
21550        // Digit chars are ASCII, so char count == byte length of the run.
21551        let mut val = i64::from_str_radix(&digits[..ndig], radix).unwrap_or(0);
21552        if neg {
21553            val = -val;
21554        }
21555        // POSIX: any leftover after the digit run — or no digits at all —
21556        // is a diagnostic (exit 1); the leading value is still used.
21557        if ndig == 0 || ndig != digits.chars().count() {
21558            PRINTF_MATH_ERR.with(|c| c.set(true));
21559        }
21560        return val;
21561    }
21562    // c:Src/builtin.c:5460 — `zlongval = mathevali(metafy(curarg, …))`.
21563    // printf evaluates the %d/%i operand as a math expression. matheval
21564    // natively handles decimal / 0x-hex / BASE#NNN / float-truncate and
21565    // overflow-truncation: the math lexer's zstrtol (Src/utils.c:2511)
21566    // emits the "number truncated after N digits" warning, so the
21567    // magnitude 9223372036854775808 truncates to 18 digits exactly as
21568    // zsh, and INT64_MIN (-9223372036854775808) round-trips without the
21569    // manual `-parsed` negate that panicked on i64::MIN.
21570    // c:5460-5464 — evaluate as math; on error emit the diagnostic (C's
21571    // mathevali does this internally), zero the value, and flag ret=1.
21572    match crate::ported::math::mathevali(s.trim()) {
21573        Ok(n) => n,
21574        Err(msg) => {
21575            crate::ported::utils::zerr(&msg);
21576            // c:5463 `errflag &= ~ERRFLAG_ERROR` — clear so the NEXT %d arg
21577            // still evaluates (matheval bails on a pre-set errflag,
21578            // math.rs:569); the soft failure is tracked separately.
21579            crate::ported::utils::errflag.fetch_and(
21580                !crate::ported::utils::ERRFLAG_ERROR,
21581                std::sync::atomic::Ordering::Relaxed,
21582            );
21583            PRINTF_MATH_ERR.with(|c| c.set(true)); // c:5464 ret = 1
21584            0
21585        }
21586    }
21587}
21588
21589/// !!! WARNING: RUST-ONLY HELPER !!!
21590///
21591/// No upstream C counterpart: this name does not exist anywhere in
21592/// `Src/`. C has no printf-spec helper functions at all: `bin_print`
21593/// (`Src/builtin.c:4587`) walks the format string inline and hands the
21594/// reconstructed spec to libc `fprintf` through the `print_val` macro
21595/// (`Src/builtin.c:4571`). Rust has no runtime format-spec engine, so
21596/// that walk is split into these hand-written pieces.
21597/// Deliberately left uncited so the port audit keeps reporting it.
21598fn format_spec_int(spec: &str, n: i64) -> String {
21599    let (left_align, zero_pad_flag, width, prec) = parse_flags_width_prec(spec);
21600    // c:libc printf %d — when precision is given, `0` flag is
21601    // ignored (precision controls the digit padding instead).
21602    let zero_pad = zero_pad_flag && !left_align && prec.is_none();
21603    // c:Src/builtin.c — `+` flag: prefix positive numbers with `+`.
21604    // ` ` flag: prefix positive numbers with a space (mutually
21605    // exclusive with `+` per POSIX; `+` wins when both set).
21606    let plus_flag = spec.contains('+');
21607    let space_flag = spec.contains(' ') && !plus_flag;
21608    let digits = n.unsigned_abs().to_string();
21609    // c:libc printf %d precision = minimum number of digits;
21610    // zero-pad the body up to that count BEFORE the sign + width. Converting
21611    // a zero value with precision 0 yields NO digits (`printf '%.0d' 0` → "").
21612    let digits = if let Some(p) = prec {
21613        if p == 0 && n == 0 {
21614            String::new()
21615        } else if digits.len() < p {
21616            format!("{}{}", "0".repeat(p - digits.len()), digits)
21617        } else {
21618            digits
21619        }
21620    } else {
21621        digits
21622    };
21623    // c:libc printf `'` (thousands-grouping) flag — C hands `'` to the
21624    // system printf; the Rust port groups the digit run itself per the
21625    // active locale's `thousands_sep` (localeconv, set from the env at
21626    // init.rs:1709). Empty separator (C/POSIX locale) → no grouping,
21627    // matching `printf "%'d" 1234567` = `1234567` under LC_ALL=C.
21628    // Grouping is suppressed when an explicit precision is given: glibc
21629    // does NOT group the precision zero-fill (`printf "%'.8d" 42` =
21630    // `00000042`, not `00,000,042`), so gate on `prec.is_none()`. This
21631    // matches glibc for every precision case where the value fits within
21632    // the precision digit count (the normal use of `%'.Nd`); the rare
21633    // degenerate combo of `'` + a precision SHORTER than a large value's
21634    // own digit count (`%'.8d` of 1234567 → glibc `1,234,567`) still
21635    // groups in glibc but is left ungrouped here — a documented edge.
21636    // Inlined rather than shared with format_spec_uint because the
21637    // port-purity gate forbids new fns under src/ported/.
21638    let digits = if spec.contains('\'') && prec.is_none() {
21639        let sep = unsafe {
21640            let lc = libc::localeconv();
21641            if lc.is_null() || (*lc).thousands_sep.is_null() {
21642                String::new()
21643            } else {
21644                std::ffi::CStr::from_ptr((*lc).thousands_sep)
21645                    .to_string_lossy()
21646                    .into_owned()
21647            }
21648        };
21649        if sep.is_empty() {
21650            digits
21651        } else {
21652            let chars: Vec<char> = digits.chars().collect();
21653            let len = chars.len();
21654            let mut grouped = String::with_capacity(len + len / 3 * sep.len());
21655            for (i, ch) in chars.iter().enumerate() {
21656                if i > 0 && (len - i) % 3 == 0 {
21657                    grouped.push_str(&sep);
21658                }
21659                grouped.push(*ch);
21660            }
21661            grouped
21662        }
21663    } else {
21664        digits
21665    };
21666    let body = if n < 0 {
21667        format!("-{}", digits)
21668    } else if plus_flag {
21669        format!("+{}", digits)
21670    } else if space_flag {
21671        format!(" {}", digits)
21672    } else {
21673        digits
21674    };
21675    let pad = width.saturating_sub(body.chars().count());
21676    if pad == 0 {
21677        body
21678    } else if left_align {
21679        format!("{}{}", body, " ".repeat(pad))
21680    } else if zero_pad {
21681        // Zero-pad: sign/prefix char (`-`, `+`, ` `) stays at the
21682        // left, zeros pad between it and the digits.
21683        if let Some(rest) = body
21684            .strip_prefix('-')
21685            .or_else(|| body.strip_prefix('+'))
21686            .or_else(|| body.strip_prefix(' '))
21687        {
21688            let sign = body.chars().next().unwrap();
21689            format!("{}{}{}", sign, "0".repeat(pad), rest)
21690        } else {
21691            format!("{}{}", "0".repeat(pad), body)
21692        }
21693    } else {
21694        format!("{}{}", " ".repeat(pad), body)
21695    }
21696}
21697
21698/// printf %x / %X / %o with full flag support: `#` prefix, zero pad,
21699/// width, left-align. Matches libc printf semantics.
21700/// !!! WARNING: RUST-ONLY HELPER !!!
21701///
21702/// No upstream C counterpart: this name does not exist anywhere in
21703/// `Src/`. C has no printf-spec helper functions at all: `bin_print`
21704/// (`Src/builtin.c:4587`) walks the format string inline and hands the
21705/// reconstructed spec to libc `fprintf` through the `print_val` macro
21706/// (`Src/builtin.c:4571`). Rust has no runtime format-spec engine, so
21707/// that walk is split into these hand-written pieces.
21708/// Deliberately left uncited so the port audit keeps reporting it.
21709fn format_spec_radix(spec: &str, n: u64, conv: char) -> String {
21710    let (left_align, zero_pad_flag, width, prec) = parse_flags_width_prec(spec);
21711    // c:libc printf — when a precision is given the `0` flag is ignored;
21712    // precision controls the digit zero-fill instead.
21713    let zero_pad = zero_pad_flag && !left_align && prec.is_none();
21714    let hash_flag = spec.contains('#');
21715    let body = match conv {
21716        'x' => format!("{:x}", n),
21717        'X' => format!("{:X}", n),
21718        'o' => format!("{:o}", n),
21719        _ => n.to_string(),
21720    };
21721    // c:libc printf %o/%u/%x/%X — precision is the MINIMUM number of digits;
21722    // zero-pad the body up to it BEFORE the `#` prefix and width. Converting
21723    // a zero value with precision 0 yields NO digits.
21724    let body = if let Some(p) = prec {
21725        let len = body.chars().count();
21726        if p == 0 && n == 0 {
21727            String::new()
21728        } else if len < p {
21729            format!("{}{}", "0".repeat(p - len), body)
21730        } else {
21731            body
21732        }
21733    } else {
21734        body
21735    };
21736    // c:Src/builtin.c — `#` flag: prefix with `0x`/`0X` for hex (only
21737    // when value non-zero), `0` for octal (always, even zero, which
21738    // libc handles by emitting "0" anyway).
21739    let body = if hash_flag {
21740        match conv {
21741            'x' if n != 0 => format!("0x{}", body),
21742            'X' if n != 0 => format!("0X{}", body),
21743            'o' if !body.starts_with('0') => format!("0{}", body),
21744            _ => body,
21745        }
21746    } else {
21747        body
21748    };
21749    let pad = width.saturating_sub(body.chars().count());
21750    if pad == 0 {
21751        body
21752    } else if left_align {
21753        format!("{}{}", body, " ".repeat(pad))
21754    } else if zero_pad {
21755        // For `%#04x` with value 15: body = "0xf" (3 chars), width=4,
21756        // pad=1. Zero-pad after the `0x` prefix → "0x0f". Match libc.
21757        if let Some(rest) = body.strip_prefix("0x").or_else(|| body.strip_prefix("0X")) {
21758            let prefix = &body[..2];
21759            format!("{}{}{}", prefix, "0".repeat(pad), rest)
21760        } else {
21761            format!("{}{}", "0".repeat(pad), body)
21762        }
21763    } else {
21764        format!("{}{}", " ".repeat(pad), body)
21765    }
21766}
21767
21768/// !!! WARNING: RUST-ONLY HELPER !!!
21769///
21770/// No upstream C counterpart: this name does not exist anywhere in
21771/// `Src/`. C has no printf-spec helper functions at all: `bin_print`
21772/// (`Src/builtin.c:4587`) walks the format string inline and hands the
21773/// reconstructed spec to libc `fprintf` through the `print_val` macro
21774/// (`Src/builtin.c:4571`). Rust has no runtime format-spec engine, so
21775/// that walk is split into these hand-written pieces.
21776/// Deliberately left uncited so the port audit keeps reporting it.
21777fn format_spec_uint(spec: &str, n: u64) -> String {
21778    // c:Src/builtin.c — `%u` is rendered via libc `%lu`. Unsigned
21779    // conversions print NO sign: the `+` and ` ` flags are ignored
21780    // (they apply only to signed conversions), and a negative math
21781    // result has already wrapped into the u64 by the caller's cast
21782    // (`-42` → 18446744073709551574). The previous port delegated to
21783    // format_spec_int(n as i64), which reinterpreted the wrapped u64
21784    // back as a negative i64 and printed `-42`, and honored `+`/` `.
21785    let (left_align, zero_pad_flag, width, prec) = parse_flags_width_prec(spec);
21786    // libc: with an explicit precision the `0` flag is ignored.
21787    let zero_pad = zero_pad_flag && !left_align && prec.is_none();
21788    let digits = n.to_string();
21789    // Precision = minimum number of digits (zero-fill the body). A zero value
21790    // with precision 0 yields NO digits (c:libc printf).
21791    let body = match prec {
21792        Some(0) if n == 0 => String::new(),
21793        Some(p) if digits.len() < p => format!("{}{}", "0".repeat(p - digits.len()), digits),
21794        _ => digits,
21795    };
21796    // c:libc printf `'` thousands-grouping flag (see format_spec_int for
21797    // the rationale, the `prec.is_none()` gate, and the port-purity-gate
21798    // note on the inlined copy). Unsigned bodies carry no sign, so the
21799    // whole body groups.
21800    let body = if spec.contains('\'') && prec.is_none() {
21801        let sep = unsafe {
21802            let lc = libc::localeconv();
21803            if lc.is_null() || (*lc).thousands_sep.is_null() {
21804                String::new()
21805            } else {
21806                std::ffi::CStr::from_ptr((*lc).thousands_sep)
21807                    .to_string_lossy()
21808                    .into_owned()
21809            }
21810        };
21811        if sep.is_empty() {
21812            body
21813        } else {
21814            let chars: Vec<char> = body.chars().collect();
21815            let len = chars.len();
21816            let mut grouped = String::with_capacity(len + len / 3 * sep.len());
21817            for (i, ch) in chars.iter().enumerate() {
21818                if i > 0 && (len - i) % 3 == 0 {
21819                    grouped.push_str(&sep);
21820                }
21821                grouped.push(*ch);
21822            }
21823            grouped
21824        }
21825    } else {
21826        body
21827    };
21828    let pad = width.saturating_sub(body.chars().count());
21829    if pad == 0 {
21830        body
21831    } else if left_align {
21832        format!("{}{}", body, " ".repeat(pad))
21833    } else if zero_pad {
21834        format!("{}{}", "0".repeat(pad), body)
21835    } else {
21836        format!("{}{}", " ".repeat(pad), body)
21837    }
21838}
21839
21840/// !!! WARNING: RUST-ONLY HELPER !!!
21841///
21842/// No upstream C counterpart: this name does not exist anywhere in
21843/// `Src/`. C has no printf-spec helper functions at all: `bin_print`
21844/// (`Src/builtin.c:4587`) walks the format string inline and hands the
21845/// reconstructed spec to libc `fprintf` through the `print_val` macro
21846/// (`Src/builtin.c:4571`). Rust has no runtime format-spec engine, so
21847/// that walk is split into these hand-written pieces.
21848/// Deliberately left uncited so the port audit keeps reporting it.
21849fn format_spec_float(spec: &str, n: f64) -> String {
21850    let (left_align, zero_pad, width, prec) = parse_flags_width_prec(spec);
21851    let p = prec.unwrap_or(6);
21852    let body = format!("{:.*}", p, n);
21853    let pad = width.saturating_sub(body.chars().count());
21854    if pad == 0 {
21855        body
21856    } else if left_align {
21857        format!("{}{}", body, " ".repeat(pad))
21858    } else if zero_pad {
21859        if let Some(rest) = body.strip_prefix('-') {
21860            format!("-{}{}", "0".repeat(pad), rest)
21861        } else {
21862            format!("{}{}", "0".repeat(pad), body)
21863        }
21864    } else {
21865        format!("{}{}", " ".repeat(pad), body)
21866    }
21867}
21868
21869/// printf %g / %G / %e / %E / %f / %F dispatch. Mirrors C printf
21870/// semantics (Src/builtin.c — libc snprintf): %g picks the shorter
21871/// of %e/%f and strips trailing zeros; %e/%E uses scientific notation;
21872/// %f/%F is decimal-fraction (no scientific). Default precision is 6.
21873// pub(crate) so zcalc can format through the SAME printf conversion it calls in
21874// zsh (zcalc:107 `printf "$_forms[_outform]\n" …`, _forms[1] = '%2$g'). The
21875// float-parameter formatter `convfloat` is NOT interchangeable: it re-appends a
21876// trailing `.` when %g yields no dot (c:params.c:5748-5749), which printf never
21877// does — `sqrt(2)*sqrt(2)` must print `2`, not `2.`.
21878/// !!! WARNING: RUST-ONLY HELPER !!!
21879///
21880/// No upstream C counterpart: this name does not exist anywhere in
21881/// `Src/`. C has no printf-spec helper functions at all: `bin_print`
21882/// (`Src/builtin.c:4587`) walks the format string inline and hands the
21883/// reconstructed spec to libc `fprintf` through the `print_val` macro
21884/// (`Src/builtin.c:4571`). Rust has no runtime format-spec engine, so
21885/// that walk is split into these hand-written pieces.
21886/// Deliberately left uncited so the port audit keeps reporting it.
21887pub(crate) fn format_spec_float_conv(spec: &str, n: f64, conv: char) -> String {
21888    // c:Src/builtin.c:5495-5499 — `/* force consistent form for Inf/NaN
21889    // output */ if (isnan(doubleval)) fputs("nan"); else if (isinf(doubleval))
21890    // fputs(doubleval < 0.0 ? "-inf" : "inf"); else print_val(...)`. zsh emits
21891    // the BARE lowercase string with no width/precision/flags/padding,
21892    // bypassing the normal formatting (platform-independent). The previous
21893    // Rust path ran nan/inf through `format!`, producing "NaN" and garbage
21894    // scientific ("NaNe+2147483647").
21895    if n.is_nan() {
21896        return "nan".to_string(); // c:5496-5497
21897    }
21898    if n.is_infinite() {
21899        return if n < 0.0 { "-inf" } else { "inf" }.to_string(); // c:5498-5499
21900    }
21901    let (left_align, zero_pad, width, prec) = parse_flags_width_prec(spec);
21902    let n = crate::dash_mode::ksh93_round_ties_away(n, conv, prec);
21903    let body = match conv {
21904        'f' | 'F' => {
21905            let p = prec.unwrap_or(6);
21906            format!("{:.*}", p, n)
21907        }
21908        'e' | 'E' => {
21909            // Use Rust's `{:.*e}` for the mantissa+exponent — it is EXACT
21910            // (correctly-rounded, like libc snprintf). The previous port
21911            // computed `mantissa = n / 10^exp` which lost precision: `37/10`
21912            // is not exactly 3.7, so `%.27e 37` printed the binary error
21913            // `3.70000…0177635683940e+01` instead of zsh's `3.7000…000e+01`.
21914            // Rust emits `<mant>e<exp>` with lowercase `e` and no exponent
21915            // padding/sign; reformat the exponent to libc's `e[+-]NN` (≥2 digits).
21916            let p = prec.unwrap_or(6);
21917            let raw = format!("{:.*e}", p, n);
21918            let (mant, exp_s) = raw.split_once('e').unwrap_or((raw.as_str(), "0"));
21919            let exp: i32 = exp_s.parse().unwrap_or(0);
21920            let e_char = if conv == 'E' { 'E' } else { 'e' };
21921            let exp_sign = if exp >= 0 { '+' } else { '-' };
21922            format!("{}{}{}{:02}", mant, e_char, exp_sign, exp.abs())
21923        }
21924        'g' | 'G' => {
21925            // c:libc printf %g: precision is # significant digits
21926            // (default 6). Use %e if exp < -4 OR exp >= precision,
21927            // else %f. Trailing zeros stripped unless `#` flag set
21928            // (zshrs doesn't track # — skip stripping suppression).
21929            let p_sig: i32 = prec.unwrap_or(6).max(1) as i32;
21930            let exp = if n == 0.0 {
21931                0i32
21932            } else {
21933                n.abs().log10().floor() as i32
21934            };
21935            let use_e = exp < -4 || exp >= p_sig;
21936            let body = if use_e {
21937                // Exact mantissa+exponent via Rust's `{:.*e}` (see the `%e`
21938                // arm) — `n / 10^exp` lost precision.
21939                let dec = (p_sig - 1).max(0) as usize;
21940                let raw = format!("{:.*e}", dec, n);
21941                let (m, exp_s) = raw.split_once('e').unwrap_or((raw.as_str(), "0"));
21942                let ex: i32 = exp_s.parse().unwrap_or(0);
21943                let e_char = if conv == 'G' { 'E' } else { 'e' };
21944                let exp_sign = if ex >= 0 { '+' } else { '-' };
21945                format!("{}{}{}{:02}", m, e_char, exp_sign, ex.abs())
21946            } else {
21947                // p_sig - 1 - exp digits after decimal point
21948                let dec = (p_sig - 1 - exp).max(0) as usize;
21949                format!("{:.*}", dec, n)
21950            };
21951            // Strip trailing zeros from the fractional part (but keep
21952            // at least one digit after `.` if `.` is present).
21953            // Only strip if no `#` flag was set in spec.
21954            // c:libc snprintf %g — trailing-zero strip done inline; no
21955            // separate helper in C source.
21956            if !spec.contains('#') {
21957                let stripped = if let Some(e_pos) = body.find(|c| c == 'e' || c == 'E') {
21958                    let (mantissa, exp) = body.split_at(e_pos);
21959                    let m = if mantissa.contains('.') {
21960                        mantissa
21961                            .trim_end_matches('0')
21962                            .trim_end_matches('.')
21963                            .to_string()
21964                    } else {
21965                        mantissa.to_string()
21966                    };
21967                    format!("{}{}", m, exp)
21968                } else if body.contains('.') {
21969                    body.trim_end_matches('0').trim_end_matches('.').to_string()
21970                } else {
21971                    body
21972                };
21973                stripped
21974            } else {
21975                body
21976            }
21977        }
21978        _ => format!("{}", n),
21979    };
21980    // c:libc printf `#` flag on a float conversion forces a decimal point to
21981    // appear even when precision 0 leaves no fractional digits: `%#.0f` 5 →
21982    // "5.", `%#.0e` 5 → "5.e+00", `%#.0g` 5 → "5.". (The %g/%G arm above also
21983    // uses `#` to suppress trailing-zero stripping.)
21984    let body = if spec.contains('#') && !body.contains('.') {
21985        match body.find(|c| c == 'e' || c == 'E') {
21986            Some(ep) => format!("{}.{}", &body[..ep], &body[ep..]),
21987            None => format!("{body}."),
21988        }
21989    } else {
21990        body
21991    };
21992    // c:libc printf flags `+` and ` ` — a non-negative float value gets
21993    // a forced leading `+` (with `+`) or a blank (with ` `); `+`
21994    // overrides ` `. parse_flags_width_prec only extracts `-`/`0`, so
21995    // these sign flags were dropped: `printf "%+.2f" 1.5` rendered
21996    // "1.50" instead of "+1.50". Negatives keep their own `-`. The
21997    // zero-pad arm below already expects an optional leading `+`.
21998    let body = if body.starts_with('-') {
21999        body
22000    } else if spec.contains('+') {
22001        format!("+{}", body)
22002    } else if spec.contains(' ') {
22003        format!(" {}", body)
22004    } else {
22005        body
22006    };
22007    let pad = width.saturating_sub(body.chars().count());
22008    if pad == 0 {
22009        body
22010    } else if left_align {
22011        // c:libc printf — `-` overrides `0`; pad on the right with spaces.
22012        format!("{}{}", body, " ".repeat(pad))
22013    } else if zero_pad {
22014        // c:libc printf — `0` flag without `-`: zero-pad between the
22015        // optional sign and digits. `printf "%05.2f" 1.5` → "01.50",
22016        // `printf "%05.2f" -1.5` → "-1.50" (no extra pad since sign +
22017        // body already fills width). Previously the helper discarded
22018        // zero_pad via parse_width_prec and always space-padded.
22019        if let Some(rest) = body.strip_prefix('-') {
22020            format!("-{}{}", "0".repeat(pad), rest)
22021        } else if let Some(rest) = body.strip_prefix('+') {
22022            format!("+{}{}", "0".repeat(pad), rest)
22023        } else if let Some(rest) = body.strip_prefix(' ') {
22024            // ` ` (space) flag + `0`: blank stays leftmost, zeros pad
22025            // between it and the digits. `printf "% 08.2f" 1.5` → " 0001.50".
22026            format!(" {}{}", "0".repeat(pad), rest)
22027        } else {
22028            format!("{}{}", "0".repeat(pad), body)
22029        }
22030    } else {
22031        format!("{}{}", " ".repeat(pad), body)
22032    }
22033}
22034
22035/// !!! WARNING: RUST-ONLY HELPER !!!
22036///
22037/// No upstream C counterpart: this name does not exist anywhere in
22038/// `Src/`. C has no printf-spec helper functions at all: `bin_print`
22039/// (`Src/builtin.c:4587`) walks the format string inline and hands the
22040/// reconstructed spec to libc `fprintf` through the `print_val` macro
22041/// (`Src/builtin.c:4571`). Rust has no runtime format-spec engine, so
22042/// that walk is split into these hand-written pieces.
22043/// Deliberately left uncited so the port audit keeps reporting it.
22044fn parse_width_prec(spec: &str) -> (bool, usize, Option<usize>) {
22045    let (left_align, _zero_pad, width, prec) = parse_flags_width_prec(spec);
22046    (left_align, width, prec)
22047}
22048
22049/// Parse printf-format flags `-`, `+`, ` `, `#`, `0`, width digits,
22050/// optional `.precision`. Returns (left_align, zero_pad, width, prec).
22051/// Mirrors C `parsefmt()` in Src/builtin.c — the `0` flag is only a
22052/// flag when it appears BEFORE any width digit; a `0` inside the
22053/// width number (e.g. `%10d`) is just part of the width.
22054/// !!! WARNING: RUST-ONLY HELPER !!!
22055///
22056/// No upstream C counterpart: this name does not exist anywhere in
22057/// `Src/`. C has no printf-spec helper functions at all: `bin_print`
22058/// (`Src/builtin.c:4587`) walks the format string inline and hands the
22059/// reconstructed spec to libc `fprintf` through the `print_val` macro
22060/// (`Src/builtin.c:4571`). Rust has no runtime format-spec engine, so
22061/// that walk is split into these hand-written pieces.
22062/// Deliberately left uncited so the port audit keeps reporting it.
22063fn parse_flags_width_prec(spec: &str) -> (bool, bool, usize, Option<usize>) {
22064    let s = spec.trim_start_matches('%');
22065    let mut i = 0;
22066    let bytes = s.as_bytes();
22067    let mut left_align = false;
22068    let mut zero_pad = false;
22069    // `'` (thousands-grouping) is a flag char too — skip it here so a
22070    // following width/precision (`%'12d`, `%'.8d`) is still parsed. The
22071    // grouping itself is applied by the caller (format_spec_int/uint).
22072    while i < bytes.len() && matches!(bytes[i], b'-' | b'+' | b' ' | b'#' | b'0' | b'\'') {
22073        match bytes[i] {
22074            b'-' => left_align = true,
22075            b'0' => zero_pad = true,
22076            _ => {}
22077        }
22078        i += 1;
22079    }
22080    let width_start = i;
22081    while i < bytes.len() && bytes[i].is_ascii_digit() {
22082        i += 1;
22083    }
22084    let width: usize = s[width_start..i].parse().unwrap_or(0);
22085    let mut prec: Option<usize> = None;
22086    if i < bytes.len() && bytes[i] == b'.' {
22087        i += 1;
22088        let p_start = i;
22089        while i < bytes.len() && bytes[i].is_ascii_digit() {
22090            i += 1;
22091        }
22092        prec = Some(s[p_start..i].parse().unwrap_or(0));
22093    }
22094    (left_align, zero_pad, width, prec)
22095}
22096
22097// `findcmd` (Src/exec.c:897) — moved to its canonical home at
22098// `crate::ported::exec::findcmd` per PORT.md Rule C (the C source
22099// lives in exec.c, so the Rust port belongs in exec.rs). Call sites
22100// import from the new path.
22101pub use crate::ported::exec::findcmd;
22102use crate::ported::signals_h::run_queued_signals;
22103
22104/// Port of `getsigidx()` from `Src/jobs.c:3047`.
22105/// C decl: `getsigidx(const char *s)`
22106/// Local wrapper that delegates to the canonical
22107/// `crate::ported::jobs::getsigidx` (matching `Src/jobs.c` location).
22108/// Returns -1 for unknown so existing builtin.rs call sites (which
22109/// use the i32 sentinel) don't need to change.
22110fn getsigidx(name: &str) -> i32 {
22111    crate::ported::jobs::getsigidx(name).unwrap_or(-1)
22112}
22113
22114/// Port of `int pat_enables(const char *cmd, char **patp, int enable)`
22115/// from `Src/pattern.c:4171`. Local builtin.rs shim that delegates to
22116/// the canonical pattern.rs port. Static-link path: the actual
22117/// zpc_strings/zpc_disables manipulation lives in
22118/// `pat_enables`.
22119/// Port of `pat_enables()` from `Src/pattern.c:4171`. — C decl `pat_enables(const char *cmd, char **patp, int enable)`
22120fn pat_enables(name: &str, argv: &[String], on: bool) -> i32 {
22121    // c:4171
22122    let patp: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
22123    crate::ported::pattern::pat_enables(name, &patp, on)
22124}
22125
22126// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
22127// ─── RUST-ONLY ACCESSORS ───
22128//
22129// Singleton accessor ported for `OnceLock<Mutex<T>>` / `OnceLock<
22130// RwLock<T>>` globals declared above. C zsh uses direct global
22131// access; Rust needs these wrappers because `OnceLock::get_or_init`
22132// is the only way to lazily construct shared state. These ported sit
22133// here so the body of this file reads in C source order without
22134// the accessor wrappers interleaved between real port ported.
22135// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
22136
22137// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
22138// ─── RUST-ONLY ACCESSORS ───
22139//
22140// Singleton accessor ported for `OnceLock<Mutex<T>>` / `OnceLock<
22141// RwLock<T>>` globals declared above. C zsh uses direct global
22142// access; Rust needs these wrappers because `OnceLock::get_or_init`
22143// is the only way to lazily construct shared state. These ported sit
22144// here so the body of this file reads in C source order without
22145// the accessor wrappers interleaved between real port ported.
22146// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
22147/// `traps_table` — see implementation.
22148/// !!! WARNING: RUST-ONLY HELPER !!!
22149///
22150/// No upstream C counterpart. C keeps trap state in the
22151/// `int *sigtrapped` (`Src/signals.c:39`) and `Eprog *siglists`
22152/// (`Src/signals.c:53`) arrays and touches them directly; this is the
22153/// `OnceLock` accessor for the builtin.rs-side `TRAPS_INNER` mirror
22154/// that `bin_trap` reads/writes.
22155/// Deliberately left uncited so the port audit keeps reporting it.
22156pub fn traps_table() -> &'static Mutex<HashMap<String, String>> {
22157    TRAPS_INNER.get_or_init(|| Mutex::new(HashMap::new()))
22158}
22159
22160#[cfg(test)]
22161mod tests {
22162    use super::*;
22163
22164    /// `findcmd` with an existing ABSOLUTE path bypasses the PATH
22165    /// walk entirely (c:916 `if (arg0 == s || ...)` branch — `s` is
22166    /// the result of `strchr(arg0, '/')`; when the first char IS
22167    /// the slash, `arg0 == s` is true). The caller's `$PATH` is
22168    /// irrelevant for an absolute path. A regression that always
22169    /// walked $PATH would fail to find `/bin/sh` when $PATH was
22170    /// empty, breaking command-name resolution for cron/init contexts
22171    /// that explicitly pass absolute paths.
22172    #[test]
22173    fn findcmd_absolute_path_skips_path_walk() {
22174        let _g = crate::test_util::global_state_lock();
22175        // `PATH` is an exported special: writing it also rewrites the
22176        // PROCESS environment, and `unsetparam` does not put the old
22177        // value back. Leaving it empty broke every later test that
22178        // spawns a helper by name (`Command::new("zsh")` → ENOENT), so
22179        // snapshot and restore it here.
22180        let saved_path = getsparam("PATH");
22181        // Empty $PATH to guarantee the walk would miss.
22182        setsparam("PATH", "");
22183        let resolved = findcmd("/bin/sh", 0, 0);
22184        match saved_path {
22185            Some(p) => {
22186                setsparam("PATH", &p);
22187            }
22188            None => {
22189                unsetparam("PATH");
22190            }
22191        }
22192        assert_eq!(
22193            resolved.as_deref(),
22194            Some("/bin/sh"),
22195            "c:914-919 — absolute path that exists must resolve to itself \
22196             regardless of $PATH"
22197        );
22198    }
22199
22200    /// `findcmd` with `default_path != 0` MUST search the hardcoded
22201    /// `DEFAULT_PATH` (`/usr/bin:/bin:/usr/sbin:/sbin`), NOT the
22202    /// caller's `$PATH`. C body c:903-908. This is the `command -p`
22203    /// security contract: scripts that need to invoke a sanitized
22204    /// `awk`/`sed`/`grep` regardless of user-poisoned $PATH (e.g.
22205    /// `command -p sh -c '...'` in a setuid wrapper) rely on the
22206    /// fallback path. A regression that ignored `default_path` would
22207    /// re-introduce the very PATH-injection vulnerability that
22208    /// `command -p` exists to prevent.
22209    ///
22210    /// Pin: with $PATH set to a non-existent directory, `findcmd`
22211    /// for a binary that ONLY lives in /bin or /usr/bin (e.g. `sh`)
22212    /// must still resolve when `default_path=1`.
22213    #[test]
22214    fn findcmd_default_path_searches_hardcoded_dirs() {
22215        let _g = crate::test_util::global_state_lock();
22216        // Snapshot/restore — see findcmd_absolute_path_skips_path_walk.
22217        let saved_path = getsparam("PATH");
22218        // Poison $PATH so the normal path-walk would miss.
22219        setsparam("PATH", "/nonexistent/zshrs-test-poison");
22220        // `sh` exists in /bin on every POSIX system.
22221        let resolved = findcmd("sh", 0, 1);
22222        match saved_path {
22223            Some(p) => {
22224                setsparam("PATH", &p);
22225            }
22226            None => {
22227                unsetparam("PATH");
22228            }
22229        }
22230        assert!(
22231            resolved.is_some(),
22232            "c:903-908 — default_path must search DEFAULT_PATH regardless of $PATH"
22233        );
22234        let p = resolved.unwrap();
22235        assert!(
22236            DEFAULT_PATH.split(':').any(|d| p.starts_with(d)),
22237            "resolved path must be under one of DEFAULT_PATH's dirs; got {:?}",
22238            p
22239        );
22240    }
22241
22242    /// c:7399 — `trap - <undefined>` MUST report failure (non-zero
22243    /// exit) so scripts can detect the bad signal name. The previous
22244    /// Rust port returned 0 unconditionally from the clear path,
22245    /// silently masking errors. C returns `*argv != NULL` — non-zero
22246    /// when the loop broke on an undefined signal.
22247    #[test]
22248    fn bin_trap_clear_undefined_signal_returns_nonzero() {
22249        let _g = crate::test_util::global_state_lock();
22250        let empty = options {
22251            ind: [0u8; MAX_OPS],
22252            args: Vec::new(),
22253            argscount: 0,
22254            argsalloc: 0,
22255        };
22256        // `trap - BOGUS_NEVER_A_SIGNAL` → must return 1.
22257        let r = bin_trap(
22258            "trap",
22259            &["-".into(), "BOGUS_NEVER_A_SIGNAL".into()],
22260            &empty,
22261            0,
22262        );
22263        assert_ne!(
22264            r, 0,
22265            "trap - <undefined> must report error per c:7399 (got {})",
22266            r
22267        );
22268    }
22269
22270    /// c:7405-7409 — the trap body is parsed when the trap is INSTALLED
22271    /// (`parse_string(arg, 1)`), so an unparseable body fails right here
22272    /// with `couldn't parse trap command` and rc=1, and nothing is
22273    /// installed. zshrs previously stored the body text unexamined and
22274    /// returned 0, deferring the failure to signal delivery: `trap 'for'
22275    /// EXIT` reported success and then emitted a parse error at exit.
22276    ///
22277    /// The bodies here are the ones the oracle rejects. `while` is
22278    /// deliberately not among them — `zsh -fc 'while'` treats it as an
22279    /// incomplete construct and reads the remainder from stdin rather
22280    /// than erroring, so it is not an install-time parse failure.
22281    #[test]
22282    fn bin_trap_unparseable_body_rejected_at_install() {
22283        let _g = crate::test_util::global_state_lock();
22284        let empty = options {
22285            ind: [0u8; MAX_OPS],
22286            args: Vec::new(),
22287            argscount: 0,
22288            argsalloc: 0,
22289        };
22290        for body in [
22291            "for",
22292            "((",
22293            "fi",
22294            "done",
22295            "case",
22296            "if true",
22297            "print ok; for",
22298        ] {
22299            let r = bin_trap("trap", &[body.into(), "USR1".into()], &empty, 0);
22300            assert_eq!(
22301                r, 1,
22302                "trap '{body}' USR1 must fail at install per c:7407 (got {r})"
22303            );
22304            // The rejected body must not have been installed (c:7408
22305            // returns before the install loop at c:7421).
22306            let installed = traps_table()
22307                .lock()
22308                .ok()
22309                .map(|t| t.contains_key("USR1"))
22310                .unwrap_or(false);
22311            assert!(
22312                !installed,
22313                "trap '{body}' USR1 was rejected but still installed a handler"
22314            );
22315        }
22316        // A parseable body still installs — the gate must not reject
22317        // everything.
22318        let r = bin_trap("trap", &["print ok".into(), "USR1".into()], &empty, 0);
22319        assert_eq!(r, 0, "parseable body must install (got {r})");
22320        if let Ok(mut t) = traps_table().lock() {
22321            assert!(t.contains_key("USR1"), "parseable body must be installed");
22322            t.remove("USR1");
22323        }
22324    }
22325
22326    /// c:7371 — `getpermtext(siglists[sig], NULL, 0)`. C keeps the trap
22327    /// body as a compiled Eprog and renders it back to source for the
22328    /// listing, so `trap` prints CANONICAL text rather than the string
22329    /// that was typed: separators become newlines. zshrs stores the body
22330    /// as raw text and previously echoed it verbatim, which is correct
22331    /// only for a single command — `trap 'print a; print b' EXIT` listed
22332    /// as `'print a; print b'` where zsh lists `$'print a\nprint b'`.
22333    ///
22334    /// Pins the deparse itself (parse_string → getpermtext) rather than
22335    /// bin_trap's stdout, so the assertion doesn't depend on capturing
22336    /// the builtin's print! output.
22337    #[test]
22338    fn trap_body_listing_deparses_to_canonical_text() {
22339        let _g = crate::test_util::global_state_lock();
22340        let prog = crate::ported::exec::parse_string("print a; print b", 1)
22341            .expect("body must parse for the listing path");
22342        let rendered = crate::ported::text::getpermtext(Box::new(prog), None, 0);
22343        assert_eq!(
22344            rendered, "print a\nprint b",
22345            "c:7371 getpermtext must canonicalise `;` separators to newlines (got {rendered:?})"
22346        );
22347    }
22348
22349    /// Src/options.c:537-549 — `emulate(zsh_name, ...)` dispatches
22350    /// on the FIRST char of the shell name, stripping a leading `r`
22351    /// (so `rcsh`/`rksh` work as restricted variants of their base
22352    /// shell). `bash` aliases to SH (the `'b'` branch of the case).
22353    /// Pin the bits assigned by `bin_emulate` for the canonical
22354    /// names + their first-char-overlap aliases.
22355    #[test]
22356    fn bin_emulate_dispatches_on_first_char_per_c537() {
22357        let _g = crate::test_util::global_state_lock();
22358        let empty = options {
22359            ind: [0u8; MAX_OPS],
22360            args: Vec::new(),
22361            argscount: 0,
22362            argsalloc: 0,
22363        };
22364        let saved = emulation.load(Relaxed);
22365        // `emulate` rewrites the WHOLE option table (installemulation,
22366        // c:551-572), not just the emulation bits — `emulate csh` turns
22367        // CPRECEDENCES on, `emulate sh` turns SHWORDSPLIT on, and so on.
22368        // Restoring only `emulation` left those options set for every
22369        // later test in the process (a leaked CPRECEDENCES made
22370        // `4 - - 3 * 7 << 1 & 7 ^ 1 | 16 ** 2` evaluate to C's 259
22371        // instead of zsh's 1591). Snapshot and restore the table too.
22372        let saved_opts = crate::ported::options::opt_state_snapshot();
22373
22374        // Each (name, expected_bits) — name covers the canonical
22375        // shell names AND their `r`-prefix / first-char variants.
22376        for (name, expected) in [
22377            ("csh", EMULATE_CSH),
22378            ("ksh", EMULATE_KSH),
22379            ("sh", EMULATE_SH),
22380            ("rcsh", EMULATE_CSH), // c:539-540
22381            ("rksh", EMULATE_KSH), // c:539-540
22382            ("bash", EMULATE_SH),  // c:548 'b'
22383        ] {
22384            emulation.store(0, Relaxed);
22385            bin_emulate("emulate", &[name.into()], &empty, 0);
22386            let bits = emulation.load(Relaxed);
22387            assert_eq!(
22388                bits, expected,
22389                "emulate {} must set bits {:#x}, got {:#x}",
22390                name, expected, bits
22391            );
22392        }
22393        emulation.store(saved, Relaxed);
22394        crate::ported::options::opt_state_restore(saved_opts);
22395    }
22396
22397    /// c:7399 — `trap - SIGUSR1` (valid signal) MUST return 0, even
22398    /// when the trap was never set (remove is a no-op).
22399    #[test]
22400    fn bin_trap_clear_valid_signal_returns_zero() {
22401        let _g = crate::test_util::global_state_lock();
22402        let empty = options {
22403            ind: [0u8; MAX_OPS],
22404            args: Vec::new(),
22405            argscount: 0,
22406            argsalloc: 0,
22407        };
22408        let r = bin_trap("trap", &["-".into(), "USR1".into()], &empty, 0);
22409        assert_eq!(
22410            r, 0,
22411            "trap - USR1 must succeed even with no prior trap (got {})",
22412            r
22413        );
22414    }
22415
22416    #[test]
22417    fn registration_table_matches_c_count() {
22418        let _g = crate::test_util::global_state_lock();
22419        // Src/builtin.c:40-137 has 79 rows total (5 BIN_PREFIX + 71
22420        // BUILTIN + 3 debug-only BUILTIN). The Rust port bundles
22421        // additional builtins eagerly that C would load via zmodload:
22422        //   zsh/rlimits (limit/ulimit/unlimit)
22423        //   zsh/zle (bindkey/vared/zle)
22424        //   zsh/cap (cap/getcap/setcap)
22425        //   zsh/files (chmod/chown/ln/mkdir/rm/rmdir/sync)
22426        //   zsh/complete (compadd/compset)
22427        //   zsh/terminfo (echoti)
22428        //   zsh/pcre (pcre_compile/pcre_match/pcre_study)
22429        //   zsh/zutil (zformat/zgdbmpath)
22430        //   zsh/sched (sched)
22431        //   zsh/computil (comparguments/compdescribe/compfiles/
22432        //     compgroups/compquote/comptags/comptry/compvalues)
22433        //   zsh/system (syserror/sysread/syswrite/sysopen/sysseek/zsystem)
22434        //   zsh/zselect (zselect)
22435        //   zsh/socket (zsocket)
22436        //   zsh/stat (stat)
22437        //   zsh/watch (log)
22438        //   zsh/zprof (zprof)
22439        //   zsh/datetime (strftime)
22440        //   zsh/zftp (zftp), zsh/zpty (zpty), zsh/curses (zcurses)
22441        //   zsh/clone (clone), zsh/example (example)
22442        //   zsh/param/private (private)
22443        //   zsh/termcap (echotc)
22444        //   zsh/compctl (compcall, compctl)
22445        //   zsh/attr (zgetattr, zsetattr, zdelattr, zlistattr)
22446        // Tripwire pin on BUILTINS table length. The number drifts every
22447        // time the eagerly-loaded-module list above grows (new builtin
22448        // ported, new module wired). Bump it alongside the change so
22449        // accidental additions/removals still trip a review.
22450        // 159 ported zsh builtins + 1 zshrs-native (`__rust_compile`, the
22451        // inline Rust FFI desugar target — no C counterpart) = 160.
22452        assert_eq!(BUILTINS.len(), 160,
22453            "BUILTINS table size changed — bump count or update the eagerly-loaded-module list above");
22454    }
22455
22456    /// `Src/builtin.c:40-137` — every name in the canonical C builtin
22457    /// table must be present in the Rust port. Pins coverage of all
22458    /// 79 C builtins by name (ignores option-mask / handler details).
22459    /// Detects regressions where a builtin gets accidentally dropped
22460    /// from BUILTINS. Names extracted from upstream zsh `Src/builtin.c`.
22461    #[test]
22462    fn registration_table_contains_all_c_builtins() {
22463        let _g = crate::test_util::global_state_lock();
22464        // Canonical 79 names from Src/builtin.c:40-137 (verbatim).
22465        let c_names: &[&str] = &[
22466            "-",
22467            ".",
22468            ":",
22469            "[",
22470            "alias",
22471            "autoload",
22472            "bg",
22473            "break",
22474            "builtin",
22475            "bye",
22476            "cd",
22477            "chdir",
22478            "command",
22479            "continue",
22480            "declare",
22481            "dirs",
22482            "disable",
22483            "disown",
22484            "echo",
22485            "emulate",
22486            "enable",
22487            "eval",
22488            "exec",
22489            "exit",
22490            "export",
22491            "false",
22492            "fc",
22493            "fg",
22494            "float",
22495            "functions",
22496            "getln",
22497            "getopts",
22498            "hash",
22499            "hashinfo",
22500            "history",
22501            "integer",
22502            "jobs",
22503            "kill",
22504            "let",
22505            "local",
22506            "logout",
22507            "mem",
22508            "noglob",
22509            "patdebug",
22510            "popd",
22511            "print",
22512            "printf",
22513            "pushd",
22514            "pushln",
22515            "pwd",
22516            "r",
22517            "read",
22518            "readonly",
22519            "rehash",
22520            "return",
22521            "set",
22522            "setopt",
22523            "shift",
22524            "source",
22525            "suspend",
22526            "test",
22527            "times",
22528            "trap",
22529            "true",
22530            "ttyctl",
22531            "type",
22532            "typeset",
22533            "umask",
22534            "unalias",
22535            "unfunction",
22536            "unhash",
22537            "unset",
22538            "unsetopt",
22539            "wait",
22540            "whence",
22541            "where",
22542            "which",
22543            "zcompile",
22544            "zmodload",
22545        ];
22546        assert_eq!(
22547            c_names.len(),
22548            79,
22549            "C builtin.c row count is 79 — recount if changed"
22550        );
22551        let table_names: std::collections::HashSet<&str> =
22552            BUILTINS.iter().map(|b| b.node.nam.as_str()).collect();
22553        for c_name in c_names {
22554            assert!(
22555                table_names.contains(*c_name),
22556                "missing C builtin '{}' from BUILTINS table",
22557                c_name
22558            );
22559        }
22560    }
22561
22562    #[test]
22563    fn lookup_finds_known_builtins() {
22564        let _g = crate::test_util::global_state_lock();
22565        for name in [
22566            "cd", "echo", "print", "fg", "bg", "jobs", "wait", "typeset", "test", "[", ".",
22567        ] {
22568            assert!(
22569                createbuiltintable().get(name).copied().is_some(),
22570                "missing: {name}"
22571            );
22572        }
22573    }
22574
22575    #[test]
22576    fn lookup_misses_unknown() {
22577        let _g = crate::test_util::global_state_lock();
22578        assert!(createbuiltintable()
22579            .get("not-a-builtin-zZz")
22580            .copied()
22581            .is_none());
22582    }
22583
22584    #[test]
22585    fn prefix_entries_have_prefix_flag() {
22586        let _g = crate::test_util::global_state_lock();
22587        for name in ["-", "builtin", "command", "exec", "noglob"] {
22588            let b = createbuiltintable().get(name).copied().unwrap();
22589            assert!(
22590                b.node.flags as u32 & BINF_PREFIX != 0,
22591                "{name} missing BINF_PREFIX"
22592            );
22593        }
22594    }
22595
22596    /// Run `fixdir` (c:Src/builtin.c:1298) on `p`; the rewritten path and
22597    /// the int it returns.
22598    fn fixdir_of(p: &str) -> (String, i32) {
22599        let mut s = p.to_string();
22600        let r = fixdir(&mut s);
22601        (s, r)
22602    }
22603
22604    #[test]
22605    fn fixdir_canonicalizes_absolute_paths() {
22606        let _g = crate::test_util::global_state_lock();
22607        // c:1334-1392 — collapse `//`, drop `./`, pop `foo/..` for a
22608        // directory `foo`.
22609        let d = std::env::temp_dir().join(format!("fixdir-{}", std::process::id()));
22610        std::fs::create_dir_all(d.join("bar/baz")).unwrap();
22611        let d = d.to_string_lossy().into_owned();
22612        assert_eq!(fixdir_of(&format!("{d}/./bar")), (format!("{d}/bar"), 0));
22613        assert_eq!(fixdir_of(&format!("{d}//bar//")), (format!("{d}/bar"), 0));
22614        assert_eq!(fixdir_of(&format!("{d}/bar/../x")), (format!("{d}/x"), 0));
22615        assert_eq!(fixdir_of(&format!("{d}/bar/baz/../..")), (d.clone(), 0));
22616        let _ = std::fs::remove_dir_all(&d);
22617    }
22618
22619    #[test]
22620    fn fixdir_keeps_dotdot_after_a_non_directory() {
22621        let _g = crate::test_util::global_state_lock();
22622        // c:1366-1374 — `foo/..` is removed only once stat() shows `foo`
22623        // is a directory; otherwise the rest stays as written and the
22624        // result is 1 (so `cd /nonexistent/../tmp` fails, as in zsh).
22625        assert_eq!(
22626            fixdir_of("/nonexistent-fixdir/../tmp"),
22627            ("/nonexistent-fixdir/../tmp".to_string(), 1)
22628        );
22629    }
22630
22631    #[test]
22632    fn fixdir_drops_dotdot_past_root() {
22633        let _g = crate::test_util::global_state_lock();
22634        // c:1359 `if (dest > d0 + 1)` — at `/` there is nothing to pop.
22635        assert_eq!(fixdir_of("/.."), ("/".to_string(), 0));
22636        assert_eq!(fixdir_of("/../.."), ("/".to_string(), 0));
22637    }
22638
22639    #[test]
22640    fn fixdir_chasedots_keeps_dotdot() {
22641        let _g = crate::test_util::global_state_lock();
22642        // c:1356-1358 — under CHASE_DOTS `..` is an ordinary segment and
22643        // the result asks cd to chase links.
22644        crate::ported::options::opt_state_set("chasedots", true);
22645        let r = fixdir_of("/tmp/../usr");
22646        crate::ported::options::opt_state_set("chasedots", false);
22647        assert_eq!(r, ("/tmp/../usr".to_string(), 1));
22648    }
22649
22650    #[test]
22651    fn fixdir_relative_leading_dotdot_has_nothing_to_pop() {
22652        let _g = crate::test_util::global_state_lock();
22653        // c:1359 — callers hand fixdir an absolute path; a leading `..` in a
22654        // relative one has no segment before it and is simply skipped.
22655        assert_eq!(fixdir_of("../foo"), ("foo".to_string(), 0));
22656        assert_eq!(fixdir_of("../../foo"), ("foo".to_string(), 0));
22657    }
22658
22659    #[test]
22660    fn fixdir_lone_dot_collapses_to_empty() {
22661        let _g = crate::test_util::global_state_lock();
22662        // c:1385-1387 skip the `.` section, c:1343-1349 end with nothing.
22663        assert_eq!(fixdir_of("./"), (String::new(), 0));
22664    }
22665
22666    #[test]
22667    fn fixdir_empty_input_returns_empty() {
22668        let _g = crate::test_util::global_state_lock();
22669        assert_eq!(fixdir_of(""), (String::new(), 0));
22670    }
22671
22672    #[test]
22673    fn fg_dispatch_id_distinguishes_aliases() {
22674        let _g = crate::test_util::global_state_lock();
22675        // bin_fg covers fg, bg, jobs, wait, disown — same handler,
22676        // different funcid. Mirrors Src/builtin.c:52,61,75,88,131.
22677        assert_eq!(
22678            createbuiltintable().get("fg").copied().unwrap().funcid,
22679            BIN_FG
22680        );
22681        assert_eq!(
22682            createbuiltintable().get("bg").copied().unwrap().funcid,
22683            BIN_BG
22684        );
22685        assert_eq!(
22686            createbuiltintable().get("jobs").copied().unwrap().funcid,
22687            BIN_JOBS
22688        );
22689        assert_eq!(
22690            createbuiltintable().get("wait").copied().unwrap().funcid,
22691            BIN_WAIT
22692        );
22693        assert_eq!(
22694            createbuiltintable().get("disown").copied().unwrap().funcid,
22695            BIN_DISOWN
22696        );
22697    }
22698
22699    /// c:1297 — `fixdir` is the lexical-canonicalisation for `cd`. The
22700    /// path `/a/b/../c` must resolve to `/a/c` BEFORE chdir(2) — the
22701    /// shell uses it to compute the logical PWD for $PWD/OLDPWD. A
22702    /// regression that drops the `..` consumption would make $PWD
22703    /// report `/a/b/../c` literally on `cd /a/b/../c`.
22704    #[test]
22705    fn fixdir_pops_dotdot_against_previous_component() {
22706        let _g = crate::test_util::global_state_lock();
22707        // c:1360-1378 — `foo/..` pops only after stat() shows foo is a
22708        // directory; /usr/bin and /usr exist on every supported host.
22709        assert_eq!(fixdir_of("/usr/bin/../lib"), ("/usr/lib".to_string(), 0));
22710        assert_eq!(fixdir_of("/usr/bin/../../tmp"), ("/tmp".to_string(), 0));
22711        assert_eq!(fixdir_of("/usr/.."), ("/".to_string(), 0));
22712    }
22713
22714    /// c:1352 — `./` collapses to nothing.  `/a/./b` must equal `/a/b`.
22715    #[test]
22716    fn fixdir_drops_dot_components() {
22717        let _g = crate::test_util::global_state_lock();
22718        assert_eq!(fixdir_of("/a/./b").0, "/a/b");
22719        assert_eq!(fixdir_of("./a").0, "a");
22720        // c:1385-1387 — every `.` section is skipped, leaving nothing.
22721        assert_eq!(fixdir_of("./.").0, "");
22722    }
22723
22724    /// c:1388 — `//` collapses to single `/` (no preservation of POSIX
22725    /// implementation-defined `//` semantics, which zsh doesn't honour).
22726    #[test]
22727    fn fixdir_collapses_consecutive_slashes() {
22728        let _g = crate::test_util::global_state_lock();
22729        assert_eq!(fixdir_of("/a//b").0, "/a/b");
22730        assert_eq!(fixdir_of("/a///b/c").0, "/a/b/c");
22731    }
22732
22733    /// c:1404 — absolute path: `..` past `/` silently drops. `/..`
22734    /// resolves to `/`. Catches a regression where the underflow
22735    /// emits `..` literally.
22736    #[test]
22737    fn fixdir_dotdot_past_root_clamps_to_root() {
22738        let _g = crate::test_util::global_state_lock();
22739        assert_eq!(fixdir_of("/..").0, "/");
22740        assert_eq!(fixdir_of("/../../a").0, "/a");
22741    }
22742
22743    /// c:1400 — RELATIVE path: leading `..` are preserved (no parent
22744    /// known until chdir time). This is critical for `cd ../../foo`
22745    /// which must NOT resolve `..` lexically.
22746    #[test]
22747    fn fixdir_relative_leading_dotdot_is_preserved() {
22748        let _g = crate::test_util::global_state_lock();
22749        // c:1360 — `if (dest > d0 + 1)`: a leading `..` has nothing to pop
22750        // and is skipped, so the caller resolves the result against the
22751        // logical PWD it already prepended (cd_do_chdir).
22752        assert_eq!(fixdir_of("../foo"), ("foo".to_string(), 0));
22753        assert_eq!(fixdir_of("../../foo"), ("foo".to_string(), 0));
22754    }
22755
22756    /// c:1683 — `fcgetcomm` returns 0 for ambiguous numeric inputs
22757    /// only when the string actually starts with '0'. The atoi result
22758    /// alone (which is 0 for non-numeric) MUST NOT short-circuit —
22759    /// non-numeric input should fall through to hcomsearch instead.
22760    #[test]
22761    fn fcgetcomm_numeric_zero_only_for_literal_zero_prefix() {
22762        let _g = crate::test_util::global_state_lock();
22763        assert_eq!(fcgetcomm("0"), 0, "literal `0` is event 0");
22764        assert_eq!(fcgetcomm("42"), 42);
22765        // Non-numeric falls through to hcomsearch (no hist match → -1).
22766        assert_eq!(fcgetcomm("definitely_not_a_history_command_zshrs"), -1);
22767    }
22768
22769    /// c:1088-1093 — `cd_able_vars` requires CDABLEVARS to be set;
22770    /// otherwise returns None even when the head names a param. A
22771    /// regression that ignores the option flag would let `cd HOME`
22772    /// silently `cd $HOME` even when the user disabled CDABLEVARS.
22773    #[test]
22774    fn cd_able_vars_returns_none_without_cdablevars_option() {
22775        let _g = crate::test_util::global_state_lock();
22776        // CDABLEVARS is not set by default → must return None.
22777        // We don't fight the option state here; just verify the
22778        // off-state default short-circuits before paramtab lookup.
22779        // (If a future commit enables CDABLEVARS by default, this
22780        // test will fail loudly — that's the right canary.)
22781        let r = cd_able_vars("HOME/anything");
22782        // Without CDABLEVARS, must be None; with it, would be Some.
22783        // Accept either since the option default is the actual invariant.
22784        if !isset(CDABLEVARS) {
22785            assert!(r.is_none());
22786        }
22787    }
22788
22789    /// c:212 — `init_builtins` is idempotent: calling twice doesn't
22790    /// duplicate entries in the table. Regression that re-inserts on
22791    /// every call would balloon memory + break dispatch lookups.
22792    #[test]
22793    fn init_builtins_is_idempotent() {
22794        let _g = crate::test_util::global_state_lock();
22795        init_builtins();
22796        let count1 = createbuiltintable().len();
22797        init_builtins();
22798        let count2 = createbuiltintable().len();
22799        assert_eq!(count1, count2, "init_builtins must not duplicate entries");
22800    }
22801
22802    /// c:1708 — `fcsubs(sp, [(old, new), ...])` applies each
22803    /// substitution to the running string, returning the total
22804    /// replacement count. A regression returning 0 with substitutions
22805    /// applied would silently break `fc -s old=new`.
22806    #[test]
22807    fn fcsubs_applies_each_substitution_in_order() {
22808        let _g = crate::test_util::global_state_lock();
22809        let mut s = "echo foo bar foo".to_string();
22810        let n = fcsubs(&mut s, &[("foo".to_string(), "FOO".to_string())]);
22811        assert_eq!(s, "echo FOO bar FOO");
22812        assert_eq!(n, 2, "two `foo` matches replaced");
22813    }
22814
22815    /// c:1708 — empty `old` MUST skip (avoid infinite empty-match
22816    /// replacement loop). Regression treating "" as "match anywhere"
22817    /// would hang or silently corrupt every fc invocation.
22818    #[test]
22819    fn fcsubs_skips_empty_pattern() {
22820        let _g = crate::test_util::global_state_lock();
22821        let mut s = "anything".to_string();
22822        let n = fcsubs(&mut s, &[("".to_string(), "X".to_string())]);
22823        assert_eq!(s, "anything", "empty pattern must be skipped");
22824        assert_eq!(n, 0);
22825    }
22826
22827    /// c:1708 — chained substitutions apply left-to-right. After
22828    /// `a→b`, the next pair sees the post-substitution text. So
22829    /// `[(a→b), (b→c)]` over `a` yields `c`.
22830    #[test]
22831    fn fcsubs_chains_substitutions_left_to_right() {
22832        let _g = crate::test_util::global_state_lock();
22833        let mut s = "a".to_string();
22834        let n = fcsubs(
22835            &mut s,
22836            &[
22837                ("a".to_string(), "b".to_string()),
22838                ("b".to_string(), "c".to_string()),
22839            ],
22840        );
22841        assert_eq!(s, "c", "second sub sees post-first-sub text");
22842        assert_eq!(n, 2);
22843    }
22844
22845    /// c:1708 — substitution on no-match leaves string unchanged AND
22846    /// reports 0. Regression touching the string anyway would mangle
22847    /// fc output for events containing none of the requested patterns.
22848    #[test]
22849    fn fcsubs_no_match_returns_zero_unchanged() {
22850        let _g = crate::test_util::global_state_lock();
22851        let mut s = "hello world".to_string();
22852        let n = fcsubs(&mut s, &[("xyz".to_string(), "abc".to_string())]);
22853        assert_eq!(s, "hello world", "no match → unchanged");
22854        assert_eq!(n, 0);
22855    }
22856
22857    /// c:1297 — `fixdir` for plain relative path (no slashes, no
22858    /// dots) returns it unchanged. Most-common cd path; regression
22859    /// here would break `cd subdir`.
22860    #[test]
22861    fn fixdir_plain_relative_path_unchanged() {
22862        let _g = crate::test_util::global_state_lock();
22863        assert_eq!(fixdir_of("subdir").0, "subdir");
22864        assert_eq!(fixdir_of("a/b/c").0, "a/b/c");
22865        assert_eq!(fixdir_of(".").0, "", "c:1385-1387 — a lone `.` section is skipped");
22866    }
22867
22868    /// Shared mutex for bin_let tests that toggle the global errflag.
22869    static BIN_LET_TEST_LOCK: Mutex<()> = Mutex::new(());
22870
22871    /// `Src/builtin.c:7469-7484` — `bin_let` semantics:
22872    ///   1. Returns 0 (success) when the LAST arg evaluates to non-zero.
22873    ///   2. Returns 1 (failure) when the LAST arg evaluates to zero.
22874    ///   3. Returns 2 AND CLEARS ERRFLAG_ERROR when any arg errors
22875    ///      (let errors are non-fatal and local).
22876    #[test]
22877    fn bin_let_clears_errflag_on_math_error() {
22878        let _g = crate::test_util::global_state_lock();
22879        let _g = BIN_LET_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
22880        let saved = errflag.load(Relaxed);
22881        errflag.store(0, Relaxed);
22882
22883        // 1. Last arg evaluates to non-zero → return 0.
22884        let ops = options {
22885            ind: [0; MAX_OPS],
22886            args: Vec::new(),
22887            argscount: 0,
22888            argsalloc: 0,
22889        };
22890        let argv = vec!["1".to_string()];
22891        assert_eq!(
22892            bin_let("let", &argv, &ops, 0),
22893            0,
22894            "c:7482 — last expr non-zero → return 0 (success)"
22895        );
22896
22897        // 2. Last arg evaluates to zero → return 1.
22898        let argv = vec!["0".to_string()];
22899        assert_eq!(
22900            bin_let("let", &argv, &ops, 0),
22901            1,
22902            "c:7482 — last expr zero → return 1 (failure)"
22903        );
22904
22905        // 3. Bad-syntax arg → return 2 AND clear ERRFLAG_ERROR.
22906        // Pre-set errflag manually to simulate matheval failure side
22907        // effect (since exact bad-syntax behavior of the matheval port
22908        // is implementation-dependent — what we're pinning is the
22909        // bin_let response to a set errflag).
22910        errflag.store(ERRFLAG_ERROR, Relaxed);
22911        // Use a valid expression so matheval succeeds, but errflag
22912        // is already set from a prior step.
22913        let argv = vec!["1".to_string()];
22914        let rc = bin_let("let", &argv, &ops, 0);
22915        assert_eq!(rc, 2, "c:7479 — `return 2;` after a math error");
22916        // c:7478 — `errflag &= ~ERRFLAG_ERROR` must have run.
22917        assert_eq!(
22918            errflag.load(Relaxed) & ERRFLAG_ERROR,
22919            0,
22920            "c:7478 — ERRFLAG_ERROR must be CLEARED after let error"
22921        );
22922
22923        // Restore.
22924        errflag.store(saved, Relaxed);
22925    }
22926
22927    /// Bug #1026: `test ! ! -n abc` = not(not(-n abc)) → 0 (true). The
22928    /// pre-flight rejected a leading-`!` chain over a unary operand as
22929    /// "condition expected: !" (c:builtin.c:7270 + par_cond `!` rule).
22930    #[test]
22931    fn bin_test_double_negation_of_unary_op() {
22932        let _g = crate::test_util::global_state_lock();
22933        let saved = errflag.load(Relaxed);
22934        let ops = options {
22935            ind: [0; MAX_OPS],
22936            args: Vec::new(),
22937            argscount: 0,
22938            argsalloc: 0,
22939        };
22940        let mk = |s: &[&str]| s.iter().map(|x| (*x).to_string()).collect::<Vec<_>>();
22941        let run = |a: &[&str]| {
22942            errflag.store(0, Relaxed);
22943            bin_test("test", &mk(a), &ops, crate::ported::hashtable_h::BIN_TEST)
22944        };
22945        // not(not(-n abc)) = not(not true) = true → 0
22946        assert_eq!(run(&["!", "!", "-n", "abc"]), 0);
22947        // not(not(not(-n abc))) = false → 1
22948        assert_eq!(run(&["!", "!", "!", "-n", "abc"]), 1);
22949        // not(not(-n "")) = not(not false) = false → 1
22950        assert_eq!(run(&["!", "!", "-n", ""]), 1);
22951        // Single negation unaffected: not(-n abc) → 1
22952        assert_eq!(run(&["!", "-n", "abc"]), 1);
22953        errflag.store(saved, Relaxed);
22954    }
22955
22956    /// `Src/builtin.c:7474-7475` — C walks ALL argv via
22957    /// `while (*argv) val = matheval(*argv++);`. The LAST matheval
22958    /// result is what determines the return code. The previous Rust
22959    /// port broke on first error, skipping later args. Pin: a sequence
22960    /// of two non-zero exprs returns 0 even if both are evaluated.
22961    #[test]
22962    fn bin_let_walks_all_argv_last_wins() {
22963        let _g = crate::test_util::global_state_lock();
22964        let _g = BIN_LET_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
22965        errflag.store(0, Relaxed);
22966
22967        let ops = options {
22968            ind: [0; MAX_OPS],
22969            args: Vec::new(),
22970            argscount: 0,
22971            argsalloc: 0,
22972        };
22973        // c:7474 — `5; 0` (two args): last is 0 → return 1.
22974        let argv = vec!["5".to_string(), "0".to_string()];
22975        assert_eq!(
22976            bin_let("let", &argv, &ops, 0),
22977            1,
22978            "c:7474 — last arg wins (here: 0 → return 1)"
22979        );
22980
22981        // c:7474 — `0; 5` (two args): last is 5 → return 0.
22982        let argv = vec!["0".to_string(), "5".to_string()];
22983        assert_eq!(
22984            bin_let("let", &argv, &ops, 0),
22985            0,
22986            "c:7474 — last arg wins (here: 5 → return 0)"
22987        );
22988
22989        errflag.fetch_and(!ERRFLAG_ERROR, Relaxed);
22990    }
22991
22992    /// `Src/builtin.c:4799-4808` — `print -o` (sort) is CASE-SENSITIVE
22993    /// by default; `-i` flips to case-insensitive. The previous Rust
22994    /// port had this INVERTED: case-sensitive under `-i`,
22995    /// case-insensitive without. Pin the canonical semantic by direct
22996    /// reproduction of the sort step.
22997    ///
22998    /// `bin_print` itself is harder to test in isolation because it
22999    /// emits to stdout; instead we replicate the in-port sort logic
23000    /// to ensure the gate matches C semantics. If the port body's
23001    /// `if ignore_case` is ever re-inverted, the regression here
23002    /// surfaces immediately.
23003    #[test]
23004    fn bin_print_sort_matches_c_case_gate() {
23005        let _g = crate::test_util::global_state_lock();
23006        // Helper mirroring the in-port logic exactly.
23007        let sort_with = |items: &[&str], ignore_case: bool, backwards: bool| -> Vec<String> {
23008            let mut v: Vec<String> = items.iter().map(|s| s.to_string()).collect();
23009            if ignore_case {
23010                v.sort_by_key(|s| s.to_lowercase());
23011            } else {
23012                v.sort();
23013            }
23014            if backwards {
23015                v.reverse();
23016            }
23017            v
23018        };
23019        // `print -o foo Bar BAZ` (no `-i`): case-sensitive ASCII sort.
23020        // Uppercase ASCII < lowercase ASCII, so caps come first.
23021        let no_i = sort_with(&["foo", "Bar", "BAZ"], false, false);
23022        assert_eq!(
23023            no_i,
23024            vec!["BAZ", "Bar", "foo"],
23025            "c:4805 — without -i: case-sensitive sort (caps first by ASCII)"
23026        );
23027        // `print -oi foo Bar BAZ`: case-insensitive sort.
23028        // Lower-case comparison: "bar" < "baz" < "foo", so order is
23029        // Bar, BAZ, foo.
23030        let with_i = sort_with(&["foo", "Bar", "BAZ"], true, false);
23031        assert_eq!(
23032            with_i,
23033            vec!["Bar", "BAZ", "foo"],
23034            "c:4805 — with -i: case-insensitive sort"
23035        );
23036        // `print -O foo Bar BAZ` (no `-i`): case-sensitive descending.
23037        let big_o = sort_with(&["foo", "Bar", "BAZ"], false, true);
23038        assert_eq!(
23039            big_o,
23040            vec!["foo", "Bar", "BAZ"],
23041            "c:4806 — -O reverses after sort"
23042        );
23043        // Conjunction check: zsh-equivalent: print -O foo Bar BAZ
23044        // gives `foo Bar BAZ`. Pin so an inadvertent reverse-before-
23045        // sort regression fails.
23046    }
23047
23048    /// `Src/builtin.c:4854-4856 + 5564-5565` — `printf -z FMT ARGS...`
23049    /// captures formatted output then pushes to bufstack (same path
23050    /// as -z without -f).
23051    #[test]
23052    fn bin_print_printf_with_minus_z() {
23053        let _g = crate::test_util::global_state_lock();
23054        let mut ops = options {
23055            ind: [0u8; MAX_OPS],
23056            args: Vec::new(),
23057            argscount: 0,
23058            argsalloc: 0,
23059        };
23060        ops.ind[b'z' as usize] = 1;
23061        // -f set to "echo %s" (positional)
23062        ops.ind[b'f' as usize] = 1 | (1 << 2);
23063        ops.args = vec!["echo %s".to_string()];
23064        ops.argscount = 1;
23065        crate::ported::zle::zle_main::BUFSTACK
23066            .lock()
23067            .unwrap()
23068            .clear();
23069        let r = bin_print("printf", &["hello".to_string()], &ops, BIN_PRINTF);
23070        assert_eq!(r, 0);
23071        let buf = crate::ported::zle::zle_main::BUFSTACK.lock().unwrap();
23072        assert_eq!(
23073            buf.last().map(|s| s.as_str()),
23074            Some("echo hello"),
23075            "c:4854-4856 — printf -z must push formatted output to bufstack"
23076        );
23077    }
23078
23079    /// `Src/builtin.c:5529-5559` — `printf -s` / `print -f … -s` captures the
23080    /// FORMATTED output and stores it as a history entry.
23081    ///
23082    /// This asserts the RING, not `histtab`, and that is the whole point.
23083    /// C's `prepnexthistent` (`Src/hist.c:1399-1410`) allocates the entry and
23084    /// links it into `hist_ring` itself; zshrs's only evicts and bumps
23085    /// `curhist`, so every caller has to do the link. `addhistnode` alone
23086    /// populates `histtab`, and `fc` does not read `histtab` — it reads the
23087    /// ring. `bin_print_minus_s_pushes_to_history` above checks only
23088    /// `histtab`, which is why the fmt arm could omit the link and still pass
23089    /// every existing test while `print -f '%s\n' -s X; fc -ln -1` printed
23090    /// nothing at all.
23091    #[test]
23092    fn bin_print_printf_with_minus_s_links_into_hist_ring() {
23093        let _g = crate::test_util::global_state_lock();
23094        let mut ops = options {
23095            ind: [0u8; MAX_OPS],
23096            args: Vec::new(),
23097            argscount: 0,
23098            argsalloc: 0,
23099        };
23100        ops.ind[b's' as usize] = 1;
23101        // -f set to "echo %s" (positional), same shape as the -z test above.
23102        ops.ind[b'f' as usize] = 1 | (1 << 2);
23103        ops.args = vec!["echo %s".to_string()];
23104        ops.argscount = 1;
23105        crate::ported::hist::hist_ring.lock().unwrap().clear();
23106        let r = bin_print("printf", &["hello".to_string()], &ops, BIN_PRINTF);
23107        assert_eq!(r, 0);
23108        let ring = crate::ported::hist::hist_ring.lock().unwrap();
23109        assert!(
23110            ring.iter().any(|h| h.node.nam == "echo hello"),
23111            "c:5553-5558 — printf -s must LINK the entry into hist_ring (what fc reads), \
23112             not only into histtab; ring held {:?}",
23113            ring.iter().map(|h| h.node.nam.clone()).collect::<Vec<_>>()
23114        );
23115    }
23116
23117    /// `Src/builtin.c:5564-5565` — `print -z WORDS...` pushes the
23118    /// joined string to the ZLE bufstack instead of stdout (consumed
23119    /// by the next zleread call so the string lands at the prompt).
23120    #[test]
23121    fn bin_print_minus_z_pushes_to_bufstack() {
23122        let _g = crate::test_util::global_state_lock();
23123        let mut ops = options {
23124            ind: [0u8; MAX_OPS],
23125            args: Vec::new(),
23126            argscount: 0,
23127            argsalloc: 0,
23128        };
23129        ops.ind[b'z' as usize] = 1;
23130        crate::ported::zle::zle_main::BUFSTACK
23131            .lock()
23132            .unwrap()
23133            .clear();
23134        let r = bin_print(
23135            "print",
23136            &["echo".to_string(), "foo".to_string()],
23137            &ops,
23138            BIN_PRINT,
23139        );
23140        assert_eq!(r, 0, "c:5565 — -z should succeed");
23141        let buf = crate::ported::zle::zle_main::BUFSTACK.lock().unwrap();
23142        assert_eq!(
23143            buf.last().map(|s| s.as_str()),
23144            Some("echo foo"),
23145            "c:5565 — bufstack must have `echo foo` as the top entry"
23146        );
23147    }
23148
23149    /// `Src/builtin.c:5569-5574` — `print -s WORDS...` pushes the
23150    /// joined string to the history table instead of stdout.
23151    #[test]
23152    fn bin_print_minus_s_pushes_to_history() {
23153        let _g = crate::test_util::global_state_lock();
23154        let mut ops = options {
23155            ind: [0u8; MAX_OPS],
23156            args: Vec::new(),
23157            argscount: 0,
23158            argsalloc: 0,
23159        };
23160        ops.ind[b's' as usize] = 1;
23161        // Clear histtab to a known state.
23162        crate::ported::hashtable::histtab_lock()
23163            .write()
23164            .unwrap()
23165            .clear();
23166        let r = bin_print(
23167            "print",
23168            &["hello".to_string(), "world".to_string()],
23169            &ops,
23170            BIN_PRINT,
23171        );
23172        assert_eq!(r, 0, "c:5574 — -s should succeed");
23173        // After -s, the joined "hello world" string must appear in
23174        // histtab (the in-process history lookup table).
23175        let tab = crate::ported::hashtable::histtab_lock().read().unwrap();
23176        assert!(
23177            tab.contains_key("hello world"),
23178            "c:5574 — addhistnode must record `hello world` in histtab"
23179        );
23180    }
23181
23182    /// `Src/builtin.c:4718-4741` — `print -m PATTERN args...` keeps
23183    /// only the args matching PATTERN. Pipe-roundtrip pin: pat=`foo*`,
23184    /// args=[foo1, bar, foo2] → expect `foo1 foo2\n` (NOT `bar`).
23185    #[test]
23186    fn bin_print_minus_m_glob_filter() {
23187        let _g = crate::test_util::global_state_lock();
23188        use std::io::Read as _;
23189        let mut fds: [libc::c_int; 2] = [0, 0];
23190        assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0);
23191        let (rfd, wfd) = (fds[0], fds[1]);
23192        let mut ops = options {
23193            ind: [0u8; MAX_OPS],
23194            args: Vec::new(),
23195            argscount: 0,
23196            argsalloc: 0,
23197        };
23198        ops.ind[b'u' as usize] = 1 | (1 << 2);
23199        ops.args = vec![wfd.to_string()];
23200        ops.argscount = 1;
23201        ops.ind[b'm' as usize] = 1; // -m
23202
23203        let r = bin_print(
23204            "print",
23205            &[
23206                "foo*".to_string(),
23207                "foo1".to_string(),
23208                "bar".to_string(),
23209                "foo2".to_string(),
23210            ],
23211            &ops,
23212            BIN_PRINT,
23213        );
23214        assert_eq!(r, 0);
23215        unsafe { libc::close(wfd) };
23216
23217        let mut buf = String::new();
23218        unsafe {
23219            use std::os::unix::io::FromRawFd;
23220            let mut f = fs::File::from_raw_fd(rfd);
23221            f.read_to_string(&mut buf).unwrap();
23222        }
23223        assert_eq!(
23224            buf, "foo1 foo2\n",
23225            "c:4718-4741 — -m filters to only `foo*`-matching args"
23226        );
23227    }
23228
23229    /// `Src/builtin.c:5126-5132` — `print -N a b` separates args with
23230    /// `\0` and terminates with `\0` (not `\n`). Pipe-roundtrip pin.
23231    #[test]
23232    fn bin_print_nul_separator_with_minus_N() {
23233        let _g = crate::test_util::global_state_lock();
23234        use std::io::Read as _;
23235        let mut fds: [libc::c_int; 2] = [0, 0];
23236        assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0);
23237        let (rfd, wfd) = (fds[0], fds[1]);
23238        let mut ops = options {
23239            ind: [0u8; MAX_OPS],
23240            args: Vec::new(),
23241            argscount: 0,
23242            argsalloc: 0,
23243        };
23244        // -u <wfd>
23245        ops.ind[b'u' as usize] = 1 | (1 << 2);
23246        ops.args = vec![wfd.to_string()];
23247        ops.argscount = 1;
23248        // -N (no arg)
23249        ops.ind[b'N' as usize] = 1;
23250
23251        let r = bin_print(
23252            "print",
23253            &["a".to_string(), "b".to_string(), "c".to_string()],
23254            &ops,
23255            BIN_PRINT,
23256        );
23257        assert_eq!(r, 0);
23258        unsafe { libc::close(wfd) };
23259
23260        let mut buf = Vec::new();
23261        unsafe {
23262            use std::os::unix::io::FromRawFd;
23263            let mut f = fs::File::from_raw_fd(rfd);
23264            f.read_to_end(&mut buf).unwrap();
23265        }
23266        assert_eq!(
23267            buf, b"a\0b\0c\0",
23268            "c:5126-5132 — -N: NUL separators + NUL terminator"
23269        );
23270    }
23271
23272    /// `Src/builtin.c:4815-4847` — `print -u FD` writes to the given
23273    /// file descriptor. Pin: write to a pipe via -u and read back.
23274    #[test]
23275    fn bin_print_writes_to_specified_fd() {
23276        let _g = crate::test_util::global_state_lock();
23277        use std::io::Read as _;
23278        // Open a pipe; print -u writes to write end, we read off read
23279        // end.
23280        let mut fds: [libc::c_int; 2] = [0, 0];
23281        assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0);
23282        let (rfd, wfd) = (fds[0], fds[1]);
23283
23284        // Build options with -u set to the write fd.
23285        let mut ops = options {
23286            ind: [0u8; MAX_OPS],
23287            args: Vec::new(),
23288            argscount: 0,
23289            argsalloc: 0,
23290        };
23291        ops.ind[b'u' as usize] = 1;
23292        ops.args = vec![wfd.to_string()];
23293        // OPT_ARG looks up via OPT_HASARG/argscount; the exact wiring
23294        // depends on the parseopts pre-call path. Use a minimal stub
23295        // so OPT_ARG('u') returns wfd's string.
23296        ops.argscount = 1;
23297
23298        // The OPT_ARG indexing path requires `ops.ind[b'u']` to encode
23299        // both the "is set" bit and an arg-index pointer. The default
23300        // parseopts wires this; for the unit test we synthesize the
23301        // wfd-as-string into args[0] AND set `ops.ind[b'u']` to point
23302        // at it via the same convention (`(ops.ind[c] >> 2) - 1`).
23303        ops.ind[b'u' as usize] = 1 | (1 << 2); // sense=1, arg_index=1 → args[0]
23304
23305        // Closing wfd in the caller after print so reader sees EOF.
23306        // We dup'd inside bin_print so closing wfd here is safe AFTER
23307        // bin_print returns.
23308        let r = bin_print("print", &["hello".to_string()], &ops, BIN_PRINT);
23309        assert_eq!(r, 0, "c:4847 — bin_print should return 0 on success");
23310        unsafe { libc::close(wfd) };
23311
23312        // Read from rfd.
23313        let mut buf = String::new();
23314        unsafe {
23315            use std::os::unix::io::FromRawFd;
23316            let mut f = fs::File::from_raw_fd(rfd);
23317            f.read_to_string(&mut buf).unwrap();
23318        }
23319        assert_eq!(buf, "hello\n", "c:4847 — write should land on -u FD");
23320    }
23321
23322    // ═══════════════════════════════════════════════════════════════════
23323    // fixdir — pure path-normalization helper (port of c:1297-1395).
23324    // Tests pin C-faithful collapsing of `.`, `..`, double slashes, and
23325    // sticky-`..` semantics for relative paths.
23326    // ═══════════════════════════════════════════════════════════════════
23327
23328    /// Empty input → empty output.
23329    #[test]
23330    fn fixdir_empty_returns_empty() {
23331        assert_eq!(fixdir_of("").0, "");
23332    }
23333
23334    /// Root passes through.
23335    #[test]
23336    fn fixdir_root_passes_through() {
23337        assert_eq!(fixdir_of("/").0, "/");
23338    }
23339
23340    /// `/.` → `/` (drop `.`).
23341    #[test]
23342    fn fixdir_root_dot_collapses_to_root() {
23343        assert_eq!(fixdir_of("/.").0, "/");
23344    }
23345
23346    /// `/a/./b` → `/a/b` (drop intermediate `.`).
23347    #[test]
23348    fn fixdir_strips_dot_components() {
23349        assert_eq!(fixdir_of("/a/./b").0, "/a/b");
23350    }
23351
23352    /// `/a/b/..` → `/a` (`..` pops).
23353    #[test]
23354    fn fixdir_dot_dot_pops_previous_component() {
23355        assert_eq!(fixdir_of("/usr/bin/.."), ("/usr".to_string(), 0));
23356        // c:1370-1374 — a missing directory before `..` stops the walk:
23357        // the rest stays as written and the result is 1.
23358        assert_eq!(fixdir_of("/no_such_dir_zshrs/b/.."), ("/no_such_dir_zshrs/b/..".to_string(), 1));
23359    }
23360
23361    /// `/a/b/../c` → `/a/c` (pop then append).
23362    #[test]
23363    fn fixdir_dot_dot_then_continue() {
23364        assert_eq!(fixdir_of("/usr/bin/../lib"), ("/usr/lib".to_string(), 0));
23365    }
23366
23367    /// `/..` → `/` (`..` past root silently drops).
23368    #[test]
23369    fn fixdir_dot_dot_past_root_drops() {
23370        assert_eq!(fixdir_of("/..").0, "/");
23371    }
23372
23373    /// `/../..` → `/` (multiple `..` past root all drop).
23374    #[test]
23375    fn fixdir_multiple_dot_dot_past_root_drops() {
23376        assert_eq!(fixdir_of("/../..").0, "/");
23377    }
23378
23379    /// `//a` → `/a` (collapse `//`).
23380    #[test]
23381    fn fixdir_collapses_double_slash() {
23382        assert_eq!(fixdir_of("//a").0, "/a");
23383    }
23384
23385    /// `/a//b///c` → `/a/b/c` (collapse runs of slashes).
23386    #[test]
23387    fn fixdir_collapses_repeated_slashes() {
23388        assert_eq!(fixdir_of("/a//b///c").0, "/a/b/c");
23389    }
23390
23391    // ── Relative paths ───────────────────────────────────────────────
23392    /// `a/b/c` → `a/b/c` (no change).
23393    #[test]
23394    fn fixdir_relative_no_dots_unchanged() {
23395        assert_eq!(fixdir_of("a/b/c").0, "a/b/c");
23396    }
23397
23398    /// `a/./b` → `a/b` (drop `.`).
23399    #[test]
23400    fn fixdir_relative_drops_dot() {
23401        assert_eq!(fixdir_of("a/./b").0, "a/b");
23402    }
23403
23404    /// `a/b/..` → `a` — `..` pops.
23405    #[test]
23406    fn fixdir_relative_dot_dot_pops() {
23407        // c:1360-1374 — relative `a/b` is stat()ed as written; it does not
23408        // exist here, so the path is left as written and the result is 1.
23409        assert_eq!(fixdir_of("no_such_rel_zshrs/b/.."), ("no_such_rel_zshrs/b/..".to_string(), 1));
23410    }
23411
23412    /// `..` (leading) → `..` — relative path keeps leading `..`.
23413    #[test]
23414    fn fixdir_leading_dot_dot_preserved_in_relative() {
23415        // c:1360 — nothing before a leading `..` to pop; it is skipped.
23416        assert_eq!(fixdir_of("..").0, "");
23417    }
23418
23419    /// `../..` (sticky `..`) — both preserved.
23420    #[test]
23421    fn fixdir_double_leading_dot_dot_both_preserved() {
23422        assert_eq!(fixdir_of("../..").0, "");
23423    }
23424
23425    /// `../foo/..` → `..` (pop `foo`, leading `..` remains).
23426    #[test]
23427    fn fixdir_dot_dot_then_dir_then_dot_dot() {
23428        // The leading `..` is skipped (c:1360); `foo` then fails stat()
23429        // (c:1370-1374), so `foo/..` stays as written.
23430        assert_eq!(fixdir_of("../no_such_rel_zshrs/.."), ("no_such_rel_zshrs/..".to_string(), 1));
23431    }
23432
23433    /// `.` alone → `.` (empty body → "." preserved for relative).
23434    #[test]
23435    fn fixdir_single_dot_returns_dot() {
23436        // c:1385-1387 then c:1343-1349 — the `.` section is skipped and
23437        // nothing is left.
23438        assert_eq!(fixdir_of("."), (String::new(), 0));
23439    }
23440
23441    /// Trailing slash dropped (output never has trailing `/`).
23442    #[test]
23443    fn fixdir_trailing_slash_dropped() {
23444        assert_eq!(fixdir_of("/a/b/").0, "/a/b");
23445        assert_eq!(fixdir_of("a/b/").0, "a/b");
23446    }
23447
23448    // ═══════════════════════════════════════════════════════════════════
23449    // cd_able_vars — CDABLEVARS option-gated lookup. Returns Some(val/tail)
23450    // if `s` head is a set parameter AND `cdablevars` is on. Else None.
23451    // ═══════════════════════════════════════════════════════════════════
23452
23453    use crate::ported::options::{opt_state_get, opt_state_set};
23454
23455    /// `cd_able_vars` returns None when CDABLEVARS option is OFF.
23456    #[test]
23457    fn cd_able_vars_returns_none_when_option_off() {
23458        let _g = crate::test_util::global_state_lock();
23459        let saved = opt_state_get("cdablevars").unwrap_or(false);
23460        opt_state_set("cdablevars", false);
23461        // Even if HOME is set, with cdablevars off cd_able_vars rejects.
23462        assert_eq!(cd_able_vars("HOME"), None);
23463        opt_state_set("cdablevars", saved);
23464    }
23465
23466    /// `cd_able_vars` looks up the named var when CDABLEVARS is ON.
23467    #[test]
23468    fn cd_able_vars_returns_value_when_option_on_and_var_set() {
23469        let _g = crate::test_util::global_state_lock();
23470        let saved_opt = opt_state_get("cdablevars").unwrap_or(false);
23471        opt_state_set("cdablevars", true);
23472        opt_state_set("exec", true);
23473        unsetparam("zshrs_cdav_proj");
23474        setsparam("zshrs_cdav_proj", "/tmp/myproject");
23475
23476        assert_eq!(
23477            cd_able_vars("zshrs_cdav_proj"),
23478            Some("/tmp/myproject".to_string())
23479        );
23480
23481        unsetparam("zshrs_cdav_proj");
23482        opt_state_set("cdablevars", saved_opt);
23483    }
23484
23485    /// With slash: head looked up, tail appended.
23486    /// `cd_able_vars("PROJ/src")` where PROJ=/home/user → "/home/user/src".
23487    #[test]
23488    fn cd_able_vars_appends_tail_after_head_substitution() {
23489        let _g = crate::test_util::global_state_lock();
23490        let saved_opt = opt_state_get("cdablevars").unwrap_or(false);
23491        opt_state_set("cdablevars", true);
23492        opt_state_set("exec", true);
23493        unsetparam("zshrs_cdav_PROJ");
23494        setsparam("zshrs_cdav_PROJ", "/home/user");
23495
23496        assert_eq!(
23497            cd_able_vars("zshrs_cdav_PROJ/src"),
23498            Some("/home/user/src".to_string())
23499        );
23500
23501        unsetparam("zshrs_cdav_PROJ");
23502        opt_state_set("cdablevars", saved_opt);
23503    }
23504
23505    /// Unknown head var → None (even with option on).
23506    #[test]
23507    fn cd_able_vars_unknown_var_returns_none() {
23508        let _g = crate::test_util::global_state_lock();
23509        let saved_opt = opt_state_get("cdablevars").unwrap_or(false);
23510        opt_state_set("cdablevars", true);
23511        unsetparam("zshrs_cdav_doesnt_exist");
23512
23513        assert_eq!(cd_able_vars("zshrs_cdav_doesnt_exist"), None);
23514
23515        opt_state_set("cdablevars", saved_opt);
23516    }
23517
23518    /// Empty head (e.g. "/some/path" starts with `/`) → None.
23519    #[test]
23520    fn cd_able_vars_empty_head_returns_none() {
23521        let _g = crate::test_util::global_state_lock();
23522        let saved_opt = opt_state_get("cdablevars").unwrap_or(false);
23523        opt_state_set("cdablevars", true);
23524        // Leading `/` → head split is empty.
23525        assert_eq!(cd_able_vars("/path/to/foo"), None);
23526        opt_state_set("cdablevars", saved_opt);
23527    }
23528
23529    // ─── zsh-corpus pins for fixed-return builtins ──────────────────
23530
23531    fn empty_opts_for_corpus() -> options {
23532        options {
23533            ind: [0u8; MAX_OPS],
23534            args: Vec::new(),
23535            argscount: 0,
23536            argsalloc: 0,
23537        }
23538    }
23539
23540    /// `Src/builtin.c:4550` — `bin_true` always returns 0 regardless of
23541    /// argv / opts / func. Matches `:` / `true` semantics.
23542    #[test]
23543    fn builtin_corpus_bin_true_always_zero() {
23544        let _g = crate::test_util::global_state_lock();
23545        let o = empty_opts_for_corpus();
23546        assert_eq!(bin_true("true", &[], &o, 0), 0, "bin_true no args = 0");
23547        assert_eq!(
23548            bin_true("true", &["x".into(), "y".into()], &o, 0),
23549            0,
23550            "bin_true with args = 0",
23551        );
23552    }
23553
23554    /// `Src/builtin.c:4559` — `bin_false` always returns 1.
23555    #[test]
23556    fn builtin_corpus_bin_false_always_one() {
23557        let _g = crate::test_util::global_state_lock();
23558        let o = empty_opts_for_corpus();
23559        assert_eq!(bin_false("false", &[], &o, 0), 1, "bin_false no args = 1");
23560        assert_eq!(
23561            bin_false("false", &["any".into()], &o, 0),
23562            1,
23563            "bin_false with args = 1",
23564        );
23565    }
23566
23567    /// `Src/builtin.c` — `bin_shift` with no positional params and no
23568    /// argv → 0 (zsh's POSIX-conforming "shift past end" semantics
23569    /// when no $@/argv to shift, plus no count argument).
23570    #[test]
23571    fn builtin_corpus_bin_shift_empty_positional_returns_zero_or_one() {
23572        let _g = crate::test_util::global_state_lock();
23573        let o = empty_opts_for_corpus();
23574        // No positional params, no arg → either zero (no-op) or one
23575        // (POSIX error). Both are acceptable per the spec language —
23576        // we pin only that it doesn't panic and returns 0 or 1.
23577        let r = bin_shift("shift", &[], &o, 0);
23578        assert!(r == 0 || r == 1, "shift on empty positional, got {r}");
23579    }
23580
23581    /// `Src/builtin.c` — `bin_let` with no math arg returns 1
23582    /// (no expression to evaluate).
23583    #[test]
23584    fn builtin_corpus_bin_let_no_args_returns_one() {
23585        let _g = crate::test_util::global_state_lock();
23586        let o = empty_opts_for_corpus();
23587        let r = bin_let("let", &[], &o, 0);
23588        assert_eq!(r, 1, "let with no args = 1");
23589    }
23590
23591    /// `bin_let "x=5"` evaluates math and assigns; success returns 0
23592    /// since the result (5) is non-zero. let returns 0 iff last expr
23593    /// was non-zero.
23594    #[test]
23595    fn builtin_corpus_bin_let_nonzero_expr_returns_zero() {
23596        let _g = crate::test_util::global_state_lock();
23597        let o = empty_opts_for_corpus();
23598        unsetparam("ZL_X");
23599        let r = bin_let("let", &["ZL_X=5".into()], &o, 0);
23600        assert_eq!(r, 0, "let 'x=5' assigns and returns 0 (nonzero result)");
23601        assert_eq!(getiparam("ZL_X"), 5);
23602        unsetparam("ZL_X");
23603    }
23604
23605    /// `bin_let "x=0"` returns 1 since last expression evaluates to 0.
23606    #[test]
23607    fn builtin_corpus_bin_let_zero_expr_returns_one() {
23608        let _g = crate::test_util::global_state_lock();
23609        let o = empty_opts_for_corpus();
23610        unsetparam("ZL_Y");
23611        let r = bin_let("let", &["ZL_Y=0".into()], &o, 0);
23612        assert_eq!(r, 1, "let 'x=0' returns 1 (zero result)");
23613        assert_eq!(getiparam("ZL_Y"), 0);
23614        unsetparam("ZL_Y");
23615    }
23616
23617    /// `bin_let` walks multiple expressions, exit status from last.
23618    #[test]
23619    fn builtin_corpus_bin_let_multi_expr_last_wins() {
23620        let _g = crate::test_util::global_state_lock();
23621        let o = empty_opts_for_corpus();
23622        unsetparam("ZL_A");
23623        unsetparam("ZL_B");
23624        let r = bin_let("let", &["ZL_A=1".into(), "ZL_B=7".into()], &o, 0);
23625        assert_eq!(r, 0, "last expr non-zero → 0");
23626        assert_eq!(getiparam("ZL_A"), 1);
23627        assert_eq!(getiparam("ZL_B"), 7);
23628        unsetparam("ZL_A");
23629        unsetparam("ZL_B");
23630    }
23631
23632    /// `bin_pwd` returns 0 on success — even without -P/-L, it should
23633    /// produce the current dir and return zero.
23634    #[test]
23635    fn builtin_corpus_bin_pwd_returns_zero() {
23636        let _g = crate::test_util::global_state_lock();
23637        let o = empty_opts_for_corpus();
23638        let r = bin_pwd("pwd", &[], &o, 0);
23639        assert_eq!(r, 0, "pwd returns 0 on success");
23640    }
23641
23642    // ═══════════════════════════════════════════════════════════════════
23643    // Additional C-parity tests for Src/builtin.c fixdir path normaliser.
23644    // ═══════════════════════════════════════════════════════════════════
23645
23646    /// c:1297 — `fixdir_of("").0` returns empty string.
23647    #[test]
23648    fn fixdir_empty_returns_empty_pin() {
23649        let _g = crate::test_util::global_state_lock();
23650        assert_eq!(fixdir_of("").0, "");
23651    }
23652
23653    /// c:1297 — `fixdir_of("/").0` returns "/".
23654    #[test]
23655    fn fixdir_root_returns_root() {
23656        let _g = crate::test_util::global_state_lock();
23657        assert_eq!(fixdir_of("/").0, "/", "root path preserved");
23658    }
23659
23660    /// c:1352 — `fixdir_of("/foo/./bar").0` collapses `.` to `/foo/bar`.
23661    #[test]
23662    fn fixdir_drops_dot_segments() {
23663        let _g = crate::test_util::global_state_lock();
23664        assert_eq!(fixdir_of("/foo/./bar").0, "/foo/bar");
23665        assert_eq!(fixdir_of("/./a").0, "/a");
23666        assert_eq!(fixdir_of("/a/.").0, "/a");
23667    }
23668
23669    /// c:1339 — `fixdir_of("/foo//bar").0` collapses `//` to `/foo/bar`.
23670    #[test]
23671    fn fixdir_collapses_double_slash_pin() {
23672        let _g = crate::test_util::global_state_lock();
23673        assert_eq!(fixdir_of("/foo//bar").0, "/foo/bar");
23674        assert_eq!(fixdir_of("//foo//bar//").0, "/foo/bar");
23675    }
23676
23677    /// c:1358-1372 — `fixdir_of("/foo/bar/..").0` pops via `..` → `/foo`.
23678    #[test]
23679    fn fixdir_pops_via_dotdot() {
23680        let _g = crate::test_util::global_state_lock();
23681        assert_eq!(fixdir_of("/usr/bin/..").0, "/usr");
23682        assert_eq!(fixdir_of("/usr/lib/../bin/..").0, "/usr");
23683        assert_eq!(fixdir_of("/usr/bin/../lib").0, "/usr/lib");
23684    }
23685
23686    /// c:1358-1372 — `fixdir_of("/..").0` past root → `/` (absolute paths
23687    /// silently drop `..` past `/`).
23688    #[test]
23689    fn fixdir_dotdot_past_root_clamps_to_root_pin() {
23690        let _g = crate::test_util::global_state_lock();
23691        assert_eq!(fixdir_of("/..").0, "/", "absolute `..` past root → `/`");
23692        assert_eq!(fixdir_of("/../..").0, "/");
23693        assert_eq!(fixdir_of("/usr/../../..").0, "/", "successive pops past root → /");
23694    }
23695
23696    /// c:1358-1372 — `fixdir_of("../foo").0` on relative path KEEPS leading `..`.
23697    #[test]
23698    fn fixdir_relative_keeps_leading_dotdot_pin() {
23699        let _g = crate::test_util::global_state_lock();
23700        // c:1360 — leading `..` sections have nothing to pop and are skipped.
23701        assert_eq!(fixdir_of("../foo").0, "foo");
23702        assert_eq!(fixdir_of("../../foo").0, "foo");
23703    }
23704
23705    /// c:1297-1395 — `fixdir` is idempotent: fixdir(fixdir(x)) == fixdir(x).
23706    #[test]
23707    fn fixdir_is_idempotent() {
23708        let _g = crate::test_util::global_state_lock();
23709        for input in &[
23710            "/a/b/c",
23711            "/a/./b",
23712            "/a/b/..",
23713            "/../x",
23714            "../foo",
23715            "/foo//bar",
23716        ] {
23717            let once = fixdir_of(input).0;
23718            let twice = fixdir_of(&once).0;
23719            assert_eq!(once, twice, "fixdir must be idempotent on {:?}", input);
23720        }
23721    }
23722
23723    /// c:1297 — relative path of just `.` → `.` (not empty).
23724    #[test]
23725    fn fixdir_relative_dot_returns_dot() {
23726        let _g = crate::test_util::global_state_lock();
23727        assert_eq!(fixdir_of(".").0, "", "c:1385-1387 — a lone `.` section is skipped");
23728    }
23729
23730    /// c:1297 — `fixdir_of("foo").0` (plain relative) is identity.
23731    #[test]
23732    fn fixdir_plain_relative_is_identity() {
23733        let _g = crate::test_util::global_state_lock();
23734        assert_eq!(fixdir_of("foo").0, "foo");
23735        assert_eq!(fixdir_of("foo/bar").0, "foo/bar");
23736    }
23737
23738    // ═══════════════════════════════════════════════════════════════════
23739    // Additional C-parity tests for Src/builtin.c bin_true/false +
23740    // bin_break + other simple builtins.
23741    // ═══════════════════════════════════════════════════════════════════
23742
23743    /// c:4550 — `bin_true` always returns 0 regardless of args.
23744    #[test]
23745    fn bin_true_always_returns_zero() {
23746        let _g = crate::test_util::global_state_lock();
23747        let ops = empty_opts_for_corpus();
23748        assert_eq!(bin_true("true", &[], &ops, 0), 0);
23749        assert_eq!(
23750            bin_true("true", &["arg1".into(), "arg2".into()], &ops, 0),
23751            0
23752        );
23753    }
23754
23755    /// c:4559 — `bin_false` always returns 1 regardless of args.
23756    #[test]
23757    fn bin_false_always_returns_one() {
23758        let _g = crate::test_util::global_state_lock();
23759        let ops = empty_opts_for_corpus();
23760        assert_eq!(bin_false("false", &[], &ops, 0), 1);
23761        assert_eq!(bin_false("false", &["a".into()], &ops, 0), 1);
23762    }
23763
23764    /// c:4550 — `bin_true` ignores name arg.
23765    #[test]
23766    fn bin_true_ignores_name() {
23767        let _g = crate::test_util::global_state_lock();
23768        let ops = empty_opts_for_corpus();
23769        // Any name returns 0 (name is unused per UNUSED(char *name)).
23770        assert_eq!(bin_true("anything", &[], &ops, 0), 0);
23771        assert_eq!(bin_true("", &[], &ops, 0), 0);
23772    }
23773
23774    /// c:4559 — `bin_false` ignores name arg.
23775    #[test]
23776    fn bin_false_ignores_name() {
23777        let _g = crate::test_util::global_state_lock();
23778        let ops = empty_opts_for_corpus();
23779        assert_eq!(bin_false("anything", &[], &ops, 0), 1);
23780        assert_eq!(bin_false("", &[], &ops, 0), 1);
23781    }
23782
23783    /// `bin_true`/`bin_false` are deterministic (pure functions).
23784    #[test]
23785    fn bin_true_false_deterministic() {
23786        let _g = crate::test_util::global_state_lock();
23787        let ops = empty_opts_for_corpus();
23788        for _ in 0..10 {
23789            assert_eq!(bin_true("true", &[], &ops, 0), 0);
23790            assert_eq!(bin_false("false", &[], &ops, 0), 1);
23791        }
23792    }
23793
23794    /// c:7872 — `realexit` would terminate process; pin that we DON'T
23795    /// accidentally call it. (no-op test — just documents the contract.)
23796    /// `_realexit` similarly forbidden in tests.
23797    #[test]
23798    fn realexit_contract_pin_no_test_actually_calls_it() {
23799        // This test exists to document that realexit / _realexit are
23800        // never called by test code (would terminate the test process).
23801        // No assertions — pin via not calling.
23802    }
23803
23804    // ═══════════════════════════════════════════════════════════════════
23805    // Additional C-parity tests for Src/builtin.c
23806    // c:131 createbuiltintable / c:232 init_builtins / c:1837 cd_able_vars
23807    // c:1999 fixdir / c:2490 fcgetcomm / c:2526 fcsubs / c:209 freebuiltinnode
23808    // ═══════════════════════════════════════════════════════════════════
23809
23810    /// c:131 — `createbuiltintable` returns &HashMap (compile-time pin).
23811    #[test]
23812    fn createbuiltintable_returns_hashmap_ref_type() {
23813        let _: &HashMap<String, &builtin> = createbuiltintable();
23814    }
23815
23816    /// c:131 — `createbuiltintable` is idempotent (returns same ref).
23817    #[test]
23818    fn createbuiltintable_returns_same_ref() {
23819        let a = createbuiltintable() as *const _;
23820        let b = createbuiltintable() as *const _;
23821        assert_eq!(a, b, "createbuiltintable must return same singleton");
23822    }
23823
23824    /// c:131 — table contains common shell builtins (sanity sweep).
23825    #[test]
23826    fn createbuiltintable_contains_canonical_builtins() {
23827        let t = createbuiltintable();
23828        for name in ["true", "false", "set", "cd", "exit", "echo"] {
23829            assert!(
23830                t.contains_key(name),
23831                "builtin table must contain {:?}",
23832                name
23833            );
23834        }
23835    }
23836
23837    /// c:232 — `init_builtins` is idempotent.
23838    #[test]
23839    fn init_builtins_idempotent() {
23840        let _g = crate::test_util::global_state_lock();
23841        for _ in 0..5 {
23842            init_builtins();
23843        }
23844    }
23845
23846    /// c:1837 — `cd_able_vars("")` empty returns None.
23847    #[test]
23848    fn cd_able_vars_empty_returns_none() {
23849        let _g = crate::test_util::global_state_lock();
23850        assert!(cd_able_vars("").is_none(), "empty → None");
23851    }
23852
23853    /// c:1837 — `cd_able_vars` returns Option<String> (type pin).
23854    #[test]
23855    fn cd_able_vars_returns_option_string_type() {
23856        let _g = crate::test_util::global_state_lock();
23857        let _: Option<String> = cd_able_vars("anything");
23858    }
23859
23860    /// c:1999 — `fixdir_of("").0` empty returns String (type pin).
23861    #[test]
23862    fn fixdir_empty_returns_string_type() {
23863        let _: String = fixdir_of("").0;
23864    }
23865
23866    /// c:1999 — `fixdir` is pure.
23867    #[test]
23868    fn fixdir_is_pure() {
23869        for s in ["", "/abs", "rel", "./dot", "../parent", "a/b/c"] {
23870            let first = fixdir_of(s).0;
23871            for _ in 0..3 {
23872                assert_eq!(fixdir_of(s).0, first, "fixdir_of({:?}).0 must be pure", s);
23873            }
23874        }
23875    }
23876
23877    /// c:2490 — `fcgetcomm("")` empty returns i64 (type pin).
23878    #[test]
23879    fn fcgetcomm_empty_returns_i64_type() {
23880        let _g = crate::test_util::global_state_lock();
23881        let _: i64 = fcgetcomm("");
23882    }
23883
23884    /// c:2490 — `fcgetcomm` is deterministic for invalid input.
23885    #[test]
23886    fn fcgetcomm_invalid_is_deterministic() {
23887        let _g = crate::test_util::global_state_lock();
23888        for s in ["", "garbage", "not_a_number"] {
23889            let first = fcgetcomm(s);
23890            for _ in 0..3 {
23891                assert_eq!(
23892                    fcgetcomm(s),
23893                    first,
23894                    "fcgetcomm({:?}) must be deterministic",
23895                    s
23896                );
23897            }
23898        }
23899    }
23900
23901    /// c:2526 — `fcsubs(&mut s, &[])` empty sub list is no-op.
23902    #[test]
23903    fn fcsubs_empty_subs_returns_zero() {
23904        let mut s = "hello".to_string();
23905        let r = fcsubs(&mut s, &[]);
23906        assert_eq!(r, 0, "empty subs → 0 (no substitutions)");
23907        assert_eq!(s, "hello", "string unchanged");
23908    }
23909
23910    // ═══════════════════════════════════════════════════════════════════
23911    // Additional C-parity tests for Src/builtin.c
23912    // c:1324 bin_pwd / c:6810 bin_true / c:6823 bin_false / c:7247 bin_shift /
23913    // c:8444 bin_eval / c:8922 bin_test / c:9012 bin_times / c:9207 bin_ttyctl /
23914    // c:9295 bin_umask / c:9240 bin_let — type pins + edge cases
23915    // ═══════════════════════════════════════════════════════════════════
23916
23917    /// c:1324 — `bin_pwd` returns i32 (compile-time type pin).
23918    #[test]
23919    fn bin_pwd_returns_i32_type() {
23920        let _g = crate::test_util::global_state_lock();
23921        let o = empty_opts_for_corpus();
23922        let _: i32 = bin_pwd("pwd", &[], &o, 0);
23923    }
23924
23925    /// c:6810 — `bin_true` returns i32.
23926    #[test]
23927    fn bin_true_returns_i32_type() {
23928        let _g = crate::test_util::global_state_lock();
23929        let o = empty_opts_for_corpus();
23930        let _: i32 = bin_true("true", &[], &o, 0);
23931    }
23932
23933    /// c:6823 — `bin_false` returns i32.
23934    #[test]
23935    fn bin_false_returns_i32_type() {
23936        let _g = crate::test_util::global_state_lock();
23937        let o = empty_opts_for_corpus();
23938        let _: i32 = bin_false("false", &[], &o, 0);
23939    }
23940
23941    /// c:9240 — `bin_let` returns i32.
23942    #[test]
23943    fn bin_let_returns_i32_type() {
23944        let _g = crate::test_util::global_state_lock();
23945        let o = empty_opts_for_corpus();
23946        let _: i32 = bin_let("let", &[], &o, 0);
23947    }
23948
23949    /// c:7247 — `bin_shift` returns i32.
23950    #[test]
23951    fn bin_shift_returns_i32_type() {
23952        let _g = crate::test_util::global_state_lock();
23953        let o = empty_opts_for_corpus();
23954        let _: i32 = bin_shift("shift", &[], &o, 0);
23955    }
23956
23957    /// c:6810 + c:6823 — `bin_true` and `bin_false` are pure (always
23958    /// same value regardless of state).
23959    #[test]
23960    fn bin_true_false_are_pure() {
23961        let _g = crate::test_util::global_state_lock();
23962        let o = empty_opts_for_corpus();
23963        for _ in 0..5 {
23964            assert_eq!(bin_true("true", &[], &o, 0), 0);
23965            assert_eq!(bin_false("false", &[], &o, 0), 1);
23966        }
23967    }
23968
23969    /// c:1324 — `bin_pwd` exit code in u8 range.
23970    #[test]
23971    fn bin_pwd_return_in_u8_range() {
23972        let _g = crate::test_util::global_state_lock();
23973        let o = empty_opts_for_corpus();
23974        let r = bin_pwd("pwd", &[], &o, 0);
23975        assert!(
23976            (0..256).contains(&r),
23977            "bin_pwd exit code must fit u8, got {}",
23978            r
23979        );
23980    }
23981
23982    /// c:7247 — `bin_shift` with no args + empty positional returns
23983    /// in 0/1 range (POSIX-conforming).
23984    #[test]
23985    fn bin_shift_no_args_in_zero_one_range() {
23986        let _g = crate::test_util::global_state_lock();
23987        let o = empty_opts_for_corpus();
23988        let r = bin_shift("shift", &[], &o, 0);
23989        assert!(r == 0 || r == 1, "shift no args ∈ {{0,1}}, got {}", r);
23990    }
23991
23992    /// c:9240 — `bin_let` is deterministic for no-args.
23993    #[test]
23994    fn bin_let_no_args_is_deterministic() {
23995        let _g = crate::test_util::global_state_lock();
23996        let o = empty_opts_for_corpus();
23997        let first = bin_let("let", &[], &o, 0);
23998        for _ in 0..3 {
23999            assert_eq!(
24000                bin_let("let", &[], &o, 0),
24001                first,
24002                "bin_let no-args must be deterministic"
24003            );
24004        }
24005    }
24006
24007    /// c:8444 — `bin_eval(&[])` empty args returns i32 (type pin).
24008    #[test]
24009    fn bin_eval_returns_i32_type() {
24010        let _g = crate::test_util::global_state_lock();
24011        let o = empty_opts_for_corpus();
24012        let _: i32 = bin_eval("eval", &[], &o, 0);
24013    }
24014
24015    /// c:6810 — `bin_true` ignores everything (args, opts, func).
24016    #[test]
24017    fn bin_true_full_argument_immunity() {
24018        let _g = crate::test_util::global_state_lock();
24019        let o = empty_opts_for_corpus();
24020        // Various permutations all return 0.
24021        assert_eq!(bin_true("true", &[], &o, 0), 0);
24022        assert_eq!(bin_true("anything", &[], &o, 0), 0);
24023        assert_eq!(bin_true("true", &["unused".into()], &o, 99), 0);
24024        assert_eq!(
24025            bin_true("", &["a".into(), "b".into(), "c".into()], &o, -1),
24026            0
24027        );
24028    }
24029
24030    /// c:6823 — `bin_false` ignores everything (args, opts, func).
24031    #[test]
24032    fn bin_false_full_argument_immunity() {
24033        let _g = crate::test_util::global_state_lock();
24034        let o = empty_opts_for_corpus();
24035        assert_eq!(bin_false("false", &[], &o, 0), 1);
24036        assert_eq!(bin_false("anything", &[], &o, 0), 1);
24037        assert_eq!(bin_false("false", &["unused".into()], &o, 99), 1);
24038        assert_eq!(
24039            bin_false("", &["a".into(), "b".into(), "c".into()], &o, -1),
24040            1
24041        );
24042    }
24043}