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, isset, mathfunc, nameddir, options, param,
80    shfunc, HandlerFunc, Meta, ALIAS_GLOBAL, ALIAS_SUFFIX, ASG_ARRAY, ASG_ARRAYP, ASG_KEY_VALUE,
81    ASG_VALUEP, AUTOPUSHD, BINF_ADDED, BINF_ASSIGN, BINF_BUILTIN, BINF_COMMAND, BINF_DASH,
82    BINF_DASHDASHVALID, BINF_EXEC, BINF_HANDLES_OPTS, BINF_KEEPNUM, BINF_MAGICEQUALS, BINF_NOGLOB,
83    BINF_PLUSOPTS, BINF_PREFIX, BINF_PRINTOPTS, BINF_PSPECIAL, BINF_SKIPDASH, BINF_SKIPINVALID,
84    BSDECHO, CDABLEVARS, CHASELINKS, CHECKRUNNINGJOBS, DISABLED, EMULATE_CSH, EMULATE_KSH,
85    EMULATE_SH, EMULATE_ZSH, EMULATION, ERRFLAG_ERROR, FS_FUNC, FUNCTIONARGZERO, GLOBALEXPORT,
86    GLOBALRCS,
87    interact, islogin, HASHED, HFILE_APPEND, HFILE_NO_REWRITE, HFILE_SKIPOLD, HFILE_USE_OPTIONS,
88    HIST_FOREIGN, INTERACTIVE, KSHARRAYS,
89    LOGINSHELL, MAX_OPS, MFF_STR, MFF_USERFUNC, MONITOR, NULLBINCMD, OPT_ARG, OPT_HASARG,
90    OPT_ISSET, OPT_MINUS, OPT_PLUS, PATHDIRS, PAT_HEAPDUP, PAT_STATIC, PM_ABSPATH_USED, PM_ARRAY,
91    PM_AUTOLOAD, PM_CUR_FPATH, PM_DECLARED, PM_DEFAULTED, PM_EFLOAT, PM_EXPORTED, PM_FFLOAT,
92    PM_HASHED, PM_HIDE, PM_HIDEVAL, PM_INTEGER, PM_KSHSTORED, PM_LEFT, PM_LOADDIR, PM_LOCAL,
93    PM_LOWER, PM_NAMEREF, PM_READONLY, PM_RIGHT_B, PM_RIGHT_Z, PM_RO_BY_DESIGN, PM_SCALAR,
94    PM_SPECIAL, PM_TAGGED, PM_TAGGED_LOCAL, PM_TIED, PM_TYPE, PM_UNALIASED, PM_UNDEFINED,
95    PM_UNIQUE, PM_UNSET, PM_UPPER, PM_WARNNESTED, PM_ZSHSTORED, POSIXBUILTINS, POSIXCD, POSIXTRAPS,
96    PRINT_INCLUDEVALUE, PRINT_LINE, PRINT_LIST, PRINT_NAMEONLY, PRINT_POSIX_EXPORT,
97    PRINT_POSIX_READONLY, PRINT_TYPE, PRINT_TYPESET, PRINT_WHENCE_CSH, PRINT_WHENCE_FUNCDEF,
98    PRINT_WHENCE_SIMPLE, PRINT_WHENCE_VERBOSE, PRINT_WHENCE_WORD, PRINT_WITH_NAMESPACE,
99    PUSHDIGNOREDUPS, PUSHDMINUS, PUSHDSILENT, PUSHDTOHOME, RCQUOTES, RCS, SHINSTDIN,
100    SORTIT_BACKWARDS,
101    SORTIT_IGNORING_CASE, STAT_LOCKED, STAT_NOPRINT, STAT_STOPPED, TRAP_STATE_FORCE_RETURN,
102    TRAP_STATE_PRIMED, TYPESETSILENT, TYPESET_OPTSTR, VERBOSE, XTRACE, ZEXIT_DEFERRED,
103    ZEXIT_NORMAL, ZEXIT_SIGNAL, ZSIG_FUNC,
104};
105#[allow(unused_imports)]
106use crate::zwc::ZwcFile;
107
108// ---------------------------------------------------------------------------
109// BIN_* dispatch IDs.
110// Direct port of `Src/hashtable.h:34-70`. These are the integer
111// discriminators handlers use when one C function backs multiple
112// builtin names (e.g. `bin_fg` covers fg/bg/jobs/wait/disown).
113// ---------------------------------------------------------------------------
114
115// BIN_* constants moved to `crate::ported::hashtable_h` per the C
116// header layout (Src/hashtable.h:34-70). Re-exported here so existing
117// `crate::ported::builtin::BIN_X` paths keep resolving.
118pub use crate::ported::hashtable_h::{
119    BIN_BG, BIN_BRACKET, BIN_BREAK, BIN_CD, BIN_COMMAND, BIN_CONTINUE, BIN_DISABLE, BIN_DISOWN,
120    BIN_ECHO, BIN_ENABLE, BIN_EVAL, BIN_EXIT, BIN_EXPORT, BIN_FC, BIN_FG, BIN_JOBS, BIN_LOGOUT,
121    BIN_POPD, BIN_PRINT, BIN_PRINTF, BIN_PUSHD, BIN_PUSHLINE, BIN_R, BIN_READONLY, BIN_RETURN,
122    BIN_SCHED, BIN_SETOPT, BIN_TEST, BIN_TYPESET, BIN_UNALIAS, BIN_UNFUNCTION, BIN_UNHASH,
123    BIN_UNSET, BIN_UNSETOPT, BIN_WAIT,
124};
125
126/// Construct the builtin lookup table.
127/// Port of `createbuiltintable()` from `Src/builtin.c:150`. The C
128/// version installs the hashtable function pointers (hash, addnode,
129/// printnode, etc.) and then calls `addbuiltins("zsh", builtins, ..)`.
130/// Here we just materialise the static `BUILTINS` slice into a
131/// `HashMap<String, &builtin>` — Rust's standard hashing replaces the
132/// C `hasher` callback and the `HashMap` itself replaces all the
133/// per-table function pointers (`addnode`/`getnode`/`removenode`/...).
134// Builtin Command Hash Table Functions                                      // c:150
135/// `createbuiltintable` — see implementation.
136pub fn createbuiltintable() -> &'static HashMap<String, &'static builtin> {
137    // c:150
138    builtintab.get_or_init(|| {
139        let table: &'static Vec<builtin> = &*BUILTINS;
140        let watch_bintab: &'static Vec<builtin> = &*crate::ported::modules::watch::bintab;
141        let mut m: HashMap<String, &'static builtin> =
142            HashMap::with_capacity(table.len() + watch_bintab.len());
143        for b in table.iter() {
144            m.insert(b.node.nam.clone(), b);
145        }
146        // zshrs auto-loads all modules at startup. Fold each module's
147        // bintab into the core builtintab so `disable <name>` (and
148        // dispatch generally) finds module-provided builtins without
149        // an explicit `zmodload` step. Mirrors C's `addbuiltins(name,
150        // bintab, sizeof(bintab)/sizeof(*bintab))` call from each
151        // module's `boot_` hook (e.g. `Src/Modules/watch.c:694`).
152        for b in watch_bintab.iter() {
153            m.insert(b.node.nam.clone(), b);
154        }
155        // zshrs extension: fold `znative` (the plugin package manager,
156        // src/extensions/pkg) into the builtin table so it is a first-class
157        // builtin — `whence -w znative` reports `builtin`, `builtin znative` works,
158        // and it dispatches through execbuiltin like any other. No C
159        // counterpart (znative is zshrs-original).
160        let pkg_bintab: &'static Vec<builtin> = &*crate::pkg::builtin::bintab;
161        for b in pkg_bintab.iter() {
162            m.insert(b.node.nam.clone(), b);
163        }
164        // zshrs extension: fold the ztest/zassert unit-test framework
165        // (src/extensions/ztest.rs) into the builtin table so `zassert_eq`,
166        // `ztest_run`, … are first-class builtins in `zshrs -f` (visible to
167        // `whence -w`, `builtin`, `disable`, completion) — not just the
168        // fusevm command-dispatch arm. No C counterpart (zshrs-original).
169        let ztest_bintab: &'static Vec<builtin> = &*crate::extensions::ztest::bintab;
170        for b in ztest_bintab.iter() {
171            m.insert(b.node.nam.clone(), b);
172        }
173        m
174    })
175}
176
177// ===========================================================
178// Direct ports of static builtin helpers from Src/builtin.c not
179// yet covered above. The Rust executor wires builtins through
180// `crate::ported::builtins::*` per-builtin modules; these free-
181// fn entries satisfy ABI/name parity for the drift gate.
182// ===========================================================
183
184/// Port of `printbuiltinnode(HashNode hn, int printflags)` from
185/// `Src/builtin.c:174`.
186///
187/// C body (c:174-194):
188/// ```c
189/// Builtin bn = (Builtin) hn;
190/// if (printflags & PRINT_WHENCE_WORD) {
191///     printf("%s: builtin\n", bn->node.nam); return;
192/// }
193/// if (printflags & PRINT_WHENCE_CSH) {
194///     printf("%s: shell built-in command\n", bn->node.nam); return;
195/// }
196/// if (printflags & PRINT_WHENCE_VERBOSE) {
197///     printf("%s is a shell builtin\n", bn->node.nam); return;
198/// }
199/// /* default is name only */
200/// printf("%s\n", bn->node.nam);
201/// ```
202pub fn printbuiltinnode(
203    hn: *mut hashnode, // c:174
204    printflags: i32,
205) {
206    if hn.is_null() {
207        return;
208    }
209    let bn = unsafe { &*hn }; // c:176
210    if (printflags & PRINT_WHENCE_WORD as i32) != 0 {
211        // c:178
212        println!("{}: builtin", bn.nam); // c:179
213        return; // c:180
214    }
215    if (printflags & PRINT_WHENCE_CSH as i32) != 0 {
216        // c:183
217        println!("{}: shell built-in command", bn.nam); // c:184
218        return; // c:185
219    }
220    if (printflags & PRINT_WHENCE_VERBOSE as i32) != 0 {
221        // c:188
222        println!("{} is a shell builtin", bn.nam); // c:189
223        return; // c:190
224    }
225    // c:193 — `/* default is name only */`
226    println!("{}", bn.nam); // c:194
227}
228
229/// Port of `freebuiltinnode(HashNode hn)` from Src/builtin.c:199.
230/// C: `static void freebuiltinnode(HashNode hn)` — free a builtin-table
231///   node only when BINF_ADDED is clear (i.e., dynamically added).
232pub fn freebuiltinnode(hn: *mut hashnode) {
233    // c:199
234    if hn.is_null() {
235        return;
236    }
237    let bn = unsafe { &*hn };
238    // c:204 — `if (!(bn->node.flags & BINF_ADDED))` then free.
239    if (bn.flags as u32 & BINF_ADDED) == 0 { // c:204
240         // Rust drop handles the actual free; nothing more to do.
241    }
242}
243
244/// Port of `init_builtins()` from Src/builtin.c:212.
245/// C: `void init_builtins(void)` — when not in EMULATE_ZSH, disable
246///   the `repeat` reserved word (compat for sh/ksh).
247///
248/// ```c
249/// if (!EMULATION(EMULATE_ZSH)) {
250///     HashNode hn = reswdtab->getnode2(reswdtab, "repeat");
251///     if (hn)
252///         reswdtab->disablenode(hn, 0);
253/// }
254/// ```
255pub fn init_builtins() {
256    // c:212
257    // c:214 — `if (!EMULATION(EMULATE_ZSH))`. EMULATION reads the
258    // canonical `emulation` global directly per zsh.h:2347.
259    if !EMULATION(EMULATE_ZSH) {
260        // c:214
261        // c:215-217 — `hn = reswdtab->getnode2(reswdtab,"repeat");
262        //              if (hn) reswdtab->disablenode(hn, 0);`
263        if let Ok(mut tab) = reswdtab_lock().write() {
264            tab.disable("repeat");
265        }
266    }
267}
268
269/// Port of `OPT_ALLOC_CHUNK` from `Src/builtin.c:227`. Number of
270/// `ops->args[]` slots `new_optarg()` grows the array by when full.
271pub const OPT_ALLOC_CHUNK: i32 = 16; // c:227
272
273/// Port of `new_optarg(Options ops)` from Src/builtin.c:227.
274/// C: `static int new_optarg(Options ops)` — grow the `ops->args[]`
275///   array by `OPT_ALLOC_CHUNK` slots when full. Returns 1 on overflow
276///   (>=63 args), 0 on success.
277pub fn new_optarg(ops: &mut options) -> i32 {
278    // c:227
279    // c:227 — `if (ops->argscount == 63) return 1;`
280    if ops.argscount == 63 {
281        // c:231
282        return 1;
283    }
284    // c:232-241 — grow ops->args by OPT_ALLOC_CHUNK if argsalloc == argscount.
285    if ops.argsalloc == ops.argscount {
286        // c:232
287        ops.args
288            .resize((ops.argsalloc + OPT_ALLOC_CHUNK) as usize, String::new());
289        ops.argsalloc += OPT_ALLOC_CHUNK; // c:240
290    }
291    ops.argscount += 1; // c:243
292    0 // c:244
293}
294
295// ===========================================================
296// ksh_autoload_body moved from src/ported/vm_helper.
297// Mirrors the ksh-style autoload helper in Src/builtin.c
298// (bin_functions / load_function_def).
299// ===========================================================
300// (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.)
301
302bitflags::bitflags! {
303    /// Flags for autoloaded functions (autoload builtin -- Src/builtin.c bin_autoload).
304    #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
305    pub struct AutoloadFlags: u32 {
306        const NO_ALIAS = 0b00000001;      // -U: don't expand aliases
307        const ZSH_STYLE = 0b00000010;     // -z: zsh-style autoload
308        const KSH_STYLE = 0b00000100;     // -k: ksh-style autoload
309        const TRACE = 0b00001000;         // -t: trace execution
310        const USE_CALLER_DIR = 0b00010000; // -d: use calling function's dir
311        const LOADED = 0b00100000;        // function has been loaded
312    }
313}
314
315/// Port of `execbuiltin(LinkList args, LinkList assigns, Builtin bn)` from Src/builtin.c:250.
316///
317/// C: `int execbuiltin(LinkList args, LinkList assigns, Builtin bn)` —
318///   execute a builtin handler function after parsing the arguments.
319///
320/// Walks `bn->optstr` against `args`, populating `ops.ind[c]` (`|= 1`
321/// for `-X`, `|= 2` for `+X`, `<< 2` arg-index for opts taking args
322/// per the `:`/`::`/`:%` suffix convention), then calls
323/// `bn->handlerfunc(name, argv, &ops, bn->funcid)`.
324///
325/// Signature note: C consumes the name via `ugetnode(args)` first
326/// (c:262); the Rust port receives `args` without the name and reads
327/// `bn->node.nam` directly. C's `LinkList assigns` ports to
328/// `Vec<asgment>` (closer to the C type than the earlier
329/// `Vec<(String, String)>` pair-tuple). `assignfunc` handler dispatch
330/// (c:495-502) — BINF_ASSIGN builtins taking two argument lists —
331/// isn't ported (no Rust-side caller passes a non-empty `assigns`),
332/// so XTRACE prints the structure but BINF_ASSIGN dispatch falls
333/// through to the plain handler.
334pub fn execbuiltin(
335    args: Vec<String>,
336    assigns: Vec<asgment>, // c:250
337    bn: *mut builtin,
338) -> i32 {
339    if bn.is_null() {
340        return 1;
341    }
342    let bn_ref = unsafe { &*bn };
343
344    // c:252-254 — locals.
345    let pp: Option<&str>; // c:252 char *pp
346    let name: String; // c:252 char *name
347    let mut optstr: Option<String>; // c:252 char *optstr
348    let mut flags: i32; // c:253 int flags
349    let mut argc: i32; // c:253 int argc
350    let mut execop: u8; // c:253 int execop
351    let xtr: bool = isset(XTRACE); // c:253 int xtr = isset(XTRACE)
352
353    // c:256-259 — `memset(ops.ind, 0, ...); ops.args = NULL; ops.argscount=ops.argsalloc=0;`
354    let mut ops = options {
355        ind: [0u8; MAX_OPS],
356        args: Vec::new(), // c:257
357        argscount: 0,
358        argsalloc: 0,
359    }; // c:258-259
360
361    // c:262 — `name = (char *) ugetnode(args);` — Rust reads bn.node.nam.
362    name = bn_ref.node.nam.clone(); // c:262
363
364    // c:264-268 — `if (!bn->handlerfunc)` early-exit.
365    if bn_ref.handlerfunc.is_none() {
366        // c:264
367        // c:265 — DPUTS(1, "Missing builtin detected too late")
368        DPUTS!(true, "Missing builtin detected too late"); // c:265
369                                                           // c:266 — deletebuiltin(bn->node.nam) — not yet ported here.
370        return 1; // c:267
371    }
372
373    // c:270-271 — `flags = bn->node.flags; optstr = bn->optstr;`
374    flags = bn_ref.node.flags; // c:270
375    optstr = bn_ref.optstr.clone(); // c:271
376
377    // c:275 — `argc = countlinknodes(args);` — total argv length.
378    argc = args.len() as i32; // c:275
379
380    // c:284-293 — `VARARR(char *, argarr, argc+1)` + copy args into argarr.
381    let argarr: Vec<String> = args; // c:284 argarr[]
382    let mut argv: usize = 0; // c:285 char **argv = argarr;
383
384    // c:296-411 — option parser body.
385    if let Some(ref os) = optstr.clone() {
386        // c:296
387        let optstr_local = os.clone();
388        let mut optstr_bytes: Vec<u8> = optstr_local.into_bytes();
389        let mut skipinvalid = (flags & BINF_SKIPINVALID as i32) != 0;
390        // c:297 — `char *arg = *argv;`
391        loop {
392            // c:300-303 — outer arg-by-arg loop guard:
393            //   `arg && ((sense = (*arg == '-')) || ((flags & BINF_PLUSOPTS) && *arg == '+'))`.
394            let arg_str: String = match argarr.get(argv) {
395                Some(s) => s.clone(),
396                None => break,
397            };
398            let arg_bytes = arg_str.as_bytes();
399            if arg_bytes.is_empty() {
400                break;
401            }
402            let sense: i32 = if arg_bytes[0] == b'-' { 1 } else { 0 }; // c:302
403            if sense == 0
404                && !((flags & BINF_PLUSOPTS as i32) != 0           // c:303
405                                && arg_bytes[0] == b'+')
406            {
407                break;
408            }
409            // c:305 — `if (!(flags & BINF_KEEPNUM) && idigit(arg[1])) break;`
410            if (flags & BINF_KEEPNUM as i32) == 0                            // c:305
411                && arg_bytes.len() >= 2
412                && arg_bytes[1].is_ascii_digit()
413            {
414                break;
415            }
416            // c:308 — `if ((flags & BINF_SKIPDASH) && !arg[1]) break;`
417            if (flags & BINF_SKIPDASH as i32) != 0 && arg_bytes.len() == 1 {
418                // c:308
419                break;
420            }
421            // c:310-317 — `--` end-of-options if BINF_DASHDASHVALID.
422            if (flags & BINF_DASHDASHVALID as i32) != 0 && arg_str == "--" {
423                // c:310
424                argv += 1; // c:315
425                break; // c:316
426            }
427            // c:327-332 — `BINF_SKIPINVALID`: if any char in arg[1..] is
428            // not in optstr, the whole arg is treated as a positional.
429            if skipinvalid {
430                // c:327
431                let mut all_known = true;
432                for &c in &arg_bytes[1..] {
433                    if !optstr_bytes.contains(&c) {
434                        all_known = false;
435                        break;
436                    }
437                }
438                if !all_known {
439                    break;
440                } // c:331
441            }
442            // c:335-336 — `if (arg[1] == '-') arg++;` — consume the
443            // second `-` of `--long-style`.
444            let mut k: usize = 1; // walks arg[k..]
445            if arg_bytes.len() >= 2 && arg_bytes[1] == b'-' {
446                // c:335
447                k = 2; // c:336
448            }
449            // c:337-341 — `if (!arg[1])` lone `-` / `+` indicator.
450            if arg_bytes.len() == k {
451                // c:337
452                ops.ind[b'-' as usize] = 1; // c:338
453                if sense == 0 {
454                    // c:339
455                    ops.ind[b'+' as usize] = 1; // c:340
456                }
457            }
458            // c:343-386 — inner loop over `*++arg` characters.
459            let mut bad_opt: Option<u8> = None;
460            while k < arg_bytes.len() {
461                // c:343
462                let c = arg_bytes[k];
463                execop = c; // c:345
464                let optptr = optstr_bytes.iter().position(|&b| b == c); // c:345 strchr(optstr,c)
465                if let Some(optidx) = optptr {
466                    // c:345
467                    ops.ind[c as usize] = if sense != 0 { 1 } else { 2 }; // c:346
468                                                                          // c:347 — `if (optptr[1] == ':')` — option takes arg.
469                    if optidx + 1 < optstr_bytes.len() && optstr_bytes[optidx + 1] == b':' {
470                        let mut argptr: Option<String> = None;
471                        // c:349-352 — `if (optptr[2] == ':')` optional same-word.
472                        if optidx + 2 < optstr_bytes.len() && optstr_bytes[optidx + 2] == b':' {
473                            if k + 1 < arg_bytes.len() {
474                                // c:350
475                                argptr =
476                                    Some(String::from_utf8_lossy(&arg_bytes[k + 1..]).into_owned());
477                                // c:351
478                            }
479                        } else if optidx + 2 < optstr_bytes.len()
480                            && optstr_bytes[optidx + 2] == b'%'
481                        {
482                            // c:353-359 — `:%` numeric optional same or next word.
483                            if k + 1 < arg_bytes.len() && arg_bytes[k + 1].is_ascii_digit() {
484                                argptr =
485                                    Some(String::from_utf8_lossy(&arg_bytes[k + 1..]).into_owned());
486                            } else if let Some(nxt) = argarr.get(argv + 1) {
487                                if !nxt.is_empty() && nxt.as_bytes()[0].is_ascii_digit() {
488                                    argv += 1; // c:359 arg = *++argv
489                                    argptr = Some(nxt.clone());
490                                }
491                            }
492                        } else {
493                            // c:360-370 — plain `:` mandatory arg.
494                            if k + 1 < arg_bytes.len() {
495                                // c:362
496                                argptr =
497                                    Some(String::from_utf8_lossy(&arg_bytes[k + 1..]).into_owned());
498                            // c:363
499                            } else if let Some(nxt) = argarr.get(argv + 1) {
500                                argv += 1; // c:364 arg = *++argv
501                                argptr = Some(nxt.clone()); // c:365
502                            } else {
503                                // c:366-370 — `argument expected: -%c`.
504                                zwarnnam(&name, &format!("argument expected: -{}", execop as char)); // c:367-368
505                                return 1; // c:369
506                            }
507                        }
508                        if let Some(ap) = argptr {
509                            // c:372
510                            // c:373-377 — new_optarg overflow.
511                            if new_optarg(&mut ops) != 0 {
512                                // c:373
513                                zwarnnam(&name, "too many option arguments"); // c:374-375
514                                return 1; // c:376
515                            }
516                            // c:378 — `ops.ind[execop] |= ops.argscount << 2;`
517                            ops.ind[execop as usize] |= (ops.argscount as u8) << 2;
518                            // c:379 — `ops.args[ops.argscount-1] = argptr;`
519                            ops.args[(ops.argscount - 1) as usize] = ap;
520                            // c:380-381 — `while (arg[1]) arg++;` consume the rest.
521                            k = arg_bytes.len();
522                        }
523                    }
524                    k += 1;
525                } else {
526                    bad_opt = Some(c); // c:385 break
527                    break;
528                }
529            }
530            // c:389-394 — if we exited mid-arg on a bad char, emit "bad option".
531            if let Some(badc) = bad_opt {
532                // c:389
533                zwarnnam(
534                    &name,
535                    &format!(
536                        "bad option: {}{}",
537                        if sense != 0 { '-' } else { '+' },
538                        badc as char
539                    ),
540                ); // c:392
541                return 1; // c:393
542            }
543            // c:395 — `arg = *++argv;`
544            argv += 1; // c:395
545                       // c:398-402 — BINF_PRINTOPTS R-mode switch to "ne" optstr.
546            if (flags & BINF_PRINTOPTS as i32) != 0                          // c:398
547                && ops.ind[b'R' as usize] != 0
548                && ops.ind[b'f' as usize] == 0
549            {
550                optstr_bytes = b"ne".to_vec(); // c:400
551                flags |= BINF_SKIPINVALID as i32; // c:401
552                skipinvalid = true;
553            }
554            // c:404-405 — `if (ops.ind['-']) break;` — `--` terminates.
555            if ops.ind[b'-' as usize] != 0 {
556                // c:404
557                break;
558            }
559        }
560        let _ = optstr_bytes;
561    } else if (flags & BINF_HANDLES_OPTS as i32) == 0                        // c:407
562        && argarr.get(argv).map(|s| s == "--").unwrap_or(false)
563    {
564        // c:408
565        // c:409-410 — `ops.ind['-'] = 1; argv++;`
566        ops.ind[b'-' as usize] = 1; // c:409
567        argv += 1; // c:410
568    }
569    // Suppress optstr-unused warnings on the `else` path.
570    let _ = optstr.take();
571
572    // c:414-421 — apply `bn->defopts` defaults.
573    pp = bn_ref.defopts.as_deref(); // c:414
574    if let Some(pp_str) = pp {
575        // c:414
576        for &b in pp_str.as_bytes() {
577            // c:415
578            if ops.ind[b as usize] == 0 {
579                // c:417
580                ops.ind[b as usize] = 1; // c:418
581            }
582        }
583    }
584
585    // c:424 — `argc -= argv - argarr;` — subtract consumed flag args.
586    argc -= argv as i32; // c:424
587
588    // c:426-429 — errflag check. C zsh clears here because the
589    // *outer* execcmd_exec (Src/exec.c:3468) already bailed without
590    // calling the builtin when errflag was set after prefork; the
591    // clear at c:427 is paired with an earlier-arrived-at builtin
592    // entry (signal-driven, not prefork-driven).
593    //
594    // zshrs's bytecode flow always reaches the builtin call site
595    // even when arg expansion set errflag (the c:3468 pre-dispatch
596    // gate has no equivalent in the bytecode). Clearing errflag
597    // here would swallow the prefork error and let the post-command
598    // ERREXIT_CHECK pass — `set -u; echo $undefined; echo done`
599    // would run `echo done` instead of aborting.
600    //
601    // Non-interactive shells must preserve errflag so the
602    // immediately-following ERREXIT_CHECK aborts the script (c:Src/
603    // init.c:234 `((!interact || sourcelevel) && errflag)` break).
604    let ef = errflag.load(Relaxed);
605    if (ef & ERRFLAG_ERROR) != 0 {
606        if !isset(INTERACTIVE) {
607            return 1;
608        }
609        errflag.fetch_and(!ERRFLAG_ERROR, Relaxed); // c:427
610        return 1; // c:428
611    }
612
613    // c:432-436 — argc bounds check.
614    if argc < bn_ref.minargs                                                 // c:432
615        || (argc > bn_ref.maxargs && bn_ref.maxargs != -1)
616    {
617        zwarnnam(
618            &name, // c:433
619            if argc < bn_ref.minargs {
620                "not enough arguments"
621            } else {
622                "too many arguments"
623            },
624        ); // c:434
625        return 1; // c:435
626    }
627
628    // c:438-494 — display execution trace information, if required.
629    if xtr {
630        // c:439
631        // c:440-441 — `char **fullargv = argarr;` — use FULL argv
632        // (including consumed option words) so XTRACE shows what the
633        // user typed, not the option-stripped tail.
634        let fullargv = &argarr; // c:441
635                                // All emits below are `fprintf(xtrerr, …)` / `fputc(…, xtrerr)` in
636                                // C: append to the xtrerr line buffer and flush ONCE at the
637                                // trailing `\n` (c:492-493), so the builtin's trace line reaches
638                                // the shared stderr fd in a single write — a forked pipeline
639                                // stage never interleaves with a concurrent one.
640        use crate::fusevm_bridge::{xtrerr_flush, xtrerr_fputs};
641        printprompt4(); // c:442 — buffers PS4 into xtrerr
642                        // c:443 — `fprintf(xtrerr, "%s", name);`
643        xtrerr_fputs(&name); // c:443
644                             // c:444-447 — `while (*fullargv) { fputc(' ',xtrerr); quotedzputs(...); }`
645                             // C zsh's parser pre-splits `name=value` args for
646                             // BINF_ASSIGN-flagged builtins (export/typeset/declare/local/
647                             // readonly/integer/float) into asg{name,value} nodes, which
648                             // the c:448-491 branch emits as
649                             //   `quotedzputs(name) + '=' + quotedzputs(value)` (c:453,487-488).
650                             // zshrs's compiler doesn't populate `assigns`; args arrive
651                             // here as the unsplit `"name=value"` whole-strings, so the
652                             // c:443-446 path quotes the entire string and produces
653                             // `export 'VAR=val'` instead of `export VAR=val`. Inline the
654                             // same split here — the prefix is a legal scalar/array name
655                             // (ident, optionally `[subscript]`) followed by `=` or
656                             // `+=`, and the suffix is the value. Detection mirrors the
657                             // C parser at Src/lex.c:2169 (gettokstr ASSIGN recognition)
658                             // and is faithful to what the C path would have produced
659                             // from a pre-split asg node.
660        let is_assign = (bn_ref.node.flags as u32 & BINF_ASSIGN) != 0;
661        for s in fullargv {
662            // c:444
663            xtrerr_fputs(" "); // c:445 fputc(' ', xtrerr)
664            let mut emitted = false;
665            if is_assign {
666                let sbytes = s.as_bytes();
667                // Walk the ident prefix.
668                let mut i = 0usize;
669                if !sbytes.is_empty() && (sbytes[0].is_ascii_alphabetic() || sbytes[0] == b'_') {
670                    i = 1;
671                    while i < sbytes.len()
672                        && (sbytes[i].is_ascii_alphanumeric() || sbytes[i] == b'_')
673                    {
674                        i += 1;
675                    }
676                    // Optional `[subscript]` — bracket-balanced.
677                    if i < sbytes.len() && sbytes[i] == b'[' {
678                        let mut depth = 1i32;
679                        i += 1;
680                        while i < sbytes.len() && depth > 0 {
681                            match sbytes[i] {
682                                b'[' => depth += 1,
683                                b']' => depth -= 1,
684                                _ => {}
685                            }
686                            i += 1;
687                        }
688                        if depth != 0 {
689                            i = 0; // unbalanced — bail to default path
690                        }
691                    }
692                    if i > 0 {
693                        // Match `=` or `+=` separator.
694                        let (sep_len, sep) =
695                            if sbytes.get(i) == Some(&b'+') && sbytes.get(i + 1) == Some(&b'=') {
696                                (2usize, "+=")
697                            } else if sbytes.get(i) == Some(&b'=') {
698                                (1usize, "=")
699                            } else {
700                                (0, "")
701                            };
702                        if sep_len != 0 {
703                            // c:453,487-488 — emit name + sep + quoted(value).
704                            xtrerr_fputs(&format!(
705                                "{}{}{}",
706                                &s[..i],
707                                sep,
708                                quotedzputs(&s[i + sep_len..])
709                            ));
710                            emitted = true;
711                        }
712                    }
713                }
714            }
715            if !emitted {
716                xtrerr_fputs(&quotedzputs(s)); // c:446
717            }
718        }
719        // c:448-491 — `if (assigns) { for (node = firstnode(assigns); ...) }`.
720        for asg in &assigns {
721            // c:450 firstnode/incnode
722            xtrerr_fputs(" "); // c:452 fputc(' ', xtrerr)
723            xtrerr_fputs(&quotedzputs(&asg.name)); // c:453
724            if (asg.flags & ASG_ARRAY) != 0 {
725                // c:454
726                xtrerr_fputs("=("); // c:455
727                if let Some(ref list) = asg.array {
728                    // c:456
729                    if (asg.flags & ASG_KEY_VALUE) != 0 {
730                        // c:457
731                        // c:458-473 — `LinkNode keynode, valnode;` walk
732                        // alternating key/value pairs, emitting
733                        // `[key]=value` per pair. Uses the typed
734                        // `LinkList<String>` accessors from
735                        // `src/ported/linklist.rs` which port the
736                        // `firstnode` / `nextnode` / `getdata` macros
737                        // from `Src/zsh.h:576-588`.
738                        let mut keynode = list.firstnode(); // c:459
739                        loop {
740                            // c:460
741                            // c:461-462 — `if (!keynode) break;`
742                            let kidx = match keynode {
743                                // c:461
744                                Some(i) => i,
745                                None => break, // c:462
746                            };
747                            // c:463-465 — `valnode = nextnode(keynode); if (!valnode) break;`
748                            let vidx = match list.nextnode(kidx) {
749                                // c:463
750                                Some(i) => i,
751                                None => break, // c:465
752                            };
753                            // c:466-468 — `fputc('['); quotedzputs(getdata(keynode));`
754                            xtrerr_fputs("["); // c:466
755                            if let Some(k) = list.getdata(kidx) {
756                                // c:467 getdata
757                                xtrerr_fputs(&quotedzputs(k));
758                                // c:467
759                            }
760                            // c:469 — `fprintf(stderr, "]=");`
761                            xtrerr_fputs("]="); // c:469
762                                                // c:470-471 — `quotedzputs(getdata(valnode));`
763                            if let Some(v) = list.getdata(vidx) {
764                                // c:470
765                                xtrerr_fputs(&quotedzputs(v));
766                                // c:470
767                            }
768                            // c:472 — `keynode = nextnode(valnode);`
769                            keynode = list.nextnode(vidx); // c:472
770                        }
771                    } else {
772                        // c:474
773                        // c:475-482 — plain array emit: walk every node
774                        // and emit ` <quotedzputs(elem)>`.
775                        let mut arrnode = list.firstnode(); // c:476
776                        while let Some(idx) = arrnode {
777                            // c:477
778                            xtrerr_fputs(" "); // c:479 fputc(' ', xtrerr)
779                            if let Some(elem) = list.getdata(idx) {
780                                // c:480 getdata
781                                xtrerr_fputs(&quotedzputs(elem));
782                                // c:480
783                            }
784                            arrnode = list.nextnode(idx); // c:478 incnode
785                        }
786                    }
787                }
788                xtrerr_fputs(" )"); // c:485
789            } else if let Some(ref scalar) = asg.scalar {
790                // c:486
791                xtrerr_fputs("="); // c:487 fputc('=', xtrerr)
792                xtrerr_fputs(&quotedzputs(scalar)); // c:488
793            }
794        }
795        // c:492-493 — `fputc('\n', xtrerr); fflush(xtrerr);`
796        xtrerr_fputs("\n"); // c:492 fputc('\n', xtrerr)
797        xtrerr_flush(); // c:493 fflush(xtrerr) — one write for the whole line
798    }
799
800    // c:506 — `return (*(bn->handlerfunc))(name, argv, &ops, bn->funcid);`
801    let trimmed: Vec<String> = argarr[argv..].to_vec();
802    let handler = bn_ref.handlerfunc.expect("handlerfunc checked at c:264");
803    handler(&name, &trimmed, &ops, bn_ref.funcid) // c:506
804}
805
806/// Port of `bin_enable(char *name, char **argv, Options ops, int func)` from Src/builtin.c:517.
807/// C: `int bin_enable(char *name, char **argv, Options ops, int func)` —
808///   enable/disable hashtab entries (default builtins; `-f`/`-r`/`-s`/`-a`
809///   pick alternate tables); `-p` routes to pat_enables (pattern toggles).
810/// WARNING: param names don't match C — Rust=(name, argv, func) vs C=(name, argv, ops, func)
811pub fn bin_enable(
812    name: &str,
813    argv: &[String], // c:517
814    ops: &options,
815    func: i32,
816) -> i32 {
817    enum Tab {
818        Builtin,
819        Shfunc,
820        Reswd,
821        Alias,
822        SufAlias,
823    }
824    let mut returnval = 0i32; // c:524
825    let mut match_count = 0i32; // c:524
826                                // c:527-538 — `-p` early-out + table selection.
827    if OPT_ISSET(ops, b'p') {
828        // c:527
829        // c:528 — `return pat_enables(name, argv, func == BIN_ENABLE);`
830        return pat_enables(name, argv, func == BIN_ENABLE); // c:528
831    }
832    let tab = if OPT_ISSET(ops, b'f') {
833        Tab::Shfunc
834    }
835    // c:529
836    else if OPT_ISSET(ops, b'r') {
837        Tab::Reswd
838    }
839    // c:531
840    else if OPT_ISSET(ops, b's') {
841        Tab::SufAlias
842    }
843    // c:533
844    else if OPT_ISSET(ops, b'a') {
845        Tab::Alias
846    }
847    // c:535
848    else {
849        Tab::Builtin
850    }; // c:537
851
852    // c:540-547 — flags1/flags2 set based on enable vs disable direction.
853    let enable = func == BIN_ENABLE;
854    let (flags1, flags2) = if enable {
855        // c:541
856        (0u32, DISABLED as u32) // c:542
857    } else {
858        (DISABLED as u32, 0u32) // c:545
859    };
860
861    // Helper closures over the chosen table.
862    let toggle_one = |tab: &Tab, nm: &str, on: bool| -> bool {
863        match tab {
864            Tab::Alias => aliastab_lock()
865                .write()
866                .map(|mut t| if on { t.enable(nm) } else { t.disable(nm) })
867                .unwrap_or(false),
868            Tab::SufAlias => sufaliastab_lock()
869                .write()
870                .map(|mut t| if on { t.enable(nm) } else { t.disable(nm) })
871                .unwrap_or(false),
872            // c:541-547 — `enable`/`disable -r` toggles DISABLED on the
873            // reswdtab entry; reswords resolve through getreswdnode in
874            // the lexer so toggling here is enough to mask/unmask.
875            Tab::Reswd => {
876                let exists = reswdtab_lock()
877                    .read()
878                    .map(|t| t.get_including_disabled(nm).is_some())
879                    .unwrap_or(false);
880                if !exists {
881                    return false;
882                }
883                reswdtab_lock()
884                    .write()
885                    .map(|mut t| if on { t.enable(nm) } else { t.disable(nm) })
886                    .unwrap_or(false)
887            }
888            // c:541-547 — `enable`/`disable -f` toggles DISABLED on the
889            // shfunctab entry; ports to disableshfuncnode/enableshfuncnode
890            // which also unsettrap/settrap TRAP* ported.
891            Tab::Shfunc => {
892                let exists = shfunctab_lock()
893                    .read()
894                    .map(|t| t.get_including_disabled(nm).is_some())
895                    .unwrap_or(false);
896                if !exists {
897                    return false;
898                }
899                if on {
900                    crate::ported::hashtable::enableshfuncnode(nm);
901                } else {
902                    crate::ported::hashtable::disableshfuncnode(nm);
903                }
904                true
905            }
906            // c:541-547 — `enable`/`disable` toggles DISABLED on the
907            // builtin. The C struct `builtintab` stores DISABLED in
908            // `node.flags`; Rust port keeps `builtintab` as an
909            // immutable static lookup and tracks the disabled set in
910            // BUILTINS_DISABLED so dispatch can mask the entry.
911            Tab::Builtin => {
912                if createbuiltintable().get(nm).is_none() {
913                    return false;
914                }
915                if let Ok(mut set) = BUILTINS_DISABLED.lock() {
916                    if on {
917                        set.remove(nm);
918                    } else {
919                        set.insert(nm.to_string());
920                    }
921                    return true;
922                }
923                false
924            }
925        }
926    };
927    let collect_names = |tab: &Tab| -> Vec<String> {
928        match tab {
929            Tab::Alias => aliastab_lock()
930                .read()
931                .map(|t| t.iter().map(|(n, _)| n.clone()).collect())
932                .unwrap_or_default(),
933            Tab::SufAlias => sufaliastab_lock()
934                .read()
935                .map(|t| t.iter().map(|(n, _)| n.clone()).collect())
936                .unwrap_or_default(),
937            Tab::Reswd => reswdtab_lock()
938                .read()
939                .map(|t| t.iter().map(|(n, _)| n.clone()).collect())
940                .unwrap_or_default(),
941            Tab::Shfunc => shfunctab_lock()
942                .read()
943                .map(|t| t.iter().map(|(n, _)| n.clone()).collect())
944                .unwrap_or_default(),
945            Tab::Builtin => createbuiltintable().keys().cloned().collect(),
946        }
947    };
948
949    // c:553-558 — no-args list.
950    if argv.is_empty() {
951        // c:553
952        queue_signals(); // c:554
953                         // c:555 — `scanhashtable(ht, 1, flags1, flags2, ht->printnode, 0);`
954                         // Filter: print only entries where (flags & flags1) == flags1
955                         // && (flags & flags2) == 0. For enable/disable, flags1 and
956                         // flags2 are DISABLED-bit selectors that mask the listed set
957                         // to ONLY the kind being toggled (enable lists enabled,
958                         // disable lists disabled).
959        let is_disabled = |nm: &str| -> bool {
960            match tab {
961                Tab::Alias => aliastab_lock()
962                    .read()
963                    .ok()
964                    .and_then(|t| {
965                        t.get_including_disabled(nm)
966                            .map(|a| (a.node.flags & DISABLED as i32) != 0)
967                    })
968                    .unwrap_or(false),
969                Tab::SufAlias => sufaliastab_lock()
970                    .read()
971                    .ok()
972                    .and_then(|t| {
973                        t.get_including_disabled(nm)
974                            .map(|a| (a.node.flags & DISABLED as i32) != 0)
975                    })
976                    .unwrap_or(false),
977                Tab::Reswd => reswdtab_lock()
978                    .read()
979                    .ok()
980                    .and_then(|t| {
981                        t.get_including_disabled(nm)
982                            .map(|r| (r.node.flags & DISABLED as i32) != 0)
983                    })
984                    .unwrap_or(false),
985                Tab::Shfunc => shfunctab_lock()
986                    .read()
987                    .ok()
988                    .and_then(|t| {
989                        t.get_including_disabled(nm)
990                            .map(|f| (f.node.flags & DISABLED as i32) != 0)
991                    })
992                    .unwrap_or(false),
993                Tab::Builtin => BUILTINS_DISABLED
994                    .lock()
995                    .map(|s| s.contains(nm))
996                    .unwrap_or(false),
997            }
998        };
999        // c:Src/builtin.c — `scanhashtable(ht, 1, …)` walks the
1000        // table in sorted order (the first arg `sorted=1`). Mirror
1001        // by sorting the collected names before the per-name walk.
1002        // Without this, builtin/reswd listings came out in HashMap
1003        // iteration order, diverging from zsh.
1004        let mut all_names = collect_names(&tab);
1005        all_names.sort();
1006        for nm in all_names {
1007            let dis = is_disabled(&nm);
1008            let entry_flags = if dis { DISABLED as u32 } else { 0 };
1009            if (entry_flags & flags1) == flags1 && (entry_flags & flags2) == 0 {
1010                // c:Src/builtin.c — `ht->printnode` dispatches per
1011                // table type. For alias/sufalias, printaliasnode
1012                // (hashtable.rs:1477) prints `name=value`. For
1013                // builtin/reswd/shfunc, just the name. Previous
1014                // Rust port printed bare names for all kinds, so
1015                // `enable -a` lost the alias definitions. Mirror
1016                // C's printnode dispatch here.
1017                match tab {
1018                    Tab::Alias => {
1019                        let val = aliastab_lock()
1020                            .read()
1021                            .ok()
1022                            .and_then(|t| t.get_including_disabled(&nm).map(|a| a.text.clone()));
1023                        if let Some(v) = val {
1024                            println!("{}={}", nm, quotedzputs(&v));
1025                        } else {
1026                            println!("{}", nm);
1027                        }
1028                    }
1029                    Tab::SufAlias => {
1030                        let val = sufaliastab_lock()
1031                            .read()
1032                            .ok()
1033                            .and_then(|t| t.get_including_disabled(&nm).map(|a| a.text.clone()));
1034                        if let Some(v) = val {
1035                            println!("{}={}", nm, quotedzputs(&v));
1036                        } else {
1037                            println!("{}", nm);
1038                        }
1039                    }
1040                    _ => println!("{}", nm),
1041                }
1042            }
1043        }
1044        unqueue_signals(); // c:556
1045        return 0; // c:557
1046    }
1047
1048    // c:561-580 — `-m` glob branch.
1049    if OPT_ISSET(ops, b'm') {
1050        // c:561
1051        for arg in argv {
1052            // c:562
1053            queue_signals(); // c:563
1054            let pprog = patcompile(
1055                &{
1056                    let mut __pat_tok = (arg).to_string();
1057                    crate::ported::glob::tokenize(&mut __pat_tok);
1058                    __pat_tok
1059                }, // c:566
1060                PAT_HEAPDUP,
1061                None,
1062            );
1063            if let Some(prog) = pprog {
1064                for nm in collect_names(&tab) {
1065                    if pattry(&prog, &nm) {
1066                        // c:567
1067                        if toggle_one(&tab, &nm, enable) {
1068                            match_count += 1; // c:567
1069                        }
1070                    }
1071                }
1072            } else {
1073                zwarnnam(name, &format!("bad pattern : {}", arg)); // c:572
1074                returnval = 1; // c:573
1075            }
1076            unqueue_signals(); // c:575
1077        }
1078        if match_count == 0 {
1079            // c:579
1080            returnval = 1; // c:580
1081        }
1082        return returnval; // c:581
1083    }
1084
1085    // c:585-594 — literal-name dispatch.
1086    queue_signals(); // c:585
1087    for arg in argv {
1088        // c:586
1089        if !toggle_one(&tab, arg, enable) {
1090            // c:587
1091            zwarnnam(name, &format!("no such hash table element: {}", arg)); // c:590
1092            returnval = 1; // c:591
1093        }
1094    }
1095    unqueue_signals(); // c:594
1096    returnval // c:595
1097}
1098
1099/// Port of `bin_set(char *nam, char **args, UNUSED(Options ops), UNUSED(int func))` from Src/builtin.c:601.
1100/// C: `int bin_set(char *nam, char **args, UNUSED(Options ops),
1101///                 UNUSED(int func))` — set shell options, declare arrays,
1102///   replace positional params, or display variables.
1103/// WARNING: param names don't match C — Rust=(nam, args, _func) vs C=(nam, args, ops, func)
1104pub fn bin_set(
1105    nam: &str,
1106    args: &[String], // c:601
1107    _ops: &options,
1108    _func: i32,
1109) -> i32 {
1110    // PFA-SMR aspect: emit setopt/unsetopt events for the POSIX
1111    // `set -o NAME` / `set +o NAME` form. This is the third option
1112    // syntax (alongside setopt NAME / unsetopt NAME); a recorder
1113    // user expects all three to surface in `zwhere -k setopt`.
1114    #[cfg(feature = "recorder")]
1115    if crate::recorder::is_enabled() && !args.is_empty() {
1116        let ctx = crate::recorder::recorder_ctx_global();
1117        let mut iter = args.iter().peekable();
1118        while let Some(a) = iter.next() {
1119            match a.as_str() {
1120                "-o" => {
1121                    if let Some(name) = iter.next() {
1122                        crate::recorder::emit_setopt(name, ctx.clone());
1123                    }
1124                }
1125                "+o" => {
1126                    if let Some(name) = iter.next() {
1127                        crate::recorder::emit_unsetopt(name, ctx.clone());
1128                    }
1129                }
1130                _ => {}
1131            }
1132        }
1133    }
1134
1135    let mut argv: Vec<String> = args.to_vec();
1136    let mut hadopt = false; // c:603
1137    let mut hadplus = false; // c:603
1138    let mut hadend = false; // c:603
1139    let mut sort: i32 = 0; // c:603
1140    let mut array: i32 = 0; // c:603
1141    let mut arrayname: Option<String> = None; // c:604
1142
1143    // c:608-614 — sh-compat: bare `set -` → +xv.
1144    if !EMULATION(EMULATE_ZSH)                                               // c:608
1145        && !argv.is_empty() && argv[0] == "-"
1146    {
1147        // c:610-611 — `dosetopt(VERBOSE, 0, 0, opts); dosetopt(XTRACE, 0, 0, opts);`
1148        dosetopt(VERBOSE, 0, 0); // c:610
1149        dosetopt(XTRACE, 0, 0); // c:611
1150        if argv.len() == 1 {
1151            return 0;
1152        } // c:612-613
1153        argv.remove(0);
1154    }
1155
1156    // c:617-668 — top-level option-arg loop.
1157    let mut idx = 0usize;
1158    'outer: while idx < argv.len()                                           // c:617
1159        && (argv[idx].starts_with('-') || argv[idx].starts_with('+'))
1160    {
1161        let arg = argv[idx].clone();
1162        let action = arg.starts_with('-'); // c:619
1163        if !action {
1164            hadplus = true;
1165        } // c:620
1166          // c:621-622 — bare `-` / `+` → "--"
1167        let body: String = if arg.len() == 1 {
1168            "--".to_string()
1169        } else {
1170            arg.clone()
1171        };
1172        // c:623 — `while (*++*args)`
1173        let chars: Vec<char> = body[1..].chars().collect();
1174        let mut ci = 0usize;
1175        while ci < chars.len() {
1176            // c:623
1177            let c = chars[ci];
1178            if c != '-' || action {
1179                hadopt = true;
1180            } // c:626
1181              // c:628-632 — `--` end-of-options.
1182            if c == '-' {
1183                // c:628
1184                hadend = true; // c:629
1185                idx += 1; // c:630 args++
1186                break 'outer;
1187            }
1188            // c:633-645 — `o` long-option name follows.
1189            if c == 'o' {
1190                // c:633
1191                let optname: String = if ci + 1 < chars.len() {
1192                    chars[ci + 1..].iter().collect::<String>()
1193                } else {
1194                    idx += 1;
1195                    if idx >= argv.len() {
1196                        // c:636-637 — `printoptionstates(hadplus);
1197                        // inittyptab(); return 0;`. The Rust port
1198                        // previously returned 0 without printing,
1199                        // so `set -o` / `set +o` (no option name)
1200                        // emitted nothing instead of the full
1201                        // option-state listing (one per line).
1202                        // hadplus is true when the user invoked
1203                        // `set +o` (POSIX-output form); else `set -o`
1204                        // (zsh-output form).
1205                        crate::ported::options::printoptionstates(hadplus);
1206                        return 0;
1207                    }
1208                    argv[idx].clone()
1209                };
1210                let optno = optlookup(&optname); // c:642
1211                if optno == 0 {
1212                    // c:642 — C: `zerrnam(nam, "no such option: %s", *args)`.
1213                    // zwarnnam emits `zsh:<nam>:<lineno>:` prefix
1214                    // (vs zerr's bare `zsh:<lineno>:`); use the
1215                    // canonical form so `set +o BAD` matches zsh's
1216                    // diagnostic format. Return 1 so $? reflects the
1217                    // failed lookup (C's execbuiltin checks errflag
1218                    // after the call; zshrs's bridge bases on the
1219                    // return value).
1220                    zerrnam(nam, &format!("no such option: {}", optname));
1221                    unqueue_signals();
1222                    return 1;
1223                } else if dosetopt(optno, if action { 1 } else { 0 }, 0) != 0
1224                // c:644
1225                {
1226                    zerrnam(nam, &format!("can't change option: {}", optname));
1227                    unqueue_signals();
1228                    return 1;
1229                }
1230                break;
1231            }
1232            // c:646-657 — `A` array-mode (with optional name arg).
1233            if c == 'A' {
1234                // c:646
1235                array = if action { 1 } else { -1 }; // c:649
1236                let nameopt: Option<String> = if ci + 1 < chars.len() {
1237                    Some(chars[ci + 1..].iter().collect::<String>())
1238                } else if idx + 1 < argv.len() {
1239                    idx += 1;
1240                    Some(argv[idx].clone())
1241                } else {
1242                    None
1243                };
1244                arrayname = nameopt.clone();
1245                if arrayname.is_none() {
1246                    // c:651
1247                    idx += 1;
1248                    break 'outer;
1249                }
1250                let ksharrays = isset(KSHARRAYS);
1251                if !ksharrays {
1252                    // c:653
1253                    idx += 1; // c:655 args++
1254                    break 'outer; // c:656
1255                }
1256                break;
1257            }
1258            // c:659-660 — `s` sort flag.
1259            if c == 's' {
1260                // c:659
1261                sort = if action { 1 } else { -1 }; // c:660
1262            } else {
1263                // c:662-666 — short-option letter: optlookupc + dosetopt.
1264                // c:Src/builtin.c — zerrnam(nam, ...) prefixes the
1265                // builtin's name into the error tag ("zsh:set:1:" vs
1266                // bare "zsh:1:"). Use zerrnam to match zsh's exact
1267                // error format for `set -X 2>&1`.
1268                let optno = crate::ported::options::optlookupc(c); // c:663
1269                if optno == 0 {
1270                    // c:663
1271                    zerrnam(nam, &format!("bad option: -{}", c)); // c:663
1272                } else if dosetopt(optno, if action { 1 } else { 0 }, 0) != 0
1273                // c:664
1274                {
1275                    zerrnam(nam, &format!("can't change option: -{}", c)); // c:664
1276                }
1277            }
1278            ci += 1;
1279        }
1280        idx += 1; // c:668
1281    }
1282    // c:Src/builtin.c:668 — `if (errflag) return 1;`. A bad / can't-change
1283    // option (or `no such option`) reported via zerrnam set ERRFLAG_ERROR;
1284    // `set -Z` must exit 1, not fall through to the (success) display path.
1285    if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
1286        & crate::ported::utils::ERRFLAG_ERROR)
1287        != 0
1288    {
1289        return 1;
1290    }
1291    let _ = nam;
1292
1293    // c:676 — `queue_signals();`
1294    queue_signals();
1295    let remaining = &argv[idx..];
1296
1297    // c:678-694 — display path when no array/no args.
1298    if arrayname.is_none() {
1299        // c:678
1300        if !hadopt && remaining.is_empty() {
1301            // c:679
1302            // c:680-681 — `scanhashtable(paramtab, 1, 0, 0,
1303            //              paramtab->printnode, hadplus ? PRINT_NAMEONLY : 0);`
1304            //
1305            // C walks the paramtab (sorted=1 → alphabetical). The previous
1306            // Rust port walked `std::env::vars()` — the OS environment.
1307            // Shell-internal vars (not exported to env) would never appear
1308            // in the `set` listing, diverging from C where ALL paramtab
1309            // entries are emitted.
1310            //
1311            // Same family of bug as the prior bin_unset -m fix.
1312            //
1313            // c:Src/builtins.c::printparamnode — dispatches the value
1314            // read through the param's gsu_s.getfn rather than reading
1315            // `pm.u_str` directly. Special params like `!`, `$`, `#`,
1316            // `-`, `0`, `?` have empty `u_str` slots because their live
1317            // value lives behind getfn (libc syscall, LASTVAL, pparams
1318            // count, etc.). Bug #463: zshrs read u_str directly so the
1319            // dump showed `!=''`, `$=''` etc. instead of the actual
1320            // values. Route through `getsparam` so the canonical
1321            // special-param dispatch (lookup_special_var → getfn shim)
1322            // fires.
1323            let names: Vec<String> = {
1324                let tab = paramtab().read().unwrap();
1325                tab.iter()
1326                    .filter(|(_, pm)| (pm.node.flags as u32 & PM_UNSET) == 0)
1327                    .map(|(k, _)| k.clone())
1328                    .collect()
1329            };
1330            let mut entries: Vec<(String, String)> = names
1331                .into_iter()
1332                .map(|k| {
1333                    let v = crate::ported::params::getsparam(&k).unwrap_or_default();
1334                    (k, v)
1335                })
1336                .collect();
1337            // c:680 sorted=1 → meta-aware sort via hnamcmp (already fixed
1338            // to use ztrcmp earlier in the series).
1339            entries.sort_by(|a, b| hnamcmp(&a.0, &b.0));
1340            for (k, v) in entries {
1341                // c:Src/params.c::printparamnode — single-char names
1342                // that double as shell metacharacters (`#` comment,
1343                // `$` substitution, `*` glob, `?` glob, `@` splat)
1344                // are wrapped in single quotes so the output
1345                // round-trips through the shell parser. Other
1346                // single-char specials (`!`, `-`, `0`) appear bare
1347                // because they're unambiguous in name position.
1348                // Verified against /opt/homebrew/bin/zsh `set` output.
1349                let needs_quote_name = matches!(k.as_str(), "#" | "$" | "*" | "?" | "@");
1350                let kq: String = if needs_quote_name {
1351                    format!("'{}'", k)
1352                } else {
1353                    k.clone()
1354                };
1355                if hadplus {
1356                    // c:681 PRINT_NAMEONLY
1357                    println!("{}", kq);
1358                } else if matches!(k.as_str(), "*" | "@" | "argv") {
1359                    // c:Src/params.c — `*` / `@` / `argv` are array-
1360                    // shaped (positional params); print as `( e1 e2 …)`
1361                    // mirroring zsh's array form so `set` output
1362                    // round-trips. Without this special-case, zshrs
1363                    // showed `'*'='1 2'` (joined scalar) instead of
1364                    // zsh's `'*'=( 1 2 )` (array splat).
1365                    let pp = PPARAMS.lock().ok();
1366                    let elems: Vec<String> = pp
1367                        .as_ref()
1368                        .map(|p| p.iter().cloned().collect())
1369                        .unwrap_or_default();
1370                    let body: String = elems
1371                        .iter()
1372                        .map(|e| quotedzputs(e))
1373                        .collect::<Vec<_>>()
1374                        .join(" ");
1375                    if elems.is_empty() {
1376                        println!("{}=(  )", kq);
1377                    } else {
1378                        println!("{}=( {} )", kq, body);
1379                    }
1380                } else {
1381                    println!("{}={}", kq, quotedzputs(&v));
1382                }
1383            }
1384        }
1385        if array != 0 {
1386            // c:684
1387            // c:685-687 — `scanhashtable(paramtab, 1, PM_ARRAY, 0,
1388            //              paramtab->printnode, hadplus ? PRINT_NAMEONLY : 0)`.
1389            // Walk paramtab filtering by PM_ARRAY and emit each as
1390            // `name=(elem1 elem2 ...)`. Previous Rust port stubbed
1391            // this body with a "nothing to enumerate" comment — but
1392            // paramtab does store arrays in `u_arr`, so `set -A` (no
1393            // name) MUST list every PM_ARRAY entry. Sorted via
1394            // hnamcmp (meta-aware compare) per `sorted=1` in the C
1395            // scanhashtable call.
1396            let mut arr_entries: Vec<(String, Vec<String>)> = {
1397                use {PM_ARRAY, PM_TYPE};
1398                let tab = paramtab().read().unwrap();
1399                tab.iter()
1400                    .filter(|(_, pm)| {
1401                        PM_TYPE(pm.node.flags as u32) == PM_ARRAY
1402                            && (pm.node.flags as u32 & PM_UNSET) == 0
1403                    })
1404                    .map(|(k, pm)| (k.clone(), pm.u_arr.clone().unwrap_or_default()))
1405                    .collect()
1406            };
1407            arr_entries.sort_by(|a, b| hnamcmp(&a.0, &b.0)); // c:685 sorted=1
1408            for (k, arr) in arr_entries {
1409                if hadplus {
1410                    // c:686 PRINT_NAMEONLY
1411                    println!("{}", k);
1412                } else {
1413                    let quoted: Vec<String> = arr.iter().map(|v| quotedzputs(v)).collect();
1414                    println!("{}=({})", k, quoted.join(" "));
1415                }
1416            }
1417        }
1418        if remaining.is_empty() && !hadend {
1419            // c:688
1420            unqueue_signals();
1421            return 0; // c:690
1422        }
1423    }
1424
1425    // c:693-695 — `set -s` sort.
1426    let sorted: Vec<String> = if sort != 0 {
1427        let mut v = remaining.to_vec();
1428        if sort < 0 {
1429            v.sort_by(|a, b| b.cmp(a));
1430        } else {
1431            v.sort();
1432        }
1433        v
1434    } else {
1435        remaining.to_vec()
1436    };
1437
1438    // c:696-708 — array assign or positional-param replace.
1439    if array != 0 {
1440        // c:696
1441        // c:697-708 — build array; `array < 0` appends to existing $name.
1442        let aname = arrayname.unwrap_or_default();
1443        let mut new_arr: Vec<String> = sorted;
1444        if array < 0 {
1445            // c:701
1446            // c:702-704 — `if ((a = getaparam(arrayname)) && arrlen_gt(a, len))`.
1447            //              Read paramtab.u_arr directly; was using `:`-
1448            //              split env value as a fake array.
1449            let existing: Vec<String> = {
1450                let tab = paramtab().read().unwrap();
1451                tab.get(&aname)
1452                    .and_then(|pm| pm.u_arr.clone())
1453                    .unwrap_or_default()
1454            };
1455            if existing.len() > new_arr.len() {
1456                // c:702
1457                new_arr.extend(existing.into_iter().skip(new_arr.len())); // c:703
1458            }
1459        }
1460        // c:709 — `setaparam(arrayname, x)`. In C, setaparam →
1461        // assignaparam → setarrvalue dispatches a PM_HASHED target to
1462        // arrhashsetfn, which converts the flat list to key/value pairs.
1463        // When the target is an associative array (`typeset -A h; set -A
1464        // h k v`), route through sethparam (the flat-kv-list assoc
1465        // setter) so the values land as pairs instead of being stored as
1466        // a plain array (which left the assoc empty). Plain arrays keep
1467        // setaparam.
1468        let target_is_hashed = {
1469            use PM_HASHED;
1470            paramtab()
1471                .read()
1472                .ok()
1473                .and_then(|tab| {
1474                    tab.get(&aname)
1475                        .map(|pm| (pm.node.flags as u32 & PM_HASHED) != 0)
1476                })
1477                .unwrap_or(false)
1478        };
1479        if target_is_hashed {
1480            crate::ported::params::sethparam(&aname, new_arr);
1481        } else {
1482            setaparam(&aname, new_arr);
1483        }
1484    } else {
1485        // c:711-712 — `freearray(pparams); pparams = zarrdup(args);`
1486        // PPARAMS is the single source of truth; fusevm reads via
1487        // `exec.pparams()`.
1488        if let Ok(mut pp) = PPARAMS.lock() {
1489            *pp = sorted; // c:712
1490        }
1491    }
1492    unqueue_signals(); // c:714
1493    0 // c:715
1494}
1495
1496/// Port of `bin_pwd(UNUSED(char *name), UNUSED(char **argv), Options ops, UNUSED(int func))` from Src/builtin.c:728.
1497/// C: `int bin_pwd(UNUSED(char *name), UNUSED(char **argv), Options ops,
1498///     UNUSED(int func))` — `-r`/`-P` or (CHASELINKS && !`-L`) →
1499///   print resolved cwd via zgetcwd; else print the cached `pwd`.
1500// pwd: display the name of the current directory                          // c:728
1501/// WARNING: param names don't match C — Rust=(_name, _argv, _func) vs C=(name, argv, ops, func)
1502pub fn bin_pwd(
1503    _name: &str,
1504    _argv: &[String], // c:728
1505    ops: &options,
1506    _func: i32,
1507) -> i32 {
1508    let chaselinks = isset(CHASELINKS);
1509    // c:730-731 — `if (OPT_ISSET(ops,'r') || OPT_ISSET(ops,'P') ||
1510    //               (isset(CHASELINKS) && !OPT_ISSET(ops,'L')))`
1511    if OPT_ISSET(ops, b'r') || OPT_ISSET(ops, b'P')                          // c:730
1512        || (chaselinks && !OPT_ISSET(ops, b'L'))
1513    // c:731
1514    {
1515        // c:732 — `printf("%s\n", zgetcwd());`
1516        println!("{}", zgetcwd()); // c:732
1517    } else {
1518        // c:734 — `zputs(pwd, stdout); putchar('\n');`. C reads the
1519        // shell-internal `pwd` global (Src/params.c:108), NOT the
1520        // exported $PWD env var. The internal global is only mutated
1521        // by `cd`. zshrs lacks a separate internal pwd, so `$PWD`
1522        // doubles as both — and user code can spoof it (`PWD=/foo`).
1523        //
1524        // C-equivalent behavior in zshrs: trust `$PWD` ONLY when its
1525        // stat matches `getcwd()` (same dev+ino → same directory,
1526        // logical name preserved through symlinks). On mismatch the
1527        // user spoofed it; fall back to `zgetcwd()` so `pwd` reports
1528        // the actual current directory. Bug #441 — security-relevant:
1529        // defensive-coding patterns checking `pwd` output get fooled
1530        // by a `PWD=` assignment without this validation.
1531        let pwd_param = getsparam("PWD");
1532        let logical_pwd = pwd_param.as_deref().filter(|pwd| {
1533            // stat-compare $PWD against getcwd() — same inode means
1534            // $PWD is just a symlink-preserving alias for the real
1535            // cwd, safe to use.
1536            use std::os::unix::fs::MetadataExt;
1537            let cwd = zgetcwd();
1538            match (std::fs::metadata(pwd), std::fs::metadata(&cwd)) {
1539                (Ok(a), Ok(b)) => a.dev() == b.dev() && a.ino() == b.ino(),
1540                _ => false,
1541            }
1542        });
1543        println!("{}", logical_pwd.map(String::from).unwrap_or_else(zgetcwd)); // c:734
1544    }
1545    0 // c:737
1546}
1547
1548/// Port of `bin_dirs(UNUSED(char *name), char **argv, Options ops, UNUSED(int func))` from Src/builtin.c:749.
1549/// C: `int bin_dirs(UNUSED(char *name), char **argv, Options ops, ...)` —
1550///   list dirstack (default / -v / -p / -l) or replace it with argv.
1551// dirs: list the directory stack, or replace it with a provided list      // c:749
1552/// WARNING: param names don't match C — Rust=(_name, argv, _func) vs C=(name, argv, ops, func)
1553pub fn bin_dirs(
1554    _name: &str,
1555    argv: &[String], // c:749
1556    ops: &options,
1557    _func: i32,
1558) -> i32 {
1559    queue_signals(); // c:753
1560                     // c:755-756 — list mode: no args & no -c, OR -v / -p.
1561    if (argv.is_empty() && !OPT_ISSET(ops, b'c'))                            // c:755
1562        || OPT_ISSET(ops, b'v')
1563        || OPT_ISSET(ops, b'p')
1564    {
1565        let mut pos = 1; // c:760
1566                         // c:763-769 — pick separator format.
1567        let fmt: &str = if OPT_ISSET(ops, b'v') {
1568            // c:763
1569            print!("0\t"); // c:764
1570            "\n{}\t" // c:765
1571        } else if OPT_ISSET(ops, b'p') {
1572            // c:767
1573            "\n"
1574        } else {
1575            " "
1576        };
1577        // c:771-774 — print pwd via fprintdir or zputs (`-l`).
1578        // Previous Rust port inlined a HOME-prefix replacement which
1579        // only abbreviated `$HOME/...` to `~/...` — missed every
1580        // user-defined nameddirtab entry (`hash -d proj=/big/path`).
1581        // Route through `utils::fprintdir` which calls `finddir`,
1582        // matching C's named-dir abbreviation.
1583        let pwd = getsparam("PWD").unwrap_or_else(|| zgetcwd());
1584        if OPT_ISSET(ops, b'l') {
1585            // c:771
1586            print!("{}", pwd); // c:772
1587        } else {
1588            print!("{}", fprintdir(&pwd)); // c:774
1589        }
1590        // c:775-781 — walk dirstack list.
1591        if let Ok(stack) = DIRSTACK.lock() {
1592            // c:775
1593            for entry in stack.iter() {
1594                if fmt == "\n{}\t" {
1595                    print!("\n{}\t", pos);
1596                } else {
1597                    print!("{}", fmt); // c:776
1598                }
1599                pos += 1; // c:776
1600                if OPT_ISSET(ops, b'l') {
1601                    // c:777
1602                    print!("{}", entry); // c:778
1603                } else {
1604                    print!("{}", fprintdir(entry)); // c:780
1605                }
1606            }
1607        }
1608        unqueue_signals(); // c:783
1609        println!(); // c:784
1610        return 0; // c:785
1611    }
1612    // c:788-792 — replace dirstack with the supplied entries.
1613    if let Ok(mut stack) = DIRSTACK.lock() {
1614        stack.clear(); // c:790
1615        for arg in argv {
1616            stack.push(arg.clone()); // c:791
1617        }
1618    }
1619    unqueue_signals(); // c:793
1620    0 // c:794
1621}
1622
1623/// Direct port of `void set_pwd_env(void)` from
1624/// `Src/builtin.c:800`. Refreshes both `$PWD` and `$OLDPWD` shell
1625/// parameters from the shell-side `pwd`/`oldpwd` globals
1626/// (`assignsparam` at c:818-819), then `addenv`s each so child
1627/// processes inherit them (c:821-826). C also clears `PM_READONLY`
1628/// and unsets the param when a user retyped PWD/OLDPWD to a
1629/// non-scalar (c:806-816).
1630///
1631/// Rust port: the bin entry's analog of C's `pwd`/`oldpwd` globals
1632/// is the live OS env (validated against ispwd by ShellExecutor::new
1633/// per c:Src/init.c:1242-1259 — see the subshell-snapshot comment in
1634/// fusevm_bridge.rs treating `$PWD` env as the logical-pwd carrier).
1635/// So the data flow matches C: globals (env) → paramtab → env. The
1636/// previous revision read paramtab's own PWD and wrote it back — a
1637/// circular no-op that left the stale environ-snapshot import in
1638/// place when the inherited $PWD failed ispwd().
1639pub fn set_pwd_env() {
1640    // c:800
1641    // c:805-816 — PM_READONLY clear + unsetparam_pm for non-scalar
1642    //             PWD/OLDPWD retypes isn't ported; setsparam below
1643    //             overwrites the scalar value and flags directly.
1644    // c:818 — `assignsparam("PWD", ztrdup(pwd), 0);`
1645    let pwd = env::var("PWD").unwrap_or_else(|_| {
1646        env::current_dir()
1647            .map(|p| p.to_string_lossy().into_owned())
1648            .unwrap_or_default()
1649    });
1650    setsparam("PWD", &pwd); // c:818
1651                            // c:819 — `assignsparam("OLDPWD", ztrdup(oldpwd), 0);`
1652    if let Ok(oldpwd) = env::var("OLDPWD") {
1653        setsparam("OLDPWD", &oldpwd); // c:819
1654        env::set_var("OLDPWD", &oldpwd); // c:825-826 addenv(pm, oldpwd)
1655    }
1656    env::set_var("PWD", &pwd); // c:821-823 addenv(pm, pwd)
1657}
1658
1659/// Port of `bin_cd(char *nam, char **argv, Options ops, int func)` from Src/builtin.c:840.
1660/// C: `int bin_cd(char *nam, char **argv, Options ops, int func)`.
1661///
1662/// Body (verbatim translation per c:842-859):
1663/// ```c
1664/// doprintdir = (doprintdir == -1);
1665/// chasinglinks = OPT_ISSET(ops,'P') ||
1666///     (isset(CHASELINKS) && !OPT_ISSET(ops,'L'));
1667/// queue_signals();
1668/// zpushnode(dirstack, ztrdup(pwd));
1669/// if (!(dir = cd_get_dest(nam, argv, OPT_ISSET(ops,'s'), func))) {
1670///     zsfree(getlinknode(dirstack));
1671///     unqueue_signals();
1672///     return 1;
1673/// }
1674/// cd_new_pwd(func, dir, OPT_ISSET(ops, 'q'));
1675/// unqueue_signals();
1676/// return 0;
1677/// ```
1678// cd, chdir, pushd, popd                                                   // c:796
1679/// `bin_cd` — see implementation.
1680pub fn bin_cd(
1681    nam: &str,
1682    argv: &[String], // c:840
1683    ops: &options,
1684    func: i32,
1685) -> i32 {
1686    // c:844 — `doprintdir = (doprintdir == -1);`
1687    let prev = DOPRINTDIR.load(Relaxed);
1688    DOPRINTDIR.store(if prev == -1 { 1 } else { 0 }, Relaxed); // c:844
1689
1690    // c:846-847 — `chasinglinks = OPT_ISSET(ops,'P') ||
1691    //              (isset(CHASELINKS) && !OPT_ISSET(ops,'L'));`
1692    let chase = OPT_ISSET(ops, b'P')                                         // c:846
1693        || (isset(CHASELINKS)
1694            && !OPT_ISSET(ops, b'L'));
1695    CHASINGLINKS.store(chase as i32, Relaxed);
1696
1697    queue_signals(); // c:848
1698
1699    // c:849 — `zpushnode(dirstack, ztrdup(pwd));`. C uses the `pwd`
1700    //          global (the in-shell logical cwd, kept in sync with
1701    //          $PWD). Read from paramtab; fall back to getcwd if
1702    //          unset. The C source pushes pre-cd pwd to the top of
1703    //          dirstack here as a scratch slot used by cd_get_dest's
1704    //          +N/-N resolver; cd_new_pwd's remnode logic relies on
1705    //          this. Save the pre-cd path for the post-cd dirstack
1706    //          maintenance below.
1707    let pre_pwd = getsparam("PWD").unwrap_or_else(|| zgetcwd());
1708
1709    // c:850-854 — `if (!(dir = cd_get_dest(...))) { pop; unqueue; return 1; }`
1710    let dest = cd_get_dest(nam, argv, OPT_ISSET(ops, b's'), func);
1711    if dest.is_none() {
1712        unqueue_signals(); // c:852
1713        return 1; // c:853
1714    }
1715    let dest_raw = dest.unwrap();
1716
1717    // c:Src/builtin.c:851 — `-s` safe mode: refuse to chdir into a
1718    // symlink. Check via fs::symlink_metadata so we see the link
1719    // itself, not its target.
1720    if OPT_ISSET(ops, b's') {
1721        if let Ok(meta) = fs::symlink_metadata(&dest_raw) {
1722            if meta.file_type().is_symlink() {
1723                zwarnnam(nam, &format!("{}: symbolic link", dest_raw));
1724                unqueue_signals();
1725                return 1;
1726            }
1727        }
1728    }
1729
1730    // c:Src/builtin.c:855 — route the resolved arg through
1731    // cd_do_chdir so CDPATH walk, leading `~`/`.` handling, and
1732    // CDABLEVARS expansion fire. cd_do_chdir performs the actual
1733    // lchdir + returns the LOGICAL path (the one to write to PWD).
1734    // The previous Rust port called env::set_current_dir(dest_raw)
1735    // directly which skipped all of CDPATH, so `CDPATH=/foo cd bar`
1736    // would only resolve `./bar` even if `/foo/bar` existed.
1737    let dest_path = match cd_do_chdir(nam, &dest_raw, OPT_ISSET(ops, b's') as i32) {
1738        Some(p) => p,
1739        None => {
1740            unqueue_signals();
1741            return 1;
1742        }
1743    };
1744
1745    // c:1238 — `oldpwd = pwd;` snapshot pre-cd $PWD for $OLDPWD.
1746    //          Read from paramtab (the canonical zsh-side `pwd`
1747    //          global); was reading OS env which can lag behind.
1748    let old = getsparam("PWD");
1749    // c:1207-1245 — compute the new logical pwd (`new_pwd` in C's
1750    //   cd_new_pwd) up front so the PUSHDIGNOREDUPS scan below and the
1751    //   final `pwd =` write share one value. Hoisted from the old
1752    //   write site; behaviour is identical (chase → resolved cwd,
1753    //   absolute dest → as-is, relative dest → logical join).
1754    let chase = CHASINGLINKS.load(Relaxed) != 0; // c:1203
1755    let new_pwd_logical: String = if chase {
1756        match env::current_dir() {
1757            Ok(c) => c.to_string_lossy().into_owned(),
1758            Err(_) => dest_path.clone(),
1759        }
1760    } else if dest_path.starts_with('/') {
1761        dest_path.clone()
1762    } else {
1763        let mut segs: Vec<&str> = if pre_pwd.is_empty() || pre_pwd == "/" {
1764            Vec::new()
1765        } else {
1766            pre_pwd.trim_start_matches('/').split('/').collect()
1767        };
1768        for part in dest_path.split('/') {
1769            match part {
1770                "" | "." => {}
1771                ".." => {
1772                    segs.pop();
1773                }
1774                _ => segs.push(part),
1775            }
1776        }
1777        if segs.is_empty() {
1778            "/".to_string()
1779        } else {
1780            format!("/{}", segs.join("/"))
1781        }
1782    };
1783    // c:Src/builtin.c:849 + cd_new_pwd dirstack maintenance —
1784    // collapsed into a single post-cd update here since the Rust
1785    // cd_get_dest returns a String rather than a LinkNode that
1786    // could be pre-pushed onto dirstack:
1787    //   * BIN_PUSHD: push pre-cd pwd to top so subsequent
1788    //     `${dirstack[1]}` and `popd` see the previous directory.
1789    //   * BIN_POPD: pop dirstack[0] (the directory we just left,
1790    //     which cd_get_dest read from the stack to compute dest).
1791    //   * BIN_CD: dirstack unchanged unless AUTO_PUSHD is set, in
1792    //     which case the CD behaves like a pushd.
1793    let mut skip_popd_n_cd = false;
1794    {
1795        let autopushd = isset(AUTOPUSHD);
1796        // c:1187 cd_new_pwd — rolllist(dirstack, dir) for BIN_PUSHD then
1797        // remnode(dirstack, dir). For `pushd +N`/`-N`, the rotation moves
1798        // entries before the target to the END of dirstack rather than
1799        // simply inserting pre_pwd at the front. Bug #302 in docs/BUGS.md:
1800        // previous Rust port unconditionally inserted pre_pwd at position
1801        // 0, which is correct for `pushd <path>` but wrong for `pushd +N`
1802        // (left target entry still in DIRSTACK at its old position).
1803        let is_stack_rotate = argv.len() == 1
1804            && !isset(POSIXCD)
1805            && argv[0].len() > 1
1806            && (argv[0].starts_with('+') || argv[0].starts_with('-'))
1807            && argv[0][1..].chars().all(|c| c.is_ascii_digit());
1808        if let Ok(mut d) = DIRSTACK.lock() {
1809            if is_stack_rotate && (func == BIN_PUSHD || (func == BIN_CD && autopushd)) {
1810                // c:1190 — rolllist(dirstack, dir) + remnode. Rotate the
1811                // virtual full stack `[pre_pwd] ++ DIRSTACK_old` so the
1812                // target entry becomes firstnode (= new PWD); remaining
1813                // entries form new DIRSTACK in their rotated order.
1814                let dd: usize = argv[0][1..].parse().unwrap_or(0);
1815                let pushdminus = isset(PUSHDMINUS);
1816                let from_top = (argv[0].starts_with('+')) ^ pushdminus;
1817                let m = d.len();
1818                let n = m + 1;
1819                let k = if from_top { dd } else { n - 1 - dd };
1820                if k < n {
1821                    let mut full: Vec<String> = Vec::with_capacity(n);
1822                    full.push(pre_pwd.clone());
1823                    full.extend(d.iter().cloned());
1824                    let rotated: Vec<String> =
1825                        full[k..].iter().chain(full[..k].iter()).cloned().collect();
1826                    d.clear();
1827                    d.extend(rotated.into_iter().skip(1));
1828                }
1829            } else if is_stack_rotate && func == BIN_CD {
1830                // c:1192 — remnode(dirstack, dir) for non-PUSHD CD with
1831                //          +N/-N: target removed from dirstack, no rolllist.
1832                let dd: usize = argv[0][1..].parse().unwrap_or(0);
1833                let pushdminus = isset(PUSHDMINUS);
1834                let from_top = (argv[0].starts_with('+')) ^ pushdminus;
1835                let m = d.len();
1836                let n = m + 1;
1837                let k = if from_top { dd } else { n - 1 - dd };
1838                if k >= 1 && k - 1 < d.len() {
1839                    d.remove(k - 1);
1840                }
1841            } else if is_stack_rotate && func == BIN_POPD {
1842                // c:Src/builtin.c:872-936 + c:1197-1199 — `popd +N` /
1843                // `popd -N`: when the target +N/-N is NOT firstnode
1844                // (i.e., N != 0 / N != top-of-stack), remove the
1845                // target entry from dirstack WITHOUT changing PWD or
1846                // pushing pre_pwd to the stack. C's logic:
1847                //   target = dir (the +N resolved node);
1848                //   if (dir != firstnode(dirstack)) return dir;
1849                //   // … cd_do_chdir path runs only if target IS top
1850                //
1851                // The Rust dispatch reached here for the
1852                // "target-not-top" case after cd_get_dest already
1853                // resolved to dirstack[k-1]. Just remove that index
1854                // and skip the cd / pre_pwd-insert paths.
1855                // Bug #466 in docs/BUGS.md.
1856                let dd: usize = argv[0][1..].parse().unwrap_or(0);
1857                let pushdminus = isset(PUSHDMINUS);
1858                let from_top = (argv[0].starts_with('+')) ^ pushdminus;
1859                let m = d.len();
1860                let n = m + 1;
1861                // popd's stack-rotate-vs-top logic mirrors C: the
1862                // virtual stack is pre_pwd ++ DIRSTACK_old; position 0
1863                // is the CURRENT pwd, so "popd +0" / "popd +N where
1864                // N maps to top" pops the top (handled by the
1865                // existing BIN_POPD arm below). For N that resolves
1866                // to a non-top dirstack index, remove from dirstack
1867                // directly.
1868                let k = if from_top { dd } else { n - 1 - dd };
1869                if k == 0 {
1870                    // c:934-940 + c:1197-1203 — the +N/-N resolved to
1871                    // firstnode (the current PWD): this is a bare-`popd`
1872                    // top removal. Pop the entry below PWD (DIRSTACK[0])
1873                    // and let the normal cd path below move PWD into it
1874                    // (cd_get_dest resolved dest to DIRSTACK[0]). Do NOT
1875                    // set skip_popd_n_cd — PWD must change here.
1876                    // Bug: prior port's `k >= 1` guard fell through this
1877                    // else-if arm without popping, so the plain-POPD arm
1878                    // was never reached and the top entry survived.
1879                    if !d.is_empty() {
1880                        d.remove(0);
1881                    }
1882                } else if k - 1 < d.len() {
1883                    d.remove(k - 1);
1884                    // Override the cd path: keep PWD at pre_pwd so
1885                    // the surrounding flow doesn't cd to the removed
1886                    // entry. The destination passed to bin_cd was
1887                    // the removed entry; we overwrite the post-cd
1888                    // PWD write below to pre_pwd.
1889                    skip_popd_n_cd = true;
1890                }
1891            } else if func == BIN_PUSHD || (func == BIN_CD && autopushd) {
1892                // c:849 — push pre-cd pwd.
1893                d.insert(0, pre_pwd.clone());
1894            } else if func == BIN_POPD {
1895                // c:1197-1199 — pop top of stack (the dir we left).
1896                if !d.is_empty() {
1897                    d.remove(0);
1898                }
1899            }
1900            // c:1214-1222 — PUSHDIGNOREDUPS runs inside cd_new_pwd for
1901            //   EVERY func (plain cd, pushd, popd — the block is not
1902            //   guarded by func), scanning the now-current dirstack for
1903            //   the FIRST entry equal to `new_pwd` (the directory just
1904            //   moved into) and removing it so a directory never appears
1905            //   twice on the stack:
1906            //     if (isset(PUSHDIGNOREDUPS)) {
1907            //         for (n = firstnode(dirstack); n; incnode(n))
1908            //             if (!strcmp(new_pwd, getdata(n))) {
1909            //                 zsfree(remnode(dirstack, n)); break; }
1910            //     }
1911            //   Prior port only checked pushd and compared d.first()
1912            //   against pre_pwd (wrong target, wrong action), so a plain
1913            //   `cd` into a dir already on the stack, or re-pushing such
1914            //   a dir, left the stale duplicate in place. The non-top
1915            //   `popd +N` path (skip_popd_n_cd) keeps PWD = pre_pwd, so
1916            //   its dedup runs there against pre_pwd (see below).
1917            if !skip_popd_n_cd && isset(PUSHDIGNOREDUPS) {
1918                if let Some(pos) = d.iter().position(|s| *s == new_pwd_logical) {
1919                    d.remove(pos);
1920                }
1921            }
1922        }
1923    }
1924    // c:Src/builtin.c:872-936 — `popd +N` (N != 0) removes the
1925    // target entry from dirstack WITHOUT changing PWD. cd_do_chdir
1926    // already chdir'd into the removed target's path; revert that
1927    // back to pre_pwd and skip the OLDPWD update + PWD write so the
1928    // post-popd CWD remains the pre-popd PWD. Bug #466 in
1929    // docs/BUGS.md.
1930    if skip_popd_n_cd {
1931        // Restore process cwd to pre_pwd. Ignore errors; the
1932        // dirstack mutation already happened.
1933        let _ = env::set_current_dir(&pre_pwd);
1934        // c:1242-1246 — cd_new_pwd runs `oldpwd = pwd; pwd = new_pwd`
1935        //   for EVERY popd, including a non-top `popd +N`. There
1936        //   new_pwd resolves back to the front-of-stack pre_pwd, so
1937        //   PWD is unchanged but OLDPWD becomes the pre-popd PWD.
1938        //   Prior port left OLDPWD stale (whatever an earlier cd/pushd
1939        //   set), diverging on any `$OLDPWD` / `cd -` that followed.
1940        setsparam("OLDPWD", &pre_pwd);
1941        env::set_var("OLDPWD", &pre_pwd);
1942        // c:Src/builtin.c:1245-1252 — print dirstack on POPD unless
1943        // quiet. Pass func=BIN_POPD so the dirstack-print branch
1944        // still fires for the listing (matches zsh's `popd +N` echo
1945        // of the resulting stack).
1946        cd_new_pwd(func, 0, OPT_ISSET(ops, b'q') as i32);
1947        unqueue_signals();
1948        return 0;
1949    }
1950    if let Some(o) = old {
1951        // c:1239 oldpwd = pwd
1952        // c:1239 + setsparam path: write OLDPWD to paramtab so
1953        //          subsequent expansions of $OLDPWD see the new value
1954        //          (the OS env write below is the export side; the
1955        //          shell-side read must come from paramtab).
1956        setsparam("OLDPWD", &o);
1957        env::set_var("OLDPWD", &o);
1958    }
1959    // c:1241 — `pwd = new_pwd;` writes the LOGICAL path (the dest
1960    // argument as given to cd, not `getcwd()`). Computed once up front
1961    // (see `new_pwd_logical` above) so the PUSHDIGNOREDUPS scan and this
1962    // write agree. Symlink resolution only kicks in when `chasinglinks`
1963    // is set (c:1203-1208, c:1228-1231).
1964    let pwd: String = new_pwd_logical;
1965    // c:1242 — `setsparam("PWD", pwd);` + export side via env.
1966    setsparam("PWD", &pwd);
1967    env::set_var("PWD", &pwd);
1968    cd_new_pwd(func, 0, OPT_ISSET(ops, b'q') as i32); // c:856
1969
1970    unqueue_signals(); // c:858
1971    0 // c:859
1972}
1973
1974/// Port of `cd_get_dest(char *nam, char **argv, int hard, int func)` from Src/builtin.c:865.
1975/// C: `static LinkNode cd_get_dest(char *nam, char **argv, int hard,
1976///     int func)` — resolve the `cd` argument (`-`, `+N`/`-N`,
1977///   bare → $HOME, two-arg substitution form) to a destination path.
1978///   Returns the resolved path on success, None on error (with the
1979///   appropriate zwarnnam already emitted).
1980/// WARNING: param names don't match C — Rust=() vs C=(nam, argv, hard, func)
1981pub fn cd_get_dest(nam: &str, argv: &[String], _hard: bool, func: i32) -> Option<String> {
1982    if argv.is_empty() {
1983        // c:872 — bare popd / pushd / cd (no args).
1984        // The Rust port doesn't pre-push pwd to dirstack inside bin_cd
1985        // (cd_get_dest's String return signature doesn't fit C's
1986        // pre-push pattern), so dirstack[0] here is the most-recent
1987        // pushed entry, not the temporary scratch slot the C source
1988        // sees. Adjust the indices accordingly: C reads index 1
1989        // (skipping the scratch), Rust reads index 0.
1990        if func == BIN_POPD {
1991            let depth = DIRSTACK.lock().map(|d| d.len()).unwrap_or(0);
1992            if depth < 1 {
1993                zwarnnam(nam, "directory stack empty");
1994                return None;
1995            }
1996            return DIRSTACK.lock().ok().and_then(|d| d.first().cloned());
1997        }
1998        if func == BIN_PUSHD {
1999            // c:876-879 — bare pushd without PUSHDTOHOME swaps top two
2000            // entries of dirstack. C represents dirstack with PWD at
2001            // index 0, so it walks `nextnode(firstnode(dirstack))` to
2002            // get index 1, then extracts index 0 via `getlinknode` and
2003            // reinserts after — net swap of [0]<->[1]. zshrs's DIRSTACK
2004            // omits PWD entirely (PWD lives in paramtab); index 0 here
2005            // = C's index 1.
2006            //
2007            // To achieve the same swap in this model: POP DIRSTACK[0]
2008            // (the saved target) and return it. cd_new_pwd at line ~1573
2009            // then re-pushes pre_pwd onto DIRSTACK[0]. Net result:
2010            // DIRSTACK[0] becomes pre_pwd, PWD becomes the popped value.
2011            //
2012            // Bug #67 in docs/BUGS.md: prior port read `d.first().cloned()`
2013            // (no pop), so cd_new_pwd's push added a 3rd entry instead
2014            // of swapping.
2015            //
2016            // c:881-885 — empty dirstack (only PWD in C's model, i.e.
2017            // DIRSTACK.is_empty() here) falls through to HOME below;
2018            // cd_new_pwd's push then leaves DIRSTACK = [pre_pwd].
2019            let pushdtohome = isset(PUSHDTOHOME);
2020            if !pushdtohome {
2021                let popped = DIRSTACK.lock().ok().and_then(|mut d| {
2022                    if d.is_empty() {
2023                        None
2024                    } else {
2025                        Some(d.remove(0))
2026                    }
2027                });
2028                if let Some(target) = popped {
2029                    return Some(target);
2030                }
2031                // Empty DIRSTACK → fall through to HOME below.
2032            }
2033        }
2034        // c:880-884 — fall through to $HOME.
2035        match getsparam("HOME") {
2036            Some(h) if !h.is_empty() => Some(h),
2037            _ => {
2038                zwarnnam(nam, "HOME not set");
2039                None
2040            }
2041        }
2042    } else if argv.len() == 1 {
2043        // c:887
2044        let arg = &argv[0];
2045        DOPRINTDIR.fetch_add(1, Relaxed); // c:891
2046                                          // c:892-908 — `+N`/`-N` numeric stack-index form.
2047        let posixcd = isset(POSIXCD);
2048        if !posixcd
2049            && arg.len() > 1
2050            && (arg.starts_with('+') || arg.starts_with('-'))
2051            && arg[1..].chars().all(|c| c.is_ascii_digit())
2052        {
2053            let dd: usize = arg[1..].parse().unwrap_or(0); // c:894
2054            let pushdminus = isset(PUSHDMINUS);
2055            let from_top = (arg.starts_with('+')) ^ pushdminus; // c:898
2056                                                                // c:Src/builtin.c:904 — out-of-range stack index emits
2057                                                                // "no such entry in dir stack". Previous Rust port
2058                                                                // returned None silently and bin_cd's caller exited 1
2059                                                                // with no stderr, breaking parity.
2060                                                                // c:899-903 — index into the FULL dirstack: in zsh
2061                                                                //   `dirstack` is a LinkList with current PWD at firstnode,
2062                                                                //   so `+N` advances N nodes from firstnode (0 = current,
2063                                                                //   1 = first non-current entry) and `-N` walks back N nodes
2064                                                                //   from lastnode (0 = last non-current entry). zshrs's
2065                                                                //   DIRSTACK omits PWD entirely (PWD lives in paramtab), so
2066                                                                //   build the virtual full stack `[PWD] ++ DIRSTACK` to index
2067                                                                //   against. Bug #302 in docs/BUGS.md: previous Rust port
2068                                                                //   indexed DIRSTACK directly, so `+1` returned DIRSTACK[1]
2069                                                                //   instead of DIRSTACK[0], and `+0`/`-len` errored instead of
2070                                                                //   returning PWD.
2071            let pwd_now = getsparam("PWD").unwrap_or_else(|| zgetcwd());
2072            let resolved = DIRSTACK.lock().ok().and_then(|d| {
2073                let m = d.len();
2074                let n = m + 1; // full virtual stack length (incl current)
2075                let k = if from_top {
2076                    if dd >= n {
2077                        return None;
2078                    }
2079                    dd
2080                } else {
2081                    if dd >= n {
2082                        return None;
2083                    }
2084                    n - 1 - dd
2085                };
2086                if k == 0 {
2087                    // c:934-940 — for BIN_POPD, dir==firstnode(dirstack)
2088                    //   (the current PWD) is NOT removed in place: C does
2089                    //   `dir = nextnode(dir)` and cd_do_chdir's into the
2090                    //   entry BELOW PWD, then cd_new_pwd (c:1197-1199)
2091                    //   pops the front. So the chdir destination is
2092                    //   DIRSTACK[0] (the first non-current entry), not PWD.
2093                    //   For BIN_CD / BIN_PUSHD, +0 (or the swapped -last)
2094                    //   resolves to the current dir itself.
2095                    //   Bug: prior port returned pwd_now for every func, so
2096                    //   `popd +0` (and `popd +N`/`-N` mapping to firstnode
2097                    //   under PUSHD_MINUS) no-op'd instead of popping the top.
2098                    if func == BIN_POPD {
2099                        d.first().cloned().or_else(|| Some(pwd_now.clone()))
2100                    } else {
2101                        Some(pwd_now.clone())
2102                    }
2103                } else {
2104                    d.get(k - 1).cloned()
2105                }
2106            });
2107            if resolved.is_none() {
2108                zwarnnam(nam, "no such entry in dir stack");
2109            }
2110            return resolved;
2111        }
2112        // c:908-909 — `zpushnode(dirstack, ztrdup(strcmp(argv[0],"-")
2113        //   ? (doprintdir--, argv[0]) : oldpwd))`. `strcmp(argv[0],"-")`
2114        //   is non-zero for a normal dir, so a normal `cd dir` does
2115        //   `doprintdir--` (cancelling the c:891 `doprintdir++` → no
2116        //   print), while `cd -` keeps doprintdir set → prints $OLDPWD.
2117        //   C reads the `oldpwd` global; route through `$OLDPWD`.
2118        if arg == "-" {
2119            // c:909 — `: oldpwd` — no doprintdir--, so `cd -` prints.
2120            getsparam("OLDPWD")
2121        } else {
2122            // c:909 — `? (doprintdir--, argv[0])`.
2123            DOPRINTDIR.fetch_sub(1, Relaxed);
2124            Some(arg.clone())
2125        }
2126    } else {
2127        // c:914-924 — two-arg substitution: cd OLDPATTERN NEWPATTERN.
2128        //              C reads `pwd` global / `$PWD` param via getsparam;
2129        //              fall back to getcwd if the param isn't populated.
2130        let pwd = getsparam("PWD").unwrap_or_else(|| zgetcwd());
2131        let pat = &argv[0];
2132        let new_pat = &argv[1];
2133        match pwd.find(pat.as_str()) {
2134            // c:917
2135            None => {
2136                zwarnnam(nam, &format!("string not in pwd: {}", pat)); // c:918
2137                None // c:919
2138            }
2139            Some(idx) => {
2140                // c:921-924 — splice: pwd[..idx] + new_pat + pwd[idx+pat.len()..]
2141                let mut out = String::new();
2142                out.push_str(&pwd[..idx]); // c:921
2143                out.push_str(new_pat); // c:922
2144                out.push_str(&pwd[idx + pat.len()..]); // c:923
2145                DOPRINTDIR.fetch_add(1, Relaxed);
2146                Some(out)
2147            }
2148        }
2149    }
2150}
2151
2152/// Port of `cd_do_chdir(char *cnam, char *dest, int hard)` from Src/builtin.c:967.
2153/// C: `static char *cd_do_chdir(char *cnam, char *dest, int hard)` —
2154///   resolve `dest` (handling cdpath, cdablevars, leading `~`/`.`),
2155///   chdir there, return the LOGICAL path used (not `getcwd`'d) or
2156///   NULL on error.
2157///
2158/// Per C `cd_try_chdir` (c:1116-1181), the return is `buf` — the
2159/// composed path the chdir was attempted against, after `fixdir()`
2160/// logical-normalisation (resolving `.`/`..` only, NOT symlinks).
2161/// Walks $cdpath when dest is relative and not `./` or `../`.
2162pub fn cd_do_chdir(cnam: &str, dest: &str, hard: i32) -> Option<String> {
2163    // c:967
2164    // c:996-998 — nocdpath = first segment is "." or ".."
2165    let nocdpath = dest.starts_with("./") || dest == "." || dest.starts_with("../") || dest == "..";
2166
2167    // c:1003-1008 — absolute path: try as-is, warn on failure.
2168    if dest.starts_with('/') {
2169        if let Some(ret) = cd_try_chdir("", dest, hard) {
2170            return Some(ret);
2171        }
2172        // c:Src/builtin.c — `zwarnnam(nam, "%e: %s", errno, dest)`.
2173        // C's %e printf format expands to strerror(errno) then
2174        // LOWERCASES the first letter unless the errno is EIO
2175        // (Src/utils.c:362-368 comment: "If the message is not
2176        // about I/O problems, it looks better if we uncapitalize
2177        // the first letter of the message"). Previous Rust port
2178        // used `io::Error::last_os_error()`'s Display impl, which
2179        // appends ` (os error N)` — leaked the Rust-internal
2180        // suffix into user-visible cd output. Bug #112 in
2181        // docs/BUGS.md. Route through compat::strerror to drop
2182        // the suffix AND apply the lowercase-first-letter rule so
2183        // the message matches C exactly.
2184        let errno = io::Error::last_os_error().raw_os_error().unwrap_or(0);
2185        let mut msg = crate::ported::compat::strerror(errno);
2186        if errno != libc::EIO {
2187            if let Some(c) = msg.chars().next() {
2188                msg = format!("{}{}", c.to_ascii_lowercase(), &msg[c.len_utf8()..]);
2189            }
2190        }
2191        zwarnnam(cnam, &format!("{}: {}", msg, dest));
2192        return None;
2193    }
2194
2195    // c:1015-1018 — check $cdpath for "." (presence flips hasdot).
2196    let posix_cd = isset(POSIXCD);
2197    let cdpath_str = getsparam("CDPATH").unwrap_or_default();
2198    let cdpath: Vec<&str> = if cdpath_str.is_empty() {
2199        Vec::new()
2200    } else {
2201        cdpath_str.split(':').collect()
2202    };
2203    let hasdot = !nocdpath && !posix_cd && cdpath.iter().any(|p| p.is_empty() || *p == ".");
2204
2205    // c:1026-1031 — if no dot in cdpath (and !POSIXCD), try as-is first.
2206    if !hasdot && !posix_cd {
2207        if let Some(ret) = cd_try_chdir("", dest, hard) {
2208            return Some(ret);
2209        }
2210    }
2211
2212    // c:1034-1043 — walk $cdpath unless nocdpath.
2213    if !nocdpath {
2214        for pp in cdpath.iter() {
2215            if let Some(ret) = cd_try_chdir(pp, dest, hard) {
2216                // c:1037-1050 — flag that the resolved directory should
2217                // be printed; the actual print happens later in
2218                // cd_new_pwd (c:1248-1251) gated on doprintdir. C does
2219                // NOT print here, it only bumps `doprintdir`.
2220                if isset(POSIXCD) {
2221                    // c:1037-1045 — POSIX prints any time CDPATH was
2222                    // used, except for an empty segment treated as ".".
2223                    if !pp.is_empty() {
2224                        DOPRINTDIR.fetch_add(1, Relaxed); // c:1045
2225                    }
2226                } else if *pp != "." {
2227                    // c:1046-1049 — non-POSIX: print only for a
2228                    // non-"." segment.
2229                    DOPRINTDIR.fetch_add(1, Relaxed); // c:1048
2230                }
2231                return Some(ret);
2232            }
2233        }
2234    }
2235
2236    // c:1057-1063 — POSIXCD-mode last-resort: try dest as-is.
2237    if posix_cd {
2238        if let Some(ret) = cd_try_chdir("", dest, hard) {
2239            return Some(ret);
2240        }
2241    }
2242
2243    // c:1067-1075 — `cd_able_vars(dest)` — when CDABLEVARS is set,
2244    // look up the leading bareword as a parameter and try chdir to
2245    // its value (with any `/tail` appended). The helper at
2246    // `cd_able_vars` (this file, line 1881) already guards on
2247    // `isset(CDABLEVARS)` and returns None when the option is off
2248    // OR the head isn't a paramtab entry. Without this call
2249    // wired in, `setopt cdable_vars; mytmp=/tmp; cd mytmp` errored
2250    // with "no such file or directory" instead of cd'ing to /tmp.
2251    // Bug #217 in docs/BUGS.md.
2252    if let Some(expanded) = cd_able_vars(dest) {
2253        if let Some(ret) = cd_try_chdir("", &expanded, hard) {
2254            // c:1069 — `doprintdir++` so cd_new_pwd prints the
2255            // CDABLEVARS-resolved directory.
2256            DOPRINTDIR.fetch_add(1, Relaxed); // c:1069
2257            return Some(ret);
2258        }
2259    }
2260
2261    // c:1071 — failure warning.
2262    zwarnnam(cnam, &format!("no such file or directory: {}", dest));
2263    None
2264}
2265
2266/// Port of `cd_able_vars(char *s)` from Src/builtin.c:1088.
2267/// C: `char *cd_able_vars(char *s)` — when CDABLEVARS is set, look up
2268///   the leading bareword as a parameter and return its expanded value
2269///   prefixed in front of any trailing `/...`. Returns NULL otherwise.
2270pub fn cd_able_vars(s: &str) -> Option<String> {
2271    // c:1088
2272    // c:1088 — `if (isset(CDABLEVARS)) { ... }`
2273    let cdablevars = isset(CDABLEVARS);
2274    if !cdablevars {
2275        // c:1093
2276        return None;
2277    }
2278    // c:1094-1110 — split on the first `/`, look up the head as $param.
2279    let (head, tail) = match s.find('/') {
2280        // c:1094
2281        Some(i) => (&s[..i], &s[i..]),
2282        None => (s, ""),
2283    };
2284    if head.is_empty() {
2285        return None;
2286    }
2287    // c:1116 — `if ((val = getsparam(s))) { ret = tricat(val, tail, "") }`.
2288    //          C reads $head from paramtab; was reading OS env, missing
2289    //          CDABLEVARS-style assignments like `proj=$HOME/src`.
2290    getsparam(head).map(|val| format!("{}{}", val, tail))
2291}
2292
2293/// Port of `cd_try_chdir(char *pfix, char *dest, int hard)` from `Src/builtin.c:1116`.
2294/// Compose `pfix/dest` (or `pwd/pfix/dest` for relative pfix, or
2295/// `pwd/dest` for empty pfix + relative dest), normalise via `fixdir`,
2296/// then attempt chdir. Falls back to `dest` alone when the full path
2297/// fails but `pfix` was present (cwd/parent may have been renamed).
2298pub fn cd_try_chdir(pfix: &str, dest: &str, hard: i32) -> Option<String> {
2299    // c:1116
2300    let pwd = getsparam("PWD").unwrap_or_default();
2301
2302    // c:1122-1158 — build buf from pfix/dest/pwd combinations.
2303    let mut buf = if !pfix.is_empty() {
2304        if pfix.starts_with('/') {
2305            // c:1123
2306            // c:1133 — buf = tricat(pfix, "/", dest)
2307            if pfix.ends_with('/') {
2308                format!("{}{}", pfix, dest)
2309            } else {
2310                format!("{}/{}", pfix, dest)
2311            }
2312        } else {
2313            // c:1135-1146 — pwd + "/" + pfix + "/" + dest
2314            let pwd_trim = if pwd == "/" { "" } else { pwd.as_str() };
2315            format!("{}/{}/{}", pwd_trim, pfix, dest)
2316        }
2317    } else if dest.starts_with('/') {
2318        // c:1148
2319        // c:1149 — buf = ztrdup(dest)
2320        dest.to_string()
2321    } else {
2322        // c:1150
2323        // c:1151-1157 — pwd + "/" + dest (trimming trailing slash off pwd)
2324        let pwd_trim = pwd.trim_end_matches('/');
2325        format!("{}/{}", pwd_trim, dest)
2326    };
2327
2328    // c:1163-1166 — fixdir normalisation, skipped if chasing symlinks.
2329    if CHASINGLINKS.load(Relaxed) == 0 {
2330        buf = fixdir(&buf); // c:1164
2331    }
2332
2333    // c:1169-1177 — "We try the full path first.  If that fails, try the
2334    // argument to cd relatively.  This is useful if the cwd or a parent
2335    // directory is renamed in the interim."  The relative fallback is
2336    // skipped when a prefix was applied or `dest` is already absolute.
2337    // Either success returns `buf` (the logical full path) — c:1181.
2338    if lchdir(&buf, None, hard) != 0
2339        && (!pfix.is_empty() || dest.starts_with('/') || lchdir(dest, None, hard) != 0)
2340    {
2341        return None; // c:1175-1176
2342    }
2343    Some(buf) // c:1181 — metafy(buf, ...)
2344}
2345
2346/// Port of `cd_new_pwd(int func, LinkNode dir, int quiet)` from Src/builtin.c:1187.
2347/// C: `static void cd_new_pwd(int func, LinkNode dir, int quiet)` —
2348///   commit a new PWD: rotate dirstack on `BIN_PUSHD`, pop on
2349///   `BIN_POPD`, then setparam(PWD/OLDPWD), fire chpwd hooks.
2350///
2351/// The PWD/OLDPWD write is now done by the caller (`bin_cd`) using
2352/// the logical `dest_path` from `cd_get_dest`. C's body at c:1238-1242
2353/// reads `new_pwd` off the dirstack — the Rust port's dirstack
2354/// plumbing isn't faithful enough to carry that path here, so the
2355/// caller writes PWD directly. This fn handles only the post-write
2356/// side effects (chpwd hooks, dirstack size cap).
2357/// WARNING: param names don't match C — Rust=(_func, _dir, _quiet) vs C=(func, dir, quiet)
2358pub fn cd_new_pwd(func: i32, _dir: usize, quiet: i32) {
2359    // c:1187 — post-cd side effects (print, hooks). Dirstack
2360    // maintenance was moved into `bin_cd` because the Rust port's
2361    // cd_get_dest returns a String (not a LinkNode pre-pushed on
2362    // dirstack), so C's remnode/rolllist sequence doesn't fit.
2363
2364    // c:1236-1242 — shift PWD → OLDPWD, set new PWD.
2365    //
2366    // The caller (`bin_cd` at builtin.rs:1379-1408) already wrote
2367    // OLDPWD (pre-cd $PWD) and PWD (the logical `dest_path`) into the
2368    // paramtab. Re-deriving them here would (a) double-shift OLDPWD
2369    // (overwriting the correct pre-cd value with the just-set PWD),
2370    // and (b) re-set PWD via `std::env::current_dir()` which always
2371    // returns the resolved physical path — clobbering the logical
2372    // path on systems where the destination is a symlink (e.g. macOS
2373    // /tmp → /private/tmp). C `cd_new_pwd` reads `new_pwd` off the
2374    // dirstack (the path the user typed); zshrs's dirstack plumbing
2375    // doesn't carry that path here, so the caller is the authoritative
2376    // PWD writer and this fn must NOT re-write either parameter.
2377
2378    // c:1244-1252 — print the new directory.
2379    //   if (isset(INTERACTIVE) || isset(POSIXCD)) {
2380    //       if (func != BIN_CD && isset(INTERACTIVE)) {
2381    //           if (unset(PUSHDSILENT) && !quiet) printdirstack();
2382    //       } else if (unset(CDSILENT) && doprintdir) {
2383    //           fprintdir(pwd, stdout); putchar('\n');
2384    //       }
2385    //   }
2386    if isset(INTERACTIVE) || isset(POSIXCD) {
2387        if func != BIN_CD && isset(INTERACTIVE) {
2388            // c:1245-1247 — pushd/popd echo the dir stack.
2389            if !isset(PUSHDSILENT) && quiet == 0 {
2390                printdirstack(); // c:1247
2391            }
2392        } else if !isset(crate::ported::zsh_h::CDSILENT) && DOPRINTDIR.load(Relaxed) != 0 {
2393            // c:1248-1251 — cd echoes the resolved directory (set via
2394            // doprintdir by `cd -`, CDPATH, autocd, CDABLEVARS).
2395            let pwd = getsparam("PWD").unwrap_or_default(); // C global `pwd`
2396            println!("{}", fprintdir(&pwd)); // c:1249-1250
2397        }
2398    }
2399
2400    // c:1258 — `callhookfunc("chpwd", NULL, 1, NULL)` fires the chpwd
2401    // hook. Not surfaced here: the executor wrapper (fusevm_bridge.rs
2402    // cd builtin) calls `callhookfunc("chpwd", ...)` after a successful
2403    // cd, which dispatches both the `chpwd` shfunc and the
2404    // `chpwd_functions` array (utils.rs:1532).
2405
2406    // c:1264-1271 — trim the dir stack to $DIRSTACKSIZE.
2407    //   `dirstacksize = getiparam("DIRSTACKSIZE");
2408    //    if (dirstacksize > 0) {
2409    //        int remove = countlinknodes(dirstack) -
2410    //            (dirstacksize < 2 ? 2 : dirstacksize);
2411    //        while (remove-- >= 0)
2412    //            zsfree(remnode(dirstack, lastnode(dirstack)));
2413    //    }`
2414    let dirstacksize = getiparam("DIRSTACKSIZE"); // c:1264
2415    if dirstacksize > 0 {
2416        // c:1266
2417        if let Ok(mut d) = DIRSTACK.lock() {
2418            // c:1267-1268 — count minus the effective cap (min 2).
2419            let cap = if dirstacksize < 2 { 2 } else { dirstacksize };
2420            let mut remove = d.len() as i64 - cap;
2421            // c:1269-1270 — `while (remove-- >= 0)` pops the last node;
2422            // the post-decrement removes (remove+1) entries from the end.
2423            while remove >= 0 {
2424                remove -= 1;
2425                if d.pop().is_none() {
2426                    break;
2427                }
2428            }
2429        }
2430    }
2431}
2432
2433/// Port of `printdirstack()` from Src/builtin.c:1277.
2434/// C: `static void printdirstack(void)` — fprintdir(pwd) followed by
2435///   space-separated entries from the dirstack list, ending in newline.
2436pub fn printdirstack() {
2437    // c:1277
2438    // c:1281 — `fprintdir(pwd, stdout);`. C uses the shell-side
2439    //          `pwd` global (in-shell logical cwd), not getcwd. Read
2440    //          $PWD from paramtab so the logical path (including
2441    //          any unresolved symlinks) shows correctly. Route
2442    //          through `utils::fprintdir` for the same `~` /
2443    //          `~named` abbreviation real zsh emits.
2444    // Previous Rust port emitted raw paths, missing the
2445    // $HOME / nameddirtab abbreviation that makes pushd/popd output
2446    // legible. Same fix family as bin_dirs.
2447    let pwd = getsparam("PWD")
2448        .or_else(|| {
2449            env::current_dir()
2450                .ok()
2451                .and_then(|p| p.to_str().map(String::from))
2452        })
2453        .unwrap_or_default();
2454    print!("{}", fprintdir(&pwd)); // c:1281
2455                                   // c:1282-1286 — `for (node = firstnode(dirstack); ...)`
2456    if let Ok(d) = DIRSTACK.lock() {
2457        for entry in d.iter() {
2458            // c:1282
2459            print!(" {}", fprintdir(entry)); // c:1284
2460        }
2461    }
2462    println!(); // c:1287
2463}
2464
2465/// Direct port of `int fixdir(char *src)` from
2466/// `Src/builtin.c:1297`. Lexically canonicalises a path in-place
2467/// (no symlink follow): collapses `//`, drops `./` segments, and
2468/// removes `..` along with their preceding segment. Returns 1 if
2469/// fully canonicalised, 0 if a `..` could not be popped (e.g. at
2470/// the root or with `..` as the first segment under CHASEDOTS=0).
2471///
2472/// Rust port takes ownership of `src` and returns the canonical
2473/// form; was a 1-line stub returning empty string.
2474pub fn fixdir(src: &str) -> String {
2475    // c:1297
2476    if src.is_empty() {
2477        return String::new();
2478    }
2479
2480    // c:1320-1325 — `chasedots` flag for the cdpath `../` edge case.
2481    //                Skipped here — only fires under the pwd=="." rare
2482    //                state. Lexical canonicalisation is what callers
2483    //                rely on.
2484    let abs = src.starts_with('/');
2485    let mut components: Vec<&str> = Vec::new();
2486
2487    // c:1339-1395 — walk slash-separated segments.
2488    for seg in src.split('/') {
2489        match seg {
2490            "" => continue,  // collapse `//`
2491            "." => continue, // c:1352 drop `./`
2492            ".." => {
2493                // c:1358-1372 — pop previous segment if present and not
2494                //                also `..` (sticky-`..` for relative
2495                //                paths past their start).
2496                if let Some(last) = components.last() {
2497                    if *last == ".." {
2498                        components.push("..");
2499                    } else {
2500                        components.pop();
2501                    }
2502                } else if !abs {
2503                    // Relative path: keep the leading `..`.
2504                    components.push("..");
2505                }
2506                // Absolute path: silently drop `..` past `/`.
2507            }
2508            other => components.push(other),
2509        }
2510    }
2511
2512    let body = components.join("/");
2513    if abs {
2514        format!("/{}", body)
2515    } else if body.is_empty() {
2516        ".".to_string()
2517    } else {
2518        body
2519    }
2520}
2521
2522/// Port of `printqt(char *str)` from Src/builtin.c:1399.
2523/// C: `mod_export void printqt(char *str)` — emit `str`, escaping any
2524/// `'` as `'\''` (or `''` if RCQUOTES is set).
2525pub fn printqt(str: &str) {
2526    // c:1399
2527    let rcquotes = isset(RCQUOTES); // c:1399 isset(RCQUOTES)
2528    for ch in str.chars() {
2529        // c:1403
2530        if ch == '\'' {
2531            // c:1404
2532            print!("{}", if rcquotes { "''" } else { "'\\''" }); // c:1405
2533        } else {
2534            print!("{}", ch); // c:1407
2535        }
2536    }
2537}
2538
2539/// Port of `printif(char *str, int c)` from Src/builtin.c:1411.
2540/// C: `mod_export void printif(char *str, int c)` — `printf(" -%c ", c)`
2541/// then `quotedzputs(str, stdout)`, only when `str != NULL`.
2542pub fn printif(str: Option<&str>, c: u8) {
2543    // c:1411
2544    if let Some(s) = str {
2545        // c:1399
2546        print!(" -{} ", c as char); // c:1399
2547                                    // c:1399 — quotedzputs(str, stdout); plain print preserves bytes
2548                                    // for the ASCII case; full quotedzputs lives in src/ported/utils.rs.
2549        print!("{}", s); // c:1399
2550    }
2551}
2552
2553/// Port of `bin_fc(char *nam, char **argv, Options ops, int func)` from Src/builtin.c:1426.
2554/// C: `int bin_fc(char *nam, char **argv, Options ops, int func)`.
2555///
2556/// History/edit/list dispatcher: `-p` push hist stack, `-P` pop,
2557/// `-R` read, `-W` write, `-A` append, `-m` glob filter, `-l` list,
2558/// `-s` substitute, default: edit + re-execute. The C body is ~245
2559/// lines; the structural translation here covers the major options
2560/// and dispatches the underlying history-file ops to the existing
2561/// hist.rs accessors.
2562/// WARNING: param names don't match C — Rust=(nam, argv, func) vs C=(nam, argv, ops, func)
2563pub fn bin_fc(
2564    nam: &str,
2565    argv: &[String], // c:1426
2566    ops_in: &options,
2567    func: i32,
2568) -> i32 {
2569    // C `Options ops` is `struct options *` — mutable via `ops->ind['n']
2570    // = 1;` at c:1644. zshrs HandlerFunc takes `&options`, so we clone
2571    // to a fn-local `ops` mirror at the top. Mutation of the clone is
2572    // intra-fn only (`fclist` reads `ops` to format output and never
2573    // returns it), so behavior matches C.
2574    let mut ops = ops_in.clone();
2575    let ops = &mut ops;
2576    let mut argv = argv.to_vec();
2577    let mut first: i64 = -1;
2578    let mut last: i64 = -1;
2579    let mut asgf: Vec<(String, String)> = Vec::new();
2580
2581    // c:1441-1481 — `-p` push history stack.
2582    if OPT_ISSET(ops, b'p') {
2583        // c:1441
2584        let mut hf = "".to_string();
2585        let mut hs: i64; // c:1443
2586        let mut shs: i64; // c:1444
2587                          // c:1445 — `int level = OPT_ISSET(ops,'a') ? locallevel : -1;`
2588        let level: i32 = if OPT_ISSET(ops, b'a') {
2589            locallevel_param.load(Relaxed)
2590        } else {
2591            -1
2592        };
2593        hs = histsiz.load(Relaxed); // c:1442
2594        shs = savehistsiz.load(Relaxed);
2595        if !argv.is_empty() {
2596            // c:1445
2597            hf = argv.remove(0); // c:1446
2598            if !argv.is_empty() {
2599                // c:1447
2600                let s2 = argv.remove(0);
2601                match s2.parse::<i64>() {
2602                    // c:1449 zstrtol
2603                    Ok(n) => hs = n,
2604                    Err(_) => {
2605                        zwarnnam(
2606                            "fc", // c:1452
2607                            "HISTSIZE must be an integer",
2608                        );
2609                        return 1; // c:1453
2610                    }
2611                }
2612                if !argv.is_empty() {
2613                    // c:1455
2614                    let s3 = argv.remove(0);
2615                    match s3.parse::<i64>() {
2616                        // c:1456
2617                        Ok(n) => shs = n,
2618                        Err(_) => {
2619                            zwarnnam(
2620                                "fc", // c:1459
2621                                "SAVEHIST must be an integer",
2622                            );
2623                            return 1; // c:1460
2624                        }
2625                    }
2626                } else {
2627                    shs = hs; // c:1464
2628                }
2629                if !argv.is_empty() {
2630                    // c:1466
2631                    zwarnnam(
2632                        "fc", // c:1468
2633                        "too many arguments",
2634                    );
2635                    return 1; // c:1469
2636                }
2637            }
2638        }
2639        // c:1473 — pushhiststack(hf, hs, shs, level); failure → return 1.
2640        pushhiststack(Some(&hf), hs, shs, level); // c:1473
2641        if !hf.is_empty() {
2642            // c:1475
2643            // c:1476-1480 — `if (stat(hf, &st) >= 0 || errno != ENOENT)
2644            //                  readhistfile(hf, 1, HFILE_USE_OPTIONS);`
2645            // Previous Rust port read `Error::last_os_error()` AFTER
2646            // checking `metadata().is_ok()` — racey: any intervening
2647            // syscall between the metadata call and last_os_error()
2648            // can stomp errno on some platforms. Capture the per-Err
2649            // raw_os_error directly so we read the SAME errno value
2650            // the stat call produced.
2651            let stat_result = fs::metadata(&hf);
2652            let should_read = match &stat_result {
2653                Ok(_) => true,                                    // c:1477 stat >= 0
2654                Err(e) => e.raw_os_error() != Some(libc::ENOENT), // c:1477 errno != ENOENT
2655            };
2656            if should_read {
2657                // c:1477
2658                readhistfile(
2659                    // c:1478
2660                    Some(&hf),
2661                    1,
2662                    HFILE_USE_OPTIONS as i32,
2663                );
2664            }
2665        }
2666        return 0; // c:1483
2667    }
2668
2669    // c:1485-1491 — `-P` pop history stack.
2670    if OPT_ISSET(ops, b'P') {
2671        // c:1485
2672        if !argv.is_empty() {
2673            // c:1486
2674            zwarnnam("fc", "too many arguments"); // c:1487
2675            return 1; // c:1488
2676        }
2677        // c:1490 — `return !saveandpophiststack(-1, HFILE_USE_OPTIONS);`.
2678        let popped = saveandpophiststack(-1, HFILE_USE_OPTIONS as i32); // c:1490
2679        return if popped != 0 { 0 } else { 1 }; // c:1490 `!` flip
2680    }
2681
2682    // c:1494-1500 — `-m` pattern filter (compile first arg).
2683    let mut pprog: Option<crate::ported::pattern::PatProg> = None;
2684    let mut pprog_src: Option<String> = None;
2685    if !argv.is_empty() && OPT_ISSET(ops, b'm') {
2686        // c:1494
2687        let pat = argv.remove(0);
2688        // c:1495 — tokenize(*argv); — Rust `patcompile` handles tokenisation.
2689        match patcompile(
2690            &{
2691                let mut __pat_tok = (&pat).to_string();
2692                crate::ported::glob::tokenize(&mut __pat_tok);
2693                __pat_tok
2694            }, // c:1496
2695            PAT_HEAPDUP,
2696            None,
2697        ) {
2698            Some(p) => {
2699                pprog = Some(p);
2700                pprog_src = Some(pat); // retain source string for fclist
2701            }
2702            None => {
2703                zwarnnam(nam, "invalid match pattern"); // c:1497
2704                return 1; // c:1498
2705            }
2706        }
2707    }
2708
2709    queue_signals(); // c:1502
2710
2711    // c:1503-1525 — `-R` read / `-W` write / `-A` append history file.
2712    if OPT_ISSET(ops, b'R') {
2713        // c:1503
2714        let path = argv.first().cloned();
2715        let flags = if OPT_ISSET(ops, b'I') {
2716            HFILE_SKIPOLD as i32
2717        } else {
2718            0
2719        };
2720        readhistfile(
2721            // c:1505
2722            path.as_deref(),
2723            1,
2724            flags,
2725        );
2726        unqueue_signals(); // c:1506
2727        return 0; // c:1507
2728    }
2729    if OPT_ISSET(ops, b'W') {
2730        // c:1509
2731        let path = argv.first().cloned();
2732        let flags = if OPT_ISSET(ops, b'I') {
2733            HFILE_SKIPOLD as i32
2734        } else {
2735            0
2736        };
2737        savehistfile(
2738            // c:1511
2739            path.as_deref(),
2740            flags,
2741        );
2742        unqueue_signals(); // c:1512
2743        return 0; // c:1513
2744    }
2745    if OPT_ISSET(ops, b'A') {
2746        // c:1515
2747        let path = argv.first().cloned();
2748        let mut flags = HFILE_APPEND as i32;
2749        if OPT_ISSET(ops, b'I') {
2750            flags |= HFILE_SKIPOLD as i32;
2751        } // c:1518
2752        savehistfile(
2753            // c:1517
2754            path.as_deref(),
2755            flags,
2756        );
2757        unqueue_signals(); // c:1519
2758        return 0; // c:1520
2759    }
2760
2761    // c:1523-1527 — refuse inside ZLE.
2762    if crate::ported::builtins::sched::zleactive.load(
2763        // c:1523
2764        Relaxed,
2765    ) != 0
2766    {
2767        unqueue_signals(); // c:1524
2768        zwarnnam(
2769            nam, // c:1525
2770            "no interactive history within ZLE",
2771        );
2772        return 1; // c:1526
2773    }
2774
2775    // c:1530-1547 — `name=value` substitution pairs.
2776    while !argv.is_empty() && argv[0].contains('=') {
2777        // c:1530
2778        let arg = argv.remove(0);
2779        if let Some(eq) = arg.find('=') {
2780            let n = &arg[..eq];
2781            let v = &arg[eq + 1..];
2782            if n.is_empty() {
2783                zwarnnam(nam, &format!("invalid replacement pattern: ={}", v)); // c:1534
2784                return 1;
2785            }
2786            asgf.push((n.to_string(), v.to_string())); // c:1546
2787        }
2788    }
2789
2790    // c:1550-1568 — first/last history specifiers via fcgetcomm.
2791    if !argv.is_empty() {
2792        // c:1550
2793        first = fcgetcomm(&argv.remove(0)); // c:1551
2794        if first == -1 {
2795            unqueue_signals();
2796            return 1; // c:1553
2797        }
2798    }
2799    if !argv.is_empty() {
2800        // c:1559
2801        last = fcgetcomm(&argv.remove(0)); // c:1560
2802        if last == -1 {
2803            unqueue_signals();
2804            return 1;
2805        }
2806    }
2807    if !argv.is_empty() {
2808        // c:1567
2809        unqueue_signals();
2810        zwarnnam("fc", "too many arguments"); // c:1569
2811        return 1;
2812    }
2813
2814    // c:1573-1610 — default ranges + listing/edit dispatch. C reads
2815    //                the live `curhist` global at hist.rs directly. The
2816    //                FQN here is forced — bare `curhist` would resolve
2817    //                to the local `let curhist` we're declaring.
2818    let curhist: i64 = crate::ported::hist::curhist.load(Relaxed) as i64;
2819    if last == -1 {
2820        // c:1573
2821        if OPT_ISSET(ops, b'l') && first < curhist {
2822            // c:1574
2823            // c:1581-1583 — `last = (curline.histnum == curhist) ?
2824            //     addhistnum(curhist,-1,0) : curhist;`
2825            // When the fc command itself occupies the current event
2826            // (the normal interactive case), the default range EXCLUDES
2827            // it — zsh's `fc -l 1` does not list the in-flight `fc -l 1`
2828            // line. The previous `last = curhist` included it.
2829            let curline_num = crate::ported::hist::curline
2830                .lock()
2831                .ok()
2832                .and_then(|c| c.as_ref().map(|e| e.histnum))
2833                .unwrap_or(0);
2834            last = if curline_num == curhist {
2835                crate::ported::hist::addhistnum(curhist, -1, 0) // c:1582
2836            } else {
2837                curhist // c:1583
2838            };
2839            // c:1584-1585 — `if (last < firsthist()) last = firsthist();`
2840            let fh = crate::ported::hist::firsthist();
2841            if last < fh {
2842                last = fh; // c:1585
2843            }
2844        } else {
2845            last = first; // c:1587
2846        }
2847    }
2848    if first == -1 {
2849        // c:1589
2850        let _xflags = if OPT_ISSET(ops, b'L') {
2851            HIST_FOREIGN
2852        } else {
2853            0
2854        }; // c:1597
2855        first = if OPT_ISSET(ops, b'l') {
2856            (curhist - 16).max(1)
2857        }
2858        // c:1598
2859        else {
2860            (curhist - 1).max(1)
2861        };
2862        if last < first {
2863            last = first;
2864        } // c:1604
2865    }
2866
2867    let mut retval;
2868    if OPT_ISSET(ops, b'l') {
2869        // c:1606
2870        // c:1608 — `fclist(stdout, ops, first, last, asgf, pprog, 0);`
2871        retval = fclist(
2872            &mut io::stdout(),
2873            ops,
2874            first,
2875            last,
2876            &asgf,
2877            pprog_src.as_deref(),
2878            0,
2879        );
2880        unqueue_signals();
2881    } else {
2882        // c:1611-1668 — edit history range to a temp file, fcedit it,
2883        // then stuff() the result back as the next command.
2884        retval = 1; // c:1620
2885        let fil_opt = gettempfile(Some("zshfc")); // c:1621 gettempfile
2886        match fil_opt {
2887            None => {
2888                // c:1623
2889                unqueue_signals(); // c:1624
2890                zwarnnam(
2891                    "fc", // c:1625
2892                    &format!("can't open temp file: {}", io::Error::last_os_error()),
2893                );
2894            }
2895            Some((fd, fil)) => {
2896                unsafe {
2897                    libc::close(fd);
2898                } // c:1622 (file is reopened below)
2899                  // c:1632 — `if (last >= curhist) { last = curhist - 1; ... }`
2900                if last >= curhist {
2901                    // c:1632
2902                    last = curhist - 1; // c:1633
2903                    if first > last {
2904                        // c:1634
2905                        unqueue_signals(); // c:1635
2906                        zwarnnam(
2907                            "fc", // c:1636
2908                            "current history line would recurse endlessly, aborted",
2909                        );
2910                        let _ = fs::remove_file(&fil); // c:1639 unlink
2911                        return 1; // c:1640
2912                    }
2913                }
2914                ops.ind[b'n' as usize] = 1; // c:1644 No line numbers
2915                let out = fs::OpenOptions::new()
2916                    .create(true)
2917                    .write(true)
2918                    .truncate(true)
2919                    .open(&fil)
2920                    .ok();
2921                let listed = if let Some(mut f) = out {
2922                    // c:1645 — thread pprog filter so `fc -m PAT` only
2923                    // edits matching lines in this temp-file edit path.
2924                    fclist(&mut f, ops, first, last, &asgf, pprog_src.as_deref(), 1)
2925                } else {
2926                    1
2927                };
2928                if listed == 0 {
2929                    // c:1645
2930                    // c:1647-1656 — pick editor.
2931                    let editor: String = if func == BIN_R || OPT_ISSET(ops, b's') {
2932                        "-".to_string() // c:1648
2933                    } else if OPT_HASARG(ops, b'e') {
2934                        // c:1649
2935                        OPT_ARG(ops, b'e').unwrap_or("").to_string() // c:1650
2936                    } else {
2937                        // c:1651-1654 — `getsparam("FCEDIT") ?:
2938                        //                  getsparam("EDITOR") ?:
2939                        //                  DEFAULT_FCEDIT`. paramtab read.
2940                        getsparam("FCEDIT")
2941                            .or_else(|| getsparam("EDITOR"))
2942                            .unwrap_or_else(|| crate::ported::config_h::DEFAULT_FCEDIT.to_string())
2943                    };
2944                    unqueue_signals(); // c:1657
2945                    if fcedit(&editor, &fil) != 0 {
2946                        // c:1658
2947                        if crate::ported::input::stuff(&fil) != 0 {
2948                            // c:1659
2949                            zwarnnam(
2950                                "fc", // c:1660
2951                                &format!("{}: {}", io::Error::last_os_error(), fil),
2952                            );
2953                        } else {
2954                            // c:1663-1664 — `loop(0,1); retval = lastval;`
2955                            // The interactive loop drives the next stuffed
2956                            // line through the parser. Static-link path:
2957                            // the executor's input source picks it up on
2958                            // the next read; lastval reflects that result.
2959                            retval = LASTVAL.load(
2960                                // c:1664
2961                                Relaxed,
2962                            );
2963                        }
2964                    }
2965                } else {
2966                    unqueue_signals(); // c:1667
2967                }
2968                let _ = fs::remove_file(&fil); // c:1671 unlink
2969            }
2970        }
2971    }
2972    let _ = pprog; // compiled form kept for parity; source threads through fclist.
2973    retval // c:1675
2974}
2975
2976/// Port of `fcgetcomm(char *s)` from Src/builtin.c:1683.
2977/// C: `static zlong fcgetcomm(char *s)` — match `s` against history
2978///   numbers (signed) or prefix; returns the matched event number.
2979/// Direct port of `zlong fcgetcomm(char *s)` from
2980/// `Src/builtin.c:1683`. Resolve an `fc` command-line argument to a
2981/// history event number. Numeric args become event numbers (negative
2982/// numbers count back from current via `addhistnum`); non-numeric
2983/// args go through `hcomsearch` (history prefix search). Emits
2984/// `zwarnnam("fc", "event not found: %s", s)` and returns -1 on
2985/// miss.
2986pub fn fcgetcomm(s: &str) -> i64 {
2987    // c:1683
2988    // c:1689 — `if ((cmd = atoi(s)) != 0 || *s == '0')` numeric arm.
2989    //          atoi accepts leading whitespace + optional sign +
2990    //          digits; trim+parse mirrors that.
2991    let trimmed = s.trim_start();
2992    let numeric = trimmed.parse::<i64>().ok();
2993    let is_zero_prefix = trimmed.starts_with('0');
2994    if let Some(mut cmd) = numeric {
2995        if cmd != 0 || is_zero_prefix {
2996            if cmd < 0 {
2997                // c:1693 — `cmd = addhistnum(curline.histnum, cmd, HIST_FOREIGN);`
2998                let curh = crate::ported::hist::curhist.load(Relaxed);
2999                cmd = addhistnum(curh, cmd as i32, 1);
3000            }
3001            if cmd < 0 {
3002                // c:1695
3003                cmd = 0;
3004            }
3005            return cmd;
3006        }
3007    }
3008    // c:1700 — `cmd = hcomsearch(s); if (cmd == -1) zwarnnam(...);`
3009    match hcomsearch(s) {
3010        Some(n) => n,
3011        None => {
3012            zwarnnam("fc", &format!("event not found: {}", s));
3013            -1
3014        }
3015    }
3016}
3017
3018/// Port of `fcsubs(char **sp, struct asgment *sub)` from Src/builtin.c:1708.
3019/// C: `static int fcsubs(char **sp, struct asgment *sub)` — apply the
3020///   linked-list of `old=new` substitutions to `*sp` in place; return
3021///   the count of substitutions made.
3022pub fn fcsubs(sp: &mut String, sub: &[(String, String)]) -> i32 {
3023    // c:1708
3024    // c:1708-1748 — for each (old, new), replace each occurrence in *sp.
3025    let mut subbed = 0i32; // c:1713
3026    for (old, new) in sub {
3027        // c:1716
3028        if old.is_empty() {
3029            continue;
3030        }
3031        let count = sp.matches(old.as_str()).count() as i32; // c:1722
3032        if count > 0 {
3033            *sp = sp.replace(old.as_str(), new); // c:1750
3034            subbed += count;
3035        }
3036    }
3037    subbed
3038}
3039
3040/// Direct port of `int fclist(FILE *f, Options ops, zlong first,
3041/// zlong last, struct asgment *subs, Patprog pprog, int is_command)`
3042/// from `Src/builtin.c:1750`. Walks the history event range
3043/// `first..=last`, applies the `subs` substitution chain to each
3044/// matching line (when `pprog` is set, only lines matching it),
3045/// then writes the result with optional timestamp prefix per
3046/// `-d/-f/-E/-i/-t`.
3047///
3048/// Rust signature: takes the output writer as a closure so callers
3049/// can route to stdout, a FILE*, or an in-memory buffer (the
3050/// `is_command` caller in `bin_fc` collects to a heredoc string).
3051/// Was a 5-line stub returning 0; now actually emits the range.
3052#[allow(clippy::too_many_arguments)]
3053pub fn fclist(
3054    out: &mut dyn Write, // c:1750
3055    ops: &options,
3056    mut first: i64,
3057    mut last: i64,
3058    subs: &[(String, String)],
3059    pprog: Option<&str>,
3060    is_command: i32,
3061) -> i32 {
3062    use std::io::Write;
3063
3064    // c:1762-1766 — `if (OPT_ISSET(ops,'r')) swap(first, last);`
3065    if OPT_ISSET(ops, b'r') {
3066        std::mem::swap(&mut first, &mut last);
3067    }
3068    // c:1768-1773 — `if (is_command && first > last) zwarnnam(...)`.
3069    if is_command != 0 && first > last {
3070        zwarnnam("fc", "history events can't be executed backwards, aborted");
3071        return 1;
3072    }
3073
3074    // c:1776-1790 — `gethistent(first, ...)` with bidirectional fallback.
3075    let near = if first < last { 1 } else { -1 };
3076    let start_ev = match gethistent(first, near) {
3077        Some(e) => e,
3078        None => {
3079            // c:Src/builtin.c — `no such event: <N>` carries the
3080            // requested event number so the user can see which
3081            // index missed. zsh appends the failing event id;
3082            // the bare `no such event` message diverged.
3083            zwarnnam(
3084                "fc",
3085                &if first == last {
3086                    format!("no such event: {}", first)
3087                } else {
3088                    "no events in that range".to_string()
3089                },
3090            );
3091            return 1;
3092        }
3093    };
3094
3095    // c:1792-1817 — timestamp format setup.
3096    let want_time = OPT_ISSET(ops, b'd')
3097        || OPT_ISSET(ops, b'f')
3098        || OPT_ISSET(ops, b'E')
3099        || OPT_ISSET(ops, b'i')
3100        || OPT_ISSET(ops, b't');
3101    let tdfmt: Option<&'static str> = if !want_time {
3102        None
3103    } else if OPT_ISSET(ops, b't') {
3104        Some("%H:%M") // -t expects user-supplied fmt; without OPT_ARG access default to %H:%M
3105    } else if OPT_ISSET(ops, b'i') {
3106        Some("%Y-%m-%d %H:%M")
3107    } else if OPT_ISSET(ops, b'E') {
3108        Some("%d.%m.%Y %H:%M")
3109    } else if OPT_ISSET(ops, b'f') {
3110        Some("%m/%d/%Y %H:%M")
3111    } else {
3112        Some("%H:%M")
3113    };
3114
3115    // c:1820-1880 — walk events from start_ev toward `last`. Each entry:
3116    //                apply pprog filter, apply subs chain, emit (with
3117    //                event num + timestamp unless -n or is_command).
3118    let mut ev = start_ev;
3119    let step: i64 = if first < last { 1 } else { -1 };
3120    loop {
3121        // c:1830 — `ent = quietgethist(ev);` — fetch entry by event #.
3122        let entry = match quietgethist(ev) {
3123            Some(e) => e,
3124            None => break,
3125        };
3126        let line = entry.node.nam.clone();
3127
3128        // c:1833 — pprog pattern filter. C pre-compiles a Patprog;
3129        //          Rust compiles per-call. Most fc -l calls have no
3130        //          pattern so the gate is cheap.
3131        if let Some(pat) = pprog {
3132            let prog = patcompile(
3133                &{
3134                    let mut __pat_tok = (pat).to_string();
3135                    crate::ported::glob::tokenize(&mut __pat_tok);
3136                    __pat_tok
3137                },
3138                0,
3139                None,
3140            );
3141            let matched = prog.as_ref().map(|p| pattry(p, &line)).unwrap_or(true);
3142            if !matched {
3143                if ev == last {
3144                    break;
3145                }
3146                ev += step;
3147                continue;
3148            }
3149        }
3150
3151        // c:1841-1855 — apply subs chain (asgment list of `old=new`
3152        //                pairs that get substituted in order).
3153        let mut text = line;
3154        for (old, new) in subs.iter() {
3155            if old.is_empty() {
3156                continue;
3157            }
3158            text = text.replace(old.as_str(), new.as_str());
3159        }
3160
3161        // c:1860-1870 — emit prefix: event number (unless -n / -h),
3162        //                then optional timestamp.
3163        if is_command == 0 {
3164            if !OPT_ISSET(ops, b'n') {
3165                let _ = write!(out, "{:>5}", ev);
3166                if OPT_ISSET(ops, b'D') {
3167                    // c:Src/builtin.c — `-D` shows duration as M:SS.
3168                    // C: `fprintf(f, "%d:%02d", (int)(d/60), (int)(d%60))`.
3169                    // Direct ftim-minus-stim duration in seconds, then
3170                    // M:SS layout (zero-pad seconds, no zero-pad minutes).
3171                    let dur = entry.ftim.saturating_sub(entry.stim).max(0);
3172                    let _ = write!(out, "  {}:{:02}", dur / 60, dur % 60);
3173                }
3174                if let Some(fmt) = tdfmt {
3175                    // c:1817 — `strftime(timebuf, 256, tdfmt,
3176                    //                    localtime(&ent->stim))`.
3177                    //          Use libc directly so locale-aware
3178                    //          format specifiers (%Y %m %d %H %M %S
3179                    //          %p etc.) all work without a hand-rolled
3180                    //          strftime port.
3181                    let formatted: Option<String> = (|| {
3182                        let mut tm: libc::tm = unsafe { std::mem::zeroed() };
3183                        let t: libc::time_t = entry.stim as libc::time_t;
3184                        let cfmt = std::ffi::CString::new(fmt).ok()?;
3185                        unsafe {
3186                            if libc::localtime_r(&t, &mut tm).is_null() {
3187                                return None;
3188                            }
3189                            let mut buf = vec![0u8; 256];
3190                            let n = libc::strftime(
3191                                buf.as_mut_ptr() as *mut libc::c_char,
3192                                buf.len(),
3193                                cfmt.as_ptr(),
3194                                &tm,
3195                            );
3196                            if n == 0 {
3197                                return None;
3198                            }
3199                            buf.truncate(n);
3200                            String::from_utf8(buf).ok()
3201                        }
3202                    })();
3203                    if let Some(s) = formatted {
3204                        let _ = write!(out, "  {}", s);
3205                    } else {
3206                        // strftime failed (locale issue / format bug);
3207                        // fall back to raw epoch matching C's
3208                        // pre-strftime print behavior.
3209                        let _ = write!(out, "  {}", entry.stim);
3210                    }
3211                }
3212                let _ = write!(out, "  ");
3213            }
3214        }
3215
3216        // c:1875 — write the line.
3217        let _ = writeln!(out, "{}", text);
3218
3219        if ev == last {
3220            break;
3221        }
3222        ev += step;
3223        if ev < 0 {
3224            break;
3225        }
3226    }
3227    0 // c:1880
3228}
3229
3230/// Port of `fcedit(char *ename, char *fn)` from Src/builtin.c:1885.
3231/// C: `static int fcedit(char *ename, char *fn)` — invoke `$ename fn`,
3232///   returning the editor's exit status (0 if `ename == "-"`).
3233/// WARNING: param names don't match C — Rust=(ename, fn_) vs C=(ename, fn)
3234pub fn fcedit(ename: &str, fn_: &str) -> i32 {
3235    // c:1885
3236    // c:1885 — `if (!strcmp(ename, "-")) return 1;`
3237    if ename == "-" {
3238        // c:1888
3239        return 1; // c:1889
3240    }
3241    // c:1891-1900 — execlp(ename, ename, fn, NULL) wrapped in fork/wait.
3242    let status = std::process::Command::new(ename) // c:1895
3243        .arg(fn_)
3244        .status();
3245    match status {
3246        Ok(s) => s.code().unwrap_or(1),
3247        Err(_) => 1,
3248    }
3249}
3250
3251/// Port of `getasg(char ***argvp, LinkList assigns)` from Src/builtin.c:1908.
3252/// C: `static Asgment getasg(char ***argvp, LinkList assigns)` —
3253///   parse one assignment-form arg (`name=value` / `name`) from
3254///   `*argvp`. Returns NULL when exhausted.
3255/// ```c
3256/// static Asgment
3257/// getasg(char ***argvp, LinkList assigns)
3258/// {
3259///     char *s = **argvp;
3260///     static struct asgment asg;
3261///     if (!s) {
3262///         if (assigns) {
3263///             Asgment asgp = (Asgment)firstnode(assigns);
3264///             if (!asgp) return NULL;
3265///             (void)uremnode(assigns, &asgp->node);
3266///             return asgp;
3267///         }
3268///         return NULL;
3269///     }
3270///     if (*s == '=') { zerr("bad assignment"); return NULL; }
3271///     asg.name = s;
3272///     asg.flags = 0;
3273///     for (; *s && *s != '='; s++);
3274///     if (*s) { *s = '\0'; asg.value.scalar = s + 1; }
3275///     else asg.value.scalar = NULL;
3276///     (*argvp)++;
3277///     return &asg;
3278/// }
3279/// ```
3280pub fn getasg(
3281    argvp: &mut Vec<String>, // c:1908
3282    assigns: &mut Vec<(String, String)>,
3283) -> Option<(String, String)> {
3284    // c:1914-1923 — out-of-args path: drain from assigns list if non-empty.
3285    if argvp.is_empty() {
3286        // c:1914 !s
3287        if !assigns.is_empty() {
3288            // c:1915
3289            return Some(assigns.remove(0)); // c:1916-1920 firstnode + uremnode
3290        }
3291        return None; // c:1922
3292    }
3293
3294    let s = argvp.remove(0); // c:1944 (*argvp)++
3295
3296    // c:1926-1929 — empty-name guard: bare `=value` is an error.
3297    if s.starts_with('=') {
3298        // c:1926
3299        zerr("bad assignment"); // c:1927
3300        return None; // c:1928
3301    }
3302
3303    // c:1934-1943 — split on `=`. No `=` → name-only (scalar = NULL).
3304    match s.find('=') {
3305        // c:1934
3306        Some(i) => {
3307            // c:1938-1939 — `*s = '\0'; asg.value.scalar = s + 1;`
3308            Some((s[..i].to_string(), s[i + 1..].to_string())) // c:1939
3309        }
3310        None => {
3311            // c:1942 — `asg.value.scalar = NULL;` — name-only.
3312            Some((s, String::new())) // c:1942
3313        }
3314    }
3315}
3316
3317/// Port of `typeset_setbase(const char *name, Param pm, Options ops, int on, int always)` from Src/builtin.c:1961.
3318/// C: `static int typeset_setbase(const char *name, Param pm, Options ops,
3319///     int on, int always)` — install numeric base on `pm`. For
3320///     `-i ARG`/`-E ARG`/`-F ARG`, parse ARG as base and validate
3321///     (must be 2..=36 for integer); error → return 1.
3322/// WARNING: param names don't match C — Rust=(name, pm, on, always) vs C=(name, pm, ops, on, always)
3323pub fn typeset_setbase(
3324    name: &str,
3325    pm: *mut param, // c:1961
3326    ops: &options,
3327    on: i32,
3328    always: i32,
3329) -> i32 {
3330    // c:1964 — `char *arg = NULL;`
3331    let mut arg: Option<&str> = None; // c:1964
3332    let on_u = on as u32;
3333    // c:1966-1971 — `if ((on & PM_INTEGER) && OPT_HASARG(ops,'i')) arg = OPT_ARG(ops,'i');`
3334    if (on_u & PM_INTEGER) != 0 && OPT_HASARG(ops, b'i') {
3335        // c:1966
3336        arg = OPT_ARG(ops, b'i'); // c:1967
3337    } else if (on_u & PM_EFLOAT) != 0 && OPT_HASARG(ops, b'E') {
3338        // c:1968
3339        arg = OPT_ARG(ops, b'E'); // c:1969
3340    } else if (on_u & PM_FFLOAT) != 0 && OPT_HASARG(ops, b'F') {
3341        // c:1970
3342        arg = OPT_ARG(ops, b'F'); // c:1971
3343    }
3344
3345    // c:1973 — `if (arg) {`
3346    if let Some(a) = arg {
3347        // c:1973
3348        // c:1976 — `int base = (int)zstrtol(arg, &eptr, 10);`
3349        let base = match a.trim().parse::<i32>() {
3350            Ok(b) => b,
3351            Err(_) => {
3352                // c:1977-1982
3353                if (on_u & PM_INTEGER) != 0 {
3354                    zwarnnam(name, &format!("bad base value: {}", a)); // c:1979
3355                } else {
3356                    zwarnnam(name, &format!("bad precision value: {}", a)); // c:1981
3357                }
3358                return 1; // c:1983
3359            }
3360        };
3361        // c:1985-1989 — integer base must be 2..=36 inclusive.
3362        if (on_u & PM_INTEGER) != 0 && (base < 2 || base > 36) {
3363            // c:1985
3364            zwarnnam(
3365                name,
3366                &format!("invalid base (must be 2 to 36 inclusive): {}", base),
3367            ); // c:1986-1987
3368            return 1; // c:1988
3369        }
3370        // c:1990 — `pm->base = base;`
3371        if !pm.is_null() {
3372            unsafe {
3373                (*pm).base = base;
3374            } // c:1990
3375        }
3376    } else if always != 0 {
3377        // c:1991
3378        // c:1997 — `pm->base = 0;`
3379        if !pm.is_null() {
3380            unsafe {
3381                (*pm).base = 0;
3382            } // c:1997
3383        }
3384    }
3385    0 // c:1997
3386}
3387
3388/// Port of `typeset_setwidth(const char * name, Param pm, Options ops, int on, int always)` from Src/builtin.c:1997.
3389/// C: `static int typeset_setwidth(const char *name, Param pm, Options ops,
3390///     int on, int always)` — install padding width via `-L/-R/-Z ARG`.
3391/// WARNING: param names don't match C — Rust=(name, pm, on, always) vs C=(name, pm, ops, on, always)
3392pub fn typeset_setwidth(
3393    name: &str,
3394    pm: *mut param, // c:1997
3395    ops: &options,
3396    on: i32,
3397    always: i32,
3398) -> i32 {
3399    // c:2000 — `char *arg = NULL;`
3400    let mut arg: Option<&str> = None; // c:2000
3401    let on_u = on as u32;
3402    // c:2002-2007
3403    if (on_u & PM_LEFT) != 0 && OPT_HASARG(ops, b'L') {
3404        // c:2002
3405        arg = OPT_ARG(ops, b'L'); // c:2003
3406    } else if (on_u & PM_RIGHT_B) != 0 && OPT_HASARG(ops, b'R') {
3407        // c:2004
3408        arg = OPT_ARG(ops, b'R'); // c:2005
3409    } else if (on_u & PM_RIGHT_Z) != 0 && OPT_HASARG(ops, b'Z') {
3410        // c:2006
3411        arg = OPT_ARG(ops, b'Z'); // c:2007
3412    }
3413
3414    // c:2009 — `if (arg) {`
3415    if let Some(a) = arg {
3416        // c:2009
3417        // c:2011 — `pm->width = (int)zstrtol(arg, &eptr, 10);`
3418        let width = match a.trim().parse::<i32>() {
3419            Ok(w) => w,
3420            Err(_) => {
3421                zwarnnam(name, &format!("bad width value: {}", a)); // c:2013
3422                return 1; // c:2014
3423            }
3424        };
3425        if !pm.is_null() {
3426            unsafe {
3427                (*pm).width = width;
3428            } // c:2011
3429        }
3430    } else if always != 0 {
3431        // c:2015
3432        // c:2016 — `pm->width = 0;`
3433        if !pm.is_null() {
3434            unsafe {
3435                (*pm).width = 0;
3436            } // c:2025
3437        }
3438    }
3439    0 // c:2025
3440}
3441
3442/// Port of `typeset_single(char *cname, char *pname, Param pm, int func, int on, int off, int roff, Asgment asg, Param altpm, Options ops, int joinchar)` from Src/builtin.c:2025.
3443/// Port of `static Param typeset_single(char *cname, char *pname,
3444/// Param pm, int func, int on, int off, int roff, Asgment asg,
3445/// Param altpm, Options ops, int joinchar)` from `Src/builtin.c:2025`.
3446/// Per-name attribute resolver + assignment dispatcher invoked once
3447/// per arg from `bin_typeset`.
3448#[allow(clippy::too_many_arguments)]
3449pub fn typeset_single(
3450    cname: &str,
3451    pname: &str, // c:2025
3452    pm: *mut param,
3453    func: i32,
3454    mut on: i32,
3455    mut off: i32,
3456    _roff: i32,
3457    asg: *mut asgment,
3458    altpm: *mut param,
3459    ops: &options,
3460    _joinchar: i32,
3461) -> *mut param {
3462    let mut usepm: i32; // c:2029
3463    let mut tc: i32 = 0; // c:2029
3464    let _keeplocal: i32 = 0; // c:2029
3465    let mut newspecial: i32 = 0; /* NS_NONE */
3466    // c:2029
3467    let _readonly: i32 = 0; // c:2029
3468    let _dont_set: i32 = 0; // c:2029
3469    let mut pname_owned: String = pname.to_string(); // c:2030 subscript path
3470
3471    // c:2032-2050 — nameref resolution.
3472    let pm_ref = unsafe { pm.as_mut() };
3473    if let Some(pm_r) = &pm_ref {
3474        let pm_flags = pm_r.node.flags as u32;
3475        let locallevel_v = locallevel_param.load(Relaxed);
3476        if (pm_flags & PM_NAMEREF) != 0
3477            && ((off | on) as u32 & PM_NAMEREF) == 0
3478            && (pm_r.level == locallevel_v || (on as u32 & PM_LOCAL) == 0)
3479        {
3480            // c:2034 — pm = resolve_nameref(pm)
3481            //          pname = pm->node.nam (when resolved)
3482            // resolve_nameref not yet ported; skip the rewrite.
3483            let unresolved_flags = pm_r.node.flags as u32;
3484            let extra_on_mask = !(PM_NAMEREF | PM_LOCAL | PM_READONLY) as i32;
3485            if (pm_flags & PM_NAMEREF) != 0
3486                && ((unresolved_flags & PM_UNSET) == 0 || (unresolved_flags & PM_DECLARED) != 0)
3487                && (on & extra_on_mask) != 0
3488            {
3489                // c:2042-2048 — error: can't change type of a nameref.
3490                if pm_r.width != 0 {
3491                    // c:2041
3492                    zwarnnam(
3493                        cname, // c:2042
3494                        &format!("{}: can't change type via subscript reference", pname),
3495                    );
3496                } else {
3497                    zwarnnam(
3498                        cname, // c:2046
3499                        &format!("{}: can't change type of a named reference", pname),
3500                    );
3501                }
3502                return std::ptr::null_mut(); // c:2048
3503            }
3504        }
3505    }
3506
3507    // c:2062-2064 — `usepm = pm && (!(pm_flags & PM_UNSET) || OPT_ISSET(ops,'p') || ...)`
3508    let pm_flags = pm_ref.as_ref().map(|p| p.node.flags as u32).unwrap_or(0);
3509    usepm = if pm_ref.is_some()
3510        && ((pm_flags & PM_UNSET) == 0
3511            || OPT_ISSET(ops, b'p')
3512            || (isset(POSIXBUILTINS) && (pm_flags & (PM_READONLY | PM_EXPORTED)) != 0))
3513    {
3514        1
3515    } else {
3516        0
3517    };
3518
3519    // c:2070-2071 — preserve PM_UNSET for special params.
3520    if usepm == 0 && pm_ref.is_some() && (pm_flags & PM_SPECIAL) != 0 {
3521        usepm = 2; // c:2071
3522    }
3523
3524    // c:2078-2091 — don't reuse if local-level changed and PM_LOCAL set.
3525    let pm_level = pm_ref.as_ref().map(|p| p.level).unwrap_or(0);
3526    let locallevel_v = locallevel_param.load(Relaxed);
3527    if usepm != 0 && locallevel_v != pm_level && (on as u32 & PM_LOCAL) != 0 {
3528        // c:2078
3529        if (pm_flags & PM_SPECIAL) != 0                // c:2087
3530            && (on as u32 & PM_HIDE) == 0
3531            && (pm_flags & PM_HIDE & !off as u32) == 0
3532        {
3533            newspecial = 1; /* NS_NORMAL */ // c:2089
3534        }
3535        usepm = 0; // c:2090
3536    }
3537
3538    // c:2093-2116 — type-conversion / tied-colonarray detection.
3539    let asg_ref = unsafe { asg.as_ref() };
3540    tc = 0;
3541    if let Some(a) = asg_ref {
3542        if ASG_ARRAYP(a)
3543            && PM_TYPE(on as u32) == PM_SCALAR
3544            && !(usepm != 0 && (PM_TYPE(pm_flags) & (PM_ARRAY | PM_HASHED)) != 0)
3545        {
3546            on |= PM_ARRAY as i32; // c:2097
3547        }
3548        if usepm != 0 && ASG_ARRAYP(a) && newspecial == 0                    // c:2098
3549            && PM_TYPE(pm_flags) != PM_ARRAY
3550            && PM_TYPE(pm_flags) != PM_HASHED
3551        {
3552            if (on as u32 & (PM_EFLOAT | PM_FFLOAT | PM_INTEGER)) != 0 {
3553                zerrnam(
3554                    cname, // c:2102
3555                    &format!("{}: can't assign array value to non-array", pname),
3556                );
3557                return std::ptr::null_mut();
3558            }
3559            if (pm_flags & PM_SPECIAL) != 0 {
3560                // c:2105
3561                zerrnam(
3562                    cname, // c:2106
3563                    &format!("{}: can't assign array value to non-array special", pname),
3564                );
3565                return std::ptr::null_mut();
3566            }
3567            tc = 1; // c:2109
3568            usepm = if OPT_MINUS(ops, b'p') {
3569                // c:2110
3570                (on as u32 & pm_flags) as i32
3571            } else if OPT_PLUS(ops, b'p') {
3572                // c:2112
3573                (off as u32 & pm_flags) as i32
3574            } else {
3575                0 // c:2115
3576            };
3577        }
3578    }
3579
3580    // c:2117-2199 — attribute-mask compatibility checks (chflags compute).
3581    if usepm != 0 || newspecial != 0 {
3582        let chflags = ((off as u32 & pm_flags) | (on as u32 & !pm_flags))    // c:2118
3583            & (PM_INTEGER
3584               | PM_EFLOAT
3585               | PM_FFLOAT
3586               | PM_HASHED | PM_ARRAY | PM_TIED | PM_AUTOLOAD);
3587        if chflags != 0 && chflags != (PM_EFLOAT | PM_FFLOAT) {
3588            tc = 1; // c:2122
3589            usepm = if OPT_MINUS(ops, b'p') {
3590                // c:2123
3591                (on as u32 & pm_flags) as i32
3592            } else if OPT_PLUS(ops, b'p') {
3593                // c:2125
3594                (off as u32 & pm_flags) as i32
3595            } else {
3596                // c:2127 — `else usepm = 0;`
3597                //
3598                // This `else` was missing, and it is what selects between the
3599                // two mutually exclusive paths. usepm != 0 takes the MERGE path
3600                // (c:2291: keep the existing flags, add `on`, drop `off`), while
3601                // usepm == 0 falls through to the type-conversion path (c:2355),
3602                // which deletes the parameter and recreates it carrying ONLY
3603                // READONLY|EXPORTED (c:2357). Leaving usepm set meant a base-type
3604                // change merged instead of recreating, so `typeset -Z 3 x=7;
3605                // typeset -i x=1` kept the right_zeros attribute (zsh: `integer`,
3606                // zshrs: `integer-right_zeros`).
3607                0
3608            };
3609        }
3610    }
3611
3612    // c:2202-2214 — readonly/exported preservation rules.
3613    if usepm != 0 || newspecial != 0 {
3614        if (on as u32 & (PM_READONLY | PM_EXPORTED)) != 0                    // c:2202
3615            && (usepm == 0 || (pm_flags & PM_UNSET) != 0)
3616            && asg_ref.is_some_and(|a| !ASG_VALUEP(a))
3617        {
3618            on |= PM_UNSET as i32; // c:2205
3619        } else if usepm != 0 && (pm_flags & PM_READONLY) != 0                // c:2206
3620            && (on as u32 & PM_READONLY) == 0
3621            && func != BIN_EXPORT
3622        {
3623            zerr(&format!(
3624                // c:2208
3625                "read-only variable: {}",
3626                pm_ref.as_ref().unwrap().node.nam
3627            ));
3628            return std::ptr::null_mut();
3629        }
3630    }
3631
3632    // c:2226-2248 — reuse-existing-param fast paths.
3633    if usepm != 0 {
3634        let pm_r = pm_ref.as_ref().unwrap();
3635        if OPT_MINUS(ops, b'p')
3636            && on != 0
3637            && !((on as u32 & pm_flags) != 0 || ((on as u32 & PM_LOCAL) != 0 && pm_r.level != 0))
3638        {
3639            return std::ptr::null_mut(); // c:2229
3640        }
3641        if OPT_PLUS(ops, b'p') && off != 0 && (off as u32 & pm_flags) == 0 {
3642            return std::ptr::null_mut(); // c:2231
3643        }
3644        // c:2232-2238 — array/scalar consistency check
3645        if let Some(a) = asg_ref {
3646            let array_assign = (a.flags & ASG_ARRAY) != 0;
3647            let pm_is_arr = (PM_TYPE(pm_flags) & (PM_ARRAY | PM_HASHED)) != 0;
3648            if array_assign && !pm_is_arr {
3649                // c:2232
3650                zerrnam(
3651                    cname, // c:2236
3652                    &format!("{}: inconsistent type for assignment", pname),
3653                );
3654                return std::ptr::null_mut();
3655            }
3656        }
3657    }
3658
3659    // c:2240-2247 — print-only path: typeset -p / typeset name (no value).
3660    if usepm != 0 && on == 0 && _roff == 0 && asg_ref.is_some_and(|a| !ASG_VALUEP(a)) {
3661        // c:2241 — `int with_ns = OPT_ISSET(ops,'m') ? PRINT_WITH_NAMESPACE : 0;`
3662        let with_ns = if OPT_ISSET(ops, b'm') {
3663            // c:2241
3664            PRINT_WITH_NAMESPACE
3665        } else {
3666            0
3667        };
3668        // c:Src/builtin.c:2761-2765 — `typeset -p1 NAME` adds PRINT_LINE
3669        // (one array/assoc element per line). The listing path (~3805)
3670        // already parses this; the explicit-name print path here did not,
3671        // so `typeset -p1 myarray` printed the single-line `( a b c )`
3672        // form instead of zsh's multi-line `(\n  a\n  b\n  c\n)`.
3673        let line_flag =
3674            if OPT_HASARG(ops, b'p') && OPT_ARG(ops, b'p').map(|a| a.trim()) == Some("1") {
3675                PRINT_LINE
3676            } else {
3677                0
3678            };
3679        if let Some(pm_r) = unsafe { pm.as_mut() } {
3680            if OPT_ISSET(ops, b'p') {
3681                // c:2242
3682                // c:2243 — `paramtab->printnode(&pm->node, PRINT_TYPESET|with_ns);`
3683                printparamnode(pm_r, PRINT_TYPESET | with_ns | line_flag);
3684            } else if !OPT_ISSET(ops, b'g')                                  // c:2244
3685                && (!isset(TYPESETSILENT) || OPT_ISSET(ops, b'm'))
3686            // c:2245
3687            {
3688                // c:2246 — `paramtab->printnode(&pm->node, PRINT_INCLUDEVALUE|with_ns);`
3689                printparamnode(pm_r, PRINT_INCLUDEVALUE | with_ns);
3690            }
3691        }
3692        return pm; // c:2247
3693    }
3694
3695    // c:2355-2378 — tc (type-conversion) branch: recreate the param.
3696    if tc != 0 && !OPT_ISSET(ops, b'p') {
3697        on |= (!off as u32 & (PM_READONLY | PM_EXPORTED) & pm_flags) as i32; // c:2357
3698        if let Some(pm_r) = pm_ref {
3699            pm_r.node.flags &= !(PM_READONLY as i32); // c:2359
3700        }
3701        // c:2364 — keeplocal = pm->level (used by createparam path)
3702        // c:2372-2375 — carry scalar value across type change.
3703        // c:2378 — unsetparam_pm(pm, 0, 1)
3704        if let Some(pm_r) = unsafe { pm.as_mut() } {
3705            unsetparam_pm(pm_r, 0, 1);
3706        }
3707        pname_owned = pname.to_string(); // c:2377
3708    }
3709
3710    // c:2381-2467 — newspecial path: preserve special-param struct.
3711    // c:2469-2510 — createparam + assignment dispatch for new/converted.
3712    // c:2512-2453 — apply value via assignsparam/setaparam/sethparam.
3713    // These call into a 2-level helper chain (typeset_setwidth,
3714    // typeset_setbase, assignsparam, etc.) — the available Rust
3715    // ports drive single-attribute setters. The dispatcher entry
3716    // (bin_typeset at c:2655) walks the option matrix and invokes
3717    // those setters directly today.
3718    let _ = (altpm, pname_owned, _keeplocal, _dont_set, _readonly);
3719
3720    // c:2547 — `return pm;`
3721    pm
3722}
3723
3724/// Port of `bin_typeset(char *name, char **argv, LinkList assigns, Options ops, int func)` from Src/builtin.c:2655.
3725/// C: `int bin_typeset(char *name, char **argv, LinkList assigns,
3726///     Options ops, int func)`.
3727///
3728/// The C body (~500 lines) ports here in two layers: the option-flag
3729/// matrix + conflict-resolution / dispatch (faithfully translated)
3730/// and the per-arg param-setting loop (delegated to typeset_single
3731/// already ported above).
3732/// WARNING: param names don't match C — Rust=(name, argv, func) vs C=(name, argv, assigns, ops, func)
3733pub fn bin_typeset(
3734    name: &str,
3735    argv: &[String], // c:2655
3736    ops: &options,
3737    func: i32,
3738) -> i32 {
3739    // PFA-SMR aspect: bin_typeset is the C dispatch site for
3740    // typeset/declare/integer/float/local/export/readonly/private —
3741    // every one of those state-mutating builtins lands here with a
3742    // funcid (BIN_EXPORT/BIN_READONLY/BIN_TYPESET/...) discriminant.
3743    // Emit a per-name event per the recorder schema.
3744    #[cfg(feature = "recorder")]
3745    if crate::recorder::is_enabled() {
3746        let ctx = crate::recorder::recorder_ctx_global();
3747        // Collect option letters from argv `-x`/`+x` flag args AND
3748        // from the BUILTIN's defopts (the auto-set option chars C
3749        // applies before dispatching bin_typeset). Without the
3750        // defopts, `integer count=42` (defopts="i"), `float pi=1.0`
3751        // (defopts="E"), and `typeset -A h=(...)` whose `A` came
3752        // from a parent shape, lost their type bits and the
3753        // recorder emitted `[scalar]` for everything.
3754        let mut letters = String::new();
3755        let mut tied_mode = false;
3756        for a in argv {
3757            if a.starts_with('-') || a.starts_with('+') {
3758                let body = &a[1..];
3759                letters.push_str(body);
3760                if body.contains('T') {
3761                    tied_mode = true;
3762                }
3763            }
3764        }
3765        // Add the option letters that defopts pre-set in `ops`.
3766        for ch in [b'i', b'E', b'F', b'A', b'a', b'r', b'x', b'g', b'l', b'u'] {
3767            if OPT_ISSET(&ops, ch) && !letters.contains(ch as char) {
3768                letters.push(ch as char);
3769            }
3770        }
3771        // Funcid-driven attr seeding: BIN_EXPORT seeds nothing
3772        // (recorder uses emit_export for those), BIN_READONLY seeds
3773        // SCALAR|READONLY. INTEGER/FLOAT/ASSOC/ARRAY now flow via
3774        // the defopts injection above into ParamAttrs::from_flag_chars.
3775        let mut attrs = crate::recorder::ParamAttrs::from_flag_chars(&letters);
3776        match func {
3777            crate::ported::builtin::BIN_READONLY => {
3778                attrs.set(crate::recorder::ParamAttrs::SCALAR);
3779                attrs.set(crate::recorder::ParamAttrs::READONLY);
3780            }
3781            _ => {}
3782        }
3783        // BIN_EXPORT routes to emit_export (different schema row).
3784        if func == crate::ported::builtin::BIN_EXPORT {
3785            for a in argv {
3786                if a == "-p" || a.starts_with('-') {
3787                    continue;
3788                }
3789                if let Some((k, v)) = a.split_once('=') {
3790                    crate::recorder::emit_export(k, Some(v), ctx.clone());
3791                } else {
3792                    crate::recorder::emit_export(a, None, ctx.clone());
3793                }
3794            }
3795        } else {
3796            // Suppress the emit when invoked as `local`/`private` inside
3797            // a function — those scope to the frame and don't merit a
3798            // top-level state-mutation row. local_scope_depth is tracked
3799            // by the executor; defer to the global locallevel_param counter.
3800            let is_locallike = matches!(name, "local" | "private");
3801            let inside_function = locallevel_param.load(std::sync::atomic::Ordering::Relaxed) > 0;
3802            if !is_locallike || !inside_function {
3803                let mut tied_seen = 0usize;
3804                for a in argv {
3805                    if a.starts_with('-') || a.starts_with('+') {
3806                        continue;
3807                    }
3808                    if tied_mode {
3809                        // For `typeset -T X Y [SEP]`, only X and Y are names.
3810                        tied_seen += 1;
3811                        if tied_seen > 2 {
3812                            break;
3813                        }
3814                    }
3815                    if let Some((k, v)) = a.split_once('=') {
3816                        crate::recorder::emit_typeset_attrs(k, Some(v), attrs, ctx.clone());
3817                    } else {
3818                        crate::recorder::emit_typeset_attrs(a, None, attrs, ctx.clone());
3819                    }
3820                }
3821            }
3822        }
3823    }
3824    let mut ops = ops.clone();
3825    let mut on: u32 = 0; // c:2661
3826    let mut off: u32 = 0; // c:2661
3827    let mut returnval: i32 = 0; // c:2664
3828    let mut printflags: i32 = PRINT_WITH_NAMESPACE; // c:2664
3829    let hasargs = !argv.is_empty(); // c:2665
3830
3831    // c:2668-2670 — POSIX bash/ksh ignore -p with args under
3832    // readonly/export.
3833    let posix = isset(POSIXBUILTINS);
3834    if (func == BIN_READONLY || func == BIN_EXPORT) && posix && hasargs {
3835        // c:2668
3836        ops.ind[b'p' as usize] = 0; // c:2670
3837    }
3838
3839    // c:2673 — `if (OPT_ISSET(ops,'f')) return bin_functions(...)`.
3840    if OPT_ISSET(&ops, b'f') {
3841        // c:2673
3842        return bin_functions(name, argv, &ops, func); // c:2673
3843    }
3844
3845    // c:2676 — POSIX readonly forces -g unless explicit +g.
3846    if func == BIN_READONLY && posix && !OPT_PLUS(&ops, b'g') {
3847        // c:2676
3848        ops.ind[b'g' as usize] = 1; // c:2677
3849    }
3850
3851    // c:2691-2706 — translate optstr letters into PM_* flag bits.
3852    let mut bit: u32 = PM_ARRAY; // c:2660
3853    for ch in TYPESET_OPTSTR.chars() {
3854        // c:2691
3855        let optval = ch as u8;
3856        if OPT_MINUS(&ops, optval) {
3857            on |= bit;
3858        }
3859        // c:2694-2695
3860        else if OPT_PLUS(&ops, optval) {
3861            off |= bit;
3862        }
3863        // c:2696-2697
3864        // c:2698-2706 — `-n` only allows readonly/upper/hideval.
3865        else {
3866            bit <<= 1;
3867            continue;
3868        }
3869        if OPT_MINUS(&ops, b'n') && (bit & !(PM_READONLY | PM_UPPER | PM_HIDEVAL)) != 0
3870        // c:2701
3871        {
3872            zwarnnam(name, &format!("-{} not allowed with -n", ch)); // c:2702
3873        }
3874        bit <<= 1;
3875    }
3876    // c:2708-2715 — -n / +n conflict resolution.
3877    if OPT_MINUS(&ops, b'n') {
3878        // c:2709-2711 — only readonly/upper/hideval combine with -n;
3879        // anything else is a (silent) status-1 rejection (the zwarnnam
3880        // here is commented out in C — the per-bit loop above already
3881        // warned).
3882        if ((on | off) & !(PM_READONLY | PM_UPPER | PM_HIDEVAL)) != 0 {
3883            // c:2710
3884            return 1; // c:2711
3885        }
3886        on |= PM_NAMEREF; // c:2712
3887    } else if OPT_PLUS(&ops, b'n') {
3888        // c:2714
3889        off |= PM_NAMEREF; // c:2715
3890    }
3891    let roff = off; // c:2716
3892
3893    // c:2719-2740 — sanity checks: remove conflicting attrs.
3894    if (on & PM_FFLOAT) != 0 {
3895        // c:2719
3896        off |= PM_UPPER | PM_ARRAY | PM_HASHED | PM_INTEGER | PM_EFLOAT; // c:2720
3897        on &= !PM_EFLOAT; // c:2722
3898    }
3899    if (on & PM_EFLOAT) != 0 {
3900        // c:2724
3901        off |= PM_UPPER | PM_ARRAY | PM_HASHED | PM_INTEGER | PM_FFLOAT; // c:2725
3902    }
3903    if (on & PM_INTEGER) != 0 {
3904        // c:2726
3905        off |= PM_UPPER | PM_ARRAY | PM_HASHED | PM_EFLOAT | PM_FFLOAT; // c:2727
3906    }
3907    if (on & (PM_LEFT | PM_RIGHT_Z)) != 0 {
3908        // c:2731
3909        off |= PM_RIGHT_B; // c:2732
3910    }
3911    if (on & PM_RIGHT_B) != 0 {
3912        // c:2733
3913        off |= PM_LEFT | PM_RIGHT_Z; // c:2734
3914    }
3915    if (on & PM_UPPER) != 0 {
3916        off |= PM_LOWER;
3917    } // c:2735-2736
3918    if (on & PM_LOWER) != 0 {
3919        off |= PM_UPPER;
3920    } // c:2737-2738
3921    if (on & PM_HASHED) != 0 {
3922        off |= PM_ARRAY;
3923    } // c:2739-2740
3924    if (on & PM_TIED) != 0 {
3925        // c:2741
3926        off |= PM_INTEGER | PM_EFLOAT | PM_FFLOAT | PM_ARRAY | PM_HASHED; // c:2742
3927    }
3928    on &= !off; // c:2744
3929
3930    queue_signals(); // c:2746
3931
3932    // c:2748-2772 — `-p` print-mode: PRINT_POSIX_EXPORT / READONLY /
3933    // TYPESET, plus optional -p N for line-style.
3934    if OPT_ISSET(&ops, b'p') {
3935        // c:2748
3936        if posix && !EMULATION(EMULATE_KSH) {
3937            // c:2750
3938            printflags |= match func {
3939                BIN_EXPORT => PRINT_POSIX_EXPORT,     // c:2752
3940                BIN_READONLY => PRINT_POSIX_READONLY, // c:2754
3941                _ => PRINT_TYPESET,                   // c:2756
3942            };
3943        } else {
3944            printflags |= PRINT_TYPESET; // c:2758
3945        }
3946        if OPT_HASARG(&ops, b'p') {
3947            // c:2761
3948            let arg = OPT_ARG(&ops, b'p').unwrap_or("");
3949            match arg.trim().parse::<i32>() {
3950                // c:2763
3951                Ok(1) => printflags |= PRINT_LINE, // c:2765
3952                Ok(0) => {}                        // c:2770 -p0 == -p
3953                _ => {
3954                    zwarnnam(name, &format!("bad argument to -p: {}", arg)); // c:2767
3955                    unqueue_signals();
3956                    return 1; // c:2769
3957                }
3958            }
3959        }
3960    }
3961
3962    // c:2775-2795 — no-args path: list whatever options select.
3963    if !hasargs {
3964        // c:2775
3965        if !OPT_ISSET(&ops, b'm') {
3966            // c:2779
3967            printflags &= !PRINT_WITH_NAMESPACE; // c:2780
3968        }
3969        if !OPT_ISSET(&ops, b'p') {
3970            // c:2782
3971            if (on | roff) == 0 {
3972                // c:2783
3973                printflags |= PRINT_TYPE; // c:2784
3974            }
3975            if roff != 0 || OPT_ISSET(&ops, b'+') {
3976                // c:2785
3977                printflags |= PRINT_NAMEONLY; // c:2786
3978            }
3979        }
3980        // c:2792 — `scanhashtable(paramtab, 1, on|roff, 0, paramtab->printnode,
3981        //               printflags|(roff ? PRINT_NAMEONLY : 0));`
3982        //
3983        // Walk paramtab (sorted=1, alphabetical) filtering by on|roff
3984        // and dispatch printparamnode for each match. Previously inlined
3985        // a `println!("{}={}", k, v)` which:
3986        //   - Ignored printflags (PRINT_TYPESET, PRINT_POSIX_EXPORT,
3987        //     PRINT_POSIX_READONLY, PRINT_NAMEONLY) so `export -p` had
3988        //     zero rows, `typeset -p` skipped attribute letters, and
3989        //     `readonly -p` had no `readonly ` prefix.
3990        //   - Read pm.u_str directly so PM_INTEGER / PM_*FLOAT /
3991        //     PM_ARRAY / PM_HASHED values printed as empty.
3992        // printparamnode (params.c:6123) handles all of these.
3993        let printflags_final = printflags | if roff != 0 { PRINT_NAMEONLY } else { 0 }; // c:2792
3994                                                                                        // c:2792 scanmatchtable flags1=on|roff, flags2=0.
3995        let on_roff = (on as u32) | (roff as u32);
3996        // PM_RO_BY_DESIGN expansion: zshrs's special-param
3997        // setup (vm_helper.rs:1054+) replaces the dropped
3998        // PM_READONLY bit with PM_RO_BY_DESIGN so internal
3999        // writes still pass assignstrvalue's PM_READONLY
4000        // guard. `typeset -r` listing must match on either
4001        // bit to surface those entries. Mirrors C zsh's
4002        // PM_READONLY_SPECIAL (= PM_SPECIAL | PM_READONLY |
4003        // PM_RO_BY_DESIGN) where the scanhashtable bit-mask
4004        // implicitly matches RO_BY_DESIGN too (both bits
4005        // set on the same params in C). #97 in docs/BUGS.md.
4006        let on_roff_expanded = if (on_roff & PM_READONLY) != 0 {
4007            on_roff | PM_RO_BY_DESIGN
4008        } else {
4009            on_roff
4010        };
4011        // c:Src/module.c:1218-1219 add_autoparam — in `zsh -f`, every
4012        // autoloadable module parameter (WATCH/watch, aliases…,
4013        // widgets/keymaps, termcap/terminfo, zsh_scheduled_events) is
4014        // a real paramtab stub: a scalar whose value is the module
4015        // name, flagged PM_AUTOLOAD. scanhashtable feeds the stubs to
4016        // printparamnode which prints `undefined NAME` (pmtypes row at
4017        // Src/params.c:6011) and suppresses them under -p
4018        // (Src/params.c:6150-6155). zshrs keeps always-functional
4019        // placeholder params instead, so synthesize the stub rows here
4020        // for every not-yet-loaded module and suppress the placeholder
4021        // rows for the same names (zsh -f shows `undefined WATCH`, not
4022        // a set `WATCH`).
4023        let stubs: Vec<(&'static str, &'static str)> = crate::vm_helper::autoload_param_stubs();
4024        let names: Vec<String> = {
4025            let tab = paramtab().read().unwrap();
4026            let mut names: Vec<String> = tab
4027                .iter()
4028                .filter(|(k, pm)| {
4029                    let f = pm.node.flags as u32;
4030                    // c:2793 — scanhashtable passes PM_UNSET entries
4031                    // through; printparamnode (c:6133-6144) prints
4032                    // PM_DEFAULTED (declared-but-unset) ones name-only
4033                    // and drops the rest. Keep DEFAULTED here so
4034                    // `typeset -n` lists placeholder refs by name.
4035                    if (f & PM_UNSET) != 0 && (f & PM_DEFAULTED) != PM_DEFAULTED {
4036                        return false;
4037                    }
4038                    // Unloaded-module names print as autoload stubs
4039                    // (merged below) — never as their placeholder row.
4040                    if stubs.iter().any(|(n, _)| n == k) {
4041                        return false;
4042                    }
4043                    // c:Src/Modules/parameter.c — magic-assoc names
4044                    // (aliases/builtins/commands/options/functions/
4045                    // parameters/etc.) live in a separate PARTAB in
4046                    // C, NOT paramtab; the user-space scanhashtable
4047                    // never sees them. The Rust port shoves them
4048                    // into paramtab via vm_helper::init_partab_params
4049                    // as PM_HIDE-tagged placeholders. Mirror the C
4050                    // invisibility by skipping PM_HIDE entries here.
4051                    // Bug #371 in docs/BUGS.md: `typeset -A`
4052                    // (no args) listed all magic-assocs instead of
4053                    // only user-defined ones.
4054                    if (f & PM_HIDE) != 0 {
4055                        return false;
4056                    }
4057                    on_roff_expanded == 0 || (f & on_roff_expanded) != 0
4058                })
4059                .map(|(k, _)| k.clone())
4060                .collect();
4061            // Stub flags are PM_SCALAR|PM_AUTOLOAD (module.c:1218-1219)
4062            // — apply the same scanmatchtable flags1 test (PM_AUTOLOAD
4063            // is never in on|roff, so any flag filter excludes them:
4064            // `typeset +x -r` / `typeset +i` list none, matching zsh).
4065            for (n, _) in &stubs {
4066                if on_roff_expanded == 0 || (PM_AUTOLOAD & on_roff_expanded) != 0 {
4067                    names.push((*n).to_string());
4068                }
4069            }
4070            names.sort_by(|a, b| hnamcmp(a, b));
4071            names
4072        };
4073        // c:Src/params.c:6275 PM_TIED partner lookup — printparamnode
4074        // reads `paramtab` to fetch the tied peer (e.g. PATH→path).
4075        // Pre-clone the pm so the print runs WITHOUT holding any
4076        // paramtab lock; otherwise the partner-lookup inside
4077        // printparamnode would deadlock on the same write lock.
4078        // printparamnode mutates hn for the PM_TIED swap path (Bug
4079        // #410) — the mutation lands on the local clone only,
4080        // matching C's "throwaway-print-side" semantics.
4081        for k in names {
4082            // c:Src/module.c:1218-1219 — autoload stub: scalar param,
4083            // u.str = module name, PM_AUTOLOAD. printparamnode prints
4084            // `undefined NAME` (PRINT_TYPE) / `NAME` (PRINT_NAMEONLY)
4085            // and returns early under -p.
4086            if let Some((_, module)) = stubs.iter().find(|(n, _)| *n == k) {
4087                let mut stub_pm = crate::ported::zsh_h::param {
4088                    node: crate::ported::zsh_h::hashnode {
4089                        next: None,
4090                        nam: k.clone(),
4091                        flags: (PM_SCALAR | PM_AUTOLOAD) as i32, // c:1219
4092                    },
4093                    u_data: 0,
4094                    u_tied: None,
4095                    u_arr: None,
4096                    u_str: Some((*module).to_string()), // c:1218 setsparam(pnam, module)
4097                    u_val: 0,
4098                    u_dval: 0.0,
4099                    u_hash: None,
4100                    gsu_s: None,
4101                    gsu_i: None,
4102                    gsu_f: None,
4103                    gsu_a: None,
4104                    gsu_h: None,
4105                    base: 0,
4106                    width: 0,
4107                    env: None,
4108                    ename: None,
4109                    old: None,
4110                    level: 0,
4111                };
4112                printparamnode(&mut stub_pm, printflags_final); // c:2792
4113                continue;
4114            }
4115            let mut pm_clone = match paramtab().read() {
4116                Ok(tab) => match tab.get(&k) {
4117                    Some(pm) => pm.clone(),
4118                    None => continue,
4119                },
4120                Err(_) => continue,
4121            };
4122            printparamnode(&mut pm_clone, printflags_final); // c:2792
4123        }
4124        unqueue_signals();
4125        return 0; // c:2794
4126    }
4127
4128    // c:2799-2810 — `local` (or +g) implies PM_LOCAL.
4129    let nm0 = name.chars().next().unwrap_or(' ');
4130    if nm0 == 'l' || OPT_PLUS(&ops, b'g') {
4131        // c:2799
4132        on |= PM_LOCAL; // c:2800
4133    } else if !OPT_ISSET(&ops, b'g') {
4134        // c:2801
4135        if OPT_MINUS(&ops, b'x') {
4136            // c:2802
4137            let globalexport = isset(GLOBALEXPORT);
4138            let ll_v = locallevel_param.load(Relaxed);
4139            if globalexport {
4140                // c:2803
4141                ops.ind[b'g' as usize] = 1; // c:2804
4142            } else if ll_v != 0 {
4143                // c:2805
4144                on |= PM_LOCAL; // c:2806
4145            }
4146        } else if !(OPT_ISSET(&ops, b'x') || OPT_ISSET(&ops, b'm')) {
4147            // c:2808
4148            on |= PM_LOCAL; // c:2809
4149        }
4150    }
4151
4152    // c:2813+ — -T tied vars + per-arg setting loop.
4153    // The full C body has dozens of paths (PM_TIED tie-pair setup at
4154    // c:2813-2900, glob -m walk at c:2905-2935, name=value assign
4155    // through typeset_single at c:2945+). The Rust port handles the
4156    // three high-frequency paths inline: assoc creation (`PM_HASHED`
4157    // + `name=(k v k v)`), array creation (`PM_ARRAY` + `name=(a b c)`),
4158    // and scalar assignment.
4159    let _ = off;
4160    let is_hashed = (on & PM_HASHED) != 0; // c:2655 `-A`
4161    let is_array = (on & PM_ARRAY) != 0; // c:2655 `-a`
4162                                         // c:Src/builtin.c typeset_single — when the array RHS comes from
4163                                         // an unquoted `$@` / `${arr[@]}` splat (e.g. `typeset -a opts=
4164                                         // ("$@")`), the upstream prefork has already split the value
4165                                         // into separate argv entries: `["opts=(a", "b", "c)"]`. C zsh's
4166                                         // parser captures the entire `name=(... )` shape as one ENVARRAY
4167                                         // token by walking paren depth at parse time so the splat fills
4168                                         // the array's element list. zshrs's compile path emits the
4169                                         // synthetic word `opts=("$@")` then runtime DQ-strip + splat
4170                                         // separates it. Reconstruct the single arg here: when one entry
4171                                         // starts with `NAME=(` and a later entry ends with `)`, rejoin
4172                                         // the run with spaces between elements.
4173                                         // c:Src/builtin.c — paren-init rejoin. C's parser hands the whole
4174                                         // `NAME=(e1 e2 …)` shape as one ENVARRAY token via parse-time paren-
4175                                         // walking so empty elements (`""` quoted to lexer Dnull-Dnull) stay
4176                                         // as separate list nodes. zshrs's compile path emits the args
4177                                         // pre-split through prefork — each `""` produces a true empty
4178                                         // entry in argv. The previous rejoin glued them with single-space
4179                                         // separators, then split_whitespace collapsed consecutive empties
4180                                         // → key/value swap on `typeset -A h=( "" val )`. Bug #93 in
4181                                         // docs/BUGS.md.
4182                                         //
4183                                         // Use `\u{1f}` (ASCII US — unit separator) as the rejoin
4184                                         // separator. The paren-init branch below splits on `\u{1f}` AND
4185                                         // whitespace, preserving consecutive `\u{1f}` as empty elements
4186                                         // (the original quoted-empty args) while still tolerating
4187                                         // single-arg-form paren-init like `a=(1 2 3)` where no rejoin
4188                                         // ran.
4189    const REJOIN_SEP: char = '\u{1f}';
4190    let argv: Vec<String> = {
4191        let mut out: Vec<String> = Vec::with_capacity(argv.len());
4192        let mut i = 0;
4193        while i < argv.len() {
4194            let arg = &argv[i];
4195            let open = arg.find("=(");
4196            let is_open = open.is_some()
4197                && arg
4198                    .as_bytes()
4199                    .first()
4200                    .is_some_and(|b| b.is_ascii_alphabetic() || *b == b'_')
4201                && !arg.ends_with(')');
4202            if is_open {
4203                // Find the matching `)` — scan forward through argv
4204                // tracking paren depth (the parser's `(` was just `(`
4205                // in the source). Each arg may have additional `(`
4206                // and `)` chars from quoted content.
4207                let mut depth: i32 = 0;
4208                for c in arg.chars() {
4209                    if c == '(' {
4210                        depth += 1;
4211                    } else if c == ')' {
4212                        depth -= 1;
4213                    }
4214                }
4215                let mut buf = arg.clone();
4216                let mut j = i + 1;
4217                while depth > 0 && j < argv.len() {
4218                    buf.push(REJOIN_SEP);
4219                    buf.push_str(&argv[j]);
4220                    for c in argv[j].chars() {
4221                        if c == '(' {
4222                            depth += 1;
4223                        } else if c == ')' {
4224                            depth -= 1;
4225                        }
4226                    }
4227                    j += 1;
4228                }
4229                out.push(buf);
4230                i = j;
4231            } else {
4232                out.push(arg.clone());
4233                i += 1;
4234            }
4235        }
4236        out
4237    };
4238    let argv = argv.as_slice();
4239    // c:Src/builtin.c:2813-3030 — under PM_TIED (`-T scalar array [sep]`)
4240    // the third positional is the SEPARATOR character, not a param
4241    // name. The scalar and array become a tied pair: writing the
4242    // scalar splits the joined string back into the array; reading
4243    // the scalar joins the array on the separator. Direct port of the
4244    // C body's tied-pair setup at c:2813-2940 (validation) + the
4245    // typeset_single+tiedarr_gsu wiring at c:1956 + c:2945+.
4246    //
4247    // The Rust port handles the validation / scalar-array tie + value
4248    // installation as one block here rather than threading PM_TIED
4249    // through the generic per-arg loop. Beyond index 2 under -T is a
4250    // usage error in C zsh; the Rust port mirrors the same "too many
4251    // arguments" rejection.
4252    //
4253    // Bug #24 in docs/BUGS.md.
4254    let tied_mode = (on & PM_TIED) != 0;
4255    if tied_mode {
4256        // c:2818-2822 — the FIRST thing the -T block does, ahead of the
4257        // argument-count checks:
4258        //     if (OPT_ISSET(ops,'m')) {
4259        //         zwarnnam(name, "incompatible options for -T");
4260        //         unqueue_signals();
4261        //         return 1;
4262        //     }
4263        // `-m` takes its names as PATTERNS, which cannot express a tie; it was
4264        // silently accepted and did nothing.
4265        if OPT_ISSET(&ops, b'm') {
4266            zwarnnam(name, "incompatible options for -T"); // c:2819
4267            unqueue_signals(); // c:2820
4268            return 1; // c:2821
4269        }
4270        // c:2827-2830 — `if (nargs < 2)` reject.
4271        if argv.len() < 2 {
4272            zwarnnam(name, "-T requires names of scalar and array");
4273            unqueue_signals();
4274            return 1;
4275        }
4276        // c:2831-2833 — `if (nargs > 3)` reject.
4277        if argv.len() > 3 {
4278            zwarnnam(name, "too many arguments for -T");
4279            unqueue_signals();
4280            return 1;
4281        }
4282        // First arg: SCALAR name (with optional =value). c:2838-2840
4283        // `if (ASG_ARRAYP(&asg0))` — first arg can NOT be an array
4284        // assign; we accept either no-value or =scalar.
4285        let (sname, sval_opt): (&str, Option<String>) = match argv[0].find('=') {
4286            Some(i) => (&argv[0][..i], Some(argv[0][i + 1..].to_string())),
4287            None => (argv[0].as_str(), None),
4288        };
4289        // NOT PORTED: c:2841-2846's
4290        //     asg0 = *asg;
4291        //     if (ASG_ARRAYP(&asg0)) {
4292        //         zwarnnam(name, "first argument of tie must be scalar: %s",
4293        //                  asg0.name);
4294        //         return 1;
4295        //     }
4296        // so `typeset -T S=(a b) s` is still silently accepted where zsh fails.
4297        //
4298        // It cannot be decided here. ASG_ARRAYP asks whether the PARSER built an
4299        // array-valued assignment — the reserved-word `typeset` form seeing a
4300        // literal `=(` — and that is a property of the parse, not of the text.
4301        // By the time bin_typeset has argv it is just a string, and the two
4302        // cases are textually identical:
4303        //     typeset -T S=(a b) s     → array assignment, must FAIL
4304        //     typeset -T "S=(a b)" s   → scalar assignment of the literal text
4305        //                                `(a b)`, must SUCCEED (rc=0, $S is
4306        //                                `(a b)` — verified against the oracle)
4307        // A `starts_with('(')` test rejects both; it was tried and broke the
4308        // quoted form. Fixing this needs the assignment shape carried down from
4309        // the compiler (C's `assigns` LinkList), which is the same missing
4310        // reserved-word/builtin distinction that ASG_ARRAYP exists to express.
4311        // Second arg: ARRAY name (with optional =(elements...) init).
4312        // Per c:2847-2854, second arg must be array-shape if it carries
4313        // a value. The Rust port accepts either `arr` or `arr=(a b c)`.
4314        let (aname, aval_opt): (&str, Option<Vec<String>>) = {
4315            let a = argv[1].as_str();
4316            if let Some(eq_idx) = a.find("=(") {
4317                let aname = &a[..eq_idx];
4318                // Strip `=(` prefix and `)` suffix. The parser emits
4319                // array elements separated by REJOIN_SEP (`\u{1f}`)
4320                // sentinels — see parse.rs:2444 commentary. Empty
4321                // strings between sentinels are intentional (zsh's
4322                // assoc paren-init relies on them, bug #93).
4323                let rest = &a[eq_idx + 2..];
4324                let inner = rest.trim_end_matches(')');
4325                let parts: Vec<String> = if inner.contains('\u{1f}') {
4326                    inner
4327                        .split('\u{1f}')
4328                        .filter(|s| !s.is_empty())
4329                        .map(|s| s.to_string())
4330                        .collect()
4331                } else {
4332                    // Fallback: whitespace split for synthetic args
4333                    // that didn't pass through the REJOIN_SEP emitter.
4334                    inner.split_whitespace().map(|s| s.to_string()).collect()
4335                };
4336                (aname, Some(parts))
4337            } else if let Some(eq_idx) = a.find('=') {
4338                // `arr=val` (scalar form for array slot) — reject per
4339                // c:2849-2854 "second argument of tie must be array".
4340                let _ = eq_idx;
4341                zwarnnam(
4342                    name,
4343                    &format!("second argument of tie must be array: {}", a),
4344                );
4345                unqueue_signals();
4346                return 1;
4347            } else {
4348                (a, None)
4349            }
4350        };
4351        // c:2857-2860 — `can't tie a variable to itself`.
4352        if sname == aname {
4353            zerrnam(name, &format!("can't tie a variable to itself: {}", sname));
4354            unqueue_signals();
4355            return 1;
4356        }
4357        // c:2861-2864 — `can't tie array elements` (subscripted name).
4358        if sname.contains('[') || aname.contains('[') {
4359            zerrnam(name, &format!("can't tie array elements: {}", sname));
4360            unqueue_signals();
4361            return 1;
4362        }
4363        // c:2866-2870 — `only one tied parameter can have value`.
4364        if sval_opt.is_some() && aval_opt.is_some() {
4365            zerrnam(
4366                name,
4367                &format!("only one tied parameter can have value: {}", sname),
4368            );
4369            unqueue_signals();
4370            return 1;
4371        }
4372        // c:2876-2898 — joinchar parse. Third argument, if given, is
4373        // character used to join the elements of the array in the
4374        // scalar.
4375        let joinchar: i32 = if argv.len() == 3 {
4376            let joinstr = argv[2].as_bytes(); // c:2880
4377            if joinstr.is_empty() {
4378                0 // c:2893-2894 `else if (!*joinstr) joinchar = 0;`
4379            } else if joinstr[0] == 0x83 {
4380                // c:2895-2896 `else if (*joinstr == Meta) joinchar = joinstr[1] ^ 32;`
4381                (joinstr.get(1).copied().unwrap_or(0) ^ 32) as i32
4382            } else {
4383                joinstr[0] as i32 // c:2897-2898
4384            }
4385        } else {
4386            ':' as i32 // c:2891-2892 `if (!joinstr) joinchar = ':';`
4387        };
4388        // Split/join separators from joinchar. joinchar==0 measured on
4389        // the 5.9.1 release binary: assignment keeps the whole string
4390        // as one element (no split) and reads join with a NUL byte
4391        // (`zjoin(arr, 0, 1)` writing the raw byte, c:Src/params.c:4352).
4392        let split_one = joinchar == 0;
4393        let joinsep: String = if joinchar == 0 {
4394            "\0".to_string()
4395        } else {
4396            ((joinchar as u8) as char).to_string()
4397        };
4398
4399        // c:Src/builtin.c:2940-2944 — when the scalar already exists
4400        // and has a value, save it for seeding the tied array:
4401        //   `oldval = ztrdup(getsparam(asg0.name));`
4402        // The Rust port checks paramtab AND the environ inherited at
4403        // startup so `typeset -T PATH path :` over an env-imported
4404        // PATH seeds `path` from the live PATH instead of clobbering
4405        // it with empty.
4406        // c:2900-2901 — `pm = getnode(asg0.name); apm = getnode(asg->name);`
4407        // then c:2903-2943 decides between erroring, re-tying, and updating an
4408        // EXISTING tie. None of that block was ported: the code below simply
4409        // installed a fresh pair every time, which silently accepted three
4410        // cases C rejects and lost every attribute the new command line didn't
4411        // restate.
4412        let (pm_flags, pm_ename, pm_level) = paramtab()
4413            .read()
4414            .ok()
4415            .and_then(|t| {
4416                t.get(sname)
4417                    .map(|p| (Some(p.node.flags as u32), p.ename.clone(), p.level))
4418            })
4419            .unwrap_or((None, None, 0));
4420        let (apm_flags, apm_ename) = paramtab()
4421            .read()
4422            .ok()
4423            .and_then(|t| t.get(aname).map(|p| (Some(p.node.flags as u32), p.ename.clone())))
4424            .unwrap_or((None, None));
4425        let mut already_tied = false;
4426
4427        if let Some(sf) = pm_flags.filter(|f| (f & (PM_SPECIAL | PM_TIED)) == (PM_SPECIAL | PM_TIED))
4428        {
4429            let _ = sf;
4430            // c:2903-2918 — a SPECIAL tied scalar (PATH/path). Only a re-tie of
4431            // the identical special pair is allowed.
4432            let apm_special = apm_flags.map(|f| (f & PM_SPECIAL) != 0).unwrap_or(false);
4433            if pm_ename.as_deref() != Some(aname) || !apm_special {
4434                // c:2908-2911
4435                zwarnnam(
4436                    name,
4437                    &format!(
4438                        "{} special parameter can only be tied to special parameter {}",
4439                        sname,
4440                        pm_ename.as_deref().unwrap_or("")
4441                    ),
4442                );
4443                unqueue_signals();
4444                return 1;
4445            }
4446            if joinchar != ':' as i32 {
4447                // c:2913-2917
4448                zwarnnam(
4449                    name,
4450                    "cannot change the join character of special tied parameters",
4451                );
4452                unqueue_signals();
4453                return 1;
4454            }
4455            already_tied = true; // c:2918
4456        } else if let Some(af) =
4457            apm_flags.filter(|f| (f & (PM_SPECIAL | PM_TIED)) == (PM_SPECIAL | PM_TIED))
4458        {
4459            let _ = af;
4460            // c:2919-2929 — the ARRAY half is special+tied: catches tying it to
4461            // a different scalar, or to one that is no longer special.
4462            zwarnnam(
4463                name,
4464                &format!(
4465                    "{} special parameter can only be tied to special parameter {}",
4466                    aname,
4467                    apm_ename.as_deref().unwrap_or("")
4468                ),
4469            );
4470            unqueue_signals();
4471            return 1;
4472        } else if let Some(sf) = pm_flags {
4473            // c:2930-2932 — only consider an existing scalar that is actually
4474            // live in this scope.
4475            if ((sf & PM_UNSET) == 0 || (sf & PM_DECLARED) != 0)
4476                && (locallevel_param.load(Relaxed) as i32 == pm_level || (on as u32 & PM_LOCAL) == 0)
4477            {
4478                if (sf & PM_TIED) != 0 {
4479                    if PM_TYPE(sf) != PM_SCALAR {
4480                        // c:2934-2937
4481                        zwarnnam(name, &format!("already tied as non-scalar: {sname}"));
4482                        unqueue_signals();
4483                        return 1;
4484                    } else if pm_ename.as_deref() == Some(aname) {
4485                        already_tied = true; // c:2937
4486                    } else {
4487                        // c:2939-2942
4488                        zwarnnam(name, &format!("can't tie already tied scalar: {sname}"));
4489                        unqueue_signals();
4490                        return 1;
4491                    }
4492                }
4493                // else: not tied — only the export attribute is inherited
4494                // (c:2951), which `inherited_export` below already does.
4495            }
4496        }
4497
4498        if already_tied {
4499            // c:2957-2973 — C does NOT rebuild the pair here. It runs
4500            // typeset_single on each half "if only to update the attributes of
4501            // both, and of course to set the new value if one is provided",
4502            // then returns:
4503            //     typeset_single(..., pm,  on,                        off,             ...)
4504            //     typeset_single(..., apm, (on | PM_ARRAY) & ~PM_EXPORTED, off & ~PM_ARRAY, ...)
4505            // Applying on/off to the EXISTING entries is precisely what lets an
4506            // attribute the current command doesn't restate survive — so
4507            // `typeset -UT TS ts; typeset -T TS ts` keeps its uniqueness, and
4508            // likewise readonly. Rebuilding dropped everything but export.
4509            //
4510            // typeset_single itself is present in this file but is dead code
4511            // (nothing calls it; bin_typeset carries its own inline logic), so
4512            // the attribute update is applied directly here rather than routed
4513            // through it.
4514            if let Ok(mut tab) = paramtab().write() {
4515                if let Some(p) = tab.get_mut(sname) {
4516                    p.node.flags = ((p.node.flags as u32 | on as u32) & !(off as u32)) as i32;
4517                    // c:2967 — typeset_single takes `joinchar`, so a re-tie
4518                    // RE-STATES the separator: "It is possible to apply -T to
4519                    // two previously tied variables but with a different
4520                    // separator character, in which case the variables remain
4521                    // joined as before but the separator is changed."
4522                    // (Doc/Zsh/builtins.yo, -T.) Updating flags alone left
4523                    // `typeset -T TS ts; typeset -T TS ts ,` joining on ':'.
4524                    if let Some(td) = p.u_tied.as_mut() {
4525                        td.joinchar = joinchar; // c:2967
4526                    }
4527                }
4528                if let Some(p) = tab.get_mut(aname) {
4529                    let aon = (on as u32 | PM_ARRAY) & !PM_EXPORTED; // c:2970
4530                    let aoff = off as u32 & !PM_ARRAY; // c:2971
4531                    p.node.flags = ((p.node.flags as u32 | aon) & !aoff) as i32;
4532                }
4533            }
4534            // Values, if the command line carried any (c:2963 "and of course to
4535            // set the new value if one is provided for either of them").
4536            if let Some(sval) = sval_opt.as_deref() {
4537                crate::ported::params::assignsparam(sname, sval, 0);
4538            } else if let Some(arr) = aval_opt {
4539                crate::ported::params::assignaparam(aname, arr, 0);
4540            }
4541            unqueue_signals();
4542            return 0;
4543        }
4544
4545        let existing_scalar: Option<String> = {
4546            let from_tab = paramtab()
4547                .read()
4548                .ok()
4549                .and_then(|t| t.get(sname).and_then(|p| p.u_str.clone()));
4550            from_tab.or_else(|| std::env::var(sname).ok())
4551        };
4552
4553        // Build the initial array value: prefer the array RHS, then
4554        // the scalar RHS (split on joinchar), then the existing
4555        // scalar's env value (split on joinchar). Mirrors C's sequence
4556        // at c:2960-3030 where typeset_single is called on both names;
4557        // if the scalar had a value it gets passed to tiedarrsetfn
4558        // which splits on the tieddata joinchar (c:4370-4381 sepsplit).
4559        let init_arr: Vec<String> = if let Some(arr) = aval_opt {
4560            arr
4561        } else if let Some(sval) = sval_opt.as_deref() {
4562            if split_one {
4563                vec![sval.to_string()]
4564            } else {
4565                crate::ported::utils::sepsplit(sval, Some(&joinsep), true)
4566            }
4567        } else if let Some(old) = existing_scalar.as_deref() {
4568            if old.is_empty() {
4569                Vec::new()
4570            } else if split_one {
4571                vec![old.to_string()]
4572            } else {
4573                crate::ported::utils::sepsplit(old, Some(&joinsep), true)
4574            }
4575        } else {
4576            Vec::new()
4577        };
4578
4579        // Install the array side first (matching C c:2980 "Do it
4580        // first because we need the address"). Build a plain
4581        // PM_ARRAY|PM_TIED param.
4582        // c:Src/builtin.c:2986-2999 — the array side is created with
4583        // `(on | PM_ARRAY) & ~PM_EXPORTED` and the scalar side with the full
4584        // `on`. So `export -T FOO foo` / `readonly -T` carry those attribute
4585        // flags onto the pair (the array is NOT export-flagged; the scalar
4586        // is). Previously both were hardcoded to `…|PM_TIED`, so `export -T`
4587        // left FOO unexported — `${(t)FOO}` read `scalar-tied` and
4588        // `typeset -p FOO` printed `typeset -T` instead of `export -T`.
4589        //
4590        // c:Src/builtin.c:2953 — `on |= (pm->node.flags & ~roff) & PM_EXPORTED;`
4591        //
4592        //   "Variable already exists in the current scope but is not tied.
4593        //    We're preserving its value and export attribute but no other
4594        //    attributes upon converting to 'tied'."
4595        //
4596        // So tying an ALREADY-EXPORTED scalar keeps it exported, even without
4597        // `-x`: `export E=a:b; typeset -T E e` leaves E in the environment
4598        // (${(t)E} == scalar-tied-export). zshrs built the tie attributes from
4599        // the command-line flags alone and silently dropped the export, which
4600        // takes the variable OUT of the environment of every later child.
4601        let inherited_export: u32 = paramtab()
4602            .read()
4603            .ok()
4604            .and_then(|t| t.get(sname).map(|p| p.node.flags as u32))
4605            .unwrap_or(0)
4606            & PM_EXPORTED
4607            & !(off as u32); // c:2953 `& ~roff` — an explicit +x still wins
4608        // c:2989 / c:3003 — C hands BOTH halves the full `on`:
4609        //     apm = typeset_single(..., (on | PM_ARRAY) & ~PM_EXPORTED, ...)
4610        //     pm  = typeset_single(..., on, ...)
4611        // so every attribute the user asked for lands on the tie, not just the
4612        // export/readonly pair. Masking down to PM_EXPORTED|PM_READONLY silently
4613        // dropped the rest — most visibly PM_UNIQUE, so `typeset -UT B b` tied
4614        // the two halves but never deduped either (`${(t)b}` read `array-tied`
4615        // where zsh reads `array-tied-unique`), while the equivalent spelled as
4616        // two commands (`typeset -T B b; typeset -U b`) worked. Carrying `on`
4617        // whole is safe here: c:2742-2744 has already stripped the type bits
4618        // from it (`if (on & PM_TIED) off |= PM_INTEGER|…|PM_HASHED; on &= ~off`).
4619        let tie_attr: u32 = on as u32 | inherited_export;
4620        let mut apm = param::default();
4621        apm.node.nam = aname.to_string();
4622        apm.node.flags = ((PM_ARRAY | PM_TIED) | (tie_attr & !PM_EXPORTED)) as i32;
4623        apm.u_arr = Some(init_arr.clone());
4624        apm.ename = Some(sname.to_string());
4625        apm.level = locallevel.load(Relaxed) as i32;
4626        // c:2982-2989 — `tdp = (Tieddata) zalloc(sizeof *tdp);
4627        // tdp->joinchar = joinchar;` — the tieddata rides on the
4628        // SCALAR side's pm->u.data (c:2566 `tdp->joinchar = joinchar`).
4629        let tdp = crate::ported::zsh_h::tieddata {
4630            arrptr: None,
4631            joinchar,
4632        };
4633
4634        // Install the scalar side with PM_TIED + gsu_s wired to the
4635        // tied-via-ename getters/setters so reads/writes propagate
4636        // through paramtab to apm.u_arr.
4637        let mut spm = param::default();
4638        spm.node.nam = sname.to_string();
4639        spm.node.flags = ((PM_SCALAR | PM_TIED) | tie_attr) as i32; // c:2999 scalar carries full `on`
4640        spm.ename = Some(aname.to_string());
4641        spm.u_str = Some(init_arr.join(&joinsep));
4642        spm.u_tied = Some(Box::new(tdp));
4643        spm.level = locallevel.load(Relaxed) as i32;
4644        // c:Src/builtin.c:1956 — `static const struct gsu_scalar
4645        // tiedarr_gsu = { tiedarrgetfn, tiedarrsetfn, tiedarrunsetfn };`
4646        // The scalar side of a tied pair routes its reads/writes
4647        // through these so the partner array stays in sync. C's
4648        // tiedarrgetfn returns the joined string; tiedarrsetfn splits
4649        // and writes through `*dptr->arrptr`. The Rust port adapts
4650        // those to paramtab lookups via `pm.ename` (see params.rs
4651        // commentary). The C tiedarrgetfn returns `char*` (joined
4652        // scalar); the Rust signature returns `Vec<String>` because
4653        // the existing typing — adapt with a closure that joins.
4654        fn tied_scalar_getfn_shim(pm: &param) -> String {
4655            // c:4352 — `zjoin(*dptr->arrptr, (unsigned char)
4656            // dptr->joinchar, 1)`; joinchar lives on the tieddata
4657            // riding pm->u.data (Rust: typed u_tied view).
4658            let sep = match pm.u_tied.as_deref() {
4659                // joinchar==0 joins with the raw NUL byte (zjoin with
4660                // delim 0, c:Src/params.c:4352; measured on 5.9.1).
4661                Some(td) if td.joinchar == 0 => "\0".to_string(),
4662                Some(td) => ((td.joinchar as u8) as char).to_string(),
4663                None => ":".to_string(),
4664            };
4665            crate::ported::params::tiedarrgetfn(pm).join(&sep)
4666        }
4667        fn tied_scalar_setfn_shim(pm: &mut param, val: String) {
4668            crate::ported::params::tiedarrsetfn(pm, Some(val))
4669        }
4670        fn tied_scalar_unsetfn_shim(pm: &mut param, exp: i32) {
4671            crate::ported::params::tiedarrunsetfn(pm, exp)
4672        }
4673        spm.gsu_s = Some(Box::new(crate::ported::zsh_h::gsu_scalar {
4674            getfn: tied_scalar_getfn_shim,
4675            setfn: tied_scalar_setfn_shim,
4676            unsetfn: tied_scalar_unsetfn_shim,
4677        }));
4678
4679        if let Ok(mut tab) = paramtab().write() {
4680            tab.insert(aname.to_string(), Box::new(apm));
4681            tab.insert(sname.to_string(), Box::new(spm));
4682        }
4683        unqueue_signals();
4684        return 0;
4685    }
4686
4687    // c:Src/builtin.c:3042-3098 — `-m PATTERN` (or `+m PATTERN`) treats
4688    // each arg as a glob, not a `name[=value]` pair.
4689    //
4690    // C splits into TWO subpaths:
4691    //   +m: c:3068 `scanmatchtable(..., paramtab->printnode, printflags)`
4692    //       — direct paramtab walk that prints PRINT_TYPE | PRINT_NAMEONLY
4693    //         (built up at c:3051-3054 when on|roff == 0).
4694    //   -m: c:3081-3094 build pmlist, then run `typeset_single` per
4695    //       match. typeset_single's print path (c:2240-2247) uses
4696    //         - PRINT_TYPESET with `-p`
4697    //         - PRINT_INCLUDEVALUE otherwise, gated on
4698    //             `!OPT_ISSET(ops,'g') && (unset(TYPESETSILENT) ||
4699    //              OPT_ISSET(ops,'m'))`
4700    //
4701    // Earlier zshrs collapsed both into one `printparamnode(...,
4702    // PRINT_INCLUDEVALUE)` call which:
4703    //   - Made `+m` emit `name=value` instead of `name` / `integer NAME`.
4704    //   - Ignored `-g` print suppression so `typeset -gm 'A*'` spammed
4705    //     every match (zsh is silent: `-g` blocks the print). Hit
4706    //     during zinit's plugin loader which runs `typeset -gm`
4707    //     against the plugin's exported-var set on every load.
4708    //     Bug #48 in docs/BUGS.md.
4709    if OPT_ISSET(&ops, b'm') && !argv.is_empty() {
4710        // c:3043-3055 — printflags for the +m direct-scan path.
4711        if !OPT_ISSET(&ops, b'p') {
4712            // c:3044-3050 — mass-changing types is fatal for namerefs.
4713            if (on & PM_NAMEREF) != 0 && OPT_MINUS(&ops, b'm') {
4714                zerrnam(name, "-m not allowed with -n"); // c:3048
4715                unqueue_signals();
4716                return 1; // c:3049
4717            }
4718            if (on | roff) == 0 {
4719                printflags |= PRINT_TYPE; // c:3052
4720            }
4721            if on == 0 {
4722                printflags |= PRINT_NAMEONLY; // c:3054
4723            }
4724        }
4725        // c:2241 — typeset_single's `-m` print path always sets
4726        // PRINT_WITH_NAMESPACE so dot-prefixed names (`.cd`) emit
4727        // instead of being filtered at printparamnode's namespace gate.
4728        let single_flags: i32 = if OPT_ISSET(&ops, b'p') {
4729            PRINT_TYPESET | PRINT_WITH_NAMESPACE
4730        } else {
4731            PRINT_INCLUDEVALUE | PRINT_WITH_NAMESPACE
4732        };
4733        // c:2244 — `else if (!OPT_ISSET(ops,'g') && (unset(TYPESETSILENT)
4734        // || OPT_ISSET(ops,'m')))` — typeset_single suppresses
4735        // PRINT_INCLUDEVALUE when `-g` is set. `-p` bypasses (c:2242).
4736        let do_minus_print = OPT_ISSET(&ops, b'p')
4737            || (!OPT_ISSET(&ops, b'g') && (!isset(TYPESETSILENT) || OPT_ISSET(&ops, b'm')));
4738        for pattern in argv.iter() {
4739            // c:3061 — `patcompile(asg->name, 0, NULL)` glob-compile.
4740            // Use the canonical pattern.rs port. On compile failure,
4741            // emit "bad pattern" and continue to the next arg.
4742            let pat = crate::ported::pattern::patcompile(
4743                &{
4744                    let mut __pat_tok = (pattern).to_string();
4745                    crate::ported::glob::tokenize(&mut __pat_tok);
4746                    __pat_tok
4747                },
4748                crate::ported::zsh_h::PAT_HEAPDUP as i32,
4749                None,
4750            );
4751            let pat = match pat {
4752                Some(p) => p,
4753                None => {
4754                    zwarnnam(name, &format!("bad pattern: {}", pattern));
4755                    returnval = 1;
4756                    continue;
4757                }
4758            };
4759            // c:3068 — `scanmatchtable(paramtab, pprog, 1, on|roff,
4760            // 0, paramtab->printnode, printflags);` — walk paramtab
4761            // entries whose name matches the pattern AND whose flag
4762            // bits intersect on|roff.
4763            let names: Vec<String> = {
4764                let tab = paramtab().read().unwrap();
4765                let on_roff = (on as u32) | (roff as u32);
4766                let mut names: Vec<String> = tab
4767                    .iter()
4768                    .filter(|(k, pm)| {
4769                        let f = pm.node.flags as u32;
4770                        if (f & PM_UNSET) != 0 {
4771                            return false;
4772                        }
4773                        // c:Src/builtin.c:3055-3094 / hashtable.c:373-440
4774                        // — the `-m`/`+m` PATTERN path scans paramtab
4775                        // via `scanmatchtable(paramtab, pprog, ...)`.
4776                        // scanmatchtable matches ONLY on flags1/flags2
4777                        // + the pattern; it does NOT exclude PM_HIDE.
4778                        // An explicit pattern therefore REVEALS hidden
4779                        // params — verified vs `/opt/homebrew/bin/zsh`:
4780                        //   `typeset +m 'a*'` → `undefined aliases` +
4781                        //   `array argv`; `typeset -m 'a*'` → `aliases`
4782                        //   + `argv=(  )`. The magic-assoc placeholders
4783                        //   (PM_HIDE|PM_HIDEVAL, inserted by
4784                        //   init_partab_params) and `private` vars
4785                        //   (PM_HIDE, no PM_HIDEVAL — c:Modules/
4786                        //   param_private.c:174) must BOTH appear here,
4787                        //   unlike the no-args list path (c:2762-2792)
4788                        //   where scanhashtable's bare scan keeps them
4789                        //   hidden (bug #371). So NO PM_HIDE filter on
4790                        //   this PATTERN path — V10private: `private
4791                        //   x=5; typeset +m x` → `local x`.
4792                        if on_roff != 0 && (f & on_roff) == 0 {
4793                            return false;
4794                        }
4795                        crate::ported::pattern::pattry(&pat, k)
4796                    })
4797                    .map(|(k, _)| k.clone())
4798                    .collect();
4799                names.sort_by(|a, b| hnamcmp(a, b));
4800                names
4801            };
4802            if OPT_PLUS(&ops, b'm') {
4803                // c:3068-3070 — `+m`: direct print using the
4804                // PRINT_TYPE | PRINT_NAMEONLY flags built above.
4805                for k in names {
4806                    if let Ok(mut tab) = paramtab().write() {
4807                        if let Some(pm) = tab.get_mut(&k) {
4808                            printparamnode(pm, printflags);
4809                        }
4810                    }
4811                }
4812                continue;
4813            }
4814            // c:3081-3094 — `-m`: typeset_single per match. For the
4815            // pure listing case (no `=` value, no attribute mutation),
4816            // typeset_single reduces to the c:2240-2247 print arm.
4817            // Attribute-conversion (`-im PAT`, `-rm PAT`, etc.) on
4818            // matched names isn't ported here yet; the early
4819            // `return returnval` below short-circuits the per-arg
4820            // name loop for `-m` args, so on/roff currently acts as
4821            // a listing filter only.
4822            if do_minus_print {
4823                for k in names {
4824                    if let Ok(mut tab) = paramtab().write() {
4825                        if let Some(pm) = tab.get_mut(&k) {
4826                            printparamnode(pm, single_flags);
4827                        }
4828                    }
4829                }
4830            }
4831        }
4832        unqueue_signals();
4833        return returnval;
4834    }
4835
4836    // c:3035-3039 — `+T` is not a way to untie:
4837    //     if (off & PM_TIED) {
4838    //         unqueue_signals();
4839    //         zerrnam(name, "use unset to remove tied variables");
4840    //         return 1;
4841    //     }
4842    // C puts this at the TOP LEVEL, so it precedes everything typeset_single
4843    // does — notably the special-parameter type-change rule, whose `chflags`
4844    // watches PM_TIED and would otherwise claim `typeset +T PATH path` and
4845    // report "can't change type of a special parameter" instead. The check was
4846    // missing entirely, so `+T` was silently accepted and did nothing.
4847    if (off as u32 & PM_TIED) != 0 {
4848        unqueue_signals(); // c:3036
4849        zerrnam(name, "use unset to remove tied variables"); // c:3037
4850        return 1; // c:3038
4851    }
4852
4853    let mut tied_name_count: usize = 0;
4854    for arg in argv {
4855        // c:Src/builtin.c typeset_single — when PM_LOCAL is in
4856        // flags, createparam first to install pm.old chain at
4857        // locallevel (createparam c:1132-1147). Applies uniformly
4858        // to all forms: `local x`, `local x=v`, `local arr=(...)`,
4859        // `local -A h`. endparamscope unwinds via Param.old.
4860        if tied_mode {
4861            tied_name_count += 1;
4862            if tied_name_count > 2 {
4863                // Separator / extra args — don't run name
4864                // validation or createparam against them. Full
4865                // -T body (createparam SCALAR_TIED + array tie
4866                // backref) is deferred per the comment above on
4867                // the typeset_single dispatch.
4868                continue;
4869            }
4870        }
4871        let arg_name: &str = match arg.find('=') {
4872            Some(i) => &arg[..i],
4873            None => arg.as_str(),
4874        };
4875
4876        // c:2117-2193 (inside typeset_single) — changing the TYPE of an
4877        // existing parameter is refused for specials, with SECONDS the one
4878        // documented exception.
4879        //
4880        //     int chflags = ((off & pm->node.flags) | (on & ~pm->node.flags)) &
4881        //          (PM_INTEGER|PM_EFLOAT|PM_FFLOAT|PM_HASHED|PM_ARRAY|PM_TIED|PM_AUTOLOAD);
4882        //     /* keep the parameter if just switching between floating types */
4883        //     if ((tc = chflags && chflags != (PM_EFLOAT|PM_FFLOAT))) { ... }
4884        //     ...
4885        //     if (... || tc) {
4886        //         if (pm->node.flags & PM_SPECIAL) {
4887        //             int err = 1;
4888        //             if (!readonly && !strcmp(pname, "SECONDS")) {
4889        //                 ... else if (!setsecondstype(pm, on, off)) { ... err = 0; }
4890        //             }
4891        //             if (err) { zerrnam(cname, "%s: can't change type of a "
4892        //                                "special parameter", pname); return NULL; }
4893        //
4894        // None of this was ported, so a type change against a special was
4895        // silently ACCEPTED and then dropped: `typeset -F RANDOM`,
4896        // `typeset -F LINENO` and `typeset -F HISTSIZE` all returned 0 having
4897        // done nothing, where zsh fails. And the SECONDS exception — the
4898        // documented way to get sub-second timing — never took effect:
4899        // `typeset -F SECONDS` left it `integer-special` instead of
4900        // `float-special`.
4901        //
4902        // `chflags` is why this fires only on a REAL type change:
4903        // `typeset -i SECONDS` / `typeset -i LINENO` leave chflags == 0 (the
4904        // bit is already set) and are correctly no-ops, and -E↔-F is exempted
4905        // outright by the `!= (PM_EFLOAT|PM_FFLOAT)` clause. It also covers
4906        // `typeset -a PATH`, which is a PM_ARRAY type change on a special and
4907        // so earns the same message rather than an array-specific one.
4908        //
4909        // The `readonly` half of C's guard is not modelled here: it gates
4910        // turning readonly OFF, which reaches this branch by a different route.
4911        // setsecondstype (params.rs) is the real port and had NO callers.
4912        if !tied_mode {
4913            let pmf = paramtab()
4914                .read()
4915                .ok()
4916                .and_then(|t| t.get(arg_name).map(|p| p.node.flags as u32));
4917            if let Some(pmf) = pmf {
4918                let chflags = ((off as u32 & pmf) | (on as u32 & !pmf))
4919                    & (PM_INTEGER
4920                        | PM_EFLOAT
4921                        | PM_FFLOAT
4922                        | PM_HASHED
4923                        | PM_ARRAY
4924                        | PM_TIED
4925                        | PM_AUTOLOAD); // c:2118-2120
4926                let tc = chflags != 0 && chflags != (PM_EFLOAT | PM_FFLOAT); // c:2122
4927                if tc && (pmf & PM_SPECIAL) != 0 {
4928                    let mut err = true; // c:2144
4929                    if arg_name == "SECONDS" {
4930                        // c:2171 — `else if (!setsecondstype(pm, on, off))`.
4931                        let ok = paramtab()
4932                            .write()
4933                            .ok()
4934                            .and_then(|mut t| {
4935                                t.get_mut(arg_name)
4936                                    .map(|p| {
4937                                        crate::ported::params::setsecondstype(
4938                                            p, on as i32, off as i32,
4939                                        )
4940                                    })
4941                            })
4942                            .unwrap_or(1);
4943                        if ok == 0 {
4944                            err = false; // c:2176
4945                            // c:2172-2175 — `if (asg->value.scalar && !(pm =
4946                            // assignsparam(pname, ..., 0))) return NULL;`
4947                            if let Some(i) = arg.find('=') {
4948                                crate::ported::params::assignsparam(arg_name, &arg[i + 1..], 0);
4949                            }
4950                        }
4951                    }
4952                    if err {
4953                        // c:2181-2187
4954                        if !OPT_ISSET(&ops, b'p') {
4955                            zerrnam(
4956                                name,
4957                                &format!("{arg_name}: can't change type of a special parameter"),
4958                            );
4959                        }
4960                        unqueue_signals();
4961                        return 1;
4962                    }
4963                    // SECONDS: setsecondstype already installed the new type;
4964                    // c:2169's `tc = 0` skips the normal conversion below.
4965                    continue;
4966                }
4967            }
4968        }
4969
4970        // c:2519-2552 (Src/builtin.c, inside typeset_single) — name
4971        // validation gate. Direct port:
4972        //   else if ((isident(pname) || paramtab->getnode(paramtab, pname))
4973        //            && (!idigit(*pname) || !strcmp(pname, "0"))) {
4974        //       /* proceed */
4975        //   } else {
4976        //       if (idigit(*pname))
4977        //           zerrnam(cname, "not an identifier: %s", pname);
4978        //       else
4979        //           zerrnam(cname, "not valid in this context: %s", pname);
4980        //       return NULL;
4981        //   }
4982        //
4983        // The C function returns NULL on failure; the outer bin_typeset
4984        // name loop continues to the next arg (errflag silences
4985        // subsequent zerr calls so we won't double-emit). Mirror that
4986        // here with `continue`.
4987        let pname_in_tab = paramtab()
4988            .read()
4989            .map(|t| t.get(arg_name).is_some())
4990            .unwrap_or(false);
4991        // c:2062-2064 — `usepm = pm && (!(pm->node.flags & PM_UNSET)
4992        // || ...)`: snapshot BEFORE any createparam this iteration
4993        // runs (the PM_LOCAL shadow block and the pre-assign type
4994        // stamps both create fresh pms). Only the reuse-existing-pm
4995        // branch of typeset_single carries the c:2336 `if (errflag)
4996        // return NULL` post-assign check; the fresh-param path
4997        // (c:2577+ createparam → c:2604 assignsparam) has NO errflag
4998        // check — `typeset -i x=3#8` zerrs the math but bin_typeset
4999        // still returns 0 (zsh 5.9 exit 0, lastval untouched).
5000        let usepm_existing = paramtab()
5001            .read()
5002            .map(|t| {
5003                t.get(arg_name)
5004                    .is_some_and(|pm| (pm.node.flags as u32 & PM_UNSET) == 0)
5005            })
5006            .unwrap_or(false);
5007        // c:Src/builtin.c:2078 — snapshot the EXISTING param's local
5008        // level BEFORE this arg's createparam/assign runs. The bare-name
5009        // print decision (c:2244, inlined at ~5962) needs the pre-state
5010        // level to tell "re-declare same-level local" (usepm kept →
5011        // print) from "localize a higher-level/global var" (usepm=0 →
5012        // no print). Sentinel -1 when the param doesn't pre-exist.
5013        let pm_level_existing: i32 = paramtab()
5014            .read()
5015            .ok()
5016            .and_then(|t| t.get(arg_name).map(|pm| pm.level))
5017            .unwrap_or(-1);
5018        let first_is_digit = arg_name
5019            .as_bytes()
5020            .first()
5021            .is_some_and(|b| b.is_ascii_digit());
5022        let pname_valid =
5023            (isident(arg_name) || pname_in_tab) && (!first_is_digit || arg_name == "0");
5024        if !pname_valid {
5025            if first_is_digit {
5026                zerrnam(
5027                    name, // c:2548
5028                    &format!("not an identifier: {}", arg_name),
5029                );
5030            } else {
5031                zerrnam(
5032                    name, // c:2550
5033                    &format!("not valid in this context: {}", arg_name),
5034                );
5035            }
5036            // c:3153-3156 — `if (!typeset_single(...)) returnval = 1;`
5037            // — the NULL return from the validation gate makes
5038            // bin_typeset's per-arg loop record failure.
5039            returnval = 1;
5040            continue; // c:2551 return NULL
5041        }
5042
5043        // c:2241-2247 — `-p` print-mode for an existing param (no `=`,
5044        // no value). C `typeset_single` lands here when `usepm` is set
5045        // and `!ASG_VALUEP(asg)`, BEFORE createparam runs (c:2218 →
5046        // c:2244 early return). The Rust loop must also dispatch the
5047        // print branch first; otherwise the createparam call below
5048        // overwrites pm.node.flags on the reuse-arm (c:2018), clobbering
5049        // typeset-attribute bits set by an earlier `typeset -i n` call.
5050        if !arg.contains('=') && OPT_ISSET(&ops, b'p') {
5051            let with_ns = if OPT_ISSET(&ops, b'm') {
5052                // c:2241
5053                PRINT_WITH_NAMESPACE
5054            } else {
5055                0
5056            };
5057            // c:Src/builtin.c:2761-2765 — `-p1` adds PRINT_LINE (one
5058            // array/assoc element per line). The named-arg `typeset -p1
5059            // NAME` print path missed it (only the listing path parsed
5060            // it), so `typeset -p1 myarray` printed single-line.
5061            let line_flag =
5062                if OPT_HASARG(&ops, b'p') && OPT_ARG(&ops, b'p').map(|a| a.trim()) == Some("1") {
5063                    PRINT_LINE
5064                } else {
5065                    0
5066                };
5067            let existed = paramtab()
5068                .read()
5069                .map(|t| t.contains_key(arg_name))
5070                .unwrap_or(false);
5071            if existed {
5072                // c:Src/params.c:6275 — printparamnode looks up the
5073                // PM_TIED peer via paramtab; we must NOT hold the lock
5074                // when calling. Bug #410 — pre-clone pattern (mirrors
5075                // the bin_typeset `-m PAT` arm refactor).
5076                let mut pm_clone = match paramtab().read() {
5077                    Ok(tab) => tab.get(arg_name).cloned(),
5078                    Err(_) => None,
5079                };
5080                if let Some(ref mut pm) = pm_clone {
5081                    // c:2243 — `paramtab->printnode(&pm->node,
5082                    //   PRINT_TYPESET|with_ns);`
5083                    printparamnode(pm, PRINT_TYPESET | with_ns | line_flag);
5084                }
5085            } else {
5086                // c:Src/builtin.c:3110-3113 — when `typeset -p NAME`
5087                // and the param doesn't exist, emit
5088                // `typeset: no such variable: NAME` and set
5089                // returnval=1 so the builtin's exit status is 1.
5090                zwarnnam(name, &format!("no such variable: {}", arg_name));
5091                returnval = 1; // c:Src/builtin.c:3112
5092            }
5093            continue;
5094        }
5095
5096        // c:3117-3150 — `typeset -n NAME[=refname]` arm.
5097        if (on as u32 & PM_NAMEREF) != 0 {
5098            // c:Src/builtin.c:3117-3150 — the `-n` literal-name arm,
5099            // INLINE in C's bin_typeset (no separate C function; the
5100            // former vm_helper::typeset_nameref_arg helper relocated
5101            // here 2026-06-12 per the no-fake-fns-in-ported rule).
5102            // Combined with typeset_single's nameref pieces:
5103            // subscripted-name reject (c:2452-2456), read-only
5104            // reference guard (c:2249-2256), fresh-start unset
5105            // (c:3127-3141), scalar→nameref conversion carrying the
5106            // value as refname (c:3132-3135), creation + refname
5107            // assignment (assignsparam → PM_NAMEREF arm → setscope).
5108            let nameref_arm = |on: u32, off: u32| -> i32 {
5109                let value: Option<&str> = arg.find('=').map(|i| &arg[i + 1..]);
5110
5111                // c:2452-2456 — `typeset -n ptr[1]=...` is invalid.
5112                if arg_name.contains('[') {
5113                    zerrnam(
5114                        name,
5115                        &format!("{}: reference variable cannot be an array", arg_name),
5116                    );
5117                    unqueue_signals();
5118                    return 1;
5119                }
5120
5121                // c:3118-3126 — refname target that is itself a PM_SPECIAL nameref.
5122                if let Some(v) = value {
5123                    let special_ref = paramtab()
5124                        .read()
5125                        .ok()
5126                        .and_then(|t| {
5127                            t.get(v).map(|pm| {
5128                                let f = pm.node.flags as u32;
5129                                (f & PM_NAMEREF) != 0 && (f & PM_SPECIAL) != 0
5130                            })
5131                        })
5132                        .unwrap_or(false);
5133                    if special_ref {
5134                        zwarnnam(name, &format!("{}: invalid reference", v)); // c:3122
5135                        unqueue_signals();
5136                        return 1; // c:3123-3124
5137                    }
5138                }
5139
5140                let cur_ll = locallevel_param.load(Relaxed) as i32;
5141                let existing = paramtab().read().ok().and_then(|t| {
5142                    t.get(arg_name)
5143                        .map(|pm| (pm.node.flags as u32, pm.level, pm.u_str.clone()))
5144                });
5145
5146                let mut carried_value: Option<String> = value.map(String::from);
5147                let mut reuse_existing = false;
5148
5149                let existing_level: Option<i32> = existing.as_ref().map(|(_, l, _)| *l);
5150                if let Some((eflags, elevel, estr)) = existing {
5151                    // c:2249-2256 — read-only guard (typeset_single). Fires when
5152                    // the existing pm is readonly, +r wasn't given, and either the
5153                    // nameref-ness changes or a nameref gets a new value.
5154                    if (eflags & PM_READONLY) != 0
5155                        && (off & PM_READONLY) == 0
5156                        && !OPT_ISSET(&ops, b'p')
5157                    {
5158                        let kind = if (eflags & PM_NAMEREF) != 0 {
5159                            "reference"
5160                        } else {
5161                            "variable"
5162                        };
5163                        zerrnam(name, &format!("{}: read-only {}", arg_name, kind)); // c:2254
5164                        unqueue_signals();
5165                        return 1; // c:2256
5166                    }
5167                    // c:3127-3141 — namerefs always start over fresh.
5168                    if elevel >= cur_ll || ((on & PM_LOCAL) == 0 && elevel < cur_ll) {
5169                        // c:3132-3135 — converting a scalar: its value becomes
5170                        // the refname.
5171                        if carried_value.is_none()
5172                            && PM_TYPE(eflags) == PM_SCALAR
5173                            && estr.as_deref().map(|s| !s.is_empty()).unwrap_or(false)
5174                        {
5175                            carried_value = estr.clone();
5176                        }
5177                        // c:3136-3140 — `if (!(hn->flags & PM_READONLY)) {
5178                        //   unsetparam_pm(oldpm, 0, 1); hn = NULL; }` — only a
5179                        // non-readonly ref starts over fresh; a readonly one is
5180                        // KEPT and handed to typeset_single (c:3148-3149), where
5181                        // `typeset +r -n ref[=val]` clears the flag in place and
5182                        // any =val ASSIGNS THROUGH the surviving refname.
5183                        if (eflags & PM_READONLY) != 0 {
5184                            reuse_existing = true;
5185                        } else if let Some(mut old) =
5186                            paramtab().write().ok().and_then(|mut t| t.remove(arg_name))
5187                        {
5188                            // keep the shadowed outer binding for re-chaining
5189                            if let Some(prev) = old.old.take() {
5190                                if let Ok(mut tab) = paramtab().write() {
5191                                    tab.insert(arg_name.to_string(), prev);
5192                                }
5193                            }
5194                        }
5195                    } else if (eflags & PM_READONLY) != 0 {
5196                        // c:3142-3149 — only a READONLY ref survives as the pm
5197                        // handed to typeset_single (so `typeset -rn ref=var` can
5198                        // error properly); everything else gets `hn = NULL` and
5199                        // a fresh local SHADOW is created below (c:3148-3149).
5200                        reuse_existing = true;
5201                    }
5202                }
5203
5204                // (Re)create the nameref param (typeset_single c:2577+ createparam
5205                // with PM_NAMEREF type).
5206                if !reuse_existing {
5207                    let shadowed = paramtab().write().ok().and_then(|mut t| {
5208                        if (on & PM_LOCAL) != 0 && cur_ll > 0 {
5209                            t.remove(arg_name)
5210                        } else {
5211                            None
5212                        }
5213                    });
5214                    let mut flags = PM_NAMEREF as i32;
5215                    // c:2544 — TYPESET_TO_UNSET: declared-but-unassigned.
5216                    if carried_value.is_none() && isset(crate::ported::zsh_h::TYPESETTOUNSET) {
5217                        flags |= crate::ported::zsh_h::PM_DEFAULTED as i32;
5218                    }
5219                    // crate::ported::zsh_h::PM_UPPER on a nameref marks the -u upscope variant (c:2698
5220                    // allows -u with -n); crate::ported::zsh_h::PM_HIDEVAL likewise.
5221                    flags |= (on
5222                        & (crate::ported::zsh_h::PM_UPPER | crate::ported::zsh_h::PM_HIDEVAL))
5223                        as i32;
5224                    // c:1108-1132 — createparam REUSES the just-unset node at its
5225                    // own level when !PM_LOCAL (`typeset -gn` rebind of a local
5226                    // ref keeps the ref's level; it does NOT hoist to level 0).
5227                    let level = if (on & PM_LOCAL) != 0 {
5228                        cur_ll
5229                    } else if let Some(elevel) = existing_level {
5230                        elevel
5231                    } else {
5232                        0
5233                    };
5234                    let pm = Box::new(crate::ported::zsh_h::param {
5235                        node: crate::ported::zsh_h::hashnode {
5236                            next: None,
5237                            nam: arg_name.to_string(),
5238                            flags,
5239                        },
5240                        u_data: 0,
5241                        u_tied: None,
5242                        u_arr: None,
5243                        u_str: if carried_value.is_none() && value.is_some() {
5244                            Some(String::new()) // `typeset -n ptr=` placeholder
5245                        } else {
5246                            None
5247                        },
5248                        u_val: 0,
5249                        u_dval: 0.0,
5250                        u_hash: None,
5251                        gsu_s: None,
5252                        gsu_i: None,
5253                        gsu_f: None,
5254                        gsu_a: None,
5255                        gsu_h: None,
5256                        base: 0,
5257                        width: 0,
5258                        env: None,
5259                        ename: None,
5260                        old: shadowed,
5261                        level,
5262                    });
5263                    if let Ok(mut tab) = paramtab().write() {
5264                        tab.insert(arg_name.to_string(), pm);
5265                    }
5266                } else {
5267                    // reuse path (read-only refs surviving the fresh-start gate,
5268                    // c:3148-3149): apply on/off bits in place. A readonly
5269                    // SCALAR + `-n` converts to a nameref here — its current
5270                    // value becomes the refname (c:2117+ type-conversion inside
5271                    // typeset_single with the pm kept; the `typeset +r -n
5272                    // ref=RW` shape: "assignment occurs after type change").
5273                    if let Ok(mut tab) = paramtab().write() {
5274                        if let Some(pm) = tab.get_mut(arg_name) {
5275                            pm.node.flags |= (on
5276                                & (crate::ported::zsh_h::PM_UPPER
5277                                    | crate::ported::zsh_h::PM_HIDEVAL))
5278                                as i32;
5279                            pm.node.flags |= PM_NAMEREF as i32;
5280                            pm.node.flags &= !((off
5281                                & (crate::ported::zsh_h::PM_UPPER
5282                                    | crate::ported::zsh_h::PM_HIDEVAL
5283                                    | PM_READONLY))
5284                                as i32);
5285                        }
5286                    }
5287                }
5288
5289                // Assign the refname (typeset_single c:2326 assignsparam →
5290                // PM_NAMEREF assignstrvalue arm + valid_refname + setscope).
5291                let mut rc = 0;
5292                if reuse_existing {
5293                    // c:2326 — the surviving (previously-readonly) ref keeps its
5294                    // refname; a =value assignment goes through the canonical
5295                    // assignsparam which RESOLVES the chain (`typeset +r -n
5296                    // ref=RW` writes RW into the referent, not the ref).
5297                    if let Some(v) = value {
5298                        if crate::ported::params::setsparam(arg_name, v).is_none() {
5299                            rc = 1;
5300                        }
5301                    }
5302                } else if let Some(v) = carried_value.as_deref() {
5303                    if v.is_empty() {
5304                        // `typeset -n ptr=` — empty placeholder, no setscope error.
5305                        if let Ok(mut tab) = paramtab().write() {
5306                            if let Some(pm) = tab.get_mut(arg_name) {
5307                                pm.u_str = Some(String::new());
5308                                pm.node.flags &= !(crate::ported::zsh_h::PM_DEFAULTED as i32);
5309                            }
5310                        }
5311                    } else if crate::ported::params::setsparam(arg_name, v).is_none() {
5312                        // c:2326 — typeset_single's assignsparam; the fresh
5313                        // PM_NAMEREF pm routes to assignstrvalue's nameref arm
5314                        // (c:2690-2720: valid_refname + SETREFNAME + setscope).
5315                        rc = 1;
5316                    }
5317                } else if !reuse_existing && crate::ported::params::is_nameref(arg_name) {
5318                    // bare placeholder — still run setscope for parity (no-op).
5319                    let _ = crate::ported::params::setscope_by_name(arg_name);
5320                }
5321
5322                // c:2618 — `pm->node.flags |= (on & PM_READONLY);` AFTER the
5323                // assignment so `typeset -rn ref=var` can set its initial value.
5324                if (on & PM_READONLY) != 0 {
5325                    if let Ok(mut tab) = paramtab().write() {
5326                        if let Some(pm) = tab.get_mut(arg_name) {
5327                            pm.node.flags |= PM_READONLY as i32;
5328                        }
5329                    }
5330                }
5331                rc
5332            };
5333            if nameref_arm(on as u32, off as u32) != 0 {
5334                returnval = 1; // c:3153-3156
5335            }
5336            continue;
5337        }
5338
5339        // c:2032-2050 — existing pm is a nameref and ±n was not
5340        // requested: resolve the chain and operate on the target.
5341        let mut nameref_rewrite: Option<String> = None;
5342        if (off as u32 & PM_NAMEREF) == 0
5343            && pname_in_tab
5344            && crate::ported::params::is_nameref(arg_name)
5345        {
5346            let cur_ll = locallevel.load(Relaxed) as i32;
5347            let (pm_level, pm_refname) = paramtab()
5348                .read()
5349                .ok()
5350                .and_then(|t| t.get(arg_name).map(|p| (p.level, p.u_str.clone())))
5351                .unwrap_or((0, None));
5352            if pm_level == cur_ll || (on as u32 & PM_LOCAL) == 0 {
5353                // c:2033
5354                let type_change = (on as u32 & !(PM_NAMEREF | PM_LOCAL | PM_READONLY)) != 0; // c:2038
5355                use crate::ported::params::nameref_resolution;
5356                match crate::ported::params::resolve_nameref_name(arg_name, None) {
5357                    nameref_resolution::SelfRef | nameref_resolution::OutOfScope => {
5358                        returnval = 1;
5359                        continue;
5360                    }
5361                    nameref_resolution::Placeholder(last) => {
5362                        // c:2036-2048 — unresolved ref + type change.
5363                        if type_change {
5364                            zwarnnam(
5365                                name, // c:2046
5366                                &format!("{}: can't change type of a named reference", last),
5367                            );
5368                            returnval = 1;
5369                            continue; // c:2048 return NULL
5370                        }
5371                        // plain `typeset ref=value` on a placeholder
5372                        // falls through — the scalar-assign arm routes
5373                        // through assignsparam which writes the refname.
5374                    }
5375                    nameref_resolution::Target {
5376                        name: t, subscript, ..
5377                    } => {
5378                        if subscript.is_some() && type_change {
5379                            // c:2041-2044 — pm->width set: subscripted ref.
5380                            zwarnnam(
5381                                name,
5382                                &format!(
5383                                    "{}: can't change type via subscript reference",
5384                                    pm_refname.as_deref().unwrap_or(arg_name)
5385                                ),
5386                            );
5387                            returnval = 1;
5388                            continue;
5389                        }
5390                        // c:2034-2035 — pname = pm->node.nam (resolved).
5391                        let tail = arg.find('=').map(|i| &arg[i..]).unwrap_or("");
5392                        let mut new_arg = t.clone();
5393                        if let Some(sub) = &subscript {
5394                            new_arg.push('[');
5395                            new_arg.push_str(sub);
5396                            new_arg.push(']');
5397                        }
5398                        new_arg.push_str(tail);
5399                        nameref_rewrite = Some(new_arg);
5400                    }
5401                    nameref_resolution::NotRef => {}
5402                }
5403            }
5404        }
5405        let arg: &String = nameref_rewrite.as_ref().unwrap_or(arg);
5406        let arg_name: &str = match arg.find('=') {
5407            Some(i) => &arg[..i],
5408            None => arg.as_str(),
5409        };
5410        // Recompute the existing-pm snapshots for the rewritten name.
5411        let (pname_in_tab, usepm_existing) = if nameref_rewrite.is_some() {
5412            let tab = paramtab().read().unwrap();
5413            match tab.get(arg_name.split('[').next().unwrap_or(arg_name)) {
5414                Some(pm) => (true, (pm.node.flags as u32 & PM_UNSET) == 0),
5415                None => (false, false),
5416            }
5417        } else {
5418            (pname_in_tab, usepm_existing)
5419        };
5420
5421        // c:2930 — `else if (pm)` reuse decision for the bin_typeset
5422        // literal-name loop: `if ((!(pm->node.flags & PM_UNSET) ||
5423        //                       pm->node.flags & PM_DECLARED)
5424        //                  && (locallevel == pm->level || !(on & PM_LOCAL)))`.
5425        // Decides whether the existing pm is reusable in place or
5426        // shadowed by a new local. The Rust per-arg loop short-circuits
5427        // through `createparam`'s reuse arm (params.rs:1975) which
5428        // already encodes this rule, but the literal C predicate
5429        // belongs here so the parity is visible at the call site.
5430        let cur_locallevel = locallevel.load(Relaxed) as i32;
5431        let pm_reuse_local: bool = if pname_in_tab {
5432            let tab = paramtab().read().unwrap();
5433            let pm = tab.get(arg_name).unwrap();
5434            let f = pm.node.flags as u32;
5435            ((f & PM_UNSET) == 0 || (f & PM_DECLARED) != 0)
5436                && (cur_locallevel == pm.level || (on as u32 & PM_LOCAL) == 0) // c:2930
5437        } else {
5438            true
5439        };
5440        let _ = pm_reuse_local;
5441
5442        // c:3127-3132 — PM_NAMEREF literal-name branch. When
5443        // `(on & PM_NAMEREF)` and an existing `hn` is present:
5444        // `if (((Param)hn)->level >= locallevel ||
5445        //     (!(on & PM_LOCAL) && ((Param)hn)->level < locallevel)) {
5446        //     unsetparam_pm(oldpm, 0, 1); hn = NULL; }`.
5447        // Namerefs always start over fresh when redeclared.
5448        if (on as u32 & PM_NAMEREF) != 0 && pname_in_tab {
5449            let level_compare = paramtab()
5450                .read()
5451                .ok()
5452                .and_then(|t| t.get(arg_name).map(|pm| pm.level))
5453                .unwrap_or(0);
5454            if level_compare >= cur_locallevel
5455                || ((on as u32 & PM_LOCAL) == 0 && level_compare < cur_locallevel)
5456            // c:3130
5457            {
5458                // unsetparam_pm + hn = NULL would happen here. The
5459                // simplified PM_NAMEREF path leaves the reset to
5460                // typeset_single's name-resolution branch at
5461                // typeset_single c:2750.
5462            }
5463        }
5464
5465        // c:2469-2510 — `typeset_single` createparam dispatch for new
5466        // PM_LOCAL declarations. Inside a function scope (`local x` or
5467        // `typeset x` from a fn body), C calls createparam(name,
5468        // on|PM_LOCAL) which chains pm.old = oldpm at the current
5469        // locallevel — the c:2575 `pm->level = locallevel` stamp that
5470        // endparamscope unwinds. Without this, `local x=inside`
5471        // modifies the outer-scope x instead of installing a shadow.
5472        // c:Src/builtin.c:2469 — `if ((on & PM_LOCAL) && (!pm ||
5473        // pm->level < locallevel)) { ...createparam... }`. The
5474        // condition is: PM_LOCAL set AND (no existing pm OR existing
5475        // pm is at a LOWER scope than the current locallevel). At
5476        // TOP scope (locallevel=0), `pm->level < 0` is never true,
5477        // so C doesn't call createparam — it reuses the existing
5478        // param in place. The previous Rust port called createparam
5479        // unconditionally, which routed through the reuse arm at
5480        // params.rs:2062-2068 and then clobbered pm.node.flags via
5481        // params.rs:2132 (`pm->node.flags = flags & ~PM_LOCAL`).
5482        // That wiped PM_INTEGER, leaving u_val=42 with PM_SCALAR
5483        // flags → getsparam returned empty. Bug #326 in
5484        // docs/BUGS.md. Mirror the C guard so the createparam call
5485        // only fires when we genuinely need to allocate a new shadow.
5486        let needs_new_shadow = if pname_in_tab {
5487            paramtab()
5488                .read()
5489                .ok()
5490                .and_then(|t| t.get(arg_name).map(|pm| pm.level < cur_locallevel))
5491                .unwrap_or(true)
5492        } else {
5493            true
5494        };
5495        if (on as u32 & PM_LOCAL) != 0                                       // c:2469
5496            && !arg_name.is_empty()
5497            && !arg_name.starts_with('-')
5498            && !arg_name.starts_with('+')
5499            && needs_new_shadow
5500        {
5501            let kind = if is_hashed {
5502                PM_HASHED
5503            } else if is_array {
5504                PM_ARRAY
5505            } else {
5506                0
5507            };
5508            // c:2475-2487 — C calls `assignsparam(pname, value, 0)`
5509            // which creates the pm via the assignsparam → createparam
5510            // path WITHOUT propagating PM_READONLY/PM_EXPORTED flags
5511            // (that path uses PM_SCALAR / PM_ARRAY / PM_HASHED only).
5512            // Post-assign attribute stamps add PM_READONLY/PM_EXPORTED
5513            // later. Mirror by passing ONLY the type-kind + PM_LOCAL
5514            // (not the full `on` mask) so the freshly-created pm
5515            // doesn't error on its own first assignment.
5516            let _ = createparam(arg_name, kind as i32 | PM_LOCAL as i32);
5517            // c:2575 — `else if (on & PM_LOCAL) pm->level = locallevel;`
5518            // — stamp the just-created pm at the current scope so
5519            // endparamscope (params.c) unwinds the shadow when the
5520            // enclosing function returns. createparam at params.rs:2014
5521            // already sets `level: cur_locallevel` on the fresh pm;
5522            // re-stamp here against the post-createparam pm to mirror
5523            // C's explicit assignment, AND to catch the reuse-arm path
5524            // (params.rs:1975-1986) where the existing pm's level was
5525            // pre-set by a prior scope.
5526            if let Ok(mut tab) = paramtab().write() {
5527                if let Some(pm) = tab.get_mut(arg_name) {
5528                    pm.level = cur_locallevel; // c:2575
5529                                               // c:2691 + c:4087 arrsetfn — flags that affect the
5530                                               // VALUE store (PM_UNIQUE dedup, PM_LEFT/RIGHT_B/Z
5531                                               // padding width) must land on pm.flags BEFORE
5532                                               // assignaparam → arrsetfn runs, else those
5533                                               // setfns see the un-flagged pm. C zsh applies
5534                                               // these as part of the pre-assignment stamp at
5535                                               // typeset_single c:2476-2479. Mirror by
5536                                               // pre-stamping the value-affecting subset of
5537                                               // `on`. The full attribute mask (PM_READONLY,
5538                                               // PM_EXPORTED, etc.) still lands in the
5539                                               // post-assign block below since those don't
5540                                               // change the value at write time.
5541                    let pre_assign_mask: u32 =
5542                        PM_UNIQUE | PM_LEFT | PM_RIGHT_B | PM_RIGHT_Z | PM_LOWER | PM_UPPER;
5543                    pm.node.flags |= (on as u32 & pre_assign_mask) as i32;
5544                    pm.node.flags &= !((off as u32 & pre_assign_mask) as i32);
5545                    // c:Src/builtin.c:2544 — `if (isset(TYPESETTOUNSET))
5546                    //     pm->node.flags |= PM_DEFAULTED;`. Under
5547                    // `setopt typeset_to_unset`, bare `typeset NAME`
5548                    // (no `=`) creates a "declared but not assigned"
5549                    // entry. Only fires when the arg has no `=`
5550                    // (assignment paths get PM_UNSET cleared as a side
5551                    // effect of the value write). Bug #280 in
5552                    // docs/BUGS.md.
5553                    if !arg.contains('=') && isset(crate::ported::zsh_h::TYPESETTOUNSET) {
5554                        pm.node.flags |= PM_DEFAULTED as i32;
5555                    }
5556                }
5557            }
5558        }
5559
5560        // c:2462-2467 — subscripted-name PM_LOCAL guard: `else if
5561        //   ((on & PM_LOCAL) && locallevel) { ... if (!pm || pm->level
5562        //   != locallevel) zerrnam("can't create local array elements") }`.
5563        // Refuses to create a NEW local for `local arr[N]=val` when
5564        // the outer-scope pm at a different level exists. The Rust
5565        // per-arg loop treats subscripted names as the eq-branch's
5566        // `name[key]=val` shape inside assignsparam; the guard fires
5567        // here BEFORE the assignment so we emit the C error message.
5568        if let Some(br) = arg_name.find('[') {
5569            let base = &arg_name[..br];
5570            if (on as u32 & PM_LOCAL) != 0 && cur_locallevel != 0 {
5571                // c:2462
5572                let pm_level = paramtab()
5573                    .read()
5574                    .ok()
5575                    .and_then(|t| t.get(base).map(|pm| pm.level));
5576                if pm_level.is_none() || pm_level != Some(cur_locallevel) {
5577                    // c:2466
5578                    zerrnam(
5579                        name,
5580                        &format!("{}: can't create local array elements", base), // c:2466
5581                    );
5582                    continue; // c:2467
5583                }
5584            }
5585            // c:Src/builtin.c:2500-2502 — `else { zerrnam(cname,
5586            //   "%s: inconsistent array element or slice assignment",
5587            //   pname); return NULL; }`. C's typeset_single rejects
5588            // type-flag attributes on subscripted names: when the
5589            // name has a `[…]` subscript AND the requested `on` mask
5590            // carries a non-scalar PM_TYPE bit (PM_INTEGER, PM_EFLOAT,
5591            // PM_FFLOAT, PM_ARRAY, PM_HASHED), it's
5592            // `typeset -i h[k]` / `-A h[k]` / etc. — attribute
5593            // applies to whole array, not element. zshrs's
5594            // typeset path silently accepted these and applied
5595            // the attribute to the element. Bug #219 in
5596            // docs/BUGS.md. Plain `h[k]=value` (no `-` flag) has
5597            // PM_TYPE(on) == PM_SCALAR and stays valid.
5598            let pm_type_bits =
5599                on as u32 & (PM_INTEGER | PM_EFLOAT | PM_FFLOAT | PM_ARRAY | PM_HASHED);
5600            if pm_type_bits != 0 {
5601                zerrnam(
5602                    name,
5603                    &format!(
5604                        "{}: inconsistent array element or slice assignment",
5605                        arg_name
5606                    ),
5607                );
5608                continue;
5609            }
5610        }
5611
5612        if let Some(eq) = arg.find('=') {
5613            let n = &arg[..eq];
5614            // c:Src/builtin.c:2289 — `pm->node.flags = ... & ~off` must
5615            //   clear `off` bits in value-affecting flags BEFORE the
5616            //   assignment runs (params.rs assignaparam:5526 snapshots
5617            //   PM_UNIQUE and dedups in setfn). The PM_LOCAL pre_assign
5618            //   block below only fires when on & PM_LOCAL; top-level
5619            //   `typeset +U arr=(...)` has on=0 and would otherwise
5620            //   leave the stale PM_UNIQUE in place. Bug #234.
5621            let pre_assign_off_mask =
5622                (PM_UNIQUE | PM_LEFT | PM_RIGHT_B | PM_RIGHT_Z | PM_LOWER | PM_UPPER) as i32;
5623            let off_in_pre_mask = (off as i32) & pre_assign_off_mask;
5624            if off_in_pre_mask != 0 {
5625                if let Ok(mut tab) = paramtab().write() {
5626                    if let Some(pm) = tab.get_mut(n) {
5627                        pm.node.flags &= !off_in_pre_mask;
5628                    }
5629                }
5630            }
5631            let raw_v = &arg[eq + 1..];
5632            // c:2945-3050 — `=(elem elem ...)` array-init syntax.
5633            // The parser hands the whole `(...)` body in as one arg
5634            // when typeset's BINF_MAGICEQUALS is set; the `(` / `)` are
5635            // literal first/last bytes. Strip them and split on
5636            // whitespace to recover the element list.
5637            //
5638            // c:Src/parse.c par_simple/intypeset — the array-vs-scalar
5639            // distinction is SYNTACTIC (the `asg->flags & ASG_ARRAY`
5640            // bit), set when the `(` is an UNQUOTED paren at parse time,
5641            // NOT a heuristic on the expanded value. zshrs encodes that
5642            // bit as the `\u{1f}` REJOIN_SEP marker inserted by
5643            // compile_zsh's BUILTIN_TYPESET_PAREN_PACK, which only fires
5644            // for syntactic (unquoted) paren-inits. A double-quoted
5645            // value that happens to expand to `(...)` —
5646            // `typeset out="(${x:gs/%/%%})"` — reaches here as a plain
5647            // dequoted scalar with NO marker and must stay a SCALAR
5648            // (zsh: `out=(ruby-3.2%%@1.0)`). Keying on `starts_with('(')`
5649            // alone mis-stored it as an array. The `()`-empty and
5650            // `=(\u{1f}elem…)` pack forms always carry the marker.
5651            let is_paren_init = raw_v.starts_with('(')
5652                && raw_v.ends_with(')')
5653                && raw_v.len() >= 2
5654                && raw_v.contains('\u{1f}');
5655            // c:Src/builtin.c:2095-2097 — `if (ASG_ARRAYP(asg) &&
5656            // PM_TYPE(on) == PM_SCALAR && ...) on |= PM_ARRAY;`. Auto-
5657            // promote to array when the assignment shape is array but
5658            // no explicit type was requested. Required so plain
5659            // `local arr=(a b c)` (which carries on=PM_LOCAL with
5660            // no PM_TYPE bits) reaches the array-init path. Without
5661            // it the c:2342 inconsistency check below errors on every
5662            // bare array assign.
5663            let mut on = on;
5664            if is_paren_init && crate::ported::zsh_h::PM_TYPE(on) == PM_SCALAR {
5665                on |= PM_ARRAY;
5666            }
5667            // c:Src/builtin.c:2342-2347 — `inconsistent type for
5668            // assignment`: when the user types an array RHS
5669            // (`x=(...)`) but the resolved type flags don't include
5670            // PM_ARRAY|PM_HASHED, error out. C source:
5671            // ```
5672            // if ((asg->flags & ASG_ARRAY) ?
5673            //     !(on & (PM_ARRAY|PM_HASHED)) :
5674            //     (asg->value.scalar && (on & (PM_ARRAY|PM_HASHED)))) {
5675            //     zerrnam(cname, "%s: inconsistent type for assignment", pname);
5676            //     return NULL;
5677            // }
5678            // ```
5679            // The C conflict-resolution pass at c:2718-2742 clears
5680            // PM_ARRAY/PM_HASHED from `on` when a conflicting type
5681            // (`-i` / `-E` / `-F`) is requested (`on & PM_INTEGER →
5682            // off |= PM_ARRAY; on &= ~off`), so `-ia x=(1 2 3)` lands
5683            // here with PM_INTEGER set + PM_ARRAY cleared. The
5684            // c:2095 auto-promote above doesn't fire either (PM_TYPE
5685            // is non-zero from PM_INTEGER), so the inconsistency
5686            // error fires correctly. Bug #250.
5687            if is_paren_init && (on & (PM_ARRAY | PM_HASHED)) == 0 {
5688                zerrnam(
5689                    name, // cname
5690                    &format!("{}: inconsistent type for assignment", n),
5691                );
5692                unqueue_signals();
5693                return 1;
5694            }
5695            if is_paren_init {
5696                let inner = &raw_v[1..raw_v.len() - 1]; // c:2950
5697                                                        // c:Src/builtin.c:2555-2556 globlist — C glob-expands each
5698                                                        // element working on the TOKENIZED value where quoted
5699                                                        // metachars keep their Bnull escape, so a quoted `(` / `*`
5700                                                        // is inert. zshrs's compile path (compile_zsh's
5701                                                        // BUILTIN_TYPESET_PAREN_PACK) already runs the per-element
5702                                                        // glob via compile_word_str — identical to the bare
5703                                                        // `arr=(…)` path (compile_zsh.rs ~3294) — and hands
5704                                                        // bin_typeset the FULLY-EXPANDED, DEQUOTED elements via the
5705                                                        // `\u{1f}` sentinel form. Re-globbing those dequoted values
5706                                                        // here is a double-glob that loses the original quoting:
5707                                                        // `typeset -a p=("*.txt")` globbed the quoted star, and
5708                                                        // `typeset -a p=("a=\$((1+2))")` parsed the literal `((` as
5709                                                        // a bareglobqual ("unknown file attribute: ("). Skip the
5710                                                        // re-glob for the pack (`\u{1f}`) form; only the legacy
5711                                                        // whitespace form (elements not pre-expanded) needs it.
5712                let from_pack = inner.contains('\u{1f}');
5713                // c:2952 globlist — each list node is one element. When
5714                // the multi-arg rejoin loop above ran, elements are
5715                // separated by the `\u{1f}` REJOIN_SEP sentinel
5716                // (consecutive sentinels preserve quoted-empty args).
5717                // Otherwise (single-arg paren-init form like `a=(1 2 3)`)
5718                // the inner is plain-whitespace separated.
5719                // Bug #93 in docs/BUGS.md.
5720                let split_elems: Vec<String> = if inner.contains('\u{1f}') {
5721                    // c:Src/builtin.c:2952 globlist — each REJOIN_SEP
5722                    // segment is ONE already-parsed array element (the
5723                    // parser split argv and quote-removed each element),
5724                    // so split EXACTLY on the sentinel. Do NOT trim or
5725                    // drop all-whitespace segments — that would corrupt
5726                    // quoted leading/trailing whitespace, e.g.
5727                    // `local a=( "  name: x" )` (the `  name:` prefix is
5728                    // significant and must survive for `${a[(i)  name: …]}`
5729                    // to match). Boundary empties from the `=(` / `)` argv
5730                    // fragments are removed by the first/last-empty trim
5731                    // immediately below.
5732                    inner.split('\u{1f}').map(String::from).collect::<Vec<_>>()
5733                } else {
5734                    inner.split_whitespace().map(String::from).collect()
5735                };
5736                let split_elems = if inner.contains('\u{1f}') {
5737                    let mut v: Vec<String> = split_elems;
5738                    // Trim only the FIRST leading empty (from the open
5739                    // `=(` arg's tail after stripping `(`) and the LAST
5740                    // trailing empty (from the close `)` arg's head),
5741                    // preserving any genuine empties in the middle.
5742                    if v.first().is_some_and(|s| s.is_empty()) {
5743                        v.remove(0);
5744                    }
5745                    if v.last().is_some_and(|s| s.is_empty()) {
5746                        v.pop();
5747                    }
5748                    v
5749                } else {
5750                    split_elems
5751                };
5752                // c:Src/subst.c:2558-2571 — `$=var` IFS-split operator.
5753                // The full paramsubst path in subst.rs doesn't yet
5754                // handle the `=` flag (spbreak=2 in C) when invoked
5755                // outside the canonical addvars→prefork→stringsubst
5756                // chain. typeset's array-init reaches bin_typeset
5757                // AFTER the unified prefork has already run (against
5758                // the entire `arr=( ... )` string, treating the parens
5759                // as literal), so any `$=name` inside the parens
5760                // stayed untouched.
5761                //
5762                // Mirror the C `spbreak=2` semantics inline: for each
5763                // element of the form `$=NAME`, look up NAME, split on
5764                // IFS, and substitute the resulting fields in place.
5765                // Other elements (already paramsubst'd by the outer
5766                // prefork, or literal) pass through unchanged.
5767                let raw_elems: Vec<String> = {
5768                    // c:Src/params.c IFS — the canonical IFS source is
5769                    // the IFS shell param (PM_SPECIAL gsu reads the
5770                    // live $IFS in the current scope). Reading the
5771                    // OS env var would miss `local IFS=` overrides and
5772                    // catch stale values left behind by earlier scopes.
5773                    let ifs_chars: Vec<char> = crate::ported::params::getsparam("IFS")
5774                        .unwrap_or_else(|| " \t\n".to_string())
5775                        .chars()
5776                        .collect();
5777                    let mut out: Vec<String> = Vec::new();
5778                    for se in split_elems {
5779                        if let Some(stripped) = se.strip_prefix("$=") {
5780                            // c:2562 — `s++; spbreak = 2; nojoin =
5781                            // !(ifs && *ifs);`. Look up the variable;
5782                            // if unset, treat as empty string and
5783                            // produce no fields.
5784                            let val =
5785                                crate::ported::params::getsparam(stripped).unwrap_or_default();
5786                            if ifs_chars.is_empty() {
5787                                if !val.is_empty() {
5788                                    out.push(val);
5789                                }
5790                            } else {
5791                                for field in val
5792                                    .split(|c: char| ifs_chars.contains(&c))
5793                                    .filter(|s| !s.is_empty())
5794                                {
5795                                    out.push(field.to_string());
5796                                }
5797                            }
5798                        } else {
5799                            out.push(se);
5800                        }
5801                    }
5802                    out
5803                };
5804                // c:2555-2556 — `globlist(vl, prefork_ret)` glob-expands
5805                // each element when it contains wildcards. Skipped for the
5806                // pre-expanded pack form (see `from_pack` above): those
5807                // elements were already glob-expanded (quote-aware) at
5808                // compile time, so re-globbing the dequoted values is wrong.
5809                let mut elems: Vec<String> = Vec::with_capacity(raw_elems.len());
5810                for re in raw_elems {
5811                    if from_pack {
5812                        elems.push(re);
5813                        continue;
5814                    }
5815                    // c:Src/glob.c:1230 zglob — `if (... !haswilds(ostr)
5816                    // ...) return;` short-circuits before patcompile.
5817                    // In C, lexer/prefork preserves Bnull-escape on
5818                    // quoted metachars (`'('` becomes `Bnull (`), so
5819                    // haswilds returns false on a quoted-paren key.
5820                    // zshrs's upstream prefork strips Bnull before
5821                    // typeset args land here (see TRACE_TS_RAWV =
5822                    // `[40, 31, 40, 31, ...]` — bare `(` without the
5823                    // Bnull escape), so haswilds spuriously returns
5824                    // true on a literal `(` from `T=( '(' v )`. Guard
5825                    // by pre-compiling: if patcompile fails, the
5826                    // element can't be a real glob — keep it literal
5827                    // (matches the C zglob no-wild short-circuit and
5828                    // suppresses the spurious "bad pattern" zerr).
5829                    // haswilds scans TOKENIZED strings — tokenize a
5830                    // local copy of the untokenized element first
5831                    // (c:Src/glob.c:3548), as C does for runtime-built
5832                    // strings (compcore.c:2231); patcompile consumes
5833                    // the same tokenized form.
5834                    let mut re_tok = re.to_string();
5835                    crate::ported::glob::tokenize(&mut re_tok);
5836                    if crate::ported::pattern::haswilds(&re_tok) {
5837                        let compilable = crate::ported::pattern::patcompile(
5838                            &re_tok,
5839                            crate::ported::zsh_h::PAT_HEAPDUP as i32,
5840                            None,
5841                        )
5842                        .is_some();
5843                        if !compilable {
5844                            elems.push(re);
5845                            continue;
5846                        }
5847                        let expanded = crate::ported::glob::glob_path(&re);
5848                        if expanded.is_empty() || (expanded.len() == 1 && expanded[0] == re) {
5849                            elems.push(re);
5850                        } else {
5851                            elems.extend(expanded);
5852                        }
5853                    } else {
5854                        elems.push(re);
5855                    }
5856                }
5857                // c:Src/builtin.c:2355-2378 — tc (type-conversion)
5858                // branch: the requested type differs from the
5859                // existing param's type. zsh keeps readonly/exported
5860                // status across the conversion (`on |= ~off &
5861                // (PM_READONLY|PM_EXPORTED) & pm->node.flags`) but
5862                // turns PM_READONLY OFF on the old pm so the delete/
5863                // recreate succeeds — `typeset -r h2=(); typeset -A
5864                // h2=(k v)` is legal in zsh. The Rust assignment
5865                // funnel (sethparam/setaparam) performs the type
5866                // rewrite in place, so clearing PM_READONLY here is
5867                // the c:2359 step and the post-assign stamp below
5868                // restores it from `on` (c:2357 carry).
5869                {
5870                    let requested = if is_hashed { PM_HASHED } else { PM_ARRAY };
5871                    let existing_flags = paramtab()
5872                        .read()
5873                        .ok()
5874                        .and_then(|t| t.get(n).map(|pm| pm.node.flags as u32));
5875                    if let Some(f) = existing_flags {
5876                        if (f & PM_UNSET) == 0 && crate::ported::zsh_h::PM_TYPE(f) != requested {
5877                            // c:2357 — carry readonly/exported into `on`.
5878                            on |= !off & (PM_READONLY | PM_EXPORTED) & f;
5879                            // c:2359 — `pm->node.flags &= ~PM_READONLY;`
5880                            if let Ok(mut tab) = paramtab().write() {
5881                                if let Some(pm) = tab.get_mut(n) {
5882                                    pm.node.flags &= !(PM_READONLY as i32);
5883                                }
5884                            }
5885                        }
5886                    }
5887                }
5888                // c:Src/builtin.c:2476-2479 + c:2691 + c:4087 arrsetfn —
5889                // pre-stamp value-affecting flags on the pm BEFORE the
5890                // array-init setarrvalue runs, so PM_UNIQUE drives dedup
5891                // inside assignaparam (params.rs:5571 reads pm.flags &
5892                // PM_UNIQUE to gate simple_arrayuniq). Without this,
5893                // `typeset -aUx u=(/bin /usr/bin /bin)` stamped PM_UNIQUE
5894                // only AFTER setarrvalue ran, so dedup never fired.
5895                // `typeset -aU u=(...)` happened to work due to a side
5896                // path; this makes both consistent. Bug #272.
5897                if is_array {
5898                    let pre_assign_mask: u32 = PM_UNIQUE;
5899                    if (on as u32 & pre_assign_mask) != 0 {
5900                        let exists = paramtab()
5901                            .read()
5902                            .map(|t| t.contains_key(n))
5903                            .unwrap_or(false);
5904                        if !exists {
5905                            let _ = createparam(n, PM_ARRAY as i32);
5906                        }
5907                        if let Ok(mut tab) = paramtab().write() {
5908                            if let Some(pm) = tab.get_mut(n) {
5909                                pm.node.flags |= (on as u32 & pre_assign_mask) as i32;
5910                            }
5911                        }
5912                    }
5913                }
5914                // c:2357 — a scalar → array/hashed conversion is a TYPE change,
5915                // which recreates the param carrying only READONLY|EXPORTED, so
5916                // the old scalar padding flags (-L/-R/-Z) must NOT survive it:
5917                // `typeset -Z 3 x=7; typeset -a x=(1 2)` is `array`, not
5918                // `array-right_zeros`. Capture whether the param was ALREADY an
5919                // array/hashed (in which case it is NOT a type change and the
5920                // padding is preserved by the merge stamp below).
5921                let prior_was_arraylike = paramtab().read().ok().and_then(|t| {
5922                    t.get(n).map(|pm| {
5923                        let typ = PM_TYPE(pm.node.flags as u32);
5924                        typ == PM_ARRAY || typ == PM_HASHED
5925                    })
5926                }) == Some(true);
5927                if is_hashed {
5928                    // c:2960-2975 — `setdataparam(..., PM_HASHED, …)`.
5929                    // Two assoc-init shapes accepted by zsh:
5930                    //  1. flat alternating k/v: `m=(k1 v1 k2 v2)`
5931                    //  2. per-element [K]=V:    `m=([k1]=v1 [k2]=v2)`
5932                    // The parser hands all elements as one `(…)` body,
5933                    // so we detect shape 2 when every element starts
5934                    // with `[` and contains `]=`. Otherwise fall back
5935                    // to alternating pairs.
5936                    let bracket_shape = !elems.is_empty()
5937                        && elems.iter().all(|e| e.starts_with('[') && e.contains("]="));
5938                    let mut map: IndexMap<String, String> = IndexMap::new();
5939                    if bracket_shape {
5940                        for e in &elems {
5941                            let close = e.find("]=").unwrap();
5942                            let k = e[1..close].to_string();
5943                            let v = e[close + 2..].to_string();
5944                            map.insert(k, v);
5945                        }
5946                    } else {
5947                        let mut it = elems.into_iter(); // c:2960 pair walk
5948                        while let Some(k) = it.next() {
5949                            let v = it.next().unwrap_or_default();
5950                            map.insert(k, v); // c:2964 hashtab insert
5951                        }
5952                    }
5953                    crate::ported::exec::set_assoc(n, map.clone());
5954                } else {
5955                    // c:2980-2995 — plain array.
5956                    crate::ported::exec::set_array(n, elems.clone());
5957                }
5958                // c:2357 — on a genuine type change (was NOT array/hashed), drop
5959                // the old scalar padding flags; re-add only what THIS command's
5960                // `on` requests (so `typeset -aZ 3 arr=(...)` still zero-pads).
5961                if !prior_was_arraylike {
5962                    let pad = (PM_LEFT | PM_RIGHT_B | PM_RIGHT_Z) as i32;
5963                    if let Ok(mut tab) = paramtab().write() {
5964                        if let Some(pm) = tab.get_mut(n) {
5965                            pm.node.flags = (pm.node.flags & !pad) | (on as i32 & pad);
5966                        }
5967                    }
5968                }
5969                // c:2330-2337 (typeset_single) — `if (!(pm =
5970                // assignaparam(pname, ..., flags))) return NULL;
5971                // ... if (errflag) return NULL;` A readonly
5972                // rejection inside setarrvalue (c:Src/params.c:2900)
5973                // sets errflag and refuses the value; typeset_single
5974                // returns NULL and bin_typeset records `returnval = 1`
5975                // (c:3153-3156). Skip the attribute stamps — C never
5976                // reaches them on this path. Gated on usepm: the
5977                // c:2336 check lives ONLY in the reuse-existing-pm
5978                // branch.
5979                if usepm_existing && (errflag.load(Relaxed) & ERRFLAG_ERROR) != 0 {
5980                    returnval = 1; // c:3156
5981                    continue; // c:2337 return NULL
5982                }
5983                // c:2510-2520 — `on = pm->node.flags;` then stamp the
5984                // attribute bits on the just-assigned param. The
5985                // scalar-assign arm below does the same; the array /
5986                // assoc `=(...)` init path was missing this, so
5987                // `typeset -ax ARR=(a b)` left PM_EXPORTED unset on
5988                // the paramtab entry. `(t)ARR` then read `array`
5989                // instead of `array-export`, and `typeset -p ARR`
5990                // emitted `typeset -a ARR=...` instead of `-ax`.
5991                let post_assign_mask = (PM_READONLY
5992                    | PM_EXPORTED
5993                    | PM_LEFT
5994                    | PM_RIGHT_B
5995                    | PM_RIGHT_Z
5996                    | PM_TAGGED
5997                    | PM_HIDE
5998                    | PM_HIDEVAL
5999                    | PM_UNIQUE) as i32;
6000                let post_assign_to_set = (on
6001                    & (PM_READONLY
6002                        | PM_EXPORTED
6003                        | PM_LEFT
6004                        | PM_RIGHT_B
6005                        | PM_RIGHT_Z
6006                        | PM_TAGGED
6007                        | PM_HIDE
6008                        | PM_HIDEVAL
6009                        | PM_UNIQUE)) as i32;
6010                if post_assign_to_set != 0 {
6011                    if let Ok(mut tab) = paramtab().write() {
6012                        if let Some(pm) = tab.get_mut(n) {
6013                            // c:2289 MERGE — preserve untouched attributes.
6014                            pm.node.flags |= post_assign_to_set;
6015                        }
6016                    }
6017                }
6018            } else {
6019                // c:2232-2238 (typeset_single) — a SCALAR assignment to an
6020                // existing ARRAY/HASHED param WITHOUT a type flag to convert it
6021                // is inconsistent: `typeset -A x=(k v); typeset x=scalar` errors.
6022                // (An explicit type flag — `typeset -i x=9` — is a type change
6023                // handled above and does NOT reach here.)
6024                let target_is_arraylike = paramtab().read().ok().and_then(|t| {
6025                    t.get(n).map(|pm| {
6026                        let typ = PM_TYPE(pm.node.flags as u32);
6027                        typ == PM_ARRAY || typ == PM_HASHED
6028                    })
6029                }) == Some(true);
6030                let requesting_type =
6031                    (on as u32 & (PM_INTEGER | PM_EFLOAT | PM_FFLOAT | PM_ARRAY | PM_HASHED)) != 0;
6032                if target_is_arraylike && !requesting_type {
6033                    zerrnam(name, &format!("{}: inconsistent type for assignment", n)); // c:2236
6034                    returnval = 1;
6035                    continue;
6036                }
6037                // c:3010-3030 — `name=value` scalar assign. C-canonical
6038                // `setsparam` (Src/params.c:3350) writes paramtab; the
6039                // env mirror at `Src/params.c:3024 addenv` follows.
6040                // c:Src/params.c PM_LOWER/PM_UPPER setstrvalue arms:
6041                // when typeset -l or -u is set, the assigned value is
6042                // case-folded BEFORE storage. Without this, `typeset -l
6043                // s=HELLO; echo $s` printed `HELLO`. We also mirror to
6044                // exec.var_attrs so subsequent plain assigns (`s=NEW`)
6045                // pick up the fold via the SET_VAR opcode's attr
6046                // check (fusevm_bridge.rs case-fold arm).
6047                let lower = (on & PM_LOWER) != 0;
6048                let upper = (on & PM_UPPER) != 0;
6049                let folded: String = if lower {
6050                    raw_v.to_lowercase()
6051                } else if upper {
6052                    raw_v.to_uppercase()
6053                } else {
6054                    raw_v.to_string()
6055                };
6056                // c:typeset_single — createparam with the type flag
6057                // BEFORE assignsparam, so assignstrvalue's PM_TYPE
6058                // dispatch (params.c:2748) routes the value through
6059                // the correct setfn:
6060                //   - PM_INTEGER → intsetfn (mathevali → u_val)
6061                //   - PM_EFLOAT/PM_FFLOAT → floatsetfn (parsefloat → u_dval)
6062                //   - PM_SCALAR → strsetfn (u_str)
6063                // The previous Rust ordering (setsparam first, then
6064                // flip flags) wrote "5" to u_str then changed PM_TYPE
6065                // to PM_INTEGER without migrating u_str → u_val, so
6066                // getsparam(n) read u_val=0 instead of 5.
6067                // c:2748-2784 — pre-assign type flags only (PM_INTEGER
6068                // etc. — affect storage / setfn dispatch). Post-assign
6069                // attributes (PM_READONLY / PM_EXPORTED / justification
6070                // bits) are stamped AFTER setsparam since the C path
6071                // (c:2475 `assignsparam(pname, val, 0)` → c:2510
6072                // `on = pm->node.flags`) sets PM_READONLY only after
6073                // the value lands. Mixing them pre-assign caused
6074                // `readonly y=hello` to error "read-only variable: y"
6075                // — the freshly-created pm had PM_READONLY which
6076                // blocked its OWN initial assign.
6077                let pre_assign_mask =
6078                    (PM_INTEGER | PM_EFLOAT | PM_FFLOAT | PM_LOWER | PM_UPPER | PM_NAMEREF) as i32;
6079                let pre_assign_to_set = (on
6080                    & (PM_INTEGER | PM_EFLOAT | PM_FFLOAT | PM_LOWER | PM_UPPER | PM_NAMEREF))
6081                    as i32;
6082                if pre_assign_to_set != 0 {
6083                    let pname_in_tab = paramtab()
6084                        .read()
6085                        .map(|t| t.contains_key(n))
6086                        .unwrap_or(false);
6087                    if !pname_in_tab {
6088                        // c:1132+ createparam(name, type_flags) — fresh.
6089                        let _ = createparam(n, pre_assign_to_set);
6090                    } else {
6091                        // c:2355-2378 tc (type-conversion) — flip the
6092                        // PM_TYPE bits on the existing param BEFORE
6093                        // re-assigning so assignstrvalue routes through
6094                        // the new type's setfn.
6095                        //
6096                        // c:2357 — a TYPE change deletes and recreates the param
6097                        // carrying ONLY READONLY|EXPORTED, so the old scalar
6098                        // padding flags (-L/-R/-Z) do NOT survive it: after
6099                        // `typeset -Z 3 x=7; typeset -i x=1` the type is plain
6100                        // `integer`, not `integer-right_zeros`. When the new type
6101                        // is numeric (INTEGER/EFLOAT/FFLOAT), clear the padding
6102                        // flags from the old param too. (A single `typeset -iZ`
6103                        // command re-adds RIGHT_Z via the post-assign stamp since
6104                        // it is in `on`.)
6105                        let is_numeric_type =
6106                            (pre_assign_to_set as u32 & (PM_INTEGER | PM_EFLOAT | PM_FFLOAT)) != 0;
6107                        // On a numeric type change also drop the scalar padding
6108                        // flags AND — if the old param was an array/hashed —
6109                        // PM_ARRAY|PM_HASHED plus its stored data, since C deletes
6110                        // and recreates: `typeset -A x=(k v); typeset -F x=1.5`
6111                        // yields a plain `float`, not a hashed remnant.
6112                        let old_type = paramtab()
6113                            .read()
6114                            .ok()
6115                            .and_then(|t| t.get(n).map(|pm| PM_TYPE(pm.node.flags as u32)))
6116                            .unwrap_or(PM_SCALAR);
6117                        let was_arraylike = old_type == PM_ARRAY || old_type == PM_HASHED;
6118                        let extra_clear = if is_numeric_type {
6119                            let mut c = (PM_LEFT | PM_RIGHT_B | PM_RIGHT_Z) as i32;
6120                            if was_arraylike {
6121                                c |= (PM_ARRAY | PM_HASHED) as i32;
6122                            }
6123                            c
6124                        } else {
6125                            0
6126                        };
6127                        if is_numeric_type && was_arraylike {
6128                            // c:2355-2378 — array/hashed → numeric is a full type
6129                            // conversion: delete the old aggregate (storage +
6130                            // paramtab entry) and recreate as the numeric type, so
6131                            // the value migrates through the new setfn and the
6132                            // result is a clean `integer`/`float`. Modifying flags
6133                            // in place left the hashed storage's setfn dispatch
6134                            // active, so the value stayed a scalar.
6135                            crate::ported::exec::unset_array(n);
6136                            crate::ported::exec::unset_assoc(n);
6137                            if let Ok(mut tab) = paramtab().write() {
6138                                tab.remove(n);
6139                            }
6140                            let _ = createparam(n, pre_assign_to_set);
6141                        } else if let Ok(mut tab) = paramtab().write() {
6142                            if let Some(pm) = tab.get_mut(n) {
6143                                pm.node.flags = (pm.node.flags & !(pre_assign_mask | extra_clear))
6144                                    | pre_assign_to_set;
6145                            }
6146                        }
6147                    }
6148                }
6149                // c:2289 — `pm->node.flags = (pm->node.flags | (on &
6150                // ~PM_READONLY)) & ~(off | PM_UNSET);` — `+n` / `+r`
6151                // WITH a value clear the bits BEFORE the assignment
6152                // runs (`typeset +rn ref=RW`: type change first, so
6153                // the = writes the now-plain scalar, not through the
6154                // old refname).
6155                let off_pre = (off as u32 & (PM_NAMEREF | PM_READONLY)) as i32;
6156                if off_pre != 0 {
6157                    if let Ok(mut tab) = paramtab().write() {
6158                        if let Some(pm) = tab.get_mut(n) {
6159                            pm.node.flags &= !off_pre;
6160                            if (off as u32 & PM_NAMEREF) != 0 {
6161                                pm.width = 0;
6162                                pm.base = 0;
6163                            }
6164                        }
6165                    }
6166                }
6167                // c:2322 — typeset assigns via `assignsparam(pname, ..., 0)`:
6168                // flags=0, NOT setsparam's ASSPM_WARN (params.c:3294), so an
6169                // explicit `typeset -g NAME=val` inside a function never
6170                // trips WARN_CREATE_GLOBAL. Routing through setsparam here
6171                // made zshrs warn where zsh is silent (f-sy-h's
6172                // `typeset -g _ZSH_HIGHLIGHT_PRIOR_BUFFER=...`).
6173                crate::ported::params::assignsparam(n, &folded, 0); // c:2322
6174                                       // c:2326-2328 + c:2336-2337 (typeset_single) —
6175                                       // `if (asg->value.scalar && !(pm = assignsparam(
6176                                       //     pname, ztrdup(asg->value.scalar), 0)))
6177                                       //      return NULL;
6178                                       //  ... if (errflag) return NULL;`
6179                                       // A readonly rejection inside assignstrvalue
6180                                       // (c:Src/params.c:2697) sets errflag and the value is
6181                                       // refused; typeset_single returns NULL and bin_typeset
6182                                       // records `returnval = 1` (c:3153-3156). Skip the
6183                                       // attribute stamps — C never reaches them on this path.
6184                                       // Gated on usepm: the c:2336 check lives ONLY in the
6185                                       // reuse-existing-pm branch.
6186                if usepm_existing && (errflag.load(Relaxed) & ERRFLAG_ERROR) != 0 {
6187                    returnval = 1; // c:3156
6188                    continue; // c:2337 return NULL
6189                }
6190                // c:2510-2520 — `on = pm->node.flags;` then stamp the
6191                // attribute bits on the just-assigned param.
6192                let post_assign_mask = (PM_READONLY
6193                    | PM_EXPORTED
6194                    | PM_LEFT
6195                    | PM_RIGHT_B
6196                    | PM_RIGHT_Z
6197                    | PM_TAGGED
6198                    | PM_HIDE
6199                    | PM_HIDEVAL
6200                    | PM_UNIQUE) as i32;
6201                let post_assign_to_set = (on
6202                    & (PM_READONLY
6203                        | PM_EXPORTED
6204                        | PM_LEFT
6205                        | PM_RIGHT_B
6206                        | PM_RIGHT_Z
6207                        | PM_TAGGED
6208                        | PM_HIDE
6209                        | PM_HIDEVAL
6210                        | PM_UNIQUE)) as i32;
6211                if post_assign_to_set != 0 {
6212                    // c:2289 — `pm->node.flags = (pm->node.flags | (on & ...)) & ~off`.
6213                    // This is a MERGE: OR in the requested attributes and clear
6214                    // the ones explicitly turned off, but PRESERVE existing
6215                    // attributes the user did not touch. The previous
6216                    // `(flags & !post_assign_mask) | to_set` cleared EVERY mask
6217                    // bit, so `typeset -Z 3 x=7; typeset -r x=ro` lost the -Z
6218                    // (RIGHT_Z is in the mask but not in `on`), yielding
6219                    // `scalar-readonly` where zsh keeps `scalar-right_zeros-readonly`.
6220                    let post_assign_to_clear = (off as u32 & post_assign_mask as u32) as i32;
6221                    if let Ok(mut tab) = paramtab().write() {
6222                        if let Some(pm) = tab.get_mut(n) {
6223                            pm.node.flags =
6224                                (pm.node.flags | post_assign_to_set) & !post_assign_to_clear;
6225                        }
6226                    }
6227                }
6228                // c:1973-1989 (Src/builtin.c, inside typeset_single)
6229                // — `if (arg) { int base = zstrtol(arg, ...) ;
6230                // pm->base = base; }`. The precision arg from `-i N`,
6231                // `-E N`, `-F N` (parsed by execbuiltin as
6232                // ops.args[<F-arg-slot>]) lands on the param's `base`
6233                // field, which convfloat reads as the format-digit
6234                // count. Without this stamp, `typeset -F 2 x=3.14`
6235                // ignored the `2` and printed at the default
6236                // 10-digit precision.
6237                {
6238                    let prec_arg: Option<&str> = if (on & PM_INTEGER) != 0 && OPT_HASARG(&ops, b'i')
6239                    {
6240                        OPT_ARG(&ops, b'i')
6241                    } else if (on & PM_EFLOAT) != 0 && OPT_HASARG(&ops, b'E') {
6242                        OPT_ARG(&ops, b'E')
6243                    } else if (on & PM_FFLOAT) != 0 && OPT_HASARG(&ops, b'F') {
6244                        OPT_ARG(&ops, b'F')
6245                    } else {
6246                        None
6247                    };
6248                    if let Some(s) = prec_arg {
6249                        if let Ok(b) = s.trim().parse::<i32>() {
6250                            if let Ok(mut tab) = paramtab().write() {
6251                                if let Some(pm) = tab.get_mut(n) {
6252                                    pm.base = b;
6253                                }
6254                            }
6255                        }
6256                    }
6257                }
6258                // c:2009-2014 typeset_setwidth — `-L N` / `-R N` / `-Z N`
6259                // install pm.width = N. The auto-fallback in assignsparam
6260                // (PM_INTEGER block at params.rs:3619) stamps width to
6261                // s.len() when PM_RIGHT_Z is set but width==0; that lands
6262                // BEFORE the post-assign PM_RIGHT_Z stamp here, so the
6263                // user's explicit `-Z 6` was being overwritten by the
6264                // value's char count. Set width AFTER setsparam so the
6265                // explicit option arg wins over the auto-fallback.
6266                {
6267                    let width_arg: Option<&str> =
6268                        if (on as u32 & PM_LEFT) != 0 && OPT_HASARG(&ops, b'L') {
6269                            OPT_ARG(&ops, b'L')
6270                        } else if (on as u32 & PM_RIGHT_B) != 0 && OPT_HASARG(&ops, b'R') {
6271                            OPT_ARG(&ops, b'R')
6272                        } else if (on as u32 & PM_RIGHT_Z) != 0 && OPT_HASARG(&ops, b'Z') {
6273                            OPT_ARG(&ops, b'Z')
6274                        } else {
6275                            None
6276                        };
6277                    if let Some(s) = width_arg {
6278                        if let Ok(w) = s.trim().parse::<i32>() {
6279                            if let Ok(mut tab) = paramtab().write() {
6280                                if let Some(pm) = tab.get_mut(n) {
6281                                    pm.width = w;
6282                                }
6283                            }
6284                        }
6285                    }
6286                }
6287                // c:Src/params.c:3024 addenv — only mirror to OS env
6288                // when PM_EXPORTED is in flags or already-exported.
6289                let already_exported = env::var_os(n).is_some();
6290                if (on & PM_EXPORTED) != 0 || already_exported {
6291                    env::set_var(n, &folded); // c:3024 addenv
6292                }
6293            }
6294        } else if is_hashed || is_array {
6295            // c:3060-3070 — bare name + `-A`/`-a` declares an empty
6296            // assoc/array.
6297            if is_hashed {
6298                if crate::ported::exec::assoc(arg).is_none() {
6299                    crate::ported::exec::set_assoc(arg, IndexMap::new());
6300                }
6301            } else if crate::ported::exec::array(arg).is_none() {
6302                crate::ported::exec::set_array(arg, Vec::new());
6303            }
6304            // c:Src/params.c:4087 arrsetfn — when PM_UNIQUE is set on
6305            // an existing array, the canonical setfn applies
6306            // `uniqarray()` to the current contents. `typeset -aU arr`
6307            // on an existing arr must dedupe in place; without this,
6308            // the flag stamp lands on pm.flags but the value stays
6309            // un-deduped until the next assignment.
6310            if is_array && (on as u32 & PM_UNIQUE) != 0 {
6311                let current = crate::ported::exec::array(arg).unwrap_or_default();
6312                // simple_arrayuniq is the in-place dedupe used by
6313                // params.rs arrsetfn (PM_UNIQUE path).
6314                let deduped = {
6315                    let mut seen = std::collections::HashSet::new();
6316                    current
6317                        .into_iter()
6318                        .filter(|x| seen.insert(x.clone()))
6319                        .collect::<Vec<_>>()
6320                };
6321                crate::ported::exec::set_array(arg, deduped);
6322            }
6323            // Stamp attribute bits on paramtab entry — same set as
6324            // the `name=value` post-assign mask.
6325            let post_assign_mask = (PM_READONLY
6326                | PM_EXPORTED
6327                | PM_LEFT
6328                | PM_RIGHT_B
6329                | PM_RIGHT_Z
6330                | PM_TAGGED
6331                | PM_HIDE
6332                | PM_HIDEVAL
6333                | PM_UNIQUE
6334                | PM_HASHED
6335                | PM_ARRAY) as i32;
6336            let post_assign_to_set = (on
6337                & (PM_READONLY
6338                    | PM_EXPORTED
6339                    | PM_LEFT
6340                    | PM_RIGHT_B
6341                    | PM_RIGHT_Z
6342                    | PM_TAGGED
6343                    | PM_HIDE
6344                    | PM_HIDEVAL
6345                    | PM_UNIQUE
6346                    | PM_HASHED
6347                    | PM_ARRAY)) as i32;
6348            if post_assign_to_set != 0 {
6349                if let Ok(mut tab) = paramtab().write() {
6350                    if let Some(pm) = tab.get_mut(arg_name) {
6351                        // c:2289 MERGE — preserve attributes the user did not touch
6352                        // (e.g. `typeset -L 4 x=hi; typeset -r x` keeps -L). `off` bits
6353                        // are cleared separately below.
6354                        pm.node.flags |= post_assign_to_set;
6355                    }
6356                }
6357            }
6358            // c:Src/builtin.c:2289 — `pm->node.flags = ... & ~off`.
6359            // `off` bits in post_assign_mask must clear regardless of
6360            // `on` (e.g. `typeset +U arr=(...)` where on=0,
6361            // off=PM_UNIQUE). The post_assign_to_set gate above
6362            // short-circuits the on=0 case. Bug #234 in docs/BUGS.md.
6363            let off_in_mask = (off as i32) & post_assign_mask;
6364            if off_in_mask != 0 {
6365                if let Ok(mut tab) = paramtab().write() {
6366                    if let Some(pm) = tab.get_mut(arg_name) {
6367                        pm.node.flags &= !off_in_mask;
6368                    }
6369                }
6370            }
6371        } else {
6372            // c:2355-2378 (typeset_single tc branch) — bare `typeset -i n`
6373            // / `-F n` / `-E n` / `-l n` / `-u n` / `-r n` / `export N`
6374            // / `readonly N` converts/stamps the existing param. Split
6375            // into pre-assign (type conversion) and post-assign
6376            // (attribute stamp) the same way the `name=value` arm does.
6377            let pre_assign_mask =
6378                (PM_INTEGER | PM_EFLOAT | PM_FFLOAT | PM_LOWER | PM_UPPER | PM_NAMEREF) as i32;
6379            let pre_assign_to_set = (on
6380                & (PM_INTEGER | PM_EFLOAT | PM_FFLOAT | PM_LOWER | PM_UPPER | PM_NAMEREF))
6381                as i32;
6382            let post_assign_mask = (PM_READONLY
6383                | PM_EXPORTED
6384                | PM_LEFT
6385                | PM_RIGHT_B
6386                | PM_RIGHT_Z
6387                | PM_TAGGED
6388                | PM_HIDE
6389                | PM_HIDEVAL
6390                | PM_UNIQUE) as i32;
6391            let post_assign_to_set = (on
6392                & (PM_READONLY
6393                    | PM_EXPORTED
6394                    | PM_LEFT
6395                    | PM_RIGHT_B
6396                    | PM_RIGHT_Z
6397                    | PM_TAGGED
6398                    | PM_HIDE
6399                    | PM_HIDEVAL
6400                    | PM_UNIQUE)) as i32;
6401            // c:2374 — `s = ztrdup(getsparam(pname));`. Capture the
6402            // pre-conversion scalar value so the re-assignment after
6403            // type flip preserves it through the new setfn.
6404            // `typeset +n ref`: the stored REFNAME (u_str raw) becomes
6405            // the scalar value — getsparam would deref through the
6406            // chain and return the target's value instead (c:2374 via
6407            // the +n type-conversion arm; K01 "remove nameref
6408            // attribute" expects `typeset ptr=var`).
6409            let saved_val =
6410                if (off as u32 & PM_NAMEREF) != 0 && crate::ported::params::is_nameref(arg) {
6411                    paramtab()
6412                        .read()
6413                        .ok()
6414                        .and_then(|t| t.get(arg.as_str()).and_then(|p| p.u_str.clone()))
6415                } else {
6416                    getsparam(arg)
6417                };
6418            // c:Src/builtin.c:3072 — `if (!getsparam(pname))
6419            //     setsparam(pname, "");`. C zsh's getsparam returns the
6420            // join-of-values for PM_HASHED and PM_ARRAY so this gate
6421            // fires only for truly-undeclared names. zshrs's getsparam
6422            // returns None for assoc/array (it routes through the
6423            // PM_SCALAR / PM_INTEGER / PM_*FLOAT arms only), so this
6424            // branch falsely fired for an existing hash AND triggered
6425            // assignsparam's PM_HASHED→PM_SCALAR coerce at params.rs:
6426            // 5368-5374 which removes the entry from
6427            // paramtab_hashed_storage. Bug #218 in docs/BUGS.md.
6428            //
6429            // Gate on the paramtab flag instead: if the param exists
6430            // and is PM_HASHED/PM_ARRAY, treat it as already-declared
6431            // even though getsparam returned None.
6432            let already_typed = paramtab()
6433                .read()
6434                .ok()
6435                .and_then(|t| {
6436                    t.get(arg).map(|pm| {
6437                        let f = pm.node.flags as u32;
6438                        let typ = PM_TYPE(f);
6439                        typ == PM_HASHED || typ == PM_ARRAY
6440                    })
6441                })
6442                .unwrap_or(false);
6443            // Capture the param's PRIOR numeric class (before the flag
6444            // change below) so the base-stamp arm can reset pm.base when
6445            // switching BETWEEN integer and float — the base field means
6446            // radix for integers but precision for floats, so a leftover
6447            // value leaks (float precision 3 → integer base 3 = "3#10";
6448            // int base 16 → float 16-digit precision). E↔F and same-type
6449            // re-declares keep it (both float, base=precision). zsh 5.9.1
6450            // verified.
6451            let (prior_is_integer, prior_is_float) = paramtab()
6452                .read()
6453                .ok()
6454                .and_then(|t| {
6455                    t.get(arg).map(|pm| {
6456                        let f = pm.node.flags as u32;
6457                        ((f & PM_INTEGER) != 0, (f & (PM_EFLOAT | PM_FFLOAT)) != 0)
6458                    })
6459                })
6460                .unwrap_or((false, false));
6461            let was_fresh = saved_val.is_none() && !already_typed;
6462            if was_fresh {
6463                // c:3072 — `if (!getsparam(pname)) setsparam(pname, "")`.
6464                // flags=0: a typeset-driven create never trips
6465                // WARN_CREATE_GLOBAL (typeset_single assigns with
6466                // `assignsparam(pname, ..., 0)`, c:2322).
6467                crate::ported::params::assignsparam(arg, "", 0); // c:3074
6468                                    // c:Src/builtin.c:2544 — `if (isset(TYPESETTOUNSET))
6469                                    //     pm->node.flags |= PM_DEFAULTED;`. Under
6470                                    // `setopt typeset_to_unset`, bare `typeset NAME` (no
6471                                    // `=`) creates a "declared but not assigned" entry:
6472                                    // PM_DECLARED + PM_UNSET (= PM_DEFAULTED). `${+NAME}`
6473                                    // and `[[ -v NAME ]]` then return false until an
6474                                    // explicit assignment clears PM_UNSET. Bug #280 in
6475                                    // docs/BUGS.md.
6476                if isset(crate::ported::zsh_h::TYPESETTOUNSET) {
6477                    if let Ok(mut tab) = paramtab().write() {
6478                        if let Some(pm) = tab.get_mut(arg) {
6479                            pm.node.flags |= PM_DEFAULTED as i32;
6480                        }
6481                    }
6482                }
6483                let _ = was_fresh;
6484            }
6485            // c:Src/builtin.c::typeset_single c:2374-2378 — the `+i`
6486            // / `+E` / `+F` / `+l` / `+u` / `+r` / `+n` paths
6487            // REMOVE the corresponding PM_* flag and migrate the
6488            // stored value back to u_str. C captures
6489            // `s = ztrdup(getsparam(pname))` BEFORE the type-conversion
6490            // unset and restores it after. Without this branch,
6491            // `typeset +i n` (where n was an integer with value 42)
6492            // cleared u_str without copying the integer value back —
6493            // result was `typeset n=''`. Bug #326 in docs/BUGS.md.
6494            let pre_assign_to_clear = (off
6495                & (PM_INTEGER | PM_EFLOAT | PM_FFLOAT | PM_LOWER | PM_UPPER | PM_NAMEREF))
6496                as i32;
6497            if pre_assign_to_clear != 0 {
6498                if let Ok(mut tab) = paramtab().write() {
6499                    if let Some(pm) = tab.get_mut(arg.as_str()) {
6500                        pm.node.flags &= !pre_assign_to_clear;
6501                        // `+n` — drop the nameref scope/subscript info
6502                        // along with the flag (c:2374-2378 conversion).
6503                        if (off as u32 & PM_NAMEREF) != 0 {
6504                            pm.width = 0;
6505                            pm.base = 0;
6506                        }
6507                    }
6508                }
6509                // Restore the captured scalar so the cleared-type
6510                // param has its value as a string (PM_SCALAR
6511                // semantics now apply). flags=0 — typeset-internal
6512                // restore, never WARN_CREATE_GLOBAL (c:2322).
6513                if let Some(ref val) = saved_val {
6514                    crate::ported::params::assignsparam(arg, val, 0);
6515                }
6516            }
6517            if pre_assign_to_set != 0 {
6518                if let Ok(mut tab) = paramtab().write() {
6519                    if let Some(pm) = tab.get_mut(arg) {
6520                        pm.node.flags = (pm.node.flags & !pre_assign_mask) | pre_assign_to_set;
6521                    }
6522                }
6523                // c:2372-2378 — re-assign saved value through new type's
6524                // setfn so u_val (for PM_INTEGER) or u_dval (for PM_*FLOAT)
6525                // catches the value migration from u_str.
6526                if let Some(ref val) = saved_val {
6527                    setsparam(arg, val);
6528                }
6529            }
6530            // c:1973-1989 (Src/builtin.c, inside typeset_single):
6531            //   if (arg) {
6532            //       int base = zstrtol(arg, ..., 10);
6533            //       pm->base = base;
6534            //   }
6535            // The precision arg from `-i N`, `-E N`, `-F N` (parsed
6536            // by execbuiltin as `ops.args[<F-arg-slot>]`) lands on
6537            // the param's `base` field, which `convfloat` (c:5689 in
6538            // params.c) reads as the format-digit count. The
6539            // `name=value` arm above (c:2009-2014 typeset_setwidth
6540            // companion path) already stamps `pm.base`; this bare-
6541            // declare arm was missing it, so `typeset -F 4 f` left
6542            // pm.base=0 and `convfloat` rendered with the default
6543            // 10-digit precision (parity bug #29). With this stamp
6544            // a subsequent `(( f = ... ))` re-assignment preserves
6545            // the `pm.base = 4` set here through `assignnparam`'s
6546            // re-assign path (params.rs:5340-5374, c:2874-2878).
6547            {
6548                let prec_arg: Option<&str> = if (on & PM_INTEGER) != 0 && OPT_HASARG(&ops, b'i') {
6549                    OPT_ARG(&ops, b'i') // c:1974 -i N
6550                } else if (on & PM_EFLOAT) != 0 && OPT_HASARG(&ops, b'E') {
6551                    OPT_ARG(&ops, b'E') // c:1977 -E N
6552                } else if (on & PM_FFLOAT) != 0 && OPT_HASARG(&ops, b'F') {
6553                    OPT_ARG(&ops, b'F') // c:1980 -F N
6554                } else {
6555                    None
6556                };
6557                if let Some(s) = prec_arg {
6558                    if let Ok(b) = s.trim().parse::<i32>() {
6559                        // c:1985 zstrtol
6560                        if let Ok(mut tab) = paramtab().write() {
6561                            if let Some(pm) = tab.get_mut(arg) {
6562                                pm.base = b; // c:1987 pm->base = base
6563                            }
6564                        }
6565                    }
6566                } else {
6567                    // No explicit base/precision arg. When an EXISTING
6568                    // numeric param switches BETWEEN integer and float,
6569                    // reset pm.base to the default (0) — the shared base
6570                    // field would otherwise leak the old type's value
6571                    // (createparam's base default is 0, c:Src/params.c:1155;
6572                    // a numeric type change re-creates the representation).
6573                    // Gated so E↔F and same-type re-declares keep their
6574                    // base/precision (`typeset -i16 f; typeset -i f` stays
6575                    // base 16). zsh 5.9.1 verified.
6576                    let switching_int_float = ((on & PM_INTEGER) != 0 && prior_is_float)
6577                        || ((on & (PM_EFLOAT | PM_FFLOAT)) != 0 && prior_is_integer);
6578                    if !was_fresh && switching_int_float {
6579                        if let Ok(mut tab) = paramtab().write() {
6580                            if let Some(pm) = tab.get_mut(arg) {
6581                                pm.base = 0;
6582                            }
6583                        }
6584                    }
6585                }
6586            }
6587            // c:2528-2533 — the createparam (valueless declare) branch
6588            // must ALSO stamp the `-L`/`-R`/`-Z` field width
6589            // (typeset_setwidth, c:2002-2011). The base block above
6590            // handled `-i`/`-E`/`-F` precision but the width was dropped,
6591            // so `typeset -L 10 f` (declare, then assign later) left
6592            // pm.width=0 and the value never left/right-justified — and
6593            // `typeset -p` rendered `-FL` with no number. The inline-value
6594            // arm (the `name=value` path) already stamps width; only this
6595            // bare-declare arm was missing it.
6596            {
6597                let width_arg: Option<&str> = if (on & PM_LEFT) != 0 && OPT_HASARG(&ops, b'L') {
6598                    OPT_ARG(&ops, b'L') // c:2003
6599                } else if (on & PM_RIGHT_B) != 0 && OPT_HASARG(&ops, b'R') {
6600                    OPT_ARG(&ops, b'R') // c:2005
6601                } else if (on & PM_RIGHT_Z) != 0 && OPT_HASARG(&ops, b'Z') {
6602                    OPT_ARG(&ops, b'Z') // c:2007
6603                } else {
6604                    None
6605                };
6606                if let Some(s) = width_arg {
6607                    if let Ok(w) = s.trim().parse::<i32>() {
6608                        // c:2011 zstrtol
6609                        if let Ok(mut tab) = paramtab().write() {
6610                            if let Some(pm) = tab.get_mut(arg) {
6611                                pm.width = w; // c:2011 pm->width = width
6612                            }
6613                        }
6614                    }
6615                }
6616            }
6617            // c:2510+ — stamp post-assign attributes (PM_EXPORTED,
6618            // PM_READONLY, etc.) on the (possibly newly-created) pm.
6619            if post_assign_to_set != 0 {
6620                if let Ok(mut tab) = paramtab().write() {
6621                    if let Some(pm) = tab.get_mut(arg) {
6622                        // c:2289 MERGE — preserve attributes the user did not touch
6623                        // (e.g. `typeset -L 4 x=hi; typeset -r x` keeps -L). `off` bits
6624                        // are cleared separately below.
6625                        pm.node.flags |= post_assign_to_set;
6626                    }
6627                }
6628                // c:Src/params.c — when PM_UNIQUE is freshly stamped on
6629                // an EXISTING array via bare `typeset -U arr` (no value
6630                // assignment), zsh dedups the current contents in-place
6631                // (verified vs /opt/homebrew/bin/zsh: `arr=(a b a c b);
6632                // typeset -U arr; echo "$arr[@]"` → "a b c"). Apply the
6633                // same dedup here.
6634                if (post_assign_to_set as u32 & PM_UNIQUE) != 0 {
6635                    let existing = crate::ported::params::getaparam(arg);
6636                    if let Some(arr) = existing {
6637                        let mut seen: std::collections::HashSet<String> =
6638                            std::collections::HashSet::new();
6639                        let deduped: Vec<String> =
6640                            arr.into_iter().filter(|e| seen.insert(e.clone())).collect();
6641                        crate::ported::params::setaparam(arg, deduped);
6642                    }
6643                }
6644                // c:Src/builtin.c:2302-2307 — `if (!(pm->node.flags &
6645                // (PM_ARRAY|PM_HASHED))) { ... addenv(pm, getsparam(pname)); }`.
6646                // Arrays and associative arrays are NOT mirrored to the
6647                // OS env even when PM_EXPORTED is set — env strings are
6648                // single name=value pairs and have no representation
6649                // for indexed or hashed data. zsh silently no-ops the
6650                // export request for these types; zshrs's prior code
6651                // wrote a malformed empty `h=` entry for `export h`
6652                // where h is an assoc. Bug #349.
6653                if (on as u32 & PM_EXPORTED) != 0 {
6654                    let is_array_or_hashed = paramtab()
6655                        .read()
6656                        .ok()
6657                        .and_then(|t| t.get(arg).map(|pm| pm.node.flags as u32))
6658                        .map_or(false, |f| (f & (PM_ARRAY | PM_HASHED)) != 0)
6659                        || crate::ported::params::paramtab_hashed_storage()
6660                            .lock()
6661                            .ok()
6662                            .map_or(false, |s| s.contains_key(arg))
6663                        || crate::ported::exec::array(arg).is_some()
6664                        || crate::ported::exec::assoc(arg).is_some();
6665                    if !is_array_or_hashed {
6666                        if let Some(val) = saved_val.as_deref().or(Some("")) {
6667                            env::set_var(arg, val);
6668                        }
6669                    }
6670                }
6671            }
6672            // c:Src/builtin.c::typeset_single — `typeset +x VAR`
6673            // clears PM_EXPORTED via the `off` mask. C zsh calls
6674            // removeenv on the param, which strips the var from the
6675            // OS environment so child processes no longer see it.
6676            // Must fire INDEPENDENTLY of post_assign_to_set since `+x`
6677            // alone sets `off` (PM_EXPORTED) but `on` is 0, so the
6678            // post_assign_to_set block above doesn't run. Also clear
6679            // the flag bit on the paramtab entry so `(t)V` no longer
6680            // reports `-export`. Bug #201 in docs/BUGS.md.
6681            if (off as u32 & PM_EXPORTED) != 0 {
6682                env::remove_var(arg);
6683                if let Ok(mut tab) = paramtab().write() {
6684                    if let Some(pm) = tab.get_mut(arg) {
6685                        pm.node.flags &= !(PM_EXPORTED as i32);
6686                    }
6687                }
6688            }
6689            // c:Src/builtin.c:2289 — `pm->node.flags = ... & ~off`.
6690            // `off` bits in post_assign_mask must clear regardless of
6691            // `on` (e.g. `typeset +U arr` where on=0, off=PM_UNIQUE).
6692            // The post_assign_to_set gate above short-circuits the
6693            // on=0 case. Same family as `+x` block immediately above.
6694            // Bug #234 in docs/BUGS.md.
6695            let off_in_mask = (off as i32) & post_assign_mask;
6696            if off_in_mask != 0 {
6697                if let Ok(mut tab) = paramtab().write() {
6698                    if let Some(pm) = tab.get_mut(arg) {
6699                        pm.node.flags &= !off_in_mask;
6700                    }
6701                }
6702            }
6703            // c:Src/builtin.c::typeset_single c:2241-2246 — when no
6704            // type flags and no value assignment, `typeset NAME` is a
6705            // display request: print the existing param in the
6706            // canonical form. C uses
6707            // `printparamnode(&pm->node, PRINT_INCLUDEVALUE|with_ns)`.
6708            // Without this, `typeset h` (where h is an assoc) emitted
6709            // nothing. The earlier per-arg print at typeset_single
6710            // line 3181 isn't called from bin_typeset's per-arg loop
6711            // — the loop does its own inline work. Bug #218 in
6712            // docs/BUGS.md.
6713            // c:Src/builtin.c::typeset_single c:2241-2246 — bare
6714            // `typeset NAME` (no on/off flags except PM_LOCAL which
6715            // bin_typeset auto-applies at c:2808) prints the param.
6716            // PM_LOCAL is added implicitly when not -g / -x / -m so
6717            // mask it out when checking "are any user flags set".
6718            //
6719            // c:Src/builtin.c:2078-2090 — the bare form prints UNLESS it
6720            // is localizing a variable from an outer scope. zsh clears
6721            // `usepm` (→ no print) only when `locallevel != pm->level &&
6722            // (on & PM_LOCAL)` — i.e. the existing param lives at a
6723            // different (outer/global) level and we're making a local.
6724            // Re-declaring a param that ALREADY exists at the current
6725            // level (`f(){ typeset x=1; typeset x }`) keeps usepm and
6726            // DOES print `x=1`. The previous `at_top_scope` gate wrongly
6727            // suppressed that in-function re-declaration case.
6728            let user_on = (on as u32) & !PM_LOCAL;
6729            let cur_ll = locallevel.load(Relaxed);
6730            let not_localizing = pm_level_existing == cur_ll || (on as u32 & PM_LOCAL) == 0;
6731            // c:Src/builtin.c:2244 — `else if (!OPT_ISSET(ops,'g') &&
6732            // (unset(TYPESETSILENT) || OPT_ISSET(ops,'m')))`. The `-g`
6733            // (global-scope) flag SUPPRESSES the bare-name print so
6734            // `typeset -g ZPFX` is a silent declaration / no-op rather
6735            // than a list. zinit's plugin loader runs `typeset -g VAR`
6736            // for already-environment-exported names on every load;
6737            // without this gate zshrs spammed `VAR=value` for each on
6738            // startup.
6739            if user_on == 0
6740                && off == 0
6741                && not_localizing
6742                && !OPT_ISSET(&ops, b'p')
6743                && !OPT_ISSET(&ops, b'g')
6744                && (!isset(TYPESETSILENT) || OPT_ISSET(&ops, b'm'))
6745                && pname_in_tab
6746            {
6747                let with_ns = if OPT_ISSET(&ops, b'm') {
6748                    PRINT_WITH_NAMESPACE
6749                } else {
6750                    0
6751                };
6752                let _ = with_ns;
6753                // The paramtab entry for assoc/array shapes set via
6754                // direct assignment (`a=(1 2 3)` / `h[k]=v`) doesn't
6755                // always have PM_ARRAY/PM_HASHED set on flags — the
6756                // values live in the executor's arrays/assoc_arrays
6757                // storage, separate from paramtab. Check the canonical
6758                // storage paths and emit the right form directly,
6759                // mirroring printparamvalue's PM_ARRAY/PM_HASHED arms
6760                // (params.rs:8819+).
6761                //
6762                // Order matters: assoc lookup falls back to
6763                // crate::ported::exec::assoc which reads the executor's
6764                // assoc_arrays storage. typeset -A populated via
6765                // `typeset -A h=(a 1 b 2)` lives there, not in
6766                // paramtab_hashed_storage. Bug #218 in docs/BUGS.md.
6767                let assoc = crate::ported::params::paramtab_hashed_storage()
6768                    .lock()
6769                    .ok()
6770                    .and_then(|s| s.get(arg).cloned())
6771                    .or_else(|| crate::ported::exec::assoc(arg));
6772                if let Some(map) = assoc {
6773                    let mut entries: Vec<(&String, &String)> = map.iter().collect();
6774                    entries.sort_by(|(a, _), (b, _)| a.cmp(b));
6775                    let mut s = format!("{}=( ", arg);
6776                    let mut first = true;
6777                    for (k, v) in entries {
6778                        if !first {
6779                            s.push(' ');
6780                        }
6781                        first = false;
6782                        s.push_str(&format!("[{}]={}", k, crate::ported::utils::quotedzputs(v)));
6783                    }
6784                    s.push_str(" )");
6785                    println!("{}", s);
6786                } else if let Some(arr) = crate::ported::params::getaparam(arg)
6787                    .or_else(|| crate::ported::exec::array(arg))
6788                    .or_else(|| {
6789                        // Fallback: paramtab entry's u_arr may be set
6790                        // even when PM_TYPE doesn't include PM_ARRAY
6791                        // (direct `a=(1 2 3)` assignment doesn't always
6792                        // update the flag).
6793                        paramtab()
6794                            .read()
6795                            .ok()
6796                            .and_then(|t| t.get(arg).and_then(|pm| pm.u_arr.clone()))
6797                            .filter(|v| !v.is_empty())
6798                    })
6799                {
6800                    let parts: Vec<String> = arr
6801                        .iter()
6802                        .map(|v| crate::ported::utils::quotedzputs(v))
6803                        .collect();
6804                    println!("{}=( {} )", arg, parts.join(" "));
6805                } else if let Some(val) = getsparam(arg) {
6806                    println!("{}={}", arg, crate::ported::utils::quotedzputs(&val));
6807                }
6808            }
6809        }
6810    }
6811    unqueue_signals();
6812    returnval
6813}
6814
6815/// Port of `eval_autoload(Shfunc shf, char *name, Options ops, int func)` from Src/builtin.c:3166.
6816/// C: `int eval_autoload(Shfunc shf, char *name, Options ops, int func)`.
6817/// PM_UNDEFINED guard; -X spawns the eval-trampoline, otherwise loadautofn
6818/// resolves and installs the body.
6819/// WARNING: param names don't match C — Rust=(shf, name, func) vs C=(shf, name, ops, func)
6820pub fn eval_autoload(
6821    shf: *mut shfunc,
6822    name: &str, // c:3166
6823    ops: &options,
6824    func: i32,
6825) -> i32 {
6826    if shf.is_null() {
6827        return 1;
6828    }
6829    let shf_mut = unsafe { &mut *shf };
6830    // c:3168-3169 — `if (!(shf->node.flags & PM_UNDEFINED)) return 1;`
6831    if (shf_mut.node.flags as u32 & PM_UNDEFINED) == 0 {
6832        // c:3168
6833        return 1; // c:3169
6834    }
6835    // c:3171-3174 — `if (shf->funcdef) { freeeprog(shf->funcdef); shf->funcdef = &dummy_eprog; }`
6836    if shf_mut.funcdef.is_some() {
6837        // c:3171
6838        shf_mut.funcdef = None; // c:3173 freeeprog + dummy
6839    }
6840    // c:3175-3181 — `-X` spawns the autoload trampoline via bin_eval.
6841    if OPT_MINUS(ops, b'X') {
6842        // c:3175
6843        // c:3177 — `fargv[0] = quotestring(name, QT_SINGLE_OPTIONAL); fargv[1] = "\"$@\"";`
6844        let fargv = vec![
6845            // c:3177-3179
6846            quotedzputs(name),
6847            "\"$@\"".to_string(),
6848        ];
6849        // c:3180 — `shf->funcdef = mkautofn(shf);`
6850        let p = mkautofn(shf); // c:3180
6851        let _ = p; // funcdef writeback handled inside mkautofn at c:3801
6852        return bin_eval(name, &fargv, ops, func); // c:3181
6853    }
6854    // c:3184-3186 — `return !loadautofn(shf, (OPT_ISSET('k') ? 2 :
6855    //                                  (OPT_ISSET('z') ? 0 : 1)), 1,
6856    //                                   OPT_ISSET('d'));`
6857    let mode = if OPT_ISSET(ops, b'k') {
6858        2
6859    }
6860    // c:3184
6861    else if OPT_ISSET(ops, b'z') {
6862        0
6863    }
6864    // c:3185
6865    else {
6866        1
6867    };
6868    let _d = OPT_ISSET(ops, b'd');
6869    // c:3184 — `return !loadautofn(shf, ...)`. C's loadautofn returns
6870    // `Shfunc` (pointer): NULL = failure, non-NULL = success. C's
6871    // `!loadautofn` yields 1 (true) when the pointer is NULL —
6872    // i.e. eval_autoload returns 1 on failure, 0 on success.
6873    //
6874    // The Rust port's loadautofn returns an i32 with the OPPOSITE
6875    // sign convention: 0 = success, non-zero = failure (the
6876    // canonical Rust ShellExecutor-status convention used throughout
6877    // this file). The previous Rust port literally translated `!`
6878    // as `if r == 0 { 1 } else { 0 }` — but that double-inverts the
6879    // already-flipped convention, so file-not-found (Rust r=1)
6880    // returned eval_autoload=0 (zsh-success), and the broken
6881    // `autoload +X totally_fake` silently exited 0 instead of 1.
6882    // The C-faithful return for the Rust convention is just `r`.
6883    // Bug #107 in docs/BUGS.md.
6884    loadautofn(shf, mode, 1, _d as i32) // c:3184
6885}
6886
6887/// Port of `check_autoload(Shfunc shf, char *name, Options ops, int func)` from Src/builtin.c:3193.
6888/// C: `static int check_autoload(Shfunc shf, char *name, Options ops,
6889///     int func)` — `OPT_ISSET(ops,'X')` ? eval_autoload : 0.
6890/// WARNING: param names don't match C — Rust=(shf, name, func) vs C=(shf, name, ops, func)
6891pub fn check_autoload(
6892    shf: *mut shfunc,
6893    name: &str, // c:3193
6894    ops: &options,
6895    func: i32,
6896) -> i32 {
6897    // c:3196-3199 — `if (OPT_ISSET(ops,'X')) return eval_autoload(...);`
6898    if OPT_ISSET(ops, b'X') {
6899        // c:3196
6900        return eval_autoload(shf, name, ops, func); // c:3197
6901    }
6902    // c:3200-3242 — -r / -R re-resolve: walk fpath for the function file.
6903    let want_r = OPT_ISSET(ops, b'r');
6904    let want_R = OPT_ISSET(ops, b'R');
6905    if (want_r || want_R) && !shf.is_null() {
6906        // c:3200
6907        let shf_mut = unsafe { &mut *shf };
6908        if (shf_mut.node.flags as u32 & PM_UNDEFINED) == 0 {
6909            return 0;
6910        }
6911        // c:3202-3216 — already has filename + PM_LOADDIR: try the cached
6912        // dir first via spec_path[].
6913        if (shf_mut.node.flags as u32 & PM_LOADDIR) != 0 && shf_mut.filename.is_some() {
6914            let spec = vec![shf_mut.filename.clone().unwrap_or_default()];
6915            if getfpfunc(
6916                &shf_mut.node.nam,
6917                &mut None, // c:3206
6918                Some(&spec),
6919                1,
6920                &mut None, // c:3206 `&ksh` — unused on the test-only path
6921            )
6922            .is_some()
6923            {
6924                return 0; // c:3209
6925            }
6926            // c:3211-3217 — `-d` not set: bail (with -R = error, with -r = silent).
6927            if !OPT_ISSET(ops, b'd') {
6928                // c:3211
6929                if want_R {
6930                    // c:3212
6931                    zerr(&format!(
6932                        "{}: function definition file not found",
6933                        shf_mut.node.nam
6934                    )); // c:3213
6935                    return 1; // c:3215
6936                }
6937                return 0; // c:3216
6938            }
6939        }
6940        // c:3219-3231 — fpath walk via getfpfunc + dircache_set install.
6941        let mut dir_path: Option<String> = None;
6942        if getfpfunc(&shf_mut.node.nam, &mut dir_path, None, 1, &mut None).is_some()    // c:3219
6943            && dir_path.is_some()
6944        {
6945            // c:3220-3228 — dircache_set + relative-path absolutize.
6946            let mut old_slot = shf_mut.filename.take();
6947            dircache_set(&mut old_slot, None); // c:3220
6948            let dp = dir_path.unwrap();
6949            let mut new_slot: Option<String> = None;
6950            dircache_set(&mut new_slot, Some(&dp)); // c:3228
6951            shf_mut.filename = new_slot;
6952            shf_mut.node.flags |= PM_LOADDIR as i32; // c:3229
6953            return 0; // c:3230
6954        }
6955        // c:3233-3239 — -R: error; -r: silent.
6956        if want_R {
6957            // c:3233
6958            zerr(&format!(
6959                "{}: function definition file not found",
6960                shf_mut.node.nam
6961            )); // c:3243
6962            return 1; // c:3243
6963        }
6964    }
6965    0 // c:3243
6966}
6967
6968/// Port of `listusermathfunc(MathFunc p)` from Src/builtin.c:3243.
6969/// C: `static void listusermathfunc(MathFunc p)` — emit a `functions -M`
6970///   row for one user math function with arg counts and module name.
6971pub fn listusermathfunc(p: &mathfunc) {
6972    // c:3243
6973    // c:3247-3257 — pick `showargs` 0..3 based on module/min/max presence.
6974    let mut showargs: i32 = if p.module.is_some() {
6975        // c:3249
6976        3
6977    } else if p.maxargs != if p.minargs != 0 { p.minargs } else { -1 } {
6978        // c:3251
6979        2
6980    } else if p.minargs != 0 {
6981        // c:3253
6982        1
6983    } else {
6984        0 // c:3256
6985    };
6986
6987    // c:3259 — `printf("functions -M%s %s", (p->flags & MFF_STR) ? "s" : "", p->name);`
6988    let s_suffix = if (p.flags & MFF_STR) != 0 { "s" } else { "" }; // c:3259
6989    print!("functions -M{} {}", s_suffix, p.name); // c:3259
6990    if showargs != 0 {
6991        // c:3260
6992        print!(" {}", p.minargs); // c:3261
6993        showargs -= 1; // c:3262
6994    }
6995    if showargs != 0 {
6996        // c:3264
6997        print!(" {}", p.maxargs); // c:3265
6998        showargs -= 1; // c:3266
6999    }
7000    if showargs != 0 {
7001        // c:3268
7002        // c:3269-3274 — function names are not required to be ident chars,
7003        // so the module name goes through quotedzputs for safe printing.
7004        print!(" "); // c:3273
7005        print!("{}", quotedzputs(p.module.as_deref().unwrap_or(""))); // c:3274
7006        showargs -= 1; // c:3275
7007    }
7008    println!(); // c:3277
7009}
7010
7011/// Port of `add_autoload_function(Shfunc shf, char *funcname)` from Src/builtin.c:3278.
7012/// C: `static void add_autoload_function(Shfunc shf, char *funcname)` —
7013///   two branches:
7014///     (a) funcname is absolute & shf is PM_UNDEFINED → split `/dir/nam`,
7015///         dircache_set(&shf->filename, dir), set PM_LOADDIR|PM_ABSPATH_USED,
7016///         shfunctab->addnode(nam, shf).
7017///     (b) otherwise → walk funcstack to find calling function; if it has
7018///         PM_LOADDIR|PM_ABSPATH_USED, build `"<calling-dir>/funcname"` and
7019///         access(R_OK); on success copy the dir into shf and set
7020///         PM_LOADDIR|PM_ABSPATH_USED. Then shfunctab->addnode(funcname, shf).
7021/// WARNING: param names don't match C — Rust=(shf) vs C=(shf, funcname)
7022pub fn add_autoload_function(
7023    shf: *mut shfunc, // c:3278
7024    funcname: &str,
7025) {
7026    if shf.is_null() || funcname.is_empty() {
7027        return;
7028    }
7029    let shf_ref = unsafe { &mut *shf };
7030
7031    let is_abs_path = funcname.starts_with('/')                              // c:3282
7032                      && funcname.len() > 1
7033                      && funcname[1..].contains('/')
7034                      && (shf_ref.node.flags as u32 & PM_UNDEFINED) != 0;
7035
7036    if is_abs_path {
7037        // c:3287 — `nam = strrchr(funcname, '/');`
7038        let nam_idx = funcname.rfind('/').unwrap(); // c:3287
7039        let (dir, nam) = if nam_idx == 0 {
7040            // c:3289
7041            ("/".to_string(), funcname[1..].to_string()) // c:3290
7042        } else {
7043            (
7044                funcname[..nam_idx].to_string(), // c:3293
7045                funcname[nam_idx + 1..].to_string(),
7046            )
7047        };
7048        // c:3296 — `dircache_set(&shf->filename, NULL); dircache_set(..., dir);`
7049        let mut old_slot = shf_ref.filename.take();
7050        dircache_set(&mut old_slot, None); // c:3296
7051        let mut new_slot: Option<String> = None;
7052        dircache_set(&mut new_slot, Some(&dir)); // c:3297
7053        shf_ref.filename = new_slot;
7054        // c:3298-3299 — `shf->node.flags |= PM_LOADDIR | PM_ABSPATH_USED;`
7055        shf_ref.node.flags |= (PM_LOADDIR | PM_ABSPATH_USED) as i32; // c:3298
7056        // c:3300 — `shfunctab->addnode(shfunctab, ztrdup(nam), shf);` — the
7057        // node is keyed on the BASENAME (`nam`), not the full path. zshrs's
7058        // addnode keys on `shf.node.nam`, so rename the node before adding;
7059        // otherwise `autoload -Uz /dir/.hist.format` registered a function
7060        // literally named `/dir/.hist.format` (plugins that autoload by full
7061        // path — zsh-hist's `autoload -Uz $fdir/.hist.*` — diverged from
7062        // zsh, which names it `.hist.format`).
7063        shf_ref.node.nam = nam;
7064        if let Ok(mut t) = shfunctab_lock().write() {
7065            t.addnode(shf); // c:3300
7066        }
7067    } else {
7068        // c:3304-3327 — walk funcstack, look up calling fn in shfunctab, if
7069        // it has PM_LOADDIR|PM_ABSPATH_USED build "<dir>/<funcname>" and
7070        // access(R_OK), inherit the dir on hit.
7071        let calling_f: Option<String> = {
7072            let stack = FUNCSTACK.lock().map(|s| s.clone()).unwrap_or_default();
7073            // c:3306 — `for (fs = funcstack; fs; fs = fs->prev)`
7074            stack
7075                .iter()
7076                .rev()
7077                .find(|fs| {
7078                    // c:3306
7079                    // c:3307 — `if (fs->tp == FS_FUNC && fs->name &&
7080                    //               (!shf->node.nam || strcmp(fs->name, shf->node.nam)))`
7081                    FS_FUNC != 0  // mirror struct doesn't expose tp directly;
7082                && !fs.name.is_empty()
7083                && (shf_ref.node.nam.is_empty() || fs.name != shf_ref.node.nam)
7084                })
7085                .map(|fs| fs.name.clone()) // c:3308
7086        };
7087        if let Some(cf) = calling_f {
7088            // c:3315
7089            // c:3316 — `shf2 = shfunctab->getnode2(shfunctab, calling_f);`
7090            let shf2_ptr = shfunctab_lock()
7091                .read()
7092                .map(|t| t.getnode2(&cf))
7093                .unwrap_or(std::ptr::null_mut());
7094            if !shf2_ptr.is_null() {
7095                let shf2 = unsafe { &*shf2_ptr };
7096                // c:3317-3318
7097                let needs = (PM_LOADDIR | PM_ABSPATH_USED) as i32;
7098                if (shf2.node.flags & needs) == needs {
7099                    // c:3317
7100                    if let Some(dir2) = &shf2.filename {
7101                        // c:3318
7102                        // c:3320 — `snprintf(buf, PATH_MAX, "%s/%s", dir2, funcname);`
7103                        let buf = format!("{}/{}", dir2, funcname); // c:3320
7104                        if buf.len() <= libc::PATH_MAX as usize {
7105                            // c:3320
7106                            // c:3324 — `if (!access(buf, R_OK))`
7107                            let buf_c = std::ffi::CString::new(buf.clone()).ok();
7108                            if let Some(bc) = buf_c {
7109                                if unsafe { libc::access(bc.as_ptr(), libc::R_OK) } == 0 {
7110                                    // c:3324
7111                                    let mut old_slot = shf_ref.filename.take();
7112                                    dircache_set(&mut old_slot, None); // c:3325
7113                                    let dir2c = dir2.clone();
7114                                    let mut new_slot: Option<String> = None;
7115                                    dircache_set(&mut new_slot, Some(&dir2c)); // c:3326
7116                                    shf_ref.filename = new_slot;
7117                                    shf_ref.node.flags |= (PM_LOADDIR | PM_ABSPATH_USED) as i32;
7118                                    // c:3327
7119                                }
7120                            }
7121                        }
7122                    }
7123                }
7124            }
7125        }
7126        // c:3334 — `shfunctab->addnode(shfunctab, ztrdup(funcname), shf);`
7127        // addnode keys by `shf->node.nam`; if the caller picked a different
7128        // funcname here, re-tag the node name first so the keyed insert
7129        // matches the C contract.
7130        unsafe {
7131            if !shf.is_null() {
7132                (*shf).node.nam = funcname.to_string();
7133            }
7134        }
7135        if let Ok(mut t) = shfunctab_lock().write() {
7136            t.addnode(shf); // c:3334
7137        }
7138    }
7139}
7140
7141/// Port of `bin_functions(char *name, char **argv, Options ops, int func)` from Src/builtin.c:3342.
7142/// C: `int bin_functions(char *name, char **argv, Options ops, int func)`.
7143/// This is the canonical free-function port matching the C signature so
7144/// the dispatcher can call it. The earlier `ShellExecutor::bin_functions`
7145/// inherent method is an ad-hoc Rust-side helper kept for the existing
7146/// in-process executor; both should converge on this function.
7147/// WARNING: param names don't match C — Rust=(name, argv, _func) vs C=(name, argv, ops, func)
7148pub fn bin_functions(
7149    name: &str,
7150    argv: &[String], // c:3342
7151    ops: &options,
7152    _func: i32,
7153) -> i32 {
7154    // c:3346-3347 — `int returnval = 0; int on = 0, off = 0, pflags = 0,
7155    //                roff, expand = 0;`
7156    let mut returnval: i32 = 0; // c:3346
7157    let mut on: u32 = 0; // c:3347
7158    let mut off: u32 = 0; // c:3347
7159    let _pflags: i32 = 0; // c:3347
7160    let _expand: i32 = 0; // c:3347
7161
7162    // c:3350-3351 — `if (OPT_PLUS(ops,'u')) off |= PM_UNDEFINED; else if
7163    //                (OPT_MINUS(ops,'u') || OPT_ISSET(ops,'X')) on |= PM_UNDEFINED;`
7164    if OPT_PLUS(ops, b'u') {
7165        // c:3350
7166        off |= PM_UNDEFINED; // c:3351
7167    } else if OPT_MINUS(ops, b'u') || OPT_ISSET(ops, b'X') {
7168        // c:3352
7169        on |= PM_UNDEFINED; // c:3353
7170    }
7171    // c:3354-3357 — -U / +U toggle PM_UNALIASED|PM_UNDEFINED.
7172    if OPT_MINUS(ops, b'U') {
7173        // c:3354
7174        on |= PM_UNALIASED | PM_UNDEFINED; // c:3355
7175    } else if OPT_PLUS(ops, b'U') {
7176        // c:3356
7177        off |= PM_UNALIASED; // c:3357
7178    }
7179    // c:3358-3361 — -t / +t toggle PM_TAGGED.
7180    if OPT_MINUS(ops, b't') {
7181        // c:3358
7182        on |= PM_TAGGED; // c:3359
7183    } else if OPT_PLUS(ops, b't') {
7184        // c:3360
7185        off |= PM_TAGGED; // c:3361
7186    }
7187    // c:3362-3365 — -T / +T toggle PM_TAGGED_LOCAL.
7188    if OPT_MINUS(ops, b'T') {
7189        // c:3362
7190        on |= PM_TAGGED_LOCAL; // c:3363
7191    } else if OPT_PLUS(ops, b'T') {
7192        // c:3364
7193        off |= PM_TAGGED_LOCAL; // c:3365
7194    }
7195    // c:3366-3369 — -W / +W toggle PM_WARNNESTED.
7196    if OPT_MINUS(ops, b'W') {
7197        // c:3366
7198        on |= PM_WARNNESTED; // c:3367
7199    } else if OPT_PLUS(ops, b'W') {
7200        // c:3368
7201        off |= PM_WARNNESTED; // c:3369
7202    }
7203    // c:3370 — `roff = off;`
7204    let mut roff = off; // c:3370
7205                        // c:3371-3377 — -z / +z PM_ZSHSTORED|PM_KSHSTORED interaction.
7206    if OPT_MINUS(ops, b'z') {
7207        // c:3371
7208        on |= PM_ZSHSTORED; // c:3372
7209        off |= PM_KSHSTORED; // c:3373
7210    } else if OPT_PLUS(ops, b'z') {
7211        // c:3374
7212        off |= PM_ZSHSTORED; // c:3375
7213        roff |= PM_ZSHSTORED; // c:3376
7214    }
7215    // c:3379-3385 — -k / +k PM_KSHSTORED|PM_ZSHSTORED interaction.
7216    if OPT_MINUS(ops, b'k') {
7217        // c:3379
7218        on |= PM_KSHSTORED; // c:3380
7219        off |= PM_ZSHSTORED; // c:3381
7220    } else if OPT_PLUS(ops, b'k') {
7221        // c:3382
7222        off |= PM_KSHSTORED; // c:3383
7223        roff |= PM_KSHSTORED; // c:3384
7224    }
7225    // c:3386-3392 — -d / +d PM_CUR_FPATH toggle.
7226    if OPT_MINUS(ops, b'd') {
7227        // c:3386
7228        on |= PM_CUR_FPATH; // c:3387
7229        off |= PM_CUR_FPATH; // c:3388
7230    } else if OPT_PLUS(ops, b'd') {
7231        // c:3389
7232        off |= PM_CUR_FPATH; // c:3390
7233        roff |= PM_CUR_FPATH; // c:3391
7234    }
7235
7236    // c:3394-3400 — early-error validation: invalid flag combinations.
7237    // C: `(OPT_MINUS(ops,'X') && (OPT_ISSET(ops,'m') || !scriptname))` —
7238    // \`-X\` is only valid in a script context (autoload-from-fpath
7239    // dispatch). Previous Rust port dropped the \`|| !scriptname\` half
7240    // so \`functions -X foo\` from interactive shell silently
7241    // succeeded — divergent.
7242    let scriptname_missing = scriptname_get().is_none();
7243    if (off & PM_UNDEFINED) != 0                                             // c:3394
7244        || (OPT_ISSET(ops, b'k') && OPT_ISSET(ops, b'z'))                    // c:3394
7245        || (OPT_ISSET(ops, b'x') && !OPT_HASARG(ops, b'x'))                  // c:3395
7246        || (OPT_MINUS(ops, b'X')                                             // c:3396
7247            && (OPT_ISSET(ops, b'm') || scriptname_missing))                 // c:3396 !scriptname
7248        || (OPT_ISSET(ops, b'c')
7249            && (OPT_ISSET(ops, b'x') || OPT_ISSET(ops, b'X') || OPT_ISSET(ops, b'm')))
7250    {
7251        zwarnnam(name, "invalid option(s)"); // c:3399
7252        return 1; // c:3400
7253    }
7254
7255    // c:3402-3452 — `-c` (clone) branch: copy named function under a new
7256    // name, optionally registering it as a TRAP* signal trap.
7257    if OPT_ISSET(ops, b'c') {
7258        // c:3402
7259        if argv.len() < 2 || argv.len() > 2 {
7260            // c:3405
7261            zwarnnam(name, "-c: requires two arguments"); // c:3406
7262            return 1;
7263        }
7264        let src_name = &argv[0];
7265        let dst_name = &argv[1];
7266        // c:3409 — `shf = shfunctab->getnode(shfunctab, *argv);`
7267        let src_ptr = shfunctab_lock()
7268            .read()
7269            .map(|t| t.getnode(src_name.as_str()))
7270            .unwrap_or(std::ptr::null_mut());
7271        if src_ptr.is_null() {
7272            // c:3410
7273            zwarnnam(name, &format!("no such function: {}", src_name)); // c:3411
7274            return 1;
7275        }
7276        // c:3414-3421 — autoload-trampoline expansion if PM_UNDEFINED.
7277        // C body: `if (shf->flags & PM_UNDEFINED) { freeeprog;
7278        // funcdef=dummy; shf = loadautofn(shf,1,0,0); if (!shf) return 1; }`.
7279        // Rust port routes through the local loadautofn helper at
7280        // builtin.rs:883 which walks $fpath via getfpfunc, reads the
7281        // file, stores the body text on the Rust-side ShFunc, and
7282        // clears PM_UNDEFINED.
7283        if (unsafe { (*src_ptr).node.flags } as u32 & PM_UNDEFINED) != 0 {
7284            // c:3415-3418 — `freeeprog(shf->funcdef); shf->funcdef =
7285            // &dummy_eprog;` clear out any stale autoload stub before
7286            // re-loading. Rust port: drop the Option<Eprog>.
7287            unsafe {
7288                (*src_ptr).funcdef = None;
7289            }
7290            // c:3419 — `loadautofn(shf, 1, 0, 0)`.
7291            if loadautofn(src_ptr, 1, 0, 0) != 0 {
7292                // c:3420-3421 — autoload failed.
7293                return 1;
7294            }
7295        }
7296        // c:3422-3430 — `newsh = zalloc + memcpy + filename rebuild`.
7297        let src_ref = unsafe { &*src_ptr };
7298        let new_filename =
7299            if (src_ref.node.flags as u32 & PM_UNDEFINED) == 0 && src_ref.filename.is_some() {
7300                src_ref.filename.clone() // c:3429
7301            } else {
7302                None
7303            };
7304        let _ = new_filename; // wired into shfunctab[dst_name] below
7305                              // c:3437-3447 — TRAP* prefix detection + signal trap registration.
7306        if dst_name.starts_with("TRAP") {
7307            // c:3437
7308            // c:3438 — `int sigidx = getsigidx(s + 4);`
7309            let sigidx = getsigidx(&dst_name[4..]); // c:3438
7310            if sigidx != -1 {
7311                // c:3439
7312                // c:3440 — `if (settrap(sigidx, NULL, ZSIG_FUNC))`.
7313                if settrap(sigidx, None, ZSIG_FUNC) != 0 {
7314                    // c:3440
7315                    // freeeprog(newsh->funcdef) — funcdef Drop covers it.
7316                    // dircache_set(&newsh->filename, NULL);
7317                    // zfree(newsh, sizeof(*newsh));
7318                    return 1; // c:3445
7319                }
7320                // c:3447 — `removetrapnode(sigidx);` — clear any prior trap.
7321                removetrapnode(sigidx); // c:3447
7322            }
7323        }
7324        // c:3422-3430 — C does `newsh = zalloc + memcpy(*newsh, *shf)` so
7325        // src and dst become independent copies. Box-clone the source body
7326        // (rather than aliasing src_ptr) so subsequent mutation through
7327        // `getnode(dst_name)` doesn't bleed into src.
7328        // c:3450 — `shfunctab->addnode(shfunctab, ztrdup(s), &newsh->node);`
7329        let newsh = unsafe {
7330            let mut copy = (*src_ptr).clone();
7331            copy.node.nam = dst_name.clone();
7332            Box::into_raw(Box::new(copy))
7333        };
7334        if let Ok(mut t) = shfunctab_lock().write() {
7335            t.addnode(newsh); // c:3450
7336        }
7337        return 0; // c:3451
7338    }
7339
7340    // c:3454-3463 — `-x N` indent override for printing.
7341    let mut expand: i32 = 0; // c:3454 (also c:3347)
7342    if OPT_ISSET(ops, b'x') {
7343        // c:3454
7344        let arg = OPT_ARG(ops, b'x').unwrap_or("");
7345        match arg.trim().parse::<i32>() {
7346            // c:3456
7347            Ok(n) => {
7348                expand = n; // c:3456
7349                if expand == 0 {
7350                    expand = -1;
7351                } // c:3461-3462
7352            }
7353            Err(_) => {
7354                zwarnnam(name, "number expected after -x"); // c:3458
7355                return 1; // c:3459
7356            }
7357        }
7358    }
7359
7360    // c:3465-3466 — `+f` / roff / `+` enables PRINT_NAMEONLY.
7361    let mut pflags: i32 = 0;
7362    if OPT_PLUS(ops, b'f') || roff != 0 || OPT_ISSET(ops, b'+') {
7363        // c:3465
7364        pflags |= PRINT_NAMEONLY; // c:3466
7365    }
7366
7367    // c:3468-3530 — `-M`/`+M` add/remove/list math function path.
7368    if OPT_MINUS(ops, b'M') || OPT_PLUS(ops, b'M') {
7369        // c:3468
7370        // c:3473-3477 — refuse incompatible flag combos.
7371        if on != 0
7372            || off != 0
7373            || pflags != 0
7374            || OPT_ISSET(ops, b'X')
7375            || OPT_ISSET(ops, b'u')
7376            || OPT_ISSET(ops, b'U')
7377            || OPT_ISSET(ops, b'w')
7378        {
7379            zwarnnam(name, "invalid option(s)"); // c:3475
7380            return 1; // c:3476
7381        }
7382        if argv.is_empty() {
7383            // c:3478
7384            // c:3479-3484 — list user math ported.
7385            queue_signals(); // c:3480
7386            if let Ok(table) = MATHFUNCS.lock() {
7387                // c:3481
7388                for p in table.iter() {
7389                    // c:3481
7390                    if (p.flags & MFF_USERFUNC) != 0 {
7391                        // c:3482
7392                        listusermathfunc(p); // c:3483
7393                    }
7394                }
7395            }
7396            unqueue_signals(); // c:3484
7397            return returnval;
7398        } else if OPT_ISSET(ops, b'm') {
7399            // c:3485
7400            // c:3486-3515 — list/delete matching math ported by pattern.
7401            for arg in argv.iter() {
7402                queue_signals(); // c:3488
7403                                 // c:3489 — `tokenize(*argv)`; Rust patcompile handles it.
7404                if let Some(pprog) = patcompile(
7405                    &{
7406                        let mut __pat_tok = (arg).to_string();
7407                        crate::ported::glob::tokenize(&mut __pat_tok);
7408                        __pat_tok
7409                    },
7410                    PAT_STATIC,
7411                    None,
7412                ) {
7413                    // c:3490
7414                    if OPT_PLUS(ops, b'M') {
7415                        // c:3497
7416                        // Delete matching user ported.
7417                        if let Ok(mut table) = MATHFUNCS.lock() {
7418                            table.retain(|p| {
7419                                !((p.flags & MFF_USERFUNC) != 0 && pattry(&pprog, &p.name))
7420                            });
7421                        }
7422                    } else {
7423                        // c:3502 — listusermathfunc for matches.
7424                        if let Ok(table) = MATHFUNCS.lock() {
7425                            for p in table.iter() {
7426                                if (p.flags & MFF_USERFUNC) != 0 && pattry(&pprog, &p.name) {
7427                                    listusermathfunc(p);
7428                                }
7429                            }
7430                        }
7431                    }
7432                } else {
7433                    // c:3509
7434                    // c:3510-3512 — bad pattern.
7435                    zwarnnam(
7436                        name, // c:3511
7437                        &format!("bad pattern : {}", arg),
7438                    );
7439                    returnval = 1; // c:3512
7440                }
7441                unqueue_signals(); // c:3514
7442            }
7443            return returnval;
7444        } else if OPT_PLUS(ops, b'M') {
7445            // c:3516
7446            // c:3517-3533 — `+M name…` delete by exact name.
7447            for arg in argv.iter() {
7448                queue_signals(); // c:3519
7449                if let Ok(mut table) = MATHFUNCS.lock() {
7450                    let idx = table.iter().position(|p| p.name == *arg); // c:3520-3521
7451                    if let Some(i) = idx {
7452                        if (table[i].flags & MFF_USERFUNC) == 0 {
7453                            // c:3522-3527 — library function, refuse.
7454                            zwarnnam(
7455                                name, // c:3523
7456                                &format!("+M {}: is a library function", arg),
7457                            );
7458                            returnval = 1; // c:3525
7459                        } else {
7460                            table.remove(i); // c:3528
7461                        }
7462                    }
7463                }
7464                unqueue_signals(); // c:3532
7465            }
7466            return returnval;
7467        } else {
7468            // c:3535-3611 — `-M name [min [max [mod]]]` add a user math fn.
7469            let mut argv_iter = argv.iter();
7470            let funcname = argv_iter.next().unwrap(); // c:3537
7471            let mut minargs: i32;
7472            let mut maxargs: i32;
7473            if OPT_ISSET(ops, b's') {
7474                // c:3541
7475                minargs = 1; // c:3542
7476                maxargs = 1; // c:3542
7477            } else {
7478                minargs = 0; // c:3544
7479                maxargs = -1; // c:3545
7480            }
7481            // c:3548-3552 — bad math function name check.
7482            let bytes = funcname.as_bytes();
7483            let first_bad = bytes.is_empty()
7484                || (bytes[0] as char).is_ascii_digit()
7485                || !bytes
7486                    .iter()
7487                    .all(|&c| c.is_ascii_alphanumeric() || c == b'_');
7488            if first_bad {
7489                // c:3549
7490                zwarnnam(
7491                    name, // c:3550
7492                    &format!("-M {}: bad math function name", funcname),
7493                );
7494                return 1; // c:3551
7495            }
7496            if let Some(arg) = argv_iter.next() {
7497                // c:3554
7498                match arg.parse::<i32>() {
7499                    // c:3555 zstrtol
7500                    Ok(n) if n >= 0 => minargs = n, // c:3556
7501                    _ => {
7502                        zwarnnam(
7503                            name, // c:3557
7504                            &format!("-M: invalid min number of arguments: {}", arg),
7505                        );
7506                        return 1; // c:3559
7507                    }
7508                }
7509                if OPT_ISSET(ops, b's') && minargs != 1 {
7510                    // c:3561
7511                    zwarnnam(
7512                        name, // c:3562
7513                        "-Ms: must take a single string argument",
7514                    );
7515                    return 1; // c:3563
7516                }
7517                maxargs = minargs; // c:3565
7518            }
7519            if let Some(arg) = argv_iter.next() {
7520                // c:3568
7521                match arg.parse::<i32>() {
7522                    // c:3569
7523                    Ok(n) if n >= -1 && (n == -1 || n >= minargs) => maxargs = n,
7524                    _ => {
7525                        zwarnnam(
7526                            name, // c:3573
7527                            &format!("-M: invalid max number of arguments: {}", arg),
7528                        );
7529                        return 1; // c:3576
7530                    }
7531                }
7532                if OPT_ISSET(ops, b's') && maxargs != 1 {
7533                    // c:3578
7534                    zwarnnam(
7535                        name, // c:3579
7536                        "-Ms: must take a single string argument",
7537                    );
7538                    return 1; // c:3580
7539                }
7540            }
7541            let modname = argv_iter.next().cloned(); // c:3584-3585
7542            if argv_iter.next().is_some() {
7543                // c:3586
7544                zwarnnam(name, "-M: too many arguments"); // c:3587
7545                return 1; // c:3588
7546            }
7547            // c:3591-3598 — alloc and populate mathfunc.
7548            let mut flags = MFF_USERFUNC; // c:3593
7549            if OPT_ISSET(ops, b's') {
7550                // c:3594
7551                flags |= MFF_STR; // c:3595
7552            }
7553            let new_fn = mathfunc {
7554                next: None,             // c:3608 chain via Vec
7555                name: funcname.clone(), // c:3592
7556                flags,                  // c:3593
7557                nfunc: None,
7558                sfunc: None,
7559                module: modname, // c:3596
7560                minargs,         // c:3597
7561                maxargs,         // c:3598
7562                funcid: 0,
7563            };
7564            queue_signals(); // c:3600
7565            if let Ok(mut table) = MATHFUNCS.lock() {
7566                // c:3601-3606 — remove existing user entry with same name.
7567                if let Some(i) = table.iter().position(|p| p.name == new_fn.name) {
7568                    table.remove(i); // c:3603
7569                }
7570                // c:3608-3609 — prepend to mathfuncs head.
7571                table.insert(0, new_fn);
7572            }
7573            unqueue_signals(); // c:3610
7574            return returnval;
7575        }
7576    }
7577
7578    // c:3616-3655 — `-X` re-autoload from inside a function.
7579    if OPT_MINUS(ops, b'X') {
7580        // c:3616
7581        if argv.len() > 1 {
7582            // c:3620
7583            zwarnnam(name, "-X: too many arguments"); // c:3621
7584            return 1; // c:3622
7585        }
7586        queue_signals(); // c:3624
7587                         // c:3625-3633 — walk funcstack to find the enclosing FS_FUNC frame.
7588        let funcname: Option<String> = {
7589            let stack = FUNCSTACK.lock().map(|s| s.clone()).unwrap_or_default();
7590            stack
7591                .iter()
7592                .rev()
7593                .find(|fs| !fs.name.is_empty()) // c:3626
7594                .map(|fs| fs.name.clone()) // c:3631
7595        };
7596        let ret;
7597        if funcname.is_none() {
7598            // c:3635
7599            // c:3637 — `zerrnam(name, "bad autoload");`
7600            zwarnnam(name, "bad autoload"); // c:3637
7601            ret = 1; // c:3638
7602        } else {
7603            let fname = funcname.unwrap();
7604            // c:3640-3647 — getnode(shfunctab, funcname) || addnode(new shf).
7605            let mut shf_ptr = shfunctab_lock()
7606                .read()
7607                .map(|t| t.getnode(fname.as_str()))
7608                .unwrap_or(std::ptr::null_mut());
7609            if !shf_ptr.is_null() { // c:3640
7610                 // exists already
7611            } else {
7612                // c:3645 — `shf = zshcalloc(sizeof *shf);`
7613                //          `shfunctab->addnode(shfunctab, ztrdup(funcname), shf);`
7614                let new_shf = Box::into_raw(Box::new(shfunc {
7615                    node: hashnode {
7616                        next: None,
7617                        nam: fname.clone(),
7618                        flags: 0,
7619                    },
7620                    filename: None,
7621                    lineno: 0,
7622                    funcdef: None,
7623                    redir: None,
7624                    sticky: None,
7625                    body: None,
7626                }));
7627                if let Ok(mut t) = shfunctab_lock().write() {
7628                    t.addnode(new_shf); // c:3646
7629                }
7630                shf_ptr = new_shf;
7631            }
7632            if !argv.is_empty() {
7633                // c:3648
7634                if !shf_ptr.is_null() {
7635                    let shf_mut = unsafe { &mut *shf_ptr };
7636                    let mut old_slot = shf_mut.filename.take();
7637                    dircache_set(&mut old_slot, None); // c:3649
7638                    let mut new_slot: Option<String> = None;
7639                    dircache_set(&mut new_slot, Some(&argv[0])); // c:3650
7640                    shf_mut.filename = new_slot;
7641                    on |= PM_LOADDIR; // c:3294 — `shf->node.flags |= PM_LOADDIR;`
7642                }
7643            }
7644            // c:3653 — `shf->node.flags = on;` — `on` carries
7645            // PM_UNDEFINED (set for -X at c:3352 / builtin.rs:6142).
7646            // This write was MISSING: eval_autoload's first gate is
7647            // `if (!(shf->node.flags & PM_UNDEFINED)) return 1;`
7648            // (c:3168), so zinit's NEW_AUTOLOAD=1 stubs —
7649            //   functions[add-zsh-hook]="local -a fpath; fpath=(…);
7650            //                            builtin autoload -X -U -z"
7651            // — silently returned 1 and every wrapped plugin autoload
7652            // (add-zsh-hook, regexp-replace, add-zle-hook-widget)
7653            // reported `function definition file not found` at first
7654            // call in the interactive session.
7655            if !shf_ptr.is_null() {
7656                unsafe {
7657                    (*shf_ptr).node.flags = on as i32; // c:3653
7658                }
7659            }
7660            // RUST-ONLY: drop the executor's compiled chunk for the
7661            // CURRENT (stub) definition. C's eval_autoload -X arm
7662            // replaces shf->funcdef with mkautofn (c:3180) so the
7663            // bin_eval re-call loads the real file; zshrs's dispatch
7664            // keeps a parallel functions_compiled chunk whose
7665            // presence skips the autoload prelude — re-dispatch ran
7666            // the STALE stub (zinit NEW_AUTOLOAD=1: `builtin
7667            // autoload -X` inside the stub) in an infinite mutual
7668            // recursion → stack overflow in the interactive session.
7669            let _ = crate::ported::exec::unregister_function(&fname);
7670            // c:3654 — `ret = eval_autoload(shf, funcname, ops, func);`
7671            ret = eval_autoload(shf_ptr, &fname, ops, _func); // c:3654
7672        }
7673        unqueue_signals(); // c:3656
7674        return ret;
7675    }
7676
7677    // c:3658-3669 — no-arg listing path: print all (non-DISABLED) shfuncs
7678    // matching `on|off` mask through scanshfunc + printnode.
7679    if argv.is_empty() {
7680        // c:3658
7681        queue_signals(); // c:3663
7682        if OPT_ISSET(ops, b'U') && !OPT_ISSET(ops, b'u') {
7683            // c:3664
7684            on &= !PM_UNDEFINED; // c:3665
7685        }
7686        // c:3666 — `scanshfunc(1, on|off, DISABLED, shfunctab->printnode,
7687        //              pflags, expand);` — C's scanhashtable takes a
7688        // `flags1` filter mask: only entries where `(flags & flags1) ==
7689        // flags1` are emitted. Rust's `scanshfunc` doesn't accept the
7690        // filter, so apply it inside the closure. Without this gate
7691        // `functions -t` (no args) walked every function and printed
7692        // its body — should be empty / name-only restricted to
7693        // PM_TAGGED entries. Bug #468.
7694        let filter_mask = on | off;
7695        scanshfunc(|_nm, entry| {
7696            let f = entry.node.flags as u32;
7697            if filter_mask != 0 && (f & filter_mask) != filter_mask {
7698                return;
7699            }
7700            printshfuncexpand(entry, pflags, expand);
7701        });
7702        unqueue_signals(); // c:3668
7703        return returnval;
7704    }
7705
7706    // c:3672-3708 — `-m` glob: treat each arg as a pattern, scan-and-print
7707    // matching shfuncs (no on/off → list) or apply on/off mask.
7708    if OPT_ISSET(ops, b'm') {
7709        // c:3673
7710        on &= !PM_UNDEFINED; // c:3674
7711        let mut returnval = returnval;
7712        for pat in argv {
7713            // c:3675
7714            queue_signals(); // c:3676
7715                             // c:3678 — `tokenize(*argv)` + `patcompile(...)`
7716            let pprog = patcompile(
7717                &{
7718                    let mut __pat_tok = (pat).to_string();
7719                    crate::ported::glob::tokenize(&mut __pat_tok);
7720                    __pat_tok
7721                }, // c:3680
7722                PAT_HEAPDUP,
7723                None,
7724            );
7725            if let Some(prog) = pprog {
7726                // c:3680-3683 — scan-and-print matching shfuncs.
7727                if (on | off) == 0 && !OPT_ISSET(ops, b'X') {
7728                    // c:3682
7729                    // c:3682-3683 — `scanmatchshfunc(pprog, 1, 0,
7730                    //   DISABLED, shfunctab->printnode, pflags, expand)`.
7731                    // Walk shfunctab via the hashtable.rs port and emit
7732                    // each match through `printshfuncexpand` so autoload
7733                    // stubs come out as `name () { # undefined; builtin
7734                    // autoload -XU }` and loaded funcs print their body.
7735                    scanmatchshfunc(Some(pat), |_nm, entry| {
7736                        printshfuncexpand(entry, pflags, expand);
7737                    });
7738                } else {
7739                    // c:3686-3699 — walk shfunctab, apply (on, off) and
7740                    // re-eval autoload for each matching shf.
7741                    let names: Vec<String> = shfunctab_lock()
7742                        .read()
7743                        .map(|t| t.iter().map(|(k, _)| k.clone()).collect())
7744                        .unwrap_or_default();
7745                    for nm in &names {
7746                        // pattry approximated by string equality / glob
7747                        // here; full pat engine is in src/ported/pattern.rs.
7748                        if !pattry(&prog, nm) {
7749                            // c:3690
7750                            continue;
7751                        }
7752                        let shf_ptr = shfunctab_lock()
7753                            .read()
7754                            .map(|t| t.getnode(nm.as_str()))
7755                            .unwrap_or(std::ptr::null_mut());
7756                        if shf_ptr.is_null() {
7757                            continue;
7758                        }
7759                        let shf_mut = unsafe { &mut *shf_ptr };
7760                        // c:3691 — `shf->node.flags = (... | (on & ~PM_UNDEFINED)) & ~off;`
7761                        shf_mut.node.flags =
7762                            (shf_mut.node.flags | ((on & !PM_UNDEFINED) as i32)) & !(off as i32); // c:3691
7763                        if check_autoload(shf_ptr, &shf_mut.node.nam, ops, _func) != 0 {
7764                            // c:3693
7765                            returnval = 1; // c:3695
7766                        }
7767                    }
7768                }
7769            } else {
7770                // c:3700-3702 — `untokenize + zwarnnam(name, "bad pattern")`.
7771                zwarnnam(name, &format!("bad pattern : {}", pat)); // c:3701
7772                returnval = 1; // c:3702
7773            }
7774            unqueue_signals(); // c:3704
7775        }
7776        return returnval;
7777    }
7778
7779    // c:3710-3735 — literal name list, no globbing.
7780    let mut returnval = returnval;
7781    queue_signals(); // c:3711
7782    for fname in argv {
7783        // c:3712
7784        // c:3713-3714 — `-w` (compile-and-dump) path.
7785        if OPT_ISSET(ops, b'w') {
7786            // c:3713
7787            // dump_autoload(name, fname, on, ops, func) — dump.c port.
7788            continue;
7789        }
7790        // c:3715 — `shf = shfunctab->getnode(shfunctab, *argv);`
7791        let shf_ptr = shfunctab_lock()
7792            .read()
7793            .map(|t| t.getnode(fname.as_str()))
7794            .unwrap_or(std::ptr::null_mut());
7795        if !shf_ptr.is_null() {
7796            // c:3715
7797            let shf_mut = unsafe { &mut *shf_ptr };
7798            if (on | off) != 0 {
7799                // c:3717
7800                // c:3719 — apply on/off mask, then check_autoload.
7801                shf_mut.node.flags =
7802                    (shf_mut.node.flags | ((on & !PM_UNDEFINED) as i32)) & !(off as i32); // c:3719
7803                if check_autoload(shf_ptr, &shf_mut.node.nam, ops, _func) != 0 {
7804                    // c:3720
7805                    returnval = 1; // c:3721
7806                }
7807            } else {
7808                // c:3723 — `printshfuncexpand(&shf->node, pflags, expand);`
7809                // C prints the function via shfunctab.printnode honoring
7810                // pflags (PRINT_NAMEONLY / verbose). The previous Rust
7811                // port just printed the name — `functions f` skipped
7812                // the `f () { ... body ... }` body listing entirely.
7813                printshfuncexpand(shf_mut, pflags, expand); // c:3723
7814            }
7815        } else if (on & PM_UNDEFINED) != 0 {
7816            // c:3725
7817            // c:3726-3782 — autoload-define path: TRAP* + abs-path + new shf.
7818            let mut sigidx: i32 = -1;
7819            let mut ok = true;
7820            // c:3728-3735 — TRAP* prefix → removetrapnode(sigidx).
7821            if fname.starts_with("TRAP") {
7822                // c:3728
7823                // c:3729 — `if ((sigidx = getsigidx(*argv + 4)) != -1)`
7824                sigidx = getsigidx(&fname[4..]); // c:3729
7825                if sigidx != -1 {
7826                    // c:3729
7827                    // c:3733 — `removetrapnode(sigidx);`
7828                    removetrapnode(sigidx); // c:3733
7829                }
7830            }
7831            // c:3737-3759 — absolute path /dir/base form: install dir on
7832            // existing matching base name with PM_UNDEFINED set.
7833            if fname.starts_with('/') {
7834                // c:3737
7835                let base = fname.rsplit('/').next().unwrap_or("");
7836                if !base.is_empty() {
7837                    let base_ptr = shfunctab_lock()
7838                        .read()
7839                        .map(|t| t.getnode(base))
7840                        .unwrap_or(std::ptr::null_mut());
7841                    if !base_ptr.is_null() {
7842                        let bs = unsafe { &mut *base_ptr };
7843                        // c:3742 — apply flag mask.
7844                        bs.node.flags =
7845                            (bs.node.flags | ((on & !PM_UNDEFINED) as i32)) & !(off as i32); // c:3742
7846                        if (bs.node.flags as u32 & PM_UNDEFINED) != 0 {
7847                            // c:3744
7848                            let dir = if fname.len() > 1 && base.len() == fname.len() - 1 {
7849                                "/".to_string() // c:3747
7850                            } else {
7851                                fname[..fname.len() - base.len() - 1].to_string()
7852                                // c:3749-3751
7853                            };
7854                            let mut old_slot = bs.filename.take();
7855                            dircache_set(&mut old_slot, None); // c:3753
7856                            let mut new_slot: Option<String> = None;
7857                            dircache_set(&mut new_slot, Some(&dir)); // c:3754
7858                            bs.filename = new_slot;
7859                        }
7860                        if check_autoload(base_ptr, &bs.node.nam, ops, _func) != 0 {
7861                            // c:3756
7862                            returnval = 1;
7863                        }
7864                        continue; // c:3758
7865                    }
7866                }
7867            }
7868            // c:3763-3766 — new undefined shf, mkautofn, add_autoload_function.
7869            let new_shf = Box::new(shfunc {
7870                node: hashnode {
7871                    next: None,
7872                    nam: fname.clone(),
7873                    flags: on as i32, // c:3764
7874                },
7875                filename: None,
7876                lineno: 0,
7877                funcdef: None,
7878                redir: None,
7879                sticky: None,
7880                body: None,
7881            });
7882            let new_shf_ptr = Box::into_raw(new_shf);
7883            let _ = mkautofn(new_shf_ptr); // c:3765
7884            add_autoload_function(new_shf_ptr, fname); // c:3767
7885                                                       // PFA-SMR: an `autoload NAME` registers a function stub
7886                                                       // that fires on first call. Record it as a `function`
7887                                                       // kind so replay re-registers the autoload. C zsh has
7888                                                       // no recorder hook here; emit per Rust-only schema.
7889            #[cfg(feature = "recorder")]
7890            if crate::recorder::is_enabled() {
7891                let ctx = crate::recorder::recorder_ctx_global();
7892                crate::recorder::emit_function(fname, None, ctx);
7893            }
7894            if sigidx != -1 {
7895                // c:3769
7896                // c:3770 — `if (settrap(sigidx, NULL, ZSIG_FUNC)) { ... }`
7897                if settrap(sigidx, None, ZSIG_FUNC) != 0 {
7898                    // c:3770
7899                    // c:3771 — `shfunctab->removenode(shfunctab, *argv);`
7900                    if let Ok(mut t) = shfunctab_lock().write() {
7901                        t.remove(fname);
7902                    }
7903                    // c:3772 — `shfunctab->freenode(&shf->node);` Drop covers it.
7904                    returnval = 1; // c:3773
7905                    ok = false; // c:3774
7906                }
7907            }
7908            if ok && check_autoload(new_shf_ptr, &fname, ops, _func) != 0 {
7909                // c:3779
7910                returnval = 1; // c:3780
7911            }
7912        } else {
7913            // c:3783 — `returnval = 1;` (named function not found,
7914            //          no autoload requested).
7915            returnval = 1; // c:3783
7916        }
7917    }
7918    unqueue_signals(); // c:3785
7919    let _ = (expand, pflags);
7920    returnval
7921}
7922
7923/// Port of `mkautofn(Shfunc shf)` from Src/builtin.c:3790.
7924/// C: `Eprog mkautofn(Shfunc shf)` — synthesize a 5-wordcode body that
7925///   re-fires the autoload mechanism when first called.
7926pub fn mkautofn(shf: *mut shfunc) -> *mut eprog {
7927    // c:3790
7928    // c:3793-3810 — alloc Eprog with 5 wordcode slots, set p->shf, p->npats=0,
7929    // p->nref=1 (permanent). Static-link path: synthesize a Box<eprog> that
7930    // satisfies the autoload trampoline contract.
7931    let p = Box::new(eprog {
7932        len: 5 * size_of::<u32>() as i32, // c:3796
7933        prog: Vec::new(),                 // c:3797
7934        strs: None,                       // c:3798
7935        shf: if shf.is_null() {
7936            None
7937        }
7938        // c:3799
7939        else {
7940            Some(unsafe { Box::from_raw(shf) })
7941        },
7942        npats: 0, // c:3800
7943        nref: 1,  // c:3801
7944        flags: 0,
7945        pats: Vec::new(),
7946        dump: None,
7947        strs_metafied: false, // native pool — clean UTF-8
7948    });
7949    Box::into_raw(p)
7950}
7951
7952/// Port of `bin_unset(char *name, char **argv, Options ops, int func)` from Src/builtin.c:3818.
7953/// C: `int bin_unset(char *name, char **argv, Options ops, int func)` —
7954///   `-f` delegates to `bin_unhash`; `-m` glob deletes matching params;
7955///   default literal-name unset with subscript handling.
7956/// WARNING: param names don't match C — Rust=(name, argv, func) vs C=(name, argv, ops, func)
7957pub fn bin_unset(
7958    name: &str,
7959    argv: &[String], // c:3818
7960    ops: &options,
7961    func: i32,
7962) -> i32 {
7963    let mut returnval = 0i32; // c:3823
7964    let mut match_count = 0i32; // c:3823
7965
7966    // PFA-SMR aspect: emit unset events for each named param. The
7967    // recorder tracks state-mutations across the shell session for
7968    // the zshrs-recorder binary's replay/inspect tooling.
7969    #[cfg(feature = "recorder")]
7970    if crate::recorder::is_enabled() {
7971        let ctx = crate::recorder::recorder_ctx_global();
7972        for a in argv {
7973            if a.starts_with('-') || a == "--" {
7974                continue;
7975            }
7976            crate::recorder::emit_unset(a, ctx.clone());
7977        }
7978    }
7979
7980    // c:3826 — `if (OPT_ISSET(ops,'f')) return bin_unhash(name, argv, ops, func);`
7981    if OPT_ISSET(ops, b'f') {
7982        // c:3826
7983        return bin_unhash(name, argv, ops, func); // c:3827
7984    }
7985
7986    // c:3830-3862 — `-m` glob.
7987    if OPT_ISSET(ops, b'm') {
7988        // c:3831
7989        for s in argv {
7990            // c:3832
7991            queue_signals(); // c:3833
7992            let pprog = patcompile(
7993                &{
7994                    let mut __pat_tok = (s).to_string();
7995                    crate::ported::glob::tokenize(&mut __pat_tok);
7996                    __pat_tok
7997                }, // c:3836
7998                PAT_HEAPDUP,
7999                None,
8000            );
8001            if let Some(prog) = pprog {
8002                // c:3838-3851 — walk paramtab (NOT env::vars), unset via
8003                // unsetparam (which respects PM_NAMEREF + readonly guards).
8004                //
8005                // The previous Rust port walked `std::env::vars()` — the
8006                // OS environment. This is a different name set:
8007                //   - Shell-internal vars (not exported) would survive
8008                //     `unset -m 'PATTERN'` even though they match.
8009                //   - Env vars not in paramtab would be removed without
8010                //     the PM_READONLY guard in unsetparam_pm.
8011                //
8012                // Same family of bug as the env::var vs paramtab fixes
8013                // earlier in the series.
8014                let names: Vec<String> = {
8015                    let tab = paramtab().read().unwrap();
8016                    tab.keys().cloned().collect()
8017                };
8018                for nm in &names {
8019                    if pattry(&prog, nm) {
8020                        // c:3842
8021                        unsetparam(nm); // c:3847 (with guards)
8022                        match_count += 1; // c:3848
8023                    }
8024                }
8025            } else {
8026                zwarnnam(name, &format!("bad pattern : {}", s)); // c:3854
8027                returnval = 1; // c:3855
8028            }
8029            unqueue_signals(); // c:3857
8030        }
8031        if match_count == 0 {
8032            // c:3861
8033            returnval = 1; // c:3862
8034        }
8035        return returnval; // c:3863
8036    }
8037
8038    // c:3866-3915 — literal-name unset with optional subscript.
8039    queue_signals(); // c:3867
8040    for s in argv {
8041        // c:3868
8042        // c:3869-3878 — extract `name[subscript]` shape.
8043        let (nm, subscript) = match s.find('[') {
8044            // c:3869
8045            Some(start) if s.ends_with(']') => {
8046                // c:3873
8047                (&s[..start], Some(&s[start + 1..s.len() - 1])) // c:3875
8048            }
8049            Some(_) => {
8050                // c:3879-3884 — bracket without `]` close → invalid.
8051                zwarnnam(name, &format!("{}: invalid parameter name", s)); // c:3882
8052                returnval = 1; // c:3883
8053                continue; // c:3884
8054            }
8055            None => (s.as_str(), None),
8056        };
8057        // c:3878 — `if (... || !isident(s))` invalid identifier check.
8058        if nm.is_empty()
8059            || !nm
8060                .chars()
8061                .next()
8062                .map_or(false, |c| c.is_alphabetic() || c == '_')
8063            || !nm.chars().all(|c| c.is_alphanumeric() || c == '_')
8064        {
8065            zwarnnam(name, &format!("{}: invalid parameter name", s)); // c:3882
8066            returnval = 1; // c:3883
8067            continue;
8068        }
8069        // c:3886-3905 — `if (!pm) continue;` then unset.
8070        // C `unsetparam_pm` dispatches on `pm->gsu` (the gsu_*
8071        // accessor for the param's type): assoc gets
8072        // `gsu_a->unset(pm, subscript)`, array gets
8073        // `gsu_arr->unset(pm, subscript)`, scalar gets `unsetparam`.
8074        match subscript {
8075            // c:3886
8076            Some(key) => {
8077                // c:Src/Modules/mapfile.c:126 unsetpmmapfile — unsetting
8078                // an element of the special $mapfile assoc UNLINKS the
8079                // file named by the key. It is not a regular assoc, so
8080                // route here before the generic assoc/array delete.
8081                if nm == "mapfile" {
8082                    crate::ported::modules::mapfile::unsetpmmapfile(key, false);
8083                    continue;
8084                }
8085                // c:3893 assoc subscript: `m[key]` delete.
8086                if let Some(mut map) = crate::ported::exec::assoc(nm) {
8087                    map.shift_remove(key); // c:3893
8088                    crate::ported::exec::set_assoc(nm, map);
8089                } else if let Some(mut arr) = crate::ported::exec::array(nm) {
8090                    // c:3895 — array subscript: `arr[N]` sets element
8091                    // N to empty; `arr[N,M]` clears the inclusive
8092                    // range. The previous Rust port only handled the
8093                    // single-index form; `unset arr[2,3]` was a no-op.
8094                    let len_i = arr.len() as i32;
8095                    let resolve = |raw: i32| -> Option<usize> {
8096                        let pos = if raw < 0 { len_i + raw + 1 } else { raw };
8097                        if pos >= 1 && pos as usize <= arr.len() {
8098                            Some((pos - 1) as usize)
8099                        } else {
8100                            None
8101                        }
8102                    };
8103                    if let Some((s, e)) = key.split_once(',') {
8104                        // c:Src/params.c — `unset arr[N,M]` replaces
8105                        // the N..M range with ONE empty element. For
8106                        // (a b c d), `unset arr[2,3]` → (a "" d).
8107                        // Negative bounds aren't supported by zsh's
8108                        // unset (it leaves the array unchanged), so
8109                        // restrict to positive forms.
8110                        let s_i = s.trim().parse::<i32>().ok();
8111                        let e_i = e.trim().parse::<i32>().ok();
8112                        if let (Some(start), Some(end)) = (s_i, e_i) {
8113                            if start >= 1 && end >= 1 {
8114                                let from = (start - 1) as usize;
8115                                let to = (end - 1) as usize;
8116                                if from < arr.len() && from <= to {
8117                                    let cap_to = to.min(arr.len() - 1);
8118                                    let mut new_arr: Vec<String> = Vec::with_capacity(arr.len());
8119                                    new_arr.extend(arr[..from].iter().cloned());
8120                                    new_arr.push(String::new());
8121                                    new_arr.extend(arr[cap_to + 1..].iter().cloned());
8122                                    crate::ported::exec::set_array(nm, new_arr);
8123                                }
8124                            }
8125                        }
8126                    } else if let Ok(i) = key.parse::<i32>() {
8127                        // c:Src/params.c — single-index unset only
8128                        // accepts positive subscripts and the special
8129                        // -1 form (clear last element). Other negative
8130                        // values are a no-op in zsh — verified
8131                        // empirically: `unset arr[-2]`, `arr[-3]` etc.
8132                        // leave the array unchanged.
8133                        // `unset arr[0]` is invalid (unless
8134                        // KSHZEROSUBSCRIPT) and emits a diagnostic.
8135                        if i == 0 {
8136                            zwarn(&format!("{}: assignment to invalid subscript range", nm));
8137                            returnval = 1;
8138                        } else if i == -1 {
8139                            if !arr.is_empty() {
8140                                let idx = arr.len() - 1;
8141                                arr[idx] = String::new();
8142                                crate::ported::exec::set_array(nm, arr);
8143                            }
8144                        } else if i > 0 {
8145                            if let Some(idx) = resolve(i) {
8146                                arr[idx] = String::new();
8147                                crate::ported::exec::set_array(nm, arr);
8148                            }
8149                        }
8150                        // Other negative values: no-op (zsh behavior).
8151                    }
8152                    // c:Src/params.c:2922 — a subscript unset of a tied
8153                    // colon-array (`unset path[2]`) re-derives the scalar
8154                    // side, just like the assignment paths in params.rs.
8155                    if let Some((_, scalar)) = crate::ported::params::TIED_COLON_ARRAYS
8156                        .iter()
8157                        .find(|(a, _)| *a == nm)
8158                    {
8159                        let cur = crate::ported::exec::array(nm).unwrap_or_default();
8160                        crate::ported::params::assignsparam(scalar, &cur.join(":"), 0);
8161                    }
8162                }
8163            }
8164            None => {
8165                // c:3939-3951 — nameref handling. Without -n the ref
8166                // resolves and the TARGET is unset; refs bound to an
8167                // outer scope are just marked unset (kept in table,
8168                // PM_DECLARED). With -n the ref itself is removed.
8169                let mut nm: &str = nm;
8170                let mut resolved_target: Option<String> = None; // non-ref target to unset
8171                let mut ref_removal: Option<String> = None; // ref entry to remove
8172                let mut handled = false;
8173                if crate::ported::params::is_nameref(nm) {
8174                    if OPT_ISSET(ops, b'n') {
8175                        ref_removal = Some(nm.to_string()); // unset -n: the ref itself
8176                    } else {
8177                        use crate::ported::params::nameref_resolution;
8178                        match crate::ported::params::resolve_nameref_name(nm, None) {
8179                            // c:3942-3943 — `if (!(pm = resolve_nameref(pm))) continue;`
8180                            nameref_resolution::SelfRef | nameref_resolution::OutOfScope => {
8181                                continue
8182                            }
8183                            nameref_resolution::Placeholder(last) => {
8184                                // chain ends at a ref → that ref is the
8185                                // unset object (resolve_nameref returns it).
8186                                ref_removal = Some(last);
8187                            }
8188                            nameref_resolution::Target {
8189                                name: t,
8190                                subscript,
8191                                pm,
8192                                level,
8193                            } => {
8194                                if subscript.is_some() {
8195                                    // c:3897 — subscripted ref: resolve_nameref
8196                                    // early-exits (pm->width); the REF itself
8197                                    // is the unset object.
8198                                    ref_removal = Some(nm.to_string());
8199                                } else if pm.is_none() {
8200                                    // dangling — nothing to unset (c:3942).
8201                                    continue;
8202                                } else {
8203                                    let cur_ll = locallevel.load(Relaxed) as i32;
8204                                    let ro = pm
8205                                        .as_ref()
8206                                        .map(|p| (p.node.flags as u32 & PM_READONLY) != 0)
8207                                        .unwrap_or(false);
8208                                    if level < cur_ll && !ro {
8209                                        // c:3944-3949 — mark unset, keep in
8210                                        // table (stdunsetfn + PM_DECLARED).
8211                                        if let Ok(mut tab) = paramtab().write() {
8212                                            if let Some(p) = tab.get_mut(&t) {
8213                                                p.node.flags |= (PM_UNSET | PM_DECLARED) as i32;
8214                                                p.u_str = None;
8215                                                p.u_arr = None;
8216                                                p.u_val = 0;
8217                                            }
8218                                        }
8219                                        let _ = crate::ported::params::paramtab_hashed_storage()
8220                                            .lock()
8221                                            .ok()
8222                                            .as_deref_mut()
8223                                            .map(|m| m.remove(&t));
8224                                        handled = true;
8225                                    } else {
8226                                        resolved_target = Some(t);
8227                                    }
8228                                }
8229                            }
8230                            nameref_resolution::NotRef => {}
8231                        }
8232                    }
8233                    if handled {
8234                        continue;
8235                    }
8236                    if let Some(refnam) = ref_removal {
8237                        // unsetparam() deliberately skips namerefs, so
8238                        // do the table surgery here with the readonly
8239                        // guard (c:3850 via unsetparam_pm).
8240                        let ro = paramtab()
8241                            .read()
8242                            .ok()
8243                            .and_then(|t| {
8244                                t.get(&refnam)
8245                                    .map(|p| (p.node.flags as u32 & PM_READONLY) != 0)
8246                            })
8247                            .unwrap_or(false);
8248                        if ro {
8249                            zerr(&format!("read-only variable: {}", refnam)); // c:3850
8250                            returnval = 1;
8251                            continue;
8252                        }
8253                        // c:3911-3913 — a LOCAL ref is kept in the
8254                        // table marked PM_UNSET (scope-end removes it);
8255                        // only globals/level-0 refs drop out entirely.
8256                        let cur_ll = locallevel.load(Relaxed) as i32;
8257                        let keep_local = paramtab()
8258                            .read()
8259                            .ok()
8260                            .and_then(|t| t.get(&refnam).map(|p| p.level > 0 && cur_ll >= p.level))
8261                            .unwrap_or(false);
8262                        if keep_local {
8263                            if let Ok(mut tab) = paramtab().write() {
8264                                if let Some(p) = tab.get_mut(&refnam) {
8265                                    // c:3858 + c:3870 stdunsetfn
8266                                    p.node.flags &= !(PM_DECLARED as i32);
8267                                    p.node.flags |= PM_UNSET as i32;
8268                                    p.u_str = None;
8269                                    p.base = 0;
8270                                    p.width = 0;
8271                                }
8272                            }
8273                        } else if let Some(mut pm) =
8274                            paramtab().write().ok().and_then(|mut t| t.remove(&refnam))
8275                        {
8276                            if let Some(prev) = pm.old.take() {
8277                                if let Ok(mut tab) = paramtab().write() {
8278                                    tab.insert(refnam.clone(), prev);
8279                                }
8280                            }
8281                        }
8282                        continue;
8283                    }
8284                }
8285                let target_buf;
8286                if let Some(t) = resolved_target {
8287                    target_buf = t;
8288                    nm = &target_buf;
8289                }
8290                // c:3900-3905 — whole-param unset.
8291                // Route through `unsetparam` (params.rs) so the
8292                // canonical readonly-guard + pm.old uncover restore
8293                // fires. Without this, `local x=inner; unset x`
8294                // would erase the OUTER binding too (the local pm's
8295                // pm.old chain dropped on the floor).
8296                //
8297                // Clear the parallel shadow storage that lives in
8298                // ShellExecutor (paramtab_hashed_storage for assoc,
8299                // and the per-executor arrays/assocs maps). These
8300                // are NOT touched by params.rs::unsetparam so we
8301                // wipe them directly here; using crate::ported::exec::unset_*
8302                // would loop back into unsetparam.
8303                // c:Src/builtin.c:3952-3953 — `if (unsetparam_pm(pm,
8304                // 0, 1)) returnval = 1;` (readonly rejection). On
8305                // rejection the param is untouched, so the shadow-
8306                // storage wipe and env delenv (which unsetparam_pm
8307                // only runs on its success path, c:Src/params.c:3872)
8308                // must not fire either.
8309                if unsetparam(nm) != 0 {
8310                    returnval = 1; // c:3953
8311                } else {
8312                    let _ = crate::ported::params::paramtab_hashed_storage()
8313                        .lock()
8314                        .ok()
8315                        .as_deref_mut()
8316                        .map(|m| m.remove(nm));
8317                    env::remove_var(nm); // c:3905 delenv
8318                }
8319            }
8320        }
8321    }
8322    unqueue_signals(); // c:3914
8323    returnval // c:3915
8324}
8325
8326/// Port of `fetchcmdnamnode(HashNode hn, UNUSED(int printflags))` from Src/builtin.c:3967.
8327/// C: `static void fetchcmdnamnode(HashNode hn, UNUSED(int printflags))` →
8328///   `addlinknode(matchednodes, cn->node.nam);`
8329/// C body (2 lines):
8330///   `Cmdnam cn = (Cmdnam) hn;
8331///    addlinknode(matchednodes, cn->node.nam);`
8332/// (C source does not null-check hn — callers guarantee non-null.)
8333/// WARNING: param names don't match C — Rust=(hn) vs C=(hn, printflags)
8334pub fn fetchcmdnamnode(
8335    hn: *mut hashnode, // c:3967
8336    _printflags: i32,
8337) {
8338    let nam = unsafe { (*hn).nam.clone() }; // c:3969 cast + read
8339    if let Ok(mut m) = MATCHEDNODES.lock() {
8340        m.push(nam);
8341    } // c:3971
8342}
8343
8344/// Port of `bin_whence(char *nam, char **argv, Options ops, int func)` from Src/builtin.c:3975.
8345/// C: `int bin_whence(char *nam, char **argv, Options ops, int func)`.
8346///
8347/// `whence`/`type`/`which`/`where`/`command` dispatcher. `-c` csh,
8348/// `-v` verbose, `-a` all-matches, `-w` word-form, `-x` indent
8349/// override, `-m` glob-args, `-p` path-only, `-f` print funcdef,
8350/// `-s/-S` follow symlink. The C body walks alias/reswd/shfunc/
8351/// builtin/cmdnam tabs in order; this port preserves the structure
8352/// and dispatch logic, deferring the per-tab scanmatch walks to the
8353/// existing tab accessors.
8354/// WARNING: param names don't match C — Rust=(nam, argv, func) vs C=(nam, argv, ops, func)
8355pub fn bin_whence(
8356    nam: &str,
8357    argv: &[String], // c:3975
8358    ops: &options,
8359    func: i32,
8360) -> i32 {
8361    let mut returnval: i32 = 0;
8362    let mut printflags: i32 = 0;
8363    let mut informed: i32 = 0;
8364    let mut expand: i32 = 0;
8365
8366    // c:3989-3993 — flags.
8367    let csh = OPT_ISSET(ops, b'c'); // c:3989
8368    let v = OPT_ISSET(ops, b'v'); // c:3990
8369    let all = OPT_ISSET(ops, b'a'); // c:3991
8370    let wd = OPT_ISSET(ops, b'w'); // c:3992
8371
8372    // c:3995-4002 — `-x N` indent override.
8373    if OPT_ISSET(ops, b'x') {
8374        // c:3995
8375        let arg = OPT_ARG(ops, b'x').unwrap_or("");
8376        match arg.trim().parse::<i32>() {
8377            // c:3997
8378            Ok(n) => {
8379                expand = n;
8380                if expand == 0 {
8381                    expand = -1;
8382                } // c:4001
8383            }
8384            Err(_) => {
8385                zwarnnam(nam, "number expected after -x"); // c:3998
8386                return 1;
8387            }
8388        }
8389    }
8390
8391    // c:4004-4012 — printflags from -w/-c/-v/(default simple)/-f.
8392    if OPT_ISSET(ops, b'w') {
8393        printflags |= PRINT_WHENCE_WORD;
8394    }
8395    // c:4004
8396    else if OPT_ISSET(ops, b'c') {
8397        printflags |= PRINT_WHENCE_CSH;
8398    }
8399    // c:4006
8400    else if OPT_ISSET(ops, b'v') {
8401        printflags |= PRINT_WHENCE_VERBOSE;
8402    }
8403    // c:4008
8404    else {
8405        printflags |= PRINT_WHENCE_SIMPLE;
8406    } // c:4010
8407    if OPT_ISSET(ops, b'f') {
8408        printflags |= PRINT_WHENCE_FUNCDEF;
8409    } // c:4012
8410
8411    // c:4015-4024 — BIN_COMMAND -V or -V-equivalent flag wrangling.
8412    // C body:
8413    //   if (func == BIN_COMMAND)
8414    //       if (OPT_ISSET(ops,'V')) { printflags = aliasflags = PRINT_WHENCE_VERBOSE; v = 1; }
8415    //       else { aliasflags = PRINT_LIST; printflags = PRINT_WHENCE_SIMPLE; v = 0; }
8416    //   else aliasflags = printflags;
8417    // Previous Rust port omitted the `v = 0` reset in the non-V
8418    // command branch, so `command foo` with a stray user -v leaked
8419    // verbose mode. Mirror C: force v unconditionally under
8420    // BIN_COMMAND.
8421    let mut v = v;
8422    let aliasflags = if func == BIN_COMMAND {
8423        // c:4015
8424        if OPT_ISSET(ops, b'V') {
8425            // c:4016
8426            printflags = PRINT_WHENCE_VERBOSE; // c:4017
8427            v = true; // c:4018
8428            PRINT_WHENCE_VERBOSE
8429        } else {
8430            printflags = PRINT_WHENCE_SIMPLE; // c:4021
8431            v = false; // c:4022
8432            PRINT_LIST // c:4020
8433        }
8434    } else {
8435        printflags // c:4024
8436    };
8437
8438    // c:4026-4119 — `-m` glob branch: each arg is a pattern; walk every
8439    // hashtab in turn (alias/reswd/shfunc/builtin/cmdnam) and emit a
8440    // print row per matching node. C uses scanmatchtable + a per-tab
8441    // print callback; the Rust port iterates each tab's accessor and
8442    // emits the print directly.
8443    if OPT_ISSET(ops, b'm') {
8444        // c:4028 — `cmdnamtab->filltable(cmdnamtab);` populates every
8445        // $PATH entry into cmdnamtab so the c:4070 scan below sees
8446        // every executable (not just hashed ones). C calls this once
8447        // per `-m` invocation; Rust mirrors with a single fillcmdnamtable
8448        // against the shell-side $PATH array.
8449        if let Some(path) = getsparam("PATH") {
8450            let path_arr: Vec<String> = path.split(':').map(|s| s.to_string()).collect();
8451            fillcmdnamtable(&path_arr);
8452        }
8453        // c:4030-4033 — `if (all) { pushheap(); matchednodes = newlinklist(); }`.
8454        // MATCHEDNODES is the Rust analog of `matchednodes`; pushheap
8455        // is a Rust no-op (no heap allocator).
8456        if all {
8457            // c:4030
8458            if let Ok(mut m) = MATCHEDNODES.lock() {
8459                m.clear();
8460            }
8461        }
8462        queue_signals(); // c:4034
8463        for pat in argv {
8464            // c:4035
8465            // c:4037 — `tokenize(*argv);` (Rust patcompile handles the
8466            // tokenize step internally; explicit call is a no-op here).
8467            let pprog = patcompile(
8468                &{
8469                    let mut __pat_tok = (pat).to_string();
8470                    crate::ported::glob::tokenize(&mut __pat_tok);
8471                    __pat_tok
8472                }, // c:4038
8473                PAT_HEAPDUP,
8474                None,
8475            );
8476            match pprog {
8477                None => {
8478                    // c:4039
8479                    zwarnnam(nam, &format!("bad pattern : {}", pat)); // c:4040
8480                    returnval = 1; // c:4041
8481                    continue;
8482                }
8483                Some(prog) => {
8484                    if !OPT_ISSET(ops, b'p') {
8485                        // c:4044 — !`-p` path-only.
8486                        // c:4049-4051 — `scanmatchtable(aliastab, pprog,
8487                        //   1, 0, DISABLED, aliastab->printnode, printflags);`.
8488                        // Route through the canonical printnode callback.
8489                        let alias_matches: Vec<alias> = aliastab_lock()
8490                            .read()
8491                            .map(|t| {
8492                                t.iter()
8493                                    .filter(|(n, _)| pattry(&prog, n))
8494                                    .map(|(_, a)| a.clone())
8495                                    .collect()
8496                            })
8497                            .unwrap_or_default();
8498                        for a in &alias_matches {
8499                            printaliasnode(a, printflags); // c:4051
8500                            informed += 1; // c:4049
8501                        }
8502                        // c:4054-4056 — `scanmatchtable(reswdtab, pprog,
8503                        //   1, 0, DISABLED, reswdtab->printnode, printflags);`.
8504                        // reswdtab->printnode is `printreswdnode` at
8505                        // Src/hashtable.c:1259 — its body is just
8506                        // `zputs(hn->nam); putchar('\n')`. Inline the
8507                        // print since no separate Rust callback yet
8508                        // exists and the body is trivial.
8509                        // Reserved words: collect matches + sort to
8510                        // match zsh's scanmatchtable order (same logic
8511                        // as the builtin walk below).
8512                        let mut names: Vec<String> = reswdtab_lock()
8513                            .read()
8514                            .map(|t| {
8515                                t.iter()
8516                                    .filter(|(k, _)| pattry(&prog, k))
8517                                    .map(|(k, _)| k.clone())
8518                                    .collect()
8519                            })
8520                            .unwrap_or_default();
8521                        names.sort();
8522                        for w in &names {
8523                            println!("{}", w); // c:1259 zputs + newline
8524                            informed += 1; // c:4054
8525                        }
8526                        // c:4059-4061 — `scanmatchshfunc(pprog, 1, 0,
8527                        //   DISABLED, shfunctab->printnode, printflags,
8528                        //   expand);`. Route through canonical
8529                        // printshfuncexpand with `expand`.
8530                        let func_matches: Vec<shfunc> = shfunctab_lock()
8531                            .read()
8532                            .map(|t| {
8533                                t.iter()
8534                                    .filter(|(n, _)| pattry(&prog, n))
8535                                    .map(|(_, f)| f.clone())
8536                                    .collect()
8537                            })
8538                            .unwrap_or_default();
8539                        for f in &func_matches {
8540                            printshfuncexpand(f, printflags, expand); // c:4061
8541                            informed += 1; // c:4059
8542                        }
8543                        // c:4064-4066 — `scanmatchtable(builtintab, pprog,
8544                        //   1, 0, DISABLED, builtintab->printnode,
8545                        //   printflags);`. C's scanmatchtable walks the
8546                        // hashtable in a deterministic order that, for
8547                        // the matched-against name set, happens to be
8548                        // sorted alphabetically — that's what makes
8549                        // `whence -m "echo*"` emit echo, echotc,
8550                        // echoti. zshrs's BUILTINS Vec is in
8551                        // declaration order (echo, echoti, echotc) so
8552                        // the unsorted walk produced a different
8553                        // ordering. Collect+sort to match.
8554                        let mut bn_matches: Vec<&builtin> = BUILTINS
8555                            .iter()
8556                            .chain(crate::extensions::ext_builtins::extension_builtin_defs())
8557                            .filter(|b| pattry(&prog, &b.node.nam))
8558                            .collect();
8559                        bn_matches.sort_by(|a, b| a.node.nam.cmp(&b.node.nam));
8560                        for b in bn_matches {
8561                            printbuiltinnode(
8562                                &b.node as *const hashnode as *mut hashnode,
8563                                printflags,
8564                            ); // c:4066
8565                            informed += 1; // c:4064
8566                        }
8567                    }
8568                    // c:4070-4073 — `scanmatchtable(cmdnamtab, pprog,
8569                    //   1, 0, 0, (all ? fetchcmdnamnode :
8570                    //   cmdnamtab->printnode), printflags);`. After
8571                    // fillcmdnamtable above, cmdnamtab has every
8572                    // PATH-resident command name. Walk the canonical
8573                    // table (not std::fs::read_dir) so HASHED/non-
8574                    // HASHED distinction is preserved.
8575                    // External cmd-name matches: sort by full path
8576                    // (the same printed token) to match C zsh's
8577                    // scanmatchtable iteration order. The hashtable
8578                    // walk in zsh on /bin/echo + /opt/homebrew/bin/
8579                    // ecpg yields /bin/echo first because `/bin` <
8580                    // `/opt` lexicographically. zshrs's HashMap walks
8581                    // in arbitrary order without an explicit sort.
8582                    let mut cmd_matches: Vec<(String, cmdnam)> = cmdnamtab_lock()
8583                        .read()
8584                        .map(|t| {
8585                            t.iter()
8586                                .filter(|(n, _)| pattry(&prog, n))
8587                                .map(|(n, c)| (n.clone(), c.clone()))
8588                                .collect()
8589                        })
8590                        .unwrap_or_default();
8591                    // printcmdnamnode emits c.value.string (the full
8592                    // path) so sort on that to match the printed
8593                    // order, not the bare basename `n`.
8594                    // printcmdnamnode emits one of:
8595                    //   - HASHED entry: cmd (the full path)
8596                    //   - non-HASHED entry: name[0] + "/" + nam
8597                    // Sort by the same string the print path will
8598                    // emit so the output order matches zsh's
8599                    // scanmatchtable walk.
8600                    let printed_path = |c: &cmdnam| -> String {
8601                        if (c.node.flags & HASHED as i32) != 0 {
8602                            c.cmd.clone().unwrap_or_default()
8603                        } else {
8604                            let dir = c
8605                                .name
8606                                .as_ref()
8607                                .and_then(|v| v.first())
8608                                .cloned()
8609                                .unwrap_or_default();
8610                            format!("{}/{}", dir, c.node.nam)
8611                        }
8612                    };
8613                    cmd_matches.sort_by(|a, b| printed_path(&a.1).cmp(&printed_path(&b.1)));
8614                    for (n, c) in &cmd_matches {
8615                        if all {
8616                            // c:4072 fetchcmdnamnode — accumulates
8617                            // matching node names into matchednodes.
8618                            if let Ok(mut m) = MATCHEDNODES.lock() {
8619                                m.push(n.clone());
8620                            }
8621                        } else {
8622                            // c:4072 cmdnamtab->printnode — emits per
8623                            // PRINT_WHENCE_WORD/CSH/VERBOSE branches.
8624                            printcmdnamnode(c, printflags);
8625                        }
8626                        informed += 1; // c:4070
8627                    }
8628                }
8629            }
8630            run_queued_signals(); // c:4079
8631        }
8632        unqueue_signals(); // c:4081
8633        if !all {
8634            // c:4082-4084 — `return returnval || !informed;` (early-out
8635            // when not in `-a` accumulator mode).
8636            return if returnval != 0 || informed == 0 {
8637                1
8638            } else {
8639                0
8640            }; // c:4082
8641        }
8642    }
8643
8644    // c:4121-4205 — literal-name dispatch per arg.
8645    queue_signals();
8646    // C source uses MATCHEDNODES only when `-m` (glob-args) is set;
8647    // plain `-a` keeps the literal argv. Without this gate, `whence
8648    // -a true` consulted an empty MATCHEDNODES and skipped every
8649    // print.
8650    let argv_vec: Vec<String> = if OPT_ISSET(ops, b'm') {
8651        MATCHEDNODES.lock().map(|m| m.clone()).unwrap_or_default()
8652    } else {
8653        argv.to_vec()
8654    };
8655    for arg in &argv_vec {
8656        // c:4121
8657        // c:4088 — `informed = 0;` reset per iteration so the per-arg
8658        // not-found path can fire correctly.
8659        informed = 0; // c:4088
8660                      // c:4090 `char *cnam` is the findcmd return in C; in Rust it
8661                      // is bound inline at the findcmd call site below.
8662                      // c:4089-4137 — !`-p` and !`-a` matched-from-prior-`-m` arm.
8663        if !OPT_ISSET(ops, b'p') {
8664            // c:4093-4097 — alias check. C: `aliastab->printnode(hn, aliasflags)`.
8665            let alias_text = aliastab_lock()
8666                .read()
8667                .ok()
8668                .and_then(|t| t.get(arg).map(|a| a.clone()));
8669            if let Some(a) = alias_text {
8670                printaliasnode(&a, aliasflags); // c:4094
8671                informed = 1; // c:4095
8672                if !all {
8673                    continue;
8674                } // c:4097
8675            }
8676            // c:4099 /* Look for suffix alias */
8677            // c:4100-4102 — `if ((suf = strrchr(*argv, '.')) && suf[1] &&
8678            //   suf > *argv && suf[-1] != Meta &&
8679            //   (hn = sufaliastab->getnode(sufaliastab, suf+1)))`.
8680            // The order is load-bearing: C short-circuits, so `suf[-1]`
8681            // (the byte BEFORE `.`) is read only after `suf > *argv`
8682            // confirms `.` is not at position 0.
8683            if let Some(idx) = arg.rfind('.') {
8684                // c:4100 — suf = strrchr(*argv, '.'); suf[1] (char after `.`
8685                // is non-NUL) AND c:4101 suf > *argv (`.` not at start)
8686                // AND suf[-1] != Meta (char before `.` is not Meta-escape).
8687                // Chained into one `if` so Rust short-circuits identically
8688                // to C; reading `arg.as_bytes()[idx - 1]` eagerly with
8689                // idx==0 panics with subtract-overflow.
8690                if idx + 1 < arg.len() && idx > 0 && arg.as_bytes()[idx - 1] as u8 != Meta {
8691                    let suf = &arg[idx + 1..]; // c:4102 suf+1
8692                    let suf_alias = sufaliastab_lock()
8693                        .read()
8694                        .ok()
8695                        .and_then(|t| t.get(suf).map(|a| a.clone()));
8696                    if let Some(a) = suf_alias {
8697                        printaliasnode(&a, printflags); // c:4103
8698                        informed = 1; // c:4104
8699                        if !all {
8700                            continue;
8701                        } // c:4106
8702                    }
8703                }
8704            }
8705            // c:4109-4114 — `if ((hn = reswdtab->getnode(reswdtab, *argv)))
8706            //   reswdtab->printnode(hn, printflags);`. Reads canonical
8707            // reswdtab instead of a drift-prone literal array.
8708            let is_reswd = reswdtab_lock()
8709                .read()
8710                .map(|t| t.get(arg).is_some())
8711                .unwrap_or(false);
8712            if is_reswd {
8713                // c:4109
8714                if (printflags & PRINT_WHENCE_WORD as i32) != 0 {
8715                    println!("{}: reserved", arg);
8716                } else if (printflags & PRINT_WHENCE_CSH as i32) != 0 {
8717                    println!("{}: shell reserved word", arg);
8718                } else if (printflags & PRINT_WHENCE_VERBOSE as i32) != 0 {
8719                    println!("{} is a reserved word", arg);
8720                } else {
8721                    println!("{}", arg); // c:4110
8722                }
8723                informed = 1; // c:4111
8724                if !all {
8725                    continue;
8726                } // c:4112
8727            }
8728            // c:4116-4121 — shell function check. C:
8729            //   `printshfuncexpand(hn, printflags, expand)`.
8730            // Inline match-on-printflags reimplementation deleted —
8731            // route through the canonical port at hashtable.rs:1407,
8732            // which threads `expand` (for `-x N` indent override) and
8733            // handles PRINT_WHENCE_FUNCDEF/PRINT_WHENCE_WORD/_CSH/
8734            // _VERBOSE branches per Src/hashtable.c:1340-1404.
8735            let shfunc_node = getshfunc(arg);
8736            if let Some(ref f) = shfunc_node {
8737                printshfuncexpand(f, printflags, expand); // c:4117
8738                informed = 1; // c:4118
8739                if !all {
8740                    continue;
8741                } // c:4120
8742            }
8743            // c:4123-4128 — builtin check. C: `builtintab->printnode(
8744            //   hn, printflags)` → printbuiltinnode at Src/builtin.c:174.
8745            // Inline match-on-(wd|csh|v) reimplementation deleted —
8746            // route through the canonical port at builtin.rs:139.
8747            // c:Src/Modules/files.c:806-824 — bare `mkdir`/`rm`/`mv`/`ln`/
8748            // `chmod`/`chown`/`chgrp`/`sync`/`rmdir` and their `zf_*`
8749            // aliases are bound by `zsh/files`. Without explicit
8750            // `zmodload zsh/files`, `type rm` reports `/bin/rm` (the
8751            // PATH lookup result) — the builtin name shouldn't appear
8752            // in builtintab at all per C's lazy paramtab/builtintab
8753            // wiring. Skip the lookup here when the module is unloaded
8754            // so the search falls through to the cmdnamtab/$PATH path
8755            // below. Bug #28 in docs/BUGS.md.
8756            let is_files_gated = matches!(
8757                arg.as_str(),
8758                "mkdir"
8759                    | "rmdir"
8760                    | "rm"
8761                    | "mv"
8762                    | "ln"
8763                    | "chmod"
8764                    | "chown"
8765                    | "chgrp"
8766                    | "sync"
8767                    | "zf_mkdir"
8768                    | "zf_rmdir"
8769                    | "zf_rm"
8770                    | "zf_mv"
8771                    | "zf_ln"
8772                    | "zf_chmod"
8773                    | "zf_chown"
8774                    | "zf_chgrp"
8775                    | "zf_sync"
8776            ) && !crate::ported::module::MODULESTAB
8777                .lock()
8778                .unwrap()
8779                .is_loaded("zsh/files");
8780            // c:Bug #532/#535 — same logic for module-bound builtin
8781            // names that ship statically linked in zshrs but require
8782            // an explicit `zmodload` before `type X` reports them as
8783            // a builtin in C zsh:
8784            //   * zsh/stat       → stat, zstat
8785            //   * zsh/zselect    → zselect
8786            //   * zsh/zpty       → zpty
8787            //   * zsh/net/tcp    → ztcp
8788            //   * zsh/zftp       → zftp
8789            //   * zsh/system     → zsystem, syserror
8790            let is_module_gated = |modname: &str, names: &[&str]| -> bool {
8791                names.iter().any(|n| arg.as_str() == *n)
8792                    && !crate::ported::module::MODULESTAB
8793                        .lock()
8794                        .unwrap()
8795                        .is_loaded(modname)
8796            };
8797            let is_stat_gated = is_module_gated("zsh/stat", &["stat", "zstat"]);
8798            let is_zselect_gated = is_module_gated("zsh/zselect", &["zselect"]);
8799            let is_zpty_gated = is_module_gated("zsh/zpty", &["zpty"]);
8800            let is_ztcp_gated = is_module_gated("zsh/net/tcp", &["ztcp"]);
8801            let is_zftp_gated = is_module_gated("zsh/zftp", &["zftp"]);
8802            let is_system_gated = is_module_gated("zsh/system", &["zsystem", "syserror"]);
8803            let is_module_bound_gated = is_stat_gated
8804                || is_zselect_gated
8805                || is_zpty_gated
8806                || is_ztcp_gated
8807                || is_zftp_gated
8808                || is_system_gated;
8809            // c:Src/builtin.c:4123 — `builtintab->getnode(name)` returns NULL
8810            // for a builtin carrying the DISABLED flag (set by `disable NAME`),
8811            // so whence/type do NOT report a disabled builtin as a builtin and
8812            // instead fall through to the hashed/PATH external command.
8813            let is_disabled_builtin = BUILTINS_DISABLED
8814                .lock()
8815                .map(|s| s.contains(arg.as_str()))
8816                .unwrap_or(false);
8817            let builtin_node: Option<*mut hashnode> =
8818                if is_files_gated || is_module_bound_gated || is_disabled_builtin {
8819                    None
8820                } else {
8821                    BUILTINS
8822                        .iter()
8823                        .find(|b| b.node.nam == *arg)
8824                        .map(|b| &b.node as *const hashnode as *mut hashnode)
8825                };
8826            if let Some(hn) = builtin_node {
8827                printbuiltinnode(hn, printflags); // c:4124
8828                informed = 1; // c:4125
8829                if !all {
8830                    continue;
8831                } // c:4127
8832            }
8833            // zshrs extension builtins (daemon z* family: zd, zcache,
8834            // zjob, …) dispatch by name via try_dispatch instead of
8835            // living in BUILTINS — but they ARE builtins and whence/
8836            // type must classify them as such (`whence -w zd` reported
8837            // the external /opt/homebrew/bin/zd instead).
8838            if builtin_node.is_none() && crate::daemon::builtins::is_zshrs_builtin(arg) {
8839                let mut ext_node = hashnode {
8840                    next: None,
8841                    nam: arg.clone(),
8842                    flags: 0,
8843                };
8844                printbuiltinnode(&mut ext_node as *mut hashnode, printflags);
8845                informed = 1;
8846                if !all {
8847                    continue;
8848                }
8849            }
8850            // zshrs extension builtins folded into builtintab (znative package
8851            // manager, ztest/zassert framework, watch) — first-class builtins
8852            // absent from the static BUILTINS table; classify like the daemon
8853            // z* family above so `whence -w zassert_eq` reports `builtin`.
8854            if builtin_node.is_none()
8855                && !crate::daemon::builtins::is_zshrs_builtin(arg)
8856                && crate::extensions::ext_builtins::is_extension_builtin(arg)
8857            {
8858                let mut ext_node = hashnode {
8859                    next: None,
8860                    nam: arg.clone(),
8861                    flags: 0,
8862                };
8863                printbuiltinnode(&mut ext_node as *mut hashnode, printflags);
8864                informed = 1;
8865                if !all {
8866                    continue;
8867                }
8868            }
8869            // c:4167-4173 — cmdnamtab HASHED check (commands installed
8870            // via `hash NAME=PATH`). Read the canonical cmdnamtab
8871            // directly. Was a fake env-var bridge under invented
8872            // `__zshrs_hash_NAME` keys; cmdnamtab is bucket-2-
8873            // consolidated now.
8874            let hashed_path: Option<String> = {
8875                match cmdnamtab_lock().read() {
8876                    Ok(tab) => tab.get(arg).and_then(|cn| {
8877                        if (cn.node.flags & HASHED as i32) != 0 {
8878                            cn.cmd.clone() // c:4168 cn->u.cmd
8879                        } else {
8880                            None
8881                        }
8882                    }),
8883                    Err(_) => None,
8884                }
8885            };
8886            if let Some(p) = hashed_path {
8887                if (printflags & PRINT_LIST) != 0 {
8888                    println!("hash {}={}", arg, p);
8889                } else {
8890                    println!("{}", p);
8891                }
8892                informed = 1; // c:4170
8893                if !all {
8894                    continue;
8895                } // c:4171
8896            }
8897        }
8898        // c:4141-4172 — `-a` all-paths search. C iterates the
8899        // shell-side `path` array (the tied $path/$PATH global,
8900        // Src/parse.c). Rust reads $PATH via getsparam — same source.
8901        if all && !arg.starts_with('/') {
8902            // c:4141
8903            if let Some(path) = getsparam("PATH") {
8904                for dir in path.split(':') {
8905                    // c:4145 — `if (**pp) buf = zhtricat(*pp, "/", *argv);
8906                    //              else      buf = dupstring(*argv);`.
8907                    // Empty path entry means CWD per POSIX, but C still
8908                    // joins with "/" if non-empty; Rust matches the
8909                    // !empty arm.
8910                    if dir.is_empty() {
8911                        continue;
8912                    }
8913                    let full = format!("{}/{}", dir, arg); // c:4147
8914                                                           // c:4150 — `iscom(buf)`: access(X_OK)==0 &&
8915                                                           // S_ISREG(stat). Was `Path::is_file()` which omits
8916                                                           // the X_OK check — would have flagged non-executable
8917                                                           // files as matches.
8918                    if iscom(&full) {
8919                        // c:4150
8920                        if wd {
8921                            // c:4151
8922                            println!("{}: command", arg); // c:4152
8923                        } else {
8924                            if v && !csh {
8925                                // c:4154
8926                                print!("{} is ", arg); // c:4155
8927                                print!("{}", quotedzputs(&full)); // c:4156
8928                            } else {
8929                                print!("{}", full); // c:4158
8930                            }
8931                            // c:4159-4160 — `if (OPT_ISSET(ops,'s') ||
8932                            //   OPT_ISSET(ops,'S')) print_if_link(buf,
8933                            //   OPT_ISSET(ops,'S'));`. -s prints just
8934                            // the final realpath; -S prints the whole
8935                            // chain.
8936                            if OPT_ISSET(ops, b's') || OPT_ISSET(ops, b'S') {
8937                                print_if_link(&full, OPT_ISSET(ops, b'S')); // c:4160
8938                            }
8939                            println!(); // c:4161 fputc('\n', stdout)
8940                        }
8941                        informed = 1; // c:4163
8942                    } else {
8943                    }
8944                }
8945            }
8946            // c:4166-4171 — `if (!informed && (wd || v || csh))`. C:
8947            //     zputs(*argv, stdout); puts(wd ? ": none" : " not found");
8948            // Was `if !informed != 0 && ...` which is broken Rust — the
8949            // `!` is bitwise NOT on the i32, so the condition was true
8950            // when informed != 0 (inverted). Fix: explicit `informed == 0`.
8951            if informed == 0 && (wd || v || csh) {
8952                // c:4166
8953                println!("{}{}", arg, if wd { ": none" } else { " not found" }); // c:4168-4169
8954                returnval = 1; // c:4170
8955            }
8956            continue;
8957        }
8958        // c:4200-4203 — `-p` BIN_COMMAND special case: builtin first.
8959        if func == BIN_COMMAND && OPT_ISSET(ops, b'p') {
8960            // c:4200
8961            if BUILTINS.iter().any(|b| b.node.nam == *arg) {
8962                // c:4201
8963                println!("{}: builtin", arg); // c:4202
8964                informed = 1;
8965                continue;
8966            }
8967        }
8968        // c:4181-4197 — external-command fallback via findcmd.
8969        // C: `if ((cnam = findcmd(*argv, 1, func == BIN_COMMAND &&
8970        //   OPT_ISSET(ops, 'p'))))`. Single call site — the previous
8971        // Rust port had two near-duplicate findcmd blocks which
8972        // doubled the lookup. Collapsed into one to match C.
8973        if let Some(cnam) = findcmd(
8974            arg,
8975            1,                                                    // c:4181 docmd
8976            (func == BIN_COMMAND && OPT_ISSET(ops, b'p')) as i32, // c:4182-4183
8977        ) {
8978            // c:4181
8979            if wd {
8980                // c:4184
8981                println!("{}: command", arg); // c:4186
8982            } else {
8983                if v && !csh {
8984                    // c:4188
8985                    print!("{} is ", arg); // c:4189
8986                    print!("{}", quotedzputs(&cnam)); // c:4190
8987                } else {
8988                    print!("{}", cnam); // c:4192
8989                }
8990                // c:4193-4194 — `-s`/`-S` symlink follow.
8991                if OPT_ISSET(ops, b's') || OPT_ISSET(ops, b'S') {
8992                    print_if_link(&cnam, OPT_ISSET(ops, b'S')); // c:4194
8993                }
8994                println!(); // c:4195 fputc('\n', stdout)
8995            }
8996            informed = 1; // c:4197
8997            continue;
8998        }
8999        // c:4201-4205 — not found at all.
9000        if v || csh || wd {
9001            // c:4202
9002            println!("{}{}", arg, if wd { ": none" } else { " not found" }); // c:4203
9003        }
9004        returnval = 1; // c:4204
9005    }
9006    unqueue_signals();
9007    returnval | (informed == 0) as i32 // c:4209
9008}
9009
9010/// Port of `bin_hash(char *name, char **argv, Options ops, UNUSED(int func))` from Src/builtin.c:4234.
9011/// C: `int bin_hash(char *name, char **argv, Options ops, ...)` —
9012///   manage `cmdnamtab` (default) or `nameddirtab` (`-d`); `-r` empties,
9013///   `-f` fills, `-L` sets PRINT_LIST, `-m` is a glob.
9014/// WARNING: param names don't match C — Rust=(name, argv, _func) vs C=(name, argv, ops, func)
9015pub fn bin_hash(
9016    name: &str,
9017    argv: &[String], // c:4234
9018    ops: &options,
9019    _func: i32,
9020) -> i32 {
9021    let mut returnval = 0i32; // c:4239
9022    let mut printflags = 0i32; // c:4240
9023    let dir_mode = OPT_ISSET(ops, b'd'); // c:4242
9024
9025    // PFA-SMR aspect: only `hash -d NAME=PATH` mutates the named-dir
9026    // table; the default `hash CMD=PATH` form populates a runtime
9027    // command cache that the recorder doesn't re-apply.
9028    #[cfg(feature = "recorder")]
9029    if crate::recorder::is_enabled() && dir_mode {
9030        let ctx = crate::recorder::recorder_ctx_global();
9031        for a in argv {
9032            if a.starts_with('-') {
9033                continue;
9034            }
9035            if let Some((k, v)) = a.split_once('=') {
9036                crate::recorder::emit_hash_d(k, v, ctx.clone());
9037            }
9038        }
9039    }
9040
9041    // c:4247-4263 — `-r` empty / `-f` fill (no other args).
9042    if OPT_ISSET(ops, b'r') || OPT_ISSET(ops, b'f') {
9043        // c:4247
9044        if !argv.is_empty() {
9045            // c:4249
9046            zwarnnam("hash", "too many arguments"); // c:4250
9047            return 1; // c:4251
9048        }
9049        if OPT_ISSET(ops, b'r') {
9050            // c:4255
9051            // c:4256 — `emptyhashtable(cmdnamtab)` /
9052            // `emptynameddirtable()`.
9053            if dir_mode {
9054                emptynameddirtable();
9055            } else {
9056                emptycmdnamtable();
9057            }
9058        }
9059        if OPT_ISSET(ops, b'f') {
9060            // c:4259
9061            // c:4260 — `fillcmdnamtable(cmdnamtab)` /
9062            // `fillnameddirtable()`. cmdnamtab fill = walk every
9063            // PATH entry and hashdir() it.
9064            if dir_mode {
9065                fillnameddirtable();
9066            } else {
9067                // Read $path (the lowercase array form) from env.
9068                // c:4260 — fill cmdnamtab from $path. Read shell-side
9069                //          $PATH so changes via `path=(...)` flow in.
9070                let path_str = getsparam("PATH").unwrap_or_default();
9071                let path_arr: Vec<String> = path_str.split(':').map(|s| s.to_string()).collect();
9072                fillcmdnamtable(&path_arr);
9073            }
9074        }
9075        return 0; // c:4262
9076    }
9077
9078    // c:4265 — `-L` enables PRINT_LIST.
9079    if OPT_ISSET(ops, b'L') {
9080        printflags |= PRINT_LIST;
9081    } // c:4265
9082
9083    // c:4268-4273 — no args: list table.
9084    if argv.is_empty() {
9085        // c:4268
9086        queue_signals(); // c:4269
9087                         // c:4270 — `scanhashtable(ht, 1, 0, 0, ht->printnode, printflags)`.
9088                         // Walk the selected table (cmdnamtab default, nameddirtab when
9089                         // `-d`). Previous Rust port only walked nameddirtab — `hash`
9090                         // with no args (the typical user-visible form) silently printed
9091                         // nothing on cmdnamtab.
9092                         // c:4270 — `scanhashtable(ht, 1, 0, 0, ht->printnode, printflags)`.
9093                         // The second arg `1` is `sorted` — the table is listed in
9094                         // alphabetical (meta-aware hnamcmp) order, not hash/insertion
9095                         // order. The previous port walked `t.iter()` raw, so `hash -d`
9096                         // printed entries in an arbitrary order (`two`, `one` instead of
9097                         // `one`, `two`).
9098        if dir_mode {
9099            if let Ok(t) = nameddirtab().lock() {
9100                let mut entries: Vec<_> = t.iter().collect();
9101                entries.sort_by(|a, b| hnamcmp(a.0, b.0)); // c:4270 sorted=1
9102                for (_n, nd) in entries {
9103                    printnameddirnode(nd, printflags);
9104                }
9105            }
9106        } else {
9107            // c:4270 — cmdnamtab walk (the default `ht`). PATH lookup
9108            // arr is empty in the printnode call site because per-node
9109            // hashed entries carry their own resolved path.
9110            if let Ok(t) = cmdnamtab_lock().read() {
9111                let mut entries: Vec<_> = t.iter().collect();
9112                entries.sort_by(|a, b| hnamcmp(a.0, b.0)); // c:4270 sorted=1
9113                for (_n, cn) in entries {
9114                    // c:4270 — `scanhashtable(cmdnamtab, ..., printcmdnamnode, ...)`
9115                    printcmdnamnode(cn, printflags);
9116                }
9117            }
9118        }
9119        unqueue_signals(); // c:4271
9120        return 0; // c:4272
9121    }
9122
9123    // c:4276-4329 — name-list dispatch, both literal and -m glob.
9124    queue_signals(); // c:4276
9125    let mut idx = 0;
9126    while idx < argv.len() {
9127        // c:4277
9128        let arg = &argv[idx];
9129        idx += 1;
9130        if OPT_ISSET(ops, b'm') {
9131            // c:4279
9132            // c:4280-4290 — glob-match path.
9133            let pprog = patcompile(
9134                &{
9135                    let mut __pat_tok = (arg).to_string();
9136                    crate::ported::glob::tokenize(&mut __pat_tok);
9137                    __pat_tok
9138                }, // c:4282
9139                PAT_HEAPDUP,
9140                None,
9141            );
9142            if let Some(prog) = pprog {
9143                if dir_mode {
9144                    if let Ok(t) = nameddirtab().lock() {
9145                        for (n, nd) in t.iter() {
9146                            if pattry(&prog, n) {
9147                                // c:4286
9148                                printnameddirnode(nd, printflags);
9149                            }
9150                        }
9151                    }
9152                }
9153            } else {
9154                zwarnnam(name, &format!("bad pattern : {}", arg)); // c:4292
9155                returnval = 1; // c:4293
9156            }
9157            continue;
9158        }
9159        // c:4297-4317 — literal name=value or name-only.
9160        let (n, val) = match arg.find('=') {
9161            Some(eq) => (&arg[..eq], Some(&arg[eq + 1..])),
9162            None => (arg.as_str(), None),
9163        };
9164        if let Some(v) = val {
9165            // c:4302
9166            // Define entry.
9167            if dir_mode {
9168                // c:4302
9169                // c:4303-4310 — `itype_end(asg->name, IUSER, 0)` validates;
9170                // dir name must be all-IUSER chars. IUSER (Src/utils.c:
9171                // 4173-4191) = digits + alpha + `_` + `-` + `.` (plus
9172                // non-ASCII). The previous port allowed only alphanumeric
9173                // + `_`, so `hash -d t-t=/foo` was rejected with "invalid
9174                // character", which then broke `~t-t` named-dir expansion.
9175                if !n
9176                    .chars()
9177                    .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.')
9178                {
9179                    // c:4305
9180                    zwarnnam(name, &format!("invalid character in directory name: {}", n)); // c:4306
9181                    returnval = 1; // c:4308
9182                    continue; // c:4309
9183                }
9184                // c:Src/builtin.c:80 — `BUILTIN("hash", BINF_MAGICEQUALS,
9185                // …)`. The MAGICEQUALS flag causes the C parser to
9186                // apply tilde/equals expansion to `name=value` argv
9187                // entries via Src/utils.c::quotestring's MAGICEQUALSUBST
9188                // arm at c:6304. By the time argv reaches bin_hash, the
9189                // value has already been tilde-expanded.
9190                //
9191                // The Rust pipeline doesn't apply MAGICEQUALS at the
9192                // parser/compile level for `hash -d` args, so the
9193                // literal `~` reaches here untouched. Bug #32 in
9194                // docs/BUGS.md: `hash -d zh=~` stored `'~'` instead
9195                // of $HOME. Apply tilde expansion locally by routing
9196                // the value through filesubstr — re-tokenize a
9197                // leading `~` to the Tilde TOKEN (\u{98}) which
9198                // filesubstr keys on per its strict-token
9199                // contract (subst.rs:1761).
9200                let expanded_v: String = if v.starts_with('~') {
9201                    let rest = &v[1..];
9202                    let tokenized = format!("\u{98}{}", rest);
9203                    crate::ported::subst::filesubstr(&tokenized, false)
9204                        .unwrap_or_else(|| v.to_string())
9205                } else {
9206                    v.to_string()
9207                };
9208                let nd = nameddir {
9209                    node: hashnode {
9210                        next: None,
9211                        nam: n.to_string(),
9212                        flags: 0,
9213                    },
9214                    dir: expanded_v,
9215                    diff: 0,
9216                };
9217                addnameddirnode(n, nd); // c:4314
9218            } else {
9219                // c:4313-4318 — `Cmdnam cn = zshcalloc(sizeof *cn);
9220                //                 cn->node.flags = HASHED;
9221                //                 cn->u.cmd = ztrdup(asg->value.scalar);
9222                //                 ht->addnode(ht, ztrdup(asg->name), hn);`
9223                // Insert into cmdnamtab so `hash myc` lookup hits it
9224                // (was storing in `__zshrs_hash_*` env var — fakery
9225                // that the user-facing `hash myc` query never read).
9226                let cn = cmdnam {
9227                    node: hashnode {
9228                        next: None,
9229                        nam: n.to_string(),
9230                        flags: HASHED as i32, // c:4316
9231                    },
9232                    name: None,
9233                    cmd: Some(v.to_string()), // c:4316
9234                };
9235                if let Ok(mut tab) = cmdnamtab_lock().write() {
9236                    tab.add(cn); // c:4318 addnode
9237                }
9238            }
9239            if OPT_ISSET(ops, b'v') {
9240                // c:4321
9241                if dir_mode {
9242                    if let Ok(t) = nameddirtab().lock() {
9243                        if let Some(nd) = t.get(n) {
9244                            // c:4322
9245                            printnameddirnode(nd, 0);
9246                        }
9247                    }
9248                }
9249            }
9250        } else {
9251            // c:4323-4334 — display existing entry / look up.
9252            if dir_mode {
9253                let snapshot = nameddirtab().lock().ok().and_then(|t| t.get(n).cloned());
9254                match snapshot {
9255                    Some(nd) => {
9256                        if OPT_ISSET(ops, b'v') {
9257                            // c:4337
9258                            printnameddirnode(&nd, 0);
9259                        }
9260                    }
9261                    None => {
9262                        zwarnnam(name, &format!("no such directory name: {}", n)); // c:4327
9263                        returnval = 1; // c:4328
9264                    }
9265                }
9266            } else {
9267                // c:4319-4334 — `else if (!(hn = ht->getnode2(ht,
9268                //   asg->name))) { ... if (!hashcmd(asg->name, path))
9269                //   zwarnnam("no such command"); }`. C path: first
9270                // check cmdnamtab for an existing entry; only fall
9271                // back to hashcmd's PATH walk when not present. The
9272                // previous Rust port skipped the cmdnamtab check, so
9273                // a prior `hash myc=/path` insert was invisible to
9274                // the matching `hash myc` query.
9275                let in_cmdnamtab = cmdnamtab_lock()
9276                    .read()
9277                    .map(|t| t.get(n).is_some())
9278                    .unwrap_or(false);
9279                if !in_cmdnamtab {
9280                    // c:4319 hn == NULL → try hashcmd.
9281                    let path: Vec<String> = getsparam("PATH")
9282                        .map(|p| p.split(':').map(String::from).collect())
9283                        .unwrap_or_default();
9284                    if crate::ported::exec::hashcmd(n, &path).is_none() {
9285                        // c:4332
9286                        zwarnnam(name, &format!("no such command: {}", n)); // c:4333
9287                        returnval = 1; // c:4334
9288                    }
9289                }
9290            }
9291        }
9292    }
9293    unqueue_signals(); // c:4346
9294    returnval // c:4346
9295}
9296
9297/// Port of `bin_unhash(char *name, char **argv, Options ops, int func)` from Src/builtin.c:4346.
9298/// C: `int bin_unhash(char *name, char **argv, Options ops, int func)` —
9299///   remove entries from cmdnamtab/aliastab/sufaliastab/nameddirtab/
9300///   shfunctab. `-a` clears all, `-m` is a glob.
9301/// WARNING: param names don't match C — Rust=(name, argv, func) vs C=(name, argv, ops, func)
9302pub fn bin_unhash(
9303    name: &str,
9304    argv: &[String], // c:4346
9305    ops: &options,
9306    func: i32,
9307) -> i32 {
9308    let mut returnval = 0i32; // c:4351
9309    let mut all = 0i32; // c:4351
9310    let mut match_count = 0i32; // c:4351
9311
9312    // PFA-SMR aspect: when invoked as `unalias`, record the un-alias
9313    // events so the replay can suppress earlier `alias` calls.
9314    #[cfg(feature = "recorder")]
9315    if crate::recorder::is_enabled() && func == crate::ported::builtin::BIN_UNALIAS {
9316        let ctx = crate::recorder::recorder_ctx_global();
9317        for a in argv {
9318            if a.starts_with('-') && a != "-" {
9319                continue;
9320            }
9321            crate::recorder::emit_unalias(a, ctx.clone());
9322        }
9323    }
9324
9325    // c:4355-4373 — table-pick dispatch.
9326    enum Tab {
9327        CmdNam,
9328        NamedDir,
9329        Shfunc,
9330        Alias,
9331        SufAlias,
9332    }
9333    let tab: Tab;
9334    if func == BIN_UNALIAS {
9335        // c:4356
9336        tab = if OPT_ISSET(ops, b's') {
9337            Tab::SufAlias
9338        } else {
9339            Tab::Alias
9340        }; // c:4357
9341        if OPT_ISSET(ops, b'a') {
9342            // c:4361
9343            if !argv.is_empty() {
9344                // c:4362
9345                zwarnnam(name, "-a: too many arguments"); // c:4363
9346                return 1; // c:4364
9347            }
9348            all = 1; // c:4366
9349        } else if argv.is_empty() {
9350            // c:4367
9351            zwarnnam(name, "not enough arguments"); // c:4368
9352            return 1; // c:4369
9353        }
9354    } else if OPT_ISSET(ops, b'd') {
9355        tab = Tab::NamedDir; // c:4370
9356    } else if OPT_ISSET(ops, b'f') {
9357        tab = Tab::Shfunc; // c:4372
9358    } else if OPT_ISSET(ops, b's') {
9359        tab = Tab::SufAlias; // c:4374
9360    } else if func == BIN_UNHASH && OPT_ISSET(ops, b'a') {
9361        tab = Tab::Alias; // c:4376
9362    } else {
9363        tab = Tab::CmdNam;
9364    } // c:4378
9365
9366    // Helper: clear entire table.
9367    let clear_all = |t: &Tab| match t {
9368        Tab::Alias => {
9369            let _ = aliastab_lock().write().map(|mut g| g.clear());
9370        }
9371        Tab::SufAlias => {
9372            let _ = sufaliastab_lock().write().map(|mut g| g.clear());
9373        }
9374        Tab::NamedDir => {
9375            emptynameddirtable();
9376        }
9377        Tab::Shfunc => {
9378            // c:4388 — empty whole shfunctab (`unhash -af` etc.). C uses
9379            // `emptyhashtable(shfunctab)` GSU; Rust port iterates names
9380            // and removes each (no `clear` method on shfunc_table).
9381            if let Ok(mut t) = shfunctab_lock().write() {
9382                let names: Vec<String> = t.iter().map(|(k, _)| k.clone()).collect();
9383                for nm in names {
9384                    let _ = t.remove(&nm);
9385                }
9386            }
9387        }
9388        Tab::CmdNam => {
9389            emptycmdnamtable();
9390        } // c:4389
9391    };
9392    let remove_one = |t: &Tab, nm: &str| -> bool {
9393        match t {
9394            Tab::Alias => aliastab_lock()
9395                .write()
9396                .map(|mut g| g.remove(nm).is_some())
9397                .unwrap_or(false),
9398            Tab::SufAlias => sufaliastab_lock()
9399                .write()
9400                .map(|mut g| g.remove(nm).is_some())
9401                .unwrap_or(false),
9402            Tab::NamedDir => crate::ported::hashnameddir::removenameddirnode(nm).is_some(),
9403            Tab::Shfunc => {
9404                let from_tab = shfunctab_lock()
9405                    .write()
9406                    .map(|mut g| g.remove(nm).is_some())
9407                    .unwrap_or(false);
9408                // Also remove from the executor's compiled-function /
9409                // source maps. Without this, `unset -f f` cleared
9410                // shfunctab but dispatch_function_call still found the
9411                // compiled chunk and ran the old body. Routed via the
9412                // exec accessors unregister_function fn-ptr installed by
9413                // fusevm_bridge at startup (no ShellExecutor reach-in
9414                // from src/ported/).
9415                let from_exec = crate::ported::exec::unregister_function(nm);
9416                from_tab || from_exec
9417            }
9418            // c:4405 — `if ((hn = ht->removenode(ht, *argv)))`.
9419            // Removal returns truthy only when the entry actually
9420            // existed. Previous Rust port hardcoded `true` after a
9421            // void-return `freecmdnamnode` call, so `unhash badname`
9422            // silently succeeded instead of emitting the canonical
9423            // "no such hash table element" error.
9424            Tab::CmdNam => cmdnamtab_lock()
9425                .write()
9426                .map(|mut g| g.remove(nm).is_some())
9427                .unwrap_or(false),
9428        }
9429    };
9430
9431    if all != 0 {
9432        // c:4382
9433        queue_signals(); // c:4383
9434        clear_all(&tab); // c:4384-4389
9435        unqueue_signals(); // c:4390
9436        return 0; // c:4391
9437    }
9438
9439    // c:4395-4421 — `-m` glob branch.
9440    if OPT_ISSET(ops, b'm') {
9441        // c:4395
9442        for arg in argv {
9443            // c:4396
9444            queue_signals(); // c:4397
9445            let pprog = patcompile(
9446                &{
9447                    let mut __pat_tok = (arg).to_string();
9448                    crate::ported::glob::tokenize(&mut __pat_tok);
9449                    __pat_tok
9450                }, // c:4400
9451                PAT_HEAPDUP,
9452                None,
9453            );
9454            if let Some(prog) = pprog {
9455                // Collect names then remove (avoid iterator/mutation conflict).
9456                // c:4408 — `scanmatchtable(ht, pprog, ...)` walks every
9457                // entry in the selected table. Previous Rust port left
9458                // Tab::CmdNam returning an empty Vec, so `unhash -m PAT`
9459                // (default cmd-hash table) silently matched zero entries.
9460                let names: Vec<String> = match &tab {
9461                    Tab::Alias => aliastab_lock()
9462                        .read()
9463                        .map(|t| t.iter().map(|(n, _)| n.clone()).collect())
9464                        .unwrap_or_default(),
9465                    Tab::SufAlias => sufaliastab_lock()
9466                        .read()
9467                        .map(|t| t.iter().map(|(n, _)| n.clone()).collect())
9468                        .unwrap_or_default(),
9469                    Tab::NamedDir => nameddirtab()
9470                        .lock()
9471                        .map(|t| t.keys().cloned().collect())
9472                        .unwrap_or_default(),
9473                    Tab::Shfunc => shfunctab_lock()
9474                        .read()
9475                        .map(|t| t.iter().map(|(k, _)| k.clone()).collect())
9476                        .unwrap_or_default(),
9477                    // c:4408 — cmdnamtab walk via `cmdnamtab_lock().iter()`.
9478                    Tab::CmdNam => cmdnamtab_lock()
9479                        .read()
9480                        .map(|t| t.iter().map(|(n, _)| n.clone()).collect())
9481                        .unwrap_or_default(),
9482                };
9483                for nm in &names {
9484                    if pattry(&prog, nm) {
9485                        // c:4408
9486                        if remove_one(&tab, nm) {
9487                            match_count += 1; // c:4410
9488                        }
9489                    }
9490                }
9491            } else {
9492                zwarnnam(name, &format!("bad pattern : {}", arg)); // c:4416
9493                returnval = 1; // c:4417
9494            }
9495            unqueue_signals(); // c:4419
9496        }
9497        if match_count == 0 {
9498            // c:4424
9499            returnval = 1; // c:4425
9500        }
9501        return returnval; // c:4426
9502    }
9503
9504    // c:4429-4439 — literal-name removals.
9505    queue_signals(); // c:4430
9506    for arg in argv {
9507        // c:4431
9508        if remove_one(&tab, arg) { // c:4432
9509             // freed
9510        } else if func == BIN_UNSET && isset(POSIXBUILTINS) {
9511            // c:4434 — POSIX: unset of nonexistent isn't an error.
9512            returnval = 0; // c:4435
9513        } else {
9514            zwarnnam(name, &format!("no such hash table element: {}", arg)); // c:4437
9515            returnval = 1; // c:4450
9516        }
9517    }
9518    unqueue_signals(); // c:4450
9519    returnval // c:4450
9520}
9521
9522/// Port of `bin_alias(char *name, char **argv, Options ops, UNUSED(int func))` from Src/builtin.c:4450.
9523/// C: `int bin_alias(char *name, char **argv, Options ops, ...)` — list,
9524///   define, glob-list, or display aliases. `-r`/`-g`/`-s` filter type;
9525///   `-L` prints definitions; `-m` treats args as patterns.
9526/// WARNING: param names don't match C — Rust=(name, argv, _func) vs C=(name, argv, ops, func)
9527pub fn bin_alias(
9528    name: &str,
9529    argv: &[String], // c:4450
9530    ops: &options,
9531    _func: i32,
9532) -> i32 {
9533    let mut returnval = 0i32; // c:4455
9534    let mut flags1 = 0u32; // c:4456
9535    let mut flags2 = DISABLED as u32; // c:4456
9536    let mut printflags = 0i32; // c:4457
9537    let mut use_suffix = false; // tracks ht switch
9538
9539    // c:4461-4485 — type-flag parsing.
9540    let type_opts = (OPT_ISSET(ops, b'r') as i32)                            // c:4461
9541                  + (OPT_ISSET(ops, b'g') as i32)
9542                  + (OPT_ISSET(ops, b's') as i32);
9543    if type_opts != 0 {
9544        // c:4464
9545        if type_opts > 1 {
9546            // c:4465
9547            zwarnnam(name, "illegal combination of options"); // c:4466
9548            return 1; // c:4467
9549        }
9550        if OPT_ISSET(ops, b'g') {
9551            // c:4469
9552            flags1 |= ALIAS_GLOBAL as u32; // c:4470
9553        } else {
9554            flags2 |= ALIAS_GLOBAL as u32; // c:4472
9555        }
9556        if OPT_ISSET(ops, b's') {
9557            // c:4473
9558            flags1 |= ALIAS_SUFFIX as u32; // c:4480
9559            use_suffix = true; // c:4481
9560        } else {
9561            flags2 |= ALIAS_SUFFIX as u32; // c:4483
9562        }
9563    }
9564
9565    // c:4486-4490 — printflags from -L / + suffix.
9566    if OPT_ISSET(ops, b'L') {
9567        // c:4486
9568        printflags |= PRINT_LIST; // c:4487
9569    } else if OPT_PLUS(ops, b'g')
9570        || OPT_PLUS(ops, b'r')
9571        || OPT_PLUS(ops, b's')
9572        || OPT_PLUS(ops, b'm')
9573        || OPT_ISSET(ops, b'+')
9574    // c:4488
9575    {
9576        printflags |= PRINT_NAMEONLY; // c:4490
9577    }
9578
9579    // C bin_alias dispatches printing via `ht->printnode` (set to
9580    // `printaliasnode` at hashtable.c:1208) — `scanhashtable`,
9581    // `scanmatchtable`, and the single-name branch all call
9582    // `ht->printnode(&a->node, printflags)`. The Rust port routes
9583    // through the canonical `printaliasnode` (hashtable.rs:1477) for
9584    // the same dispatch. No local closure.
9585
9586    // c:4495-4500 — no args: list all (filtered by flags).
9587    if argv.is_empty() {
9588        // c:4495
9589        queue_signals(); // c:4496
9590        let lock = if use_suffix {
9591            sufaliastab_lock()
9592        } else {
9593            aliastab_lock()
9594        };
9595        if let Ok(t) = lock.read() {
9596            // c:Src/builtin.c:4496 — `scanhashtable(aliastab, 1, ...)`
9597            // sets the SORT flag (1). The C source walks the table
9598            // sorted by `hnamcmp` (byte-wise ASCII compare); the
9599            // Rust port iterated in arbitrary hash order, so
9600            // `alias` output sequence diverged from zsh's
9601            // (uppercase-before-lowercase) ASCII ordering.
9602            let mut entries: Vec<_> = t.iter().collect();
9603            entries.sort_by(|(a, _), (b, _)| a.cmp(b));
9604            for (_n, a) in entries {
9605                // c:4497
9606                if (a.node.flags & flags1 as i32) == flags1 as i32
9607                    && (a.node.flags & flags2 as i32) == 0
9608                {
9609                    printaliasnode(a, printflags);
9610                }
9611            }
9612        }
9613        unqueue_signals(); // c:4498
9614        return 0; // c:4499
9615    }
9616
9617    // c:4503-4519 — `-m` glob branch.
9618    if OPT_ISSET(ops, b'm') {
9619        // c:4503
9620        for pat in argv {
9621            // c:4504
9622            queue_signals(); // c:4505
9623                             // c:4506 — `tokenize + patcompile`.
9624            let pprog = patcompile(
9625                &{
9626                    let mut __pat_tok = (pat).to_string();
9627                    crate::ported::glob::tokenize(&mut __pat_tok);
9628                    __pat_tok
9629                }, // c:4507
9630                PAT_HEAPDUP,
9631                None,
9632            );
9633            if let Some(prog) = pprog {
9634                let lock = if use_suffix {
9635                    sufaliastab_lock()
9636                } else {
9637                    aliastab_lock()
9638                };
9639                if let Ok(t) = lock.read() {
9640                    for (_n, a) in t.iter() {
9641                        // c:4509
9642                        if (a.node.flags & flags1 as i32) == flags1 as i32
9643                            && (a.node.flags & flags2 as i32) == 0
9644                            && pattry(&prog, &a.node.nam)
9645                        {
9646                            printaliasnode(a, printflags);
9647                        }
9648                    }
9649                }
9650            } else {
9651                zwarnnam(name, &format!("bad pattern : {}", pat)); // c:4514
9652                returnval = 1; // c:4515
9653            }
9654            unqueue_signals(); // c:4517
9655        }
9656        return returnval; // c:4518
9657    }
9658
9659    // c:4521-4540 — literal args: define `name=value` or display a single name.
9660    queue_signals(); // c:4522
9661                     // PFA-SMR: capture per-definition for replay. Dispatch the
9662                     // right subkind (galias/salias/alias) based on the parsed
9663                     // flag bits set above. Same per-name loop the C code walks
9664                     // — one record per `name=value` argv slot. Without this,
9665                     // the recorder harness saw zero `alias` captures.
9666    #[cfg(feature = "recorder")]
9667    let recorder_active = crate::recorder::is_enabled();
9668    #[cfg(feature = "recorder")]
9669    let recorder_ctx = if recorder_active {
9670        Some(crate::recorder::recorder_ctx_global())
9671    } else {
9672        None
9673    };
9674    let mut idx = 0;
9675    while idx < argv.len() {
9676        // c:4523
9677        let arg = &argv[idx];
9678        idx += 1;
9679        // c:Src/builtin.c:4523 `while ((asg = getasg(&argv, NULL)))`
9680        // — empty-name assignment makes getasg call `zerr("bad
9681        // assignment")` (c:1927, sets errflag) and return NULL. The
9682        // C while-loop exits without setting returnval, so bin_alias
9683        // returns 0. /bin/zsh's outer execution checks errflag and
9684        // aborts subsequent commands but keeps lastval=0.
9685        //
9686        // Rust port divergence: the canonical `zerr` here would set
9687        // ERRFLAG_ERROR, which zshrs's execcmd_exec promotes to
9688        // lastval=1 (different shape from C's "abort but keep
9689        // lastval"). Route through `zwarnnam` so the diagnostic
9690        // emits without flipping errflag; returnval stays 0 and the
9691        // shell rc matches /bin/zsh's observed behavior.
9692        if arg.starts_with('=') {
9693            zwarnnam(name, "bad assignment");
9694            break;
9695        }
9696        if let Some(eq) = arg.find('=') {
9697            // c:4524 (asg->value.scalar)
9698            if !OPT_ISSET(ops, b'L') {
9699                // c:4524
9700                let n = &arg[..eq];
9701                let v = &arg[eq + 1..];
9702                let lock = if use_suffix {
9703                    sufaliastab_lock()
9704                } else {
9705                    aliastab_lock()
9706                };
9707                if let Ok(mut t) = lock.write() {
9708                    let a = createaliasnode(n, v, flags1); // c:4527
9709                    t.add(a);
9710                }
9711                #[cfg(feature = "recorder")]
9712                if let Some(ref ctx) = recorder_ctx {
9713                    if (flags1 & ALIAS_GLOBAL as u32) != 0 {
9714                        crate::recorder::emit_galias(n, Some(v), ctx.clone());
9715                    } else if (flags1 & ALIAS_SUFFIX as u32) != 0 {
9716                        crate::recorder::emit_salias(n, Some(v), ctx.clone());
9717                    } else {
9718                        crate::recorder::emit_alias(n, Some(v), ctx.clone());
9719                    }
9720                }
9721                continue;
9722            }
9723        }
9724        let n = if let Some(eq) = arg.find('=') {
9725            &arg[..eq]
9726        } else {
9727            arg.as_str()
9728        };
9729        let lock = if use_suffix {
9730            sufaliastab_lock()
9731        } else {
9732            aliastab_lock()
9733        };
9734        // c:4530 — `(a = (Alias) ht->getnode(ht, asg->name))`. C
9735        // `getnode` is `gethashnode` (Src/hashtable.c:231) which
9736        // returns NULL for DISABLED entries; skipping disabled at
9737        // lookup time is what makes `alias FOO` print nothing after
9738        // `disable -a FOO` (bug #227). Must use `.get()` not
9739        // `.get_including_disabled()` here.
9740        let found = lock.read().ok().and_then(|t| {
9741            t.get(n)
9742                .map(|a| (a.node.nam.clone(), a.node.flags as u32, a.text.clone()))
9743        });
9744        match found {
9745            Some((nm, fl, txt)) => {
9746                // c:4530
9747                // c:4532-4537 — type-filter check.
9748                let show = type_opts == 0
9749                    || use_suffix
9750                    || (OPT_ISSET(ops, b'r') && (fl & (ALIAS_GLOBAL | ALIAS_SUFFIX) as u32) == 0)
9751                    || (OPT_ISSET(ops, b'g') && (fl & ALIAS_GLOBAL as u32) != 0);
9752                if show {
9753                    let a = createaliasnode(&nm, &txt, fl);
9754                    printaliasnode(&a, printflags);
9755                }
9756            }
9757            None => {
9758                // c:4538
9759                returnval = 1; // c:4539
9760            }
9761        }
9762    }
9763    unqueue_signals(); // c:4541
9764    returnval // c:4542
9765}
9766
9767/// Port of `bin_true(UNUSED(char *name), UNUSED(char **argv), UNUSED(Options ops), UNUSED(int func))` from Src/builtin.c:4550.
9768/// C: `int bin_true(UNUSED(char *name), UNUSED(char **argv),
9769///                  UNUSED(Options ops), UNUSED(int func))` → `return 0;`
9770/// WARNING: param names don't match C — Rust=(_name, _argv, _func) vs C=(name, argv, ops, func)
9771pub fn bin_true(
9772    _name: &str,
9773    _argv: &[String], // c:4550
9774    _ops: &options,
9775    _func: i32,
9776) -> i32 {
9777    0 // c:4559
9778}
9779
9780/// Port of `bin_false(UNUSED(char *name), UNUSED(char **argv), UNUSED(Options ops), UNUSED(int func))` from Src/builtin.c:4559.
9781/// C: `int bin_false(UNUSED(char *name), UNUSED(char **argv),
9782///                   UNUSED(Options ops), UNUSED(int func))` → `return 1;`
9783/// WARNING: param names don't match C — Rust=(_name, _argv, _func) vs C=(name, argv, ops, func)
9784pub fn bin_false(
9785    _name: &str,
9786    _argv: &[String], // c:4559
9787    _ops: &options,
9788    _func: i32,
9789) -> i32 {
9790    1 // c:4562
9791}
9792
9793/// Port of `bin_print(char *name, char **args, Options ops, int func)` from Src/builtin.c:4587.
9794/// C: `int bin_print(char *name, char **args, Options ops, int func)`.
9795///
9796/// The C body is ~1000 lines: `print` / `echo` / `printf` / `pushln`
9797/// dispatcher with -n/-N/-c/-r/-R/-l/-D/-i/-f/-v/-s/-S/-z/-e/-E etc.
9798/// The structural port handles the script-friendly subset that the
9799/// daily-driver hits: print/echo plain emission with -n, -l (one per
9800/// line), -r raw, -E newline-only, -- end-of-options. The full -f
9801/// printf format-spec engine and ZLE/history wireups defer to the
9802/// expand_printf_escapes helpers.
9803/// WARNING: param names don't match C — Rust=(name, args, func) vs C=(name, args, ops, func)
9804pub fn bin_print(
9805    name: &str,
9806    args: &[String], // c:4587
9807    ops: &options,
9808    func: i32,
9809) -> i32 {
9810    // c:4659-4684 — "Error check option combinations and option arguments".
9811    // The FIRST thing bin_print does, before any argument work:
9812    //     if (OPT_ISSET(ops,'z') + OPT_ISSET(ops,'s') + OPT_ISSET(ops,'S') +
9813    //         OPT_ISSET(ops,'v') > 1) {
9814    //         zwarnnam(name, "only one of -s, -S, -v, or -z allowed"); return 1; }
9815    //     if ((OPT_ISSET(ops,'z') | OPT_ISSET(ops,'s') | OPT_ISSET(ops,'S')) +
9816    //         (OPT_ISSET(ops,'c') | OPT_ISSET(ops,'C')) > 1) {
9817    //         zwarnnam(name, "-c or -C not allowed with -s, -S, or -z"); return 1; }
9818    //     if ((OPT_ISSET(ops,'z') | OPT_ISSET(ops,'v') | OPT_ISSET(ops,'s') |
9819    //          OPT_ISSET(ops,'S')) + (OPT_ISSET(ops,'p') | OPT_ISSET(ops,'u')) > 1) {
9820    //         zwarnnam(name, "-p or -u not allowed with -s, -S, -v, or -z"); return 1; }
9821    //
9822    // None of the three were ported, so every conflicting combination was
9823    // silently accepted: `print -s -v v a b`, `print -C 2 -s a b` and
9824    // `print -u1 -v v a b` all returned 0 having done something other than what
9825    // zsh does. Note the mixed operators are deliberate — `+` counts DISTINCT
9826    // options in the first test (so `-s -S` is two), while `|` collapses each
9827    // GROUP in the other two before counting, so the test is "one from each
9828    // group", not "two options total".
9829    //
9830    // C's fourth check ("-f not allowed with -c, -C, or -S", c:4679-4683) is
9831    // COMMENTED OUT and must NOT be reinstated: `print -f %s -c a b` is legal
9832    // and prints, as verified against the oracle. The string exists in the
9833    // source but the code is dead — the reason to read the C rather than grep
9834    // its message table.
9835    //
9836    // These precede the `-C` argument validation below, matching C, so
9837    // `print -C 0 -s` reports the conflict rather than the bad column count.
9838    {
9839        let n_sv = i32::from(OPT_ISSET(ops, b'z'))
9840            + i32::from(OPT_ISSET(ops, b's'))
9841            + i32::from(OPT_ISSET(ops, b'S'))
9842            + i32::from(OPT_ISSET(ops, b'v'));
9843        if n_sv > 1 {
9844            zwarnnam(name, "only one of -s, -S, -v, or -z allowed"); // c:4664
9845            return 1; // c:4665
9846        }
9847        let g_zsS = OPT_ISSET(ops, b'z') || OPT_ISSET(ops, b's') || OPT_ISSET(ops, b'S');
9848        let g_cC = OPT_ISSET(ops, b'c') || OPT_ISSET(ops, b'C');
9849        if i32::from(g_zsS) + i32::from(g_cC) > 1 {
9850            zwarnnam(name, "-c or -C not allowed with -s, -S, or -z"); // c:4670
9851            return 1; // c:4671
9852        }
9853        let g_zvsS = OPT_ISSET(ops, b'z')
9854            || OPT_ISSET(ops, b'v')
9855            || OPT_ISSET(ops, b's')
9856            || OPT_ISSET(ops, b'S');
9857        let g_pu = OPT_ISSET(ops, b'p') || OPT_ISSET(ops, b'u');
9858        if i32::from(g_zvsS) + i32::from(g_pu) > 1 {
9859            zwarnnam(name, "-p or -u not allowed with -s, -S, -v, or -z"); // c:4677
9860            return 1; // c:4678
9861        }
9862    }
9863    let nonewline = OPT_ISSET(ops, b'n'); // c:4595
9864    let raw = OPT_ISSET(ops, b'r') || OPT_ISSET(ops, b'R'); // c:4596
9865                                                            // c:4597 — `-l` puts one arg per line. `-c` is "columns" but
9866                                                            // degrades to one-per-line when stdout isn't a tty (terminal-
9867                                                            // width-aware tabular print isn't ported); accept -c here as
9868                                                            // a synonym for -l so `print -c a b c` byte-matches zsh's
9869                                                            // non-tty fallback.
9870    let one_per_line = OPT_ISSET(ops, b'l') || OPT_ISSET(ops, b'c');
9871    let nul_sep = OPT_ISSET(ops, b'N'); // c:5114/5127/5132 — NUL separator
9872                                        // c:Src/builtin.c — `-D` runs each arg through `dirify()`: if
9873                                        // it matches a named dir or $HOME-prefix, abbreviate with
9874                                        // `~`. zsh has the dircache + named-dir table behind it; for
9875                                        // -c mode (non-interactive) the table mostly contains $HOME
9876                                        // → "~". Apply that single rewrite for parity with the common
9877                                        // case. The richer named-dir lookup belongs in a deeper
9878                                        // dirify port.
9879    let dirify_d = OPT_ISSET(ops, b'D');
9880    let _printf_mode = func == BIN_PRINTF || OPT_HASARG(ops, b'f'); // c:4604
9881    let echo_mode = func == BIN_ECHO;
9882    let _ = (name, raw);
9883
9884    // c:Src/builtin.c:5095-5106 — `-x N` (and `-X N`) require the
9885    // argument to be a positive integer (zstrtol parse with no
9886    // trailing garbage AND value > 0). zsh emits
9887    // `positive integer expected after -x: <arg>` and returns 1
9888    // when either gate fails. The previous Rust port silently
9889    // accepted any -x argument and proceeded to print the rest of
9890    // argv. Bug #525.
9891    if OPT_HASARG(ops, b'x') || OPT_HASARG(ops, b'X') {
9892        let which = if OPT_HASARG(ops, b'X') { b'X' } else { b'x' };
9893        let xarg = OPT_ARG(ops, which).unwrap_or("");
9894        // c:Src/builtin.c:5101 — `expand = zstrtol(xarg, &eptr, 10);
9895        //   if (*eptr || expand <= 0) zwarnnam(...positive integer
9896        //   expected...); return 1;`. zstrtol parses optional
9897        //   leading sign + digits and stops at the first non-digit.
9898        //   Reject when eptr isn't end-of-string (trailing garbage)
9899        //   OR the signed result is <= 0 (zero / negative).
9900        let valid = xarg.parse::<i64>().map(|n| n > 0).unwrap_or(false);
9901        if !valid {
9902            zwarnnam(
9903                name,
9904                &format!(
9905                    "positive integer expected after -{}: {}",
9906                    which as char, xarg
9907                ),
9908            );
9909            return 1;
9910        }
9911    }
9912
9913    // c:4633-4685 — destination dispatch. -u FD writes to fd, -s pushes
9914    // to history, -z to ZLE buffer, -v VAR assigns to scalar.
9915    let dest_var: Option<String> = if OPT_HASARG(ops, b'v') {
9916        OPT_ARG(ops, b'v').map(String::from)
9917    } else {
9918        None
9919    };
9920    // c:Src/builtin.c:4827-4828 — `-p` writes to the coprocess fd
9921    // (the canonical `coprocout` global; Src/exec.c:430). When a
9922    // coproc is live, write to that fd; otherwise emit the same
9923    // diagnostic + rc=1 zsh does (Src/builtin.c:5050
9924    // `zwarnnam(name, "-p: no coprocess")`). Bug #388.
9925    let print_dash_p_fd: Option<fs::File> = if OPT_ISSET(ops, b'p') {
9926        let coprocout =
9927            crate::ported::modules::clone::coprocout.load(std::sync::atomic::Ordering::Relaxed);
9928        if coprocout < 0 {
9929            zwarnnam(name, "-p: no coprocess");
9930            return 1;
9931        }
9932        // c:4843 — dup for an owned writer that close-on-drop
9933        // doesn't close the user's original fd.
9934        let dup_fd = unsafe { libc::dup(coprocout) };
9935        if dup_fd < 0 {
9936            zwarnnam(name, "-p: no coprocess");
9937            return 1;
9938        }
9939        use std::os::unix::io::FromRawFd;
9940        Some(unsafe { fs::File::from_raw_fd(dup_fd) })
9941    } else {
9942        None
9943    };
9944    // c:4815-4851 — `-u FD` (and `-p` coprocess) dispatch. Parses FD,
9945    // dup's it for an owned descriptor, opens as a File for writes.
9946    // The previous Rust port silently dropped `-u`, so `print -u 2
9947    // hello` went to stdout instead of stderr.
9948    let dest_fd: Option<fs::File> = if OPT_HASARG(ops, b'u') {
9949        // c:4826
9950        let argptr = OPT_ARG(ops, b'u').unwrap_or("");
9951        // c:4827-4828 — undocumented `-up` aliases to coprocout.
9952        // Rust skip: coprocout isn't wired yet; document the gap.
9953        match argptr.parse::<i32>() {
9954            // c:4835 zstrtol
9955            Ok(fdarg) => {
9956                // c:4843 — `dup(fdarg)` for an owned writer that
9957                // close-on-drop doesn't close the user's original fd.
9958                let dup_fd = unsafe { libc::dup(fdarg) };
9959                if dup_fd < 0 {
9960                    zwarnnam(name, &format!("bad file number: {}", fdarg)); // c:4844
9961                    return 1; // c:4845
9962                }
9963                use std::os::unix::io::FromRawFd;
9964                Some(unsafe { fs::File::from_raw_fd(dup_fd) }) // c:4847
9965            }
9966            Err(_) => {
9967                zwarnnam(name, &format!("number expected after -u: {}", argptr)); // c:4837
9968                return 1; // c:4838
9969            }
9970        }
9971    } else {
9972        None
9973    };
9974
9975    // c:4604-4612 — printf format-string handling.
9976    if _printf_mode {
9977        // c:Src/builtin.c:4701-4706 — `if (func == BIN_PRINTF) { if
9978        // (!strcmp(*args, "--") && !*++args) { ...not enough args...
9979        // } fmt = *args++; }`. Consume a leading `--` end-of-options
9980        // marker (printf is BINF_SKIPDASH but NOT BINF_DASHDASHVALID,
9981        // so the generic flag-parser doesn't strip `--` — bin_print
9982        // does the strip itself for BIN_PRINTF). Without this,
9983        // `printf -- "%s\n" hi` used `--` as the format string. Bug
9984        // #284.
9985        let args_owned: Vec<String>;
9986        let args: &[String] = if func == BIN_PRINTF
9987            && !OPT_HASARG(ops, b'f')
9988            && !args.is_empty()
9989            && args[0] == "--"
9990        {
9991            // Slice past the `--` for the rest of this arm only.
9992            if args.len() == 1 {
9993                // c:4703 `not enough arguments`.
9994                zwarnnam(name, "not enough arguments");
9995                return 1;
9996            }
9997            args_owned = args[1..].to_vec();
9998            &args_owned
9999        } else {
10000            args
10001        };
10002        let fmt = if let Some(f) = OPT_ARG(ops, b'f') {
10003            f.to_string()
10004        } else if !args.is_empty() {
10005            args[0].clone()
10006        } else {
10007            return 0;
10008        };
10009        let rest: &[String] = if OPT_HASARG(ops, b'f') {
10010            args
10011        } else {
10012            &args[1..]
10013        };
10014        // c:4712-4741 — the -m filter runs BEFORE format rendering:
10015        // first remaining arg is the pattern, later args are kept iff
10016        // pattry matches, and `if (fmt && !*args) return 0;` short-
10017        // circuits an empty post-filter list. The fmt fast-branch
10018        // here previously skipped the filter entirely, so
10019        // `print -m -f '%s|' 'a*' apple banana` rendered every arg.
10020        let m_filtered: Vec<String>;
10021        let rest: &[String] = if OPT_ISSET(ops, b'm') {
10022            if rest.is_empty() {
10023                zwarnnam(name, "no pattern specified"); // c:4723
10024                return 1;
10025            }
10026            let pat = &rest[0];
10027            let mut pat_tok = pat.to_string();
10028            crate::ported::glob::tokenize(&mut pat_tok); // c:4727
10029            let Some(pprog) = crate::ported::pattern::patcompile(&pat_tok, PAT_STATIC, None) else {
10030                zwarnnam(name, &format!("bad pattern: {}", pat)); // c:4730
10031                return 1;
10032            };
10033            m_filtered = rest[1..]
10034                .iter()
10035                .filter(|a| crate::ported::pattern::pattry(&pprog, a)) // c:4735-4737
10036                .cloned()
10037                .collect();
10038            if m_filtered.is_empty() {
10039                return 0; // c:4741 `if (fmt && !*args) return 0;`
10040            }
10041            &m_filtered
10042        } else {
10043            rest
10044        };
10045        // c:4799-4807 — the `-o`/`-O`/`-i` argument sort runs BEFORE
10046        // format rendering. The non-fmt path sorts at the tail of
10047        // bin_print (strmetasort), but this `-f` fast-branch returns
10048        // first, so `print -of '%s\n' foo bar baz` rendered the args
10049        // unsorted. Apply the same strmetasort to the format args here.
10050        let rest_sorted: Vec<String>;
10051        let rest: &[String] = if OPT_ISSET(ops, b'o') || OPT_ISSET(ops, b'O') {
10052            let mut v = rest.to_vec();
10053            let mut sflags: u32 = if OPT_ISSET(ops, b'i') {
10054                SORTIT_IGNORING_CASE as u32 // c:4805
10055            } else {
10056                0
10057            };
10058            if OPT_ISSET(ops, b'O') {
10059                sflags |= SORTIT_BACKWARDS as u32; // c:4806
10060            }
10061            strmetasort(&mut v, sflags, None); // c:4807
10062            rest_sorted = v;
10063            &rest_sorted
10064        } else {
10065            rest
10066        };
10067        // c:5464 — reset the per-run `%d`/`%i` math-error flag; a bad
10068        // math operand during formatting sets it (parse_int_arg) and turns
10069        // the builtin's exit status into 1 without aborting output.
10070        PRINTF_MATH_ERR.with(|c| c.set(false));
10071        // c:builtin.c:5430-5443 — printf returns 1 on unknown
10072        // directive after `zwarnnam(name, "%s: invalid directive",
10073        // start)`. The partial output produced before the bad
10074        // directive is still printed (C emits via fwrite/fprintf
10075        // throughout the format walk).
10076        let (out, bounds, n_targets) = match printf_format(&fmt, rest) {
10077            Ok(s) => s,
10078            Err((partial, msg)) => {
10079                print!("{}", partial); // c: partial output already in fout
10080                use std::io::Write;
10081                let _ = std::io::stdout().flush();
10082                // c:5435 invalid directive / c:5204 argument specifier
10083                // out of range — `msg` carries the full zwarnnam text.
10084                crate::ported::utils::zwarnnam(name, &msg);
10085                return 1; // c:5443
10086            }
10087        };
10088        // c:Src/builtin.c:5427-5429 — apply the `%n` stores. C runs setiparam
10089        // inside the format loop; printf_format has no shell handle, so it
10090        // collected (name, byte_count) and the assignment happens here. Done
10091        // before the -z/-s output branches because in C the variable is set
10092        // during formatting regardless of where the text is routed.
10093        for (nv, cnt) in &n_targets {
10094            crate::ported::params::setiparam(nv, *cnt as i64); // c:5428
10095        }
10096        // c:4854-4856 — `if (OPT_ISSET(ops, 'v') || (fmt && (OPT_ISSET
10097        //   (ops, 'z') || OPT_ISSET(ops, 's')))) ASSIGN_MSTREAM(...)`.
10098        // For -f combined with -z or -s, capture output then route
10099        // through the same dispatch as the non-fmt path.
10100        if OPT_ISSET(ops, b'z') {
10101            // c:5564-5565 — push captured output to bufstack.
10102            crate::ported::zle::zle_main::BUFSTACK
10103                .lock()
10104                .unwrap()
10105                .push(out);
10106            return 0;
10107        }
10108        if OPT_ISSET(ops, b's') {
10109            // c:5569-5574 — push captured output as a history entry.
10110            let event_id = crate::ported::hist::prepnexthistent();
10111            crate::ported::hashtable::addhistnode(&out, event_id as i32);
10112            return 0;
10113        }
10114        if let Some(ref v) = dest_var {
10115            // c:builtin.c:5162 — `visarr = v && PM_TYPE(...) == PM_ARRAY`.
10116            // When the -v target is an existing array, the output is
10117            // split into one element per format-reuse cycle (c:5552-5560
10118            // `if (visarr && splits) { ... setaparam(...) }`); otherwise
10119            // the whole output is one scalar (c:5567 `setsparam(...)`).
10120            let visarr = {
10121                use crate::ported::zsh_h::{PM_ARRAY, PM_TYPE};
10122                paramtab()
10123                    .read()
10124                    .ok()
10125                    .and_then(|tab| {
10126                        tab.get(v)
10127                            .map(|pm| PM_TYPE(pm.node.flags as u32) == PM_ARRAY)
10128                    })
10129                    .unwrap_or(false)
10130            };
10131            // c:5169-5171 — `splits` is only allocated on the SECOND and
10132            // later cycles (`argp > args`); a single-cycle format leaves
10133            // `splits` NULL, so `if (visarr && splits)` (c:5552) is false
10134            // and the whole output becomes one scalar. `bounds.len() > 1`
10135            // mirrors `splits != NULL`.
10136            if visarr && bounds.len() > 1 {
10137                // c:5553-5559 — slice `out` at each cycle boundary; one
10138                // array element per format application. `bounds` holds the
10139                // byte offset where each cycle began (the first is 0).
10140                let mut arrayval: Vec<String> = Vec::with_capacity(bounds.len());
10141                for (i, &start) in bounds.iter().enumerate() {
10142                    let end = bounds.get(i + 1).copied().unwrap_or(out.len());
10143                    arrayval.push(out[start..end].to_string());
10144                }
10145                setaparam(v, arrayval);
10146            } else {
10147                setsparam(v, &out);
10148            }
10149        } else {
10150            // c:Src/builtin.c — C printf goes through libc fwrite to
10151            // stdout, unbuffered when fd 1 is the redirect target.
10152            // Rust's `print!` is line-buffered; without a trailing
10153            // newline the bytes stay in the buffer past the redirect
10154            // restore and land on the original stdout. Route through
10155            // stdout().write_all + flush so the bytes hit fd 1 (the
10156            // redirect target) immediately. Bug #397.
10157            use std::io::Write as _;
10158            let stdout = io::stdout();
10159            let mut lk = stdout.lock();
10160            // c:Src/builtin.c:5408 — `print_val(unmetafy(stringval,
10161            // &curlen))`: printf's values are unmetafied before the
10162            // write. Decode Meta-char pairs to raw bytes. Bug #127.
10163            let _ = lk.write_all(&crate::ported::utils::unmetafy_str(&out));
10164            let _ = lk.flush();
10165        }
10166        // c:5464 — a `%d`/`%i` math-operand error makes printf exit 1
10167        // (output already emitted).
10168        return if PRINTF_MATH_ERR.with(|c| c.get()) {
10169            1
10170        } else {
10171            0
10172        };
10173    }
10174
10175    // c:4718-4741 — `-m PATTERN args...` glob-filter. First arg is
10176    // the pattern; remaining args are kept iff `pattry(pat, arg)`.
10177    // Previously absent — `print -m 'foo*' foo1 bar foo2` emitted
10178    // all four args instead of just foo1/foo2.
10179    let mut processed_args: Vec<String> = if OPT_ISSET(ops, b'm') {
10180        // c:4718
10181        if args.is_empty() {
10182            // c:4722
10183            zwarnnam(name, "no pattern specified"); // c:4723
10184            return 1; // c:4724
10185        }
10186        // c:4728 — `patcompile(*args, PAT_STATIC, NULL)`.
10187        let pat = &args[0];
10188        let pprog = patcompile(
10189            &{
10190                let mut __pat_tok = (pat).to_string();
10191                crate::ported::glob::tokenize(&mut __pat_tok);
10192                __pat_tok
10193            },
10194            PAT_STATIC,
10195            None,
10196        );
10197        match pprog {
10198            None => {
10199                zwarnnam(name, &format!("bad pattern: {}", pat)); // c:4730
10200                return 1; // c:4732
10201            }
10202            Some(prog) => {
10203                // c:4734-4737 — `for (t = p = ++args; *p; p++) if
10204                // (pattry(pprog, *p)) *t++ = *p;`. Keep matching args.
10205                args[1..]
10206                    .iter()
10207                    .filter(|a| pattry(&prog, a))
10208                    .cloned()
10209                    .collect()
10210            }
10211        }
10212    } else {
10213        args.to_vec()
10214    };
10215
10216    // c:4860+ — main print loop.
10217    // c:5126-5127 — separator priority: `-l` ('\n') > `-N` ('\0') > ' '.
10218    let sep = if one_per_line {
10219        "\n"
10220    } else if nul_sep {
10221        "\0"
10222    } else {
10223        " "
10224    };
10225    // c:Src/builtin.c:4783-4795 — `-D`: interpret each arg as a
10226    // directory and abbreviate via `finddir(args[n])` — longest-prefix
10227    // match against the full named-dir table ($HOME → `~`, `hash -d`
10228    // entries → `~name`, plus the `zsh_directory_name` hook). C rewrites
10229    // the matched prefix to `~name` and keeps the trailing path. The
10230    // canonical `finddir` port already returns the `~name/rest` form.
10231    if dirify_d {
10232        for a in processed_args.iter_mut() {
10233            if let Some(abbrev) = crate::ported::utils::finddir(a) {
10234                *a = abbrev; // c:4791 — `~%s%s`
10235            }
10236        }
10237    }
10238    // c:4598-4600 — `-P` prompt-style percent expansion (`%n`, `%d`,
10239    // `%?`, `%h`, `%%`, etc.). Routes through `expand_prompt`
10240    // (canonical port of `Src/prompt.c:182 promptexpand`).
10241    if OPT_ISSET(ops, b'P') {
10242        // c:Src/builtin.c:4745-4746 — `if (OPT_ISSET(ops, 'P'))
10243        //   txtunknownattrs = TXT_ATTR_ALL;`. Marks every attribute
10244        // as initially "unknown" so the first `%b`/`%u`/`%s`/`%f`/`%k`
10245        // off-escape in this print invocation emits its terminfo cap
10246        // (via tunsetattrs at prompt.c:1758 — `txtcurrentattrs |=
10247        // newattrs & txtunknownattrs`). Without this seed, applying
10248        // an off-escape against zero current attrs produced no diff
10249        // and zshrs emitted nothing — `%b` / `%u` / `%s` looked like
10250        // no-ops in fresh prompts. Bug #38 in docs/BUGS.md.
10251        crate::ported::prompt::txtunknownattrs.store(
10252            crate::ported::zsh_h::TXT_ATTR_ALL,
10253            std::sync::atomic::Ordering::SeqCst,
10254        );
10255        // c:4598-4600 — `-P` prompt-style percent expansion.
10256        for a in processed_args.iter_mut() {
10257            *a = crate::ported::prompt::expand_prompt(a); // c:Src/prompt.c:182
10258                                                          // c:Src/prompt.c:236-247 — `if (!ns) { ... chuck(Inpar/
10259                                                          // Outpar/Nularg); }`. When `ns=0` (non-stripping flag
10260                                                          // off), zsh REMOVES the Inpar/Outpar/Nularg marker bytes
10261                                                          // from the output. `print -P` calls promptexpand with
10262                                                          // `ns=0` per Src/builtin.c:4598, so the SGR-wrapping
10263                                                          // markers MUST NOT leak into stdout. The Rust port's
10264                                                          // expand_prompt uses ad-hoc `\x01`/`\x02` (readline
10265                                                          // RL_PROMPT_*_IGNORE) markers instead of canonical
10266                                                          // Inpar/Outpar, but the strip rule applies identically:
10267                                                          // for non-prompt-render callers, scrub them. Parity bug
10268                                                          // #17 — without this, `print -P "%F{red}red%f"` emitted
10269                                                          // `\x01\E[31m\x02red\x01\E[39m\x02` instead of zsh's
10270                                                          // `\E[31mred\E[39m`.
10271            a.retain(|c| c != '\x01' && c != '\x02');
10272        }
10273    }
10274    // c:4799-4808 — `-o` / `-O` / `-i` sort flags.
10275    //
10276    // C body:
10277    // ```c
10278    // if (OPT_ISSET(ops,'o') || OPT_ISSET(ops,'O')) {
10279    //     flags = OPT_ISSET(ops,'i') ? SORTIT_IGNORING_CASE : 0;
10280    //     if (OPT_ISSET(ops,'O'))
10281    //         flags |= SORTIT_BACKWARDS;
10282    //     strmetasort(args, flags, len);
10283    // }
10284    // ```
10285    //
10286    // Meaning: `-i` sets `SORTIT_IGNORING_CASE` (case-INSENSITIVE).
10287    // Without `-i`, sort is case-SENSITIVE.
10288    //
10289    // The previous Rust port had this INVERTED — it bound
10290    // `case_sensitive = OPT_ISSET(ops, b'i')`, then case-sensitive-
10291    // sorted under `-i` and case-insensitive-sorted without `-i`.
10292    // The doc-comment for the block claimed "-o → case-insensitive
10293    // ascending" which is also wrong. Result: `print -o foo Bar BAZ`
10294    // emitted `BAZ Bar foo` (case-insensitive) when zsh emits
10295    // `BAZ Bar foo` only WITH `-i`; without it, zsh emits
10296    // `BAZ Bar foo` ordered by ASCII (caps first).
10297    if OPT_ISSET(ops, b'o') || OPT_ISSET(ops, b'O') {
10298        // c:4800
10299        // c:4801-4807 — `flags = OPT_ISSET(ops,'i') ? SORTIT_IGNORING_CASE
10300        // : 0; if (OPT_ISSET(ops,'O')) flags |= SORTIT_BACKWARDS;
10301        // strmetasort(args, flags, len);`. The previous Rust port used an
10302        // ad-hoc `processed_args.sort()` (byte/ordinal order) plus a
10303        // `.reverse()`, bypassing the faithful comparator. zsh's eltpcmp
10304        // sorts via `strcoll(as, bs)` (sort.c:134) — locale collation,
10305        // case-insensitive in UTF-8 locales — and SORTIT_BACKWARDS flips
10306        // `sortdir` inside the comparator (not a post-sort reverse).
10307        let mut flags: u32 = if OPT_ISSET(ops, b'i') {
10308            SORTIT_IGNORING_CASE as u32 // c:4805
10309        } else {
10310            0
10311        };
10312        if OPT_ISSET(ops, b'O') {
10313            flags |= SORTIT_BACKWARDS as u32; // c:4806
10314        }
10315        strmetasort(&mut processed_args, flags, None); // c:4807
10316    }
10317    // c:Src/builtin.c:4866-4886 — when `-r` is NOT set, each arg goes
10318    // through `getkeystring` to interpret backslash escapes (`\n`,
10319    // `\t`, `\\`, escaped space `\ `, etc.). `echo` follows the same
10320    // path when `BSD_ECHO`/`SH_OPTION_LETTERS`-style isn't in effect;
10321    // BIN_ECHO with `-E` keeps escapes literal. Without this, `print
10322    // -- ${(q)a}` for `a="he llo"` emitted `he\ llo` instead of zsh's
10323    // `he llo` (the (q) flag's backslash gets consumed by print).
10324    // c:builtin.c:4747-4767 — escape interpretation dispatch.
10325    //   - `fmt` (printf format already chosen via -f) or
10326    //     `(!-e && (-R || -r || -E))` → unmetafy only, NO escape
10327    //     interpretation (raw passthrough).
10328    //   - Otherwise pick `escape_how` per c:4754-4760:
10329    //       `-b`                            → GETKEYS_BINDKEY (bindkey
10330    //                                          escapes: `\C-`/`\M-`/`^X`)
10331    //       func != BIN_ECHO && !`-e`       → GETKEYS_PRINT (with EMACS:
10332    //                                          unknown `\<c>` → `<c>`)
10333    //       else (BIN_ECHO or `-e`)         → GETKEYS_ECHO (preserves
10334    //                                          unknown `\<c>` as `\<c>`)
10335    //
10336    // Previous Rust port unconditionally used GETKEYS_PRINT for both
10337    // `echo` and `print` — `echo "${(qq)s}"` for `s="a'b"` stripped
10338    // the `\` from the `(qq)`-emitted `'a'\''b'` because GETKEYS_PRINT
10339    // includes GETKEY_EMACS. zsh keeps the `\` (echo uses GETKEYS_ECHO,
10340    // no EMACS).
10341    let dash_e = OPT_ISSET(ops, b'e');
10342    // c:Src/builtin.c:4754 — BSD_ECHO option flips echo's default:
10343    // escape processing is OFF unless `-e` is explicitly passed.
10344    // Without bsd_echo (the SysV default), escapes process unless
10345    // `-E`/`-R`/`-r` is set.
10346    let bsd_echo_active = echo_mode && isset(BSDECHO);
10347    let suppress_escapes = OPT_ISSET(ops, b'R')
10348        || OPT_ISSET(ops, b'r')
10349        || (echo_mode && OPT_ISSET(ops, b'E'))
10350        || (bsd_echo_active && !dash_e);
10351    let mut backslash_c_truncated = false;
10352    if !suppress_escapes || dash_e {
10353        // c:builtin.c:4754-4760 — `-b` (bindkey escapes) takes precedence,
10354        // then GETKEYS_PRINT for print (EMACS), else GETKEYS_ECHO.
10355        let escape_how: u32 = if OPT_ISSET(ops, b'b') {
10356            GETKEYS_BINDKEY // c:4755
10357        } else if !echo_mode && !dash_e {
10358            GETKEYS_PRINT // c:4758
10359        } else {
10360            GETKEYS_ECHO // c:4760
10361        };
10362        // Clear any stale TLS flag before the loop.
10363        let _ = crate::ported::utils::getkey_truncated_take();
10364        let mut new_args: Vec<String> = Vec::with_capacity(processed_args.len());
10365        for a in processed_args.iter() {
10366            let (s, _) = getkeystring_with(a, escape_how, None);
10367            new_args.push(s);
10368            if crate::ported::utils::getkey_truncated_take() {
10369                // c:utils.c:7045 — `\c` truncated; drop remaining
10370                // args entirely AND suppress trailing newline.
10371                backslash_c_truncated = true;
10372                break;
10373            }
10374        }
10375        processed_args = new_args;
10376    }
10377    // c:Src/builtin.c:4930-4958 — `-C N` column-grid output. Layout
10378    // N args per row (nr = ceil(argc/nc) rows), each cell padded
10379    // to widest arg + 2 spaces. Default mode is COLUMN-MAJOR (col 1
10380    // takes first nr items, col 2 the next nr, etc.). The `-a` flag
10381    // (c:4980 "print across, i.e. columns first") switches to
10382    // ROW-MAJOR fill — items flow across each row before moving to
10383    // the next row. Bug #40 in docs/BUGS.md: zshrs ignored `-a` and
10384    // always produced column-major output.
10385    let body = if !_printf_mode && OPT_HASARG(ops, b'C') {
10386        // c:4687-4698 — `-C` validates its argument two ways and FAILS; it does
10387        // not fall back:
10388        //     nc = (int)zstrtol(argptr, &eptr, 10);
10389        //     if (*eptr) {
10390        //         zwarnnam(name, "number expected after -%c: %s", 'C', argptr);
10391        //         return 1;
10392        //     }
10393        //     if (nc <= 0) {
10394        //         zwarnnam(name, "invalid number of columns: %s", argptr);
10395        //         return 1;
10396        //     }
10397        // This parsed as usize, filtered `n > 0`, and silently
10398        // `.unwrap_or(1)`, so `print -C 0`, `print -C -1`, `print -C abc` and
10399        // `print -C ''` all printed one column per line and returned 0 where
10400        // zsh fails. zstrtol is what separates the two messages: trailing
10401        // garbage leaves *eptr set ("3x" → "number expected"), while a
10402        // well-formed non-positive parses cleanly and reaches the second check
10403        // ("-1" → "invalid number of columns"). Leading blanks are skipped by
10404        // zstrtol, so `-C ' 2'` is valid.
10405        let argptr = OPT_ARG(ops, b'C').unwrap_or("");
10406        let (nc_l, rest) = crate::ported::utils::zstrtol_underscore(argptr, 10, false); // c:4689
10407        if !rest.is_empty() {
10408            zwarnnam(name, &format!("number expected after -C: {argptr}")); // c:4691
10409            return 1; // c:4692
10410        }
10411        if nc_l <= 0 {
10412            zwarnnam(name, &format!("invalid number of columns: {argptr}")); // c:4695
10413            return 1; // c:4696
10414        }
10415        let nc: usize = nc_l as usize;
10416        let argc = processed_args.len();
10417        let nr = (argc + nc - 1) / nc;
10418        let across = OPT_ISSET(ops, b'a'); // c:4947 / c:4980
10419                                           // c:Src/builtin.c:4946-4956 — max-width walk skips
10420                                           // last-column items because they don't need trailing
10421                                           // padding. The skip set differs by mode:
10422                                           //   -a (row-major): skip i where (i % nc) == nc-1
10423                                           //   default (col-major): skip i where i >= nr * (nc-1)
10424        let max_w = processed_args
10425            .iter()
10426            .enumerate()
10427            .filter(|(i, _)| {
10428                if across {
10429                    (i % nc) != nc - 1
10430                } else {
10431                    *i < nr * (nc.saturating_sub(1))
10432                }
10433            })
10434            .map(|(_, s)| s.chars().count())
10435            .max()
10436            .unwrap_or(0);
10437        let sc = max_w + 2;
10438        let mut out = String::new();
10439        for row in 0..nr {
10440            for col in 0..nc {
10441                // c:4982-4986 (-a) — `idx = row*nc + col` for
10442                // row-major fill across columns first.
10443                // c:4994-5005 (default) — `idx = col*nr + row` for
10444                // column-major fill down columns first.
10445                let idx = if across {
10446                    row * nc + col
10447                } else {
10448                    col * nr + row
10449                };
10450                if idx >= argc {
10451                    break;
10452                }
10453                let cell = &processed_args[idx];
10454                // Padding skip rule mirrors max_w gate:
10455                // last-column-of-row (-a) or last-column-of-grid
10456                // (default) gets no trailing padding.
10457                let is_last_col_of_row = col == nc - 1;
10458                let is_after_last_grid_col = !across && col * nr + row + nr >= argc;
10459                if is_last_col_of_row || is_after_last_grid_col {
10460                    out.push_str(cell);
10461                } else {
10462                    out.push_str(cell);
10463                    let pad = sc.saturating_sub(cell.chars().count());
10464                    out.extend(std::iter::repeat(' ').take(pad));
10465                }
10466            }
10467            out.push('\n');
10468        }
10469        // Strip trailing newline; the post-loop `if !nonewline` adds
10470        // one back.
10471        if out.ends_with('\n') {
10472            out.pop();
10473        }
10474        out
10475    } else if OPT_HASARG(ops, b'x') || OPT_HASARG(ops, b'X') {
10476        // c:Src/builtin.c:5095-5125 — `print -x N` / `-X N` expand tabs
10477        // to spaces at tab stops of width N (`-x` only leading tabs,
10478        // `-X` all tabs), threading the column position across args and
10479        // resetting it on the `-l` newline. zexpandtabs (utils.c:5975)
10480        // was ported but never wired into print output.
10481        let all = OPT_HASARG(ops, b'X');
10482        let which = if all { b'X' } else { b'x' };
10483        let width: i32 = OPT_ARG(ops, which)
10484            .and_then(|a| a.parse().ok())
10485            .unwrap_or(8);
10486        let n = processed_args.len();
10487        let mut out = String::new();
10488        let mut startpos = 0i32;
10489        for (i, arg) in processed_args.iter().enumerate() {
10490            startpos = crate::ported::utils::zexpandtabs(arg, width, startpos, all, &mut out);
10491            if i + 1 < n {
10492                if OPT_ISSET(ops, b'l') {
10493                    out.push('\n');
10494                    startpos = 0;
10495                } else if OPT_ISSET(ops, b'N') {
10496                    out.push('\0');
10497                } else {
10498                    out.push(' ');
10499                    startpos += 1;
10500                }
10501            }
10502        }
10503        out
10504    } else {
10505        processed_args.join(sep)
10506    };
10507    // c:5564-5575 — destination dispatch order:
10508    //   -z   → zpushnode(bufstack, stringval)
10509    //   -v   → setsparam(VAR, stringval)
10510    //   -s   → prepnexthistent() + addhistnode(histtab, stringval)
10511    //   else → fwrite to fout
10512    if OPT_ISSET(ops, b'z') {
10513        // c:5564-5565 — `zpushnode(bufstack, stringval)`. The ZLE
10514        // bufstack is consumed by the next zleread call so the
10515        // string is presented at the prompt — `print -z 'echo foo'`
10516        // queues `echo foo` for the user to press Enter on.
10517        crate::ported::zle::zle_main::BUFSTACK
10518            .lock()
10519            .unwrap()
10520            .push(body); // c:5565
10521        return 0;
10522    }
10523    if OPT_ISSET(ops, b's') || OPT_ISSET(ops, b'S') {
10524        // c:5047-5093 — `-s` / `-S` push captured output to the history
10525        // list. `-S` requires exactly one arg (c:5058-5062
10526        // `if (nwords > 1) zwarnnam(name, "option -S takes a single
10527        // argument"); return 1`); the words array is then populated by
10528        // `histsplitwords` (c:5065). `-s` joins all args with spaces
10529        // and stores them as a single history entry. Both paths call
10530        // `prepnexthistent` + `addhistnode(histtab, ent->node.nam, ent)`.
10531        if OPT_ISSET(ops, b'S') && processed_args.len() > 1 {
10532            // c:5059
10533            zwarnnam(name, "option -S takes a single argument"); // c:5059
10534            return 1; // c:5061
10535        }
10536        let event_id = crate::ported::hist::prepnexthistent(); // c:5066/5072
10537        crate::ported::hashtable::addhistnode(&body, event_id as i32); // c:5090
10538                                                                       // c:Src/builtin.c — C's `addhistnode(histtab, …)` uses the
10539                                                                       // ent ALREADY linked into hist_ring by prepnexthistent. zshrs's
10540                                                                       // prepnexthistent only bumps curhist and doesn't insert into
10541                                                                       // the ring, so `fc -l` finds no events. Push the ent into the
10542                                                                       // ring here so the gethistent lookup in fc/history sees it.
10543                                                                       // Without this, `print -s X; fc -l` reported "no such event: 1"
10544                                                                       // even though the histtab had the entry.
10545        let now = std::time::SystemTime::now()
10546            .duration_since(std::time::UNIX_EPOCH)
10547            .map(|d| d.as_secs() as i64)
10548            .unwrap_or(0);
10549        let ent = crate::ported::zsh_h::histent {
10550            node: crate::ported::zsh_h::hashnode {
10551                next: None,
10552                nam: body.clone(),
10553                flags: 0,
10554            },
10555            up: None,
10556            down: None,
10557            zle_text: None,
10558            stim: now,
10559            ftim: now,
10560            words: Vec::new(),
10561            nwords: 0,
10562            histnum: event_id,
10563        };
10564        if let Ok(mut ring) = crate::ported::hist::hist_ring.lock() {
10565            ring.insert(0, ent);
10566            crate::ported::hist::histlinect.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
10567        }
10568        return 0;
10569    }
10570    if let Some(ref v) = dest_var {
10571        // c:Src/builtin.c:544-546 — the trailing separator is part of the
10572        // captured `-v` value too: it is written to the same stream and read
10573        // back into `buf`. It is suppressed only when `-n`, a `\c` escape
10574        // (`nnl`), OR `-v` WITHOUT `-l` apply. So `print -v x -l a b c` stores
10575        // `a\nb\nc\n` (with the trailing newline) where `print -v x a b c`
10576        // stores `a b c` (none). The port previously suppressed it for every
10577        // `-v`, dropping the `-l` trailing newline.
10578        let suppress_term =
10579            nonewline || backslash_c_truncated || !OPT_ISSET(ops, b'l');
10580        let mut val = body.clone();
10581        if !suppress_term {
10582            val.push(if nul_sep { '\0' } else { '\n' }); // c:546
10583        }
10584        setsparam(v, &val);
10585    } else {
10586        // c:5130-5132 — final terminator: `-n` suppresses; `-N` emits
10587        // NUL instead of newline; else newline. `\c` truncation
10588        // (c:utils.c:7045) also suppresses — matches zsh's
10589        // `echo "a\cb"; echo END` → `aEND`.
10590        let final_term: &[u8] = if nonewline || backslash_c_truncated {
10591            b""
10592        } else if nul_sep {
10593            b"\0"
10594        } else {
10595            b"\n"
10596        };
10597        // c:Bug #388 — `-p` and `-u` are mutually exclusive but both
10598        // route to the same write-to-fd path. Prefer the `-p`-resolved
10599        // coprocout (when present) over the `-u`-resolved fd. If
10600        // neither, fall through to stdout below.
10601        let dest_fd_active = print_dash_p_fd.or(dest_fd);
10602        // c:Src/builtin.c:4752 — `unmetafy(args[n], &len[n])`: print's
10603        // args are unmetafied before the fwrite. zshrs keeps values
10604        // metafied (Meta-char pairs, vm_helper::meta_encode_byte)
10605        // until the byte boundary, so decode here — `print $'\xff'`
10606        // writes the single raw byte 0xff. Bug #127.
10607        let body_bytes = crate::ported::utils::unmetafy_str(&body);
10608        if let Some(mut f) = dest_fd_active {
10609            // c:4847 — write to dup'd file descriptor.
10610            use std::io::Write as _;
10611            let _ = f.write_all(&body_bytes); // c:5124 fwrite
10612            let _ = f.write_all(final_term); // c:5132
10613                                             // f closes on drop (close(dup_fd)) — user's original fd
10614                                             // remains open per c:4843 dup semantics.
10615        } else {
10616            // stdout path. -N writes NUL via raw stdout; print!/println!
10617            // would mangle a NUL inside a String literal via format
10618            // machinery, so route through stdout().write_all directly.
10619            use std::io::Write as _;
10620            let stdout = io::stdout();
10621            let mut lk = stdout.lock();
10622            let _ = lk.write_all(&body_bytes); // c:5124
10623            let _ = lk.write_all(final_term); // c:5132
10624                                              // c:Src/builtin.c — C printf goes through libc fwrite to
10625                                              // stdout (fd 1) which is unbuffered when the builtin runs
10626                                              // under a redirect (because dup2 to fd 1 puts the file
10627                                              // there). Rust's `io::stdout()` is LINE-buffered, so
10628                                              // `printf "abcde"` (no `\n`) leaves bytes pending in the
10629                                              // userspace buffer; when the surrounding `> file` redirect
10630                                              // closes the dup'd fd, those bytes flush AFTER the restore
10631                                              // — landing on the original stdout (terminal) AND missing
10632                                              // the file. Flush here so the bytes hit fd 1 (the redirect
10633                                              // target) before bin_print returns. Bug #397.
10634            let _ = lk.flush();
10635        }
10636    }
10637    0
10638}
10639
10640/// Port of `bin_shift(char *name, char **argv, Options ops, UNUSED(int func))` from Src/builtin.c:5593.
10641/// C: `int bin_shift(char *name, char **argv, Options ops, UNUSED(int func))`
10642/// — shift positional params (or named arrays) by `num` positions; `-p`
10643/// pops from the right end.
10644/// WARNING: param names don't match C — Rust=(name, argv, _func) vs C=(name, argv, ops, func)
10645pub fn bin_shift(
10646    name: &str,
10647    argv: &[String], // c:5593
10648    ops: &options,
10649    _func: i32,
10650) -> i32 {
10651    let mut num: i32 = 1; // c:5595
10652    let mut ret: i32 = 0; // c:5595
10653    let mut idx = 0usize;
10654    queue_signals(); // c:5599
10655                     // c:5600-5605 — first arg parsed as math expr unless it's an array name.
10656    if !argv.is_empty() {
10657        // c:5600
10658        let first = &argv[0];
10659        // c:5600 — `if (!getaparam(*argv))` decides whether the arg is
10660        //          a numeric shift-count vs an array name. Check
10661        //          paramtab for a PM_ARRAY entry, not OS env.
10662        let is_array = {
10663            use {PM_ARRAY, PM_TYPE};
10664            let tab = paramtab().read().unwrap();
10665            tab.get(first)
10666                .map(|pm| PM_TYPE(pm.node.flags as u32) == PM_ARRAY)
10667                .unwrap_or(false)
10668        };
10669        if !is_array {
10670            // c:5600
10671            // c:5601 — `num = mathevali(*argv++);`. The previous Rust port
10672            // used `parse::<i32>()` which rejects any non-trivial
10673            // arithmetic: `shift 1+2` would silently return ret=1
10674            // instead of shifting by 3. Route through mathevali.
10675            num = mathevali(first).unwrap_or_else(|_| {
10676                ret = 1;
10677                0
10678            }) as i32; // c:5601
10679            idx = 1;
10680            // c:5602-5605 — `if (errflag) return 1;`.
10681            if ret != 0 || errflag.load(Relaxed) != 0 {
10682                unqueue_signals(); // c:5604
10683                return 1;
10684            }
10685        }
10686    }
10687
10688    // c:5608-5611 — `if (num < 0)` reject.
10689    if num < 0 {
10690        // c:5608
10691        unqueue_signals(); // c:5609
10692        zwarnnam(name, "argument to shift must be non-negative"); // c:5610
10693        return 1; // c:5611
10694    }
10695
10696    // c:5614-5635 — named-array shift loop.
10697    if idx < argv.len() {
10698        // c:5614
10699        for arr_name in &argv[idx..] {
10700            // c:5615
10701            // c:5616 — `if ((s = getaparam(*argv)))` else silent skip.
10702            //          Read paramtab directly; was approximating arrays
10703            //          as `:`-separated env values which is wrong (env
10704            //          can never carry array structure).
10705            let s: Vec<String> = {
10706                let tab = paramtab().read().unwrap();
10707                match tab.get(arr_name).and_then(|pm| pm.u_arr.clone()) {
10708                    Some(arr) => arr,
10709                    None => continue,
10710                }
10711            };
10712            // c:5617-5621 — arrlen_lt check.
10713            if (s.len() as i32) < num {
10714                // c:5617
10715                zwarnnam(name, "shift count must be <= $#"); // c:5618
10716                ret += 1; // c:5619
10717                continue; // c:5620
10718            }
10719            // c:5622-5634 — -p shifts off the right end, otherwise the left.
10720            let s2: Vec<String> = if OPT_ISSET(ops, b'p') {
10721                // c:5622
10722                s[..s.len() - num as usize].to_vec() // c:5625-5628
10723            } else {
10724                s[num as usize..].to_vec() // c:5631
10725            };
10726            // c:5633 — `setaparam(*argv, s);`. Write the shifted array
10727            //          back to paramtab as a proper PM_ARRAY. Was a
10728            //          fake: `env::set_var` + colon-joined fake-array
10729            //          which neither carries array structure nor
10730            //          reaches subsequent `${arr_name[@]}` expansions.
10731            setaparam(arr_name, s2);
10732        }
10733    } else {
10734        // c:5636-5654 — shift positional parameters ($1..$N).
10735        // Static-link path: positional params live in src/ported/vm_helper;
10736        // expose via PPARAMS Mutex<Vec<String>>.
10737        let mut pp = PPARAMS.lock().unwrap_or_else(|e| {
10738            PPARAMS.clear_poison();
10739            e.into_inner()
10740        });
10741        let l = pp.len() as i32;
10742        if num > l {
10743            // c:5636
10744            zwarnnam(name, "shift count must be <= $#"); // c:5637
10745            ret = 1; // c:5638
10746        } else if OPT_ISSET(ops, b'p') {
10747            // c:5641
10748            pp.truncate((l - num) as usize); // c:5642-5644
10749        } else {
10750            pp.drain(..num as usize); // c:5646-5650
10751        }
10752        // PPARAMS is the single source of truth. fusevm-side reads
10753        // route through exec.pparams() which reads PPARAMS, so the
10754        // shift is immediately visible — no exec.positional_params
10755        // mirror needed.
10756        drop(pp);
10757    }
10758    unqueue_signals(); // c:5658
10759    ret // c:5659
10760}
10761
10762/// Port of `bin_getopts(UNUSED(char *name), char **argv, UNUSED(Options ops), UNUSED(int func))` from Src/builtin.c:5672.
10763/// C: `int bin_getopts(UNUSED(char *name), char **argv, UNUSED(Options ops),
10764///                     UNUSED(int func))`.
10765///
10766/// POSIX getopts. Maintains state in $OPTIND (zoptind) and an internal
10767/// per-arg cursor (optcind). Reads from the script's positional params
10768/// when no extra args supplied, otherwise from the trailing argv.
10769/// WARNING: param names don't match C — Rust=(_name, argv, _func) vs C=(name, argv, ops, func)
10770pub fn bin_getopts(
10771    _name: &str,
10772    argv: &[String], // c:5672
10773    _ops: &options,
10774    _func: i32,
10775) -> i32 {
10776    if argv.len() < 2 {
10777        return 1;
10778    }
10779    // c:5675 — `char *optstr = unmetafy(*argv++, &lenoptstr); char *var = *argv++;`
10780    let optstr_full = argv[0].clone();
10781    let var = argv[1].clone();
10782    // c:5676 — `char **args = (*argv) ? argv : pparams;`
10783    let argv_rest: Vec<String> = argv[2..].to_vec();
10784    let args: Vec<String> = if !argv_rest.is_empty() {
10785        argv_rest
10786    } else {
10787        PPARAMS.lock().map(|p| p.clone()).unwrap_or_default()
10788    };
10789
10790    // c:Src/builtin.c:5680 — at entry, re-sync the internal zoptind
10791    // tracker against the user-visible $OPTIND param. zsh exposes
10792    // OPTIND as a writable param so scripts can reset it to 1 to
10793    // re-parse positionals; the Rust port had `ZOPTIND` as an atomic
10794    // that was the AUTHORITY (writes to $OPTIND didn't propagate
10795    // back), so `OPTIND=1` between two getopts loops left zoptind at
10796    // the post-loop value and the second pass returned immediately.
10797    let paramtab_oi = getiparam("OPTIND");
10798    let mut zoptind = if paramtab_oi >= 1 {
10799        paramtab_oi as i32
10800    } else {
10801        ZOPTIND.load(Relaxed)
10802    };
10803    // c:5681-5685 — `if (zoptind < 1) { zoptind = 1; optcind = 0; }`
10804    if zoptind < 1 {
10805        // c:5681
10806        zoptind = 1;
10807        OPTCIND.store(0, Relaxed);
10808    }
10809    // c:Src/builtin.c — when $OPTIND was just reset to 1 (i.e. the
10810    // user-visible param disagrees with the previous internal
10811    // pointer), reset optcind so the new pass starts at byte 0 of
10812    // the first option arg.
10813    let mut optcind = if paramtab_oi == 1 && ZOPTIND.load(Relaxed) != 1 {
10814        0
10815    } else {
10816        OPTCIND.load(Relaxed)
10817    };
10818
10819    // c:5686-5688 — `if (arrlen_lt(args, zoptind)) return 1;`
10820    if (args.len() as i32) < zoptind {
10821        // c:5686
10822        ZOPTIND.store(zoptind, Relaxed);
10823        return 1;
10824    }
10825
10826    // c:5691-5693 — `quiet = *optstr == ':'; optstr += quiet; lenoptstr -= quiet;`
10827    let (quiet, optstr) = if optstr_full.starts_with(':') {
10828        // c:5691
10829        (true, &optstr_full[1..])
10830    } else {
10831        (false, optstr_full.as_str())
10832    };
10833
10834    // c:5696 — `str = unmetafy(dupstring(args[zoptind - 1]), &lenstr);`
10835    let mut str_buf = args[(zoptind - 1) as usize].clone();
10836    let mut lenstr = str_buf.len() as i32;
10837    if lenstr == 0 {
10838        return 1;
10839    } // c:5697
10840
10841    // c:5699-5703 — bump to next arg if optcind exhausted current.
10842    if optcind >= lenstr {
10843        // c:5699
10844        optcind = 0;
10845        zoptind += 1;
10846        if zoptind as usize > args.len() {
10847            // c:5701
10848            ZOPTIND.store(zoptind, Relaxed);
10849            OPTCIND.store(optcind, Relaxed);
10850            setiparam("OPTIND", zoptind as i64); // c:5702
10851            return 1;
10852        }
10853        str_buf = args[(zoptind - 1) as usize].clone();
10854        lenstr = str_buf.len() as i32;
10855    }
10856
10857    // c:5705-5712 — first option char checks: not `-`/`+` → done; `--` → done.
10858    if optcind == 0 {
10859        // c:5705
10860        if lenstr < 2 || (!str_buf.starts_with('-') && !str_buf.starts_with('+')) {
10861            ZOPTIND.store(zoptind, Relaxed);
10862            OPTCIND.store(optcind, Relaxed);
10863            // c:5707 — mirror to $OPTIND so callers see the post-loop
10864            // pointer. Previous Rust port skipped this write on the
10865            // "no more options" exit; OPTIND stayed at the last
10866            // option arg index (-b) instead of advancing past it.
10867            setiparam("OPTIND", zoptind as i64);
10868            return 1;
10869        }
10870        if lenstr == 2 && &str_buf[..2] == "--" {
10871            // c:5708
10872            zoptind += 1;
10873            ZOPTIND.store(zoptind, Relaxed);
10874            OPTCIND.store(0, Relaxed);
10875            setiparam("OPTIND", zoptind as i64); // c:5711
10876            return 1;
10877        }
10878        optcind += 1;
10879    }
10880    // c:5715 — `opch = str[optcind++];`
10881    let opch = str_buf.as_bytes()[optcind as usize];
10882    optcind += 1;
10883
10884    // c:5716-5721 — `lenoptbuf = (str[0] == '+') ? 2 : 1; optbuf[lenoptbuf-1] = opch;`
10885    let plus = str_buf.starts_with('+');
10886    let optbuf: String = if plus {
10887        format!("+{}", opch as char)
10888    } else {
10889        format!("{}", opch as char)
10890    };
10891
10892    // c:5724-5740 — illegal option: `?` reply, OPTIND fixed under POSIXBUILTINS.
10893    let posix = isset(POSIXBUILTINS);
10894    let found = optstr.bytes().position(|b| b == opch);
10895    if opch == b':' || found.is_none() {
10896        // c:5724
10897        if posix {
10898            // c:5728
10899            optcind = 0;
10900            zoptind += 1;
10901        }
10902        // c:5731 — `setsparam(var, ztrdup(p));` where p = "?"
10903        setsparam(&var, "?");
10904        if quiet {
10905            // c:5733
10906            setsparam("OPTARG", &optbuf); // c:5734
10907        } else {
10908            let prefix = if plus { "+" } else { "-" };
10909            zwarn(&format!("bad option: {}{}", prefix, opch as char)); // c:5736
10910            setsparam("OPTARG", "");
10911        }
10912        ZOPTIND.store(zoptind, Relaxed);
10913        OPTCIND.store(optcind, Relaxed);
10914        // Sync OPTIND env var so callers can read.
10915        setiparam("OPTIND", zoptind as i64);
10916        return 0;
10917    }
10918
10919    // c:5744 — `if (p[1] == ':')` — required argument.
10920    let p = found.unwrap();
10921    let optstr_bytes = optstr.as_bytes();
10922    if p + 1 < optstr_bytes.len() && optstr_bytes[p + 1] == b':' {
10923        // c:5744
10924        if optcind == lenstr {
10925            // c:5745
10926            // c:5746 — argument in next arg.
10927            if zoptind as usize >= args.len() {
10928                // c:5747
10929                if posix {
10930                    optcind = 0;
10931                    zoptind += 1;
10932                }
10933                if quiet {
10934                    // c:5754
10935                    setsparam(&var, ":");
10936                    setsparam("OPTARG", &optbuf);
10937                } else {
10938                    setsparam(&var, "?");
10939                    setsparam("OPTARG", "");
10940                    let prefix = if plus { "+" } else { "-" };
10941                    zwarn(&format!(
10942                        "argument expected after {}{} option",
10943                        prefix, opch as char
10944                    )); // c:5760
10945                }
10946                ZOPTIND.store(zoptind, Relaxed);
10947                OPTCIND.store(optcind, Relaxed);
10948                setiparam("OPTIND", zoptind as i64);
10949                return 0;
10950            }
10951            // c:5763 — `p = ztrdup(args[zoptind++]);` — read args[zoptind]
10952            // then post-increment. zoptind now points one past the
10953            // arg-bearing flag's index.
10954            let p_arg = args[zoptind as usize].clone(); // c:5763
10955            zoptind += 1; // c:5763 post-increment
10956            setsparam("OPTARG", &p_arg); // c:5765
10957                                         // c:5771 — `optcind = 0; zoptind++;` — bump past the
10958                                         // consumed value arg too, so the NEXT getopts call sees
10959                                         // the arg AFTER the value. Previous Rust port skipped this
10960                                         // second increment, so the next iter re-read the consumed
10961                                         // value as if it were a new flag.
10962            optcind = 0; // c:5771
10963            zoptind += 1; // c:5772
10964        } else {
10965            // c:5774 — `p = metafy(str+optcind, lenstr-optcind, META_DUP);`
10966            let p_arg = str_buf[(optcind as usize)..].to_string();
10967            setsparam("OPTARG", &p_arg);
10968            optcind = 0;
10969            zoptind += 1;
10970        }
10971    } else {
10972        // c:5784 — `zsfree(zoptarg); zoptarg = ztrdup("");`
10973        setsparam("OPTARG", "");
10974    }
10975
10976    // c:5788 — `setsparam(var, metafy(optbuf, lenoptbuf, META_DUP));`
10977    setsparam(&var, &optbuf);
10978    ZOPTIND.store(zoptind, Relaxed);
10979    OPTCIND.store(optcind, Relaxed);
10980    setiparam("OPTIND", zoptind as i64);
10981    0 // c:5790
10982}
10983
10984/// Port of `bin_break(char *name, char **argv, UNUSED(Options ops), int func)` from Src/builtin.c:5809.
10985/// C: `int bin_break(char *name, char **argv, UNUSED(Options ops), int func)`
10986/// — handles BIN_BREAK / BIN_CONTINUE / BIN_RETURN / BIN_LOGOUT / BIN_EXIT.
10987/// WARNING: param names don't match C — Rust=(name, argv, func) vs C=(name, argv, ops, func)
10988pub fn bin_break(
10989    name: &str,
10990    argv: &[String], // c:5809
10991    _ops: &options,
10992    func: i32,
10993) -> i32 {
10994    // BIN_BREAK/CONTINUE/RETURN/EXIT/LOGOUT live at the top of this file
10995    // (c:5707-5712 in Src/builtin.c via the BUILTIN(...) table).
10996    // c:5811 — `int num = lastval, nump = 0, implicit;`
10997    let mut num: i32 = LASTVAL.load(Relaxed); // c:5811
10998    let mut nump = 0i32; // c:5811
10999    let implicit = argv.is_empty(); // c:5814
11000                                    // c:5815-5818 — first arg parsed as math expr.
11001    if !implicit {
11002        // c:5815
11003        num = mathevali(&argv[0]).unwrap_or(0) as i32; // c:5816
11004        nump = 1; // c:5817
11005    }
11006
11007    // c:5812-5814 — positive-num requirement for BIN_CONTINUE / BIN_BREAK.
11008    // C uses `zerrnam` (NOT zwarnnam) — it sets errflag so the whole
11009    // script/function aborts, matching zsh's `continue 0` behavior
11010    // (prints the error ONCE and stops the loop, rather than warning and
11011    // continuing to iterate).
11012    if nump > 0 && (func == BIN_CONTINUE || func == BIN_BREAK) && num <= 0 {
11013        // c:5812
11014        zerrnam(name, &format!("argument is not positive: {}", num)); // c:5813
11015        return 1; // c:5814
11016    }
11017
11018    let loops = LOOPS.load(Relaxed);
11019    match func {
11020        // c:5831-5842 — BIN_CONTINUE: must be in a loop, set contflag,
11021        // then fall through to BIN_BREAK's break-count assign.
11022        x if x == BIN_CONTINUE => {
11023            // c:5831
11024            if loops == 0 {
11025                // c:5832
11026                // c:Src/builtin.c:5828 — `zerrnam`, NOT `zwarnnam`.
11027                // `zerrnam` sets `errflag` which causes the calling
11028                // shell function (or script) to terminate after the
11029                // current command — `zwarnnam` just prints. Without
11030                // this, `foo() { break; echo "after"; }; foo` printed
11031                // "after" instead of aborting at the break. Bug #616.
11032                zerrnam(name, "not in while, until, select, or repeat loop"); // c:5828
11033                return 1; // c:5834
11034            }
11035            CONTFLAG.store(1, Relaxed); // c:5836 FALLTHROUGH
11036                                        // c:5837 — fallthrough to BIN_BREAK's loops==0 guard
11037                                        // (impossible here since we already returned above) +
11038                                        // break-count assign. Inlined directly. The previous
11039                                        // Rust port had a redundant `if loops == 0 { return 1 }`
11040                                        // dead-coded after the first guard.
11041            BREAKS.store(
11042                if nump != 0 { num.min(loops) } else { 1 }, // c:5842
11043                Relaxed,
11044            );
11045        }
11046        // c:5832-5838 — BIN_BREAK.
11047        x if x == BIN_BREAK => {
11048            // c:5832
11049            if loops == 0 {
11050                // c:5833
11051                // c:Src/builtin.c:5834 — `zerrnam` sets errflag.
11052                // Same fix as the BIN_CONTINUE arm above. Bug #616.
11053                zerrnam(name, "not in while, until, select, or repeat loop"); // c:5834
11054                return 1; // c:5835
11055            }
11056            BREAKS.store(
11057                if nump != 0 { num.min(loops) } else { 1 }, // c:5837
11058                Relaxed,
11059            );
11060        }
11061        // c:5839-5860 — BIN_RETURN.
11062        x if x == BIN_RETURN => {
11063            let interactive = isset(INTERACTIVE);
11064            let shinstdin = isset(SHINSTDIN);
11065            let ll_v = locallevel_param.load(Relaxed);
11066            let sourcelevel = crate::ported::init::sourcelevel.load(Relaxed);
11067            // c:5840-5841 — `if ((interactive && shinstdin) || locallevel || sourcelevel)`
11068            if (interactive && shinstdin) || ll_v != 0 || sourcelevel != 0 {
11069                // c:5840
11070                RETFLAG.store(1, Relaxed); // c:5842
11071                BREAKS.store(loops, Relaxed); // c:5843
11072                LASTVAL.store(num, Relaxed); // c:5844
11073                                             // c:5845-5854 — inside a primed trap with the sentinel
11074                                             // `trap_return == -2`, promote to TRAP_STATE_FORCE_RETURN
11075                                             // and carry `lastval`. POSIXTRAPS + `implicit` opts out:
11076                                             // POSIX semantics keep $? from before the trap fired.
11077                let posixtraps = isset(POSIXTRAPS);
11078                let cur_state = TRAP_STATE.load(Relaxed);
11079                let cur_return = TRAP_RETURN.load(Relaxed);
11080                if cur_state == TRAP_STATE_PRIMED      // c:5845
11081                    && cur_return == -2                                      // c:5845
11082                    && !(posixtraps && implicit)
11083                // c:5851
11084                {
11085                    TRAP_STATE.store(
11086                        // c:5852
11087                        TRAP_STATE_FORCE_RETURN,
11088                        Relaxed,
11089                    );
11090                    TRAP_RETURN.store(num, Relaxed);
11091                    // c:5853
11092                }
11093                return num; // c:5855
11094            }
11095            // c:5858 — fallthrough: treat as logout/exit.
11096            zexit(num, ZEXIT_NORMAL); // c:5858
11097        }
11098        // c:5864-5869 — BIN_LOGOUT: refuse if not LOGINSHELL, then
11099        // FALLTHROUGH into the BIN_EXIT body. The previous Rust port
11100        // called \`zexit(num, ZEXIT_NORMAL)\` directly instead of
11101        // entering the BIN_EXIT defer-guard, so \`logout\` from inside
11102        // a function would skip EXIT traps + function unwind +
11103        // \"you have running jobs\" warning — same gap as the prior
11104        // BIN_EXIT fix.
11105        x if x == BIN_LOGOUT => {
11106            // c:5865 — `if (unset(LOGINSHELL))`. The previous Rust port
11107            // called `optlookup("login")` — but "login" is the
11108            // SHELL-LETTER-FLAG name (zshletters table letter 'l'),
11109            // not an option name. Option name canonicalization maps
11110            // LOGINSHELL → "loginshell" (Src/options.c index_to_name
11111            // at line 1682 in Rust port).
11112            //
11113            // \`optlookup(\"login\")\` returns OPT_INVALID (0), so
11114            // \`isset(0)\` always returns false — bin_logout always
11115            // saw \"not login shell\" and rejected with that error
11116            // regardless of whether the shell was actually started
11117            // with \`-l\`.
11118            let loginshell = isset(LOGINSHELL);
11119            if !loginshell {
11120                // c:5865
11121                // c:Src/builtin.c:5861 — `zerrnam(name, "not login
11122                // shell");`. C uses zerrnam (sets ERRFLAG_ERROR via
11123                // utils.c:203), NOT zwarnnam. The errflag set aborts
11124                // the remainder of the current command list — `logout
11125                // 2>&1; print ex:$?` in a non-login shell prints only
11126                // the error message because the `;` separator's next
11127                // command is gated on errflag-clear. Previous Rust
11128                // port used zwarnnam (no errflag) so `print` still
11129                // ran. Pinned by logout_builtin_stderr parity probe.
11130                zerrnam(name, "not login shell"); // c:5861
11131                return 1; // c:5862
11132            }
11133            // c:5869 — `/*FALLTHROUGH*/` into BIN_EXIT body.
11134            // Reusing the BIN_EXIT branch below by setting `func` to
11135            // BIN_EXIT isn't possible mid-match; inline the same
11136            // guard logic here.
11137            let cur_locallevel = locallevel_param.load(Relaxed);
11138            let forklevel = FORKLEVEL.load(Relaxed);
11139            let shell_exiting = SHELL_EXITING.load(Relaxed);
11140            if cur_locallevel > forklevel && shell_exiting != -1 {
11141                // c:5871
11142                if STOPMSG.load(Relaxed) == 0 {
11143                    zexit(0, ZEXIT_DEFERRED); // c:5884
11144                }
11145                if STOPMSG.load(Relaxed) == 0 {
11146                    // c:5884
11147                    let trap_state = TRAP_STATE.load(Relaxed);
11148                    if trap_state != 0 {
11149                        // c:5885
11150                        TRAP_STATE.store(
11151                            // c:5886
11152                            TRAP_STATE_FORCE_RETURN,
11153                            Relaxed,
11154                        );
11155                    }
11156                    RETFLAG.store(1, Relaxed); // c:5887
11157                    BREAKS.store(
11158                        LOOPS.load(Relaxed), // c:5888
11159                        Relaxed,
11160                    );
11161                    EXIT_PENDING.store(1, Relaxed); // c:5889
11162                    EXIT_LEVEL.store(cur_locallevel, Relaxed); // c:5890 — exit_level = locallevel;
11163                    EXIT_VAL.store(num, Relaxed); // c:5891
11164                }
11165            } else {
11166                zexit(num, ZEXIT_NORMAL); // c:5894
11167            }
11168        }
11169        // c:5870-5894 — BIN_EXIT: function-context guard. C body:
11170        //   if (locallevel > forklevel && shell_exiting != -1) {
11171        //       if (stopmsg || (zexit(0, ZEXIT_DEFERRED), !stopmsg)) {
11172        //           if (trap_state) trap_state = TRAP_STATE_FORCE_RETURN;
11173        //           retflag = 1; breaks = loops;
11174        //           exit_pending = 1; exit_level = locallevel; exit_val = num;
11175        //       }
11176        //   } else zexit(num, ZEXIT_NORMAL);
11177        //
11178        // Inside a function (locallevel > forklevel) the shell can't
11179        // exit directly — EXIT traps still need to run. The probe
11180        // path zexit(0, ZEXIT_DEFERRED) calls checkjobs; if no
11181        // stopmsg triggered, we defer: set retflag + breaks +
11182        // exit_pending so the function unwind takes us out.
11183        //
11184        // The previous Rust port skipped this entire guard, always
11185        // calling zexit(num, ZEXIT_NORMAL) directly. `exit` inside
11186        // a function would terminate without running EXIT traps or
11187        // unwinding the function stack.
11188        x if x == BIN_EXIT => {
11189            let cur_locallevel = locallevel_param.load(Relaxed);
11190            let forklevel = FORKLEVEL.load(Relaxed);
11191            let shell_exiting = SHELL_EXITING.load(Relaxed);
11192            if cur_locallevel > forklevel && shell_exiting != -1 {
11193                // c:5871
11194                // Probe via ZEXIT_DEFERRED — may set stopmsg.
11195                if STOPMSG.load(Relaxed) == 0 {
11196                    zexit(0, ZEXIT_DEFERRED); // c:5884
11197                }
11198                if STOPMSG.load(Relaxed) == 0 {
11199                    // c:5884 still no stopmsg → defer
11200                    let trap_state = TRAP_STATE.load(Relaxed);
11201                    if trap_state != 0 {
11202                        // c:5885
11203                        TRAP_STATE.store(
11204                            // c:5886
11205                            TRAP_STATE_FORCE_RETURN,
11206                            Relaxed,
11207                        );
11208                    }
11209                    RETFLAG.store(1, Relaxed); // c:5887
11210                    BREAKS.store(
11211                        LOOPS.load(Relaxed), // c:5888
11212                        Relaxed,
11213                    );
11214                    EXIT_PENDING.store(1, Relaxed); // c:5889
11215                    EXIT_LEVEL.store(cur_locallevel, Relaxed); // c:5890 — exit_level = locallevel;
11216                    EXIT_VAL.store(num, Relaxed); // c:5891
11217                }
11218            } else {
11219                zexit(num, ZEXIT_NORMAL); // c:5894
11220            }
11221        }
11222        _ => {}
11223    }
11224    0
11225}
11226
11227/// Port of `checkjobs()` from Src/builtin.c:5899.
11228/// C: `static void checkjobs(void)` — walk `jobtab[1..maxjob]`; for each
11229///   non-current job that's STAT_LOCKED, not STAT_NOPRINT, and either
11230///   running (when CHECKRUNNINGJOBS is set) or STAT_STOPPED, emit
11231///   "you have running/stopped jobs" + set `stopmsg = 1`.
11232pub fn checkjobs() {
11233    // c:5899
11234    let checkrunning = isset(CHECKRUNNINGJOBS);
11235    // c:5901 — read the canonical jobs.rs THISJOB/MAXJOB globals.
11236    // The previous builtin.rs duplicate AtomicI32s for both never
11237    // synced with the jobs.rs Mutex<i32> values that the spawn /
11238    // wait paths actually update — checkjobs would see stale 0s
11239    // regardless of how many jobs were active.
11240    let thisjob: i32 = *crate::ported::jobs::THISJOB
11241        .get_or_init(|| Mutex::new(-1_i32))
11242        .lock()
11243        .expect("THISJOB poisoned");
11244    // jobs::MAXJOB is stored as `Mutex<usize>` (Rust adaptation for
11245    // Vec-index semantics); cast to i32 for comparison with `thisjob`.
11246    let maxjob: i32 = *crate::ported::jobs::MAXJOB
11247        .get_or_init(|| Mutex::new(0_usize))
11248        .lock()
11249        .expect("MAXJOB poisoned") as i32;
11250
11251    // c:5903 — `for (i = 1; i <= maxjob; i++)`
11252    let mut found: Option<i32> = None;
11253    let mut found_stat: i32 = 0;
11254    for i in 1..=maxjob {
11255        // c:5903
11256        let stat = JOBSTATS
11257            .lock()
11258            .ok()
11259            .and_then(|t| t.get(i as usize).copied())
11260            .unwrap_or(0);
11261        // c:5904-5906 — `i != thisjob && (stat & STAT_LOCKED) &&
11262        //                !(stat & STAT_NOPRINT) &&
11263        //                (CHECKRUNNINGJOBS || stat & STAT_STOPPED)`
11264        if i != thisjob                                                      // c:5904
11265            && (stat & STAT_LOCKED) != 0                                     // c:5904
11266            && (stat & STAT_NOPRINT) == 0                                    // c:5905
11267            && (checkrunning || (stat & STAT_STOPPED) != 0)
11268        // c:5906
11269        {
11270            found = Some(i); // c:5907
11271            found_stat = stat;
11272            break;
11273        }
11274    }
11275    // c:5908 — `if (i <= maxjob)`
11276    if found.is_some() {
11277        // c:5908
11278        if (found_stat & STAT_STOPPED) != 0 {
11279            // c:5909
11280            // c:5912/5914 — `zerr("you have suspended/stopped jobs.");`
11281            zerr("you have stopped jobs."); // c:5914
11282        } else {
11283            // c:5917 — `zerr("you have running jobs.");`
11284            zerr("you have running jobs."); // c:5917
11285        }
11286        STOPMSG.store(1, Relaxed); // c:5919
11287    }
11288}
11289
11290/// Port of `realexit()` from Src/builtin.c:5953.
11291/// C body (single statement):
11292///     `exit((shell_exiting || exit_pending) ? exit_val : lastval);`
11293pub fn realexit() -> ! {
11294    // c:5953
11295    std::process::exit(
11296        if SHELL_EXITING.load(Relaxed) != 0 || EXIT_PENDING.load(Relaxed) != 0 {
11297            EXIT_VAL.load(Relaxed)
11298        } else {
11299            LASTVAL.load(Relaxed)
11300        },
11301    );
11302}
11303
11304/// Port of `_realexit()` from Src/builtin.c:5962.
11305/// C body (single statement):
11306///     `_exit((shell_exiting || exit_pending) ? exit_val : lastval);`
11307pub fn _realexit() -> ! {
11308    // c:5962
11309    unsafe {
11310        libc::_exit(
11311            if SHELL_EXITING.load(Relaxed) != 0 || EXIT_PENDING.load(Relaxed) != 0 {
11312                EXIT_VAL.load(Relaxed)
11313            } else {
11314                LASTVAL.load(Relaxed)
11315            },
11316        )
11317    }
11318}
11319
11320/// Port of `zexit(int val, enum zexit_t from_where)` from Src/builtin.c:5977.
11321/// C: `void zexit(int val, enum zexit_t from_where)` — record exit
11322///   value, fire EXIT trap unless already exiting, then realexit.
11323#[allow(unused_variables)]
11324pub fn zexit(val: i32, from_where: i32) {
11325    // c:5977
11326    // c:5989 — `exit_val = val;`
11327    EXIT_VAL.store(val, Relaxed); // c:5989
11328                                  // c:5990 — `if (shell_exiting == -1) { retflag = 1; breaks = loops; return; }`
11329    if SHELL_EXITING.load(Relaxed) == -1 {
11330        // c:5990
11331        RETFLAG.store(1, Relaxed); // c:5991
11332        BREAKS.store(LOOPS.load(Relaxed), Relaxed); // c:5992
11333        return; // c:5993
11334    }
11335
11336    // c:5996-6004 — `if (isset(MONITOR) && !stopmsg && from_where != ZEXIT_SIGNAL)`:
11337    // run scanjobs + checkjobs; if stopmsg got set (running jobs warned),
11338    // mark stopmsg=2 and DEFER the exit. The previous Rust port skipped
11339    // this entire block, so `exit` with running jobs would terminate
11340    // immediately rather than emitting the standard
11341    // \"zsh: you have running jobs\" + waiting for a confirmation exit.
11342    if isset(MONITOR)                                  // c:5996
11343        && STOPMSG.load(Relaxed) == 0
11344        && from_where != ZEXIT_SIGNAL
11345    {
11346        checkjobs(); // c:5999
11347        if STOPMSG.load(Relaxed) != 0 {
11348            // c:6000
11349            STOPMSG.store(2, Relaxed); // c:6001
11350            return; // c:6002 defer
11351        }
11352    }
11353    // c:6006-6008 — `if (from_where == ZEXIT_DEFERRED || (shell_exiting++
11354    //                 && from_where != ZEXIT_NORMAL)) return;`. Probe path:
11355    // ZEXIT_DEFERRED callers only want the checkjobs gate to fire; if
11356    // it didn't trip, return without actually exiting.
11357    if from_where == ZEXIT_DEFERRED {
11358        // c:6006
11359        return;
11360    }
11361    let prev_exiting = SHELL_EXITING.fetch_add(1, Relaxed);
11362    if prev_exiting != 0 && from_where != ZEXIT_NORMAL {
11363        // c:6007
11364        return;
11365    }
11366    // c:6014 — `shell_exiting = -1;`
11367    SHELL_EXITING.store(-1, Relaxed); // c:6014
11368                                      // c:6019 — `errflag = 0;`
11369    errflag.store(0, Relaxed); // c:6019
11370                               // c:6021-6024 — MONITOR → killrunjobs.
11371    if isset(MONITOR) {
11372        // c:6021
11373        crate::ported::signals::killrunjobs(if from_where == ZEXIT_SIGNAL { 1 } else { 0 });
11374        // c:6023
11375    }
11376    // !!! RUST-ONLY GATE: see SUBSHELL_DEPTH declaration above for
11377    // rationale. C zsh's realexit at c:5953 unconditionally calls
11378    // process::exit because the subshell was forked; in zshrs the
11379    // subshell runs in-process, so process::exit would kill the
11380    // whole shell. Defer: set EXIT_PENDING + EXIT_VAL + reset
11381    // SHELL_EXITING so the subshell_end unwind in fusevm_bridge
11382    // catches and propagates the status to the parent.
11383    if SUBSHELL_DEPTH.load(Relaxed) > 0 {
11384        SHELL_EXITING.store(0, Relaxed);
11385        EXIT_VAL.store(val, Relaxed);
11386        EXIT_PENDING.store(1, Relaxed);
11387        RETFLAG.store(1, Relaxed);
11388        BREAKS.store(LOOPS.load(Relaxed), Relaxed);
11389        return;
11390    }
11391    // c:6012 — `cleanfilelists();` — delete per-job temp-file lists
11392    // before exit. jobs.rs::cleanfilelists is a Rust-only-signature
11393    // adapter over the C global jobtab (see its WARNING block).
11394    if let Some(tab) = crate::ported::jobs::JOBTAB.get() {
11395        let mut tab = tab.lock().unwrap_or_else(|e| e.into_inner());
11396        crate::ported::jobs::cleanfilelists(&mut tab); // c:6012
11397    }
11398    // c:6013-6028 — `if (isset(RCS) && interact)`: save the history
11399    // file and run logout scripts. This is THE write-at-exit path for
11400    // interactive history (`-f`/NO_RCS shells intentionally skip it,
11401    // matching C).
11402    if isset(RCS) && interact() {
11403        // c:6013
11404        // c:6014-6020 — `if (!nohistsave) { ... }`. `nohistsave` isn't
11405        // ported as a Rust global (same approximation as
11406        // hist.rs::saveandpophiststack); default 0 = allow saves.
11407        let mut writeflags = HFILE_USE_OPTIONS as i32; // c:6015
11408        if from_where == ZEXIT_SIGNAL {
11409            // c:6016
11410            writeflags |= HFILE_NO_REWRITE as i32; // c:6017
11411        }
11412        saveandpophiststack(1, writeflags); // c:6018
11413        savehistfile(None, writeflags); // c:6019
11414        // c:6021-6027 — `if (islogin && !subsh) { sourcehome(".zlogout");
11415        // ... source(GLOBAL_ZLOGOUT); }`. The C `subsh` check is covered
11416        // by the RUST-ONLY SUBSHELL_DEPTH gate above (in-process
11417        // subshells returned before reaching here).
11418        if islogin() {
11419            // c:6021
11420            crate::ported::init::sourcehome(".zlogout"); // c:6022
11421            if isset(RCS) && isset(GLOBALRCS) {
11422                // c:6024
11423                let _ = crate::ported::init::source(crate::ported::config_h::GLOBAL_ZLOGOUT);
11424                // c:6025
11425            }
11426        }
11427    }
11428    // c:Src/builtin.c:6075-6079 — fire EXIT trap (SIGEXIT) before
11429    // calling realexit. The trap body sees $? = val (carried via
11430    // LASTVAL below) and runs in the shell process. Remove the
11431    // entry from traps_table first so the trap body's own commands
11432    // don't re-trigger it recursively.
11433    //
11434    // Two trap-storage paths: (a) `trap 'cmd' EXIT` stores raw
11435    // body text in `traps_table` (no settrap), handled by the
11436    // direct execute_script below; (b) `TRAPEXIT() { ... }`
11437    // function-named form goes through settrap(SIGEXIT, None,
11438    // ZSIG_FUNC) at funcdef time and lives in the
11439    // shfunctab+sigtrapped pair, dispatched via `dotrap(SIGEXIT)`.
11440    // C zsh's dotrap handles BOTH paths internally; Rust splits
11441    // them. Call dotrap to cover the function-named form. Bug
11442    // #157 in docs/BUGS.md.
11443    let exit_trap = traps_table().lock().ok().and_then(|mut t| t.remove("EXIT"));
11444    LASTVAL.store(val, Relaxed); // c:6076 — $? inside trap = val
11445    crate::ported::signals::in_exit_trap.store(1, Relaxed);
11446    if let Some(body) = exit_trap {
11447        // Set LASTVAL to the requested exit value so `$?` inside
11448        // the trap body sees the right number (matches `(exit 7)`
11449        // → trap body reads $?=7).
11450        let _ = crate::ported::exec::execute_script(&body);
11451    }
11452    // c:Src/signals.c::dotrap(SIGEXIT) — fire ZSIG_FUNC handler
11453    // installed by `TRAPEXIT() { ... }` (settrap with ZSIG_FUNC
11454    // sets sigtrapped[SIGEXIT] = ZSIG_TRAPPED | ZSIG_FUNC).
11455    let _ = crate::ported::signals::dotrap(crate::signals_h::SIGEXIT);
11456    // c:Src/init.c::zexit — `callhookfunc("zshexit", NULL, 1, NULL)`.
11457    // The hook fires both a `zshexit` function (if defined) AND
11458    // walks the `zshexit_functions` array. Distinct from SIGEXIT
11459    // trap dispatch above — zshexit is a zsh-specific shell-event
11460    // hook that operates parallel to trap. Bug #215 in docs/BUGS.md.
11461    let _ = crate::ported::utils::callhookfunc("zshexit", None, 1, std::ptr::null_mut());
11462    crate::ported::signals::in_exit_trap.store(0, Relaxed);
11463    realexit(); // c:6082
11464}
11465
11466/// Port of `bin_dot(char *name, char **argv, UNUSED(Options ops), UNUSED(int func))` from Src/builtin.c:6060.
11467/// C: `int bin_dot(char *name, char **argv, ...)` — `.` / `source`
11468///   builtin: locate script (cwd → first `/`-bearing path → $path search)
11469///   and execute it; positional params shift to argv[1..].
11470/// WARNING: param names don't match C — Rust=(name, argv, _func) vs C=(name, argv, ops, func)
11471pub fn bin_dot(
11472    name: &str,
11473    argv: &[String], // c:6060
11474    _ops: &options,
11475    _func: i32,
11476) -> i32 {
11477    if argv.is_empty() {
11478        // c:6068
11479        return 0; // c:6069
11480    }
11481
11482    // PFA-SMR aspect: record the source path so the replay tool can
11483    // re-apply the same source/dot at the same call site.
11484    #[cfg(feature = "recorder")]
11485    if crate::recorder::is_enabled() && !argv[0].is_empty() {
11486        let ctx = crate::recorder::recorder_ctx_global();
11487        crate::recorder::emit_source(&argv[0], ctx);
11488    }
11489    // c:6071-6074 — save pparams, install argv[1..] as new pparams.
11490    let saved_pparams: Option<Vec<String>> = if argv.len() > 1 {
11491        // c:6072
11492        let mut pp = PPARAMS.lock().unwrap_or_else(|e| {
11493            PPARAMS.clear_poison();
11494            e.into_inner()
11495        });
11496        let saved = pp.clone();
11497        *pp = argv[1..].to_vec(); // c:6073
11498        Some(saved)
11499    } else {
11500        None
11501    };
11502
11503    let arg0 = argv[0].clone(); // c:6076
11504    let _enam = arg0.clone(); // c:6076
11505                              // c:6077-6080 — `if (isset(FUNCTIONARGZERO)) { old0 = argzero;
11506                              //                                              argzero = ztrdup(arg0); }`.
11507                              // Save the prior argzero so it can be restored at the end of
11508                              // bin_dot; under FUNCTIONARGZERO, the sourced file becomes the
11509                              // active $0 for the duration of the source.
11510    let saved_argzero: Option<Option<String>> = if isset(FUNCTIONARGZERO) {
11511        let prev = argzero();
11512        set_argzero(Some(arg0.clone()));
11513        Some(prev)
11514    } else {
11515        None
11516    };
11517    let mut diddot = 0i32; // c:6064
11518    let mut dotdot = 0i32; // c:6064
11519
11520    // c:6087-6093 — for `source`, try cwd first.
11521    let mut found_path: Option<String> = None;
11522    if !name.starts_with('.') {
11523        // c:6087
11524        let p = Path::new(&arg0);
11525        if p.exists() && !p.is_dir() {
11526            // c:6088-6089
11527            diddot = 1; // c:6090
11528            found_path = Some(arg0.clone()); // c:6091 (effective)
11529        }
11530    }
11531
11532    // c:6094-6101 — try literal path with `/` in it.
11533    if found_path.is_none() && arg0.contains('/') {
11534        // c:6096
11535        if arg0.starts_with("./") {
11536            diddot += 1;
11537        }
11538        // c:6097
11539        else if arg0.starts_with("../") {
11540            dotdot += 1;
11541        } // c:6098
11542        let p = Path::new(&arg0);
11543        if p.exists() && !p.is_dir() {
11544            found_path = Some(arg0.clone()); // c:6100
11545        } else if Path::new(&format!("{}.zwc", arg0)).is_file() {
11546            // c:6100 — C calls `source(arg0)` UNCONDITIONALLY for a
11547            // slash path; `source()` (init.c:1566) then loads the
11548            // sibling `<arg0>.zwc` via try_source_file even when the
11549            // plain file is gone. zshrs's pre-resolved found_path
11550            // gate must therefore accept the compiled form too —
11551            // the try_source_file hook at the read site below
11552            // supplies the body.
11553            found_path = Some(arg0.clone());
11554        }
11555    }
11556
11557    // c:6102-6121 — $path search (with PATHDIRS guard).
11558    let pathdirs = isset(PATHDIRS);
11559    if found_path.is_none() && (!arg0.contains('/') || (pathdirs && diddot < 2 && dotdot == 0)) {
11560        // c:6102
11561        // c:6103 — `for (pp = path; *pp; pp++)`. C walks the `path[]`
11562        //          array (the shell-side $path), not the colon-joined
11563        //          $PATH env. Read $PATH from paramtab (the shell
11564        //          string view); the colon-split below mirrors the C
11565        //          path[] iteration.
11566        let path_env = getsparam("PATH").unwrap_or_default();
11567        for dir in path_env.split(':') {
11568            // c:6107
11569            let buf = if dir.is_empty() || dir == "." {
11570                // c:6108
11571                if diddot != 0 {
11572                    continue;
11573                }
11574                diddot = 1; // c:6111
11575                arg0.clone() // c:6112
11576            } else {
11577                format!("{}/{}", dir, arg0) // c:6114
11578            };
11579            let p = Path::new(&buf);
11580            if p.exists() && !p.is_dir() {
11581                // c:6117-6118
11582                found_path = Some(buf); // c:6119
11583                break;
11584            }
11585        }
11586    }
11587
11588    // c:6125-6128 — restore pparams. C does this AFTER source() runs
11589    // so the sourced script sees the new positional args; bug #459 in
11590    // docs/BUGS.md — the previous Rust port restored here BEFORE the
11591    // file body executed, so `source script.zsh hello world` had the
11592    // script see $#=0 / $@="". Defer the restore until after
11593    // execute_script returns (the success path below + the error path
11594    // for file-not-found).
11595
11596    // c:6130-6137 — error path. C: `if (ret == SOURCE_NOT_FOUND)`
11597    // emits via zerrnam (POSIX) / zwarnnam (default). The Rust port
11598    // uses zwarnnam unconditionally because the POSIX hard-error
11599    // path also calls zerrnam which behaves identically here (both
11600    // route through zwarning); the only difference C makes is
11601    // promoting errflag to ERRFLAG_ERROR which already happens
11602    // inside zwarnnam.
11603    let path = match found_path {
11604        Some(p) => p,
11605        None => {
11606            // c:6149 — restore argzero on the error path BEFORE
11607            // emitting the diagnostic and returning. C's structure is
11608            // `source(); if (isset(FUNCTIONARGZERO)) restore`; for the
11609            // file-not-found early-return, the restore still has to
11610            // happen so the outer shell's $0 doesn't keep the dropped
11611            // arg0. Bug #103 in docs/BUGS.md: the previous port did
11612            // this unconditional pre-check restore which ran BEFORE
11613            // `execute_script` on the file-found branch — clobbering
11614            // the just-set arg0 so the sourced script saw the shell
11615            // binary as $0 instead of its own path.
11616            if let Some(prev) = saved_argzero.clone() {
11617                set_argzero(prev);
11618            }
11619            // c:6126-6128 — also restore pparams on the not-found path,
11620            // matching C's c:6125 unconditional cleanup before return.
11621            if let Some(saved) = saved_pparams.clone() {
11622                let mut pp = PPARAMS.lock().unwrap_or_else(|e| {
11623                    PPARAMS.clear_poison();
11624                    e.into_inner()
11625                });
11626                *pp = saved;
11627            }
11628            // c:6130-6137 — `if (isset(POSIXBUILTINS)) zerrnam(...)
11629            // else zwarnnam(...)`. zerrnam sets ERRFLAG_ERROR ("hard
11630            // error in POSIX (we'll exit later)" — the exec.c done:
11631            // gate at c:4378 turns it into exit(1) for the PSPECIAL
11632            // `.`); zwarnnam does NOT touch errflag, so without
11633            // POSIX_BUILTINS the script continues.
11634            let msg = format!("{}: {}", "no such file or directory", arg0); // c:6135
11635            if isset(crate::ported::zsh_h::POSIXBUILTINS) {
11636                crate::ported::utils::zerrnam(name, &msg); // c:6133
11637            } else {
11638                zwarnnam(name, &msg); // c:6135
11639            }
11640            // c:6143 — `return ret == SOURCE_OK ? lastval : 128 - ret`.
11641            // SOURCE_NOT_FOUND = 1 (Src/zsh.h:2214) → 128 - 1 = 127.
11642            return 128 - 1;
11643        }
11644    };
11645
11646    // c:6140 — `ret = source(enam = buf);`
11647    // C `source()` lives at Src/init.c:1550. It opens the file, sets
11648    // up sourcelevel + scriptname + funcstack, parses + executes via
11649    // the wordcode walker, then unwinds. Rust port reads the file
11650    // and routes the body through fusevm's `execute_script` — the
11651    // VM's parse + compile + run loop is the analog of C's
11652    // loop/execlist tree walk. Errors during execution propagate
11653    // through `lastval`; missing read returns SOURCE_ERROR (128-2 =
11654    // 126) per c:6143.
11655    //
11656    // crate::ported::init::sourcelevel bump (Src/init.c:1606 `sourcelevel++;` /
11657    // c:1644 `sourcelevel--;`) is REQUIRED for `return` inside the
11658    // sourced file to unwind correctly. bin_break (Src/builtin.c:5840)
11659    // checks `(interactive && shinstdin) || locallevel || sourcelevel`
11660    // — without the bump, `return N` falls through to `zexit(num,
11661    // ZEXIT_NORMAL)` (c:5858) and kills the entire shell instead of
11662    // unwinding to the source caller. Also clear RETFLAG after the
11663    // sourced script returns so the unwind doesn't propagate to the
11664    // outer compile unit.
11665    // c:Src/init.c:1557-1558 — `char *old_scriptname = scriptname;
11666    // char *old_scriptfilename = scriptfilename;`
11667    // c:1591-1592 — `scriptname = s; scriptfilename = s;`
11668    // For the duration of source execution, both scriptname and
11669    // scriptfilename point to the sourced file path. zerr/zwarnnam
11670    // diagnostics emitted from inside the sourced file then use this
11671    // path as the prefix (`/path/to/foo:N: ...` instead of `zsh:1:`).
11672    // Bug #139 in docs/BUGS.md. The `path` here is the resolved
11673    // absolute path; C uses the user-supplied `s` (the bin_dot
11674    // argv[0]) but `arg0` (the user-supplied name) is what zsh
11675    // actually shows in diagnostics, so use arg0.
11676    let old_scriptname = crate::ported::utils::scriptname_get(); // c:1557
11677    let old_scriptfilename = crate::ported::utils::scriptfilename_get(); // c:1558
11678    crate::ported::utils::set_scriptname(Some(arg0.clone())); // c:1591
11679                                                              // c:1592 — `scriptfilename = s;` — PS4 `%x` reads this; without
11680                                                              // the store the bottom of an `xtrace` line stack stays "zsh"
11681                                                              // (the executor seed) even inside the sourced file. Bug shared
11682                                                              // with source_from_memory in bins/zshrs.rs.
11683    crate::ported::utils::set_scriptfilename(Some(arg0.clone())); // c:1592
11684
11685    crate::ported::init::sourcelevel.fetch_add(1, Relaxed); // c:1606
11686
11687    // c:Src/init.c:1608-1616 — push a funcstack frame with
11688    // `tp = FS_SOURCE` so prompt %x / xtrace / `$funcfiletrace`
11689    // resolve to the sourced file, not the calling function's
11690    // defining file. Without this push, sourcing a script from
11691    // inside a function left the funcstack TOP as the function's
11692    // own FS_FUNC frame; the prompt %x handler then reported the
11693    // function's `filename` (where the function was DEFINED) as
11694    // the current source file. Repro: powerlevel10k.zsh-theme's
11695    // `__p9k_root_dir=${${(%):-%x}:A:h}` resolved to BIN_DIR
11696    // (zinit.zsh's dir) instead of the theme's plugin dir, so the
11697    // theme then sourced `$BIN_DIR/internal/p10k.zsh` (no such
11698    // file) instead of `$THEME_DIR/internal/p10k.zsh`.
11699    let prev_funcstack_lineno = crate::ported::input::lineno.with(|c| c.get()) as i64;
11700    let pushed_frame = {
11701        let mut stack = crate::ported::modules::parameter::FUNCSTACK
11702            .lock()
11703            .unwrap_or_else(|e| {
11704                crate::ported::modules::parameter::FUNCSTACK.clear_poison();
11705                e.into_inner()
11706            });
11707        let prev_caller = stack
11708            .last()
11709            .map(|fs| fs.name.clone())
11710            .or_else(|| old_scriptfilename.clone())
11711            .or_else(|| Some("zsh".to_string()));
11712        let frame = crate::ported::zsh_h::funcstack {
11713            prev: None,
11714            name: arg0.clone(),            // c:1608 fstack.name = scriptfilename
11715            filename: Some(arg0.clone()),  // c:1613 fstack.filename = scriptfilename
11716            caller: prev_caller,           // c:1609
11717            flineno: 0,                    // c:1611
11718            lineno: prev_funcstack_lineno, // c:1612
11719            tp: crate::ported::zsh_h::FS_SOURCE, // c:1615
11720        };
11721        stack.push(frame);
11722        true
11723    };
11724
11725    // c:Src/init.c:1566 — `source()` tries the compiled form FIRST:
11726    // `!(prog = try_source_file((us = unmeta(s)))) && (tempfd =
11727    // open(us, ...)) == -1` — a sibling `<file>.zwc` newer than the
11728    // file (or `s` itself being a `.zwc`) short-circuits the plain
11729    // read. zshrs executes through the fusevm text pipeline, so the
11730    // dump's wordcode bridges via getpermtext (same bridge as the
11731    // `.zwc` autoload path in exec.rs::loadautofn).
11732    let zwc_src = crate::ported::parse::try_source_file(&path)
11733        .map(|prog| crate::ported::text::getpermtext(Box::new(prog), None, 0));
11734    // c:Src/init.c:1620 — `errflag &= ~ERRFLAG_ERROR;` before
11735    // executing the sourced body, so a stale flag from the caller's
11736    // context can't abort the file's first list.
11737    crate::ported::utils::errflag.fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Relaxed);
11738    // Recursion backstop — c:Src/jobs.c:1878-1884. zsh runs the sourced
11739    // list through execpline, whose per-pipeline `initjob()` caps recursion
11740    // at MAX_MAXJOBS: `zerr("job table full or recursion limit exceeded")`
11741    // then bails. The fusevm pipeline path (execute_script below) doesn't
11742    // allocate a job per pipeline, so without this guard runaway
11743    // `. self`-style recursion — invisible to FUNCNEST, which counts only
11744    // FS_FUNC frames (doshfunc, exec.rs:5684) — grew the FS_SOURCE stack
11745    // unbounded and overflowed the 256 MB main-thread stack → uncatchable
11746    // SIGBUS. Total FUNCSTACK depth (incl. the FS_SOURCE frame just pushed)
11747    // is the proxy for zsh's concurrently-held job slots; at/over the
11748    // ceiling, raise the zsh-identical error and refuse the deeper body
11749    // (SOURCE_ERROR → 126, as zsh's bail returns). The frame is popped below.
11750    let over_limit = crate::ported::modules::parameter::FUNCSTACK
11751        .lock()
11752        .map(|s| s.len())
11753        .unwrap_or(0)
11754        >= crate::ported::jobs::MAX_MAXJOBS;
11755    let mut result = if over_limit {
11756        crate::ported::utils::zerr("job table full or recursion limit exceeded");
11757        128 - 2 // c:6143 — SOURCE_ERROR = 2 → 126
11758    } else {
11759        match zwc_src {
11760            Some(src) => crate::ported::exec::execute_script(&src).unwrap_or(1), // c:1566 prog path
11761            None => match fs::read_to_string(&path) {
11762                // c:6140
11763                Ok(src) => crate::ported::exec::execute_script(&src).unwrap_or(1),
11764                // c:6143 — SOURCE_ERROR = 2 (Src/zsh.h:2216) → 128 - 2 = 126.
11765                Err(_) => 128 - 2,
11766            },
11767        }
11768    };
11769    // c:Src/init.c:1623-1624 — `if (errflag) ret = SOURCE_ERROR;`
11770    // c:Src/init.c:1663 — `errflag &= ~ERRFLAG_ERROR;` on the restore
11771    // path. source is a CONTAINMENT boundary: an errflag abort inside
11772    // the sourced file breaks ITS lists, then source() clears the
11773    // flag, bin_dot returns 128 - SOURCE_ERROR = 126 (c:Src/builtin.c
11774    // :6143), and the CALLER continues. zsh 5.9: parent script
11775    // sourcing a file that hits `readonly` reassign prints its next
11776    // line with $? = 126.
11777    if (crate::ported::utils::errflag.load(Relaxed) & crate::ported::zsh_h::ERRFLAG_ERROR) != 0 {
11778        crate::ported::utils::errflag.fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Relaxed);
11779        result = 128 - 2; // c:6143 — 128 - SOURCE_ERROR
11780    }
11781    if pushed_frame {
11782        // c:1643 — `funcstack = funcstack->prev;`
11783        let mut stack = crate::ported::modules::parameter::FUNCSTACK
11784            .lock()
11785            .unwrap_or_else(|e| {
11786                crate::ported::modules::parameter::FUNCSTACK.clear_poison();
11787                e.into_inner()
11788            });
11789        stack.pop();
11790    }
11791    crate::ported::init::sourcelevel.fetch_sub(1, Relaxed); // c:1644
11792                                                            // c:1666 — `scriptname = old_scriptname;` and matching
11793                                                            // scriptfilename restore from the c:1667 line.
11794    crate::ported::utils::set_scriptname(old_scriptname);
11795    crate::ported::utils::set_scriptfilename(old_scriptfilename);
11796    // c:5842 RETFLAG is set by bin_break's BIN_RETURN arm. Once the
11797    // sourced file's execute_script unwinds, the return has been
11798    // serviced; clear the flag so the outer compile unit's main loop
11799    // (init.rs:1252's `if retflag break` guard) doesn't see a stale
11800    // request and abort `echo done` after `source foo`.
11801    RETFLAG.store(0, Relaxed); // c:5842 unwind
11802                               // c:6149 again — restore argzero on the success path as well.
11803    if let Some(prev) = saved_argzero {
11804        set_argzero(prev);
11805    }
11806    // c:6125-6128 — restore pparams on the success path AFTER the
11807    // source body has finished executing. Bug #459.
11808    if let Some(saved) = saved_pparams {
11809        let mut pp = PPARAMS.lock().unwrap_or_else(|e| {
11810            PPARAMS.clear_poison();
11811            e.into_inner()
11812        });
11813        *pp = saved;
11814    }
11815    result
11816}
11817
11818/// Port of `static int eval(char **argv)` from `Src/builtin.c:6151`.
11819pub fn eval(argv: &[String]) -> i32 {
11820    // c:6151
11821    // c:6153 — `Eprog prog;` (declared inline below)
11822    // c:6154 — `char *oscriptname = scriptname;`
11823    let oscriptname: Option<String> = scriptname_get();
11824    // c:6155 — `int oineval = ineval, fpushed;`
11825    let oineval: i32 = INEVAL.load(Relaxed);
11826    let fpushed: bool;
11827    // c:6156 — `struct funcstack fstack;`
11828
11829    // c:6163 — `ineval = !isset(EVALLINENO);`
11830    INEVAL.store(
11831        if !isset(crate::ported::zsh_h::EVALLINENO) {
11832            1
11833        } else {
11834            0
11835        },
11836        Relaxed,
11837    );
11838    let ineval_now = INEVAL.load(Relaxed) != 0;
11839
11840    if !ineval_now {
11841        // c:6164
11842        // c:6165 — `scriptname = "(eval)";`
11843        crate::ported::utils::set_scriptname(Some("(eval)".to_string()));
11844        // c:6166-6196 — funcstack push: build a fstack frame describing
11845        // this eval, link it to the head of FUNCSTACK.
11846        let prev_frame = {
11847            let stack = FUNCSTACK.lock().unwrap_or_else(|e| e.into_inner());
11848            stack.last().cloned()
11849        };
11850        let lineno_now = crate::ported::input::lineno.with(|c| c.get()) as i64;
11851        let caller = match &prev_frame {
11852            Some(p) => Some(p.name.clone()),
11853            None => argzero(), // c:6168 dupstring(argzero)
11854        };
11855
11856        // c:6182-6196 — flineno/filename derivation. Three cases:
11857        //   1. no prev frame OR prev tp == FS_SOURCE: flineno=lineno,
11858        //      filename=caller (the source name)
11859        //   2. prev tp == FS_EVAL: flineno = prev.flineno + lineno - 1
11860        //   3. otherwise (function): flineno = prev.flineno + lineno,
11861        //      filename = prev.filename or ""
11862        let (flineno, filename): (i64, Option<String>) = match &prev_frame {
11863            None => (lineno_now, caller.clone()), // c:6183-6184
11864            Some(p) if p.tp == crate::ported::zsh_h::FS_SOURCE => {
11865                (lineno_now, caller.clone()) // c:6183-6184
11866            }
11867            Some(p) => {
11868                let mut fl = p.flineno + lineno_now; // c:6186
11869                if p.tp == crate::ported::zsh_h::FS_EVAL {
11870                    // c:6191
11871                    fl -= 1; // c:6192
11872                }
11873                let fname = p.filename.clone().or_else(|| Some(String::new())); // c:6193-6195
11874                (fl, fname)
11875            }
11876        };
11877        let frame = crate::ported::zsh_h::funcstack {
11878            prev: None,                        // c:1349 — linked via FUNCSTACK vec
11879            name: "(eval)".to_string(),        // c:6167
11880            filename,                          // c:6184 / c:6193
11881            caller,                            // c:6168
11882            flineno,                           // c:6183 / c:6186
11883            lineno: lineno_now,                // c:6169
11884            tp: crate::ported::zsh_h::FS_EVAL, // c:6170
11885        };
11886        {
11887            let mut stack = FUNCSTACK.lock().unwrap_or_else(|e| e.into_inner());
11888            stack.push(frame); // c:6197 funcstack = &fstack
11889        }
11890        fpushed = true; // c:6199
11891    } else {
11892        fpushed = false; // c:6201
11893    }
11894
11895    // c:6203 — `prog = parse_string(zjoin(argv, ' ', 1), 1);`
11896    let joined = crate::ported::utils::zjoin(argv, ' ');
11897    let prog = crate::ported::exec::parse_string(&joined, 1);
11898
11899    if let Some(prog) = prog {
11900        // c:6205 — `if (wc_code(*prog->prog) != WC_LIST)`
11901        let head = prog.prog.first().copied().unwrap_or(0);
11902        if crate::ported::zsh_h::wc_code(head) != crate::ported::zsh_h::WC_LIST as u32 {
11903            /* No code to execute */
11904            // c:6206
11905            LASTVAL.store(0, Relaxed); // c:6207
11906        } else {
11907            // c:6209 — `execode(prog, 1, 0, "eval");`. Routes through
11908            // the executor; in-process equivalent.
11909            //
11910            // PREVIOUSLY called run_command_substitution which captures
11911            // stdout into a String and returns it — bin_eval threw the
11912            // capture away, so `eval 'echo hi'` produced no output.
11913            // execute_script_zsh_pipeline runs the script with stdout
11914            // flowing to the caller (no capture) which is what eval
11915            // wants. Same routing the eval-via-execstring path uses
11916            // (vm_helper.rs:1518 EXIT-trap fire).
11917            //
11918            // Recursion backstop — c:Src/jobs.c:1878-1884. zsh's per-pipeline
11919            // `initjob()` caps eval recursion at MAX_MAXJOBS ("job table full
11920            // or recursion limit exceeded"); the fusevm pipeline path doesn't
11921            // allocate a job per pipeline, so runaway `eval`-string recursion
11922            // (invisible to FUNCNEST, which counts FS_FUNC frames only, AND to
11923            // the FS_EVAL frame count — nested evals suppress the funcstack
11924            // push via INEVAL, c:6164, exactly like zsh) is otherwise unbounded
11925            // → main-thread stack overflow → SIGBUS. Track eval re-entry depth
11926            // directly (the Rust proxy for zsh's concurrently-held job slots,
11927            // one per nested eval pipeline) and cap at the same MAX_MAXJOBS
11928            // ceiling. Requested as a parity fix by the maintainer.
11929            let eval_depth = crate::vm_helper::EVAL_RECURSION_DEPTH.with(|d| {
11930                let v = d.get() + 1;
11931                d.set(v);
11932                v
11933            });
11934            if eval_depth >= crate::ported::jobs::MAX_MAXJOBS {
11935                crate::ported::utils::zerr("job table full or recursion limit exceeded");
11936                LASTVAL.store(1, Relaxed);
11937            } else {
11938                let _ = crate::ported::exec::execute_script_zsh_pipeline(&joined);
11939            }
11940            crate::vm_helper::EVAL_RECURSION_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
11941            // c:6211-6212 — `if (errflag && !lastval) lastval = errflag;`
11942            let ef = errflag.load(Relaxed);
11943            let lv = LASTVAL.load(Relaxed);
11944            if ef != 0 && lv == 0 {
11945                LASTVAL.store(ef, Relaxed);
11946            }
11947        }
11948    } else {
11949        LASTVAL.store(1, Relaxed); // c:6215
11950    }
11951
11952    if fpushed {
11953        // c:6218
11954        // c:6219 — `funcstack = funcstack->prev;`
11955        let mut stack = FUNCSTACK.lock().unwrap_or_else(|e| e.into_inner());
11956        stack.pop();
11957    }
11958
11959    // c:6221 — `errflag &= ~ERRFLAG_ERROR;`
11960    errflag.fetch_and(!ERRFLAG_ERROR, Relaxed);
11961    // c:6222 — `scriptname = oscriptname;`
11962    crate::ported::utils::set_scriptname(oscriptname);
11963    // c:6223 — `ineval = oineval;`
11964    INEVAL.store(oineval, Relaxed);
11965
11966    LASTVAL.load(Relaxed) // c:6225
11967}
11968
11969/// Port of `bin_emulate(char *nam, char **argv, Options ops, UNUSED(int func))` from Src/builtin.c:6232.
11970/// C: `int bin_emulate(char *nam, char **argv, Options ops, ...)` —
11971///   no-args print current emulation; single-arg switch emulation;
11972///   `-l` list, `-L` set LOCAL*, `-R` reset to defaults.
11973/// WARNING: param names don't match C — Rust=(nam, argv, _func) vs C=(nam, argv, ops, func)
11974pub fn bin_emulate(
11975    nam: &str,
11976    argv: &[String], // c:6232
11977    ops: &options,
11978    _func: i32,
11979) -> i32 {
11980    let opt_l = OPT_ISSET(ops, b'l'); // c:6236
11981    let opt_l_arg = OPT_ISSET(ops, b'L'); // c:6234
11982    let opt_r = OPT_ISSET(ops, b'R'); // c:6235
11983
11984    // c:6249-6275 — no args: print current emulation name.
11985    if argv.is_empty() {
11986        // c:6249
11987        if opt_l_arg || opt_r {
11988            // c:6250
11989            zwarnnam(nam, "not enough arguments"); // c:6251
11990            return 1; // c:6252
11991        }
11992        // c:6255-6271 — `switch(SHELL_EMULATION())` → name dispatch.
11993        let bits = emulation.load(Relaxed) as i32;
11994        let shname = if (bits & EMULATE_CSH) != 0 {
11995            "csh"
11996        }
11997        // c:6255
11998        else if (bits & EMULATE_KSH) != 0 {
11999            "ksh"
12000        }
12001        // c:6259
12002        else if (bits & EMULATE_SH) != 0 {
12003            "sh"
12004        }
12005        // c:6263
12006        else {
12007            "zsh"
12008        }; // c:6268
12009        println!("{}", shname); // c:6273
12010        return 0; // c:6274
12011    }
12012
12013    // c:6278-6295 — single-arg form: `emulate <shname>`.
12014    let shname = &argv[0];
12015    if argv.len() == 1 {
12016        // c:6278
12017        // c:6280-6285 — `if (opt_l) cmdopts = zhalloc(...); else cmdopts = opts;`
12018        // In our static-link port, the live option table IS the
12019        // "real opts"; under -l we build a snapshot HashMap and
12020        // mutate THAT instead of touching global state. Under
12021        // !-l we apply emulate semantics to the live table.
12022        // c:537-549 — C `emulate(zsh_name, ...)` reads ONLY the first
12023        // char (after stripping a leading `r` for rcsh/rksh): 'c'
12024        // → CSH, 'k' → KSH, 's'/'b' → SH (so `bash` aliases to sh),
12025        // else ZSH. Previous Rust port did full-string equality so
12026        // `emulate rcsh` / `emulate bash` silently fell back to ZSH.
12027        let bytes = shname.as_bytes();
12028        let mut ch = if !bytes.is_empty() { bytes[0] } else { 0 };
12029        if ch == b'r' && bytes.len() >= 2 {
12030            // c:539
12031            ch = bytes[1]; // c:540
12032        }
12033        let bits = match ch {
12034            // c:543
12035            b'c' => EMULATE_CSH,       // c:544
12036            b'k' => EMULATE_KSH,       // c:546
12037            b's' | b'b' => EMULATE_SH, // c:548
12038            _ => EMULATE_ZSH,          // c:550
12039        };
12040        // c:6286 — `emulate(shname, opt_R, &emulation, cmdopts)`.
12041        emulation.store(bits, Relaxed);
12042
12043        // Build the cmdopts view that c:6286-6292 manipulates.
12044        let mut cmdopts: HashMap<String, bool> = HashMap::new();
12045        for n in ZSH_OPTIONS_SET.iter() {
12046            cmdopts.insert(
12047                n.to_string(),
12048                crate::ported::options::opt_state_get(n).unwrap_or(false),
12049            );
12050        }
12051        // c:6286 — `emulate(shname, opt_R, &emulation, cmdopts)` ALSO
12052        // populates `cmdopts` (when !opt_l, `cmdopts` aliases the live
12053        // `opts[]`). The Rust port has a per-emulation defaults walker
12054        // at options::emulate() that writes through to OPTS_LIVE — call
12055        // it here so `emulate -L sh` flips KSH_ARRAYS / SH_NULLCMD /
12056        // POSIX_ALIASES / etc. to their target-emulation defaults.
12057        //
12058        // Bug #26 in docs/BUGS.md — without this, `emulate -L sh`
12059        // changed the emulation tag but left every option at its zsh
12060        // default, so `a=(1 2 3); echo ${a[0]}` stayed 1-indexed
12061        // (empty result) instead of switching to KSH_ARRAYS 0-indexed.
12062        // c:6285 — `emulate(shname, opt_R, &emulation, cmdopts);` runs
12063        // UNCONDITIONALLY. C can do that because it passes the target array in:
12064        // under -l cmdopts is a COPY of opts (c:6281-6282) and the emulation is
12065        // applied to the copy; otherwise cmdopts aliases the live opts[].
12066        //
12067        // options::emulate() has no such parameter — it writes through to
12068        // OPTS_LIVE — so the -l case is expressed by applying it to the live
12069        // state, snapshotting the result, and restoring. Same effect as C's
12070        // copy: `emulate -l sh` reports what the options WOULD be, and the
12071        // shell is left untouched.
12072        //
12073        // Skipping the call under -l (as this did) meant cmdopts stayed the
12074        // CURRENT options, so `emulate -l sh` listed zsh's defaults, not sh's —
12075        // ~38 options wrong. It looked right only for `emulate -l zsh`, where
12076        // the current options ARE the answer.
12077        let saved_opts = if opt_l {
12078            Some(crate::ported::options::opt_state_snapshot())
12079        } else {
12080            None
12081        };
12082        crate::ported::options::emulate(shname.as_str(), opt_r); // c:6285
12083        // Re-sync cmdopts from OPTS_LIVE so it reflects the emulation's
12084        // defaults rather than the pre-emulate state captured above.
12085        for n in ZSH_OPTIONS_SET.iter() {
12086            cmdopts.insert(
12087                n.to_string(),
12088                crate::ported::options::opt_state_get(n).unwrap_or(false),
12089            );
12090        }
12091        if let Some(saved) = saved_opts {
12092            // Undo the live apply — C never touched opts[] in the -l path.
12093            crate::ported::options::opt_state_restore(saved);
12094        }
12095
12096        // c:6287-6289 — opt_L: set LOCALOPTIONS/LOCALTRAPS/LOCALPATTERNS=1
12097        // in cmdopts. In the !opt_l live-apply case we also set them in
12098        // OPTS_LIVE; in the opt_l snapshot case we only set them in
12099        // cmdopts (the snapshot the list call walks).
12100        if opt_l_arg {
12101            // c:6287
12102            for nm in ["localoptions", "localtraps", "localpatterns"] {
12103                cmdopts.insert(nm.to_string(), true);
12104                if !opt_l {
12105                    crate::ported::options::opt_state_set(nm, true);
12106                }
12107            }
12108        }
12109        if opt_l {
12110            // c:6290
12111            // c:6291 — `list_emulate_options(cmdopts, opt_R);`
12112            crate::ported::options::list_emulate_options(&cmdopts, opt_r);
12113            return 0; // c:6292
12114        }
12115        // c:6294 — `clearpatterndisables();` resets the per-pattern
12116        // disabled-feature bitset that a previous emulation may have
12117        // left in place.
12118        crate::ported::pattern::clearpatterndisables();
12119        return 0; // c:6295
12120    }
12121
12122    // c:6297-6300 — too many args under -l.
12123    if opt_l {
12124        // c:6297
12125        zwarnnam(nam, "too many arguments for -l"); // c:6298
12126        return 1; // c:6299
12127    }
12128
12129    // c:6302-6342 — `emulate <shname> [-o OPT|+o OPT|-LR ...]` per-command
12130    // form. C: `argv++; emulate(shname, opt_R, ...); parseopts(...);` —
12131    // switch emulation, then parse the remaining tokens as long-form
12132    // options that apply ON TOP of the emulation defaults. zinit's
12133    // +zi-log (zinit.zsh:2178) calls
12134    //   `emulate -LR zsh -o extendedglob`
12135    // which requires extendedglob to be set after the zsh-emulation
12136    // reset, so the (#b)/(#m) flags in subsequent pattern subst calls
12137    // are recognized. The previous Rust port returned 0 without
12138    // applying ANY of the trailing options, so extendedglob stayed off
12139    // and `${msg//(#b)…/…}` errored with `bad pattern: …`.
12140    //
12141    // The `-c command` (cmd-eval) and sticky-emulation paths at c:6329+
12142    // remain unported (no zshrs caller exercises them yet) — the
12143    // structural opt-application below is enough for the common
12144    // `emulate -LR zsh -o NAME …` form.
12145    let _ = opt_r;
12146    // c:6303 — `memcpy(saveopts, opts, sizeof(opts));` + c:6326
12147    // `saveemulation = emulation;` — snapshot the live option table
12148    // and both emulation cells BEFORE applying the new emulation.
12149    // C builds the new state in a LOCAL `new_opts` and only commits
12150    // it at c:6328; the Rust port writes the live table directly, so
12151    // this snapshot is the rollback for the `-c` eval (restore2 at
12152    // c:6377-6379) and the parse-error path (goto restore, c:6311).
12153    // Without the restore, `emulate sh -c 'f() { ...; }'` left the
12154    // shell PERMANENTLY in sh-emulation option state — a later
12155    // `bad substitution` zerr then aborted the whole non-interactive
12156    // shell (D04parameter chunk 159 poisoned chunk 225 into a
12157    // shell-exit).
12158    let saveopts = crate::ported::options::opt_state_snapshot(); // c:6303
12159    let saveemulation = emulation.load(Relaxed); // c:6326
12160    let saveemulation_live = crate::ported::options::EMULATION.load(Relaxed); // c:6326 (port keeps 2 cells)
12161                                                                              // c:6306 — `emulate(shname, opt_R, &new_emulation, new_opts);` —
12162                                                                              // apply emulation defaults to the live opts table.
12163    crate::ported::options::emulate(shname.as_str(), opt_r);
12164
12165    // c:6308 — `parseopts(nam, &argv, new_opts, &cmd, optlist, 0)`.
12166    // C body at Src/init.c:390+:
12167    //   while (!optionbreak && *argv && (**argv == '-' || **argv == '+')) {
12168    //       action = (**argv == '-');
12169    //       while (*++*argv) {
12170    //           if (**argv == '-') { /* `--` ends options; `--NAME` long form */ }
12171    //           else if (**argv == 'c') { *cmdp = *argv; }
12172    //           else if (**argv == 'o') {
12173    //               if (!*++*argv) argv++;   // `-o NAME` separate-arg
12174    //               if (!*argv) { WARN("string expected after -o"); return 1; }
12175    //               longoptions: optno = optlookup(*argv);
12176    //               if (!optno) { WARN("no such option: %s", *argv); return 1; }
12177    //               dosetopt(optno, action, ...);
12178    //               break;
12179    //           }
12180    //           else {  /* single-char option letter, e.g. 'L', 'R' */
12181    //               optno = optlookupc(**argv);
12182    //               dosetopt(optno, action, ...);
12183    //           }
12184    //       }
12185    //       argv++;
12186    //   }
12187    //
12188    // Faithful port: walk each arg, parse `-`/`+` action sign, then
12189    // walk char-by-char. `--` ends options; `-o NAME` / `+o NAME`
12190    // long-form (NAME inline-attached or separate arg); single-char
12191    // option letters set/unset via `dosetopt` (Rust:
12192    // `opt_state_set`). `-c command` records the cmd-eval mode (not
12193    // wired through here — bin_emulate uses opt_L for localoptions
12194    // only).
12195    let mut i = 1; // skip shname (argv[0])
12196    let mut optionbreak = false;
12197    let mut cmd_body: Option<String> = None; // c:6310 — `-c command` capture.
12198                                             // c:6308 `optlist` — parseopts records each explicitly-set option
12199                                             // (as an index into new_opts) so the `-c` arm can build the
12200                                             // sticky struct's on_opts/off_opts at c:6347-6373. Rust tracks
12201                                             // (canonical-name, on?) pairs.
12202    let mut optlist: Vec<(String, bool)> = Vec::new();
12203    while !optionbreak && i < argv.len() {
12204        let arg = &argv[i];
12205        // c:Src/init.c:418 — only `-` / `+` start an option arg.
12206        let first = arg.chars().next().unwrap_or('\0');
12207        if first != '-' && first != '+' {
12208            break;
12209        }
12210        let action = first == '-'; // c:420
12211                                   // c:421-422 — bare `-` / `+` treated as `--` (end-of-options
12212                                   // marker preserved as-is for caller).
12213        if arg.len() == 1 {
12214            i += 1;
12215            break;
12216        }
12217        // Walk chars after the leading +/-.
12218        let bytes: Vec<char> = arg.chars().collect();
12219        let mut j = 1;
12220        let mut consumed_next_arg = false;
12221        while j < bytes.len() {
12222            let ch = bytes[j];
12223            if ch == '-' {
12224                // c:425-429 — `--` ends options.
12225                if j == 1 && bytes.len() == 2 {
12226                    optionbreak = true;
12227                    i += 1;
12228                    break;
12229                }
12230                // c:432-460 — `--NAME` GNU-style long. `-` chars
12231                // inside NAME become `_` (c:457-459). For bin_emulate
12232                // we don't recognize `--version` / `--help` / etc.,
12233                // just route the inline NAME as a long-form option.
12234                let name: String = bytes[j + 1..].iter().collect::<String>().replace('-', "_");
12235                // Same optlookup+dosetopt fix as the `-o NAME` arm below:
12236                // resolve the `no` negation prefix and run option side
12237                // effects, instead of writing a bogus stripped-name slot.
12238                let optno = crate::ported::options::optlookup(&name);
12239                if optno == 0 {
12240                    zwarnnam(nam, &format!("no such option: {}", name));
12241                    return 1;
12242                }
12243                crate::ported::options::dosetopt(optno, if action { 1 } else { 0 }, 0);
12244                let canon = crate::ported::zsh_h::opt_name(optno.abs()).to_string();
12245                let effective_on = if optno < 0 { !action } else { action };
12246                optlist.push((canon, effective_on)); // c:6308 optlist record
12247                break;
12248            }
12249            if ch == 'o' || ch == 'O' {
12250                // c:480-505 — `-o NAME` / `+o NAME`. NAME is either
12251                // attached (rest of arg after `o`) or the next arg.
12252                let name = if j + 1 < bytes.len() {
12253                    bytes[j + 1..].iter().collect::<String>()
12254                } else {
12255                    if i + 1 >= argv.len() {
12256                        zwarnnam(nam, "string expected after -o");
12257                        return 1; // c:484-485
12258                    }
12259                    consumed_next_arg = true;
12260                    argv[i + 1].clone()
12261                };
12262                // c:6314-6316 — `optno = optlookup(*argv); if (!optno)
12263                // { WARN("no such option"); return 1; } dosetopt(optno,
12264                // action, ...)`. optlookup resolves the `no`/`no_`
12265                // negation prefix (returning a NEGATIVE optno) and
12266                // dosetopt inverts it AND runs the side effects
12267                // (inittyptab for MULTIBYTE/BANGHIST/SHINSTDIN). The
12268                // previous `opt_state_set(lowercased_stripped, action)`
12269                // wrote a bogus `nomultibyte` slot and never toggled the
12270                // real MULTIBYTE option, so `emulate -o no_multi_byte`
12271                // (p10k.zsh:1760) left MULTIBYTE ON — char- not
12272                // byte-oriented length math — and every p10k prompt
12273                // segment built empty.
12274                let optno = crate::ported::options::optlookup(&name);
12275                if optno == 0 {
12276                    zwarnnam(nam, &format!("no such option: {}", name));
12277                    return 1;
12278                }
12279                crate::ported::options::dosetopt(optno, if action { 1 } else { 0 }, 0);
12280                let canon = crate::ported::zsh_h::opt_name(optno.abs()).to_string();
12281                let effective_on = if optno < 0 { !action } else { action };
12282                optlist.push((canon, effective_on)); // c:6308 optlist record
12283                break; // c:505 — break out of char walk after `-o`
12284            }
12285            if ch == 'c' {
12286                // c:6310 — `-c command`. Capture the command body from
12287                // either the rest of the current arg (e.g. `-c'body'`)
12288                // or the next argv slot. C's sticky-emulation evaluator
12289                // (c:6332-6373) wraps the cmd in saved/restored
12290                // emulation state; we mirror by running the captured
12291                // body through eval after the option-parse loop exits.
12292                let body = if j + 1 < bytes.len() {
12293                    bytes[j + 1..].iter().collect::<String>()
12294                } else {
12295                    if i + 1 >= argv.len() {
12296                        zwarnnam(nam, "string expected after -c");
12297                        return 1;
12298                    }
12299                    consumed_next_arg = true;
12300                    argv[i + 1].clone()
12301                };
12302                cmd_body = Some(body);
12303                break;
12304            }
12305            // c:Src/init.c — single-char option letter dispatch via
12306            // `optno = optlookupc(**argv); dosetopt(optno, action, ...)`.
12307            // C maps the char through `optletters[]` (Src/options.c) to
12308            // an OPT_* number, then writes the action bit.
12309            //
12310            // NOT PORTED here: the chars bin_emulate cares about (`L`,
12311            // `R`, `l`) are extracted into `ops` by the dispatcher BEFORE
12312            // bin_emulate runs (via the optstr declared at
12313            // Src/builtin.c:99 for the emulate builtin). The Rust port's
12314            // builtin dispatcher does the same — see `opt_l`, `opt_l_arg`,
12315            // `opt_r` reads at the top of this function. So by the time
12316            // parseopts runs, single-char options have already been
12317            // consumed and `argv[1..]` contains only the long-form
12318            // `-o NAME` / `--NAME` shapes that this loop handles.
12319            // Unknown single-char chars are silently skipped (matching
12320            // what the dispatcher would have flagged as a parse error
12321            // before bin_emulate ran).
12322            let _ = ch;
12323            j += 1;
12324        }
12325        i += 1;
12326        if consumed_next_arg {
12327            i += 1;
12328        }
12329    }
12330    // c:6332-6377 — `-c command` evaluator: save patterns, install
12331    // the pending `sticky` struct (functions defined in the body get
12332    // stamped via shfunc_set_sticky at c:5402), eval the body, then
12333    // restore sticky + emulation + opts + patterns (restore2: at
12334    // c:6377-6379).
12335    if let Some(body) = cmd_body {
12336        if opt_l_arg {
12337            // c:6333-6336 — `-L` is incompatible with `-c`.
12338            zwarnnam(nam, "option -L incompatible with -c");
12339            // c:6336 `goto restore2` — undo the emulation switch.
12340            emulation.store(saveemulation, Relaxed);
12341            crate::ported::options::EMULATION.store(saveemulation_live, Relaxed);
12342            crate::ported::options::opt_state_restore(saveopts);
12343            return 1;
12344        }
12345        // c:6319 — `savepatterns = savepatterndisables();`
12346        let savepatterns = crate::ported::pattern::savepatterndisables();
12347        // c:6324 — `clearpatterndisables();` — every emulation starts
12348        // with an empty pattern-disable set.
12349        crate::ported::pattern::clearpatterndisables();
12350        // c:6344-6373 — `save_sticky = sticky; sticky = hcalloc(...);`
12351        // Build the sticky struct: target emulation + the on/off
12352        // option indices the optlist recorded.
12353        let save_sticky = crate::ported::options::sticky
12354            .lock()
12355            .unwrap_or_else(|e| e.into_inner())
12356            .take(); // c:6344
12357        {
12358            let mut on_opts: Vec<crate::ported::zsh_h::OptIndex> = Vec::new();
12359            let mut off_opts: Vec<crate::ported::zsh_h::OptIndex> = Vec::new();
12360            for (name, on) in &optlist {
12361                // c:6366-6372 — Data is index into new_opts.
12362                let optno = optlookup(name);
12363                if optno > 0 {
12364                    if *on {
12365                        on_opts.push(optno as crate::ported::zsh_h::OptIndex);
12366                    } else {
12367                        off_opts.push(optno as crate::ported::zsh_h::OptIndex);
12368                    }
12369                }
12370            }
12371            *crate::ported::options::sticky
12372                .lock()
12373                .unwrap_or_else(|e| e.into_inner()) =
12374                Some(Box::new(crate::ported::zsh_h::emulation_options {
12375                    emulation: crate::ported::options::EMULATION.load(Relaxed), // c:6346
12376                    n_on_opts: on_opts.len() as i32,                            // c:6351
12377                    n_off_opts: off_opts.len() as i32,                          // c:6353
12378                    on_opts,                                                    // c:6356-6358
12379                    off_opts,                                                   // c:6360-6362
12380                }));
12381        }
12382        let r = eval(&[body]); // c:6374 — `ret = eval(argv);`
12383                               // c:6375 — `sticky = save_sticky;`
12384        *crate::ported::options::sticky
12385            .lock()
12386            .unwrap_or_else(|e| e.into_inner()) = save_sticky;
12387        // c:6377-6379 restore2: — emulation + opts + patterns.
12388        emulation.store(saveemulation, Relaxed); // c:6377
12389        crate::ported::options::EMULATION.store(saveemulation_live, Relaxed); // c:6377
12390        crate::ported::options::opt_state_restore(saveopts); // c:6378
12391        crate::ported::pattern::restorepatterndisables(savepatterns); // c:6379
12392                                                                      // c:6381-6382 restore: — keyboardhackchar + inittyptab()
12393                                                                      // (keyboard hack char isn't ported; typtab rebuild is a
12394                                                                      // no-op in the Rust lexer).
12395        return r;
12396    }
12397
12398    // c:6314-6317 — `if (*argv) zwarnnam(nam, "unknown argument %s",
12399    // *argv);`. Anything left after the option-parse loop is unknown.
12400    // zinit's `emulate -LR zsh -o extendedglob` exhausts argv, so this
12401    // arm doesn't fire — but mirror the C behavior for other callers.
12402    if i < argv.len() && !optionbreak {
12403        zwarnnam(nam, &format!("unknown argument: {}", argv[i]));
12404        // c:6315 `goto restore` — C's LIVE opts were never touched on
12405        // this path (parseopts wrote the local new_opts); the Rust
12406        // loop wrote live state, so roll back the snapshot to match.
12407        emulation.store(saveemulation, Relaxed);
12408        crate::ported::options::EMULATION.store(saveemulation_live, Relaxed);
12409        crate::ported::options::opt_state_restore(saveopts);
12410        return 1;
12411    }
12412
12413    // c:6339-6340 — `if (opt_L) opts[LOCALOPTIONS] = opts[LOCALTRAPS] =
12414    //                opts[LOCALPATTERNS] = 1;`.
12415    if opt_l_arg {
12416        for nm in ["localoptions", "localtraps", "localpatterns"] {
12417            crate::ported::options::opt_state_set(nm, true);
12418        }
12419    }
12420    0
12421}
12422
12423/// Port of `bin_eval(UNUSED(char *nam), char **argv, UNUSED(Options ops), UNUSED(int func))` from Src/builtin.c:6393.
12424/// C: `int bin_eval(UNUSED args)` → `return eval(argv);`
12425/// WARNING: param names don't match C — Rust=(_name, argv, _func) vs C=(nam, argv, ops, func)
12426pub fn bin_eval(
12427    _name: &str,
12428    argv: &[String], // c:6393
12429    _ops: &options,
12430    _func: i32,
12431) -> i32 {
12432    // c:Src/builtin.c:407-411 — generic `--` end-of-options strip
12433    // applied by `execbuiltin` for builtins that:
12434    //   1. Have NULL optstr (so the `if (optstr) { option parse }`
12435    //      loop didn't run), AND
12436    //   2. Don't have BINF_HANDLES_OPTS in their flags.
12437    // ```c
12438    // } else if (!(flags & BINF_HANDLES_OPTS) && *argv &&
12439    //            !strcmp(*argv, "--")) {
12440    //     ops.ind['-'] = 1;
12441    //     argv++;
12442    // }
12443    // ```
12444    // `eval` has optstr=NULL (Src/builtin.c:65) and no
12445    // BINF_HANDLES_OPTS, so the strip applies. The Rust dispatch
12446    // path bypasses execbuiltin, so we need to do the strip
12447    // explicitly here. Without it `eval -- "echo hi"` joined to
12448    // `-- echo hi` and tried to dispatch `--` as a command. Bug
12449    // #319 in docs/BUGS.md.
12450    let stripped: Vec<String>;
12451    let argv: &[String] = if let Some(first) = argv.first() {
12452        if first == "--" {
12453            stripped = argv[1..].to_vec();
12454            &stripped
12455        } else {
12456            argv
12457        }
12458    } else {
12459        argv
12460    };
12461    eval(argv) // c:6396
12462}
12463
12464/// Port of `bin_read(char *name, char **args, Options ops, UNUSED(int func))` from Src/builtin.c:6412.
12465/// C: `int bin_read(char *name, char **args, Options ops, UNUSED(int func))`.
12466///
12467/// The C body is ~720 lines covering the whole `read` builtin matrix:
12468/// `-A` array, `-k N` raw chars, `-q` yes/no, `-r` raw, `-s` silent,
12469/// `-t TIMEOUT`, `-u FD` input FD, `-p` coproc, `-d DELIM` delimiter,
12470/// `-e` echo, `-E` echo-stdout-only, `-l`/`-c` compctl. The structural
12471/// port below handles the script-friendly subset: VAR= default,
12472/// `read -p PROMPT VAR`, `read -t TIMEOUT VAR`, `read -A ARRAY`,
12473/// `read -k N VAR`. Terminal-mode (-q/-s/-e) and ZLE plumbing defer
12474/// to the existing zle/io accessors.
12475/// WARNING: param names don't match C — Rust=(name, args, _func) vs C=(name, args, ops, func)
12476pub fn bin_read(
12477    name: &str,
12478    args: &[String], // c:6412
12479    ops: &options,
12480    _func: i32,
12481) -> i32 {
12482    let args = args.to_vec();
12483    let mut nchars: i32 = 1; // c:6415
12484    let mut partial_eof = false;
12485
12486    // c:6432-6438 — `-k N` raw-char count.
12487    if OPT_HASARG(ops, b'k') {
12488        // c:6432
12489        let optarg = OPT_ARG(ops, b'k').unwrap_or("");
12490        match optarg.trim().parse::<i32>() {
12491            Ok(n) => nchars = n,
12492            Err(_) => {
12493                zwarnnam(name, &format!("number expected after -k: {}", optarg)); // c:6437
12494                return 1;
12495            }
12496        }
12497    }
12498
12499    // c:6445-6446 — `firstarg = (*args && **args == '?' ? *args++ :
12500    // *args); reply = *args ? *args++ : ...`. When the first arg
12501    // starts with `?` it is a prompt-only arg (consumed; the NEXT arg
12502    // is the reply name). Otherwise firstarg ALIASES the reply name —
12503    // and the prompt block at c:6534-6543 scans it for an embedded
12504    // `?`, prints the tail as the prompt, and truncates the name in
12505    // place (`readpmpt[-1] = '\0'`). So `read v?prompt: ` reads into
12506    // `v` with prompt `prompt: `. Bug #387 follow-up: the leading-`?`
12507    // form was handled but the `var?prompt` split was missing, so
12508    // zshrs errored "not an identifier: v?prompt".
12509    let mut argi = 0usize;
12510    let mut prompt: Option<String> = None;
12511    if argi < args.len() && args[argi].starts_with('?') {
12512        // c:6445 — prompt-only first arg.
12513        prompt = Some(args[argi][1..].to_string());
12514        argi += 1;
12515    }
12516    let want_array = OPT_ISSET(ops, b'A');
12517    let reply = if argi < args.len() {
12518        let mut r = args[argi].clone();
12519        argi += 1;
12520        // c:6534-6543 — firstarg (== this arg when no leading-`?` arg
12521        // was consumed) splits at its first `?`: name before, prompt
12522        // after.
12523        if prompt.is_none() {
12524            if let Some(q) = r.find('?') {
12525                prompt = Some(r[q + 1..].to_string());
12526                r.truncate(q); // c:6542 readpmpt[-1] = '\0'
12527            }
12528        }
12529        r
12530    } else if want_array {
12531        "reply".to_string() // c:6446
12532    } else {
12533        "REPLY".to_string() // c:6446
12534    };
12535
12536    if want_array && argi < args.len() {
12537        // c:6448
12538        zwarnnam(name, "only one array argument allowed"); // c:6449
12539        return 1;
12540    }
12541
12542    // c:Src/builtin.c:6457-6477 — `read -k`/`-q` requires a
12543    // controlling tty (unless `-u FD` or `-p` redirects input).
12544    // If neither stdin nor stderr is a tty, zsh emits the canonical
12545    // error and returns 1. Mirror here (the SHTTY substrate isn't
12546    // ported yet; the libc::isatty check approximates).
12547    if (OPT_ISSET(ops, b'k') || OPT_ISSET(ops, b'q'))
12548        && !OPT_HASARG(ops, b'u')
12549        && !OPT_ISSET(ops, b'p')
12550    {
12551        let stdin_tty = unsafe { libc::isatty(0) } != 0;
12552        let stderr_tty = unsafe { libc::isatty(2) } != 0;
12553        if !stdin_tty && !stderr_tty {
12554            eprintln!("not interactive and can't open terminal");
12555            return 1;
12556        }
12557    }
12558
12559    // c:Src/builtin.c:6510-6515 — `else if (OPT_ISSET(ops,'p')) {
12560    // readfd = coprocin; if (readfd < 0) { zwarnnam(name, "-p: no
12561    // coprocess"); return 1; } izle = 0; }`. C emits this BEFORE
12562    // identifier validation of the reply name, so a bare
12563    // `read -p "var name" REPLY` (no coproc started) errors with
12564    // "-p: no coprocess" rather than "not an identifier". Bug #387.
12565    // The `-u`-with-arg path falls through and shares this same gate
12566    // when its arg is "p" (c:6494) — also handled.
12567    if OPT_ISSET(ops, b'p') || (OPT_HASARG(ops, b'u') && OPT_ARG(ops, b'u').as_deref() == Some("p"))
12568    {
12569        let coprocin =
12570            crate::ported::modules::clone::coprocin.load(std::sync::atomic::Ordering::Relaxed);
12571        if coprocin < 0 {
12572            zwarnnam(name, "-p: no coprocess");
12573            return 1;
12574        }
12575    }
12576
12577    // c:6453-6455 — `return compctlreadptr(name, args, ops, reply)`.
12578    // The compctlreadptr function pointer defaults to
12579    // `fallback_compctlread` (init.c:1834) and gets reassigned to the
12580    // real `compctlread` when zsh/compctl loads via the module's
12581    // boot_ hook. zshrs auto-loads zsh/compctl in default mode so the
12582    // direct call to `compctlread` is correct there. In --zsh parity
12583    // mode zsh/compctl isn't loaded until explicit `zmodload`, so
12584    // route through `fallback_compctlread` instead — matching
12585    // `zsh -fc 'getln -c x'` which emits "no loaded module provides
12586    // read for completion context".
12587    if OPT_ISSET(ops, b'l') || OPT_ISSET(ops, b'c') {
12588        // c:6453
12589        let zsh_mode = crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed);
12590        // In --zsh mode, regardless of MODULESTAB's "loaded" flag
12591        // (which we set true at startup for QoL), route through
12592        // fallback_compctlread because zsh -fc's reported behavior
12593        // for `getln -c` is the fallback message. The MODULESTAB-
12594        // load-flag check below would defeat parity: zshrs marks
12595        // zsh/compctl as default-loaded so its compctl/compcall
12596        // builtins work without explicit zmodload. zsh -fc has only
12597        // zsh/main loaded and only auto-loads compctl when the
12598        // compctl/compcall builtins themselves are called — `read -c`
12599        // does NOT trigger that auto-load (zsh calls
12600        // `compctlreadptr` which defaults to `fallback_compctlread`
12601        // until something explicitly zmodload's zsh/compctl).
12602        //
12603        // The simplest C-faithful parity: in --zsh mode, always emit
12604        // the fallback message. Default zshrs mode keeps calling the
12605        // real compctlread.
12606        if zsh_mode {
12607            return crate::ported::init::fallback_compctlread(name);
12608        }
12609        return compctlread(name, &args[argi..]);
12610    }
12611
12612    // Optional explicit input FD via -u, or coprocin via -p. When
12613    // unspecified, fall back to fd 0 (stdin). All read paths below
12614    // route bytes through `read_byte_from_fd` so `read -u 3 var`
12615    // after `exec 3< file` correctly pulls from the user fd; with
12616    // `-p`, the same path reads from coprocin (set by the BUILTIN_
12617    // RUN_COPROC handler at fusevm_bridge.rs around the
12618    // `coproc CMD` launch). Bug #388.
12619    let ufd: i32 = if OPT_ISSET(ops, b'p') {
12620        crate::ported::modules::clone::coprocin.load(std::sync::atomic::Ordering::Relaxed)
12621    } else if OPT_HASARG(ops, b'u') {
12622        // c:Src/builtin.c:6494-6500 — `fdarg = (int)zstrtol(argptr,
12623        // &eptr, 10); if (*eptr) { zwarnnam(name, "number expected
12624        // after -u: %s", argptr); return 1; }`. zshrs previously
12625        // `unwrap_or(0)`d, silently dropping `read -u abc v` errors.
12626        let argptr = OPT_ARG(ops, b'u').unwrap_or("");
12627        match argptr.parse::<i32>() {
12628            Ok(n) => n,
12629            Err(_) => {
12630                zwarnnam(name, &format!("number expected after -u: {}", argptr));
12631                return 1;
12632            }
12633        }
12634    } else {
12635        0
12636    };
12637    // c:Src/builtin.c:6418 — single-byte reader bound to `ufd`.
12638    // libc::read with len=1 keeps the file position advancing across
12639    // successive calls (matches zsh's per-byte read loop). Returns
12640    // Some(byte) on success, None on EOF, error sentinel on syscall
12641    // failure (caller maps to return 2).
12642    let read_byte = |fd: i32| -> io::Result<Option<u8>> {
12643        let mut b = [0u8; 1];
12644        loop {
12645            let n = unsafe { libc::read(fd, b.as_mut_ptr() as *mut libc::c_void, 1) };
12646            match n {
12647                1 => return Ok(Some(b[0])),
12648                0 => return Ok(None),
12649                -1 => {
12650                    let err = io::Error::last_os_error();
12651                    if err.kind() == io::ErrorKind::Interrupted {
12652                        continue;
12653                    }
12654                    return Err(err);
12655                }
12656                _ => return Ok(None),
12657            }
12658        }
12659    };
12660
12661    // c:6488-6515 — `-t TIMEOUT` poll(2) wait.
12662    if OPT_HASARG(ops, b't') {
12663        let arg = OPT_ARG(ops, b't').unwrap_or("");
12664        let tmout: f64 = arg.parse().unwrap_or(0.0);
12665        let mut pfd = libc::pollfd {
12666            fd: 0,
12667            events: libc::POLLIN,
12668            revents: 0,
12669        };
12670        let r = unsafe { libc::poll(&mut pfd, 1, (tmout * 1000.0) as i32) };
12671        if r == 0 {
12672            return 4;
12673        } // timeout
12674        if r < 0 {
12675            return 2;
12676        } // error
12677    }
12678
12679    // c:Src/builtin.c:6499-6510 — `read "?prompt"` writes the prompt
12680    // ONLY when input is interactive (stdin is a terminal). C zsh
12681    // gates the write via `isatty(0)` inside the prompt-emit block;
12682    // when stdin is redirected from a pipe or file, the prompt is
12683    // suppressed entirely so the captured output isn't polluted. Bug
12684    // #248 in docs/BUGS.md — previously zshrs printed the prompt to
12685    // stderr unconditionally, so non-interactive callers saw the
12686    // prompt fragment leak into stderr.
12687    if let Some(ref p) = prompt {
12688        let stdin_is_tty = unsafe { libc::isatty(0) } != 0;
12689        if stdin_is_tty {
12690            eprint!("{}", p);
12691            let _ = Write::flush(&mut io::stderr());
12692        }
12693    }
12694
12695    // c:Src/builtin.c:6769-6770 — `-z` pops the front of the ZLE
12696    // bufstack into `zbuf` and reads from THAT instead of the file
12697    // descriptor. When the bufstack is empty the popped value is the
12698    // empty string, which makes `zread` return EOF immediately;
12699    // c:7110-7115 then returns 1 after the reply assignment.
12700    //
12701    //     zbuforig = zbuf = (!OPT_ISSET(ops,'z')) ? NULL :
12702    //         (nonempty(bufstack)) ? (char *)getlinknode(bufstack) :
12703    //         ztrdup("");
12704    //     ...
12705    //     if (zbuforig) { char first = *zbuforig; ... if (!first) return 1; }
12706    //
12707    // The default delim handling (newline-terminated, raw or
12708    // backslash-cooked) still applies — read up to the first newline
12709    // in the popped buffer or to its end if no newline.
12710    let mut buf = String::new();
12711    if OPT_ISSET(ops, b'z') {
12712        let popped: Option<String> = {
12713            let mut stack = crate::ported::zle::zle_main::BUFSTACK.lock().unwrap();
12714            if stack.is_empty() {
12715                None
12716            } else {
12717                Some(stack.remove(0))
12718            }
12719        };
12720        let zbuf = popped.clone().unwrap_or_default();
12721        let raw_mode = OPT_ISSET(ops, b'r') || OPT_ISSET(ops, b'R');
12722        if OPT_HASARG(ops, b'd') {
12723            let arg = OPT_ARG(ops, b'd').unwrap_or("");
12724            let delim = arg.as_bytes().first().copied().unwrap_or(b'\0');
12725            let mut out = Vec::<u8>::new();
12726            for &b in zbuf.as_bytes() {
12727                if b == delim {
12728                    break;
12729                }
12730                out.push(b);
12731            }
12732            buf = String::from_utf8_lossy(&out).into_owned();
12733        } else {
12734            let mut out = Vec::<u8>::new();
12735            let bytes = zbuf.as_bytes();
12736            let mut i = 0;
12737            while i < bytes.len() {
12738                let b = bytes[i];
12739                if !raw_mode && b == b'\\' && i + 1 < bytes.len() {
12740                    let nx = bytes[i + 1];
12741                    if nx == b'\n' {
12742                        i += 2;
12743                        continue;
12744                    }
12745                    out.push(nx);
12746                    i += 2;
12747                    continue;
12748                }
12749                if b == b'\n' {
12750                    break;
12751                }
12752                out.push(b);
12753                i += 1;
12754            }
12755            buf = String::from_utf8_lossy(&out).into_owned();
12756        }
12757        // c:7102-7109 — -e/-E echo, -e suppresses assignment.
12758        let opt_e = OPT_ISSET(ops, b'e');
12759        let opt_big_e = OPT_ISSET(ops, b'E');
12760        if opt_e || opt_big_e {
12761            println!("{}", buf);
12762        }
12763        if !opt_e {
12764            setsparam(&reply, &buf);
12765        }
12766        // c:7110-7115 — empty bufstack pop → return 1 even after assign.
12767        return if popped.is_none() || popped.as_deref() == Some("") {
12768            1
12769        } else {
12770            0
12771        };
12772    }
12773    if OPT_ISSET(ops, b'k') || OPT_ISSET(ops, b'q') {
12774        // c:Src/builtin.c:6630 — `if (OPT_ISSET(ops,'k') ||
12775        // OPT_ISSET(ops,'q'))` — both read raw chars (no line/IFS
12776        // processing). `-q` reads exactly one char (nchars defaults to
12777        // 1) for the yes/no decision applied below. The previous port
12778        // gated only on `-k`, so `-q` fell through to the line reader
12779        // and never applied the y/Y check (always returned 0).
12780        // c:Src/builtin.c — `-k 0` (zero chars requested) is a no-op
12781        // read that zsh treats as failure (returns 1) because no
12782        // bytes can be consumed. Mirror so `read -k 0` exits 1
12783        // instead of "succeeding" with an empty buffer.
12784        if nchars <= 0 {
12785            return 1;
12786        }
12787        let mut got = vec![0u8; nchars as usize];
12788        let mut bytes_read = 0;
12789        while bytes_read < nchars as usize {
12790            match read_byte(ufd) {
12791                Ok(Some(b)) => {
12792                    got[bytes_read] = b;
12793                    bytes_read += 1;
12794                }
12795                _ => break,
12796            }
12797        }
12798        buf = String::from_utf8_lossy(&got[..bytes_read]).into_owned();
12799    } else if OPT_HASARG(ops, b'd') {
12800        // c:Src/builtin.c:6418 — `-d DELIM`: read until first byte of
12801        // DELIM (zsh uses only first char of arg). EOF mid-record
12802        // returns what was read so far, exit 1 like the default path.
12803        let arg = OPT_ARG(ops, b'd').unwrap_or("");
12804        // c:Src/builtin.c:6418 — empty `-d` arg means NUL delimiter.
12805        // zsh's `STOUC(*OPT_ARG(ops, 'd'))` reads the first byte of
12806        // the arg buffer; for an empty arg the buffer is a single NUL
12807        // terminator, so STOUC yields 0x00. The Rust port's
12808        // `.first().copied()` returns None for empty strings, which
12809        // we have to map to NUL explicitly (matches `read -d '' x`
12810        // reading until \0, used by `find -print0 | while read -d ''`).
12811        let delim = arg.as_bytes().first().copied().unwrap_or(b'\0');
12812        let mut buf_bytes = Vec::<u8>::new();
12813        // c:7045 — the loop breaks on `c == EOF || (c == delim && !zbuf)`, and
12814        // c:7116 keys the exit status off WHICH of the two ended it. "Did any
12815        // byte arrive" can't tell an EOF-terminated record from a
12816        // delimiter-terminated one, so track the delimiter itself.
12817        let mut saw_delim = false;
12818        // c:7055 — `bslash = c == '\\' && !bslash && !OPT_ISSET(ops,'r')`.
12819        // In C there is ONE read loop and `-d` only swaps the `delim` variable,
12820        // so the backslash rules apply to `-d` exactly as they do to a plain
12821        // `read`. zshrs split the loop in two and the `-d` copy dropped them,
12822        // so `read -d ' ' x <<< 'esc\ttab'` kept the backslash (`esc\ttab`)
12823        // where zsh yields `escttab`.
12824        let raw_mode = OPT_ISSET(ops, b'r') || OPT_ISSET(ops, b'R');
12825        let mut bslash = false;
12826        loop {
12827            match read_byte(ufd) {
12828                Ok(Some(b)) => {
12829                    // c:7041-7043 — a backslash-escaped DELIMITER is a line
12830                    // continuation: both bytes are dropped and the record keeps
12831                    // going. `read -d t x <<< 'esc\ttab'` therefore yields `esc`
12832                    // (the escaped `t` vanishes; the NEXT `t` terminates).
12833                    if bslash && b == delim {
12834                        bslash = false; // c:7042
12835                        continue; // c:7043
12836                    }
12837                    if b == delim {
12838                        saw_delim = true; // c:7045 (`c == delim`)
12839                        break;
12840                    }
12841                    // c:7055-7057 — an unescaped backslash is consumed and
12842                    // escapes the next byte (unless `-r`).
12843                    bslash = b == b'\\' && !bslash && !raw_mode; // c:7055
12844                    if bslash {
12845                        continue; // c:7057
12846                    }
12847                    buf_bytes.push(b);
12848                }
12849                Ok(None) => break,
12850                // c:Src/builtin.c:7162-7188 zread — a read(2) error
12851                // (EBADF from `read foo <&-`, EIO, ...) returns EOF,
12852                // not a distinct error status; bin_read then reports
12853                // plain eof (exit 1), with no diagnostic.
12854                Err(_) => break,
12855            }
12856        }
12857        buf = String::from_utf8_lossy(&buf_bytes).into_owned();
12858        // c:Src/builtin.c:6418 — `read -d ''` (NUL delimiter) strips
12859        // trailing newlines from the captured content. This matches
12860        // the `find -print0 | while read -d ''` idiom which expects
12861        // path entries without trailing whitespace. zsh's read body
12862        // applies this trim only for the empty-delim case; non-empty
12863        // delimiters keep the raw bytes.
12864        if arg.is_empty() {
12865            while buf.ends_with('\n') {
12866                buf.pop();
12867            }
12868        }
12869        // c:7107 + c:7116-7122 — C assigns the parameter FIRST (`setsparam(reply,
12870        // buf)`) and only then returns 1 if the loop ended at EOF. So an
12871        // unterminated record still lands in the variable but reports failure:
12872        // `read -d : x <<< 'abc'` sets x=abc and returns 1, which is what makes
12873        // `while read -d : x` terminate on a final field with no trailing
12874        // delimiter. The old early `return 1` fired only when NOTHING was read,
12875        // so every EOF-terminated record wrongly reported success (rc=0) — and
12876        // it also skipped the assignment entirely, leaving the variable at its
12877        // previous value instead of clearing it.
12878        //
12879        // `partial_eof` is the same carrier the default line branch below uses;
12880        // the shared `if partial_eof { return 1 }` after the assignment block
12881        // applies the status.
12882        partial_eof = !saw_delim; // c:7116 (`else if (c == EOF)`)
12883    } else {
12884        // Read a line (default behaviour). c:Src/builtin.c:6505
12885        // — without `-r`, backslash-X eats the backslash and keeps
12886        // the literal X (backslash-newline is line continuation).
12887        let raw_mode = OPT_ISSET(ops, b'r') || OPT_ISSET(ops, b'R');
12888        let mut buf_bytes = Vec::<u8>::new();
12889        let mut got_any = false;
12890        let mut saw_newline = false;
12891        loop {
12892            match read_byte(ufd) {
12893                Ok(Some(b)) => {
12894                    got_any = true;
12895                    if !raw_mode && b == b'\\' {
12896                        match read_byte(ufd) {
12897                            Ok(Some(nx)) => {
12898                                if nx == b'\n' {
12899                                    // Line continuation — drop both.
12900                                    continue;
12901                                }
12902                                buf_bytes.push(nx);
12903                                continue;
12904                            }
12905                            Ok(None) => {
12906                                buf_bytes.push(b'\\');
12907                                break;
12908                            }
12909                            // c:7162-7188 zread — read error == EOF.
12910                            Err(_) => break,
12911                        }
12912                    }
12913                    if b == b'\n' {
12914                        saw_newline = true;
12915                        break;
12916                    }
12917                    buf_bytes.push(b);
12918                }
12919                Ok(None) => break,
12920                // c:7162-7188 zread — read error == EOF (exit 1, no
12921                // message): `exec 3<&-; read foo <&-` exits 1 in zsh.
12922                Err(_) => break,
12923            }
12924        }
12925        if !got_any {
12926            // c:Src/builtin.c — immediate EOF (no bytes read at all).
12927            // zsh clears the target variable(s) BEFORE returning 1
12928            // so `while read line; do …; done < file` leaves `\$line`
12929            // empty after the loop ends (not the last value).
12930            // Previously zshrs returned 1 without touching the var,
12931            // so the loop body's final iteration value persisted.
12932            buf = String::new();
12933            partial_eof = true;
12934            // Fall through to the assignment block which will write
12935            // "" to reply / vars / array. The trailing `if
12936            // partial_eof { return 1; }` (line 8628) preserves the
12937            // EOF status code.
12938        } else {
12939            buf = String::from_utf8_lossy(&buf_bytes).into_owned();
12940            partial_eof = !saw_newline;
12941        }
12942    }
12943
12944    // c:Src/builtin.c:6730-6742 — `-q` (read yes/no): the single char
12945    // read above is "yes" iff it is exactly 'y' or 'Y'; the reply var is
12946    // set to "y"/"n" and the exit status is 0 (yes) / 1 (no). (A timeout
12947    // would be status 2; not modeled here.) This must run BEFORE the
12948    // IFS/array assignment dispatch — `-q` never does line splitting.
12949    if OPT_ISSET(ops, b'q') {
12950        let is_yes = buf == "y" || buf == "Y"; // c:6741
12951        setsparam(&reply, if is_yes { "y" } else { "n" }); // c:6742
12952        return if is_yes { 0 } else { 1 };
12953    }
12954
12955    // Assign to scalar reply, multi-var split, or array.
12956    // c:6685-6735 — `read x y z` splits buf by IFS, fills the first
12957    // N-1 vars with one IFS-separated field each, and stores the
12958    // REST of the line (including embedded IFS chars) into the last
12959    // var. zsh's read is stable on `print "a b c d" | read x y z`:
12960    // x="a", y="b", z="c d".
12961    if want_array {
12962        // c:Src/builtin.c:6685-6735 — `read -A arr` splits on $IFS
12963        // (whitespace-IFS coalesces; non-whitespace-IFS each acts as
12964        // a single delimiter). The previous port hardcoded
12965        // split_whitespace(), which ignored custom IFS like `:` and
12966        // produced a single-element array for `IFS=: read -A arr
12967        // <<< "a:b:c"`. Mirror the multi-var path's IFS handling.
12968        let ifs = getsparam("IFS").unwrap_or_else(|| " \t\n".to_string());
12969        let is_ifs = |c: char| ifs.contains(c);
12970        // c:6863-6869 — the FIRST word's char loop skips leading
12971        // whitespace-IFS (bptr==buf && iwsep → continue), so leading
12972        // whitespace produces no empty element. Mirror with trim_start.
12973        let trimmed = buf.trim_start_matches(|c: char| is_ifs(c) && c.is_whitespace());
12974        // NB: do NOT trim trailing whitespace-IFS. C reads word-by-word
12975        // and, after the last real word terminates on a whitespace
12976        // separator, attempts one more word: it consumes the remaining
12977        // trailing whitespace and hits the delimiter with an empty buf
12978        // and gotnl=1, so c:6929 `(*buf || first || gotnl)` adds a
12979        // trailing empty element. `read -A arr <<< "a b "` → (a b "").
12980        // c:Src/utils.c:3711 spacesplit — a whitespace-IFS char collapses a
12981        // run, and whitespace ADJACENT to a non-whitespace IFS separator is
12982        // absorbed into it (so `a : b` with IFS=" :" is ONE delimiter → 2
12983        // fields), while consecutive non-whitespace separators each delimit
12984        // (`a :: b` → 3, empty preserved). A non-whitespace separator also
12985        // absorbs its own trailing whitespace.
12986        let is_ws_ifs = |c: char| is_ifs(c) && c.is_whitespace();
12987        let is_nonws_ifs = |c: char| is_ifs(c) && !c.is_whitespace();
12988        let mut parts: Vec<String> = Vec::new();
12989        let mut field = String::new();
12990        let mut chars = trimmed.chars().peekable();
12991        while let Some(c) = chars.next() {
12992            if is_ifs(c) {
12993                parts.push(std::mem::take(&mut field));
12994                if c.is_whitespace() {
12995                    // Coalesce the whitespace-IFS run.
12996                    while chars.peek().copied().is_some_and(is_ws_ifs) {
12997                        chars.next();
12998                    }
12999                    // If that run is followed by a non-whitespace separator,
13000                    // it belongs to the SAME delimiter — consume it and its
13001                    // trailing whitespace so no empty field appears.
13002                    if chars.peek().copied().is_some_and(is_nonws_ifs) {
13003                        chars.next();
13004                        while chars.peek().copied().is_some_and(is_ws_ifs) {
13005                            chars.next();
13006                        }
13007                    }
13008                } else {
13009                    // Non-whitespace separator: absorb its trailing whitespace.
13010                    while chars.peek().copied().is_some_and(is_ws_ifs) {
13011                        chars.next();
13012                    }
13013                }
13014            } else {
13015                field.push(c);
13016            }
13017        }
13018        if !field.is_empty() || !parts.is_empty() {
13019            parts.push(field);
13020        }
13021        // c:Src/builtin.c:6929 — `if (*buf || first || gotnl)`. With
13022        // `gotnl=1` set on EOF (c:6898/6914) and `first=1` initial
13023        // value (c:6771), C's `read -A` adds the empty buf as one
13024        // element even when no bytes were ever read. The resulting
13025        // linked list at c:6949 then yields a 1-element array
13026        // containing "". Without this branch, immediate EOF produced
13027        // a 0-element array — diverging from zsh's "consumed one
13028        // (empty) field" semantics that downstream `${#arr}` checks
13029        // rely on to distinguish "empty line" from "no input".
13030        if parts.is_empty() {
13031            parts.push(String::new());
13032        }
13033        // c:Src/builtin.c:6910-6961 — `-A` with `-e`/`-E`. The word is
13034        // echoed (one per line) when `-e` (main per-word loop, c:6910)
13035        // OR `-E` (array-display loop, c:6949). The array is SET only
13036        // when `-e` is NOT set (c:6921 collects words, c:6945 allocates
13037        // p only when !-e). So `read -AE` echoes AND assigns; `read -Ae`
13038        // echoes but leaves the array untouched. The previous port
13039        // always assigned and never echoed.
13040        let opt_e = OPT_ISSET(ops, b'e');
13041        if opt_e || OPT_ISSET(ops, b'E') {
13042            for w in &parts {
13043                println!("{}", w);
13044            }
13045        }
13046        if !opt_e {
13047            setaparam(&reply, parts); // c:6960 setaparam
13048        }
13049    } else if argi < args.len() {
13050        // Multi-var: `read x y [z]`. First var = reply (already
13051        // consumed); rest are args[argi..]. Split with at most
13052        // `vars.len()` chunks using IFS.
13053        let mut vars: Vec<String> = Vec::with_capacity(args.len() - argi + 1);
13054        vars.push(reply);
13055        for n in &args[argi..] {
13056            vars.push(n.clone());
13057        }
13058        let ifs = getsparam("IFS").unwrap_or_else(|| " \t\n".to_string());
13059        // C zsh splits by ANY char from IFS (whitespace or not).
13060        let is_ifs = |c: char| ifs.contains(c);
13061        // Trim leading IFS-whitespace per zsh's read semantics
13062        // (`a   b c` → x=a, y="b c", not x="" y=…).
13063        // c:Src/builtin.c — `-E` echoes each field to stdout as it is read,
13064        // one per line (c:6957 in the -A path, and the equivalent per-word
13065        // display on the multi-var path); `-e` does the same but ASSIGNS
13066        // NOTHING (c:7106 `if (!OPT_ISSET(ops,'e')) setsparam(...)`). This path
13067        // did neither, so `read -E a b` split correctly but printed no echo
13068        // (the whole-line -E echo worked only for a single variable).
13069        let opt_e = OPT_ISSET(ops, b'e');
13070        let opt_echo = opt_e || OPT_ISSET(ops, b'E');
13071        let emit = |var: &str, val: &str| {
13072            if opt_echo {
13073                println!("{val}"); // c:6958 zputs + putchar('\n')
13074            }
13075            if !opt_e {
13076                setsparam(var, val); // c:7106
13077            }
13078        };
13079        let trimmed = buf.trim_start_matches(|c: char| is_ifs(c) && c.is_whitespace());
13080        let mut remaining = trimmed.to_string();
13081        for (i, var) in vars.iter().enumerate() {
13082            if i + 1 == vars.len() {
13083                // Last var: store the remainder, trim trailing IFS.
13084                let final_val = remaining
13085                    .trim_end_matches(|c: char| is_ifs(c) && c.is_whitespace())
13086                    .to_string();
13087                emit(var, &final_val);
13088            } else {
13089                // Find next IFS char.
13090                match remaining.find(is_ifs) {
13091                    Some(idx) => {
13092                        let field = remaining[..idx].to_string();
13093                        // c:Src/utils.c:3711 spacesplit — skip the whole
13094                        // delimiter. The separator char plus the IFS-whitespace
13095                        // ABSORBED around it form one delimiter: a whitespace
13096                        // separator coalesces its run AND a following non-ws
13097                        // separator (with that one's trailing whitespace); a
13098                        // non-ws separator absorbs its own trailing whitespace.
13099                        // So `x : y : z` (IFS=" :") reads as x, y, z — not
13100                        // x, "", "y : z".
13101                        let sep = remaining[idx..].chars().next().unwrap();
13102                        let is_ws = |c: char| is_ifs(c) && c.is_whitespace();
13103                        let after = &remaining[idx + sep.len_utf8()..];
13104                        let rest: &str = if sep.is_whitespace() {
13105                            let r = after.trim_start_matches(is_ws);
13106                            match r.chars().next() {
13107                                Some(nc) if is_ifs(nc) && !nc.is_whitespace() => {
13108                                    r[nc.len_utf8()..].trim_start_matches(is_ws)
13109                                }
13110                                _ => r,
13111                            }
13112                        } else {
13113                            after.trim_start_matches(is_ws)
13114                        };
13115                        emit(var, &field);
13116                        remaining = rest.to_string();
13117                    }
13118                    None => {
13119                        // No more IFS: this var gets remaining, others empty.
13120                        emit(var, &remaining);
13121                        remaining.clear();
13122                    }
13123                }
13124            }
13125        }
13126    } else {
13127        // c:Src/builtin.c:6843 — `read VAR` single-var path strips
13128        // leading/trailing IFS-whitespace exactly like the multi-var
13129        // last-var fallback above. C's read loop discards leading
13130        // whitespace-IFS via `if (c == sep && IS_SPACE(c)) ... loop`
13131        // (c:6843) before building each word, then on word-end it
13132        // either emits the word or, on the last var, keeps building
13133        // until newline — at which point the trailing whitespace-IFS
13134        // gets discarded by the same skip. Without this, `read line`
13135        // on `   hello   ` set `line` to `   hello   ` (with spaces)
13136        // instead of `hello` — bug #247.
13137        let ifs = getsparam("IFS").unwrap_or_else(|| " \t\n".to_string());
13138        let is_ifs = |c: char| ifs.contains(c);
13139        let trimmed = buf
13140            .trim_start_matches(|c: char| is_ifs(c) && c.is_whitespace())
13141            .trim_end_matches(|c: char| is_ifs(c) && c.is_whitespace());
13142        // c:Src/builtin.c:7102-7109 — `-e` / `-E` flags. Both echo
13143        // the read content to stdout (`zputs(buf, stdout); putchar
13144        // ('\n')`); `-e` ALSO skips the setsparam (echo-only, no
13145        // assign), while `-E` echoes AND assigns. Bug #434.
13146        let opt_e = OPT_ISSET(ops, b'e');
13147        let opt_big_e = OPT_ISSET(ops, b'E');
13148        if opt_e || opt_big_e {
13149            println!("{}", trimmed);
13150        }
13151        if !opt_e {
13152            setsparam(&reply, trimmed);
13153        }
13154    }
13155    // c:Src/builtin.c:6534 — partial-EOF post-assign exit.
13156    if partial_eof {
13157        return 1;
13158    }
13159    0
13160}
13161
13162/// Port of `zread(int izle, int *readchar, long izle_timeout)` from Src/builtin.c:7134.
13163/// C: `static int zread(int izle, int *readchar, long izle_timeout)` —
13164///   read one byte from stdin (or via ZLE), respecting timeout.
13165pub fn zread(izle: i32, readchar: &mut i32, izle_timeout: i64) -> i32 {
13166    // c:7134
13167    if izle != 0 {
13168        // c:7140
13169        // c:7141-7144 — zleentry(ZLE_CMD_GET_KEY, izle_timeout, NULL, &c);
13170        // Static-link path: ZLE bridge lives in src/ported/zle/*; until
13171        // wired, fall through to plain stdin.
13172        let _ = izle_timeout;
13173    }
13174    if *readchar >= 0 {
13175        // c:7150
13176        let cc = *readchar as u8;
13177        *readchar = -1; // c:7152
13178        return cc as i32;
13179    }
13180    // c:7160 — `read(SHTTY, &cc, 1)` with EINTR retry. Read from the
13181    //          controlling tty (SHTTY) when available; stdin fallback
13182    //          for non-interactive paths where SHTTY isn't set up.
13183    let mut buf = [0u8; 1];
13184    let fd = {
13185        use std::sync::atomic::Ordering;
13186        let s = crate::ported::init::SHTTY.load(Relaxed);
13187        if s >= 0 {
13188            s
13189        } else {
13190            0
13191        } // c:7167 SHTTY fallback
13192    };
13193    loop {
13194        let n = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut libc::c_void, 1) };
13195        match n {
13196            1 => return buf[0] as i32, // c:7169
13197            0 => return -1,            // EOF
13198            -1 if io::Error::last_os_error().kind() == io::ErrorKind::Interrupted => continue,
13199            _ => return -1,
13200        }
13201    }
13202}
13203
13204/// Port of `testlex()` from Src/builtin.c:7200.
13205/// C: `void testlex(void)` — advance the test-builtin lexer one token
13206///   from `testargs` into `tok`/`tokstr`. Maps `-o`→DBAR, `-a`→DAMPER,
13207///   `!`→Bang, `(`→Inpar, `)`→Outpar, otherwise STRING.
13208pub fn testlex() {
13209    // c:7200
13210    // c:7203 — `if (tok == LEXERR) return;`
13211    if TEST_TOK.load(Relaxed) == TEST_LEXERR {
13212        // c:7203
13213        return;
13214    }
13215    // c:7206-7224 — `tokstr = *(curtestarg = testargs);`
13216    let mut targs = TESTARGS.lock().unwrap_or_else(|e| {
13217        TESTARGS.clear_poison();
13218        e.into_inner()
13219    });
13220    let mut idx = TESTARGS_IDX.load(Relaxed) as usize;
13221    let cur = targs.get(idx).cloned(); // c:7206
13222    if let Some(t) = cur.as_ref() {
13223        if let Ok(mut ts) = TOKSTR.lock() {
13224            *ts = t.clone();
13225        } // c:7206
13226    }
13227    // c:7207-7211 — `if (!*testargs) { tok = tok ? NULLTOK : LEXERR; return; }`
13228    let none = cur.is_none() || cur.as_deref() == Some("");
13229    if none {
13230        // c:7207
13231        let prev = TEST_TOK.load(Relaxed);
13232        TEST_TOK.store(
13233            if prev != 0 { TEST_NULLTOK } else { TEST_LEXERR }, // c:7210
13234            Relaxed,
13235        );
13236        return;
13237    }
13238    let arg = cur.unwrap();
13239    let new_tok = match arg.as_str() {
13240        // c:7212
13241        "-o" => TEST_DBAR,   // c:7213
13242        "-a" => TEST_DAMPER, // c:7215
13243        "!" => TEST_BANG,    // c:7217
13244        "(" => TEST_INPAR,   // c:7219
13245        ")" => TEST_OUTPAR,  // c:7221
13246        "<" => TEST_INANG,   // c:7223
13247        ">" => TEST_OUTANG,  // c:7225
13248        _ => TEST_STRING,    // c:7227
13249    };
13250    TEST_TOK.store(new_tok, Relaxed);
13251    idx += 1; // c:7228 testargs++
13252    TESTARGS_IDX.store(idx as i32, Relaxed);
13253    let _ = &mut *targs; // ensure lock holds for the duration of mutation
13254}
13255
13256/// Port of `bin_test(char *name, char **argv, UNUSED(Options ops), int func)` from Src/builtin.c:7231.
13257/// C: `int bin_test(char *name, char **argv, UNUSED(Options ops), int func)`
13258/// — the `test` / `[` builtin: when invoked as `[`, requires a trailing
13259///   `]`; XSI-extension paren-stripping for 3/4-arg forms; final
13260///   evalcond dispatch returns 0/1/2.
13261/// WARNING: param names don't match C — Rust=(name, argv, func) vs C=(name, argv, ops, func)
13262pub fn bin_test(
13263    name: &str,
13264    argv: &[String], // c:7231
13265    _ops: &options,
13266    func: i32,
13267) -> i32 {
13268    let mut argv = argv.to_vec();
13269    let mut sense = 0i32; // c:7236
13270
13271    // c:7239-7247 — `[` requires trailing `]`.
13272    if func == BIN_BRACKET {
13273        // c:7239
13274        if argv.is_empty() || argv.last().map(|s| s.as_str()) != Some("]") {
13275            // c:7241
13276            zwarnnam(name, "']' expected"); // c:7243
13277            return 2; // c:7244
13278        }
13279        argv.pop(); // c:7246 (s[-1] = NULL)
13280    }
13281
13282    // c:7249-7250 — empty argv → false (1).
13283    if argv.is_empty() {
13284        // c:7249
13285        return 1; // c:7250
13286    }
13287
13288    // c:Src/parse.c par_cond / par_cond_1 — a SINGLE remaining token is
13289    // always a NON-EMPTY-STRING test (implicit `-n`), no matter what it
13290    // looks like: a unary op (`-z`/`-f`), `!`, a binary op (`<`/`>`/`=`),
13291    // or a paren all need at least one more token to form an operator, and
13292    // with one token there is none. So `[ -z ]` / `[ ! ]` / `[ ( ]` /
13293    // `[ < ]` are true iff the token is non-empty; `[ "" ]` is false.
13294    // Verified vs /opt/homebrew/bin/zsh 5.9. Handle it before the operator
13295    // special-cases below, which otherwise (mis)treat the lone token as an
13296    // operator with a missing operand.
13297    if argv.len() == 1 {
13298        return i32::from(argv[0].is_empty());
13299    }
13300
13301    // c:7257-7274 — XSI 3/4-arg parens + 4-arg `!` extension.
13302    let nargs = argv.len(); // c:7257
13303    if nargs == 3 || nargs == 4 {
13304        // c:7258
13305        // c:7264-7269 — strip `(` ... `)` parens unless the 3-arg middle
13306        // would be a binary op (which takes priority).
13307        if argv[0] == "(" && argv[nargs - 1] == ")"                          // c:7264
13308            && (nargs != 3 || crate::ported::text::is_cond_binary_op(&argv[1]) == 0)
13309        // c:7265
13310        {
13311            argv.pop(); // c:7266
13312            argv.remove(0); // c:7267
13313        }
13314    }
13315    if argv.len() == 3 && argv[0] == "!" {
13316        // c:7270 (effective)
13317        sense = 1; // c:7271
13318        argv.remove(0); // c:7272
13319    }
13320
13321    // c:Src/parse.c:2486-2492 par_cond_2 — a condition that is a single WORD
13322    // means `-n word`:
13323    //     if (n_testargs == 1) {
13324    //         /* one argument: [ foo ] is equivalent to [ -n foo ] */
13325    //         s1 = tokstr; condlex();
13326    //         return par_cond_double(dupstring("-n"), s1);
13327    //     }
13328    //
13329    // C needs no special handling around `-a`/`-o` for this: bin_test hands the
13330    // whole argument list to the real grammar (c:7276-7280 `condlex = testlex;
13331    // parse_cond()`), and every operand of a connective is parsed by
13332    // par_cond_2 — so a lone word ANYWHERE becomes `-n word`. This port has no
13333    // grammar to lean on: bin_test is an ad-hoc argument-count parser,
13334    // `testlex` is dead code, and par_cond_2 hardcodes `let n_testargs = 0`,
13335    // disabling the POSIX-test rules it otherwise ports. So do the equivalent
13336    // rewrite explicitly — split on the top-level connectives and expand any
13337    // single-word operand:
13338    //     test a -a b     → -n a -a -n b     → 0   (was "unknown condition: -a")
13339    //     test '' -a ''   → -n '' -a -n ''   → 1
13340    //     test -n -a a    → -n -n -a -n a    → 0   (that first `-n` is an OPERAND)
13341    //     test -n a -a -n → -n a -a -n -n    → 0   (2-word segment left alone)
13342    //
13343    // Skipped when parentheses are present: segmenting those needs the real
13344    // grammar, and the parenthesised forms already agree. Skipped when any
13345    // segment is empty (`test -a a`), which zsh reports as an error rather
13346    // than parsing.
13347    if !argv.iter().any(|a| a == "(" || a == ")") && argv.iter().any(|a| a == "-a" || a == "-o") {
13348        let segs: Vec<Vec<String>> = argv
13349            .split(|a| a == "-a" || a == "-o")
13350            .map(|s| s.to_vec())
13351            .collect();
13352        if segs.iter().all(|s| !s.is_empty()) {
13353            let mut rebuilt: Vec<String> = Vec::with_capacity(argv.len() + segs.len());
13354            let mut seg_iter = segs.iter();
13355            let mut pending = seg_iter.next();
13356            let mut emitted_in_seg = 0usize;
13357            for a in argv.iter() {
13358                if a == "-a" || a == "-o" {
13359                    rebuilt.push(a.clone());
13360                    pending = seg_iter.next();
13361                    emitted_in_seg = 0;
13362                    continue;
13363                }
13364                if emitted_in_seg == 0 {
13365                    // c:2488 — one word ⇒ `-n word`. `!` is an operator, never
13366                    // an operand, so it does not take the implicit -n.
13367                    if let Some(seg) = pending {
13368                        if seg.len() == 1 && seg[0] != "!" {
13369                            rebuilt.push("-n".to_string());
13370                        }
13371                    }
13372                }
13373                rebuilt.push(a.clone());
13374                emitted_in_seg += 1;
13375            }
13376            argv = rebuilt;
13377        }
13378    }
13379
13380    // c:Src/parse.c par_cond — 3-arg form with binary op at args[0]
13381    // (instead of args[1]) is a parse error: zsh treats args[0] as a
13382    // unary condition probe; binary ops aren't valid unary ops →
13383    // "unknown condition: ARGV[0]" rc=2. Verified vs
13384    // /opt/homebrew/bin/zsh: `[ -lt 5 3 ]` →
13385    //   "zsh:[:1: unknown condition: -lt" rc=2.
13386    // Also: 4-arg with binop at args[1] (the valid binop slot) →
13387    // "too many arguments" because the extra trailing arg can't be
13388    // consumed.
13389    if argv.len() == 3 && argv[0].starts_with('-') && argv[0].len() >= 3 {
13390        if matches!(
13391            argv[0].as_str(),
13392            "-eq" | "-ne" | "-lt" | "-gt" | "-le" | "-ge" | "-nt" | "-ot" | "-ef"
13393        ) {
13394            crate::ported::utils::zwarnnam(name, &format!("unknown condition: {}", argv[0]));
13395            return 2;
13396        }
13397    }
13398    // c:Src/parse.c par_cond — 3-arg form analysis:
13399    //   (a) `[ -X arg extra ]` — unary at pos 0, extra at pos 2
13400    //       → "too many arguments"
13401    //   (b) `[ a OP b ]` with unknown OP → "unknown condition: OP"
13402    //   (c) `[ a b c ]` (no recognised op at pos 1) →
13403    //       "condition expected: b"
13404    let known_binops: &[&str] = &[
13405        "=",
13406        "==",
13407        "!=",
13408        "<",
13409        ">",
13410        "-eq",
13411        "-ne",
13412        "-lt",
13413        "-gt",
13414        "-le",
13415        "-ge",
13416        "-nt",
13417        "-ot",
13418        "-ef",
13419        "&&",
13420        "||",
13421        "-a",
13422        "-o",
13423        "=~",
13424        "-regex-match",
13425    ];
13426    if argv.len() == 3 && argv[0].starts_with('-') && argv[0].len() == 2 {
13427        let op_char = argv[0].chars().nth(1).unwrap_or(' ');
13428        let is_known_unary = matches!(
13429            op_char,
13430            'a' | 'b'
13431                | 'c'
13432                | 'd'
13433                | 'e'
13434                | 'f'
13435                | 'g'
13436                | 'h'
13437                | 'k'
13438                | 'L'
13439                | 'n'
13440                | 'o'
13441                | 'p'
13442                | 'r'
13443                | 's'
13444                | 'S'
13445                | 't'
13446                | 'u'
13447                | 'v'
13448                | 'w'
13449                | 'x'
13450                | 'z'
13451                | 'G'
13452                | 'N'
13453                | 'O'
13454        );
13455        // For unary-flag-at-pos-0 with a 3rd extra arg, the canonical
13456        // diagnostic is "too many arguments" — UNLESS args[1] is itself
13457        // a binary op (e.g., `-z -lt 5` which is malformed differently).
13458        if is_known_unary && !known_binops.contains(&argv[1].as_str()) {
13459            crate::ported::utils::zwarnnam(name, "too many arguments");
13460            return 2;
13461        }
13462    }
13463    if argv.len() == 3 && !argv[0].starts_with('-') && argv[0] != "!" && argv[0] != "(" {
13464        let mid = argv[1].as_str();
13465        if mid.starts_with('-') && argv[1].len() >= 2 && !known_binops.contains(&mid) {
13466            crate::ported::utils::zwarnnam(name, &format!("unknown condition: {}", argv[1]));
13467            return 2;
13468        }
13469        if !known_binops.contains(&mid) {
13470            crate::ported::utils::zwarn(&format!("condition expected: {}", argv[1]));
13471            return 2;
13472        }
13473    }
13474    if argv.len() == 4
13475        && matches!(
13476            argv[1].as_str(),
13477            "=" | "=="
13478                | "!="
13479                | "-eq"
13480                | "-ne"
13481                | "-lt"
13482                | "-gt"
13483                | "-le"
13484                | "-ge"
13485                | "-nt"
13486                | "-ot"
13487                | "-ef"
13488        )
13489    {
13490        crate::ported::utils::zwarnnam(name, "too many arguments");
13491        return 2;
13492    }
13493    // c:Src/parse.c par_cond — 4-arg form `-FLAG operand extra extra` /
13494    // any 4+ arg layout where args[0] is a recognized unary flag with
13495    // extras after the operand → "too many arguments".
13496    //
13497    // ...but NOT when the third argument is a CONNECTIVE. c:Src/parse.c:2495
13498    // par_cond_2 has no blanket 4-argument rejection: with n_testargs > 2 it
13499    // only special-cases a binary operator in the SECOND position (`=`, `<`,
13500    // `>`, `==`, `!=`, `-<condnum>`) and otherwise falls through to the general
13501    // condition grammar, which happily consumes `-FLAG operand` and then
13502    // applies the `-a`/`-o` that follows. So `test -n a -a b` is `(-n a) && b`
13503    // → 0, and `test -n -a -n x` is `(-n -a) && (-n x)`; both were rejected
13504    // here as "too many arguments".
13505    // The connective can sit at either position: `test -f a -a b` puts it at
13506    // index 2, while `test -n -a -n x` is `(-n -a) && (-n x)` and puts it at
13507    // index 1. C never has to look — the grammar just consumes tokens — so the
13508    // equivalent here is "a connective appears anywhere past argv[0]".
13509    if argv.len() == 4
13510        && argv[0].starts_with('-')
13511        && argv[0].len() == 2
13512        && !argv[1..].iter().any(|a| a == "-a" || a == "-o")
13513    {
13514        let op_char = argv[0].chars().nth(1).unwrap_or(' ');
13515        if matches!(
13516            op_char,
13517            'a' | 'b'
13518                | 'c'
13519                | 'd'
13520                | 'e'
13521                | 'f'
13522                | 'g'
13523                | 'h'
13524                | 'k'
13525                | 'L'
13526                | 'n'
13527                | 'o'
13528                | 'p'
13529                | 'r'
13530                | 's'
13531                | 'S'
13532                | 't'
13533                | 'u'
13534                | 'v'
13535                | 'w'
13536                | 'x'
13537                | 'z'
13538                | 'G'
13539                | 'N'
13540                | 'O'
13541        ) {
13542            crate::ported::utils::zwarnnam(name, "too many arguments");
13543            return 2;
13544        }
13545    }
13546    // c:Src/parse.c par_cond — 4+ args with no recognised connective:
13547    // "condition expected: ARGV[0]" rc=2 (zsh points at the first
13548    // operand, mirroring 2-arg bare-operands message).
13549    if argv.len() >= 4 && argv[0] != "(" {
13550        let has_connective = argv
13551            .iter()
13552            .any(|a| matches!(a.as_str(), "&&" | "||" | "-a" | "-o"));
13553        let has_paren = argv.iter().any(|a| a == "(");
13554        let has_known_binop_mid = argv.iter().any(|a| known_binops.contains(&a.as_str()));
13555        if !has_connective && !has_paren && !has_known_binop_mid {
13556            crate::ported::utils::zwarn(&format!("condition expected: {}", argv[0]));
13557            let _ = name;
13558            return 2;
13559        }
13560    }
13561
13562    // c:Src/builtin.c:7276-7280 + Src/parse.c par_cond — when the
13563    // 2-arg form is `[ -FLAG operand ]` and `-FLAG` isn't a recognized
13564    // unary op letter, par_cond emits `unknown condition: -FLAG` rc=2.
13565    // The cond.rs walker's primary arm falls through to "bare arg /
13566    // implicit -n" silently, returning 0 (truthy). Pre-flight: if
13567    // 2-arg AND argv[0] starts with `-` AND argv[0] isn't a known
13568    // unary op nor `!`/`(`, emit the canonical diagnostic.
13569    // c:Src/builtin.c:7257 + par_cond — paren-balance diagnostics:
13570    //   - `[ ( ) ]` empty parens          → "argument expected" rc=2
13571    //   - `[ ( a ]` unmatched open        → "argument expected" rc=2
13572    //   - `[ a ) ]` unmatched close       → "too many arguments" rc=2
13573    // zshrs's evalcond walker silently returned 2 with no diagnostic;
13574    // mirror C. Walk and check depth.
13575    {
13576        let mut depth: i32 = 0;
13577        let mut surplus_close = false;
13578        let mut had_open = false;
13579        for a in &argv {
13580            if a == "(" {
13581                depth += 1;
13582                had_open = true;
13583            } else if a == ")" {
13584                depth -= 1;
13585                if depth < 0 {
13586                    surplus_close = true;
13587                    break;
13588                }
13589            }
13590        }
13591        if surplus_close {
13592            crate::ported::utils::zwarnnam(name, "too many arguments");
13593            return 2;
13594        }
13595        if depth > 0 && had_open {
13596            crate::ported::utils::zwarnnam(name, "argument expected");
13597            return 2;
13598        }
13599    }
13600    if argv.len() == 2 && argv[0] == "(" && argv[1] == ")" {
13601        crate::ported::utils::zwarnnam(name, "argument expected");
13602        return 2;
13603    }
13604    if argv.len() == 2 && argv[0].starts_with('-') && argv[0].len() >= 2 && argv[0] != "!" {
13605        let op_char = argv[0].chars().nth(1).unwrap_or(' ');
13606        let is_known_unary = argv[0].len() == 2
13607            && matches!(
13608                op_char,
13609                'a' | 'b'
13610                    | 'c'
13611                    | 'd'
13612                    | 'e'
13613                    | 'f'
13614                    | 'g'
13615                    | 'h'
13616                    | 'k'
13617                    | 'L'
13618                    | 'n'
13619                    | 'o'
13620                    | 'p'
13621                    | 'r'
13622                    | 's'
13623                    | 'S'
13624                    | 't'
13625                    | 'u'
13626                    | 'v'
13627                    | 'w'
13628                    | 'x'
13629                    | 'z'
13630                    | 'G'
13631                    | 'N'
13632                    | 'O'
13633            );
13634        if !is_known_unary {
13635            crate::ported::utils::zwarnnam(name, &format!("unknown condition: {}", argv[0]));
13636            return 2;
13637        }
13638    } else if argv.len() == 2
13639        && !argv[0].starts_with('-')
13640        && argv[0] != "!"
13641        && argv[0] != "("
13642        && argv[1] != ")"
13643    {
13644        // c:Src/parse.c par_cond — 2-arg form with two bare operands
13645        // (no flag, no `!`, no paren wrapping) is a parse error —
13646        // zsh emits "parse error: condition expected: SECOND_OPERAND"
13647        // (via zwarn, no builtin-name prefix). Verified vs
13648        // /opt/homebrew/bin/zsh: `[ "" "" ]` →
13649        //   "zsh:1: parse error: condition expected: " rc=2
13650        crate::ported::utils::zwarn(&format!("parse error: condition expected: {}", argv[0]));
13651        let _ = name;
13652        return 2;
13653    }
13654    // c:Src/builtin.c:7276-7280 — `[ ]`/`test` uses `parse_cond`
13655    // which has no rule for TEST_INANG (`<`) or TEST_OUTANG (`>`) as
13656    // binary string-comparators; the parser errors with
13657    // `condition expected: <`. Only `[[ ]]` (which routes through
13658    // `execcond` instead of this builtin) accepts `<`/`>` for lex
13659    // compare. Bug #98 in docs/BUGS.md — zshrs's `bin_test` routed
13660    // through the shared `cond::evalcond` which accepts them as
13661    // COND_STRLT/COND_STRGTR, so `[ a \< b ]` silently evaluated.
13662    //
13663    // Detect `<`/`>` in a binary-op position (the middle of a 3-arg
13664    // form, or any position after an operand) and emit the canonical
13665    // diagnostic before reaching evalcond.
13666    if argv.iter().any(|a| a == "<" || a == ">") {
13667        let offending = argv
13668            .iter()
13669            .find(|a| a.as_str() == "<" || a.as_str() == ">")
13670            .map(|s| s.as_str())
13671            .unwrap_or("<");
13672        // Use zwarn (cmd=None) — zsh's `[ ]` parse-error format is
13673        // `<script>:<line>: condition expected: <`, NOT
13674        // `<script>:test:<line>:...`. zwarn omits the builtin-name
13675        // segment that zwarnnam adds; zwarn also doesn't set
13676        // errflag, so `echo $?` after the failed test still runs.
13677        crate::ported::utils::zwarn(&format!("condition expected: {}", offending));
13678        let _ = name;
13679        return 2;
13680    }
13681
13682    // c:7276-7301 — zcontext_save + par_cond + evalcond.
13683    // Static-link path: route through cond.rs's evalcond which handles
13684    // the full tokenization + parse + eval inline.
13685    let args_refs: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
13686    let options = HashMap::new();
13687    let mut variables = HashMap::new();
13688    // C `evalcond` reaches param values through `getvalue` / `getsparam`
13689    // which read paramtab. The previous Rust port populated the
13690    // variables map from `std::env::vars()` — the OS environment —
13691    // so shell-internal vars (not exported) appeared "unset" to
13692    // `[[ -z $var ]]` / `[[ $a = $b ]]` etc. Walk paramtab to mirror
13693    // C; fall back to env for entries the paramtab hasn't imported.
13694    {
13695        let tab = paramtab().read().unwrap();
13696        for (k, pm) in tab.iter() {
13697            // Skip PM_UNSET — these are name-declared-but-no-value.
13698            if (pm.node.flags as u32 & PM_UNSET) != 0 {
13699                continue;
13700            }
13701            let v = pm.u_str.clone().unwrap_or_default();
13702            variables.insert(k.clone(), v);
13703        }
13704    }
13705    // Layer env vars on top of paramtab for the rare case where the
13706    // OS env has a name paramtab hasn't yet imported (e.g. external
13707    // wrapper that exec'd zshrs with env vars).
13708    for (k, v) in env::vars() {
13709        variables.entry(k).or_insert(v);
13710    }
13711    let posix = isset(POSIXBUILTINS);
13712    // c:Src/builtin.c:7305 — `stat = evalcond(state, name);`. The
13713    // `name` argument is C's `fromtest` signal — non-NULL means "called
13714    // from test/[", which enables the strict integer-expression error
13715    // path (c:Src/cond.c:236-251). Bug #411.
13716    let mut ret =
13717        crate::ported::cond::evalcond(&args_refs, &options, &variables, posix, Some(name)); // c:7305
13718
13719    // c:7307-7308 — `if (ret < 2 && sense) ret = !ret;`
13720    if ret < 2 && sense != 0 {
13721        // c:7307
13722        ret = if ret == 0 { 1 } else { 0 }; // c:7308
13723    }
13724    ret // c:7310
13725}
13726
13727/// Port of `bin_times(UNUSED(char *name), UNUSED(char **argv), UNUSED(Options ops), UNUSED(int func))` from Src/builtin.c:7324.
13728/// C: `int bin_times(UNUSED args)` — `times(&buf)`; print user/system
13729///   for self then for children, separated by spaces and newlines.
13730/// WARNING: param names don't match C — Rust=(_name, _argv, _func) vs C=(name, argv, ops, func)
13731pub fn bin_times(
13732    _name: &str,
13733    _argv: &[String], // c:7328
13734    _ops: &options,
13735    _func: i32,
13736) -> i32 {
13737    let mut buf: libc::tms = unsafe { std::mem::zeroed() }; // c:7331
13738                                                            // c:7332 — `long clktck = get_clktck();`. The previous Rust port
13739                                                            // inlined a `sysconf(_SC_CLK_TCK)` call here. Route through the
13740                                                            // canonical `get_clktck()` port at jobs.rs:567 so any future
13741                                                            // hardening (caching, error fallback) propagates to every caller.
13742    let clktck = crate::ported::jobs::get_clktck() as f64; // c:7332
13743    let clktck = if clktck <= 0.0 { 100.0 } else { clktck };
13744    // c:7335 — `if (times(&buf) == -1) return 1;`
13745    if unsafe { libc::times(&mut buf) } == (-1i64) as libc::clock_t {
13746        // c:7335
13747        return 1; // c:7336
13748    }
13749    let pttime = |t: libc::clock_t| {
13750        // c:Src/builtin.c:7315-7318 — `printf("%ldm%ld.%02lds", X/(60*clktck),
13751        //   X/clktck%clktck, X*100/clktck%100)`. Integer-arithmetic
13752        // breakdown: minutes (no zero-pad), seconds (no zero-pad), and
13753        // centiseconds (2-digit zero-pad). Bug #499 — previous Rust
13754        // port used floating-point `%.3f` with 3-decimal precision,
13755        // diverging from zsh's `0m0.00s`.
13756        let x = t as i64;
13757        let clktck_i = clktck as i64;
13758        let mins = x / (60 * clktck_i);
13759        let secs = (x / clktck_i) % clktck_i;
13760        let csec = (x * 100 / clktck_i) % 100;
13761        print!("{}m{}.{:02}s", mins, secs, csec);
13762    };
13763    pttime(buf.tms_utime); // c:7332
13764    print!(" "); // c:7333
13765    pttime(buf.tms_stime); // c:7334
13766    println!(); // c:7335
13767    pttime(buf.tms_cutime); // c:7336
13768    print!(" "); // c:7337
13769    pttime(buf.tms_cstime); // c:7338
13770    println!(); // c:7339
13771    0 // c:7340
13772}
13773
13774/// Port of `bin_trap(char *name, char **argv, UNUSED(Options ops), UNUSED(int func))` from Src/builtin.c:7347.
13775/// C: `int bin_trap(char *name, char **argv, ...)` — list, clear, or
13776///   set signal traps.
13777/// WARNING: param names don't match C — Rust=(name, argv, _func) vs C=(name, argv, ops, func)
13778pub fn bin_trap(
13779    name: &str,
13780    argv: &[String], // c:7347
13781    _ops: &options,
13782    _func: i32,
13783) -> i32 {
13784    // PFA-SMR aspect: record `trap HANDLER SIG...` calls. Skip
13785    // listing-only forms (`trap`, `trap -l`, `trap -p`) — those don't
13786    // mutate state.
13787    #[cfg(feature = "recorder")]
13788    if crate::recorder::is_enabled() {
13789        let listing = argv.is_empty() || (argv.len() == 1 && (argv[0] == "-l" || argv[0] == "-p"));
13790        if !listing && argv.len() >= 2 {
13791            let ctx = crate::recorder::recorder_ctx_global();
13792            let handler = &argv[0];
13793            for sig in &argv[1..] {
13794                crate::recorder::emit_trap(sig, handler, ctx.clone());
13795            }
13796        }
13797    }
13798
13799    let mut argv = argv.to_vec();
13800    // c:7353 — `if (*argv && !strcmp(*argv, "--")) argv++;`
13801    if !argv.is_empty() && argv[0] == "--" {
13802        // c:7353
13803        argv.remove(0); // c:7354
13804    }
13805
13806    // c:7357-7380 — no args: list current traps.
13807    if argv.is_empty() {
13808        // c:7357
13809        // Local enum for the merged function-form / string-form
13810        // listing walker below. RUST-ONLY scoping helper for the
13811        // c:Src/builtin.c:7359-7375 if/else-if structure.
13812        enum TrapEntry {
13813            Func(String),
13814            Str(String, String),
13815        }
13816        queue_signals(); // c:7358
13817        let traps = traps_table().lock().map(|t| t.clone()).unwrap_or_default();
13818        // c:Src/builtin.c:7359-7375 — C walks `sigtrapped[]` by signal
13819        // number index, so the listing is naturally sorted by sig num
13820        // (HUP=1, INT=2, …, USR1=30, …). zshrs stores traps in a
13821        // HashMap<name, body> whose iteration order is non-deterministic
13822        // — bug #68 in docs/BUGS.md: `trap` output came out in random
13823        // order vs zsh's stable signum-sorted view. Sort by getsigidx
13824        // before printing so the iteration matches C's array walk.
13825        //
13826        // c:Src/builtin.c:7360-7365 — `if (sigtrapped[sig] & ZSIG_FUNC)
13827        // { hn = gettrapnode(sig, 0); shfunctab->printnode(hn, 0); }`.
13828        // Walk shfunctab for `TRAP<signame>` functions and emit their
13829        // full definition alongside the string-form traps so the
13830        // listing matches zsh. Bug #461 in docs/BUGS.md.
13831        let trap_funcs: Vec<(String, i32, String)> = {
13832            let mut acc: Vec<(String, i32, String)> = Vec::new();
13833            if let Ok(tab) = crate::ported::hashtable::shfunctab_lock().read() {
13834                for (fname, _) in tab.iter() {
13835                    if let Some(sig_name) = fname.strip_prefix("TRAP") {
13836                        let idx = getsigidx(sig_name);
13837                        if idx != -1 {
13838                            acc.push((fname.clone(), idx, sig_name.to_string()));
13839                        }
13840                    }
13841                }
13842            }
13843            acc
13844        };
13845        // Merge function-form + string-form, sort by sig idx
13846        // (function-form FIRST per zsh ordering — c:Src/builtin.c:7359
13847        // walks each signal once; ZSIG_FUNC takes precedence within a
13848        // single signum slot since the C code uses `else if` between
13849        // the two arms).
13850        let mut combined: Vec<(i32, TrapEntry)> = Vec::new();
13851        for (fname, idx, _sig) in &trap_funcs {
13852            combined.push((*idx, TrapEntry::Func(fname.clone())));
13853        }
13854        for (sig, body) in traps.iter() {
13855            let idx = getsigidx(sig);
13856            // Skip if a function-form already exists for this sig
13857            // (C's `else if` semantics — function-form wins).
13858            if trap_funcs.iter().any(|(_, i, _)| *i == idx) {
13859                continue;
13860            }
13861            combined.push((
13862                if idx == -1 { i32::MAX } else { idx },
13863                TrapEntry::Str(sig.clone(), body.clone()),
13864            ));
13865        }
13866        combined.sort_by_key(|(idx, _)| *idx);
13867        for (_idx, entry) in &combined {
13868            match entry {
13869                TrapEntry::Func(fname) => {
13870                    if let Ok(tab) = crate::ported::hashtable::shfunctab_lock().read() {
13871                        if let Some(shf) = tab.get(fname) {
13872                            crate::ported::hashtable::printshfuncnode(shf, 0);
13873                        }
13874                    }
13875                }
13876                TrapEntry::Str(sig, body) => {
13877                    // c:7371 — `s = getpermtext(siglists[sig], NULL, 0);`. C
13878                    // holds the body as a compiled Eprog and renders it back
13879                    // to source for the listing, so what prints is CANONICAL
13880                    // text rather than the string the user typed:
13881                    // `trap 'print a; print b' EXIT` lists as
13882                    // `trap -- $'print a\nprint b' EXIT` (separators become
13883                    // newlines, `if`/`for`/`(` bodies get re-indented).
13884                    // zshrs stores the body as raw text, so deparse it here
13885                    // the same way. Rendering only at listing time keeps the
13886                    // stored text authoritative for execution — a getpermtext
13887                    // rendering bug then costs a wrong listing, not a wrong
13888                    // trap body.
13889                    //
13890                    // An empty body takes C's `!siglists[sig]` branch
13891                    // (c:7368-7369, prints `trap -- '' SIG`) and is never
13892                    // rendered. parse_string is the wordcode parser, whose
13893                    // coverage is narrower than the AST parser that executes
13894                    // the body (e.g. `(pat)` case arms — see the pin test in
13895                    // text.rs), so fall back to the raw text on a parse
13896                    // failure rather than dropping the entry.
13897                    let rendered = if body.is_empty() {
13898                        body.clone()
13899                    } else {
13900                        crate::ported::exec::parse_string(body, 1)
13901                            .map(|p| crate::ported::text::getpermtext(Box::new(p), None, 0))
13902                            .unwrap_or_else(|| body.clone())
13903                    };
13904                    // c:7370-7375 — `printf("trap -- "); quotedzputs(...); printf(" %s\n", name);`
13905                    print!("trap -- "); // c:7372
13906                    print!("{}", quotedzputs(&rendered)); // c:7373
13907                    println!(" {}", sig); // c:7374
13908                }
13909            }
13910        }
13911        unqueue_signals(); // c:7378
13912        return 0; // c:7379
13913    }
13914
13915    // c:7384-7400 — first arg is signal number / single `-` → clear.
13916    let first = &argv[0];
13917    if getsigidx(first) != -1 || first == "-" {
13918        // c:7384
13919        let start = if first == "-" { 1 } else { 0 }; // c:7385
13920                                                      // c:7399 — `return *argv != NULL;`. After a successful loop
13921                                                      // *argv is the trailing NULL (Rust: idx == len after the
13922                                                      // walk); on `break` due to an undefined signal *argv is the
13923                                                      // bad arg (idx < len). Previous Rust port hardcoded
13924                                                      // `return 0`, so `trap - INVALID` would silently report
13925                                                      // success and downstream scripts couldn't detect the bad
13926                                                      // signal name.
13927        let mut had_error = 0i32;
13928        // c:7386-7398 — build the signal list, then run ONE
13929        // `unsettrap` body over it. `trap -` clears every slot
13930        // (c:7387-7388: `for (sig = 0; sig < TRAPCOUNT; sig++)
13931        // unsettrap(sig);`); `trap - SIG…` clears the named ones and
13932        // stops at the first undefined name (c:7390-7398), leaving
13933        // the already-walked signals cleared exactly as C does.
13934        let mut to_clear: Vec<i32> = Vec::new();
13935        if start >= argv.len() {
13936            // c:7386
13937            to_clear.extend(0..crate::ported::signals_h::TRAPCOUNT); // c:7387
13938        } else {
13939            for arg in &argv[start..] {
13940                // c:7390
13941                let sig = getsigidx(arg);
13942                if sig == -1 {
13943                    // c:7392
13944                    zwarnnam(name, &format!("undefined signal: {}", arg)); // c:7393
13945                    had_error = 1; // c:7399 *argv non-NULL on break
13946                    break; // c:7394
13947                }
13948                to_clear.push(sig);
13949            }
13950        }
13951        for sig in to_clear {
13952            // c:7388 / c:7397 — `unsettrap(sig)`. C's unsettrap
13953            // (c:Src/signals.c:759) → removetrap (c:Src/signals.c:772)
13954            // does three things the previous zshrs clear path skipped
13955            // by only doing `traps_table().remove()`:
13956            //   - c:Src/signals.c:800 `sigtrapped[sig] = 0`, so
13957            //     handletrap stops claiming the signal;
13958            //   - c:Src/signals.c:815 `signal_default(sig)`, which
13959            //     RESTORES THE DEFAULT DISPOSITION — this is why
13960            //     `trap 'print caught' USR2; kill -USR2 $$; trap - USR2;
13961            //     kill -USR2 $$` kills the shell in zsh instead of
13962            //     silently ignoring the second signal;
13963            //   - c:Src/signals.c:836-843 `removehashnode(shfunctab,
13964            //     node->nam)`, dropping the `TRAP<sig>` function-form
13965            //     trap (freed by unsettrap at c:765).
13966            // unsettrap MUST run FIRST: removetrap → dosavetrap (c:774)
13967            // snapshots the CURRENT body for the LOCAL_TRAPS restore.
13968            crate::ported::signals::unsettrap(sig);
13969            // c:Src/signals.c:846 `siglists[sig] = NULL` — the body
13970            // drop. zshrs keeps trap bodies in `traps_table` keyed by
13971            // canonical signal NAME (C indexes siglists[] by number),
13972            // so the number has to be mapped back to the name bin_trap
13973            // installed it under (`0` → `EXIT`).
13974            let key = if sig == 0 {
13975                "EXIT".to_string()
13976            } else {
13977                crate::ported::signals_h::sigs_name(sig)
13978                    .map(|s| s.to_string())
13979                    .unwrap_or_else(|| sig.to_string())
13980            };
13981            if let Ok(mut t) = traps_table().lock() {
13982                t.remove(&key);
13983            }
13984            // c:Src/signals.c:836-843 — ZSIG_FUNC arm.
13985            if let Ok(mut tab) = crate::ported::hashtable::shfunctab_lock().write() {
13986                tab.remove(&format!("TRAP{}", key));
13987            }
13988        }
13989        return had_error; // c:7399
13990    }
13991
13992    // c:7404-7411 — first arg is the trap body.
13993    let arg = argv.remove(0); // c:7404
13994    // c:7405-7409 — `if (!*arg) prog = &dummy_eprog; else if (!(prog =
13995    // parse_string(arg, 1))) { zwarnnam(name, "couldn't parse trap
13996    // command"); return 1; }`. The body is parsed when the trap is
13997    // installed, so an unparseable body is rejected here rather than
13998    // deferred to signal delivery. An empty body (`trap '' SIG`) takes
13999    // C's dummy_eprog branch and is never parsed. This gate precedes
14000    // the signal checks below, so `trap 'for' BOGUS` reports the parse
14001    // failure, not "undefined signal".
14002    //
14003    // C reaches its parser through parse_string (ported at
14004    // exec.rs:parse_string over the ported lexer). The trap body is
14005    // stored as text and later run through parse_isolated by
14006    // execute_script_zsh_pipeline (vm_helper.rs:2249), so validation
14007    // goes through the same parser that will execute it — otherwise a
14008    // body could pass here and still fail at delivery. Save/restore
14009    // errflag around the probe exactly as vm_helper.rs:2240-2251 does,
14010    // so a rejected body doesn't leave the error flag set and abort the
14011    // enclosing shell. The parser emits its own `parse error` via zerr
14012    // first; this adds C's second diagnostic.
14013    if !arg.is_empty() {
14014        let saved_errflag = errflag.load(Ordering::Relaxed);
14015        errflag.fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
14016        let _ = crate::vm_helper::parse_isolated(&arg);
14017        let parse_failed =
14018            (errflag.load(Ordering::Relaxed) & crate::ported::zsh_h::ERRFLAG_ERROR) != 0;
14019        errflag.store(saved_errflag, Ordering::Relaxed);
14020        if parse_failed {
14021            zwarnnam(name, "couldn't parse trap command"); // c:7407
14022            return 1; // c:7408
14023        }
14024    }
14025    if argv.is_empty() {
14026        // c:7411 — when only one arg AND it looks like a signal
14027        // (SIG-prefix or numeric) but didn't resolve to a real
14028        // signal, emit "undefined signal". For an arbitrary string
14029        // body with no following signal, zsh silently accepts and
14030        // installs nothing (no diagnostic). Mirror zsh's behavior:
14031        // skip "signal expected" for non-signal-shaped strings.
14032        if arg.starts_with("SIG") || arg.chars().next().is_some_and(|c| c.is_ascii_digit()) {
14033            zwarnnam(name, &format!("undefined signal: {}", arg)); // c:7413
14034            return 1; // c:7417
14035        }
14036        // Bare string body with no signal — zsh accepts silently.
14037        return 0;
14038    }
14039
14040    // c:7421-7448 — install trap on each named signal.
14041    let mut trap_install_error = 0i32;
14042    for sigarg in &argv {
14043        // c:7421
14044        let sig = getsigidx(sigarg);
14045        if sig == -1 {
14046            // c:7426
14047            zwarnnam(name, &format!("undefined signal: {}", sigarg)); // c:7427
14048            trap_install_error = 1; // c:7445 *argv non-NULL on break
14049            break; // c:7428
14050        }
14051        // c:Src/signals.c — C zsh stores traps in a fixed array
14052        // indexed by signal number. Aliases (`0`, `EXIT`, `SIGEXIT`)
14053        // all resolve to index 0 and share the same slot. The Rust
14054        // port stores by name string, so we must normalize the key
14055        // to the canonical signal name (or "EXIT" for the 0 alias)
14056        // — otherwise `trap 'echo bye' 0` lands in a `"0"` slot
14057        // that nothing else looks up.
14058        let canonical = if sig == 0 {
14059            "EXIT".to_string()
14060        } else if let Some(name) = crate::ported::signals_h::sigs_name(sig) {
14061            // c:Src/builtin.c — when sigarg is numeric (`trap "" 2`),
14062            // the listing path must still emit the SYMBOLIC name
14063            // (`INT`) so the canonical name lookup downstream works.
14064            // Previously zshrs's `to_uppercase()` of "2" left the key
14065            // as "2", so `trap "" 2; trap` listed `trap -- '' 2`
14066            // instead of `trap -- '' INT`. Resolve to the canonical
14067            // signal name via sigs_name(idx).
14068            //
14069            // c:Src/jobs.c:2740 alt_sigs + jobs.c:3089 getsigname — a trap
14070            // set via an ALIAS name lists under the ALIAS, not the
14071            // canonical sigs[] name (C flags it ZSIG_ALIAS). The only
14072            // unconditional alt_sigs entry is `{ "ERR", SIGZERR }`, so
14073            // `trap … ERR; trap` must print `… ERR`, not `… ZERR`. The
14074            // Rust port stores traps by name string; preserve the alias
14075            // the user typed as the key. The dotrap dispatch already
14076            // resolves SIGZERR through both "ZERR" and "ERR"
14077            // (signals.rs:1505), so firing is unaffected.
14078            let normalized = sigarg
14079                .strip_prefix("SIG")
14080                .or_else(|| sigarg.strip_prefix("sig"))
14081                .unwrap_or(sigarg.as_str())
14082                .to_uppercase();
14083            if sig == crate::ported::signals_h::SIGZERR && normalized == "ERR" {
14084                "ERR".to_string()
14085            } else {
14086                name.to_string()
14087            }
14088        } else {
14089            // Strip SIG/sig prefix and uppercase so `SIGINT` / `int`
14090            // / `INT` all map to the same key.
14091            sigarg
14092                .strip_prefix("SIG")
14093                .or_else(|| sigarg.strip_prefix("sig"))
14094                .unwrap_or(sigarg.as_str())
14095                .to_uppercase()
14096        };
14097        // c:Src/signals.c settrap — register both the libc signal
14098        // handler AND the sigtrapped[idx] flag. Without setting
14099        // sigtrapped, handletrap() early-returns 0 (sees the slot
14100        // as "not trapped") and the dotrap dispatch never fires.
14101        // The traps_table entry alone isn't enough — handletrap
14102        // gates on sigtrapped[idx] != 0.
14103        //
14104        // c:Src/signals.c:693 settrap flag semantics:
14105        //   ZSIG_FUNC → trap body is a shell function (TRAPSIG)
14106        //   no flag   → trap body is an Eprog in siglists[sig]
14107        // zshrs stores the body STRING in `traps_table` instead of
14108        // either, but the runtime's `endtrapscope`/`dotrap` dispatch
14109        // (signals.rs:1249) requires the !ZSIG_FUNC arm to fire so
14110        // the traps_table lookup happens. Passing ZSIG_FUNC sent it
14111        // down the shfunc-dispatch arm which fails silently when no
14112        // TRAPSIG function exists — bug #80 in docs/BUGS.md.
14113        //
14114        // SIGEXIT (sig=0) must also reach settrap so locallevel
14115        // tagging lands and `endtrapscope` fires the function-local
14116        // EXIT trap at function exit (the previous `sig > 0` gate
14117        // skipped it entirely).
14118        //
14119        // CRITICAL ORDER: settrap calls unsettrap → dosavetrap which
14120        // SNAPSHOTS the current traps_table body for the save-stack.
14121        // settrap MUST run BEFORE inserting the new body so the saved
14122        // entry captures the OUTER scope's body, not the one we're
14123        // about to install. (Reversed order led to the save loop
14124        // restoring the just-installed body back into traps_table
14125        // after the function exit, causing the script-end trap fire
14126        // to re-run the inner body.)
14127        if sig >= 0 && sig <= crate::ported::signals_h::SIGCOUNT && sig != libc::SIGCHLD as i32 {
14128            // c:Src/signals.c:712 — `if (!(flags & ZSIG_FUNC) && empty_eprog(l))`.
14129            // settrap treats an `l == None` (or empty Eprog) body as
14130            // `trap '' SIG` — i.e. ZSIG_IGNORED + signal_ignore(sig).
14131            // That suppresses the signal entirely AND skips
14132            // install_handler, so a non-empty `trap "echo TRAP" SIG`
14133            // would never reach handletrap → dotrap. C-zsh's bin_trap
14134            // parses the body string into an Eprog via parse_string()
14135            // before passing to settrap. The zshrs port stores the
14136            // body as plain text in `traps_table` and dispatches via
14137            // execute_script (the Eprog parser isn't on the
14138            // critical path), so we need a placeholder Eprog whose
14139            // only purpose is to make empty_eprog() return false and
14140            // steer settrap down the ZSIG_TRAPPED + install_handler
14141            // branch. The placeholder body is never executed — dotrap
14142            // reads the dispatch text from traps_table.
14143            //
14144            // Bug #104 in docs/BUGS.md: signal sent from inside a
14145            // function via `kill -X $$` was lost because settrap had
14146            // routed USR1 to signal_ignore at trap-install time, so
14147            // when the signal hit while doshfunc had queue_signals()
14148            // active, nothing in the queue → nothing to dispatch on
14149            // unqueue. Empty-body path (`trap '' SIG`) still passes
14150            // None as before so the ZSIG_IGNORED branch fires.
14151            let body_eprog: Option<crate::ported::zsh_h::Eprog> = if arg.is_empty() {
14152                None
14153            } else {
14154                let mut prog = crate::ported::zsh_h::eprog::default();
14155                // c:Src/parse.c:584 — empty_eprog returns true on
14156                // `prog[0] == WCB_END`; push any non-WCB_END value
14157                // (1 is arbitrary, matches the existing
14158                // `empty_eprog_non_empty_non_end_returns_false`
14159                // pin test in parse.rs).
14160                prog.prog.push(1);
14161                Some(Box::new(prog))
14162            };
14163            settrap(sig, body_eprog, 0);
14164        }
14165        // c:Src/signals.c:707 — settrap → unsettrap → removetrap
14166        // (c:836-843 removeshfuncnode) drops any pre-existing
14167        // FUNCTION-form trap for this signal BEFORE the new (string-form)
14168        // body is installed. zshrs's removetrap does not touch shfunctab,
14169        // so a leftover `TRAPZERR() { … }` would shadow a later `trap
14170        // 'body' ERR` — the dispatch prefers the function form, so the OLD
14171        // function fired instead of the new string body. Remove it here,
14172        // mirroring the `trap -` unset path above. Keyed by the CANONICAL
14173        // signal name (sigs_name), so both `ERR` and `ZERR` spellings drop
14174        // the same `TRAPZERR` node. Runs for VIRTUAL signals too (ZERR /
14175        // DEBUG / EXIT sit beyond SIGCOUNT, so the settrap block above is
14176        // skipped for them — the earlier placement inside that block never
14177        // fired for ZERR).
14178        let func_key = if sig == 0 {
14179            "EXIT".to_string()
14180        } else {
14181            crate::ported::signals_h::sigs_name(sig)
14182                .map(|s| s.to_string())
14183                .unwrap_or_else(|| sig.to_string())
14184        };
14185        if let Ok(mut tab) = crate::ported::hashtable::shfunctab_lock().write() {
14186            tab.remove(&format!("TRAP{}", func_key));
14187        }
14188        if let Ok(mut t) = traps_table().lock() {
14189            t.insert(canonical.clone(), arg.clone()); // c:7448 (effective)
14190        }
14191        // c:Src/signals.c::settrap — when a string-form trap
14192        // replaces an existing trap, settrap calls unsettrap which
14193        // clears the C `sigfuncs[sig]` shfunc slot. The zshrs port
14194        // dispatches function-form TRAPxxx through shfunctab
14195        // independently of sigfuncs, so a pre-existing TRAPxxx
14196        // function survives the string-form install and BOTH fire
14197        // on signal delivery. Remove any matching TRAPxxx shfunc
14198        // here so dotrap dispatches the just-installed string body
14199        // only. Bug #541 in docs/BUGS.md.
14200        let trap_fn_name = format!("TRAP{}", canonical);
14201        if let Ok(mut tab) = crate::ported::hashtable::shfunctab_lock().write() {
14202            tab.remove(&trap_fn_name);
14203        }
14204    }
14205    trap_install_error
14206}
14207
14208/// Port of `bin_ttyctl(UNUSED(char *name), UNUSED(char **argv), Options ops, UNUSED(int func))` from Src/builtin.c:7454.
14209/// C: `int bin_ttyctl(UNUSED args, Options ops, ...)` — `-f` freezes the
14210///   tty, `-u` unfreezes; otherwise emit `"tty is [not ]frozen"`.
14211/// WARNING: param names don't match C — Rust=(_name, _argv, _func) vs C=(name, argv, ops, func)
14212pub fn bin_ttyctl(
14213    _name: &str,
14214    _argv: &[String], // c:7454
14215    ops: &options,
14216    _func: i32,
14217) -> i32 {
14218    use std::sync::Mutex;
14219    // c:7456-7461 — route through the canonical jobs::TTYFROZEN
14220    // global. The previous builtin.rs duplicate AtomicI32 NEVER synced
14221    // with jobs.rs's Mutex<i32> store; `ttyctl -f` set the local
14222    // Atomic but didn't freeze the tty from the perspective of the
14223    // job-control wait path that reads jobs::TTYFROZEN.
14224    let cell = crate::ported::jobs::TTYFROZEN.get_or_init(|| Mutex::new(0_i32));
14225    if OPT_ISSET(ops, b'f') {
14226        // c:7456
14227        *cell.lock().expect("TTYFROZEN poisoned") = 1; // c:7457
14228    } else if OPT_ISSET(ops, b'u') {
14229        // c:7458
14230        *cell.lock().expect("TTYFROZEN poisoned") = 0; // c:7459
14231    } else {
14232        let f = *cell.lock().expect("TTYFROZEN poisoned");
14233        // c:7461 — `printf("tty is %sfrozen\n", ttyfrozen ? "" : "not ");`
14234        println!("tty is {}frozen", if f != 0 { "" } else { "not " }); // c:7461
14235    }
14236    0 // c:7463
14237}
14238
14239/// Port of `bin_let(UNUSED(char *name), char **argv, UNUSED(Options ops), UNUSED(int func))` from Src/builtin.c:7469.
14240/// C: `int bin_let(UNUSED(char *name), char **argv, UNUSED(Options ops),
14241///     UNUSED(int func))` — evaluate each arg as a math expression;
14242///   return 1 if the final value is zero (success/false), 0 if non-zero
14243///   (true), 2 on math error.
14244/// WARNING: param names don't match C — Rust=(_name, argv, _func) vs C=(name, argv, ops, func)
14245pub fn bin_let(
14246    _name: &str,
14247    argv: &[String], // c:7469
14248    _ops: &options,
14249    _func: i32,
14250) -> i32 {
14251    use crate::ported::utils::{errflag, ERRFLAG_ERROR};
14252    use std::sync::atomic::Ordering;
14253
14254    // c:7472 — `mnumber val = zero_mnumber;`
14255    let mut val: mnumber = mnumber {
14256        l: 0,
14257        d: 0.0,
14258        type_: MN_INTEGER,
14259    }; // c:7472
14260       // c:7474-7475 — `while (*argv) val = matheval(*argv++);` — DO walk
14261       // every arg even if one fails. C doesn't break on error mid-loop;
14262       // it just lets errflag accumulate. Previously the Rust port broke
14263       // on first failure, leaving later args unevaluated.
14264    for expr in argv {
14265        // c:7474
14266        match matheval(expr) {
14267            Ok(v) => val = v, // c:7475
14268            Err(msg) => {
14269                // c:Src/math.c:checkunary zerr side-effect — the C
14270                // path writes the parse-error string to stderr via
14271                // mathevali → checkunary → zerr. Rust's matheval
14272                // captures the message in Err and bin_let was
14273                // discarding it via `if let Ok(...)`. Surface it.
14274                zerr(&msg);
14275                // Continue loop; errflag set below resets to local 2.
14276            }
14277        }
14278    }
14279    // c:7476-7480 — math errors are non-fatal in let; CLEAR
14280    // ERRFLAG_ERROR and return the math-error code.
14281    //
14282    // The C source at Src/builtin.c:7479 says `return 2;`, but the
14283    // currently installed zsh 5.9.1 returns 1 (verified: `zsh -fc
14284    // 'let 1/0; echo $?'` → 1). Either the C source rev I'm reading
14285    // (5.9.0.3-test, src/zsh/Config/version.mk) diverges from 5.9.1
14286    // or zsh normalises the rc somewhere in execlist's post-builtin
14287    // path. Match the installed zsh's observable behaviour so the
14288    // `let_division_by_zero` parity probe passes:
14289    //   `let 1/0 2>&1; print ex:$?` → "zsh:1: division by zero\nex:1\n"
14290    // Bug surfaces in the dispatch's $? side-channel — returning 1
14291    // here matches both the parity test AND the unit test below
14292    // (`bin_let_clears_errflag_on_math_error`, since the assertion
14293    // there pins the OBSERVED status, not the c:7479 literal).
14294    if (errflag.load(Relaxed) & ERRFLAG_ERROR) != 0 {
14295        // c:7476
14296        errflag.fetch_and(!ERRFLAG_ERROR, Relaxed); // c:7478
14297        return 1; // c:7479 (observed zsh 5.9.1 behaviour)
14298    }
14299    // c:7482 — `return (val.type == MN_INTEGER) ? val.u.l == 0 : val.u.d == 0.0;`
14300    if val.type_ == MN_INTEGER {
14301        // c:7482
14302        (val.l == 0) as i32
14303    } else {
14304        (val.d == 0.0) as i32
14305    }
14306}
14307
14308/// Port of `bin_umask(char *nam, char **args, Options ops, UNUSED(int func))` from Src/builtin.c:7491.
14309/// C: `int bin_umask(char *nam, char **args, Options ops, ...)` —
14310///   set/show file-creation mask. No args → show; numeric arg → octal
14311///   parse; symbolic `[ugoa]+[+-=][rwx]+,...` → walk and apply.
14312/// WARNING: param names don't match C — Rust=(nam, args, _func) vs C=(nam, args, ops, func)
14313pub fn bin_umask(
14314    nam: &str,
14315    args: &[String], // c:7491
14316    ops: &options,
14317    _func: i32,
14318) -> i32 {
14319    // c:7497-7500 — read current umask.
14320    queue_signals(); // c:7497
14321    let mut um: u32 = unsafe { libc::umask(0o777) } as u32; // c:7498
14322    unsafe {
14323        libc::umask(um as libc::mode_t);
14324    } // c:7499
14325    unqueue_signals(); // c:7500
14326
14327    // c:7503-7521 — no args: display.
14328    if args.is_empty() {
14329        // c:7503
14330        if OPT_ISSET(ops, b'S') {
14331            // c:7504
14332            let who_chars = ['u', 'g', 'o']; // c:7505
14333            for (i, who) in who_chars.iter().enumerate() {
14334                // c:7507
14335                print!("{}=", who); // c:7510
14336                let mut what_iter = ['r', 'w', 'x'].iter(); // c:7511
14337                while let Some(w) = what_iter.next() {
14338                    // c:7512
14339                    if (um & 0o400) == 0 {
14340                        // c:7513
14341                        print!("{}", w); // c:7514
14342                    }
14343                    um <<= 1; // c:7515
14344                }
14345                if i < 2 {
14346                    print!(",");
14347                } else {
14348                    println!();
14349                } // c:7518
14350            }
14351        } else {
14352            // c:7522-7524 — `if (um & 0700) putchar('0'); printf("%03o\n", um);`
14353            if (um & 0o700) != 0 {
14354                // c:7522
14355                print!("0"); // c:7523
14356            }
14357            println!("{:03o}", um); // c:7524
14358        }
14359        return 0; // c:7526
14360    }
14361
14362    // c:7528 — `if (idigit(*s))` numeric form.
14363    let s = &args[0];
14364    if s.chars().next().is_some_and(|c| c.is_ascii_digit()) {
14365        // c:7528
14366        // c:7530 — `um = zstrtol(s, &s, 8);`
14367        match u32::from_str_radix(s, 8) {
14368            // c:7530
14369            Ok(n) => um = n, // c:7530
14370            Err(_) => {
14371                zwarnnam(nam, "bad umask"); // c:7532
14372                return 1; // c:7533
14373            }
14374        }
14375    } else {
14376        // c:7536-7585 — symbolic notation walker.
14377        let bytes = s.as_bytes();
14378        let mut i = 0;
14379        loop {
14380            // c:7544 — `whomask = 0;`
14381            let mut whomask: u32 = 0; // c:7544
14382                                      // c:7545-7553 — collect ugoa.
14383            while i < bytes.len() {
14384                // c:7545
14385                match bytes[i] {
14386                    b'u' => {
14387                        whomask |= 0o700;
14388                        i += 1;
14389                    } // c:7547
14390                    b'g' => {
14391                        whomask |= 0o070;
14392                        i += 1;
14393                    } // c:7549
14394                    b'o' => {
14395                        whomask |= 0o007;
14396                        i += 1;
14397                    } // c:7551
14398                    b'a' => {
14399                        whomask |= 0o777;
14400                        i += 1;
14401                    } // c:7553
14402                    _ => break,
14403                }
14404            }
14405            // c:7555 — default whomask = 0777.
14406            if whomask == 0 {
14407                whomask = 0o777;
14408            } // c:7555
14409              // c:7557-7565 — op +/-/=.
14410            let umaskop = if i < bytes.len() { bytes[i] } else { 0 }; // c:7557
14411            if !(umaskop == b'+' || umaskop == b'-' || umaskop == b'=') {
14412                // c:7558
14413                if umaskop != 0 {
14414                    // c:7559
14415                    zwarnnam(
14416                        nam,
14417                        &format!("bad symbolic mode operator: {}", umaskop as char),
14418                    ); // c:7560
14419                } else {
14420                    zwarnnam(nam, "bad umask"); // c:7562
14421                }
14422                return 1; // c:7564
14423            }
14424            i += 1;
14425            // c:7567-7577 — collect rwx.
14426            let mut mask: u32 = 0; // c:7567
14427            while i < bytes.len() && bytes[i] != b',' {
14428                // c:7568
14429                match bytes[i] {
14430                    b'r' => mask |= 0o444 & whomask, // c:7570
14431                    b'w' => mask |= 0o222 & whomask, // c:7572
14432                    b'x' => mask |= 0o111 & whomask, // c:7574
14433                    other => {
14434                        zwarnnam(
14435                            nam,
14436                            &format!("bad symbolic mode permission: {}", other as char),
14437                        ); // c:7576
14438                        return 1; // c:7577
14439                    }
14440                }
14441                i += 1;
14442            }
14443            // c:7580-7585 — apply.
14444            match umaskop {
14445                b'+' => um &= !mask,              // c:7581
14446                b'-' => um |= mask,               // c:7583
14447                _ => um = (um | whomask) & !mask, // c:7585 (=)
14448            }
14449            if i < bytes.len() && bytes[i] == b',' {
14450                // c:7586
14451                i += 1; // c:7587
14452            } else {
14453                break; // c:7589
14454            }
14455        }
14456        if i < bytes.len() {
14457            // c:7591
14458            zwarnnam(
14459                nam,
14460                &format!("bad character in symbolic mode: {}", bytes[i] as char),
14461            ); // c:7592
14462            return 1; // c:7593
14463        }
14464    }
14465    // c:7598 — `umask(um);`
14466    unsafe {
14467        libc::umask(um as libc::mode_t);
14468    } // c:7598
14469    0 // c:7599
14470}
14471
14472/// Port of `bin_notavail(char *nam, UNUSED(char **argv), UNUSED(Options ops), UNUSED(int func))` from Src/builtin.c:7604.
14473/// C: `int bin_notavail(char *nam, UNUSED(char **argv),
14474///                      UNUSED(Options ops), UNUSED(int func))`
14475///   → `zwarnnam(nam, "not available on this system"); return 1;`
14476/// WARNING: param names don't match C — Rust=(nam, _argv, _func) vs C=(nam, argv, ops, func)
14477pub fn bin_notavail(
14478    nam: &str,
14479    _argv: &[String], // c:7604
14480    _ops: &options,
14481    _func: i32,
14482) -> i32 {
14483    zwarnnam(nam, "not available on this system"); // c:7607
14484    1 // c:7608
14485}
14486// ---------------------------------------------------------------------------
14487// Builtin descriptor.
14488// Port of `struct builtin` from `Src/zsh.h` (the one expanded by the
14489// `BUILTIN` / `BIN_PREFIX` macros at line 1452 of zsh.h).
14490// ---------------------------------------------------------------------------
14491// ---------------------------------------------------------------------------
14492// The master registration table.
14493//
14494// Direct, line-for-line port of `static struct builtin builtins[]`
14495// at `Src/builtin.c:40-137`. Entries appear in the same order so
14496// any diff against the C source stays trivial. The `handler_name`
14497// column points at the canonical Rust port that the dispatcher in
14498// `Executor::register_builtins` (`src/ported/vm_helper`) wires up.
14499// ---------------------------------------------------------------------------
14500/// `BUILTINS` static.
14501pub static BUILTINS: std::sync::LazyLock<Vec<builtin>> = std::sync::LazyLock::new(|| {
14502    vec![
14503        BIN_PREFIX("-", BINF_DASH),
14504        BIN_PREFIX("builtin", BINF_BUILTIN),
14505        BIN_PREFIX("command", BINF_COMMAND),
14506        BIN_PREFIX("exec", BINF_EXEC),
14507        BIN_PREFIX("noglob", BINF_NOGLOB),
14508        BUILTIN(
14509            "[",
14510            BINF_HANDLES_OPTS,
14511            Some(bin_test as HandlerFunc),
14512            0,
14513            -1,
14514            BIN_BRACKET,
14515            None,
14516            None,
14517        ),
14518        BUILTIN(
14519            ".",
14520            BINF_PSPECIAL,
14521            Some(bin_dot as HandlerFunc),
14522            1,
14523            -1,
14524            0,
14525            None,
14526            None,
14527        ),
14528        BUILTIN(
14529            ":",
14530            BINF_PSPECIAL,
14531            Some(bin_true as HandlerFunc),
14532            0,
14533            -1,
14534            0,
14535            None,
14536            None,
14537        ),
14538        BUILTIN(
14539            "alias",
14540            BINF_MAGICEQUALS | BINF_PLUSOPTS,
14541            Some(bin_alias as HandlerFunc),
14542            0,
14543            -1,
14544            0,
14545            Some("Lgmrs"),
14546            None,
14547        ),
14548        BUILTIN(
14549            "autoload",
14550            BINF_PLUSOPTS,
14551            Some(bin_functions as HandlerFunc),
14552            0,
14553            -1,
14554            0,
14555            Some("dmktrRTUwWXz"),
14556            Some("u"),
14557        ),
14558        BUILTIN(
14559            "bg",
14560            0,
14561            Some(bin_fg as HandlerFunc),
14562            0,
14563            -1,
14564            BIN_BG,
14565            None,
14566            None,
14567        ),
14568        BUILTIN(
14569            "break",
14570            BINF_PSPECIAL,
14571            Some(bin_break as HandlerFunc),
14572            0,
14573            1,
14574            BIN_BREAK,
14575            None,
14576            None,
14577        ),
14578        BUILTIN(
14579            "bye",
14580            0,
14581            Some(bin_break as HandlerFunc),
14582            0,
14583            1,
14584            BIN_EXIT,
14585            None,
14586            None,
14587        ),
14588        BUILTIN(
14589            "cd",
14590            BINF_SKIPINVALID | BINF_SKIPDASH | BINF_DASHDASHVALID,
14591            Some(bin_cd as HandlerFunc),
14592            0,
14593            2,
14594            BIN_CD,
14595            Some("qsPL"),
14596            None,
14597        ),
14598        BUILTIN(
14599            "chdir",
14600            BINF_SKIPINVALID | BINF_SKIPDASH | BINF_DASHDASHVALID,
14601            Some(bin_cd as HandlerFunc),
14602            0,
14603            2,
14604            BIN_CD,
14605            Some("qsPL"),
14606            None,
14607        ),
14608        BUILTIN(
14609            "continue",
14610            BINF_PSPECIAL,
14611            Some(bin_break as HandlerFunc),
14612            0,
14613            1,
14614            BIN_CONTINUE,
14615            None,
14616            None,
14617        ),
14618        BUILTIN(
14619            "declare",
14620            BINF_PLUSOPTS | BINF_MAGICEQUALS | BINF_PSPECIAL | BINF_ASSIGN,
14621            Some(bin_typeset as HandlerFunc),
14622            0,
14623            -1,
14624            0,
14625            Some("AE:%F:%HL:%R:%TUZ:%afghi:%klmnp:%rtuxz"),
14626            None,
14627        ),
14628        BUILTIN(
14629            "dirs",
14630            0,
14631            Some(bin_dirs as HandlerFunc),
14632            0,
14633            -1,
14634            0,
14635            Some("clpv"),
14636            None,
14637        ),
14638        BUILTIN(
14639            "disable",
14640            0,
14641            Some(bin_enable as HandlerFunc),
14642            0,
14643            -1,
14644            BIN_DISABLE,
14645            Some("afmprs"),
14646            None,
14647        ),
14648        BUILTIN(
14649            "disown",
14650            0,
14651            Some(bin_fg as HandlerFunc),
14652            0,
14653            -1,
14654            BIN_DISOWN,
14655            None,
14656            None,
14657        ),
14658        BUILTIN(
14659            "echo",
14660            BINF_SKIPINVALID,
14661            Some(bin_print as HandlerFunc),
14662            0,
14663            -1,
14664            BIN_ECHO,
14665            Some("neE"),
14666            Some("-"),
14667        ),
14668        BUILTIN(
14669            "emulate",
14670            0,
14671            Some(bin_emulate as HandlerFunc),
14672            0,
14673            -1,
14674            0,
14675            Some("lLR"),
14676            None,
14677        ),
14678        BUILTIN(
14679            "enable",
14680            0,
14681            Some(bin_enable as HandlerFunc),
14682            0,
14683            -1,
14684            BIN_ENABLE,
14685            Some("afmprs"),
14686            None,
14687        ),
14688        BUILTIN(
14689            "eval",
14690            BINF_PSPECIAL,
14691            Some(bin_eval as HandlerFunc),
14692            0,
14693            -1,
14694            BIN_EVAL,
14695            None,
14696            None,
14697        ),
14698        BUILTIN(
14699            "exit",
14700            BINF_PSPECIAL,
14701            Some(bin_break as HandlerFunc),
14702            0,
14703            1,
14704            BIN_EXIT,
14705            None,
14706            None,
14707        ),
14708        BUILTIN(
14709            "export",
14710            BINF_PLUSOPTS | BINF_MAGICEQUALS | BINF_PSPECIAL | BINF_ASSIGN,
14711            Some(bin_typeset as HandlerFunc),
14712            0,
14713            -1,
14714            BIN_EXPORT,
14715            Some("E:%F:%HL:%R:%TUZ:%afhi:%lp:%rtu"),
14716            Some("xg"),
14717        ),
14718        BUILTIN(
14719            "false",
14720            0,
14721            Some(bin_false as HandlerFunc),
14722            0,
14723            -1,
14724            0,
14725            None,
14726            None,
14727        ),
14728        // C source (Src/builtin.c:69-73): the argument to -e used to be
14729        // optional; making it required is more consistent.
14730        BUILTIN(
14731            "fc",
14732            0,
14733            Some(bin_fc as HandlerFunc),
14734            0,
14735            -1,
14736            BIN_FC,
14737            Some("aAdDe:EfiIlLmnpPrRst:W"),
14738            None,
14739        ),
14740        BUILTIN(
14741            "fg",
14742            0,
14743            Some(bin_fg as HandlerFunc),
14744            0,
14745            -1,
14746            BIN_FG,
14747            None,
14748            None,
14749        ),
14750        BUILTIN(
14751            "float",
14752            BINF_PLUSOPTS | BINF_MAGICEQUALS | BINF_PSPECIAL | BINF_ASSIGN,
14753            Some(bin_typeset as HandlerFunc),
14754            0,
14755            -1,
14756            0,
14757            Some("E:%F:%HL:%R:%Z:%ghlp:%rtux"),
14758            Some("E"),
14759        ),
14760        BUILTIN(
14761            "functions",
14762            BINF_PLUSOPTS,
14763            Some(bin_functions as HandlerFunc),
14764            0,
14765            -1,
14766            0,
14767            Some("ckmMstTuUWx:z"),
14768            None,
14769        ),
14770        BUILTIN(
14771            "getln",
14772            0,
14773            Some(bin_read as HandlerFunc),
14774            0,
14775            -1,
14776            0,
14777            Some("ecnAlE"),
14778            Some("zr"),
14779        ),
14780        BUILTIN(
14781            "getopts",
14782            0,
14783            Some(bin_getopts as HandlerFunc),
14784            2,
14785            -1,
14786            0,
14787            None,
14788            None,
14789        ),
14790        BUILTIN(
14791            "hash",
14792            BINF_MAGICEQUALS,
14793            Some(bin_hash as HandlerFunc),
14794            0,
14795            -1,
14796            0,
14797            Some("Ldfmrv"),
14798            None,
14799        ),
14800        // Src/builtin.c — `#ifdef ZSH_HASH_DEBUG`
14801        //   BUILTIN("hashinfo", 0, bin_hashinfo, 0, 0, 0, NULL, NULL)
14802        BUILTIN(
14803            "hashinfo",
14804            0,
14805            Some(crate::ported::hashtable::bin_hashinfo as HandlerFunc),
14806            0,
14807            0,
14808            0,
14809            None,
14810            None,
14811        ),
14812        BUILTIN(
14813            "history",
14814            0,
14815            Some(bin_fc as HandlerFunc),
14816            0,
14817            -1,
14818            BIN_FC,
14819            Some("adDEfiLmnpPrt:"),
14820            Some("l"),
14821        ),
14822        BUILTIN(
14823            "integer",
14824            BINF_PLUSOPTS | BINF_MAGICEQUALS | BINF_PSPECIAL | BINF_ASSIGN,
14825            Some(bin_typeset as HandlerFunc),
14826            0,
14827            -1,
14828            0,
14829            Some("HL:%R:%Z:%ghi:%lp:%rtux"),
14830            Some("i"),
14831        ),
14832        BUILTIN(
14833            "jobs",
14834            0,
14835            Some(bin_fg as HandlerFunc),
14836            0,
14837            -1,
14838            BIN_JOBS,
14839            Some("dlpZrs"),
14840            None,
14841        ),
14842        BUILTIN(
14843            "kill",
14844            BINF_HANDLES_OPTS,
14845            Some(crate::ported::jobs::bin_kill as HandlerFunc),
14846            0,
14847            -1,
14848            0,
14849            None,
14850            None,
14851        ),
14852        BUILTIN("let", 0, Some(bin_let as HandlerFunc), 1, -1, 0, None, None),
14853        BUILTIN(
14854            "local",
14855            BINF_PLUSOPTS | BINF_MAGICEQUALS | BINF_PSPECIAL | BINF_ASSIGN,
14856            Some(bin_typeset as HandlerFunc),
14857            0,
14858            -1,
14859            0,
14860            Some("AE:%F:%HL:%R:%TUZ:%ahi:%lnp:%rtux"),
14861            None,
14862        ),
14863        BUILTIN(
14864            "logout",
14865            0,
14866            Some(bin_break as HandlerFunc),
14867            0,
14868            1,
14869            BIN_LOGOUT,
14870            None,
14871            None,
14872        ),
14873        // Src/builtin.c — `#if defined(ZSH_MEM) & defined(ZSH_MEM_DEBUG)`
14874        //   BUILTIN("mem", 0, bin_mem, 0, 0, 0, "v", NULL)
14875        BUILTIN(
14876            "mem",
14877            0,
14878            Some(crate::ported::mem::bin_mem as HandlerFunc),
14879            0,
14880            0,
14881            0,
14882            Some("v"),
14883            None,
14884        ),
14885        BUILTIN(
14886            "popd",
14887            BINF_SKIPINVALID | BINF_SKIPDASH | BINF_DASHDASHVALID,
14888            Some(bin_cd as HandlerFunc),
14889            0,
14890            1,
14891            BIN_POPD,
14892            Some("q"),
14893            None,
14894        ),
14895        // Src/builtin.c — `#if defined(ZSH_PAT_DEBUG)`
14896        //   BUILTIN("patdebug", 0, bin_patdebug, 1, -1, 0, "p", NULL)
14897        BUILTIN("patdebug", 0, None, 1, -1, 0, Some("p"), None),
14898        BUILTIN(
14899            "print",
14900            BINF_PRINTOPTS,
14901            Some(bin_print as HandlerFunc),
14902            0,
14903            -1,
14904            BIN_PRINT,
14905            Some("abcC:Df:ilmnNoOpPrRsSu:v:x:X:z-"),
14906            None,
14907        ),
14908        BUILTIN(
14909            "printf",
14910            BINF_SKIPINVALID | BINF_SKIPDASH,
14911            Some(bin_print as HandlerFunc),
14912            1,
14913            -1,
14914            BIN_PRINTF,
14915            Some("v:"),
14916            None,
14917        ),
14918        BUILTIN(
14919            "pushd",
14920            BINF_SKIPINVALID | BINF_SKIPDASH | BINF_DASHDASHVALID,
14921            Some(bin_cd as HandlerFunc),
14922            0,
14923            2,
14924            BIN_PUSHD,
14925            Some("qsPL"),
14926            None,
14927        ),
14928        BUILTIN(
14929            "pushln",
14930            0,
14931            Some(bin_print as HandlerFunc),
14932            0,
14933            -1,
14934            BIN_PRINT,
14935            None,
14936            Some("-nz"),
14937        ),
14938        BUILTIN(
14939            "pwd",
14940            0,
14941            Some(bin_pwd as HandlerFunc),
14942            0,
14943            0,
14944            0,
14945            Some("rLP"),
14946            None,
14947        ),
14948        BUILTIN(
14949            "r",
14950            0,
14951            Some(bin_fc as HandlerFunc),
14952            0,
14953            -1,
14954            BIN_R,
14955            Some("IlLnr"),
14956            None,
14957        ),
14958        BUILTIN(
14959            "read",
14960            0,
14961            Some(bin_read as HandlerFunc),
14962            0,
14963            -1,
14964            0,
14965            Some("cd:ek:%lnpqrst:%zu:AE"),
14966            None,
14967        ),
14968        BUILTIN(
14969            "readonly",
14970            BINF_PLUSOPTS | BINF_MAGICEQUALS | BINF_PSPECIAL | BINF_ASSIGN,
14971            Some(bin_typeset as HandlerFunc),
14972            0,
14973            -1,
14974            BIN_READONLY,
14975            Some("AE:%F:%HL:%R:%TUZ:%afghi:%lptux"),
14976            Some("r"),
14977        ),
14978        BUILTIN(
14979            "rehash",
14980            0,
14981            Some(bin_hash as HandlerFunc),
14982            0,
14983            0,
14984            0,
14985            Some("df"),
14986            Some("r"),
14987        ),
14988        BUILTIN(
14989            "return",
14990            BINF_PSPECIAL,
14991            Some(bin_break as HandlerFunc),
14992            0,
14993            1,
14994            BIN_RETURN,
14995            None,
14996            None,
14997        ),
14998        BUILTIN(
14999            "set",
15000            BINF_PSPECIAL | BINF_HANDLES_OPTS,
15001            Some(bin_set as HandlerFunc),
15002            0,
15003            -1,
15004            0,
15005            None,
15006            None,
15007        ),
15008        BUILTIN(
15009            "setopt",
15010            0,
15011            Some(crate::ported::options::bin_setopt as HandlerFunc),
15012            0,
15013            -1,
15014            BIN_SETOPT,
15015            None,
15016            None,
15017        ),
15018        // c:Src/Builtins/sched.c:375 — sched is a Builtins module
15019        // builtin (zsh/sched). bintab has only one entry. The Rust
15020        // port at builtins/sched.rs::bin_sched (325 lines) was not
15021        // registered, so `sched 09:00 echo morning` returned
15022        // "command not found".
15023        BUILTIN(
15024            "sched",
15025            0,
15026            Some(crate::ported::builtins::sched::bin_sched as HandlerFunc),
15027            0,
15028            -1,
15029            0,
15030            None,
15031            None,
15032        ),
15033        BUILTIN(
15034            "shift",
15035            BINF_PSPECIAL,
15036            Some(bin_shift as HandlerFunc),
15037            0,
15038            -1,
15039            0,
15040            Some("p"),
15041            None,
15042        ),
15043        BUILTIN(
15044            "source",
15045            BINF_PSPECIAL,
15046            Some(bin_dot as HandlerFunc),
15047            1,
15048            -1,
15049            0,
15050            None,
15051            None,
15052        ),
15053        BUILTIN(
15054            "suspend",
15055            0,
15056            Some(crate::ported::jobs::bin_suspend as HandlerFunc),
15057            0,
15058            0,
15059            0,
15060            Some("f"),
15061            None,
15062        ),
15063        BUILTIN(
15064            "test",
15065            BINF_HANDLES_OPTS,
15066            Some(bin_test as HandlerFunc),
15067            0,
15068            -1,
15069            BIN_TEST,
15070            None,
15071            None,
15072        ),
15073        BUILTIN(
15074            "ttyctl",
15075            0,
15076            Some(bin_ttyctl as HandlerFunc),
15077            0,
15078            0,
15079            0,
15080            Some("fu"),
15081            None,
15082        ),
15083        // c:Src/Builtins/rlimits.c:868-870 — limit/ulimit/unlimit are
15084        // declared in the rlimits Builtins-module's bintab. zshrs has the
15085        // free-fn ports at src/ported/builtins/rlimits.rs but never
15086        // registered them; the BUILTIN_NAMES derivation missed them and
15087        // `type limit` etc. returned empty.
15088        BUILTIN(
15089            "limit",
15090            0,
15091            Some(crate::ported::builtins::rlimits::bin_limit as HandlerFunc),
15092            0,
15093            -1,
15094            0,
15095            Some("sh"),
15096            None,
15097        ), // c:rlimits.c:868
15098        BUILTIN(
15099            "ulimit",
15100            0,
15101            Some(crate::ported::builtins::rlimits::bin_ulimit as HandlerFunc),
15102            0,
15103            -1,
15104            0,
15105            None,
15106            None,
15107        ), // c:rlimits.c:869
15108        BUILTIN(
15109            "unlimit",
15110            0,
15111            Some(crate::ported::builtins::rlimits::bin_unlimit as HandlerFunc),
15112            0,
15113            -1,
15114            0,
15115            Some("hs"),
15116            None,
15117        ), // c:rlimits.c:870
15118        BUILTIN(
15119            "times",
15120            BINF_PSPECIAL,
15121            Some(bin_times as HandlerFunc),
15122            0,
15123            0,
15124            0,
15125            None,
15126            None,
15127        ),
15128        BUILTIN(
15129            "trap",
15130            BINF_PSPECIAL | BINF_HANDLES_OPTS,
15131            Some(bin_trap as HandlerFunc),
15132            0,
15133            -1,
15134            0,
15135            None,
15136            None,
15137        ),
15138        BUILTIN(
15139            "true",
15140            0,
15141            Some(bin_true as HandlerFunc),
15142            0,
15143            -1,
15144            0,
15145            None,
15146            None,
15147        ),
15148        BUILTIN(
15149            "type",
15150            0,
15151            Some(bin_whence as HandlerFunc),
15152            0,
15153            -1,
15154            0,
15155            Some("ampfsSw"),
15156            Some("v"),
15157        ),
15158        BUILTIN(
15159            "typeset",
15160            BINF_PLUSOPTS | BINF_MAGICEQUALS | BINF_PSPECIAL | BINF_ASSIGN,
15161            Some(bin_typeset as HandlerFunc),
15162            0,
15163            -1,
15164            0,
15165            Some("AE:%F:%HL:%R:%TUZ:%afghi:%klp:%rtuxmnz"),
15166            None,
15167        ),
15168        // `nameref` — zsh/ksh93 module's named-reference declaration
15169        // builtin. Same handler as `typeset` with a tighter opt-string
15170        // (`-g`/`-p`/`-r`/`-u`) and the assign-style invocation. The
15171        // canonical Src/Modules/ksh93.c:bintab entry:
15172        //   BUILTIN("nameref", BINF_ASSIGN, bin_typeset, 0, -1, 0,
15173        //           "gpru", "n")
15174        // The 9th arg ("n") is the default-flag — typeset_flags inherits
15175        // PM_NAMEREF when the builtin is invoked under this name. zshrs's
15176        // bin_typeset wrapper doesn't read the 9th field yet, but the
15177        // registration still surfaces `nameref` in the tool window /
15178        // completion / hover docs.
15179        BUILTIN(
15180            "nameref",
15181            BINF_ASSIGN,
15182            Some(bin_typeset as HandlerFunc),
15183            0,
15184            -1,
15185            0,
15186            Some("gpru"),
15187            Some("n"),
15188        ),
15189        BUILTIN(
15190            "umask",
15191            0,
15192            Some(bin_umask as HandlerFunc),
15193            0,
15194            1,
15195            0,
15196            Some("S"),
15197            None,
15198        ),
15199        BUILTIN(
15200            "unalias",
15201            0,
15202            Some(bin_unhash as HandlerFunc),
15203            0,
15204            -1,
15205            BIN_UNALIAS,
15206            Some("ams"),
15207            None,
15208        ),
15209        BUILTIN(
15210            "unfunction",
15211            0,
15212            Some(bin_unhash as HandlerFunc),
15213            1,
15214            -1,
15215            BIN_UNFUNCTION,
15216            Some("m"),
15217            Some("f"),
15218        ),
15219        BUILTIN(
15220            "unhash",
15221            0,
15222            Some(bin_unhash as HandlerFunc),
15223            1,
15224            -1,
15225            BIN_UNHASH,
15226            Some("adfms"),
15227            None,
15228        ),
15229        BUILTIN(
15230            "unset",
15231            BINF_PSPECIAL,
15232            Some(bin_unset as HandlerFunc),
15233            1,
15234            -1,
15235            BIN_UNSET,
15236            Some("fmvn"),
15237            None,
15238        ),
15239        BUILTIN(
15240            "unsetopt",
15241            0,
15242            Some(crate::ported::options::bin_setopt as HandlerFunc),
15243            0,
15244            -1,
15245            BIN_UNSETOPT,
15246            None,
15247            None,
15248        ),
15249        BUILTIN(
15250            "wait",
15251            0,
15252            Some(bin_fg as HandlerFunc),
15253            0,
15254            -1,
15255            BIN_WAIT,
15256            None,
15257            None,
15258        ),
15259        BUILTIN(
15260            "whence",
15261            0,
15262            Some(bin_whence as HandlerFunc),
15263            0,
15264            -1,
15265            0,
15266            Some("acmpvfsSwx:"),
15267            None,
15268        ),
15269        BUILTIN(
15270            "where",
15271            0,
15272            Some(bin_whence as HandlerFunc),
15273            0,
15274            -1,
15275            0,
15276            Some("pmsSwx:"),
15277            Some("ca"),
15278        ),
15279        BUILTIN(
15280            "which",
15281            0,
15282            Some(bin_whence as HandlerFunc),
15283            0,
15284            -1,
15285            0,
15286            Some("ampsSwx:"),
15287            Some("c"),
15288        ),
15289        BUILTIN(
15290            "zmodload",
15291            0,
15292            Some(crate::ported::module::bin_zmodload as HandlerFunc),
15293            0,
15294            -1,
15295            0,
15296            Some("AFRILP:abcfdilmpsue"),
15297            None,
15298        ),
15299        BUILTIN(
15300            "zcompile",
15301            0,
15302            Some(crate::ported::parse::bin_zcompile as HandlerFunc),
15303            0,
15304            -1,
15305            0,
15306            Some("tUMRcmzka"),
15307            None,
15308        ),
15309        // Module builtins (zsh/zutil, zsh/cap, zsh/pcre, etc.) — these
15310        // live in src/ported/modules/* and src/ported/zle/* but their
15311        // canonical pub fn signatures match HandlerFunc, so they can be
15312        // dispatched via execbuiltin alongside the main builtins.
15313        BUILTIN(
15314            "zstyle",
15315            0,
15316            Some(crate::ported::modules::zutil::bin_zstyle as HandlerFunc),
15317            0,
15318            -1,
15319            0,
15320            // c:Src/Modules/zutil.c:2139 — `BUILTIN("zstyle", 0,
15321            // bin_zstyle, 0, -1, 0, NULL, NULL)`. NULL optstr: bin_zstyle
15322            // parses args[0] directly (see c:493-512 + c:587-600) so the
15323            // diagnostic format for unknown flags matches zsh's
15324            // "invalid option: -X" (the execbuiltin generic parser would
15325            // have emitted "bad option: -X" which diverges from zsh).
15326            // Bug surfaced via test_zstyle_unknown_flag_errors.
15327            None,
15328            None,
15329        ),
15330        BUILTIN(
15331            "zformat",
15332            0,
15333            Some(crate::ported::modules::zutil::bin_zformat as HandlerFunc),
15334            3,
15335            -1,
15336            0,
15337            // c:Src/Modules/zutil.c:2136 — `BUILTIN("zformat", 0,
15338            // bin_zformat, 3, -1, 0, NULL, NULL)`. NULL optstring:
15339            // bin_zformat reads args[0] as the `-X` selector itself, so
15340            // execbuiltin must not pre-eat the flag. Previously
15341            // optstring="Faf" pre-parsed -F/-a/-f into ops, leaving
15342            // args with the flag-letter stripped — combined with
15343            // minargs=0 this let `zformat -F` slip past the dispatcher
15344            // and emit "missing arguments to -f/-F" from the inner
15345            // check instead of zsh's canonical "not enough arguments".
15346            None,
15347            None,
15348        ),
15349        BUILTIN(
15350            "zparseopts",
15351            0,
15352            Some(crate::ported::modules::zutil::bin_zparseopts as HandlerFunc),
15353            1,
15354            -1,
15355            0,
15356            // c:Src/Modules/zutil.c:2137 — NULL optstring: bin_zparseopts
15357            // parses its own flags (-D/-E/-F/-K/-M/-a/-A/-v) inline. The
15358            // previous Rust spec ("D-EFK-M-a:") let execbuiltin pre-eat
15359            // them via the option-byte parser, leaving bin_zparseopts
15360            // with empty argv and `if i >= args.len()` firing
15361            // "missing option descriptions" for the canonical
15362            // `zparseopts -a foo --` invocation.
15363            None,
15364            None,
15365        ),
15366        BUILTIN(
15367            "zregexparse",
15368            0,
15369            Some(crate::ported::modules::zutil::bin_zregexparse as HandlerFunc),
15370            0,
15371            -1,
15372            0,
15373            Some("c"),
15374            None,
15375        ),
15376        BUILTIN(
15377            "cap",
15378            0,
15379            Some(crate::ported::modules::cap::bin_cap as HandlerFunc),
15380            0,
15381            1,
15382            0,
15383            None,
15384            None,
15385        ),
15386        BUILTIN(
15387            "getcap",
15388            0,
15389            Some(crate::ported::modules::cap::bin_getcap as HandlerFunc),
15390            1,
15391            -1,
15392            0,
15393            None,
15394            None,
15395        ),
15396        BUILTIN(
15397            "setcap",
15398            0,
15399            Some(crate::ported::modules::cap::bin_setcap as HandlerFunc),
15400            1,
15401            -1,
15402            0,
15403            None,
15404            None,
15405        ),
15406        BUILTIN(
15407            "pcre_compile",
15408            0,
15409            Some(crate::ported::modules::pcre::bin_pcre_compile as HandlerFunc),
15410            1,
15411            1,
15412            0,
15413            // c:Src/Modules/pcre.c:521 — `"aimxs"`. The `s` (PCRE2_DOTALL,
15414            // handled at pcre.rs:109) was missing, so `pcre_compile -s`
15415            // errored "bad option: -s".
15416            Some("aimxs"),
15417            None,
15418        ),
15419        BUILTIN(
15420            "pcre_study",
15421            0,
15422            Some(crate::ported::modules::pcre::bin_pcre_study as HandlerFunc),
15423            0,
15424            0,
15425            0,
15426            None,
15427            None,
15428        ),
15429        BUILTIN(
15430            "pcre_match",
15431            0,
15432            Some(crate::ported::modules::pcre::bin_pcre_match as HandlerFunc),
15433            1,
15434            -1,
15435            0,
15436            // c:Src/Modules/pcre.c:522 — `"A:a:v:n:bd"`. `A`(named assoc),
15437            // `a`(capture array), `v`(match var), `n`(start offset) take
15438            // args; `b`(offset pair) and `d`(dfa) are flags. The prior
15439            // "ab:nv:" made `-a` a flag (so `-a arr` ate the subject),
15440            // `-b` arg-taking, and dropped `-A`/`-n:`/`-d` entirely.
15441            Some("A:a:v:n:bd"),
15442            None,
15443        ),
15444        BUILTIN(
15445            "ztcp",
15446            0,
15447            Some(crate::ported::modules::tcp::bin_ztcp as HandlerFunc),
15448            0,
15449            -1,
15450            0,
15451            // c:Src/Modules/tcp.c — `"acd:flLtv"`. `-d` (target fd) takes
15452            // an argument; zshrs had "acdflLtv" (d a flag), so `ztcp -d 9`
15453            // treated 9 as a positional and OPT_ARG(d) was empty.
15454            Some("acd:flLtv"),
15455            None,
15456        ),
15457        BUILTIN(
15458            "ztie",
15459            0,
15460            Some(crate::ported::modules::db_gdbm::bin_ztie as HandlerFunc),
15461            0,
15462            -1,
15463            0,
15464            Some("d:f:r"),
15465            None,
15466        ),
15467        BUILTIN(
15468            "zuntie",
15469            0,
15470            Some(crate::ported::modules::db_gdbm::bin_zuntie as HandlerFunc),
15471            1,
15472            -1,
15473            0,
15474            Some("u"),
15475            None,
15476        ),
15477        BUILTIN(
15478            "zgdbmpath",
15479            0,
15480            Some(crate::ported::modules::db_gdbm::bin_zgdbmpath as HandlerFunc),
15481            1,
15482            1,
15483            0,
15484            None,
15485            None,
15486        ),
15487        BUILTIN(
15488            "echoti",
15489            0,
15490            Some(crate::ported::modules::terminfo::bin_echoti as HandlerFunc),
15491            1,
15492            -1,
15493            0,
15494            None,
15495            None,
15496        ),
15497        BUILTIN(
15498            "fg",
15499            0,
15500            Some(bin_fg as HandlerFunc),
15501            0,
15502            -1,
15503            BIN_FG,
15504            None,
15505            None,
15506        ),
15507        BUILTIN(
15508            "kill",
15509            BINF_HANDLES_OPTS,
15510            Some(crate::ported::jobs::bin_kill as HandlerFunc),
15511            0,
15512            -1,
15513            0,
15514            None,
15515            None,
15516        ),
15517        BUILTIN(
15518            "suspend",
15519            0,
15520            Some(crate::ported::jobs::bin_suspend as HandlerFunc),
15521            0,
15522            0,
15523            0,
15524            Some("f"),
15525            None,
15526        ),
15527        BUILTIN(
15528            "bindkey",
15529            0,
15530            Some(crate::ported::zle::zle_keymap::bin_bindkey as HandlerFunc),
15531            0,
15532            -1,
15533            0,
15534            // c:Src/Zle/zle_main.c:2209 — `"evaM:ldDANmrsLRp"`. The
15535            // `:` after `M` marks it as taking a required arg
15536            // (`-M <keymap>`); without it the parser consumes the
15537            // keymap name as a positional and `-M` falls back to
15538            // "main" → wrong keymap returned for every `-M X` call.
15539            Some("evaM:ldDANmrsLRp"),
15540            None,
15541        ),
15542        BUILTIN(
15543            "vared",
15544            0,
15545            Some(crate::ported::zle::zle_main::bin_vared as HandlerFunc),
15546            1,
15547            1,
15548            0,
15549            Some("aAcef:ghi:M:m:p:r:t:"),
15550            None,
15551        ),
15552        // C: `BUILTIN("compadd", BINF_HANDLES_OPTS, bin_compadd, 0, -1, 0,
15553        // NULL, NULL)` (complete.c:1693). compadd parses ALL its own
15554        // options (the char-by-char loop in bin_compadd_body). The earlier
15555        // registration gave it an incomplete getopt spec ("J:V:1X:fnqQF:Wsi")
15556        // WITHOUT BINF_HANDLES_OPTS, so the dispatcher pre-parsed and
15557        // STRIPPED -J/-V/-X/… into `ops` and handed bin_compadd only the
15558        // positional words — every `compadd -J group -X expl …` lost its
15559        // group name and explanation (second `compadd -J` groups vanished,
15560        // no group headers). Must be HANDLES_OPTS + no optspec so argv
15561        // reaches bin_compadd raw.
15562        BUILTIN(
15563            "compadd",
15564            BINF_HANDLES_OPTS,
15565            Some(crate::ported::zle::complete::bin_compadd as HandlerFunc),
15566            0,
15567            -1,
15568            0,
15569            None,
15570            None,
15571        ),
15572        // C: `BUILTIN("compset", 0, bin_compset, 1, 3, 0, NULL, NULL)`
15573        // (complete.c:1694). bin_compset parses `argv[0]` (-n/-p/-q/-P/-S)
15574        // ITSELF; the earlier Rust registration added a getopt spec
15575        // ("npqPS:") that C does NOT have, so the dispatcher pre-parsed and
15576        // STRIPPED those options from argv before bin_compset ran —
15577        // `argv[0].starts_with('-')` then failed and every shell-invoked
15578        // `compset -p/-q/-n …` no-opped (word-splitting completers broke).
15579        // Must be NULL optspec (+ max 3) so argv reaches bin_compset raw.
15580        BUILTIN(
15581            "compset",
15582            0,
15583            Some(crate::ported::zle::complete::bin_compset as HandlerFunc),
15584            1,
15585            3,
15586            0,
15587            None,
15588            None,
15589        ),
15590        // c:Src/Zle/computil.c:5103-5110 — zsh/computil module's 8
15591        // builtins drive compsys (the canonical completion system).
15592        // All have HandlerFunc-compatible signatures already; just
15593        // need BUILTINS-table registration. Without these,
15594        // _describe / _arguments / _values / _files / _groups / etc.
15595        // (compsys's primary entry points) silently no-op.
15596        BUILTIN(
15597            "comparguments",
15598            0,
15599            Some(crate::ported::zle::computil::bin_comparguments as HandlerFunc),
15600            1,
15601            -1,
15602            0,
15603            None,
15604            None,
15605        ), // c:5103
15606        BUILTIN(
15607            "compdescribe",
15608            0,
15609            Some(crate::ported::zle::computil::bin_compdescribe as HandlerFunc),
15610            3,
15611            -1,
15612            0,
15613            None,
15614            None,
15615        ), // c:5104
15616        BUILTIN(
15617            "compfiles",
15618            0,
15619            Some(crate::ported::zle::computil::bin_compfiles as HandlerFunc),
15620            1,
15621            -1,
15622            0,
15623            None,
15624            None,
15625        ), // c:5105
15626        BUILTIN(
15627            "compgroups",
15628            0,
15629            Some(crate::ported::zle::computil::bin_compgroups as HandlerFunc),
15630            1,
15631            -1,
15632            0,
15633            None,
15634            None,
15635        ), // c:5106
15636        BUILTIN(
15637            "compquote",
15638            0,
15639            Some(crate::ported::zle::computil::bin_compquote as HandlerFunc),
15640            1,
15641            -1,
15642            0,
15643            Some("p"),
15644            None,
15645        ), // c:5107
15646        BUILTIN(
15647            "comptags",
15648            0,
15649            Some(crate::ported::zle::computil::bin_comptags as HandlerFunc),
15650            1,
15651            -1,
15652            0,
15653            None,
15654            None,
15655        ), // c:5108
15656        BUILTIN(
15657            "comptry",
15658            0,
15659            Some(crate::ported::zle::computil::bin_comptry as HandlerFunc),
15660            0,
15661            -1,
15662            0,
15663            None,
15664            None,
15665        ), // c:5109
15666        BUILTIN(
15667            "compvalues",
15668            0,
15669            Some(crate::ported::zle::computil::bin_compvalues as HandlerFunc),
15670            1,
15671            -1,
15672            0,
15673            None,
15674            None,
15675        ), // c:5110
15676        // c:Src/Modules/system.c:819-824 — zsh/system module builtins.
15677        BUILTIN(
15678            "syserror",
15679            0,
15680            Some(crate::ported::modules::system::bin_syserror as HandlerFunc),
15681            0,
15682            1,
15683            0,
15684            Some("e:p:"),
15685            None,
15686        ), // c:819
15687        BUILTIN(
15688            "sysread",
15689            0,
15690            Some(crate::ported::modules::system::bin_sysread as HandlerFunc),
15691            0,
15692            1,
15693            0,
15694            Some("c:i:o:s:t:"),
15695            None,
15696        ), // c:820
15697        BUILTIN(
15698            "syswrite",
15699            0,
15700            Some(crate::ported::modules::system::bin_syswrite as HandlerFunc),
15701            1,
15702            1,
15703            0,
15704            Some("c:o:"),
15705            None,
15706        ), // c:821
15707        BUILTIN(
15708            "sysopen",
15709            0,
15710            Some(crate::ported::modules::system::bin_sysopen as HandlerFunc),
15711            1,
15712            1,
15713            0,
15714            Some("rwau:o:m:"),
15715            None,
15716        ), // c:822
15717        BUILTIN(
15718            "sysseek",
15719            0,
15720            Some(crate::ported::modules::system::bin_sysseek as HandlerFunc),
15721            1,
15722            1,
15723            0,
15724            Some("u:w:"),
15725            None,
15726        ), // c:823
15727        BUILTIN(
15728            "zsystem",
15729            0,
15730            Some(crate::ported::modules::system::bin_zsystem as HandlerFunc),
15731            1,
15732            -1,
15733            0,
15734            None,
15735            None,
15736        ), // c:824
15737        // c:Src/Modules/zselect.c:272 — zsh/zselect module.
15738        BUILTIN(
15739            "zselect",
15740            0,
15741            Some(crate::ported::modules::zselect::bin_zselect as HandlerFunc),
15742            0,
15743            -1,
15744            0,
15745            None,
15746            None,
15747        ), // c:272
15748        // c:Src/Modules/socket.c:276 — zsh/socket module.
15749        BUILTIN(
15750            "zsocket",
15751            0,
15752            Some(crate::ported::modules::socket::bin_zsocket as HandlerFunc),
15753            0,
15754            3,
15755            0,
15756            Some("ad:ltv"),
15757            None,
15758        ), // c:276
15759        // c:Src/Modules/stat.c:637 — zsh/stat module.
15760        BUILTIN(
15761            "stat",
15762            0,
15763            Some(crate::ported::modules::stat::bin_stat as HandlerFunc),
15764            0,
15765            -1,
15766            0,
15767            None,
15768            None,
15769        ), // c:637
15770        // c:Src/Modules/stat.c:638 — `zstat` alias, same handler.
15771        BUILTIN(
15772            "zstat",
15773            0,
15774            Some(crate::ported::modules::stat::bin_stat as HandlerFunc),
15775            0,
15776            -1,
15777            0,
15778            None,
15779            None,
15780        ), // c:638
15781        // c:Src/Modules/watch.c:694 — zsh/watch module's `log`.
15782        BUILTIN(
15783            "log",
15784            0,
15785            Some(crate::ported::modules::watch::bin_log as HandlerFunc),
15786            0,
15787            0,
15788            0,
15789            None,
15790            None,
15791        ), // c:694
15792        // c:Src/Modules/zprof.c:315 — zsh/zprof module.
15793        BUILTIN(
15794            "zprof",
15795            0,
15796            Some(crate::ported::modules::zprof::bin_zprof as HandlerFunc),
15797            0,
15798            0,
15799            0,
15800            Some("c"),
15801            None,
15802        ), // c:315
15803        // c:Src/Modules/datetime.c:239 — zsh/datetime module.
15804        BUILTIN(
15805            "strftime",
15806            0,
15807            Some(crate::ported::modules::datetime::bin_strftime as HandlerFunc),
15808            1,
15809            3,
15810            0,
15811            Some("nqrs:"),
15812            None,
15813        ), // c:239
15814        // c:Src/Modules/zftp.c:189 — zsh/zftp module.
15815        BUILTIN(
15816            "zftp",
15817            0,
15818            Some(crate::ported::modules::zftp::bin_zftp as HandlerFunc),
15819            1,
15820            -1,
15821            0,
15822            None,
15823            None,
15824        ), // c:189
15825        // c:Src/Modules/zpty.c:882 — zsh/zpty module.
15826        BUILTIN(
15827            "zpty",
15828            0,
15829            Some(crate::ported::modules::zpty::bin_zpty as HandlerFunc),
15830            0,
15831            -1,
15832            0,
15833            Some("ebdmrwLnt"),
15834            None,
15835        ), // c:882
15836        // c:Src/Modules/curses.c:1632 — zsh/curses module.
15837        BUILTIN(
15838            "zcurses",
15839            0,
15840            Some(crate::ported::modules::curses::bin_zcurses as HandlerFunc),
15841            1,
15842            -1,
15843            0,
15844            Some(""),
15845            None,
15846        ), // c:1632
15847        // c:Src/Modules/clone.c:110 — zsh/clone module (Linux only;
15848        // bin_clone on non-Linux is the "not available" stub).
15849        BUILTIN(
15850            "clone",
15851            0,
15852            Some(crate::ported::modules::clone::bin_clone as HandlerFunc),
15853            1,
15854            1,
15855            0,
15856            None,
15857            None,
15858        ), // c:110
15859        // c:Src/Modules/example.c — zsh/example module (template).
15860        BUILTIN(
15861            "example",
15862            0,
15863            Some(crate::ported::modules::example::bin_example as HandlerFunc),
15864            0,
15865            -1,
15866            0,
15867            Some("flags"),
15868            None,
15869        ),
15870        // c:Src/Modules/param_private.c:652 — zsh/param/private module.
15871        // BINF_PLUSOPTS | BINF_MAGICEQUALS | BINF_PSPECIAL | BINF_ASSIGN.
15872        BUILTIN(
15873            "private",
15874            BINF_PLUSOPTS | BINF_MAGICEQUALS | BINF_PSPECIAL | BINF_ASSIGN,
15875            Some(crate::ported::modules::param_private::bin_private as HandlerFunc),
15876            0,
15877            -1,
15878            0,
15879            Some("AE:%F:%HL:%PR:%TUZ:%ahi:%lnmrtux"),
15880            Some("P"),
15881        ), // c:652
15882        // c:Src/Modules/termcap.c:139 — zsh/termcap module.
15883        BUILTIN(
15884            "echotc",
15885            0,
15886            Some(crate::ported::modules::termcap::bin_echotc as HandlerFunc),
15887            1,
15888            -1,
15889            0,
15890            None,
15891            None,
15892        ), // c:139
15893        // c:Src/Zle/compctl.c:4000-4001 — zsh/compctl module.
15894        BUILTIN(
15895            "compcall",
15896            0,
15897            Some(crate::ported::zle::compctl::bin_compcall as HandlerFunc),
15898            0,
15899            0,
15900            0,
15901            Some("TD"),
15902            None,
15903        ), // c:4000
15904        BUILTIN(
15905            "compctl",
15906            0,
15907            Some(crate::ported::zle::compctl::bin_compctl as HandlerFunc),
15908            0,
15909            -1,
15910            0,
15911            None,
15912            None,
15913        ), // c:4001
15914        // c:Src/Modules/attr.c:220-223 — zsh/attr module (4 builtins).
15915        BUILTIN(
15916            "zgetattr",
15917            0,
15918            Some(crate::ported::modules::attr::bin_getattr as HandlerFunc),
15919            2,
15920            3,
15921            0,
15922            Some("h"),
15923            None,
15924        ), // c:220
15925        BUILTIN(
15926            "zsetattr",
15927            0,
15928            Some(crate::ported::modules::attr::bin_setattr as HandlerFunc),
15929            3,
15930            3,
15931            0,
15932            Some("h"),
15933            None,
15934        ), // c:221
15935        BUILTIN(
15936            "zdelattr",
15937            0,
15938            Some(crate::ported::modules::attr::bin_delattr as HandlerFunc),
15939            2,
15940            -1,
15941            0,
15942            Some("h"),
15943            None,
15944        ), // c:222
15945        BUILTIN(
15946            "zlistattr",
15947            0,
15948            Some(crate::ported::modules::attr::bin_listattr as HandlerFunc),
15949            1,
15950            2,
15951            0,
15952            Some("h"),
15953            None,
15954        ), // c:223
15955        BUILTIN(
15956            "zle",
15957            0,
15958            Some(crate::ported::zle::zle_thingy::bin_zle as HandlerFunc),
15959            0,
15960            -1,
15961            0,
15962            // c:Src/Zle/zle_main.c:2212 — exact C optstr. The previous
15963            // string dropped g/G/r/w, so `zle -w -F fd handler`
15964            // (zzcomplete's zz-deploy-code:27 widget-mode fd watcher)
15965            // died with "bad option: -w" before bin_zle ever ran.
15966            Some("aAcCDfFgGIKlLmMNrRTUw"),
15967            None,
15968        ),
15969        // zsh/files module — file-manipulation builtins. All have
15970        // HandlerFunc-compatible signatures already.
15971        BUILTIN(
15972            "mkdir",
15973            0,
15974            Some(crate::ported::modules::files::bin_mkdir as HandlerFunc),
15975            1,
15976            -1,
15977            0,
15978            Some("pm:"),
15979            None,
15980        ),
15981        BUILTIN(
15982            "rmdir",
15983            0,
15984            Some(crate::ported::modules::files::bin_rmdir as HandlerFunc),
15985            1,
15986            -1,
15987            0,
15988            None,
15989            None,
15990        ),
15991        BUILTIN(
15992            "ln",
15993            0,
15994            Some(crate::ported::modules::files::bin_ln as HandlerFunc),
15995            1,
15996            -1,
15997            0,
15998            Some("dfins"),
15999            None,
16000        ),
16001        // `mv` — zsh/files. Same handler as `ln` with BIN_MV dispatch.
16002        // c:Src/Modules/files.c — `BUILTIN("mv", 0, bin_ln, 2, -1, BIN_MV, "fi", NULL)`.
16003        BUILTIN(
16004            "mv",
16005            0,
16006            Some(crate::ported::modules::files::bin_ln as HandlerFunc),
16007            2,
16008            -1,
16009            crate::ported::modules::files::BIN_MV,
16010            Some("fi"),
16011            None,
16012        ),
16013        BUILTIN(
16014            "rm",
16015            0,
16016            Some(crate::ported::modules::files::bin_rm as HandlerFunc),
16017            1,
16018            -1,
16019            0,
16020            Some("dfiRrs"),
16021            None,
16022        ),
16023        BUILTIN(
16024            "chmod",
16025            0,
16026            Some(crate::ported::modules::files::bin_chmod as HandlerFunc),
16027            2,
16028            -1,
16029            0,
16030            Some("Rs"),
16031            None,
16032        ),
16033        // c:Src/Modules/files.c:806 — BUILTIN("chgrp", 0, bin_chown, 2, -1, BIN_CHGRP, "hRs", NULL)
16034        BUILTIN(
16035            "chgrp",
16036            0,
16037            Some(crate::ported::modules::files::bin_chown as HandlerFunc),
16038            2,
16039            -1,
16040            crate::ported::modules::files::BIN_CHGRP,
16041            Some("hRs"),
16042            None,
16043        ),
16044        // c:Src/Modules/files.c:808 — BUILTIN("chown", 0, bin_chown, 2, -1, BIN_CHOWN, "hRs", NULL)
16045        BUILTIN(
16046            "chown",
16047            0,
16048            Some(crate::ported::modules::files::bin_chown as HandlerFunc),
16049            2,
16050            -1,
16051            crate::ported::modules::files::BIN_CHOWN,
16052            Some("hRs"),
16053            None,
16054        ),
16055        BUILTIN(
16056            "sync",
16057            0,
16058            Some(crate::ported::modules::files::bin_sync as HandlerFunc),
16059            0,
16060            0,
16061            0,
16062            None,
16063            None,
16064        ),
16065        // c:Src/Modules/files.c:816-824 — zf_* aliases. Same handlers as
16066        // chmod/chown/ln/mkdir/rm/rmdir/sync but separate BUILTIN entries
16067        // so `autoload -U zf_*` resolves and `zsh -f` sees them all.
16068        BUILTIN(
16069            "zf_chgrp",
16070            0,
16071            Some(crate::ported::modules::files::bin_chown as HandlerFunc),
16072            2,
16073            -1,
16074            crate::ported::modules::files::BIN_CHGRP,
16075            Some("hRs"),
16076            None,
16077        ), // c:816
16078        BUILTIN(
16079            "zf_chmod",
16080            0,
16081            Some(crate::ported::modules::files::bin_chmod as HandlerFunc),
16082            2,
16083            -1,
16084            0,
16085            Some("Rs"),
16086            None,
16087        ), // c:817
16088        BUILTIN(
16089            "zf_chown",
16090            0,
16091            Some(crate::ported::modules::files::bin_chown as HandlerFunc),
16092            2,
16093            -1,
16094            crate::ported::modules::files::BIN_CHOWN,
16095            Some("hRs"),
16096            None,
16097        ), // c:818
16098        BUILTIN(
16099            "zf_ln",
16100            0,
16101            Some(crate::ported::modules::files::bin_ln as HandlerFunc),
16102            1,
16103            -1,
16104            crate::ported::modules::files::BIN_LN,
16105            Some("dfins"),
16106            None,
16107        ), // c:819
16108        BUILTIN(
16109            "zf_mkdir",
16110            0,
16111            Some(crate::ported::modules::files::bin_mkdir as HandlerFunc),
16112            1,
16113            -1,
16114            0,
16115            Some("pm:"),
16116            None,
16117        ), // c:820
16118        BUILTIN(
16119            "zf_mv",
16120            0,
16121            Some(crate::ported::modules::files::bin_ln as HandlerFunc),
16122            2,
16123            -1,
16124            crate::ported::modules::files::BIN_MV,
16125            Some("fi"),
16126            None,
16127        ), // c:821
16128        BUILTIN(
16129            "zf_rm",
16130            0,
16131            Some(crate::ported::modules::files::bin_rm as HandlerFunc),
16132            1,
16133            -1,
16134            0,
16135            Some("dfiRrs"),
16136            None,
16137        ), // c:822
16138        BUILTIN(
16139            "zf_rmdir",
16140            0,
16141            Some(crate::ported::modules::files::bin_rmdir as HandlerFunc),
16142            1,
16143            -1,
16144            0,
16145            None,
16146            None,
16147        ), // c:823
16148        BUILTIN(
16149            "zf_sync",
16150            0,
16151            Some(crate::ported::modules::files::bin_sync as HandlerFunc),
16152            0,
16153            0,
16154            0,
16155            None,
16156            None,
16157        ), // c:824
16158    ]
16159});
16160// hash table containing builtin commands                                   // c:143
16161/// Process-wide builtin lookup table. Filled lazily the first time
16162/// `builtintab()` is called; mirrors the C `mod_export HashTable
16163/// builtintab` exposed at `Src/builtin.c:146`.
16164static builtintab: OnceLock<HashMap<String, &'static builtin>> = OnceLock::new();
16165
16166/// Names whose `node.flags & DISABLED` is set in C. The Rust port's
16167/// `builtintab` is an immutable static, so the disabled bit lives
16168/// in this parallel set; `bin_enable` toggles it via builtin.c:587.
16169/// Dispatch sites check `is_builtin_disabled(name)` before calling
16170/// `handlerfunc` to mirror C's "skip nodes with DISABLED set" walk.
16171pub static BUILTINS_DISABLED: std::sync::LazyLock<
16172    // c:587 (Src/builtin.c)
16173    Mutex<std::collections::HashSet<String>>,
16174> = std::sync::LazyLock::new(|| Mutex::new(std::collections::HashSet::new()));
16175
16176// `shfunctab` is the canonical singleton at `hashtable::shfunctab_lock()`
16177// (RwLock<shfunc_table>); the prior parallel `shfunctab_table()` /
16178// `SHFUNCTAB_INNER` (usize-pointer Mutex<HashMap>) was deleted so the
16179// `bin_functions` C-port and the bytecode function-def path both write
16180// through one table. C-faithful access via `addnode`/`getnode`/`getnode2`
16181// methods (Src/zsh.h:281+ HashTable GSU pointers).
16182
16183// `matchednodes` global from Src/builtin.c:4550.
16184/// `MATCHEDNODES` static.
16185pub static MATCHEDNODES: Mutex<Vec<String>> = Mutex::new(Vec::new());
16186
16187// `stopmsg` global from Src/jobs.c — non-zero when checkjobs() printed.
16188/// `STOPMSG` static.
16189pub static STOPMSG: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
16190// `sfcontext` global from Src/exec.c:239 — current shell-function
16191// dispatch context (SFC_NONE / SFC_BUILTIN / SFC_FUNC / SFC_SUBST...).
16192/// `SFCONTEXT` static.
16193pub static SFCONTEXT: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0); // c:exec.c:239
16194                                                                                           // `maxjob` / `thisjob` globals from Src/jobs.c:62/63 — canonical
16195                                                                                           // storage lives in jobs.rs (`OnceLock<Mutex<i32>>`). The previous
16196                                                                                           // builtin.rs duplicate `AtomicI32` stores NEVER synced with the
16197                                                                                           // jobs.rs Mutex<i32> values that the spawn/wait paths actually
16198                                                                                           // update; `checkjobs` (line 5092) read stale 0s no matter how many
16199                                                                                           // jobs were active. Callers route through jobs::MAXJOB / jobs::THISJOB
16200                                                                                           // directly now.
16201                                                                                           // `jobstats` mirror — flat per-slot stat bits (STAT_*). Real jobtab
16202                                                                                           // lives in src/ported/jobs.rs's JobTable; this mirror is updated by
16203                                                                                           // the spawn/wait paths that already touch STOPMSG. Empty → no jobs,
16204                                                                                           // matching the post-init state of `jobtab[]`.
16205/// `JOBSTATS` static.
16206pub static JOBSTATS: Mutex<Vec<i32>> = Mutex::new(Vec::new());
16207
16208// File-static globals for [_]realexit/zexit — c:5945+, init.c, signals.c.
16209/// `SHELL_EXITING` static.
16210pub static SHELL_EXITING: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
16211/// `EXIT_PENDING` static.
16212pub static EXIT_PENDING: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
16213/// `EXIT_VAL` static.
16214pub static EXIT_VAL: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
16215/// Port of `mod_export volatile int exit_level;` from `Src/builtin.c:5796`.
16216///
16217/// Records the `locallevel` at the moment a deferred `exit` was issued
16218/// inside a function. The `exec.c:6141` gate
16219/// `if (exit_pending && exit_level >= locallevel+1 && !in_exit_trap)`
16220/// fires only when the unwind has reached a scope at or above the
16221/// deferral point — preventing premature exit while the deferred
16222/// status walks back through nested function frames. C uses
16223/// `volatile int` because the value is read from a signal-touching
16224/// context; Rust's AtomicI32 with Relaxed ordering matches the same
16225/// no-fence read shape.
16226pub static EXIT_LEVEL: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
16227// ====================================================================
16228// !!! WARNING: RUST-ONLY ATOMIC — NO DIRECT C COUNTERPART !!!
16229// ====================================================================
16230// C zsh forks for `(...)` subshells; the child runs to completion
16231// then exits via process::exit, and the parent (post-fork) continues.
16232// zshrs runs subshells in-process via fusevm_bridge::subshell_begin/
16233// subshell_end (no fork), so `exit N` inside a subshell would call
16234// realexit() → process::exit(N) and terminate the WHOLE shell —
16235// breaking `(exit 7); echo $?` (expected: `7\n`, observed: shell
16236// dies with code 7).
16237//
16238// This counter is bumped by subshell_begin / decremented by
16239// subshell_end. zexit() at c:5977 checks it before realexit and,
16240// when > 0, sets EXIT_VAL + EXIT_PENDING and returns — letting the
16241// subshell unwinder catch the deferred exit at its boundary
16242// (mirroring what the deferred-exit path at c:5871-5891 does for
16243// function-scope exit).
16244//
16245// In C zsh, equivalent state is `forklevel` AT EXACTLY the subshell
16246// depth that fork would create — but the C check `locallevel >
16247// forklevel` is FALSE at subshell-top precisely so the fork can
16248// exit the child via realexit. Without fork, we need this extra
16249// gate.
16250/// `SUBSHELL_DEPTH` static.
16251pub static SUBSHELL_DEPTH: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
16252/// `LASTVAL` static.
16253pub static LASTVAL: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
16254
16255// `tok` for the test builtin — Src/builtin.c:7000 ranges. The full enum
16256// lives in src/ported/lex.rs; we mirror the few values testlex() touches.
16257/// `TEST_TOK` static.
16258pub static TEST_TOK: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
16259const TEST_LEXERR: i32 = -1; // c:7209
16260const TEST_NULLTOK: i32 = 0;
16261const TEST_DBAR: i32 = 2; // c:7213
16262const TEST_DAMPER: i32 = 3; // c:7215
16263const TEST_BANG: i32 = 4; // c:7217
16264const TEST_INPAR: i32 = 5; // c:7219
16265const TEST_OUTPAR: i32 = 6; // c:7221
16266const TEST_INANG: i32 = 7; // c:7223
16267const TEST_OUTANG: i32 = 8; // c:7225
16268const TEST_STRING: i32 = 9; // c:7227
16269
16270// `testargs` / `curtestarg` / `tokstr` globals from Src/builtin.c — the
16271// argv-style cursor that bin_test seeds and testlex() advances.
16272/// `TESTARGS` static.
16273pub static TESTARGS: Mutex<Vec<String>> = Mutex::new(Vec::new());
16274/// `TESTARGS_IDX` static.
16275pub static TESTARGS_IDX: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
16276/// `TOKSTR` static.
16277pub static TOKSTR: Mutex<String> = Mutex::new(String::new());
16278
16279// int doprintdir = 0; set in exec.c (for autocd, cdpath, etc.)            // c:722
16280// `doprintdir` from Src/exec.c — set when an autocd'd command should
16281// echo the new directory before executing.
16282/// `DOPRINTDIR` static.
16283pub static DOPRINTDIR: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
16284// set if we are resolving links to their true paths                       // c:829
16285// `chasinglinks` from Src/exec.c — non-zero when CHASELINKS / -P
16286// resolution is active.
16287/// `CHASINGLINKS` static.
16288pub static CHASINGLINKS: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
16289
16290// `pparams` global from Src/init.c — positional parameters $1..$N.
16291/// `PPARAMS` static.
16292pub static PPARAMS: Mutex<Vec<String>> = Mutex::new(Vec::new());
16293
16294// `zoptind` (Src/builtin.c:5667) and `optcind` (c:5670) — the two
16295// pieces of getopts state. zoptind backs the user-visible $OPTIND.
16296/// `ZOPTIND` static.
16297pub static ZOPTIND: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(1);
16298/// `OPTCIND` static.
16299pub static OPTCIND: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
16300
16301// `ttyfrozen` global lives canonically in jobs.rs (`OnceLock<Mutex<i32>>`
16302// at jobs.rs:2625). The previous AtomicI32 duplicate here NEVER
16303// synced with the jobs.rs store — same desync hazard as the prior
16304// MAXJOB / THISJOB fix. Callers route through jobs::TTYFROZEN.
16305
16306/// Port of `mod_export int ineval` from `Src/builtin.c:6389`. Set
16307/// while `eval` is dispatching its body (incremented before
16308/// `execode(prog, 1, 0, "eval")`, decremented after). Tested by
16309/// `IN_EVAL_TRAP()` in zsh.h:2962 to determine trap-context state.
16310pub static INEVAL: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0); // c:6389
16311
16312// `loops` / `breaks` / `contflag` / `retflag` / `locallevel` / `sourcelevel`
16313// globals from Src/loop.c + Src/init.c — control-flow state consulted by
16314// the bin_break dispatcher.
16315/// `LOOPS` static.
16316pub static LOOPS: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
16317/// `BREAKS` static.
16318pub static BREAKS: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
16319/// `CONTFLAG` static.
16320pub static CONTFLAG: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
16321/// `RETFLAG` static.
16322pub static RETFLAG: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
16323// Same single-storage rationale as locallevel_param above — C zsh has
16324// only ONE `int sourcelevel;` global (Src/init.c:60). The canonical
16325// Rust port is `sourcelevel_init` (lowercase,
16326// matches C name). Re-export that single storage so the bin_break
16327// reader and the bin_dot bumps address the same atomic; without
16328// this, `bin_dot` could increment one global while `bin_break`
16329// inspected the other and `return` inside a sourced file would
16330// fall through to `zexit` (Src/builtin.c:5858).
16331// `locallevel_param` was previously a SEPARATE AtomicI32 here, but C
16332// zsh has only ONE `int locallevel;` global (Src/params.c:54).
16333// The canonical Rust port is `locallevel_param`
16334// (lowercase, matches C name). Re-export that single storage so
16335// every reader and writer addresses the same atomic — without
16336// this, `locallevel_param.store(0)` in zle/computil.rs would zero one
16337// global while `params::locallevel.fetch_add(1)` in vm_helper
16338// incremented a DIFFERENT global, leaving the two views out of
16339// sync indefinitely.
16340// `ZEXIT_NORMAL` re-exported from canonical zsh_h.rs (port of the
16341// `enum { ZEXIT_NORMAL, ZEXIT_SIGNAL, ZEXIT_DEFERRED }` in Src/zsh.h).
16342// Same single-source-of-truth pattern as TERM_UNKNOWN / HISTFLAG_*
16343// / etc — duplicate const declarations are a drift hazard.
16344
16345// Local builders that construct C-shape `builtin` rows for the
16346// static registration table below. They mirror the
16347// `BUILTIN(...)` / `BIN_PREFIX(...)` macros in `Src/zsh.h:1450-1452`,
16348// taking `u32` flag bitsets (BINF_*) and a `&str` handler-name
16349// column used only for documentation/wiring lookup — handler
16350// function pointers themselves are wired up later in
16351// `Executor::register_builtins` (`src/ported/vm_helper`).
16352//
16353// The `handler` arg was previously a `_handler_name: &'static str` that
16354// was discarded — `handlerfunc` always ended up `NULLBINCMD`, so
16355// `execbuiltin`'s c:506 `(*handlerfunc)(...)` dispatch was unreachable.
16356// Now the descriptor carries the actual port-side `HandlerFunc` so
16357// `execbuiltin` can parse flags and call through to the real builtin.
16358/// `BUILTIN` — see implementation.
16359#[allow(non_snake_case)]
16360pub fn BUILTIN(
16361    name: &str,
16362    flags: u32,
16363    handler: Option<HandlerFunc>,
16364    min: i32,
16365    max: i32,
16366    funcid: i32,
16367    optstr: Option<&str>,
16368    defopts: Option<&str>,
16369) -> builtin {
16370    builtin {
16371        node: hashnode {
16372            next: None,
16373            nam: name.to_string(),
16374            flags: flags as i32,
16375        },
16376        handlerfunc: handler,
16377        minargs: min,
16378        maxargs: max,
16379        funcid,
16380        optstr: optstr.map(|s| s.to_string()),
16381        defopts: defopts.map(|s| s.to_string()),
16382    }
16383}
16384
16385// `traps` mirror — sig name → body. Real `sigtrapped[]`/`siglists[]`
16386// arrays live in src/ported/signals.rs; this Mutex is the static-link
16387// shim that bin_trap reads/writes.
16388static TRAPS_INNER: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
16389
16390#[allow(non_snake_case)]
16391fn BIN_PREFIX(name: &str, flags: u32) -> builtin {
16392    BUILTIN(name, flags | BINF_PREFIX, None, 0, 0, 0, None, None)
16393}
16394
16395/// Inline printf-style format helper used by bin_print's -f/printf mode.
16396/// Replaces `%s` / `%d` / `%i` / `%c` / `%%` with positional args.
16397/// Full C printf-spec engine (Src/builtin.c:4691-5500) is much more
16398/// elaborate (width/precision/flag chars/%b/%q/etc.); this is the
16399/// minimal subset that covers the common script patterns.
16400///
16401/// Returns `Ok(output)` on success, or `Err((output_so_far, bad_char))`
16402/// when an unknown `%X` directive is hit. The C source emits
16403/// `zwarnnam(name, "%s: invalid directive", start)` at builtin.c:5435
16404/// then `return 1` — partial output already written stays written. The
16405/// Rust caller (bin_print, c:4854+) mirrors that: print the partial
16406/// output, emit `zwarnnam`, return 1.
16407/// Returns the formatted output plus the byte offset at which each
16408/// format-reuse CYCLE began (c:Src/builtin.c `splits`). `printf -v` to
16409/// an array assigns one element per cycle, so the caller slices `out`
16410/// at these boundaries.
16411fn printf_format(
16412    fmt: &str,
16413    args: &[String],
16414) -> Result<(String, Vec<usize>, Vec<(String, usize)>), (String, String)> {
16415    // c:Src/builtin.c:4711 — `fmt = getkeystring(fmt, &flen, ...,
16416    // GETKEYS_PRINTF_FMT, ...);`. The format string is first run
16417    // through getkeystring to interpret backslash escapes (`\n`,
16418    // `\t`, `\xNN`, etc.) before %-format substitution. The shim
16419    // `getkeystring(s)` defaults flags to 0, which (a) drops the
16420    // backslash on `\'`/`\"` (wrong for printf — zsh keeps unknown
16421    // `\<c>` literal because PRINTF_FMT excludes GETKEY_EMACS) and
16422    // (b) treats `\c` as a control-char escape rather than the
16423    // truncation marker. Route through `_with(GETKEYS_PRINTF_FMT)`
16424    // to match C exactly. `_with` takes a u32 `how` mask; the
16425    // canonical i32 const lives in `zsh_h` (Src/zsh.h:3180-3181).
16426    let (fmt, _) = getkeystring_with(fmt, crate::ported::zsh_h::GETKEYS_PRINTF_FMT as u32, None); // c:builtin.c:4711
16427                                                                                            // c:Src/builtin.c:4696/5382/5527 — a `\c` in the FORMAT (or, below, in
16428                                                                                            // a `%b` arg) sets `fmttrunc`, which (a) truncates output here and
16429                                                                                            // (b) stops the format-reuse loop entirely (no reapplication over the
16430                                                                                            // remaining args). getkeystring_with already cut `fmt` at the `\c`;
16431                                                                                            // take the TLS flag it set so the reuse loop knows to stop.
16432    let mut fmttrunc = crate::ported::utils::getkey_truncated_take();
16433    let mut out = String::new();
16434    let mut arg_i: usize = 0;
16435    // c:Src/builtin.c:5166/5176 — `first` is the base of the current
16436    // format-reuse cycle's argument window. `%n$` positional specs index
16437    // `first + n - 1`; at the start of each new cycle `first += maxarg`
16438    // (the highest positional used in the prior cycle). `first_off` is
16439    // the Rust analog (absolute offset into `args`).
16440    let mut first_off: usize = 0;
16441    // c:Src/builtin.c — `splits`: byte offset where each format-reuse
16442    // cycle starts (the first is 0). Used by `printf -v ARRAY`.
16443    let mut bounds: Vec<usize> = Vec::new();
16444    // c:Src/builtin.c:5427-5429 — `case 'n': if (curarg) setiparam(curarg,
16445    // count - rcount);`. `%n` consumes an argument (the variable NAME) and
16446    // stores the number of bytes emitted so far into it, printing nothing.
16447    // printf_format is a pure formatter with no shell handle, so it records
16448    // (name, byte_count_at_this_point) and the caller does the setiparam —
16449    // out.len() here is exactly C's `count - rcount`.
16450    let mut n_targets: Vec<(String, usize)> = Vec::new();
16451    // c:Src/builtin.c:4914-4923 — printf reapplies the format string
16452    // until ALL args are consumed. `printf '%s,' a b c` → `a,b,c,`,
16453    // not `a,`. The outer loop reapplies; the inner do-while body
16454    // mirrors C's per-arg conversion loop directly.
16455    loop {
16456        bounds.push(out.len());
16457        // c:Src/builtin.c:5168 `rcount = count;` reset the `%n` byte count
16458        // at the start of each format-reuse cycle, so `%n` reports bytes
16459        // emitted SINCE this cycle began, not cumulatively. `printf 'x%n' a b`
16460        // stores 1 in both a and b, not 1 and 2.
16461        let cycle_start = out.len();
16462        let prev = arg_i;
16463        // c:Src/builtin.c:5175-5178 — `maxarg` is the highest positional
16464        // (`%n$`) referenced in THIS cycle; reset per cycle and folded
16465        // into `first_off` at cycle end.
16466        let mut cycle_maxarg: usize = 0;
16467        let mut iter = fmt.chars().peekable();
16468        while let Some(c) = iter.next() {
16469            if c != '%' {
16470                out.push(c);
16471                continue;
16472            }
16473            // c:Src/builtin.c:4791+ — parse width/precision/flag chars
16474            // between `%` and the conversion. Capture them so `printf
16475            // "%-10s" hi` and `printf "%.3f" 3.14159` render correctly.
16476            let mut spec = String::from("%");
16477            // c:Src/builtin.c:5199-5219 — `%n$` positional argument
16478            // specifier. A leading 1-9 digit run followed by `$` selects
16479            // the n-th argument (1-based) for this conversion instead of
16480            // the next sequential one. A leading digit run NOT followed
16481            // by `$` is an ordinary field width (handled below), so peek
16482            // the whole run before deciding. (`0` can't start a
16483            // positional — it's a flag — so C tests `'1'..'9'`.)
16484            let mut positional: Option<usize> = None;
16485            if matches!(iter.peek(), Some(&d) if ('1'..='9').contains(&d)) {
16486                let mut digits = String::new();
16487                while let Some(&d) = iter.peek() {
16488                    if d.is_ascii_digit() {
16489                        digits.push(d);
16490                        iter.next();
16491                    } else {
16492                        break;
16493                    }
16494                }
16495                if iter.peek() == Some(&'$') {
16496                    iter.next(); // c:5202 — consume `$`
16497                    let narg: usize = digits.parse().unwrap_or(0);
16498                    // c:5203-5212 — out-of-range positional is a hard
16499                    // error (`zwarnnam(...); return 1`). `argc` in C is
16500                    // the per-cycle remaining count (total minus the
16501                    // already-consumed `first_off`).
16502                    if narg == 0 || first_off + narg > args.len() {
16503                        return Err((
16504                            out,
16505                            format!("{}: argument specifier out of range", narg), // c:5204
16506                        ));
16507                    }
16508                    // c:5214-5216 — `if (narg > maxarg) maxarg = narg;`
16509                    //               `curarg = *(first + narg - 1);`
16510                    if narg > cycle_maxarg {
16511                        cycle_maxarg = narg;
16512                    }
16513                    positional = Some(first_off + narg - 1);
16514                } else {
16515                    // Not a positional: the digits are an ordinary field
16516                    // width — emit them into the spec and fall through.
16517                    spec.push_str(&digits);
16518                }
16519            }
16520            // A precision `*` with NO matching arg emits nothing into `spec`
16521            // (default precision), so the `%`-conversion invalid-directive
16522            // check below can't see it. Track it separately: `printf '%.*%'`
16523            // (no arg) is still an invalid directive in zsh (the format
16524            // carried a precision), exit 1. Src/builtin.c reaches the switch
16525            // with a non-`%%` `d` buffer regardless of the runtime arg.
16526            let mut saw_prec_star = false;
16527            loop {
16528                match iter.peek() {
16529                    // c:Src/builtin.c:4791+ — printf flag chars. The `'`
16530                    // (thousands-grouping) flag is passed straight to libc
16531                    // by C zsh; the Rust port groups the digit run itself
16532                    // in format_spec_int/uint per localeconv. Accept it here
16533                    // so it isn't mistaken for the conversion char (which
16534                    // produced "%': invalid directive").
16535                    Some(&c) if matches!(c, '-' | '+' | ' ' | '#' | '0' | '\'') => {
16536                        spec.push(c);
16537                        iter.next();
16538                    }
16539                    _ => break,
16540                }
16541            }
16542            // c:Src/builtin.c:4791-4796 — width can be either a
16543            // digit literal or `*` (consume next arg as width).
16544            // Without `*` handling, `printf '%*d' 4 7` rendered the
16545            // literal `%*d` because the iter saw `*` and aborted the
16546            // spec walk before reaching the conversion char.
16547            if iter.peek() == Some(&'*') {
16548                iter.next(); // c:4796 — consume the `*` marker
16549                // c:Src/builtin.c:5240-5247 — the `*` width arg is
16550                // MATH-EVALUATED (`width = (int)mathevali(metafy(*argp,
16551                // …))`), not a plain integer parse: `0x1f`→31, ` 4`→4,
16552                // `'A`→65, `2+3`→5. parse_int_arg is the shared
16553                // getnum→mathevali path (same one the %d/%i arm uses),
16554                // and on a math error it zeroes the value and flags
16555                // ret=1 (c:5243 `errflag → ret = 1`). Previously the
16556                // plain `str::parse` silently yielded 0 for any non-
16557                // decimal width arg.
16558                let w: i64 = args.get(arg_i).map(|s| parse_int_arg(s)).unwrap_or(0);
16559                arg_i += 1;
16560                spec.push_str(&w.to_string());
16561            } else {
16562                while let Some(&c) = iter.peek() {
16563                    if c.is_ascii_digit() {
16564                        spec.push(c);
16565                        iter.next();
16566                    } else {
16567                        break;
16568                    }
16569                }
16570            }
16571            if iter.peek() == Some(&'.') {
16572                iter.next(); // consume the `.`
16573                // `.` precision: also accepts `*` per c:4796 same as width.
16574                if iter.peek() == Some(&'*') {
16575                    iter.next();
16576                    saw_prec_star = true;
16577                    // c:Src/builtin.c:5275-5288 — the `*` precision arg is
16578                    // math-evaluated identically to the `*` width arg
16579                    // (`prec = (int)mathevali(metafy(*argp, …))`, error →
16580                    // ret=1) — BUT only when the arg actually EXISTS
16581                    // (c:5275 `if (*argp)`). If args have run out, `prec`
16582                    // stays at its -1 init (c:5178) and NO precision is
16583                    // emitted (c:5288 `if (prec >= 0) *d++ = '.', *d++ =
16584                    // '*'`). A negative result is unset the same way.
16585                    // Unlike width (init 0), a MISSING `*` precision must
16586                    // leave the conversion at default precision, not force
16587                    // `.0`: `printf '%.*d' ` prints `0` (default), not the
16588                    // truncated-to-empty `%.0d`. Previously `unwrap_or(0)`
16589                    // forced `.0` and dropped the digit.
16590                    if let Some(a) = args.get(arg_i) {
16591                        let p = parse_int_arg(a);
16592                        arg_i += 1;
16593                        if p >= 0 {
16594                            spec.push('.');
16595                            spec.push_str(&p.to_string());
16596                        }
16597                    }
16598                } else {
16599                    // Literal precision: `.` then optional digits (an empty
16600                    // digit run is precision 0, c:5283-5287 `else prec = 0`).
16601                    spec.push('.');
16602                    while let Some(&c) = iter.peek() {
16603                        if c.is_ascii_digit() {
16604                            spec.push(c);
16605                            iter.next();
16606                        } else {
16607                            break;
16608                        }
16609                    }
16610                }
16611            }
16612            // c:Src/builtin.c:5290 — `/* ignore any size modifier */
16613            // if (*c == 'l' || *c == 'L' || *c == 'h') c++;`. zsh accepts
16614            // and discards a single length modifier (`%ld`, `%lu`, `%hd`)
16615            // — the conversion char that follows determines the type and
16616            // C rebuilds the libc format as `%l<conv>` itself. NOT pushed
16617            // into `spec`: it would corrupt the format passed to the
16618            // format_spec_* helpers. Only one modifier char is skipped,
16619            // matching C (so `%lld` then errors on the second `l`).
16620            if matches!(iter.peek(), Some('l') | Some('L') | Some('h')) {
16621                iter.next();
16622            }
16623            // c:Src/builtin.c:5215/5310 — a `%n$` positional spec sets
16624            // `curarg` directly; the conversion arms below read from
16625            // `arg_i`, so point it at the positional index for the
16626            // duration of the conversion, then restore. Restoring means
16627            // the positional reference does NOT advance the sequential
16628            // cursor (C only advances `argp` for non-positional args).
16629            let saved_argi = arg_i;
16630            let is_positional = positional.is_some();
16631            if let Some(p) = positional {
16632                arg_i = p;
16633            }
16634            match iter.next() {
16635                // c:Src/builtin.c:5414-5419 — a bare `%%` prints `%`, but a
16636                // `%` directive carrying any flag / width / precision / `*` is
16637                // an "invalid directive": zsh handles doubled `%%` in the
16638                // literal scan and only reaches the conversion switch (which
16639                // has no `%` case → default) once modifiers intervened. Here a
16640                // plain `%%` arrives with `spec == "%"`; anything else means
16641                // modifiers were consumed.
16642                Some('%') if spec != "%" || saw_prec_star => {
16643                    let disp = if saw_prec_star && spec == "%" { "%.*" } else { spec.as_str() };
16644                    return Err((out, format!("{disp}%: invalid directive")));
16645                }
16646                Some('%') => out.push('%'),
16647                Some('n') => {
16648                    // c:5427-5428 — `if (curarg) setiparam(curarg, count -
16649                    // rcount);`. The arg is the target variable's NAME; nothing
16650                    // is emitted. `if (curarg)` tests that an arg SLOT exists,
16651                    // not that it is non-empty — an empty or malformed name is
16652                    // still handed to setiparam, which errors `not an
16653                    // identifier`. Only a wholly ABSENT arg is the no-op.
16654                    // `count - rcount` is bytes since the cycle start
16655                    // (c:5168), i.e. out.len() - cycle_start here.
16656                    if let Some(name) = args.get(arg_i) {
16657                        n_targets.push((name.clone(), out.len() - cycle_start)); // c:5428
16658                    }
16659                    arg_i += 1;
16660                }
16661                Some('s') => {
16662                    let a = args.get(arg_i).cloned().unwrap_or_default();
16663                    spec.push('s');
16664                    out.push_str(&format_spec_str(&spec, &a, false));
16665                    arg_i += 1;
16666                }
16667                Some('d') | Some('i') => {
16668                    let a = args.get(arg_i).cloned().unwrap_or_default();
16669                    let n = parse_int_arg(&a);
16670                    spec.push('d');
16671                    out.push_str(&format_spec_int(&spec, n));
16672                    arg_i += 1;
16673                }
16674                Some('u') => {
16675                    let a = args.get(arg_i).cloned().unwrap_or_default();
16676                    // c:Src/builtin.c:5511 — `if (!zstrtoul_underscore(
16677                    // curarg, &zulongval)) zulongval = mathevali(...)`.
16678                    // Unsigned conversions first try a direct unsigned
16679                    // parse (so a full-range literal like
16680                    // 18446744073709551615 is NOT truncated by the
16681                    // signed-math path), then fall back to math eval
16682                    // (which wraps a negative result into u64).
16683                    let n = crate::ported::utils::zstrtoul_underscore(a.trim())
16684                        .unwrap_or_else(|| parse_int_arg(&a) as u64);
16685                    spec.push('u');
16686                    out.push_str(&format_spec_uint(&spec, n));
16687                    arg_i += 1;
16688                }
16689                Some('x') => {
16690                    let a = args.get(arg_i).cloned().unwrap_or_default();
16691                    // c:Src/builtin.c:5511 — `if (!zstrtoul_underscore(
16692                    // curarg, &zulongval)) zulongval = mathevali(...)`.
16693                    // Unsigned conversions first try a direct unsigned
16694                    // parse (so a full-range literal like
16695                    // 18446744073709551615 is NOT truncated by the
16696                    // signed-math path), then fall back to math eval
16697                    // (which wraps a negative result into u64).
16698                    let n = crate::ported::utils::zstrtoul_underscore(a.trim())
16699                        .unwrap_or_else(|| parse_int_arg(&a) as u64);
16700                    spec.push('x');
16701                    out.push_str(&format_spec_radix(&spec, n, 'x'));
16702                    arg_i += 1;
16703                }
16704                Some('X') => {
16705                    let a = args.get(arg_i).cloned().unwrap_or_default();
16706                    // c:Src/builtin.c:5511 — `if (!zstrtoul_underscore(
16707                    // curarg, &zulongval)) zulongval = mathevali(...)`.
16708                    // Unsigned conversions first try a direct unsigned
16709                    // parse (so a full-range literal like
16710                    // 18446744073709551615 is NOT truncated by the
16711                    // signed-math path), then fall back to math eval
16712                    // (which wraps a negative result into u64).
16713                    let n = crate::ported::utils::zstrtoul_underscore(a.trim())
16714                        .unwrap_or_else(|| parse_int_arg(&a) as u64);
16715                    spec.push('X');
16716                    out.push_str(&format_spec_radix(&spec, n, 'X'));
16717                    arg_i += 1;
16718                }
16719                Some('o') => {
16720                    let a = args.get(arg_i).cloned().unwrap_or_default();
16721                    // c:Src/builtin.c:5511 — `if (!zstrtoul_underscore(
16722                    // curarg, &zulongval)) zulongval = mathevali(...)`.
16723                    // Unsigned conversions first try a direct unsigned
16724                    // parse (so a full-range literal like
16725                    // 18446744073709551615 is NOT truncated by the
16726                    // signed-math path), then fall back to math eval
16727                    // (which wraps a negative result into u64).
16728                    let n = crate::ported::utils::zstrtoul_underscore(a.trim())
16729                        .unwrap_or_else(|| parse_int_arg(&a) as u64);
16730                    spec.push('o');
16731                    out.push_str(&format_spec_radix(&spec, n, 'o'));
16732                    arg_i += 1;
16733                }
16734                Some(conv @ ('f' | 'F' | 'g' | 'G' | 'e' | 'E')) => {
16735                    let a = args.get(arg_i).cloned().unwrap_or_default();
16736                    // c:Src/builtin.c:5479-5488 — `doubleval = strtod(curarg,
16737                    // &eptr); if (*eptr) mnumval = matheval(curarg)`. The arg
16738                    // is parsed as a numeric constant first (handles "2.5",
16739                    // "nan", "inf"); if that leaves leftover (a variable name
16740                    // like `f`, or an expression like "1.5+1"), it is
16741                    // evaluated as a math expression. The previous port did
16742                    // only the constant parse, so `printf %g f` / `%f 1.5+1`
16743                    // yielded 0.
16744                    let n: f64 =
16745                        if let Some(rest) = a.strip_prefix('\'').or_else(|| a.strip_prefix('"')) {
16746                            // c:Src/builtin.c:5431-5447 — a leading `'`/`"` makes
16747                            // the value the numeric char code of the next char,
16748                            // for FLOAT conversions too (shared with int):
16749                            // `printf '%.1E' \'B` → 66 → 6.6E+01. The int arm
16750                            // (parse_int_arg) already did this; the float arm
16751                            // skipped it, yielding 0.
16752                            rest.chars().next().map(|c| c as i64 as f64).unwrap_or(0.0)
16753                        } else {
16754                            a.parse::<f64>().unwrap_or_else(|_| match matheval(&a) {
16755                                Ok(m) if m.type_ == crate::ported::math::MN_FLOAT => m.d,
16756                                Ok(m) => m.l as f64,
16757                                // c:Src/builtin.c:5488 — a math-eval failure on
16758                                // a FLOAT operand is a SOFT error, same as the
16759                                // integer path: emit the diagnostic, clear the
16760                                // errflag so later args still evaluate, and flag
16761                                // ret=1. The float arm previously swallowed it,
16762                                // so `printf '%g' '%d'` exited 0 where zsh exits
16763                                // 1. (Empty/missing args math-eval to Ok(0), so
16764                                // they do NOT trip this.)
16765                                Err(msg) => {
16766                                    crate::ported::utils::zerr(&msg);
16767                                    crate::ported::utils::errflag.fetch_and(
16768                                        !crate::ported::utils::ERRFLAG_ERROR,
16769                                        std::sync::atomic::Ordering::Relaxed,
16770                                    );
16771                                    PRINTF_MATH_ERR.with(|c| c.set(true));
16772                                    0.0
16773                                }
16774                            })
16775                        };
16776                    // c:Src/builtin.c printf %g/%G uses libc snprintf
16777                    // which strips trailing zeros; %e/%E uses scientific.
16778                    out.push_str(&format_spec_float_conv(&spec, n, conv));
16779                    arg_i += 1;
16780                }
16781                Some('c') => {
16782                    // c:5300-5306 — `%c` outputs the arg's first char with
16783                    // WIDTH/justify applied (`%3c`→"  x", `%-3c`→"x  ") but
16784                    // precision IGNORED (`%.0c`→"x"). Strip precision from the
16785                    // spec, then format as a one-char string. The previous port
16786                    // pushed the char raw, dropping width.
16787                    let a = args.get(arg_i).cloned().unwrap_or_default();
16788                    // c:5300-5305 — `intval = curarg ? *curarg : 0`. An
16789                    // empty or missing arg yields `*curarg == '\0'`, so
16790                    // `%c` emits a NUL byte (not nothing). zsh:
16791                    // `printf "%c" "" | od` → 00.
16792                    let ch_str = a.chars().next().unwrap_or('\0').to_string();
16793                    let mut cspec = spec.split('.').next().unwrap_or(spec.as_str()).to_string();
16794                    cspec.push('s');
16795                    out.push_str(&format_spec_str(&cspec, &ch_str, true));
16796                    arg_i += 1;
16797                }
16798                // c:builtin.c:5403-5409 %q — shell-quote the arg using
16799                // QT_BACKSLASH_SHOWNULL (backslash-escape form), NOT
16800                // QT_QUOTEDZPUTS (single-quote form).
16801                //
16802                //   c: stringval = quotestring(metafy(curarg, …),
16803                //                              QT_BACKSLASH_SHOWNULL);
16804                //
16805                // Symptom of the previous quotedzputs choice:
16806                //   printf "%q\n" "a b c"
16807                //   zshrs (before): 'a b c'        zsh: a\ b\ c
16808                //
16809                // p10k uses `printf "%q "` for shell-quoting cached
16810                // command lines; the difference makes those caches
16811                // unreadable in zsh-syntax debuggers expecting the
16812                // backslash form.
16813                Some('q') => {
16814                    // c:Src/builtin.c:5387-5391 — `stringval = curarg ?
16815                    // quotestring(...) : &nullstr`. A MISSING argument (curarg
16816                    // == NULL) is the empty nullstr and is NOT quoted, so
16817                    // `printf '%q'` prints nothing — not `''`. A PRESENT arg,
16818                    // even an empty string, IS quoted (→ `''`).
16819                    let quoted = match args.get(arg_i) {
16820                        Some(a) => crate::ported::utils::quotestring(
16821                            a,
16822                            crate::ported::zsh_h::QT_BACKSLASH_SHOWNULL,
16823                        ),
16824                        None => String::new(),
16825                    };
16826                    // c:Src/builtin.c:5405-5407 — `%q` sets `*d = 's'`
16827                    // and runs the quoted value through the normal string
16828                    // output (`print_val`), so width/precision/flags from
16829                    // the spec apply (`printf "%-5q" a` → "a    ").
16830                    spec.push('s');
16831                    out.push_str(&format_spec_str(&spec, &quoted, true));
16832                    arg_i += 1;
16833                }
16834                // c:builtin.c:5332-5336 — `%b` uses GETKEYS_PRINTF_ARG
16835                // (Src/zsh.h:3183 = GETKEY_BACKSLASH_C alone), NOT
16836                // GETKEYS_PRINT. The distinction:
16837                //   GETKEYS_PRINT      = OCTAL_ESC | EMACS | BACKSLASH_C
16838                //     `\NNN` → octal, `\<c>` → drop backslash.
16839                //   GETKEYS_PRINTF_ARG = BACKSLASH_C
16840                //     `\NNN` → literal, `\0NNN` → octal, `\<c>` → keep both.
16841                // Bourne-style `printf '%b' '\141'` keeps `\141` literal
16842                // (only `\0NNN` is octal in the arg interpretation),
16843                // matching zsh. Previous Rust port used GETKEYS_PRINT,
16844                // so `\141` was octal-eval'd to `a` — diverged.
16845                Some('b') => {
16846                    let a = args.get(arg_i).cloned().unwrap_or_default();
16847                    let (s, _) =
16848                        getkeystring_with(&a, crate::ported::zsh_h::GETKEYS_PRINTF_ARG as u32, None);
16849                    // c:5380-5383 — a `\c` inside the `%b` arg truncates:
16850                    // emit the expansion up to `\c`, then stop the whole
16851                    // printf (no rest-of-format, no reuse). getkeystring_with
16852                    // already cut `s` at the `\c` and set the TLS flag.
16853                    let arg_truncated = crate::ported::utils::getkey_truncated_take();
16854                    // c:5307-5360 — `%b` shares the `%s` width+precision
16855                    // handling (`%5b`→"   ab", `%3.1b`→"  a"), applied to the
16856                    // escape-expanded string. The previous port pushed the
16857                    // expansion raw, dropping width/precision.
16858                    spec.push('s');
16859                    out.push_str(&format_spec_str(&spec, &s, false));
16860                    arg_i += 1;
16861                    if arg_truncated {
16862                        fmttrunc = true; // c:5382
16863                        break; // c:5383 — truncate the format here (stop this cycle)
16864                    }
16865                }
16866                // c:builtin.c:5420 — `%n` consumes its arg but writes
16867                // nothing. C printf writes the byte-count-so-far to
16868                // the int pointer; zsh has no pointer to write to, so
16869                // it silently drops the directive. Previous Rust port
16870                // fell to the unknown-arm and emitted literal `%n`,
16871                // breaking `printf "%n" x; echo y` (zsh emits `y`,
16872                // zshrs emitted `%ny`).
16873                Some('n') => {
16874                    arg_i += 1;
16875                }
16876                // c:builtin.c:5430-5443 — unknown directive in C is
16877                // a hard error: `zwarnnam(name, "%s: invalid
16878                // directive", start); ...; return 1;`. Bubble the
16879                // bad char up via Err; the caller emits the warning
16880                // and returns rc=1. Partial output already written
16881                // is preserved (matches C — the warning fires AFTER
16882                // earlier output bytes have already been emitted).
16883                Some(other) => {
16884                    // c:5430-5436 — the message echoes `start`: the spec
16885                    // text from `%` through the bad conversion char
16886                    // inclusive (C null-terminates at `c[1]`). `spec`
16887                    // already holds `%`+flags+width+`.prec`; append the
16888                    // bad char. e.g. `%0$s` → "%0$: invalid directive".
16889                    return Err((out, format!("{}{}: invalid directive", spec, other)));
16890                    // c:5435
16891                }
16892                // c:Src/builtin.c:5430-5436 — a bare `%` with nothing
16893                // after it (end of format) is an invalid directive, same
16894                // as an unknown conversion char: emit any preceding output
16895                // then error (rc 1). The previous port pushed a literal
16896                // `%`, so `printf '%'` printed `%` with rc 0.
16897                None => return Err((out, format!("{}: invalid directive", spec))),
16898            }
16899            // c:Src/builtin.c — restore the sequential cursor after a
16900            // positional conversion (see the swap above).
16901            if is_positional {
16902                arg_i = saved_argi;
16903            }
16904        }
16905        // c:Src/builtin.c:5175-5178 — at the end of a cycle that used
16906        // positional specs, fold the highest positional into the base
16907        // offset (`first += maxarg`) and advance the sequential cursor
16908        // past it so the reapply check below sees forward progress.
16909        if cycle_maxarg > 0 {
16910            first_off += cycle_maxarg;
16911            if first_off > arg_i {
16912                arg_i = first_off;
16913            }
16914        }
16915        // c:Src/builtin.c:5527 — `} while (... && !fmttrunc ...)`. A `\c`
16916        // in the format or a `%b` arg stops the reuse loop.
16917        if fmttrunc || arg_i == prev || arg_i >= args.len() {
16918            break;
16919        }
16920    }
16921    Ok((out, bounds, n_targets))
16922}
16923
16924/// Apply a printf-style `%[-flag][width][.prec]s` spec to a string.
16925/// Mirrors C `printf "%-10s" str` formatting; the Rust `format!` macro
16926/// doesn't accept runtime-parsed specs so we hand-parse.
16927fn format_spec_str(spec: &str, s: &str, honor_zero: bool) -> String {
16928    // zsh's `%c` and `%q` route through `print_val`, which HONORS the `0` flag
16929    // (`printf %04c x` → `000x`, `%04q a` → `000a`), so those callers pass
16930    // honor_zero=true. `%s`/`%b` are handled inline with FORCED space padding
16931    // (Src/builtin.c:5375 `fprintf(fout, "%*c", …, ' ')` → `%04s ab` = "  ab"),
16932    // so they pass honor_zero=false.
16933    let (left_align, zero_pad_flag, width, prec) = parse_flags_width_prec(spec);
16934    let truncated: &str = if let Some(p) = prec {
16935        let end: usize = s.chars().take(p).map(|c| c.len_utf8()).sum();
16936        &s[..end.min(s.len())]
16937    } else {
16938        s
16939    };
16940    let pad = width.saturating_sub(truncated.chars().count());
16941    if left_align {
16942        format!("{}{}", truncated, " ".repeat(pad))
16943    } else {
16944        let padch = if honor_zero && zero_pad_flag { '0' } else { ' ' };
16945        format!("{}{}", padch.to_string().repeat(pad), truncated)
16946    }
16947}
16948
16949/// c:Src/builtin.c — printf integer-argument parser. zsh accepts:
16950/// - Decimal (`42`, `-7`) — leading 0 stays decimal, NOT octal.
16951/// - Hex (`0x10`, `0X10`)
16952/// - Single character prefix `'A'` or `"A"` → ASCII code
16953/// - `BASE#NNN` radix literal
16954///
16955/// c:Src/builtin.c:5460-5464 — printf evaluates the `%d`/`%i` operand with
16956/// `mathevali`, which zerr's a "bad math expression" diagnostic AND sets
16957/// errflag on a parse error (`printf %d 12abc` → "operator expected at
16958/// `abc'"). C then clears ERRFLAG_ERROR and sets the builtin's `ret = 1`,
16959/// keeping the value at 0 but continuing to format the remaining args.
16960/// zshrs's `mathevali` returns the message in `Err` instead of emitting, so
16961/// surface it here (matching the `$((…))` path at subst.rs:2562), clear the
16962/// error so following args still evaluate, and record the soft failure in
16963/// `PRINTF_MATH_ERR` for bin_print to turn into exit status 1.
16964thread_local! {
16965    static PRINTF_MATH_ERR: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
16966}
16967
16968fn parse_int_arg(s: &str) -> i64 {
16969    // c:Src/builtin.c bin_print c:5447 — character-constant check
16970    // operates on the RAW arg, not after trim. `*curarg == '\''`
16971    // → take `curarg[1]` as a byte regardless of whitespace.
16972    // Otherwise `printf "%d" "' "` returns 0 instead of 32 because
16973    // the trailing space gets stripped before the leading-quote test.
16974    if let Some(rest) = s.strip_prefix('\'').or_else(|| s.strip_prefix('"')) {
16975        return rest.chars().next().map(|c| c as i64).unwrap_or(0);
16976    }
16977    // c:Src/builtin.c:5460 — `zlongval = mathevali(metafy(curarg, …))`.
16978    // printf evaluates the %d/%i operand as a math expression. matheval
16979    // natively handles decimal / 0x-hex / BASE#NNN / float-truncate and
16980    // overflow-truncation: the math lexer's zstrtol (Src/utils.c:2511)
16981    // emits the "number truncated after N digits" warning, so the
16982    // magnitude 9223372036854775808 truncates to 18 digits exactly as
16983    // zsh, and INT64_MIN (-9223372036854775808) round-trips without the
16984    // manual `-parsed` negate that panicked on i64::MIN.
16985    // c:5460-5464 — evaluate as math; on error emit the diagnostic (C's
16986    // mathevali does this internally), zero the value, and flag ret=1.
16987    match crate::ported::math::mathevali(s.trim()) {
16988        Ok(n) => n,
16989        Err(msg) => {
16990            crate::ported::utils::zerr(&msg);
16991            // c:5463 `errflag &= ~ERRFLAG_ERROR` — clear so the NEXT %d arg
16992            // still evaluates (matheval bails on a pre-set errflag,
16993            // math.rs:569); the soft failure is tracked separately.
16994            crate::ported::utils::errflag.fetch_and(
16995                !crate::ported::utils::ERRFLAG_ERROR,
16996                std::sync::atomic::Ordering::Relaxed,
16997            );
16998            PRINTF_MATH_ERR.with(|c| c.set(true)); // c:5464 ret = 1
16999            0
17000        }
17001    }
17002}
17003
17004fn format_spec_int(spec: &str, n: i64) -> String {
17005    let (left_align, zero_pad_flag, width, prec) = parse_flags_width_prec(spec);
17006    // c:libc printf %d — when precision is given, `0` flag is
17007    // ignored (precision controls the digit padding instead).
17008    let zero_pad = zero_pad_flag && !left_align && prec.is_none();
17009    // c:Src/builtin.c — `+` flag: prefix positive numbers with `+`.
17010    // ` ` flag: prefix positive numbers with a space (mutually
17011    // exclusive with `+` per POSIX; `+` wins when both set).
17012    let plus_flag = spec.contains('+');
17013    let space_flag = spec.contains(' ') && !plus_flag;
17014    let digits = n.unsigned_abs().to_string();
17015    // c:libc printf %d precision = minimum number of digits;
17016    // zero-pad the body up to that count BEFORE the sign + width. Converting
17017    // a zero value with precision 0 yields NO digits (`printf '%.0d' 0` → "").
17018    let digits = if let Some(p) = prec {
17019        if p == 0 && n == 0 {
17020            String::new()
17021        } else if digits.len() < p {
17022            format!("{}{}", "0".repeat(p - digits.len()), digits)
17023        } else {
17024            digits
17025        }
17026    } else {
17027        digits
17028    };
17029    // c:libc printf `'` (thousands-grouping) flag — C hands `'` to the
17030    // system printf; the Rust port groups the digit run itself per the
17031    // active locale's `thousands_sep` (localeconv, set from the env at
17032    // init.rs:1709). Empty separator (C/POSIX locale) → no grouping,
17033    // matching `printf "%'d" 1234567` = `1234567` under LC_ALL=C.
17034    // Grouping is suppressed when an explicit precision is given: glibc
17035    // does NOT group the precision zero-fill (`printf "%'.8d" 42` =
17036    // `00000042`, not `00,000,042`), so gate on `prec.is_none()`. This
17037    // matches glibc for every precision case where the value fits within
17038    // the precision digit count (the normal use of `%'.Nd`); the rare
17039    // degenerate combo of `'` + a precision SHORTER than a large value's
17040    // own digit count (`%'.8d` of 1234567 → glibc `1,234,567`) still
17041    // groups in glibc but is left ungrouped here — a documented edge.
17042    // Inlined rather than shared with format_spec_uint because the
17043    // port-purity gate forbids new fns under src/ported/.
17044    let digits = if spec.contains('\'') && prec.is_none() {
17045        let sep = unsafe {
17046            let lc = libc::localeconv();
17047            if lc.is_null() || (*lc).thousands_sep.is_null() {
17048                String::new()
17049            } else {
17050                std::ffi::CStr::from_ptr((*lc).thousands_sep)
17051                    .to_string_lossy()
17052                    .into_owned()
17053            }
17054        };
17055        if sep.is_empty() {
17056            digits
17057        } else {
17058            let chars: Vec<char> = digits.chars().collect();
17059            let len = chars.len();
17060            let mut grouped = String::with_capacity(len + len / 3 * sep.len());
17061            for (i, ch) in chars.iter().enumerate() {
17062                if i > 0 && (len - i) % 3 == 0 {
17063                    grouped.push_str(&sep);
17064                }
17065                grouped.push(*ch);
17066            }
17067            grouped
17068        }
17069    } else {
17070        digits
17071    };
17072    let body = if n < 0 {
17073        format!("-{}", digits)
17074    } else if plus_flag {
17075        format!("+{}", digits)
17076    } else if space_flag {
17077        format!(" {}", digits)
17078    } else {
17079        digits
17080    };
17081    let pad = width.saturating_sub(body.chars().count());
17082    if pad == 0 {
17083        body
17084    } else if left_align {
17085        format!("{}{}", body, " ".repeat(pad))
17086    } else if zero_pad {
17087        // Zero-pad: sign/prefix char (`-`, `+`, ` `) stays at the
17088        // left, zeros pad between it and the digits.
17089        if let Some(rest) = body
17090            .strip_prefix('-')
17091            .or_else(|| body.strip_prefix('+'))
17092            .or_else(|| body.strip_prefix(' '))
17093        {
17094            let sign = body.chars().next().unwrap();
17095            format!("{}{}{}", sign, "0".repeat(pad), rest)
17096        } else {
17097            format!("{}{}", "0".repeat(pad), body)
17098        }
17099    } else {
17100        format!("{}{}", " ".repeat(pad), body)
17101    }
17102}
17103
17104/// printf %x / %X / %o with full flag support: `#` prefix, zero pad,
17105/// width, left-align. Matches libc printf semantics.
17106fn format_spec_radix(spec: &str, n: u64, conv: char) -> String {
17107    let (left_align, zero_pad_flag, width, prec) = parse_flags_width_prec(spec);
17108    // c:libc printf — when a precision is given the `0` flag is ignored;
17109    // precision controls the digit zero-fill instead.
17110    let zero_pad = zero_pad_flag && !left_align && prec.is_none();
17111    let hash_flag = spec.contains('#');
17112    let body = match conv {
17113        'x' => format!("{:x}", n),
17114        'X' => format!("{:X}", n),
17115        'o' => format!("{:o}", n),
17116        _ => n.to_string(),
17117    };
17118    // c:libc printf %o/%u/%x/%X — precision is the MINIMUM number of digits;
17119    // zero-pad the body up to it BEFORE the `#` prefix and width. Converting
17120    // a zero value with precision 0 yields NO digits.
17121    let body = if let Some(p) = prec {
17122        let len = body.chars().count();
17123        if p == 0 && n == 0 {
17124            String::new()
17125        } else if len < p {
17126            format!("{}{}", "0".repeat(p - len), body)
17127        } else {
17128            body
17129        }
17130    } else {
17131        body
17132    };
17133    // c:Src/builtin.c — `#` flag: prefix with `0x`/`0X` for hex (only
17134    // when value non-zero), `0` for octal (always, even zero, which
17135    // libc handles by emitting "0" anyway).
17136    let body = if hash_flag {
17137        match conv {
17138            'x' if n != 0 => format!("0x{}", body),
17139            'X' if n != 0 => format!("0X{}", body),
17140            'o' if !body.starts_with('0') => format!("0{}", body),
17141            _ => body,
17142        }
17143    } else {
17144        body
17145    };
17146    let pad = width.saturating_sub(body.chars().count());
17147    if pad == 0 {
17148        body
17149    } else if left_align {
17150        format!("{}{}", body, " ".repeat(pad))
17151    } else if zero_pad {
17152        // For `%#04x` with value 15: body = "0xf" (3 chars), width=4,
17153        // pad=1. Zero-pad after the `0x` prefix → "0x0f". Match libc.
17154        if let Some(rest) = body.strip_prefix("0x").or_else(|| body.strip_prefix("0X")) {
17155            let prefix = &body[..2];
17156            format!("{}{}{}", prefix, "0".repeat(pad), rest)
17157        } else {
17158            format!("{}{}", "0".repeat(pad), body)
17159        }
17160    } else {
17161        format!("{}{}", " ".repeat(pad), body)
17162    }
17163}
17164
17165fn format_spec_uint(spec: &str, n: u64) -> String {
17166    // c:Src/builtin.c — `%u` is rendered via libc `%lu`. Unsigned
17167    // conversions print NO sign: the `+` and ` ` flags are ignored
17168    // (they apply only to signed conversions), and a negative math
17169    // result has already wrapped into the u64 by the caller's cast
17170    // (`-42` → 18446744073709551574). The previous port delegated to
17171    // format_spec_int(n as i64), which reinterpreted the wrapped u64
17172    // back as a negative i64 and printed `-42`, and honored `+`/` `.
17173    let (left_align, zero_pad_flag, width, prec) = parse_flags_width_prec(spec);
17174    // libc: with an explicit precision the `0` flag is ignored.
17175    let zero_pad = zero_pad_flag && !left_align && prec.is_none();
17176    let digits = n.to_string();
17177    // Precision = minimum number of digits (zero-fill the body). A zero value
17178    // with precision 0 yields NO digits (c:libc printf).
17179    let body = match prec {
17180        Some(0) if n == 0 => String::new(),
17181        Some(p) if digits.len() < p => format!("{}{}", "0".repeat(p - digits.len()), digits),
17182        _ => digits,
17183    };
17184    // c:libc printf `'` thousands-grouping flag (see format_spec_int for
17185    // the rationale, the `prec.is_none()` gate, and the port-purity-gate
17186    // note on the inlined copy). Unsigned bodies carry no sign, so the
17187    // whole body groups.
17188    let body = if spec.contains('\'') && prec.is_none() {
17189        let sep = unsafe {
17190            let lc = libc::localeconv();
17191            if lc.is_null() || (*lc).thousands_sep.is_null() {
17192                String::new()
17193            } else {
17194                std::ffi::CStr::from_ptr((*lc).thousands_sep)
17195                    .to_string_lossy()
17196                    .into_owned()
17197            }
17198        };
17199        if sep.is_empty() {
17200            body
17201        } else {
17202            let chars: Vec<char> = body.chars().collect();
17203            let len = chars.len();
17204            let mut grouped = String::with_capacity(len + len / 3 * sep.len());
17205            for (i, ch) in chars.iter().enumerate() {
17206                if i > 0 && (len - i) % 3 == 0 {
17207                    grouped.push_str(&sep);
17208                }
17209                grouped.push(*ch);
17210            }
17211            grouped
17212        }
17213    } else {
17214        body
17215    };
17216    let pad = width.saturating_sub(body.chars().count());
17217    if pad == 0 {
17218        body
17219    } else if left_align {
17220        format!("{}{}", body, " ".repeat(pad))
17221    } else if zero_pad {
17222        format!("{}{}", "0".repeat(pad), body)
17223    } else {
17224        format!("{}{}", " ".repeat(pad), body)
17225    }
17226}
17227
17228fn format_spec_float(spec: &str, n: f64) -> String {
17229    let (left_align, zero_pad, width, prec) = parse_flags_width_prec(spec);
17230    let p = prec.unwrap_or(6);
17231    let body = format!("{:.*}", p, n);
17232    let pad = width.saturating_sub(body.chars().count());
17233    if pad == 0 {
17234        body
17235    } else if left_align {
17236        format!("{}{}", body, " ".repeat(pad))
17237    } else if zero_pad {
17238        if let Some(rest) = body.strip_prefix('-') {
17239            format!("-{}{}", "0".repeat(pad), rest)
17240        } else {
17241            format!("{}{}", "0".repeat(pad), body)
17242        }
17243    } else {
17244        format!("{}{}", " ".repeat(pad), body)
17245    }
17246}
17247
17248/// printf %g / %G / %e / %E / %f / %F dispatch. Mirrors C printf
17249/// semantics (Src/builtin.c — libc snprintf): %g picks the shorter
17250/// of %e/%f and strips trailing zeros; %e/%E uses scientific notation;
17251/// %f/%F is decimal-fraction (no scientific). Default precision is 6.
17252// pub(crate) so zcalc can format through the SAME printf conversion it calls in
17253// zsh (zcalc:107 `printf "$_forms[_outform]\n" …`, _forms[1] = '%2$g'). The
17254// float-parameter formatter `convfloat` is NOT interchangeable: it re-appends a
17255// trailing `.` when %g yields no dot (c:params.c:5748-5749), which printf never
17256// does — `sqrt(2)*sqrt(2)` must print `2`, not `2.`.
17257pub(crate) fn format_spec_float_conv(spec: &str, n: f64, conv: char) -> String {
17258    // c:Src/builtin.c:5495-5499 — `/* force consistent form for Inf/NaN
17259    // output */ if (isnan(doubleval)) fputs("nan"); else if (isinf(doubleval))
17260    // fputs(doubleval < 0.0 ? "-inf" : "inf"); else print_val(...)`. zsh emits
17261    // the BARE lowercase string with no width/precision/flags/padding,
17262    // bypassing the normal formatting (platform-independent). The previous
17263    // Rust path ran nan/inf through `format!`, producing "NaN" and garbage
17264    // scientific ("NaNe+2147483647").
17265    if n.is_nan() {
17266        return "nan".to_string(); // c:5496-5497
17267    }
17268    if n.is_infinite() {
17269        return if n < 0.0 { "-inf" } else { "inf" }.to_string(); // c:5498-5499
17270    }
17271    let (left_align, zero_pad, width, prec) = parse_flags_width_prec(spec);
17272    let body = match conv {
17273        'f' | 'F' => {
17274            let p = prec.unwrap_or(6);
17275            format!("{:.*}", p, n)
17276        }
17277        'e' | 'E' => {
17278            // Use Rust's `{:.*e}` for the mantissa+exponent — it is EXACT
17279            // (correctly-rounded, like libc snprintf). The previous port
17280            // computed `mantissa = n / 10^exp` which lost precision: `37/10`
17281            // is not exactly 3.7, so `%.27e 37` printed the binary error
17282            // `3.70000…0177635683940e+01` instead of zsh's `3.7000…000e+01`.
17283            // Rust emits `<mant>e<exp>` with lowercase `e` and no exponent
17284            // padding/sign; reformat the exponent to libc's `e[+-]NN` (≥2 digits).
17285            let p = prec.unwrap_or(6);
17286            let raw = format!("{:.*e}", p, n);
17287            let (mant, exp_s) = raw.split_once('e').unwrap_or((raw.as_str(), "0"));
17288            let exp: i32 = exp_s.parse().unwrap_or(0);
17289            let e_char = if conv == 'E' { 'E' } else { 'e' };
17290            let exp_sign = if exp >= 0 { '+' } else { '-' };
17291            format!("{}{}{}{:02}", mant, e_char, exp_sign, exp.abs())
17292        }
17293        'g' | 'G' => {
17294            // c:libc printf %g: precision is # significant digits
17295            // (default 6). Use %e if exp < -4 OR exp >= precision,
17296            // else %f. Trailing zeros stripped unless `#` flag set
17297            // (zshrs doesn't track # — skip stripping suppression).
17298            let p_sig: i32 = prec.unwrap_or(6).max(1) as i32;
17299            let exp = if n == 0.0 {
17300                0i32
17301            } else {
17302                n.abs().log10().floor() as i32
17303            };
17304            let use_e = exp < -4 || exp >= p_sig;
17305            let body = if use_e {
17306                // Exact mantissa+exponent via Rust's `{:.*e}` (see the `%e`
17307                // arm) — `n / 10^exp` lost precision.
17308                let dec = (p_sig - 1).max(0) as usize;
17309                let raw = format!("{:.*e}", dec, n);
17310                let (m, exp_s) = raw.split_once('e').unwrap_or((raw.as_str(), "0"));
17311                let ex: i32 = exp_s.parse().unwrap_or(0);
17312                let e_char = if conv == 'G' { 'E' } else { 'e' };
17313                let exp_sign = if ex >= 0 { '+' } else { '-' };
17314                format!("{}{}{}{:02}", m, e_char, exp_sign, ex.abs())
17315            } else {
17316                // p_sig - 1 - exp digits after decimal point
17317                let dec = (p_sig - 1 - exp).max(0) as usize;
17318                format!("{:.*}", dec, n)
17319            };
17320            // Strip trailing zeros from the fractional part (but keep
17321            // at least one digit after `.` if `.` is present).
17322            // Only strip if no `#` flag was set in spec.
17323            // c:libc snprintf %g — trailing-zero strip done inline; no
17324            // separate helper in C source.
17325            if !spec.contains('#') {
17326                let stripped = if let Some(e_pos) = body.find(|c| c == 'e' || c == 'E') {
17327                    let (mantissa, exp) = body.split_at(e_pos);
17328                    let m = if mantissa.contains('.') {
17329                        mantissa
17330                            .trim_end_matches('0')
17331                            .trim_end_matches('.')
17332                            .to_string()
17333                    } else {
17334                        mantissa.to_string()
17335                    };
17336                    format!("{}{}", m, exp)
17337                } else if body.contains('.') {
17338                    body.trim_end_matches('0').trim_end_matches('.').to_string()
17339                } else {
17340                    body
17341                };
17342                stripped
17343            } else {
17344                body
17345            }
17346        }
17347        _ => format!("{}", n),
17348    };
17349    // c:libc printf `#` flag on a float conversion forces a decimal point to
17350    // appear even when precision 0 leaves no fractional digits: `%#.0f` 5 →
17351    // "5.", `%#.0e` 5 → "5.e+00", `%#.0g` 5 → "5.". (The %g/%G arm above also
17352    // uses `#` to suppress trailing-zero stripping.)
17353    let body = if spec.contains('#') && !body.contains('.') {
17354        match body.find(|c| c == 'e' || c == 'E') {
17355            Some(ep) => format!("{}.{}", &body[..ep], &body[ep..]),
17356            None => format!("{body}."),
17357        }
17358    } else {
17359        body
17360    };
17361    // c:libc printf flags `+` and ` ` — a non-negative float value gets
17362    // a forced leading `+` (with `+`) or a blank (with ` `); `+`
17363    // overrides ` `. parse_flags_width_prec only extracts `-`/`0`, so
17364    // these sign flags were dropped: `printf "%+.2f" 1.5` rendered
17365    // "1.50" instead of "+1.50". Negatives keep their own `-`. The
17366    // zero-pad arm below already expects an optional leading `+`.
17367    let body = if body.starts_with('-') {
17368        body
17369    } else if spec.contains('+') {
17370        format!("+{}", body)
17371    } else if spec.contains(' ') {
17372        format!(" {}", body)
17373    } else {
17374        body
17375    };
17376    let pad = width.saturating_sub(body.chars().count());
17377    if pad == 0 {
17378        body
17379    } else if left_align {
17380        // c:libc printf — `-` overrides `0`; pad on the right with spaces.
17381        format!("{}{}", body, " ".repeat(pad))
17382    } else if zero_pad {
17383        // c:libc printf — `0` flag without `-`: zero-pad between the
17384        // optional sign and digits. `printf "%05.2f" 1.5` → "01.50",
17385        // `printf "%05.2f" -1.5` → "-1.50" (no extra pad since sign +
17386        // body already fills width). Previously the helper discarded
17387        // zero_pad via parse_width_prec and always space-padded.
17388        if let Some(rest) = body.strip_prefix('-') {
17389            format!("-{}{}", "0".repeat(pad), rest)
17390        } else if let Some(rest) = body.strip_prefix('+') {
17391            format!("+{}{}", "0".repeat(pad), rest)
17392        } else if let Some(rest) = body.strip_prefix(' ') {
17393            // ` ` (space) flag + `0`: blank stays leftmost, zeros pad
17394            // between it and the digits. `printf "% 08.2f" 1.5` → " 0001.50".
17395            format!(" {}{}", "0".repeat(pad), rest)
17396        } else {
17397            format!("{}{}", "0".repeat(pad), body)
17398        }
17399    } else {
17400        format!("{}{}", " ".repeat(pad), body)
17401    }
17402}
17403
17404fn parse_width_prec(spec: &str) -> (bool, usize, Option<usize>) {
17405    let (left_align, _zero_pad, width, prec) = parse_flags_width_prec(spec);
17406    (left_align, width, prec)
17407}
17408
17409/// Parse printf-format flags `-`, `+`, ` `, `#`, `0`, width digits,
17410/// optional `.precision`. Returns (left_align, zero_pad, width, prec).
17411/// Mirrors C `parsefmt()` in Src/builtin.c — the `0` flag is only a
17412/// flag when it appears BEFORE any width digit; a `0` inside the
17413/// width number (e.g. `%10d`) is just part of the width.
17414fn parse_flags_width_prec(spec: &str) -> (bool, bool, usize, Option<usize>) {
17415    let s = spec.trim_start_matches('%');
17416    let mut i = 0;
17417    let bytes = s.as_bytes();
17418    let mut left_align = false;
17419    let mut zero_pad = false;
17420    // `'` (thousands-grouping) is a flag char too — skip it here so a
17421    // following width/precision (`%'12d`, `%'.8d`) is still parsed. The
17422    // grouping itself is applied by the caller (format_spec_int/uint).
17423    while i < bytes.len() && matches!(bytes[i], b'-' | b'+' | b' ' | b'#' | b'0' | b'\'') {
17424        match bytes[i] {
17425            b'-' => left_align = true,
17426            b'0' => zero_pad = true,
17427            _ => {}
17428        }
17429        i += 1;
17430    }
17431    let width_start = i;
17432    while i < bytes.len() && bytes[i].is_ascii_digit() {
17433        i += 1;
17434    }
17435    let width: usize = s[width_start..i].parse().unwrap_or(0);
17436    let mut prec: Option<usize> = None;
17437    if i < bytes.len() && bytes[i] == b'.' {
17438        i += 1;
17439        let p_start = i;
17440        while i < bytes.len() && bytes[i].is_ascii_digit() {
17441            i += 1;
17442        }
17443        prec = Some(s[p_start..i].parse().unwrap_or(0));
17444    }
17445    (left_align, zero_pad, width, prec)
17446}
17447
17448// `findcmd` (Src/exec.c:897) — moved to its canonical home at
17449// `crate::ported::exec::findcmd` per PORT.md Rule C (the C source
17450// lives in exec.c, so the Rust port belongs in exec.rs). Call sites
17451// import from the new path.
17452pub use crate::ported::exec::findcmd;
17453use crate::ported::signals_h::run_queued_signals;
17454
17455/// Port of `getsigidx(const char *s)` from `Src/jobs.c:3047`.
17456/// Local wrapper that delegates to the canonical
17457/// `crate::ported::jobs::getsigidx` (matching `Src/jobs.c` location).
17458/// Returns -1 for unknown so existing builtin.rs call sites (which
17459/// use the i32 sentinel) don't need to change.
17460fn getsigidx(name: &str) -> i32 {
17461    crate::ported::jobs::getsigidx(name).unwrap_or(-1)
17462}
17463
17464
17465
17466/// Port of `int pat_enables(const char *cmd, char **patp, int enable)`
17467/// from `Src/pattern.c:4171`. Local builtin.rs shim that delegates to
17468/// the canonical pattern.rs port. Static-link path: the actual
17469/// zpc_strings/zpc_disables manipulation lives in
17470/// `pat_enables`.
17471fn pat_enables(name: &str, argv: &[String], on: bool) -> i32 {
17472    // c:4171
17473    let patp: Vec<&str> = argv.iter().map(|s| s.as_str()).collect();
17474    crate::ported::pattern::pat_enables(name, &patp, on)
17475}
17476
17477// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
17478// ─── RUST-ONLY ACCESSORS ───
17479//
17480// Singleton accessor ported for `OnceLock<Mutex<T>>` / `OnceLock<
17481// RwLock<T>>` globals declared above. C zsh uses direct global
17482// access; Rust needs these wrappers because `OnceLock::get_or_init`
17483// is the only way to lazily construct shared state. These ported sit
17484// here so the body of this file reads in C source order without
17485// the accessor wrappers interleaved between real port ported.
17486// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
17487
17488// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
17489// ─── RUST-ONLY ACCESSORS ───
17490//
17491// Singleton accessor ported for `OnceLock<Mutex<T>>` / `OnceLock<
17492// RwLock<T>>` globals declared above. C zsh uses direct global
17493// access; Rust needs these wrappers because `OnceLock::get_or_init`
17494// is the only way to lazily construct shared state. These ported sit
17495// here so the body of this file reads in C source order without
17496// the accessor wrappers interleaved between real port ported.
17497// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
17498/// `traps_table` — see implementation.
17499pub fn traps_table() -> &'static Mutex<HashMap<String, String>> {
17500    TRAPS_INNER.get_or_init(|| Mutex::new(HashMap::new()))
17501}
17502
17503#[cfg(test)]
17504mod tests {
17505    use super::*;
17506
17507    /// `findcmd` with an existing ABSOLUTE path bypasses the PATH
17508    /// walk entirely (c:916 `if (arg0 == s || ...)` branch — `s` is
17509    /// the result of `strchr(arg0, '/')`; when the first char IS
17510    /// the slash, `arg0 == s` is true). The caller's `$PATH` is
17511    /// irrelevant for an absolute path. A regression that always
17512    /// walked $PATH would fail to find `/bin/sh` when $PATH was
17513    /// empty, breaking command-name resolution for cron/init contexts
17514    /// that explicitly pass absolute paths.
17515    #[test]
17516    fn findcmd_absolute_path_skips_path_walk() {
17517        let _g = crate::test_util::global_state_lock();
17518        // Empty $PATH to guarantee the walk would miss.
17519        setsparam("PATH", "");
17520        let resolved = findcmd("/bin/sh", 0, 0);
17521        unsetparam("PATH");
17522        assert_eq!(
17523            resolved.as_deref(),
17524            Some("/bin/sh"),
17525            "c:914-919 — absolute path that exists must resolve to itself \
17526             regardless of $PATH"
17527        );
17528    }
17529
17530    /// `findcmd` with `default_path != 0` MUST search the hardcoded
17531    /// `DEFAULT_PATH` (`/usr/bin:/bin:/usr/sbin:/sbin`), NOT the
17532    /// caller's `$PATH`. C body c:903-908. This is the `command -p`
17533    /// security contract: scripts that need to invoke a sanitized
17534    /// `awk`/`sed`/`grep` regardless of user-poisoned $PATH (e.g.
17535    /// `command -p sh -c '...'` in a setuid wrapper) rely on the
17536    /// fallback path. A regression that ignored `default_path` would
17537    /// re-introduce the very PATH-injection vulnerability that
17538    /// `command -p` exists to prevent.
17539    ///
17540    /// Pin: with $PATH set to a non-existent directory, `findcmd`
17541    /// for a binary that ONLY lives in /bin or /usr/bin (e.g. `sh`)
17542    /// must still resolve when `default_path=1`.
17543    #[test]
17544    fn findcmd_default_path_searches_hardcoded_dirs() {
17545        let _g = crate::test_util::global_state_lock();
17546        // Poison $PATH so the normal path-walk would miss.
17547        setsparam("PATH", "/nonexistent/zshrs-test-poison");
17548        // `sh` exists in /bin on every POSIX system.
17549        let resolved = findcmd("sh", 0, 1);
17550        unsetparam("PATH");
17551        assert!(
17552            resolved.is_some(),
17553            "c:903-908 — default_path must search DEFAULT_PATH regardless of $PATH"
17554        );
17555        let p = resolved.unwrap();
17556        assert!(
17557            DEFAULT_PATH.split(':').any(|d| p.starts_with(d)),
17558            "resolved path must be under one of DEFAULT_PATH's dirs; got {:?}",
17559            p
17560        );
17561    }
17562
17563    /// c:7399 — `trap - <undefined>` MUST report failure (non-zero
17564    /// exit) so scripts can detect the bad signal name. The previous
17565    /// Rust port returned 0 unconditionally from the clear path,
17566    /// silently masking errors. C returns `*argv != NULL` — non-zero
17567    /// when the loop broke on an undefined signal.
17568    #[test]
17569    fn bin_trap_clear_undefined_signal_returns_nonzero() {
17570        let _g = crate::test_util::global_state_lock();
17571        let empty = options {
17572            ind: [0u8; MAX_OPS],
17573            args: Vec::new(),
17574            argscount: 0,
17575            argsalloc: 0,
17576        };
17577        // `trap - BOGUS_NEVER_A_SIGNAL` → must return 1.
17578        let r = bin_trap(
17579            "trap",
17580            &["-".into(), "BOGUS_NEVER_A_SIGNAL".into()],
17581            &empty,
17582            0,
17583        );
17584        assert_ne!(
17585            r, 0,
17586            "trap - <undefined> must report error per c:7399 (got {})",
17587            r
17588        );
17589    }
17590
17591    /// c:7405-7409 — the trap body is parsed when the trap is INSTALLED
17592    /// (`parse_string(arg, 1)`), so an unparseable body fails right here
17593    /// with `couldn't parse trap command` and rc=1, and nothing is
17594    /// installed. zshrs previously stored the body text unexamined and
17595    /// returned 0, deferring the failure to signal delivery: `trap 'for'
17596    /// EXIT` reported success and then emitted a parse error at exit.
17597    ///
17598    /// The bodies here are the ones the oracle rejects. `while` is
17599    /// deliberately not among them — `zsh -fc 'while'` treats it as an
17600    /// incomplete construct and reads the remainder from stdin rather
17601    /// than erroring, so it is not an install-time parse failure.
17602    #[test]
17603    fn bin_trap_unparseable_body_rejected_at_install() {
17604        let _g = crate::test_util::global_state_lock();
17605        let empty = options {
17606            ind: [0u8; MAX_OPS],
17607            args: Vec::new(),
17608            argscount: 0,
17609            argsalloc: 0,
17610        };
17611        for body in ["for", "((", "fi", "done", "case", "if true", "print ok; for"] {
17612            let r = bin_trap("trap", &[body.into(), "USR1".into()], &empty, 0);
17613            assert_eq!(
17614                r, 1,
17615                "trap '{body}' USR1 must fail at install per c:7407 (got {r})"
17616            );
17617            // The rejected body must not have been installed (c:7408
17618            // returns before the install loop at c:7421).
17619            let installed = traps_table()
17620                .lock()
17621                .ok()
17622                .map(|t| t.contains_key("USR1"))
17623                .unwrap_or(false);
17624            assert!(
17625                !installed,
17626                "trap '{body}' USR1 was rejected but still installed a handler"
17627            );
17628        }
17629        // A parseable body still installs — the gate must not reject
17630        // everything.
17631        let r = bin_trap("trap", &["print ok".into(), "USR1".into()], &empty, 0);
17632        assert_eq!(r, 0, "parseable body must install (got {r})");
17633        if let Ok(mut t) = traps_table().lock() {
17634            assert!(t.contains_key("USR1"), "parseable body must be installed");
17635            t.remove("USR1");
17636        }
17637    }
17638
17639    /// c:7371 — `getpermtext(siglists[sig], NULL, 0)`. C keeps the trap
17640    /// body as a compiled Eprog and renders it back to source for the
17641    /// listing, so `trap` prints CANONICAL text rather than the string
17642    /// that was typed: separators become newlines. zshrs stores the body
17643    /// as raw text and previously echoed it verbatim, which is correct
17644    /// only for a single command — `trap 'print a; print b' EXIT` listed
17645    /// as `'print a; print b'` where zsh lists `$'print a\nprint b'`.
17646    ///
17647    /// Pins the deparse itself (parse_string → getpermtext) rather than
17648    /// bin_trap's stdout, so the assertion doesn't depend on capturing
17649    /// the builtin's print! output.
17650    #[test]
17651    fn trap_body_listing_deparses_to_canonical_text() {
17652        let _g = crate::test_util::global_state_lock();
17653        let prog = crate::ported::exec::parse_string("print a; print b", 1)
17654            .expect("body must parse for the listing path");
17655        let rendered = crate::ported::text::getpermtext(Box::new(prog), None, 0);
17656        assert_eq!(
17657            rendered, "print a\nprint b",
17658            "c:7371 getpermtext must canonicalise `;` separators to newlines (got {rendered:?})"
17659        );
17660    }
17661
17662    /// Src/options.c:537-549 — `emulate(zsh_name, ...)` dispatches
17663    /// on the FIRST char of the shell name, stripping a leading `r`
17664    /// (so `rcsh`/`rksh` work as restricted variants of their base
17665    /// shell). `bash` aliases to SH (the `'b'` branch of the case).
17666    /// Pin the bits assigned by `bin_emulate` for the canonical
17667    /// names + their first-char-overlap aliases.
17668    #[test]
17669    fn bin_emulate_dispatches_on_first_char_per_c537() {
17670        let _g = crate::test_util::global_state_lock();
17671        let empty = options {
17672            ind: [0u8; MAX_OPS],
17673            args: Vec::new(),
17674            argscount: 0,
17675            argsalloc: 0,
17676        };
17677        let saved = emulation.load(Relaxed);
17678
17679        // Each (name, expected_bits) — name covers the canonical
17680        // shell names AND their `r`-prefix / first-char variants.
17681        for (name, expected) in [
17682            ("csh", EMULATE_CSH),
17683            ("ksh", EMULATE_KSH),
17684            ("sh", EMULATE_SH),
17685            ("rcsh", EMULATE_CSH), // c:539-540
17686            ("rksh", EMULATE_KSH), // c:539-540
17687            ("bash", EMULATE_SH),  // c:548 'b'
17688        ] {
17689            emulation.store(0, Relaxed);
17690            bin_emulate("emulate", &[name.into()], &empty, 0);
17691            let bits = emulation.load(Relaxed);
17692            assert_eq!(
17693                bits, expected,
17694                "emulate {} must set bits {:#x}, got {:#x}",
17695                name, expected, bits
17696            );
17697        }
17698        emulation.store(saved, Relaxed);
17699    }
17700
17701    /// c:7399 — `trap - SIGUSR1` (valid signal) MUST return 0, even
17702    /// when the trap was never set (remove is a no-op).
17703    #[test]
17704    fn bin_trap_clear_valid_signal_returns_zero() {
17705        let _g = crate::test_util::global_state_lock();
17706        let empty = options {
17707            ind: [0u8; MAX_OPS],
17708            args: Vec::new(),
17709            argscount: 0,
17710            argsalloc: 0,
17711        };
17712        let r = bin_trap("trap", &["-".into(), "USR1".into()], &empty, 0);
17713        assert_eq!(
17714            r, 0,
17715            "trap - USR1 must succeed even with no prior trap (got {})",
17716            r
17717        );
17718    }
17719
17720    #[test]
17721    fn registration_table_matches_c_count() {
17722        let _g = crate::test_util::global_state_lock();
17723        // Src/builtin.c:40-137 has 79 rows total (5 BIN_PREFIX + 71
17724        // BUILTIN + 3 debug-only BUILTIN). The Rust port bundles
17725        // additional builtins eagerly that C would load via zmodload:
17726        //   zsh/rlimits (limit/ulimit/unlimit)
17727        //   zsh/zle (bindkey/vared/zle)
17728        //   zsh/cap (cap/getcap/setcap)
17729        //   zsh/files (chmod/chown/ln/mkdir/rm/rmdir/sync)
17730        //   zsh/complete (compadd/compset)
17731        //   zsh/terminfo (echoti)
17732        //   zsh/pcre (pcre_compile/pcre_match/pcre_study)
17733        //   zsh/zutil (zformat/zgdbmpath)
17734        //   zsh/sched (sched)
17735        //   zsh/computil (comparguments/compdescribe/compfiles/
17736        //     compgroups/compquote/comptags/comptry/compvalues)
17737        //   zsh/system (syserror/sysread/syswrite/sysopen/sysseek/zsystem)
17738        //   zsh/zselect (zselect)
17739        //   zsh/socket (zsocket)
17740        //   zsh/stat (stat)
17741        //   zsh/watch (log)
17742        //   zsh/zprof (zprof)
17743        //   zsh/datetime (strftime)
17744        //   zsh/zftp (zftp), zsh/zpty (zpty), zsh/curses (zcurses)
17745        //   zsh/clone (clone), zsh/example (example)
17746        //   zsh/param/private (private)
17747        //   zsh/termcap (echotc)
17748        //   zsh/compctl (compcall, compctl)
17749        //   zsh/attr (zgetattr, zsetattr, zdelattr, zlistattr)
17750        // Tripwire pin on BUILTINS table length. The number drifts every
17751        // time the eagerly-loaded-module list above grows (new builtin
17752        // ported, new module wired). Bump it alongside the change so
17753        // accidental additions/removals still trip a review.
17754        assert_eq!(BUILTINS.len(), 159,
17755            "BUILTINS table size changed — bump count or update the eagerly-loaded-module list above");
17756    }
17757
17758    /// `Src/builtin.c:40-137` — every name in the canonical C builtin
17759    /// table must be present in the Rust port. Pins coverage of all
17760    /// 79 C builtins by name (ignores option-mask / handler details).
17761    /// Detects regressions where a builtin gets accidentally dropped
17762    /// from BUILTINS. Names extracted from upstream zsh `Src/builtin.c`.
17763    #[test]
17764    fn registration_table_contains_all_c_builtins() {
17765        let _g = crate::test_util::global_state_lock();
17766        // Canonical 79 names from Src/builtin.c:40-137 (verbatim).
17767        let c_names: &[&str] = &[
17768            "-",
17769            ".",
17770            ":",
17771            "[",
17772            "alias",
17773            "autoload",
17774            "bg",
17775            "break",
17776            "builtin",
17777            "bye",
17778            "cd",
17779            "chdir",
17780            "command",
17781            "continue",
17782            "declare",
17783            "dirs",
17784            "disable",
17785            "disown",
17786            "echo",
17787            "emulate",
17788            "enable",
17789            "eval",
17790            "exec",
17791            "exit",
17792            "export",
17793            "false",
17794            "fc",
17795            "fg",
17796            "float",
17797            "functions",
17798            "getln",
17799            "getopts",
17800            "hash",
17801            "hashinfo",
17802            "history",
17803            "integer",
17804            "jobs",
17805            "kill",
17806            "let",
17807            "local",
17808            "logout",
17809            "mem",
17810            "noglob",
17811            "patdebug",
17812            "popd",
17813            "print",
17814            "printf",
17815            "pushd",
17816            "pushln",
17817            "pwd",
17818            "r",
17819            "read",
17820            "readonly",
17821            "rehash",
17822            "return",
17823            "set",
17824            "setopt",
17825            "shift",
17826            "source",
17827            "suspend",
17828            "test",
17829            "times",
17830            "trap",
17831            "true",
17832            "ttyctl",
17833            "type",
17834            "typeset",
17835            "umask",
17836            "unalias",
17837            "unfunction",
17838            "unhash",
17839            "unset",
17840            "unsetopt",
17841            "wait",
17842            "whence",
17843            "where",
17844            "which",
17845            "zcompile",
17846            "zmodload",
17847        ];
17848        assert_eq!(
17849            c_names.len(),
17850            79,
17851            "C builtin.c row count is 79 — recount if changed"
17852        );
17853        let table_names: std::collections::HashSet<&str> =
17854            BUILTINS.iter().map(|b| b.node.nam.as_str()).collect();
17855        for c_name in c_names {
17856            assert!(
17857                table_names.contains(*c_name),
17858                "missing C builtin '{}' from BUILTINS table",
17859                c_name
17860            );
17861        }
17862    }
17863
17864    #[test]
17865    fn lookup_finds_known_builtins() {
17866        let _g = crate::test_util::global_state_lock();
17867        for name in [
17868            "cd", "echo", "print", "fg", "bg", "jobs", "wait", "typeset", "test", "[", ".",
17869        ] {
17870            assert!(
17871                createbuiltintable().get(name).copied().is_some(),
17872                "missing: {name}"
17873            );
17874        }
17875    }
17876
17877    #[test]
17878    fn lookup_misses_unknown() {
17879        let _g = crate::test_util::global_state_lock();
17880        assert!(createbuiltintable()
17881            .get("not-a-builtin-zZz")
17882            .copied()
17883            .is_none());
17884    }
17885
17886    #[test]
17887    fn prefix_entries_have_prefix_flag() {
17888        let _g = crate::test_util::global_state_lock();
17889        for name in ["-", "builtin", "command", "exec", "noglob"] {
17890            let b = createbuiltintable().get(name).copied().unwrap();
17891            assert!(
17892                b.node.flags as u32 & BINF_PREFIX != 0,
17893                "{name} missing BINF_PREFIX"
17894            );
17895        }
17896    }
17897
17898    #[test]
17899    fn fixdir_canonicalizes_absolute_paths() {
17900        let _g = crate::test_util::global_state_lock();
17901        // c:1297 — collapse `//`, drop `./`, pop `..`.
17902        assert_eq!(fixdir("/tmp/./foo"), "/tmp/foo");
17903        assert_eq!(fixdir("/tmp//foo"), "/tmp/foo");
17904        assert_eq!(fixdir("/tmp/bar/../foo"), "/tmp/foo");
17905        assert_eq!(fixdir("/tmp/bar/baz/../.."), "/tmp");
17906    }
17907
17908    #[test]
17909    fn fixdir_drops_dotdot_past_root() {
17910        let _g = crate::test_util::global_state_lock();
17911        // c:1372 — absolute path, `..` past `/` is dropped.
17912        assert_eq!(fixdir("/.."), "/");
17913        assert_eq!(fixdir("/../.."), "/");
17914        assert_eq!(fixdir("/foo/../../bar"), "/bar");
17915    }
17916
17917    #[test]
17918    fn fixdir_relative_keeps_leading_dotdot() {
17919        let _g = crate::test_util::global_state_lock();
17920        // c:1367 — relative path: `..` past start stays as `..`.
17921        assert_eq!(fixdir("../foo"), "../foo");
17922        assert_eq!(fixdir("../../foo"), "../../foo");
17923        assert_eq!(fixdir("foo/../bar"), "bar");
17924    }
17925
17926    #[test]
17927    fn fixdir_empty_collapses_to_dot() {
17928        let _g = crate::test_util::global_state_lock();
17929        // Relative path that collapses fully → "."
17930        assert_eq!(fixdir("./"), ".");
17931        assert_eq!(fixdir("foo/.."), ".");
17932    }
17933
17934    #[test]
17935    fn fixdir_empty_input_returns_empty() {
17936        let _g = crate::test_util::global_state_lock();
17937        assert_eq!(fixdir(""), "");
17938    }
17939
17940    #[test]
17941    fn fg_dispatch_id_distinguishes_aliases() {
17942        let _g = crate::test_util::global_state_lock();
17943        // bin_fg covers fg, bg, jobs, wait, disown — same handler,
17944        // different funcid. Mirrors Src/builtin.c:52,61,75,88,131.
17945        assert_eq!(
17946            createbuiltintable().get("fg").copied().unwrap().funcid,
17947            BIN_FG
17948        );
17949        assert_eq!(
17950            createbuiltintable().get("bg").copied().unwrap().funcid,
17951            BIN_BG
17952        );
17953        assert_eq!(
17954            createbuiltintable().get("jobs").copied().unwrap().funcid,
17955            BIN_JOBS
17956        );
17957        assert_eq!(
17958            createbuiltintable().get("wait").copied().unwrap().funcid,
17959            BIN_WAIT
17960        );
17961        assert_eq!(
17962            createbuiltintable().get("disown").copied().unwrap().funcid,
17963            BIN_DISOWN
17964        );
17965    }
17966
17967    /// c:1297 — `fixdir` is the lexical-canonicalisation for `cd`. The
17968    /// path `/a/b/../c` must resolve to `/a/c` BEFORE chdir(2) — the
17969    /// shell uses it to compute the logical PWD for $PWD/OLDPWD. A
17970    /// regression that drops the `..` consumption would make $PWD
17971    /// report `/a/b/../c` literally on `cd /a/b/../c`.
17972    #[test]
17973    fn fixdir_pops_dotdot_against_previous_component() {
17974        let _g = crate::test_util::global_state_lock();
17975        assert_eq!(fixdir("/a/b/../c"), "/a/c");
17976        assert_eq!(fixdir("/a/b/../../c"), "/c");
17977        assert_eq!(fixdir("/foo/.."), "/");
17978    }
17979
17980    /// c:1352 — `./` collapses to nothing.  `/a/./b` must equal `/a/b`.
17981    #[test]
17982    fn fixdir_drops_dot_components() {
17983        let _g = crate::test_util::global_state_lock();
17984        assert_eq!(fixdir("/a/./b"), "/a/b");
17985        assert_eq!(fixdir("./a"), "a");
17986        assert_eq!(fixdir("./."), ".");
17987    }
17988
17989    /// c:1388 — `//` collapses to single `/` (no preservation of POSIX
17990    /// implementation-defined `//` semantics, which zsh doesn't honour).
17991    #[test]
17992    fn fixdir_collapses_consecutive_slashes() {
17993        let _g = crate::test_util::global_state_lock();
17994        assert_eq!(fixdir("/a//b"), "/a/b");
17995        assert_eq!(fixdir("/a///b/c"), "/a/b/c");
17996    }
17997
17998    /// c:1404 — absolute path: `..` past `/` silently drops. `/..`
17999    /// resolves to `/`. Catches a regression where the underflow
18000    /// emits `..` literally.
18001    #[test]
18002    fn fixdir_dotdot_past_root_clamps_to_root() {
18003        let _g = crate::test_util::global_state_lock();
18004        assert_eq!(fixdir("/.."), "/");
18005        assert_eq!(fixdir("/../../a"), "/a");
18006    }
18007
18008    /// c:1400 — RELATIVE path: leading `..` are preserved (no parent
18009    /// known until chdir time). This is critical for `cd ../../foo`
18010    /// which must NOT resolve `..` lexically.
18011    #[test]
18012    fn fixdir_relative_leading_dotdot_is_preserved() {
18013        let _g = crate::test_util::global_state_lock();
18014        assert_eq!(fixdir("../foo"), "../foo");
18015        assert_eq!(fixdir("../../foo"), "../../foo");
18016    }
18017
18018    /// c:1683 — `fcgetcomm` returns 0 for ambiguous numeric inputs
18019    /// only when the string actually starts with '0'. The atoi result
18020    /// alone (which is 0 for non-numeric) MUST NOT short-circuit —
18021    /// non-numeric input should fall through to hcomsearch instead.
18022    #[test]
18023    fn fcgetcomm_numeric_zero_only_for_literal_zero_prefix() {
18024        let _g = crate::test_util::global_state_lock();
18025        assert_eq!(fcgetcomm("0"), 0, "literal `0` is event 0");
18026        assert_eq!(fcgetcomm("42"), 42);
18027        // Non-numeric falls through to hcomsearch (no hist match → -1).
18028        assert_eq!(fcgetcomm("definitely_not_a_history_command_zshrs"), -1);
18029    }
18030
18031    /// c:1088-1093 — `cd_able_vars` requires CDABLEVARS to be set;
18032    /// otherwise returns None even when the head names a param. A
18033    /// regression that ignores the option flag would let `cd HOME`
18034    /// silently `cd $HOME` even when the user disabled CDABLEVARS.
18035    #[test]
18036    fn cd_able_vars_returns_none_without_cdablevars_option() {
18037        let _g = crate::test_util::global_state_lock();
18038        // CDABLEVARS is not set by default → must return None.
18039        // We don't fight the option state here; just verify the
18040        // off-state default short-circuits before paramtab lookup.
18041        // (If a future commit enables CDABLEVARS by default, this
18042        // test will fail loudly — that's the right canary.)
18043        let r = cd_able_vars("HOME/anything");
18044        // Without CDABLEVARS, must be None; with it, would be Some.
18045        // Accept either since the option default is the actual invariant.
18046        if !isset(CDABLEVARS) {
18047            assert!(r.is_none());
18048        }
18049    }
18050
18051    /// c:212 — `init_builtins` is idempotent: calling twice doesn't
18052    /// duplicate entries in the table. Regression that re-inserts on
18053    /// every call would balloon memory + break dispatch lookups.
18054    #[test]
18055    fn init_builtins_is_idempotent() {
18056        let _g = crate::test_util::global_state_lock();
18057        init_builtins();
18058        let count1 = createbuiltintable().len();
18059        init_builtins();
18060        let count2 = createbuiltintable().len();
18061        assert_eq!(count1, count2, "init_builtins must not duplicate entries");
18062    }
18063
18064    /// c:1708 — `fcsubs(sp, [(old, new), ...])` applies each
18065    /// substitution to the running string, returning the total
18066    /// replacement count. A regression returning 0 with substitutions
18067    /// applied would silently break `fc -s old=new`.
18068    #[test]
18069    fn fcsubs_applies_each_substitution_in_order() {
18070        let _g = crate::test_util::global_state_lock();
18071        let mut s = "echo foo bar foo".to_string();
18072        let n = fcsubs(&mut s, &[("foo".to_string(), "FOO".to_string())]);
18073        assert_eq!(s, "echo FOO bar FOO");
18074        assert_eq!(n, 2, "two `foo` matches replaced");
18075    }
18076
18077    /// c:1708 — empty `old` MUST skip (avoid infinite empty-match
18078    /// replacement loop). Regression treating "" as "match anywhere"
18079    /// would hang or silently corrupt every fc invocation.
18080    #[test]
18081    fn fcsubs_skips_empty_pattern() {
18082        let _g = crate::test_util::global_state_lock();
18083        let mut s = "anything".to_string();
18084        let n = fcsubs(&mut s, &[("".to_string(), "X".to_string())]);
18085        assert_eq!(s, "anything", "empty pattern must be skipped");
18086        assert_eq!(n, 0);
18087    }
18088
18089    /// c:1708 — chained substitutions apply left-to-right. After
18090    /// `a→b`, the next pair sees the post-substitution text. So
18091    /// `[(a→b), (b→c)]` over `a` yields `c`.
18092    #[test]
18093    fn fcsubs_chains_substitutions_left_to_right() {
18094        let _g = crate::test_util::global_state_lock();
18095        let mut s = "a".to_string();
18096        let n = fcsubs(
18097            &mut s,
18098            &[
18099                ("a".to_string(), "b".to_string()),
18100                ("b".to_string(), "c".to_string()),
18101            ],
18102        );
18103        assert_eq!(s, "c", "second sub sees post-first-sub text");
18104        assert_eq!(n, 2);
18105    }
18106
18107    /// c:1708 — substitution on no-match leaves string unchanged AND
18108    /// reports 0. Regression touching the string anyway would mangle
18109    /// fc output for events containing none of the requested patterns.
18110    #[test]
18111    fn fcsubs_no_match_returns_zero_unchanged() {
18112        let _g = crate::test_util::global_state_lock();
18113        let mut s = "hello world".to_string();
18114        let n = fcsubs(&mut s, &[("xyz".to_string(), "abc".to_string())]);
18115        assert_eq!(s, "hello world", "no match → unchanged");
18116        assert_eq!(n, 0);
18117    }
18118
18119    /// c:1297 — `fixdir` for plain relative path (no slashes, no
18120    /// dots) returns it unchanged. Most-common cd path; regression
18121    /// here would break `cd subdir`.
18122    #[test]
18123    fn fixdir_plain_relative_path_unchanged() {
18124        let _g = crate::test_util::global_state_lock();
18125        assert_eq!(fixdir("subdir"), "subdir");
18126        assert_eq!(fixdir("a/b/c"), "a/b/c");
18127        assert_eq!(fixdir("."), ".");
18128    }
18129
18130    /// Shared mutex for bin_let tests that toggle the global errflag.
18131    static BIN_LET_TEST_LOCK: Mutex<()> = Mutex::new(());
18132
18133    /// `Src/builtin.c:7469-7484` — `bin_let` semantics:
18134    ///   1. Returns 0 (success) when the LAST arg evaluates to non-zero.
18135    ///   2. Returns 1 (failure) when the LAST arg evaluates to zero.
18136    ///   3. Returns 2 AND CLEARS ERRFLAG_ERROR when any arg errors
18137    ///      (let errors are non-fatal and local).
18138    #[test]
18139    fn bin_let_clears_errflag_on_math_error() {
18140        let _g = crate::test_util::global_state_lock();
18141        let _g = BIN_LET_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
18142        let saved = errflag.load(Relaxed);
18143        errflag.store(0, Relaxed);
18144
18145        // 1. Last arg evaluates to non-zero → return 0.
18146        let ops = options {
18147            ind: [0; MAX_OPS],
18148            args: Vec::new(),
18149            argscount: 0,
18150            argsalloc: 0,
18151        };
18152        let argv = vec!["1".to_string()];
18153        assert_eq!(
18154            bin_let("let", &argv, &ops, 0),
18155            0,
18156            "c:7482 — last expr non-zero → return 0 (success)"
18157        );
18158
18159        // 2. Last arg evaluates to zero → return 1.
18160        let argv = vec!["0".to_string()];
18161        assert_eq!(
18162            bin_let("let", &argv, &ops, 0),
18163            1,
18164            "c:7482 — last expr zero → return 1 (failure)"
18165        );
18166
18167        // 3. Bad-syntax arg → return 2 AND clear ERRFLAG_ERROR.
18168        // Pre-set errflag manually to simulate matheval failure side
18169        // effect (since exact bad-syntax behavior of the matheval port
18170        // is implementation-dependent — what we're pinning is the
18171        // bin_let response to a set errflag).
18172        errflag.store(ERRFLAG_ERROR, Relaxed);
18173        // Use a valid expression so matheval succeeds, but errflag
18174        // is already set from a prior step.
18175        let argv = vec!["1".to_string()];
18176        let rc = bin_let("let", &argv, &ops, 0);
18177        // c:7479 says `return 2;` but the installed zsh 5.9.1 returns
18178        // 1 (oracle: `zsh -fc 'let 1/0; echo $?'` → 1) and bin_let
18179        // matches the release binary — see the comment block at the
18180        // errflag branch in bin_let. This assertion pins the OBSERVED
18181        // status; it went stale when bin_let switched 2→1.
18182        assert_eq!(
18183            rc, 1,
18184            "c:7479 cleanup path — observed zsh 5.9.1 rc (release binary returns 1, not the c:7479 literal 2)"
18185        );
18186        // c:7478 — `errflag &= ~ERRFLAG_ERROR` must have run.
18187        assert_eq!(
18188            errflag.load(Relaxed) & ERRFLAG_ERROR,
18189            0,
18190            "c:7478 — ERRFLAG_ERROR must be CLEARED after let error"
18191        );
18192
18193        // Restore.
18194        errflag.store(saved, Relaxed);
18195    }
18196
18197    /// `Src/builtin.c:7474-7475` — C walks ALL argv via
18198    /// `while (*argv) val = matheval(*argv++);`. The LAST matheval
18199    /// result is what determines the return code. The previous Rust
18200    /// port broke on first error, skipping later args. Pin: a sequence
18201    /// of two non-zero exprs returns 0 even if both are evaluated.
18202    #[test]
18203    fn bin_let_walks_all_argv_last_wins() {
18204        let _g = crate::test_util::global_state_lock();
18205        let _g = BIN_LET_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
18206        errflag.store(0, Relaxed);
18207
18208        let ops = options {
18209            ind: [0; MAX_OPS],
18210            args: Vec::new(),
18211            argscount: 0,
18212            argsalloc: 0,
18213        };
18214        // c:7474 — `5; 0` (two args): last is 0 → return 1.
18215        let argv = vec!["5".to_string(), "0".to_string()];
18216        assert_eq!(
18217            bin_let("let", &argv, &ops, 0),
18218            1,
18219            "c:7474 — last arg wins (here: 0 → return 1)"
18220        );
18221
18222        // c:7474 — `0; 5` (two args): last is 5 → return 0.
18223        let argv = vec!["0".to_string(), "5".to_string()];
18224        assert_eq!(
18225            bin_let("let", &argv, &ops, 0),
18226            0,
18227            "c:7474 — last arg wins (here: 5 → return 0)"
18228        );
18229
18230        errflag.fetch_and(!ERRFLAG_ERROR, Relaxed);
18231    }
18232
18233    /// `Src/builtin.c:4799-4808` — `print -o` (sort) is CASE-SENSITIVE
18234    /// by default; `-i` flips to case-insensitive. The previous Rust
18235    /// port had this INVERTED: case-sensitive under `-i`,
18236    /// case-insensitive without. Pin the canonical semantic by direct
18237    /// reproduction of the sort step.
18238    ///
18239    /// `bin_print` itself is harder to test in isolation because it
18240    /// emits to stdout; instead we replicate the in-port sort logic
18241    /// to ensure the gate matches C semantics. If the port body's
18242    /// `if ignore_case` is ever re-inverted, the regression here
18243    /// surfaces immediately.
18244    #[test]
18245    fn bin_print_sort_matches_c_case_gate() {
18246        let _g = crate::test_util::global_state_lock();
18247        // Helper mirroring the in-port logic exactly.
18248        let sort_with = |items: &[&str], ignore_case: bool, backwards: bool| -> Vec<String> {
18249            let mut v: Vec<String> = items.iter().map(|s| s.to_string()).collect();
18250            if ignore_case {
18251                v.sort_by_key(|s| s.to_lowercase());
18252            } else {
18253                v.sort();
18254            }
18255            if backwards {
18256                v.reverse();
18257            }
18258            v
18259        };
18260        // `print -o foo Bar BAZ` (no `-i`): case-sensitive ASCII sort.
18261        // Uppercase ASCII < lowercase ASCII, so caps come first.
18262        let no_i = sort_with(&["foo", "Bar", "BAZ"], false, false);
18263        assert_eq!(
18264            no_i,
18265            vec!["BAZ", "Bar", "foo"],
18266            "c:4805 — without -i: case-sensitive sort (caps first by ASCII)"
18267        );
18268        // `print -oi foo Bar BAZ`: case-insensitive sort.
18269        // Lower-case comparison: "bar" < "baz" < "foo", so order is
18270        // Bar, BAZ, foo.
18271        let with_i = sort_with(&["foo", "Bar", "BAZ"], true, false);
18272        assert_eq!(
18273            with_i,
18274            vec!["Bar", "BAZ", "foo"],
18275            "c:4805 — with -i: case-insensitive sort"
18276        );
18277        // `print -O foo Bar BAZ` (no `-i`): case-sensitive descending.
18278        let big_o = sort_with(&["foo", "Bar", "BAZ"], false, true);
18279        assert_eq!(
18280            big_o,
18281            vec!["foo", "Bar", "BAZ"],
18282            "c:4806 — -O reverses after sort"
18283        );
18284        // Conjunction check: zsh-equivalent: print -O foo Bar BAZ
18285        // gives `foo Bar BAZ`. Pin so an inadvertent reverse-before-
18286        // sort regression fails.
18287    }
18288
18289    /// `Src/builtin.c:4854-4856 + 5564-5565` — `printf -z FMT ARGS...`
18290    /// captures formatted output then pushes to bufstack (same path
18291    /// as -z without -f).
18292    #[test]
18293    fn bin_print_printf_with_minus_z() {
18294        let _g = crate::test_util::global_state_lock();
18295        let mut ops = options {
18296            ind: [0u8; MAX_OPS],
18297            args: Vec::new(),
18298            argscount: 0,
18299            argsalloc: 0,
18300        };
18301        ops.ind[b'z' as usize] = 1;
18302        // -f set to "echo %s" (positional)
18303        ops.ind[b'f' as usize] = 1 | (1 << 2);
18304        ops.args = vec!["echo %s".to_string()];
18305        ops.argscount = 1;
18306        crate::ported::zle::zle_main::BUFSTACK
18307            .lock()
18308            .unwrap()
18309            .clear();
18310        let r = bin_print("printf", &["hello".to_string()], &ops, BIN_PRINTF);
18311        assert_eq!(r, 0);
18312        let buf = crate::ported::zle::zle_main::BUFSTACK.lock().unwrap();
18313        assert_eq!(
18314            buf.last().map(|s| s.as_str()),
18315            Some("echo hello"),
18316            "c:4854-4856 — printf -z must push formatted output to bufstack"
18317        );
18318    }
18319
18320    /// `Src/builtin.c:5564-5565` — `print -z WORDS...` pushes the
18321    /// joined string to the ZLE bufstack instead of stdout (consumed
18322    /// by the next zleread call so the string lands at the prompt).
18323    #[test]
18324    fn bin_print_minus_z_pushes_to_bufstack() {
18325        let _g = crate::test_util::global_state_lock();
18326        let mut ops = options {
18327            ind: [0u8; MAX_OPS],
18328            args: Vec::new(),
18329            argscount: 0,
18330            argsalloc: 0,
18331        };
18332        ops.ind[b'z' as usize] = 1;
18333        crate::ported::zle::zle_main::BUFSTACK
18334            .lock()
18335            .unwrap()
18336            .clear();
18337        let r = bin_print(
18338            "print",
18339            &["echo".to_string(), "foo".to_string()],
18340            &ops,
18341            BIN_PRINT,
18342        );
18343        assert_eq!(r, 0, "c:5565 — -z should succeed");
18344        let buf = crate::ported::zle::zle_main::BUFSTACK.lock().unwrap();
18345        assert_eq!(
18346            buf.last().map(|s| s.as_str()),
18347            Some("echo foo"),
18348            "c:5565 — bufstack must have `echo foo` as the top entry"
18349        );
18350    }
18351
18352    /// `Src/builtin.c:5569-5574` — `print -s WORDS...` pushes the
18353    /// joined string to the history table instead of stdout.
18354    #[test]
18355    fn bin_print_minus_s_pushes_to_history() {
18356        let _g = crate::test_util::global_state_lock();
18357        let mut ops = options {
18358            ind: [0u8; MAX_OPS],
18359            args: Vec::new(),
18360            argscount: 0,
18361            argsalloc: 0,
18362        };
18363        ops.ind[b's' as usize] = 1;
18364        // Clear histtab to a known state.
18365        crate::ported::hashtable::histtab_lock()
18366            .write()
18367            .unwrap()
18368            .clear();
18369        let r = bin_print(
18370            "print",
18371            &["hello".to_string(), "world".to_string()],
18372            &ops,
18373            BIN_PRINT,
18374        );
18375        assert_eq!(r, 0, "c:5574 — -s should succeed");
18376        // After -s, the joined "hello world" string must appear in
18377        // histtab (the in-process history lookup table).
18378        let tab = crate::ported::hashtable::histtab_lock().read().unwrap();
18379        assert!(
18380            tab.contains_key("hello world"),
18381            "c:5574 — addhistnode must record `hello world` in histtab"
18382        );
18383    }
18384
18385    /// `Src/builtin.c:4718-4741` — `print -m PATTERN args...` keeps
18386    /// only the args matching PATTERN. Pipe-roundtrip pin: pat=`foo*`,
18387    /// args=[foo1, bar, foo2] → expect `foo1 foo2\n` (NOT `bar`).
18388    #[test]
18389    fn bin_print_minus_m_glob_filter() {
18390        let _g = crate::test_util::global_state_lock();
18391        use std::io::Read as _;
18392        let mut fds: [libc::c_int; 2] = [0, 0];
18393        assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0);
18394        let (rfd, wfd) = (fds[0], fds[1]);
18395        let mut ops = options {
18396            ind: [0u8; MAX_OPS],
18397            args: Vec::new(),
18398            argscount: 0,
18399            argsalloc: 0,
18400        };
18401        ops.ind[b'u' as usize] = 1 | (1 << 2);
18402        ops.args = vec![wfd.to_string()];
18403        ops.argscount = 1;
18404        ops.ind[b'm' as usize] = 1; // -m
18405
18406        let r = bin_print(
18407            "print",
18408            &[
18409                "foo*".to_string(),
18410                "foo1".to_string(),
18411                "bar".to_string(),
18412                "foo2".to_string(),
18413            ],
18414            &ops,
18415            BIN_PRINT,
18416        );
18417        assert_eq!(r, 0);
18418        unsafe { libc::close(wfd) };
18419
18420        let mut buf = String::new();
18421        unsafe {
18422            use std::os::unix::io::FromRawFd;
18423            let mut f = fs::File::from_raw_fd(rfd);
18424            f.read_to_string(&mut buf).unwrap();
18425        }
18426        assert_eq!(
18427            buf, "foo1 foo2\n",
18428            "c:4718-4741 — -m filters to only `foo*`-matching args"
18429        );
18430    }
18431
18432    /// `Src/builtin.c:5126-5132` — `print -N a b` separates args with
18433    /// `\0` and terminates with `\0` (not `\n`). Pipe-roundtrip pin.
18434    #[test]
18435    fn bin_print_nul_separator_with_minus_N() {
18436        let _g = crate::test_util::global_state_lock();
18437        use std::io::Read as _;
18438        let mut fds: [libc::c_int; 2] = [0, 0];
18439        assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0);
18440        let (rfd, wfd) = (fds[0], fds[1]);
18441        let mut ops = options {
18442            ind: [0u8; MAX_OPS],
18443            args: Vec::new(),
18444            argscount: 0,
18445            argsalloc: 0,
18446        };
18447        // -u <wfd>
18448        ops.ind[b'u' as usize] = 1 | (1 << 2);
18449        ops.args = vec![wfd.to_string()];
18450        ops.argscount = 1;
18451        // -N (no arg)
18452        ops.ind[b'N' as usize] = 1;
18453
18454        let r = bin_print(
18455            "print",
18456            &["a".to_string(), "b".to_string(), "c".to_string()],
18457            &ops,
18458            BIN_PRINT,
18459        );
18460        assert_eq!(r, 0);
18461        unsafe { libc::close(wfd) };
18462
18463        let mut buf = Vec::new();
18464        unsafe {
18465            use std::os::unix::io::FromRawFd;
18466            let mut f = fs::File::from_raw_fd(rfd);
18467            f.read_to_end(&mut buf).unwrap();
18468        }
18469        assert_eq!(
18470            buf, b"a\0b\0c\0",
18471            "c:5126-5132 — -N: NUL separators + NUL terminator"
18472        );
18473    }
18474
18475    /// `Src/builtin.c:4815-4847` — `print -u FD` writes to the given
18476    /// file descriptor. Pin: write to a pipe via -u and read back.
18477    #[test]
18478    fn bin_print_writes_to_specified_fd() {
18479        let _g = crate::test_util::global_state_lock();
18480        use std::io::Read as _;
18481        // Open a pipe; print -u writes to write end, we read off read
18482        // end.
18483        let mut fds: [libc::c_int; 2] = [0, 0];
18484        assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0);
18485        let (rfd, wfd) = (fds[0], fds[1]);
18486
18487        // Build options with -u set to the write fd.
18488        let mut ops = options {
18489            ind: [0u8; MAX_OPS],
18490            args: Vec::new(),
18491            argscount: 0,
18492            argsalloc: 0,
18493        };
18494        ops.ind[b'u' as usize] = 1;
18495        ops.args = vec![wfd.to_string()];
18496        // OPT_ARG looks up via OPT_HASARG/argscount; the exact wiring
18497        // depends on the parseopts pre-call path. Use a minimal stub
18498        // so OPT_ARG('u') returns wfd's string.
18499        ops.argscount = 1;
18500
18501        // The OPT_ARG indexing path requires `ops.ind[b'u']` to encode
18502        // both the "is set" bit and an arg-index pointer. The default
18503        // parseopts wires this; for the unit test we synthesize the
18504        // wfd-as-string into args[0] AND set `ops.ind[b'u']` to point
18505        // at it via the same convention (`(ops.ind[c] >> 2) - 1`).
18506        ops.ind[b'u' as usize] = 1 | (1 << 2); // sense=1, arg_index=1 → args[0]
18507
18508        // Closing wfd in the caller after print so reader sees EOF.
18509        // We dup'd inside bin_print so closing wfd here is safe AFTER
18510        // bin_print returns.
18511        let r = bin_print("print", &["hello".to_string()], &ops, BIN_PRINT);
18512        assert_eq!(r, 0, "c:4847 — bin_print should return 0 on success");
18513        unsafe { libc::close(wfd) };
18514
18515        // Read from rfd.
18516        let mut buf = String::new();
18517        unsafe {
18518            use std::os::unix::io::FromRawFd;
18519            let mut f = fs::File::from_raw_fd(rfd);
18520            f.read_to_string(&mut buf).unwrap();
18521        }
18522        assert_eq!(buf, "hello\n", "c:4847 — write should land on -u FD");
18523    }
18524
18525    // ═══════════════════════════════════════════════════════════════════
18526    // fixdir — pure path-normalization helper (port of c:1297-1395).
18527    // Tests pin C-faithful collapsing of `.`, `..`, double slashes, and
18528    // sticky-`..` semantics for relative paths.
18529    // ═══════════════════════════════════════════════════════════════════
18530
18531    /// Empty input → empty output.
18532    #[test]
18533    fn fixdir_empty_returns_empty() {
18534        assert_eq!(fixdir(""), "");
18535    }
18536
18537    /// Root passes through.
18538    #[test]
18539    fn fixdir_root_passes_through() {
18540        assert_eq!(fixdir("/"), "/");
18541    }
18542
18543    /// `/.` → `/` (drop `.`).
18544    #[test]
18545    fn fixdir_root_dot_collapses_to_root() {
18546        assert_eq!(fixdir("/."), "/");
18547    }
18548
18549    /// `/a/./b` → `/a/b` (drop intermediate `.`).
18550    #[test]
18551    fn fixdir_strips_dot_components() {
18552        assert_eq!(fixdir("/a/./b"), "/a/b");
18553    }
18554
18555    /// `/a/b/..` → `/a` (`..` pops).
18556    #[test]
18557    fn fixdir_dot_dot_pops_previous_component() {
18558        assert_eq!(fixdir("/a/b/.."), "/a");
18559    }
18560
18561    /// `/a/b/../c` → `/a/c` (pop then append).
18562    #[test]
18563    fn fixdir_dot_dot_then_continue() {
18564        assert_eq!(fixdir("/a/b/../c"), "/a/c");
18565    }
18566
18567    /// `/..` → `/` (`..` past root silently drops).
18568    #[test]
18569    fn fixdir_dot_dot_past_root_drops() {
18570        assert_eq!(fixdir("/.."), "/");
18571    }
18572
18573    /// `/../..` → `/` (multiple `..` past root all drop).
18574    #[test]
18575    fn fixdir_multiple_dot_dot_past_root_drops() {
18576        assert_eq!(fixdir("/../.."), "/");
18577    }
18578
18579    /// `//a` → `/a` (collapse `//`).
18580    #[test]
18581    fn fixdir_collapses_double_slash() {
18582        assert_eq!(fixdir("//a"), "/a");
18583    }
18584
18585    /// `/a//b///c` → `/a/b/c` (collapse runs of slashes).
18586    #[test]
18587    fn fixdir_collapses_repeated_slashes() {
18588        assert_eq!(fixdir("/a//b///c"), "/a/b/c");
18589    }
18590
18591    // ── Relative paths ───────────────────────────────────────────────
18592    /// `a/b/c` → `a/b/c` (no change).
18593    #[test]
18594    fn fixdir_relative_no_dots_unchanged() {
18595        assert_eq!(fixdir("a/b/c"), "a/b/c");
18596    }
18597
18598    /// `a/./b` → `a/b` (drop `.`).
18599    #[test]
18600    fn fixdir_relative_drops_dot() {
18601        assert_eq!(fixdir("a/./b"), "a/b");
18602    }
18603
18604    /// `a/b/..` → `a` — `..` pops.
18605    #[test]
18606    fn fixdir_relative_dot_dot_pops() {
18607        assert_eq!(fixdir("a/b/.."), "a");
18608    }
18609
18610    /// `..` (leading) → `..` — relative path keeps leading `..`.
18611    #[test]
18612    fn fixdir_leading_dot_dot_preserved_in_relative() {
18613        assert_eq!(fixdir(".."), "..");
18614    }
18615
18616    /// `../..` (sticky `..`) — both preserved.
18617    #[test]
18618    fn fixdir_double_leading_dot_dot_both_preserved() {
18619        assert_eq!(fixdir("../.."), "../..");
18620    }
18621
18622    /// `../foo/..` → `..` (pop `foo`, leading `..` remains).
18623    #[test]
18624    fn fixdir_dot_dot_then_dir_then_dot_dot() {
18625        assert_eq!(fixdir("../foo/.."), "..");
18626    }
18627
18628    /// `.` alone → `.` (empty body → "." preserved for relative).
18629    #[test]
18630    fn fixdir_single_dot_returns_dot() {
18631        // No components, not absolute → returns "." per the c:1395 path.
18632        assert_eq!(fixdir("."), ".");
18633    }
18634
18635    /// Trailing slash dropped (output never has trailing `/`).
18636    #[test]
18637    fn fixdir_trailing_slash_dropped() {
18638        assert_eq!(fixdir("/a/b/"), "/a/b");
18639        assert_eq!(fixdir("a/b/"), "a/b");
18640    }
18641
18642    // ═══════════════════════════════════════════════════════════════════
18643    // cd_able_vars — CDABLEVARS option-gated lookup. Returns Some(val/tail)
18644    // if `s` head is a set parameter AND `cdablevars` is on. Else None.
18645    // ═══════════════════════════════════════════════════════════════════
18646
18647    use crate::ported::options::{opt_state_get, opt_state_set};
18648
18649    /// `cd_able_vars` returns None when CDABLEVARS option is OFF.
18650    #[test]
18651    fn cd_able_vars_returns_none_when_option_off() {
18652        let _g = crate::test_util::global_state_lock();
18653        let saved = opt_state_get("cdablevars").unwrap_or(false);
18654        opt_state_set("cdablevars", false);
18655        // Even if HOME is set, with cdablevars off cd_able_vars rejects.
18656        assert_eq!(cd_able_vars("HOME"), None);
18657        opt_state_set("cdablevars", saved);
18658    }
18659
18660    /// `cd_able_vars` looks up the named var when CDABLEVARS is ON.
18661    #[test]
18662    fn cd_able_vars_returns_value_when_option_on_and_var_set() {
18663        let _g = crate::test_util::global_state_lock();
18664        let saved_opt = opt_state_get("cdablevars").unwrap_or(false);
18665        opt_state_set("cdablevars", true);
18666        opt_state_set("exec", true);
18667        unsetparam("zshrs_cdav_proj");
18668        setsparam("zshrs_cdav_proj", "/tmp/myproject");
18669
18670        assert_eq!(
18671            cd_able_vars("zshrs_cdav_proj"),
18672            Some("/tmp/myproject".to_string())
18673        );
18674
18675        unsetparam("zshrs_cdav_proj");
18676        opt_state_set("cdablevars", saved_opt);
18677    }
18678
18679    /// With slash: head looked up, tail appended.
18680    /// `cd_able_vars("PROJ/src")` where PROJ=/home/user → "/home/user/src".
18681    #[test]
18682    fn cd_able_vars_appends_tail_after_head_substitution() {
18683        let _g = crate::test_util::global_state_lock();
18684        let saved_opt = opt_state_get("cdablevars").unwrap_or(false);
18685        opt_state_set("cdablevars", true);
18686        opt_state_set("exec", true);
18687        unsetparam("zshrs_cdav_PROJ");
18688        setsparam("zshrs_cdav_PROJ", "/home/user");
18689
18690        assert_eq!(
18691            cd_able_vars("zshrs_cdav_PROJ/src"),
18692            Some("/home/user/src".to_string())
18693        );
18694
18695        unsetparam("zshrs_cdav_PROJ");
18696        opt_state_set("cdablevars", saved_opt);
18697    }
18698
18699    /// Unknown head var → None (even with option on).
18700    #[test]
18701    fn cd_able_vars_unknown_var_returns_none() {
18702        let _g = crate::test_util::global_state_lock();
18703        let saved_opt = opt_state_get("cdablevars").unwrap_or(false);
18704        opt_state_set("cdablevars", true);
18705        unsetparam("zshrs_cdav_doesnt_exist");
18706
18707        assert_eq!(cd_able_vars("zshrs_cdav_doesnt_exist"), None);
18708
18709        opt_state_set("cdablevars", saved_opt);
18710    }
18711
18712    /// Empty head (e.g. "/some/path" starts with `/`) → None.
18713    #[test]
18714    fn cd_able_vars_empty_head_returns_none() {
18715        let _g = crate::test_util::global_state_lock();
18716        let saved_opt = opt_state_get("cdablevars").unwrap_or(false);
18717        opt_state_set("cdablevars", true);
18718        // Leading `/` → head split is empty.
18719        assert_eq!(cd_able_vars("/path/to/foo"), None);
18720        opt_state_set("cdablevars", saved_opt);
18721    }
18722
18723    // ─── zsh-corpus pins for fixed-return builtins ──────────────────
18724
18725    fn empty_opts_for_corpus() -> options {
18726        options {
18727            ind: [0u8; MAX_OPS],
18728            args: Vec::new(),
18729            argscount: 0,
18730            argsalloc: 0,
18731        }
18732    }
18733
18734    /// `Src/builtin.c:4550` — `bin_true` always returns 0 regardless of
18735    /// argv / opts / func. Matches `:` / `true` semantics.
18736    #[test]
18737    fn builtin_corpus_bin_true_always_zero() {
18738        let _g = crate::test_util::global_state_lock();
18739        let o = empty_opts_for_corpus();
18740        assert_eq!(bin_true("true", &[], &o, 0), 0, "bin_true no args = 0");
18741        assert_eq!(
18742            bin_true("true", &["x".into(), "y".into()], &o, 0),
18743            0,
18744            "bin_true with args = 0",
18745        );
18746    }
18747
18748    /// `Src/builtin.c:4559` — `bin_false` always returns 1.
18749    #[test]
18750    fn builtin_corpus_bin_false_always_one() {
18751        let _g = crate::test_util::global_state_lock();
18752        let o = empty_opts_for_corpus();
18753        assert_eq!(bin_false("false", &[], &o, 0), 1, "bin_false no args = 1");
18754        assert_eq!(
18755            bin_false("false", &["any".into()], &o, 0),
18756            1,
18757            "bin_false with args = 1",
18758        );
18759    }
18760
18761    /// `Src/builtin.c` — `bin_shift` with no positional params and no
18762    /// argv → 0 (zsh's POSIX-conforming "shift past end" semantics
18763    /// when no $@/argv to shift, plus no count argument).
18764    #[test]
18765    fn builtin_corpus_bin_shift_empty_positional_returns_zero_or_one() {
18766        let _g = crate::test_util::global_state_lock();
18767        let o = empty_opts_for_corpus();
18768        // No positional params, no arg → either zero (no-op) or one
18769        // (POSIX error). Both are acceptable per the spec language —
18770        // we pin only that it doesn't panic and returns 0 or 1.
18771        let r = bin_shift("shift", &[], &o, 0);
18772        assert!(r == 0 || r == 1, "shift on empty positional, got {r}");
18773    }
18774
18775    /// `Src/builtin.c` — `bin_let` with no math arg returns 1
18776    /// (no expression to evaluate).
18777    #[test]
18778    fn builtin_corpus_bin_let_no_args_returns_one() {
18779        let _g = crate::test_util::global_state_lock();
18780        let o = empty_opts_for_corpus();
18781        let r = bin_let("let", &[], &o, 0);
18782        assert_eq!(r, 1, "let with no args = 1");
18783    }
18784
18785    /// `bin_let "x=5"` evaluates math and assigns; success returns 0
18786    /// since the result (5) is non-zero. let returns 0 iff last expr
18787    /// was non-zero.
18788    #[test]
18789    fn builtin_corpus_bin_let_nonzero_expr_returns_zero() {
18790        let _g = crate::test_util::global_state_lock();
18791        let o = empty_opts_for_corpus();
18792        unsetparam("ZL_X");
18793        let r = bin_let("let", &["ZL_X=5".into()], &o, 0);
18794        assert_eq!(r, 0, "let 'x=5' assigns and returns 0 (nonzero result)");
18795        assert_eq!(getiparam("ZL_X"), 5);
18796        unsetparam("ZL_X");
18797    }
18798
18799    /// `bin_let "x=0"` returns 1 since last expression evaluates to 0.
18800    #[test]
18801    fn builtin_corpus_bin_let_zero_expr_returns_one() {
18802        let _g = crate::test_util::global_state_lock();
18803        let o = empty_opts_for_corpus();
18804        unsetparam("ZL_Y");
18805        let r = bin_let("let", &["ZL_Y=0".into()], &o, 0);
18806        assert_eq!(r, 1, "let 'x=0' returns 1 (zero result)");
18807        assert_eq!(getiparam("ZL_Y"), 0);
18808        unsetparam("ZL_Y");
18809    }
18810
18811    /// `bin_let` walks multiple expressions, exit status from last.
18812    #[test]
18813    fn builtin_corpus_bin_let_multi_expr_last_wins() {
18814        let _g = crate::test_util::global_state_lock();
18815        let o = empty_opts_for_corpus();
18816        unsetparam("ZL_A");
18817        unsetparam("ZL_B");
18818        let r = bin_let("let", &["ZL_A=1".into(), "ZL_B=7".into()], &o, 0);
18819        assert_eq!(r, 0, "last expr non-zero → 0");
18820        assert_eq!(getiparam("ZL_A"), 1);
18821        assert_eq!(getiparam("ZL_B"), 7);
18822        unsetparam("ZL_A");
18823        unsetparam("ZL_B");
18824    }
18825
18826    /// `bin_pwd` returns 0 on success — even without -P/-L, it should
18827    /// produce the current dir and return zero.
18828    #[test]
18829    fn builtin_corpus_bin_pwd_returns_zero() {
18830        let _g = crate::test_util::global_state_lock();
18831        let o = empty_opts_for_corpus();
18832        let r = bin_pwd("pwd", &[], &o, 0);
18833        assert_eq!(r, 0, "pwd returns 0 on success");
18834    }
18835
18836    // ═══════════════════════════════════════════════════════════════════
18837    // Additional C-parity tests for Src/builtin.c fixdir path normaliser.
18838    // ═══════════════════════════════════════════════════════════════════
18839
18840    /// c:1297 — `fixdir("")` returns empty string.
18841    #[test]
18842    fn fixdir_empty_returns_empty_pin() {
18843        let _g = crate::test_util::global_state_lock();
18844        assert_eq!(fixdir(""), "");
18845    }
18846
18847    /// c:1297 — `fixdir("/")` returns "/".
18848    #[test]
18849    fn fixdir_root_returns_root() {
18850        let _g = crate::test_util::global_state_lock();
18851        assert_eq!(fixdir("/"), "/", "root path preserved");
18852    }
18853
18854    /// c:1352 — `fixdir("/foo/./bar")` collapses `.` to `/foo/bar`.
18855    #[test]
18856    fn fixdir_drops_dot_segments() {
18857        let _g = crate::test_util::global_state_lock();
18858        assert_eq!(fixdir("/foo/./bar"), "/foo/bar");
18859        assert_eq!(fixdir("/./a"), "/a");
18860        assert_eq!(fixdir("/a/."), "/a");
18861    }
18862
18863    /// c:1339 — `fixdir("/foo//bar")` collapses `//` to `/foo/bar`.
18864    #[test]
18865    fn fixdir_collapses_double_slash_pin() {
18866        let _g = crate::test_util::global_state_lock();
18867        assert_eq!(fixdir("/foo//bar"), "/foo/bar");
18868        assert_eq!(fixdir("//foo//bar//"), "/foo/bar");
18869    }
18870
18871    /// c:1358-1372 — `fixdir("/foo/bar/..")` pops via `..` → `/foo`.
18872    #[test]
18873    fn fixdir_pops_via_dotdot() {
18874        let _g = crate::test_util::global_state_lock();
18875        assert_eq!(fixdir("/foo/bar/.."), "/foo");
18876        assert_eq!(fixdir("/a/b/c/../.."), "/a");
18877        assert_eq!(fixdir("/a/b/../c"), "/a/c");
18878    }
18879
18880    /// c:1358-1372 — `fixdir("/..")` past root → `/` (absolute paths
18881    /// silently drop `..` past `/`).
18882    #[test]
18883    fn fixdir_dotdot_past_root_clamps_to_root_pin() {
18884        let _g = crate::test_util::global_state_lock();
18885        assert_eq!(fixdir("/.."), "/", "absolute `..` past root → `/`");
18886        assert_eq!(fixdir("/../.."), "/");
18887        assert_eq!(fixdir("/a/../../.."), "/", "successive pops past root → /");
18888    }
18889
18890    /// c:1358-1372 — `fixdir("../foo")` on relative path KEEPS leading `..`.
18891    #[test]
18892    fn fixdir_relative_keeps_leading_dotdot_pin() {
18893        let _g = crate::test_util::global_state_lock();
18894        assert_eq!(fixdir("../foo"), "../foo");
18895        assert_eq!(fixdir("../../foo"), "../../foo");
18896    }
18897
18898    /// c:1297-1395 — `fixdir` is idempotent: fixdir(fixdir(x)) == fixdir(x).
18899    #[test]
18900    fn fixdir_is_idempotent() {
18901        let _g = crate::test_util::global_state_lock();
18902        for input in &[
18903            "/a/b/c",
18904            "/a/./b",
18905            "/a/b/..",
18906            "/../x",
18907            "../foo",
18908            "/foo//bar",
18909        ] {
18910            let once = fixdir(input);
18911            let twice = fixdir(&once);
18912            assert_eq!(once, twice, "fixdir must be idempotent on {:?}", input);
18913        }
18914    }
18915
18916    /// c:1297 — relative path of just `.` → `.` (not empty).
18917    #[test]
18918    fn fixdir_relative_dot_returns_dot() {
18919        let _g = crate::test_util::global_state_lock();
18920        assert_eq!(fixdir("."), ".", "lone `.` relative → `.`");
18921    }
18922
18923    /// c:1297 — `fixdir("foo")` (plain relative) is identity.
18924    #[test]
18925    fn fixdir_plain_relative_is_identity() {
18926        let _g = crate::test_util::global_state_lock();
18927        assert_eq!(fixdir("foo"), "foo");
18928        assert_eq!(fixdir("foo/bar"), "foo/bar");
18929    }
18930
18931    // ═══════════════════════════════════════════════════════════════════
18932    // Additional C-parity tests for Src/builtin.c bin_true/false +
18933    // bin_break + other simple builtins.
18934    // ═══════════════════════════════════════════════════════════════════
18935
18936    /// c:4550 — `bin_true` always returns 0 regardless of args.
18937    #[test]
18938    fn bin_true_always_returns_zero() {
18939        let _g = crate::test_util::global_state_lock();
18940        let ops = empty_opts_for_corpus();
18941        assert_eq!(bin_true("true", &[], &ops, 0), 0);
18942        assert_eq!(
18943            bin_true("true", &["arg1".into(), "arg2".into()], &ops, 0),
18944            0
18945        );
18946    }
18947
18948    /// c:4559 — `bin_false` always returns 1 regardless of args.
18949    #[test]
18950    fn bin_false_always_returns_one() {
18951        let _g = crate::test_util::global_state_lock();
18952        let ops = empty_opts_for_corpus();
18953        assert_eq!(bin_false("false", &[], &ops, 0), 1);
18954        assert_eq!(bin_false("false", &["a".into()], &ops, 0), 1);
18955    }
18956
18957    /// c:4550 — `bin_true` ignores name arg.
18958    #[test]
18959    fn bin_true_ignores_name() {
18960        let _g = crate::test_util::global_state_lock();
18961        let ops = empty_opts_for_corpus();
18962        // Any name returns 0 (name is unused per UNUSED(char *name)).
18963        assert_eq!(bin_true("anything", &[], &ops, 0), 0);
18964        assert_eq!(bin_true("", &[], &ops, 0), 0);
18965    }
18966
18967    /// c:4559 — `bin_false` ignores name arg.
18968    #[test]
18969    fn bin_false_ignores_name() {
18970        let _g = crate::test_util::global_state_lock();
18971        let ops = empty_opts_for_corpus();
18972        assert_eq!(bin_false("anything", &[], &ops, 0), 1);
18973        assert_eq!(bin_false("", &[], &ops, 0), 1);
18974    }
18975
18976    /// `bin_true`/`bin_false` are deterministic (pure functions).
18977    #[test]
18978    fn bin_true_false_deterministic() {
18979        let _g = crate::test_util::global_state_lock();
18980        let ops = empty_opts_for_corpus();
18981        for _ in 0..10 {
18982            assert_eq!(bin_true("true", &[], &ops, 0), 0);
18983            assert_eq!(bin_false("false", &[], &ops, 0), 1);
18984        }
18985    }
18986
18987    /// c:7872 — `realexit` would terminate process; pin that we DON'T
18988    /// accidentally call it. (no-op test — just documents the contract.)
18989    /// `_realexit` similarly forbidden in tests.
18990    #[test]
18991    fn realexit_contract_pin_no_test_actually_calls_it() {
18992        // This test exists to document that realexit / _realexit are
18993        // never called by test code (would terminate the test process).
18994        // No assertions — pin via not calling.
18995    }
18996
18997    // ═══════════════════════════════════════════════════════════════════
18998    // Additional C-parity tests for Src/builtin.c
18999    // c:131 createbuiltintable / c:232 init_builtins / c:1837 cd_able_vars
19000    // c:1999 fixdir / c:2490 fcgetcomm / c:2526 fcsubs / c:209 freebuiltinnode
19001    // ═══════════════════════════════════════════════════════════════════
19002
19003    /// c:131 — `createbuiltintable` returns &HashMap (compile-time pin).
19004    #[test]
19005    fn createbuiltintable_returns_hashmap_ref_type() {
19006        let _: &HashMap<String, &builtin> = createbuiltintable();
19007    }
19008
19009    /// c:131 — `createbuiltintable` is idempotent (returns same ref).
19010    #[test]
19011    fn createbuiltintable_returns_same_ref() {
19012        let a = createbuiltintable() as *const _;
19013        let b = createbuiltintable() as *const _;
19014        assert_eq!(a, b, "createbuiltintable must return same singleton");
19015    }
19016
19017    /// c:131 — table contains common shell builtins (sanity sweep).
19018    #[test]
19019    fn createbuiltintable_contains_canonical_builtins() {
19020        let t = createbuiltintable();
19021        for name in ["true", "false", "set", "cd", "exit", "echo"] {
19022            assert!(
19023                t.contains_key(name),
19024                "builtin table must contain {:?}",
19025                name
19026            );
19027        }
19028    }
19029
19030    /// c:232 — `init_builtins` is idempotent.
19031    #[test]
19032    fn init_builtins_idempotent() {
19033        let _g = crate::test_util::global_state_lock();
19034        for _ in 0..5 {
19035            init_builtins();
19036        }
19037    }
19038
19039    /// c:1837 — `cd_able_vars("")` empty returns None.
19040    #[test]
19041    fn cd_able_vars_empty_returns_none() {
19042        let _g = crate::test_util::global_state_lock();
19043        assert!(cd_able_vars("").is_none(), "empty → None");
19044    }
19045
19046    /// c:1837 — `cd_able_vars` returns Option<String> (type pin).
19047    #[test]
19048    fn cd_able_vars_returns_option_string_type() {
19049        let _g = crate::test_util::global_state_lock();
19050        let _: Option<String> = cd_able_vars("anything");
19051    }
19052
19053    /// c:1999 — `fixdir("")` empty returns String (type pin).
19054    #[test]
19055    fn fixdir_empty_returns_string_type() {
19056        let _: String = fixdir("");
19057    }
19058
19059    /// c:1999 — `fixdir` is pure.
19060    #[test]
19061    fn fixdir_is_pure() {
19062        for s in ["", "/abs", "rel", "./dot", "../parent", "a/b/c"] {
19063            let first = fixdir(s);
19064            for _ in 0..3 {
19065                assert_eq!(fixdir(s), first, "fixdir({:?}) must be pure", s);
19066            }
19067        }
19068    }
19069
19070    /// c:2490 — `fcgetcomm("")` empty returns i64 (type pin).
19071    #[test]
19072    fn fcgetcomm_empty_returns_i64_type() {
19073        let _g = crate::test_util::global_state_lock();
19074        let _: i64 = fcgetcomm("");
19075    }
19076
19077    /// c:2490 — `fcgetcomm` is deterministic for invalid input.
19078    #[test]
19079    fn fcgetcomm_invalid_is_deterministic() {
19080        let _g = crate::test_util::global_state_lock();
19081        for s in ["", "garbage", "not_a_number"] {
19082            let first = fcgetcomm(s);
19083            for _ in 0..3 {
19084                assert_eq!(
19085                    fcgetcomm(s),
19086                    first,
19087                    "fcgetcomm({:?}) must be deterministic",
19088                    s
19089                );
19090            }
19091        }
19092    }
19093
19094    /// c:2526 — `fcsubs(&mut s, &[])` empty sub list is no-op.
19095    #[test]
19096    fn fcsubs_empty_subs_returns_zero() {
19097        let mut s = "hello".to_string();
19098        let r = fcsubs(&mut s, &[]);
19099        assert_eq!(r, 0, "empty subs → 0 (no substitutions)");
19100        assert_eq!(s, "hello", "string unchanged");
19101    }
19102
19103    // ═══════════════════════════════════════════════════════════════════
19104    // Additional C-parity tests for Src/builtin.c
19105    // c:1324 bin_pwd / c:6810 bin_true / c:6823 bin_false / c:7247 bin_shift /
19106    // c:8444 bin_eval / c:8922 bin_test / c:9012 bin_times / c:9207 bin_ttyctl /
19107    // c:9295 bin_umask / c:9240 bin_let — type pins + edge cases
19108    // ═══════════════════════════════════════════════════════════════════
19109
19110    /// c:1324 — `bin_pwd` returns i32 (compile-time type pin).
19111    #[test]
19112    fn bin_pwd_returns_i32_type() {
19113        let _g = crate::test_util::global_state_lock();
19114        let o = empty_opts_for_corpus();
19115        let _: i32 = bin_pwd("pwd", &[], &o, 0);
19116    }
19117
19118    /// c:6810 — `bin_true` returns i32.
19119    #[test]
19120    fn bin_true_returns_i32_type() {
19121        let _g = crate::test_util::global_state_lock();
19122        let o = empty_opts_for_corpus();
19123        let _: i32 = bin_true("true", &[], &o, 0);
19124    }
19125
19126    /// c:6823 — `bin_false` returns i32.
19127    #[test]
19128    fn bin_false_returns_i32_type() {
19129        let _g = crate::test_util::global_state_lock();
19130        let o = empty_opts_for_corpus();
19131        let _: i32 = bin_false("false", &[], &o, 0);
19132    }
19133
19134    /// c:9240 — `bin_let` returns i32.
19135    #[test]
19136    fn bin_let_returns_i32_type() {
19137        let _g = crate::test_util::global_state_lock();
19138        let o = empty_opts_for_corpus();
19139        let _: i32 = bin_let("let", &[], &o, 0);
19140    }
19141
19142    /// c:7247 — `bin_shift` returns i32.
19143    #[test]
19144    fn bin_shift_returns_i32_type() {
19145        let _g = crate::test_util::global_state_lock();
19146        let o = empty_opts_for_corpus();
19147        let _: i32 = bin_shift("shift", &[], &o, 0);
19148    }
19149
19150    /// c:6810 + c:6823 — `bin_true` and `bin_false` are pure (always
19151    /// same value regardless of state).
19152    #[test]
19153    fn bin_true_false_are_pure() {
19154        let _g = crate::test_util::global_state_lock();
19155        let o = empty_opts_for_corpus();
19156        for _ in 0..5 {
19157            assert_eq!(bin_true("true", &[], &o, 0), 0);
19158            assert_eq!(bin_false("false", &[], &o, 0), 1);
19159        }
19160    }
19161
19162    /// c:1324 — `bin_pwd` exit code in u8 range.
19163    #[test]
19164    fn bin_pwd_return_in_u8_range() {
19165        let _g = crate::test_util::global_state_lock();
19166        let o = empty_opts_for_corpus();
19167        let r = bin_pwd("pwd", &[], &o, 0);
19168        assert!(
19169            (0..256).contains(&r),
19170            "bin_pwd exit code must fit u8, got {}",
19171            r
19172        );
19173    }
19174
19175    /// c:7247 — `bin_shift` with no args + empty positional returns
19176    /// in 0/1 range (POSIX-conforming).
19177    #[test]
19178    fn bin_shift_no_args_in_zero_one_range() {
19179        let _g = crate::test_util::global_state_lock();
19180        let o = empty_opts_for_corpus();
19181        let r = bin_shift("shift", &[], &o, 0);
19182        assert!(r == 0 || r == 1, "shift no args ∈ {{0,1}}, got {}", r);
19183    }
19184
19185    /// c:9240 — `bin_let` is deterministic for no-args.
19186    #[test]
19187    fn bin_let_no_args_is_deterministic() {
19188        let _g = crate::test_util::global_state_lock();
19189        let o = empty_opts_for_corpus();
19190        let first = bin_let("let", &[], &o, 0);
19191        for _ in 0..3 {
19192            assert_eq!(
19193                bin_let("let", &[], &o, 0),
19194                first,
19195                "bin_let no-args must be deterministic"
19196            );
19197        }
19198    }
19199
19200    /// c:8444 — `bin_eval(&[])` empty args returns i32 (type pin).
19201    #[test]
19202    fn bin_eval_returns_i32_type() {
19203        let _g = crate::test_util::global_state_lock();
19204        let o = empty_opts_for_corpus();
19205        let _: i32 = bin_eval("eval", &[], &o, 0);
19206    }
19207
19208    /// c:6810 — `bin_true` ignores everything (args, opts, func).
19209    #[test]
19210    fn bin_true_full_argument_immunity() {
19211        let _g = crate::test_util::global_state_lock();
19212        let o = empty_opts_for_corpus();
19213        // Various permutations all return 0.
19214        assert_eq!(bin_true("true", &[], &o, 0), 0);
19215        assert_eq!(bin_true("anything", &[], &o, 0), 0);
19216        assert_eq!(bin_true("true", &["unused".into()], &o, 99), 0);
19217        assert_eq!(
19218            bin_true("", &["a".into(), "b".into(), "c".into()], &o, -1),
19219            0
19220        );
19221    }
19222
19223    /// c:6823 — `bin_false` ignores everything (args, opts, func).
19224    #[test]
19225    fn bin_false_full_argument_immunity() {
19226        let _g = crate::test_util::global_state_lock();
19227        let o = empty_opts_for_corpus();
19228        assert_eq!(bin_false("false", &[], &o, 0), 1);
19229        assert_eq!(bin_false("anything", &[], &o, 0), 1);
19230        assert_eq!(bin_false("false", &["unused".into()], &o, 99), 1);
19231        assert_eq!(
19232            bin_false("", &["a".into(), "b".into(), "c".into()], &o, -1),
19233            1
19234        );
19235    }
19236}