Skip to main content

zsh/ported/
params.rs

1//! Parameter management for zshrs
2//!
3//! Port from zsh/Src/params.c (6511 lines → full Rust port)
4//!
5//! Provides shell parameters (variables), special parameters, arrays,
6//! associative arrays, parameter attributes, namerefs, scoping,
7//! tied parameters, and all special parameter get/set functions.
8
9use crate::config_h::DEFAULT_TMPPREFIX;
10use crate::func_body_fmt::FuncBodyFmt;
11use crate::lex::parse_subscript;
12use crate::ported::builtin::{LASTVAL, PPARAMS};
13use crate::ported::config_h::{MACHTYPE, OSTYPE, VENDOR};
14use crate::ported::exec::FORKLEVEL;
15use crate::ported::hashtable::emptycmdnamtable;
16use crate::ported::hashtable::hashtable_nodes;
17use crate::ported::hist::{
18    bangchar, casemodify, hashchar, hatchar, histsiz, resizehistents, saveandpophiststack,
19    savehistsiz,
20};
21use crate::ported::init::SHTTY;
22use crate::ported::lex::untokenize;
23use crate::ported::math::lastbase;
24#[allow(unused_imports)]
25use crate::ported::math::{
26    matheval, mathevali, MN_FLOAT, MN_FLOAT as MN_FLT, MN_INTEGER, MN_INTEGER as MN_INT,
27};
28use crate::ported::mem::{popheap, pushheap};
29use crate::ported::modules::parameter::FUNCSTACK;
30#[allow(unused_imports)]
31use crate::ported::options::{opt_state_get, opt_state_set, optlookup};
32use crate::ported::patchlevel::{ZSH_PATCHLEVEL, ZSH_VERSION};
33use crate::ported::pattern::{patcompile, pattry};
34#[allow(unused_imports)]
35use crate::ported::signals::{queue_signals, unqueue_signals};
36use crate::ported::signals_h::SIGS;
37use crate::ported::string::ztrdup;
38use crate::ported::utils::{
39    adduserdir, arrlen_ge, dec_locallevel, inc_locallevel, metafy, quotedzputs, xsymlink,
40};
41#[allow(unused_imports)]
42use crate::ported::utils::{
43    adjustwinsize, argzero, colonsplit, errflag, get_username, inittyptab, itype_end,
44    locallevel as locallevel_fn, posixzero, set_argzero, set_locallevel, set_posixzero, unmeta,
45    zerr, ztrdup_metafy, zwarn,
46};
47use crate::ported::zsh_h::PAT_HEAPDUP;
48#[allow(unused_imports)]
49use crate::ported::zsh_h::{
50    gsu_array, gsu_float, gsu_hash, gsu_integer, gsu_scalar, hashnode, hashtable, isset, mnumber,
51    param, paramdef, unset, value, HashTable, Marker, Param, ALLEXPORT, ASSPM_AUGMENT,
52    ASSPM_ENV_IMPORT, ASSPM_KEY_VALUE, ASSPM_WARN, AUTONAMEDIRS, EMULATE_KSH, EMULATE_SH,
53    EMULATE_ZSH, EMULATION, ERRFLAG_ERROR, EXECOPT, FS_FUNC, KSHARRAYS, PM_ARRAY, PM_AUTOLOAD,
54    PM_DECLARED, PM_DEFAULTED, PM_DONTIMPORT, PM_DONTIMPORT_SUID, PM_EFLOAT, PM_EXPORTED,
55    PM_FFLOAT, PM_HASHED, PM_HASHELEM, PM_HIDE, PM_HIDEVAL, PM_INTEGER, PM_LEFT, PM_LOCAL,
56    PM_NAMEDDIR, PM_NAMEREF, PM_NORESTORE, PM_READONLY, PM_READONLY_SPECIAL, PM_REMOVABLE,
57    PM_RIGHT_B, PM_RIGHT_Z, PM_RO_BY_DESIGN, PM_SCALAR, PM_SPECIAL, PM_TAGGED, PM_TIED, PM_TYPE,
58    PM_UNIQUE, PM_UNSET, PM_UPPER, POSIXARGZERO, PRINT_INCLUDEVALUE, PRINT_KV_PAIR, PRINT_LINE,
59    PRINT_NAMEONLY, PRINT_POSIX_EXPORT, PRINT_POSIX_READONLY, PRINT_TYPE, PRINT_TYPESET,
60    SCANPM_ARRONLY, SCANPM_CHECKING, SCANPM_ISVAR_AT, SCANPM_KEYMATCH, SCANPM_MATCHKEY,
61    SCANPM_MATCHMANY, SCANPM_MATCHVAL, SCANPM_NONAMEREF, SCANPM_WANTINDEX, SCANPM_WANTKEYS,
62    SCANPM_WANTVALS, TERM_BAD, TERM_UNKNOWN, VALFLAG_EMPTY, VALFLAG_INV, VALFLAG_SUBST,
63    WARNCREATEGLOBAL, WARNNESTEDVAR,
64};
65use crate::ported::zsh_h::{
66    HashNode, Inbrack, Meta, CBASES, CHASELINKS, HFILE_USE_OPTIONS, INTERACTIVE, OCTALZEROES,
67    PM_LOWER, PRIVILEGED, SCANPM_ASSIGNING,
68};
69use crate::ported::zsh_h::{CASMOD_LOWER, CASMOD_UPPER};
70use crate::ported::zsh_system_h::DEFAULT_TIMEFMT;
71use crate::{DPUTS, DPUTS2};
72use fusevm::Value;
73use indexmap::IndexMap;
74use std::collections::{HashMap, HashSet};
75use std::env;
76use std::sync::atomic::{AtomicI64, Ordering};
77use std::sync::{Arc, Mutex, OnceLock, RwLock};
78use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
79
80/// Port of `static int lc_update_needed` from `Src/params.c:5850`
81/// (under `#ifdef USE_LOCALE`). Set to 1 by `scanendscope` when a
82/// LC_*/LANG param's scope ends; consumed by `endparamscope` to
83/// trigger a `setlocale()` refresh.
84pub static LC_UPDATE_NEEDED: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
85
86/// Port of `static Param foundparam` from `Src/params.c:640`.
87/// Set by `scanparamvals` to the last param it touched, read by
88/// `assignsparam` / `assignnparam` for the assoc-element path.
89/// Stores the param name; the live `&param` lookup is done by
90/// the caller through paramtab.
91pub static FOUNDPARAM: OnceLock<Mutex<Option<String>>> = OnceLock::new();
92
93/// Port of `rprompt_indent_unsetfn()` from `Src/params.c:4237`. C
94/// body: `stdunsetfn(pm, exp); rprompt_indent = 1;` — keeps in
95/// sync with init_term().
96pub fn rprompt_indent_unsetfn(pm: &mut param, exp: i32) {
97    stdunsetfn(pm, exp);
98    *RPROMPT_INDENT.lock().unwrap() = 1;
99}
100
101// =============================================================================
102// IPDEF{1,2,4,5,5U,6,7,7R,7U,8,9,10} + LCIPDEF — special-parameter
103// table entry constructors. All defined as macros in
104// `Src/params.c:296-406`. Each produces one row of the
105// `special_params[]` table; the differences are flag combinations
106// + which gsu (getter/setter union) the entry binds.
107//
108// In C, `BR(p)` is `{(void *)(p)}` for the param's `u` data field;
109// `GSU(g)` is the `&g` of the named gsu_scalar/gsu_integer/etc.
110// The Rust port stores `var` and `gsu` as `usize` slot indexes
111// into per-evaluator tables, matching the existing PARAMDEF helper
112// above. The flag bit combinations mirror the C macros line-by-line.
113// =============================================================================
114
115/// Port of `IPDEF1()` from `Src/params.c:296` —
116/// `{{NULL,A,PM_INTEGER|PM_SPECIAL|C},BR(NULL),GSU(B),10,0,...}`.
117#[inline]
118#[allow(non_snake_case)]
119pub fn IPDEF1(A: &str, B: usize, C: i32) -> paramdef {
120    // c:params.c:296
121    paramdef {
122        name: A.into(),
123        flags: (PM_INTEGER | PM_SPECIAL) as i32 | C,
124        gsu: B,
125        ..Default::default()
126    }
127}
128
129/// Port of `IPDEF2()` from `Src/params.c:309` —
130/// `{{NULL,A,PM_SCALAR|PM_SPECIAL|C},BR(NULL),GSU(B),0,0,...}`.
131#[inline]
132#[allow(non_snake_case)]
133pub fn IPDEF2(A: &str, B: usize, C: i32) -> paramdef {
134    // c:params.c:309
135    paramdef {
136        name: A.into(),
137        flags: (PM_SCALAR | PM_SPECIAL) as i32 | C,
138        gsu: B,
139        ..Default::default()
140    }
141}
142
143// ---------------------------------------------------------------------------
144// Parameter flags (from zsh.h PM_* flags)
145// ---------------------------------------------------------------------------
146
147// What level of localness we are at.                                       // c:47
148//                                                                          // c:48
149// Hand-wavingly, this is incremented at every function call and decremented // c:49
150// at every function return.  See startparamscope().                        // c:50
151
152/// Port of `mod_export int locallevel;` from `Src/params.c:54`.
153/// Tracks function-local-scope nesting depth. Bumped by
154/// `startparamscope()` (params.c:5879) on every function call,
155/// decremented by `endparamscope()` (params.c:5950) on return.
156///
157/// Storage is [`crate::thread_shell_state::LocalLevelCell`], not a bare
158/// `AtomicI32`: the counter does two jobs that C can merge because it has
159/// one thread. `load()` is the SHARED stamp the one param table's entries
160/// are compared against and is unchanged; `here()` is the calling thread's
161/// own depth, which is what the error formatters must read so an
162/// `async_precmd` hook running on a worker cannot make a top-level error
163/// print as if it had happened inside that hook. See that module for the
164/// misattribution this prevents.
165#[allow(non_upper_case_globals)]
166pub static locallevel: crate::thread_shell_state::LocalLevelCell = // c:54
167    crate::thread_shell_state::LocalLevelCell::new();
168
169/// Port of `mod_export zlong mypid;` from `Src/params.c:102` — `$$`
170/// (`IPDEF4("$", &mypid)`, c:353). Set ONCE by `setupvals`
171/// (c:Src/init.c:1227 `mypid = (zlong) getpid();`); a forked child
172/// (pipeline stage, `&` job, coproc) inherits the parent's value, so `$$`
173/// names the parent shell there — zshrs read `getpid()` live and printed
174/// the child's pid (`echo $$ | cat`).
175#[allow(non_upper_case_globals)]
176pub static mypid: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0); // c:102
177
178/// Port of `mod_export zlong ppid;` from `Src/params.c:107` — `$PPID`
179/// (`IPDEF4("PPID", &ppid)`, c:350), set by `setupvals`
180/// (c:Src/init.c:1226 `ppid = (zlong) getppid();`). Same fork rule as
181/// [`mypid`]: a forked stage keeps the shell's parent, not its own.
182#[allow(non_upper_case_globals)]
183pub static ppid: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0); // c:107
184
185// ---------------------------------------------------------------------------
186// Real `param` struct lives in Src/zsh.h:1829 (port at zsh_h.rs:750).
187// It uses C-union flattening: u_str / u_arr / u_val / u_dval / u_hash
188// dispatched on `PM_TYPE(node.flags)`. There is NO `ParamValue` enum in
189// C; do not reintroduce one.
190// ---------------------------------------------------------------------------
191
192/// Port of `LCIPDEF()` from `Src/params.c:324` —
193/// `IPDEF2(name, lc_blah_gsu, PM_UNSET)`.
194#[inline]
195#[allow(non_snake_case)]
196pub fn LCIPDEF(name: &str) -> paramdef {
197    // c:params.c:324
198    IPDEF2(name, 0, PM_UNSET as i32) // c:324 lc_blah_gsu (slot 0)
199}
200
201/// Port of `IPDEF4()` from `Src/params.c:344` —
202/// `{{NULL,A,PM_INTEGER|PM_READONLY_SPECIAL},BR((void*)B),
203///   GSU(varint_readonly_gsu),10,0,...}`.
204#[inline]
205#[allow(non_snake_case)]
206pub fn IPDEF4(A: &str, B: usize) -> paramdef {
207    // c:params.c:344
208    paramdef {
209        name: A.into(),
210        flags: (PM_INTEGER | PM_READONLY_SPECIAL) as i32,
211        var: B,
212        ..Default::default()
213    }
214}
215
216/// Port of `IPDEF5()` from `Src/params.c:353` —
217/// `{{NULL,A,PM_INTEGER|PM_SPECIAL},BR((void*)B),GSU(F),10,0,...}`.
218#[inline]
219#[allow(non_snake_case)]
220pub fn IPDEF5(A: &str, B: usize, F: usize) -> paramdef {
221    // c:params.c:353
222    paramdef {
223        name: A.into(),
224        flags: (PM_INTEGER | PM_SPECIAL) as i32,
225        var: B,
226        gsu: F,
227        ..Default::default()
228    }
229}
230
231/// Port of `IPDEF5U()` from `Src/params.c:354` — c:353 + PM_UNSET.
232#[inline]
233#[allow(non_snake_case)]
234pub fn IPDEF5U(A: &str, B: usize, F: usize) -> paramdef {
235    // c:params.c:354
236    paramdef {
237        name: A.into(),
238        flags: (PM_INTEGER | PM_SPECIAL | PM_UNSET) as i32,
239        var: B,
240        gsu: F,
241        ..Default::default()
242    }
243}
244
245/// Port of `IPDEF6()` from `Src/params.c:362` — c:353 + PM_DONTIMPORT.
246#[inline]
247#[allow(non_snake_case)]
248pub fn IPDEF6(A: &str, B: usize, F: usize) -> paramdef {
249    // c:params.c:362
250    paramdef {
251        name: A.into(),
252        flags: (PM_INTEGER | PM_SPECIAL | PM_DONTIMPORT) as i32,
253        var: B,
254        gsu: F,
255        ..Default::default()
256    }
257}
258
259/// Port of `IPDEF7()` from `Src/params.c:367` —
260/// `{{NULL,A,PM_SCALAR|PM_SPECIAL},BR((void*)B),GSU(varscalar_gsu),0,0,...}`.
261#[inline]
262#[allow(non_snake_case)]
263pub fn IPDEF7(A: &str, B: usize) -> paramdef {
264    // c:params.c:367
265    paramdef {
266        name: A.into(),
267        flags: (PM_SCALAR | PM_SPECIAL) as i32,
268        var: B,
269        ..Default::default()
270    }
271}
272
273/// Port of `IPDEF7U()` from `Src/params.c:369` — c:367 + PM_UNSET.
274#[inline]
275#[allow(non_snake_case)]
276pub fn IPDEF7U(A: &str, B: usize) -> paramdef {
277    // c:params.c:369
278    paramdef {
279        name: A.into(),
280        flags: (PM_SCALAR | PM_SPECIAL | PM_UNSET) as i32,
281        var: B,
282        ..Default::default()
283    }
284}
285
286/// Port of `IPDEF7R()` from `Src/params.c:368` — c:367 + PM_DONTIMPORT_SUID.
287#[inline]
288#[allow(non_snake_case)]
289pub fn IPDEF7R(A: &str, B: usize) -> paramdef {
290    // c:params.c:368
291    paramdef {
292        name: A.into(),
293        flags: (PM_SCALAR | PM_SPECIAL | PM_DONTIMPORT_SUID) as i32,
294        var: B,
295        ..Default::default()
296    }
297}
298
299/// Port of `IPDEF9()` from `Src/params.c:384` —
300/// `{{NULL,A,D|PM_ARRAY|PM_SPECIAL|PM_DONTIMPORT},BR((void*)B),
301///   GSU(vararray_gsu),0,0,NULL,C,NULL,0}`.
302#[inline]
303#[allow(non_snake_case)]
304pub fn IPDEF9(A: &str, B: usize, C: usize, D: i32) -> paramdef {
305    // c:params.c:384
306    paramdef {
307        name: A.into(),
308        flags: (PM_ARRAY | PM_SPECIAL | PM_DONTIMPORT) as i32 | D,
309        var: B,
310        ..Default::default()
311    }
312}
313
314/// Port of `IPDEF8()` from `Src/params.c:394` —
315/// `{{NULL,A,D|PM_SCALAR|PM_SPECIAL},BR((void*)B),GSU(colonarr_gsu),
316///   0,0,NULL,C,NULL,0}`.
317/// `C` is the colon-arr field; the Rust port stores it in `getnfn`
318/// since `paramdef` lacks a dedicated colon-arr slot until that's
319/// ported.
320#[inline]
321#[allow(non_snake_case)]
322pub fn IPDEF8(A: &str, B: usize, C: usize, D: i32) -> paramdef {
323    // c:params.c:394
324    paramdef {
325        name: A.into(),
326        flags: (PM_SCALAR | PM_SPECIAL) as i32 | D,
327        var: B,
328        ..Default::default()
329    }
330}
331
332/// Port of `IPDEF10()` from `Src/params.c:406` —
333/// `{{NULL,A,PM_ARRAY|PM_SPECIAL},BR(NULL),GSU(B),10,0,...}`.
334#[inline]
335#[allow(non_snake_case)]
336pub fn IPDEF10(A: &str, B: usize) -> paramdef {
337    // c:params.c:406
338    paramdef {
339        name: A.into(),
340        flags: (PM_ARRAY | PM_SPECIAL) as i32,
341        gsu: B,
342        ..Default::default()
343    }
344}
345
346/// Port of `newparamtable()` from `Src/params.c:519`. C body
347/// allocates a HashTable via `newhashtable(size, name, NULL)`
348/// and wires the vtable. Rust port constructs a fresh
349/// `Box<hashtable>` with the param-specific callbacks left as
350/// `None` (the hashtable.rs vtable cannot host the typed
351/// param-callback signatures yet — wiring them requires the
352/// hashtable backend refactor).
353#[allow(unused_variables)]
354pub fn newparamtable(size: i32, name: &str) -> Option<HashTable> {
355    let hsize = if size == 0 { 17 } else { size };
356    let mut nodes: Vec<Option<HashNode>> = Vec::with_capacity(hsize as usize);
357    for _ in 0..hsize {
358        nodes.push(None);
359    }
360    Some(Box::new(hashtable {
361        hsize,
362        ct: 0,
363        nodes,
364        tmpdata: 0,
365        hash: None,
366        emptytable: None,
367        filltable: None,
368        cmpnodes: None,
369        addnode: None,
370        getnode: None,
371        getnode2: None,
372        removenode: None,
373        disablenode: None,
374        enablenode: None,
375        freenode: None,
376        printnode: None,
377        scantab: None,
378    }))
379}
380
381/// Port of `loadparamnode()` from `Src/params.c:544`. If `pm` is
382/// an AUTOLOAD stub, fires the module loader and re-fetches the
383/// node from ht; otherwise returns pm unchanged.
384///
385/// C body:
386///   if (pm && (pm->flags & PM_AUTOLOAD) && pm->u.str) {
387///       int level = pm->level;
388///       char *mn = dupstring(pm->u.str);
389///       (void)ensurefeature(mn, "p:", nam);
390///       pm = (Param)gethashnode2(ht, nam);
391///       while (pm && pm->level > level) pm = pm->old;
392///       if (pm && (pm->level != level || (pm->flags & PM_AUTOLOAD)))
393///           pm = NULL;
394///       if (!pm) zerr("autoloading module %s failed...", mn, nam);
395///   }
396///   return pm;
397/// Port of `loadparamnode(HashTable ht, Param pm, const char *nam)` from `Src/params.c:544`.
398/// WARNING: param names don't match C — Rust=(pm, nam) vs C=(ht, pm, nam)
399pub fn loadparamnode(
400    // c:544
401    _ht: &HashTable,
402    pm: Option<Param>,
403    nam: &str,
404) -> Option<Param> {
405    // c:546 — `if (pm && (pm->flags & PM_AUTOLOAD) && pm->u.str)`.
406    let (level, modname) = match &pm {
407        Some(p) if p.node.flags & PM_AUTOLOAD as i32 != 0 && p.u_str.is_some() => {
408            (p.level, p.u_str.clone().unwrap())
409        }
410        _ => return pm, // c:566 fall through
411    };
412
413    // c:549 — `(void)ensurefeature(mn, "p:", (pm->node.flags & PM_AUTOALL)
414    // ? NULL : nam);` fires the module loader (same shape as
415    // exec::resolvebuiltin's c:2711 call). A feature the module lacks
416    // reports "autoload cancelled" there and drops the stub.
417    let autoall = pm
418        .as_ref()
419        .is_some_and(|p| (p.node.flags as u32 & crate::ported::zsh_h::PM_AUTOALL) != 0);
420    if let Ok(mut mtab) = crate::ported::module::MODULESTAB.lock() {
421        let _ = crate::ported::module::ensurefeature(
422            &mut mtab,
423            &modname,
424            "p:",
425            if autoall { None } else { Some(nam) },
426        );
427    }
428    // c:550 — re-fetch the node from ht after autoload.
429    let mut pm = paramtab().write().unwrap().get(nam).cloned();
430    // c:551 — walk pm->old back to original level.
431    while let Some(ref p) = pm {
432        if p.level > level {
433            pm = p.old.clone().map(|b| Param::from(b));
434        } else {
435            break;
436        }
437    }
438    // c:553-554 — if pm is at wrong level or still AUTOLOAD, treat
439    // as load failure.
440    let still_bad = match &pm {
441        Some(p) => p.level != level || p.node.flags & PM_AUTOLOAD as i32 != 0,
442        None => true,
443    };
444    if still_bad {
445        pm = None;
446        // c:561-563 — `zerr("autoloading module %s failed to define
447        // parameter: %s", mn, nam)`.
448        zerr(&format!(
449            "autoloading module {} failed to define parameter: {}",
450            modname, nam
451        ));
452    }
453    pm // c:566
454}
455
456// ---------------------------------------------------------------------------
457// Numeric type for parameters (from params.c mnumber)
458// ---------------------------------------------------------------------------
459
460// ---------------------------------------------------------------------------
461// Value struct - mirrors C's struct value for subscript access
462// ---------------------------------------------------------------------------
463// ---------------------------------------------------------------------------
464// Shell parameter
465// ---------------------------------------------------------------------------
466
467// ---------------------------------------------------------------------------
468// Tied parameter data
469// ---------------------------------------------------------------------------
470
471// TiedData removed: was a Rust-only sidecar for the deleted `ParamTable`'s
472// `tied: HashMap<String, TiedData>` field. C source stores tied-pair
473// metadata via `pm->ename` (the partner name) and `pm->u.data` (the
474// separator char) on the real `param` struct (Src/zsh.h:750 / Src/params.c
475// `bin_typeset()` typeset -T branch).
476
477// ---------------------------------------------------------------------------
478// Parameter table print types (from printparamnode)
479// ---------------------------------------------------------------------------
480
481// ---------------------------------------------------------------------------
482// Special parameter definitions table (mirrors special_params[] in C)
483// ---------------------------------------------------------------------------
484
485/// Special-parameter definition — Rust extension paralleling the
486/// `IPDEF*` macro entries in `Src/params.c:297-392`. C uses
487/// `struct paramdef` (`Src/zsh.h:2082`, mirrored at `zsh_h.rs:950`)
488/// with `var` + `gsu` pointers; the Rust port carries a trimmed
489/// shape with `pm_type`/`pm_flags`/`tied_name` until the full
490/// `gsu`-callback plumbing lands. Canonical `paramdef` is the
491/// long-term target.
492#[allow(non_camel_case_types)]
493#[derive(Clone, Debug)]
494pub struct special_paramdef {
495    /// `name` field.
496    pub name: &'static str,
497    pub pm_type: u32,  // PM_INTEGER | PM_SCALAR | PM_ARRAY
498    pub pm_flags: u32, // PM_READONLY_SPECIAL, PM_DONTIMPORT, etc.
499    /// `tied_name` field.
500    pub tied_name: Option<&'static str>,
501}
502
503/// Index of the first entry in `special_params` that lives in the
504/// zsh-only section (after the `{{NULL,NULL,0}, BR(NULL), ...}`
505/// sentinel at `Src/params.c:392`). Entries before this index are
506/// always loaded; entries at and after this index are only loaded
507/// under non-sh/non-ksh emulation. Mirrors the C two-section table
508/// terminated by an inner NULL sentinel.
509pub const SPECIAL_PARAMS_ZSH_START: usize = 54; // c:392
510
511/// All special parameters from params.c special_params[]
512pub const special_params: &[special_paramdef] = &[
513    // Integer specials with custom GSU
514    special_paramdef {
515        name: "#",
516        pm_type: PM_INTEGER,
517        pm_flags: PM_READONLY,
518        tied_name: None,
519    },
520    special_paramdef {
521        name: "ERRNO",
522        pm_type: PM_INTEGER,
523        pm_flags: PM_UNSET,
524        tied_name: None,
525    },
526    special_paramdef {
527        name: "GID",
528        pm_type: PM_INTEGER,
529        pm_flags: PM_DONTIMPORT,
530        tied_name: None,
531    },
532    special_paramdef {
533        name: "EGID",
534        pm_type: PM_INTEGER,
535        pm_flags: PM_DONTIMPORT,
536        tied_name: None,
537    },
538    special_paramdef {
539        name: "HISTSIZE",
540        pm_type: PM_INTEGER,
541        pm_flags: 0,
542        tied_name: None,
543    },
544    special_paramdef {
545        name: "RANDOM",
546        pm_type: PM_INTEGER,
547        pm_flags: 0,
548        tied_name: None,
549    },
550    special_paramdef {
551        name: "SAVEHIST",
552        pm_type: PM_INTEGER,
553        pm_flags: 0,
554        tied_name: None,
555    },
556    special_paramdef {
557        name: "SECONDS",
558        pm_type: PM_INTEGER,
559        pm_flags: 0,
560        tied_name: None,
561    },
562    special_paramdef {
563        name: "UID",
564        pm_type: PM_INTEGER,
565        pm_flags: PM_DONTIMPORT,
566        tied_name: None,
567    },
568    special_paramdef {
569        name: "EUID",
570        pm_type: PM_INTEGER,
571        pm_flags: PM_DONTIMPORT,
572        tied_name: None,
573    },
574    special_paramdef {
575        name: "TTYIDLE",
576        pm_type: PM_INTEGER,
577        pm_flags: PM_READONLY,
578        tied_name: None,
579    },
580    // Scalar specials with custom GSU
581    special_paramdef {
582        name: "USERNAME",
583        pm_type: PM_SCALAR,
584        pm_flags: PM_DONTIMPORT,
585        tied_name: None,
586    },
587    special_paramdef {
588        name: "-",
589        pm_type: PM_SCALAR,
590        pm_flags: PM_READONLY,
591        tied_name: None,
592    },
593    special_paramdef {
594        name: "histchars",
595        pm_type: PM_SCALAR,
596        pm_flags: PM_DONTIMPORT,
597        tied_name: None,
598    },
599    special_paramdef {
600        name: "HOME",
601        pm_type: PM_SCALAR,
602        pm_flags: PM_UNSET,
603        tied_name: None,
604    },
605    special_paramdef {
606        name: "TERM",
607        pm_type: PM_SCALAR,
608        pm_flags: PM_UNSET,
609        tied_name: None,
610    },
611    special_paramdef {
612        name: "TERMINFO",
613        pm_type: PM_SCALAR,
614        pm_flags: PM_UNSET,
615        tied_name: None,
616    },
617    special_paramdef {
618        name: "TERMINFO_DIRS",
619        pm_type: PM_SCALAR,
620        pm_flags: PM_UNSET,
621        tied_name: None,
622    },
623    special_paramdef {
624        name: "WORDCHARS",
625        pm_type: PM_SCALAR,
626        pm_flags: 0,
627        tied_name: None,
628    },
629    special_paramdef {
630        name: "IFS",
631        pm_type: PM_SCALAR,
632        pm_flags: PM_DONTIMPORT,
633        tied_name: None,
634    },
635    special_paramdef {
636        name: "_",
637        pm_type: PM_SCALAR,
638        pm_flags: PM_DONTIMPORT,
639        tied_name: None,
640    },
641    special_paramdef {
642        name: "KEYBOARD_HACK",
643        pm_type: PM_SCALAR,
644        pm_flags: PM_DONTIMPORT,
645        tied_name: None,
646    },
647    special_paramdef {
648        name: "0",
649        pm_type: PM_SCALAR,
650        pm_flags: 0,
651        tied_name: None,
652    },
653    // Readonly integer variables bound to C globals
654    special_paramdef {
655        name: "!",
656        pm_type: PM_INTEGER,
657        pm_flags: PM_READONLY,
658        tied_name: None,
659    },
660    special_paramdef {
661        name: "$",
662        pm_type: PM_INTEGER,
663        pm_flags: PM_READONLY,
664        tied_name: None,
665    },
666    special_paramdef {
667        name: "?",
668        pm_type: PM_INTEGER,
669        pm_flags: PM_READONLY,
670        tied_name: None,
671    },
672    special_paramdef {
673        name: "HISTCMD",
674        pm_type: PM_INTEGER,
675        pm_flags: PM_READONLY,
676        tied_name: None,
677    },
678    special_paramdef {
679        name: "LINENO",
680        pm_type: PM_INTEGER,
681        pm_flags: PM_READONLY,
682        tied_name: None,
683    },
684    special_paramdef {
685        name: "PPID",
686        pm_type: PM_INTEGER,
687        pm_flags: PM_READONLY,
688        tied_name: None,
689    },
690    special_paramdef {
691        name: "ZSH_SUBSHELL",
692        pm_type: PM_INTEGER,
693        pm_flags: PM_READONLY,
694        tied_name: None,
695    },
696    // Settable integer variables
697    special_paramdef {
698        name: "COLUMNS",
699        pm_type: PM_INTEGER,
700        pm_flags: 0,
701        tied_name: None,
702    },
703    special_paramdef {
704        name: "LINES",
705        pm_type: PM_INTEGER,
706        pm_flags: 0,
707        tied_name: None,
708    },
709    special_paramdef {
710        name: "ZLE_RPROMPT_INDENT",
711        pm_type: PM_INTEGER,
712        pm_flags: PM_UNSET,
713        tied_name: None,
714    },
715    special_paramdef {
716        name: "SHLVL",
717        pm_type: PM_INTEGER,
718        pm_flags: 0,
719        tied_name: None,
720    },
721    special_paramdef {
722        name: "FUNCNEST",
723        pm_type: PM_INTEGER,
724        pm_flags: 0,
725        tied_name: None,
726    },
727    special_paramdef {
728        name: "OPTIND",
729        pm_type: PM_INTEGER,
730        pm_flags: PM_DONTIMPORT,
731        tied_name: None,
732    },
733    special_paramdef {
734        // c:Src/params.c:364 — `IPDEF6("TRY_BLOCK_ERROR", &try_errflag,
735        // varinteger_gsu)` = PM_INTEGER | PM_SPECIAL | PM_DONTIMPORT.
736        // No PM_UNSET on the table entry — C reads -1 via the getfn
737        // reading the global `try_errflag`. zshrs's earlier port set
738        // PM_UNSET as a "no-write-yet" sentinel which broke
739        // `${(k)parameters}` parity (zsh emits the name; with PM_UNSET
740        // here scanpmparameters skipped it). The -1 default is now
741        // surfaced via the special-var getter (params.rs sentinel
742        // override on PM_UNSET removal).
743        name: "TRY_BLOCK_ERROR",
744        pm_type: PM_INTEGER,
745        pm_flags: PM_DONTIMPORT,
746        tied_name: None,
747    },
748    special_paramdef {
749        // c:Src/loop.c — `try_interrupt = -1`. Same pattern as
750        // TRY_BLOCK_ERROR: no PM_UNSET on the table entry; -1
751        // default emerges via the special-var getter.
752        name: "TRY_BLOCK_INTERRUPT",
753        pm_type: PM_INTEGER,
754        pm_flags: PM_DONTIMPORT,
755        tied_name: None,
756    },
757    // Scalar variables bound to C globals
758    special_paramdef {
759        name: "OPTARG",
760        pm_type: PM_SCALAR,
761        pm_flags: 0,
762        tied_name: None,
763    },
764    special_paramdef {
765        name: "NULLCMD",
766        pm_type: PM_SCALAR,
767        pm_flags: 0,
768        tied_name: None,
769    },
770    special_paramdef {
771        name: "POSTEDIT",
772        pm_type: PM_SCALAR,
773        pm_flags: PM_UNSET,
774        tied_name: None,
775    },
776    special_paramdef {
777        name: "READNULLCMD",
778        pm_type: PM_SCALAR,
779        pm_flags: 0,
780        tied_name: None,
781    },
782    special_paramdef {
783        name: "PS1",
784        pm_type: PM_SCALAR,
785        pm_flags: 0,
786        tied_name: None,
787    },
788    special_paramdef {
789        name: "RPS1",
790        pm_type: PM_SCALAR,
791        pm_flags: PM_UNSET,
792        tied_name: None,
793    },
794    special_paramdef {
795        name: "RPROMPT",
796        pm_type: PM_SCALAR,
797        pm_flags: PM_UNSET,
798        tied_name: None,
799    },
800    special_paramdef {
801        name: "PS2",
802        pm_type: PM_SCALAR,
803        pm_flags: 0,
804        tied_name: None,
805    },
806    special_paramdef {
807        name: "RPS2",
808        pm_type: PM_SCALAR,
809        pm_flags: PM_UNSET,
810        tied_name: None,
811    },
812    special_paramdef {
813        name: "RPROMPT2",
814        pm_type: PM_SCALAR,
815        pm_flags: PM_UNSET,
816        tied_name: None,
817    },
818    special_paramdef {
819        name: "PS3",
820        pm_type: PM_SCALAR,
821        pm_flags: 0,
822        tied_name: None,
823    },
824    special_paramdef {
825        name: "PS4",
826        pm_type: PM_SCALAR,
827        pm_flags: PM_DONTIMPORT_SUID,
828        tied_name: None,
829    },
830    special_paramdef {
831        name: "SPROMPT",
832        pm_type: PM_SCALAR,
833        pm_flags: 0,
834        tied_name: None,
835    },
836    // Readonly arrays
837    special_paramdef {
838        name: "*",
839        pm_type: PM_ARRAY,
840        pm_flags: PM_READONLY | PM_DONTIMPORT,
841        tied_name: None,
842    },
843    special_paramdef {
844        name: "@",
845        pm_type: PM_ARRAY,
846        pm_flags: PM_READONLY | PM_DONTIMPORT,
847        tied_name: None,
848    },
849    // ===================================================================
850    // c:388-392 — `/* This empty row indicates the end of parameters
851    // available in all emulations. */` NULL sentinel terminates the
852    // "always loaded" section. Entries below this line are only added
853    // under zsh emulation (else-branch of EMULATION(EMULATE_SH|EMULATE_KSH)
854    // at createparamtable c:840-846).
855    // SPECIAL_PARAMS_ZSH_START tracks this section boundary.
856    // ===================================================================
857    // Tied colon-separated/array pairs
858    special_paramdef {
859        name: "CDPATH",
860        pm_type: PM_SCALAR,
861        pm_flags: PM_TIED,
862        tied_name: Some("cdpath"),
863    },
864    special_paramdef {
865        name: "FIGNORE",
866        pm_type: PM_SCALAR,
867        pm_flags: PM_TIED,
868        tied_name: Some("fignore"),
869    },
870    special_paramdef {
871        name: "FPATH",
872        pm_type: PM_SCALAR,
873        pm_flags: PM_TIED,
874        tied_name: Some("fpath"),
875    },
876    special_paramdef {
877        name: "MAILPATH",
878        pm_type: PM_SCALAR,
879        pm_flags: PM_TIED,
880        tied_name: Some("mailpath"),
881    },
882    special_paramdef {
883        name: "PATH",
884        pm_type: PM_SCALAR,
885        pm_flags: PM_TIED,
886        tied_name: Some("path"),
887    },
888    special_paramdef {
889        name: "PSVAR",
890        pm_type: PM_SCALAR,
891        pm_flags: PM_TIED,
892        tied_name: Some("psvar"),
893    },
894    special_paramdef {
895        name: "ZSH_EVAL_CONTEXT",
896        pm_type: PM_SCALAR,
897        pm_flags: PM_READONLY | PM_TIED,
898        tied_name: Some("zsh_eval_context"),
899    },
900    special_paramdef {
901        name: "MODULE_PATH",
902        pm_type: PM_SCALAR,
903        pm_flags: PM_DONTIMPORT | PM_TIED,
904        tied_name: Some("module_path"),
905    },
906    special_paramdef {
907        name: "MANPATH",
908        pm_type: PM_SCALAR,
909        pm_flags: PM_TIED,
910        tied_name: Some("manpath"),
911    },
912    // Locale
913    special_paramdef {
914        name: "LANG",
915        pm_type: PM_SCALAR,
916        pm_flags: PM_UNSET,
917        tied_name: None,
918    },
919    special_paramdef {
920        name: "LC_ALL",
921        pm_type: PM_SCALAR,
922        pm_flags: PM_UNSET,
923        tied_name: None,
924    },
925    special_paramdef {
926        name: "LC_COLLATE",
927        pm_type: PM_SCALAR,
928        pm_flags: PM_UNSET,
929        tied_name: None,
930    },
931    special_paramdef {
932        name: "LC_CTYPE",
933        pm_type: PM_SCALAR,
934        pm_flags: PM_UNSET,
935        tied_name: None,
936    },
937    special_paramdef {
938        name: "LC_MESSAGES",
939        pm_type: PM_SCALAR,
940        pm_flags: PM_UNSET,
941        tied_name: None,
942    },
943    special_paramdef {
944        name: "LC_NUMERIC",
945        pm_type: PM_SCALAR,
946        pm_flags: PM_UNSET,
947        tied_name: None,
948    },
949    special_paramdef {
950        name: "LC_TIME",
951        pm_type: PM_SCALAR,
952        pm_flags: PM_UNSET,
953        tied_name: None,
954    },
955    // Zsh-only aliases
956    special_paramdef {
957        name: "ARGC",
958        pm_type: PM_INTEGER,
959        pm_flags: PM_READONLY,
960        tied_name: None,
961    },
962    special_paramdef {
963        name: "HISTCHARS",
964        pm_type: PM_SCALAR,
965        pm_flags: PM_DONTIMPORT,
966        tied_name: None,
967    },
968    special_paramdef {
969        name: "status",
970        pm_type: PM_INTEGER,
971        pm_flags: PM_READONLY,
972        tied_name: None,
973    },
974    special_paramdef {
975        name: "prompt",
976        pm_type: PM_SCALAR,
977        pm_flags: 0,
978        tied_name: None,
979    },
980    special_paramdef {
981        name: "PROMPT",
982        pm_type: PM_SCALAR,
983        pm_flags: 0,
984        tied_name: None,
985    },
986    special_paramdef {
987        name: "PROMPT2",
988        pm_type: PM_SCALAR,
989        pm_flags: 0,
990        tied_name: None,
991    },
992    special_paramdef {
993        name: "PROMPT3",
994        pm_type: PM_SCALAR,
995        pm_flags: 0,
996        tied_name: None,
997    },
998    special_paramdef {
999        name: "PROMPT4",
1000        pm_type: PM_SCALAR,
1001        pm_flags: 0,
1002        tied_name: None,
1003    },
1004    special_paramdef {
1005        name: "argv",
1006        pm_type: PM_ARRAY,
1007        pm_flags: 0,
1008        tied_name: None,
1009    },
1010    // c:Src/params.c:425-434 — IPDEF9 lowercase array tied counterparts
1011    // of the uppercase scalar specials. Each is a PM_ARRAY|PM_TIED|
1012    // PM_SPECIAL entry that points back to its colon-scalar partner
1013    // via tied_name. Without these in special_params, `${(t)path}` /
1014    // `${(t)cdpath}` / `${(t)fpath}` etc. miss the `-tied-special`
1015    // suffix when the paramtab.get(name) lookup runs in subst.rs:5605.
1016    special_paramdef {
1017        name: "fignore",
1018        pm_type: PM_ARRAY,
1019        pm_flags: PM_TIED,
1020        tied_name: Some("FIGNORE"),
1021    },
1022    special_paramdef {
1023        name: "cdpath",
1024        pm_type: PM_ARRAY,
1025        pm_flags: PM_TIED,
1026        tied_name: Some("CDPATH"),
1027    },
1028    special_paramdef {
1029        name: "fpath",
1030        pm_type: PM_ARRAY,
1031        pm_flags: PM_TIED,
1032        tied_name: Some("FPATH"),
1033    },
1034    special_paramdef {
1035        name: "mailpath",
1036        pm_type: PM_ARRAY,
1037        pm_flags: PM_TIED,
1038        tied_name: Some("MAILPATH"),
1039    },
1040    special_paramdef {
1041        name: "manpath",
1042        pm_type: PM_ARRAY,
1043        pm_flags: PM_TIED,
1044        tied_name: Some("MANPATH"),
1045    },
1046    special_paramdef {
1047        name: "psvar",
1048        pm_type: PM_ARRAY,
1049        pm_flags: PM_TIED,
1050        tied_name: Some("PSVAR"),
1051    },
1052    special_paramdef {
1053        name: "zsh_eval_context",
1054        pm_type: PM_ARRAY,
1055        pm_flags: PM_TIED | PM_READONLY,
1056        tied_name: Some("ZSH_EVAL_CONTEXT"),
1057    },
1058    special_paramdef {
1059        name: "module_path",
1060        pm_type: PM_ARRAY,
1061        pm_flags: PM_TIED,
1062        tied_name: Some("MODULE_PATH"),
1063    },
1064    special_paramdef {
1065        name: "path",
1066        pm_type: PM_ARRAY,
1067        pm_flags: PM_TIED,
1068        tied_name: Some("PATH"),
1069    },
1070    // pipestatus array
1071    special_paramdef {
1072        name: "pipestatus",
1073        pm_type: PM_ARRAY,
1074        pm_flags: 0,
1075        tied_name: None,
1076    },
1077];
1078
1079/// Port of `static initparam special_params_sh[]` from
1080/// `Src/params.c:447-460`. "Alternative versions of colon-separated
1081/// path parameters for sh emulation. These don't link to the array
1082/// versions." Loaded by `createparamtable` (c:840-844) when
1083/// `EMULATION(EMULATE_SH|EMULATE_KSH)` is non-zero, instead of the
1084/// zsh-only section of `special_params`. All entries are scalars
1085/// (`IPDEF8` macro adds `PM_SCALAR|PM_SPECIAL`); the C-side
1086/// `tied_name` is NULL so these aren't tied to lowercase array
1087/// counterparts.
1088pub const special_params_sh: &[special_paramdef] = &[
1089    special_paramdef {
1090        // c:448
1091        name: "CDPATH",
1092        pm_type: PM_SCALAR,
1093        pm_flags: 0,
1094        tied_name: None,
1095    },
1096    special_paramdef {
1097        // c:449
1098        name: "FIGNORE",
1099        pm_type: PM_SCALAR,
1100        pm_flags: 0,
1101        tied_name: None,
1102    },
1103    special_paramdef {
1104        // c:450
1105        name: "FPATH",
1106        pm_type: PM_SCALAR,
1107        pm_flags: 0,
1108        tied_name: None,
1109    },
1110    special_paramdef {
1111        // c:451
1112        name: "MAILPATH",
1113        pm_type: PM_SCALAR,
1114        pm_flags: 0,
1115        tied_name: None,
1116    },
1117    special_paramdef {
1118        // c:452
1119        name: "PATH",
1120        pm_type: PM_SCALAR,
1121        pm_flags: 0,
1122        tied_name: None,
1123    },
1124    special_paramdef {
1125        // c:453
1126        name: "PSVAR",
1127        pm_type: PM_SCALAR,
1128        pm_flags: 0,
1129        tied_name: None,
1130    },
1131    special_paramdef {
1132        // c:454
1133        name: "ZSH_EVAL_CONTEXT",
1134        pm_type: PM_SCALAR,
1135        pm_flags: PM_READONLY,
1136        tied_name: None,
1137    },
1138    special_paramdef {
1139        // c:457 (security comment)
1140        name: "MODULE_PATH",
1141        pm_type: PM_SCALAR,
1142        pm_flags: PM_DONTIMPORT,
1143        tied_name: None,
1144    },
1145];
1146
1147/// Port of `getparamnode()` from `Src/params.c:570`. C body:
1148/// `pm = loadparamnode(ht, gethashnode2(ht, nam), nam);
1149///  if (pm && ht == realparamtab && !PM_UNSET) pm = resolve_nameref(pm);
1150///  return (HashNode)pm;`
1151/// WARNING: param names don't match C — Rust=() vs C=(ht, nam)
1152pub fn getparamnode(ht: &HashTable, nam: &str) -> Option<Param> {
1153    // c:572 — `pm = loadparamnode(ht, gethashnode2(ht, nam), nam)`.
1154    let pm = paramtab().read().unwrap().get(nam).cloned();
1155    let pm = loadparamnode(ht, pm, nam);
1156    // c:573 — `if (pm && ht == realparamtab && !PM_UNSET) pm = resolve_nameref(pm)`.
1157    if let Some(p) = pm {
1158        if p.node.flags & PM_UNSET as i32 == 0 {
1159            // ht == realparamtab check — both Rust accessors point at
1160            // the same backing store today, so this is always true.
1161            return resolve_nameref(Some(p));
1162        }
1163        return Some(p);
1164    }
1165    None
1166}
1167
1168/// Port of `scancopyparams()` from `Src/params.c:584`. C body:
1169/// ```c
1170/// Param tpm = (Param) zshcalloc(sizeof *tpm);
1171/// tpm->node.nam = ztrdup(pm->node.nam);
1172/// copyparam(tpm, pm, 0);
1173/// addhashnode(outtable, tpm->node.nam, tpm);
1174/// ```
1175/// Real port: clone the param via `Box::new(pm.clone())` (Rust
1176/// equivalent of zshcalloc + copyparam) and push it into the
1177/// caller-supplied destination table. The original C uses the
1178/// global `outtable`; Rust port plumbs it in explicitly.
1179/// WARNING: param names don't match C — Rust=(pm, _flags, outtable) vs C=(hn, flags)
1180pub fn scancopyparams(pm: &mut param, _flags: i32, outtable: &mut HashMap<String, Box<param>>) {
1181    // c:586-588 — `tpm = (Param) zshcalloc(...); copyparam(tpm, pm, 0); addnode(...)`.
1182    let mut tpm = Box::new(pm.clone()); // c:586 zshcalloc
1183    tpm.old = None;
1184    tpm.env = None;
1185    tpm.ename = None; // c:1242 (calloc-zero fields copyparam doesn't set)
1186    copyparam(&mut tpm, pm, 0); // c:587
1187    let nam = tpm.node.nam.clone();
1188    outtable.insert(nam, tpm); // c:588 addnode(outtable, ztrdup(pm->node.nam), tpm)
1189}
1190
1191/// Port of `copyparamtable()` from `Src/params.c:596`. C body:
1192/// allocates a fresh paramtable via `newparamtable(ht->hsize, name)`,
1193/// sets the global `outtable = nht`, then scans the source via
1194/// `scanhashtable(ht, 0, 0, 0, scancopyparams, 0)` and clears
1195/// `outtable` on exit. Rust port returns the freshly-allocated
1196/// table; the per-node clone walk requires the HashTable iterator
1197/// which isn't wired yet (callers receive the empty allocated
1198/// table — same shape the C source returns when `ht` is empty).
1199pub fn copyparamtable(ht: Option<&HashTable>, name: &str) -> Option<HashTable> {
1200    let ht = ht?;
1201    newparamtable(ht.hsize, name)
1202}
1203
1204/// Port of `deleteparamtable()` from `Src/params.c:616`. C body:
1205/// `int odelunset = delunset; delunset = 1; deletehashtable(t);
1206///  delunset = odelunset;` — flips the global before tearing down
1207/// each entry so unset callbacks fire. Rust port: `Drop` cascades
1208/// through `Box<hashtable>` to clear all `nodes`; consume the
1209/// table by value to mirror the C ownership transfer.
1210pub fn deleteparamtable(t: Option<HashTable>) {
1211    // c:616-623 — `int odelunset = delunset; delunset = 1;` save/
1212    // restore so the inner free path fires every entry's unsetfn.
1213    let odelunset = DELUNSET.swap(1, Ordering::Relaxed); // c:620-621
1214    if let Some(table) = t {
1215        // Box dropped here → fields freed; param freenode callbacks
1216        // are invoked transparently via Drop on each `param` entry.
1217        drop(table);
1218    }
1219    DELUNSET.store(odelunset, Ordering::Relaxed); // c:623
1220}
1221
1222/// Port of `scancountparams()` from `Src/params.c:630`. C body:
1223/// ```c
1224/// ++numparamvals;
1225/// if ((flags & SCANPM_WANTKEYS) && (flags & SCANPM_WANTVALS))
1226///     ++numparamvals;
1227/// ```
1228/// Increments the static `numparamvals` global used by
1229/// `paramvalarr`. Rust port mirrors against a counter passed by
1230/// reference (no static-mutable in safe Rust).
1231/// WARNING: param names don't match C — Rust=(_hn, flags, numparamvals) vs C=(hn, flags)
1232pub fn scancountparams(_hn: &param, flags: i32, numparamvals: &mut u32) {
1233    *numparamvals += 1;
1234    if (flags as u32 & SCANPM_WANTKEYS) != 0 && (flags as u32 & SCANPM_WANTVALS) != 0 {
1235        *numparamvals += 1;
1236    }
1237}
1238
1239/// Port of `scanparamvals()` from `Src/params.c:644`. Real C body
1240/// is the per-node callback for `paramvalarr`: applies SCANPM_MATCHKEY
1241/// (pattry on name) / SCANPM_MATCHVAL (pattry on value) / SCANPM_KEYMATCH
1242/// (compile pm.nam as pattern, match against scanstr) / SCANPM_WANTKEYS
1243/// / SCANPM_WANTVALS / SCANPM_MATCHMANY filters, populating the
1244/// `paramvals[]` slice with the param's name and/or `getstrvalue`
1245/// result, and stashing `foundparam = pm`. State lives in the C
1246/// file-scope statics ported above as `NUMPARAMVALS` / `SCANPROG` /
1247/// `SCANSTR` / `PARAMVALS` / `FOUNDPARAM`.
1248/// WARNING: param names don't match C — Rust=(flags) vs C=(hn, flags)
1249pub fn scanparamvals(
1250    // c:644
1251    pm: &mut param,
1252    flags: i32,
1253) {
1254    let f = flags as u32;
1255    if NUMPARAMVALS.load(Ordering::Relaxed) != 0
1256        && (f & SCANPM_MATCHMANY) == 0
1257        && (f & (SCANPM_MATCHVAL | SCANPM_MATCHKEY | SCANPM_KEYMATCH)) != 0
1258    {
1259        return;
1260    }
1261    if (f & SCANPM_KEYMATCH) != 0 {
1262        // patcompile(pm.node.nam) + pattry(prog, scanstr)
1263        let scanstr = scanstr_lock().lock().unwrap().clone();
1264        if let Some(s) = scanstr {
1265            let matched = patcompile(
1266                &{
1267                    let mut __pat_tok = (&pm.node.nam).to_string();
1268                    crate::ported::glob::tokenize(&mut __pat_tok);
1269                    __pat_tok
1270                },
1271                PAT_HEAPDUP as i32,
1272                None,
1273            )
1274            .map_or(false, |p| pattry(&p, &s));
1275            if !matched {
1276                return;
1277            }
1278        } else {
1279            return;
1280        }
1281    } else if (f & SCANPM_MATCHKEY) != 0 {
1282        let prog = scanprog_lock().lock().unwrap().clone();
1283        if let Some(p) = prog {
1284            let matched = patcompile(
1285                &{
1286                    let mut __pat_tok = (&p).to_string();
1287                    crate::ported::glob::tokenize(&mut __pat_tok);
1288                    __pat_tok
1289                },
1290                PAT_HEAPDUP as i32,
1291                None,
1292            )
1293            .map_or(false, |prog| pattry(&prog, &pm.node.nam));
1294            if !matched {
1295                return;
1296            }
1297        } else {
1298            return;
1299        }
1300    }
1301    set_foundparam(Some(pm.node.nam.clone()));
1302    if (f & SCANPM_WANTKEYS) != 0 {
1303        paramvals_lock().lock().unwrap().push(pm.node.nam.clone());
1304        NUMPARAMVALS.fetch_add(1, Ordering::Relaxed);
1305        if (f & (SCANPM_WANTVALS | SCANPM_MATCHVAL)) == 0 {
1306            return;
1307        }
1308    }
1309    let mut vbuf = value {
1310        pm: None, // placeholder; real C re-binds
1311        arr: Vec::new(),
1312        scanflags: 0,
1313        valflags: 0,
1314        start: 0,
1315        end: -1,
1316    };
1317    // C: paramvals[numparamvals] = getstrvalue(&v);
1318    // We don't move pm into vbuf to preserve the borrow; mirror the
1319    // C semantics by reading u_str directly via strgetfn for the
1320    // PM_SCALAR fast path and falling back through getstrvalue when
1321    // wired.
1322    let s = strgetfn(pm);
1323    let _ = vbuf;
1324    if (f & SCANPM_MATCHVAL) != 0 {
1325        let prog = scanprog_lock().lock().unwrap().clone();
1326        let matched = prog
1327            .and_then(|p| {
1328                patcompile(
1329                    &{
1330                        let mut __pat_tok = (&p).to_string();
1331                        crate::ported::glob::tokenize(&mut __pat_tok);
1332                        __pat_tok
1333                    },
1334                    PAT_HEAPDUP as i32,
1335                    None,
1336                )
1337            })
1338            .map_or(false, |prog| pattry(&prog, &s));
1339        if matched {
1340            // c:672-674 — `numparamvals += ((flags & SCANPM_WANTVALS) ? 1 :
1341            // !(flags & SCANPM_WANTKEYS));`. C wrote the value into
1342            // `paramvals[numparamvals]` BEFORE this test (c:670), so an
1343            // increment of 0 means the slot is reused by the next node and
1344            // the value is discarded. The Rust store is a Vec whose length
1345            // IS `numparamvals`, so a 0-increment must simply not push —
1346            // otherwise `-k h[(R)pat]` (WANTKEYS + MATCHVAL, inc 0)
1347            // interleaved every matching VALUE with its key.
1348            let inc = if (f & SCANPM_WANTVALS) != 0 {
1349                1
1350            } else if (f & SCANPM_WANTKEYS) == 0 {
1351                1
1352            } else {
1353                0
1354            };
1355            if inc != 0 {
1356                paramvals_lock().lock().unwrap().push(s);
1357                NUMPARAMVALS.fetch_add(inc, Ordering::Relaxed);
1358            }
1359        } else if (f & SCANPM_WANTKEYS) != 0 {
1360            // c:675-676 — `--numparamvals; /* Value didn't match, discard key */`
1361            paramvals_lock().lock().unwrap().pop();
1362            NUMPARAMVALS.fetch_sub(1, Ordering::Relaxed);
1363        }
1364    } else {
1365        paramvals_lock().lock().unwrap().push(s);
1366        NUMPARAMVALS.fetch_add(1, Ordering::Relaxed);
1367    }
1368    set_foundparam(None);
1369}
1370
1371/// Port of `paramvalarr()` from `Src/params.c:689`. Scans the param hash twice (count,
1372/// then collect) and returns a heap-allocated string array. C body:
1373/// ```c
1374/// numparamvals = 0;
1375/// if (ht) scanhashtable(ht, 0, 0, PM_UNSET, scancountparams, flags);
1376/// paramvals = zhalloc((numparamvals + 1) * sizeof(char *));
1377/// if (ht) { numparamvals = 0;
1378///           scanhashtable(ht, 0, 0, PM_UNSET, scanparamvals, flags); }
1379/// paramvals[numparamvals] = 0;
1380/// return paramvals;
1381/// ```
1382/// SCANPM_MATCHKEY / SCANPM_MATCHVAL filter against `scanprog`
1383/// (the active glob/regex from the caller's `${(k)var[(I)pattern]}`
1384/// subscript); SCANPM_WANTKEYS / SCANPM_WANTVALS / SCANPM_WANTINDEX
1385/// control which fields land in the output array.
1386///
1387/// zshrs port note on the `ht` argument: C's assoc backing is a
1388/// `HashTable` whose nodes are `PM_SCALAR` child Params created by
1389/// `arrhashsetfn` (`createparam(k, PM_SCALAR|PM_UNSET)` + `assignstrvalue`,
1390/// c:4156-4166). zshrs's `arrhashsetfn` (params.rs, same C line) writes the
1391/// pairs into `paramtab_hashed_storage` instead and leaves `pm->u_hash`
1392/// empty, so the `IndexMap<String, String>` IS this port's assoc hash
1393/// table. Each entry is materialised back into the `PM_SCALAR` node C's
1394/// scan callbacks expect, so `scancountparams`/`scanparamvals` are the
1395/// unmodified C callbacks working over the same node shape.
1396///
1397/// Previously this scanned the GLOBAL `paramtab` and reimplemented the
1398/// per-node emit inline (never calling `scanparamvals`), so it could not
1399/// be pointed at a named assoc at all — `${h[(R)pat]}`-shaped scans and
1400/// `compadd -k 'h[(R)pat]'` had no backend.
1401/// Port of `paramvalarr(HashTable ht, int flags)` from `Src/params.c:689`.
1402pub fn paramvalarr(ht: &IndexMap<String, String>, flags: i32) -> Vec<String> {
1403    // c:689
1404    // c:691-692 — DPUTS((flags & (SCANPM_MATCHKEY|SCANPM_MATCHVAL)) && !scanprog,
1405    //                 "BUG: scanning hash without scanprog set");
1406    DPUTS!(
1407        // c:691
1408        (flags as u32 & (SCANPM_MATCHKEY | SCANPM_MATCHVAL)) != 0
1409            && scanprog_lock().lock().unwrap().is_none(), // c:691 !scanprog test
1410        "BUG: scanning hash without scanprog set" // c:692
1411    );
1412
1413    // c:Src/params.c:4156-4166 — an assoc entry is a `PM_SCALAR` Param
1414    // whose `node.nam` is the key and whose value is the element string.
1415    let as_node = |k: &String, val: &String| -> param {
1416        let mut pm = param::default();
1417        pm.node.nam = k.clone();
1418        pm.node.flags = PM_SCALAR as i32;
1419        pm.u_str = Some(val.clone());
1420        pm
1421    };
1422
1423    // c:693-695 — first pass sizes the result:
1424    //   numparamvals = 0;
1425    //   if (ht) scanhashtable(ht, 0, 0, PM_UNSET, scancountparams, flags);
1426    NUMPARAMVALS.store(0, Ordering::Relaxed); // c:693
1427    let mut count: u32 = 0;
1428    for (k, val) in ht.iter() {
1429        // c:695
1430        scancountparams(&as_node(k, val), flags, &mut count);
1431    }
1432
1433    // c:696 — `paramvals = zhalloc((numparamvals + 1) * sizeof(char *));`
1434    {
1435        let mut pv = paramvals_lock().lock().unwrap();
1436        pv.clear();
1437        pv.reserve(count as usize + 1);
1438    }
1439
1440    // c:698-700 — second pass fills it:
1441    //   numparamvals = 0;
1442    //   scanhashtable(ht, 0, 0, PM_UNSET, scanparamvals, flags);
1443    NUMPARAMVALS.store(0, Ordering::Relaxed); // c:698
1444    for (k, val) in ht.iter() {
1445        // c:700
1446        scanparamvals(&mut as_node(k, val), flags);
1447    }
1448
1449    // c:701-702 — `paramvals[numparamvals] = 0; return paramvals;`. The
1450    // Rust store is a Vec whose length is `numparamvals`, so there is no
1451    // NULL terminator to write.
1452    paramvals_lock().lock().unwrap().clone() // c:702
1453}
1454
1455/// Port of `getvaluearr()` from `Src/params.c:710`. C body:
1456/// ```c
1457/// if (v->arr) return v->arr;
1458/// else if (PM_TYPE == PM_ARRAY) return v->arr = pm->gsu.a->getfn(pm);
1459/// else if (PM_TYPE == PM_HASHED) {
1460///     v->arr = paramvalarr(pm->gsu.h->getfn(pm), v->scanflags);
1461///     v->start = 0; v->end = numparamvals + 1; return v->arr;
1462/// } else return NULL;
1463/// ```
1464pub fn getvaluearr(v: Option<&mut value>) -> Vec<String> {
1465    let v = match v {
1466        Some(v) => v,
1467        None => return Vec::new(),
1468    };
1469    if !v.arr.is_empty() {
1470        return v.arr.clone();
1471    }
1472    let (t, nam) = match v.pm.as_ref() {
1473        Some(p) => (PM_TYPE(p.node.flags as u32), p.node.nam.clone()),
1474        None => return Vec::new(),
1475    };
1476    if t == PM_ARRAY {
1477        // c:713 — `return v->arr = v->pm->gsu.a->getfn(v->pm);`. That is a
1478        // VTABLE dispatch: `arrgetfn` (c:4054 `return pm->u.arr;`) is only
1479        // the `stdarray_gsu` getfn. The zsh/parameter magic arrays
1480        // (`reswords`, `funcstack`, `dirstack`, …) carry a MODULE getfn and
1481        // have no `u.arr` at all, and this port keeps those rows outside the
1482        // pm, so `getaparam` (c:3101) is where the dispatch is spelled out —
1483        // exactly the split it documents at its own `u_arr` branch.
1484        let has_u_arr = v.pm.as_ref().map_or(false, |p| p.u_arr.is_some());
1485        let arr = if has_u_arr {
1486            arrgetfn(v.pm.as_ref().expect("pm checked above")) // c:4054
1487        } else {
1488            getaparam(&nam).unwrap_or_default() // c:3107
1489        };
1490        v.arr = arr;
1491        return v.arr.clone();
1492    }
1493    if t == PM_HASHED {
1494        // c:715 — `v->arr = paramvalarr(v->pm->gsu.h->getfn(v->pm),
1495        //                               v->scanflags);`
1496        //
1497        // `hashgetfn` (c:4084 `return pm->u.hash;`) is not the assoc
1498        // backing in this port: `arrhashsetfn` (c:4113) stores the pairs in
1499        // `paramtab_hashed_storage` and leaves `u_hash` empty. The
1500        // equivalent of `gsu.h->getfn(pm)` is therefore
1501        // `subst::assoc_get`, which resolves namerefs (c:570-575), imposes
1502        // the `local NAME` shadow over a magic special (c:1090-1115), and
1503        // returns the entries in zsh HASH-BUCKET order — the visit order
1504        // C's `scanhashtable` (Src/hashtable.c:426) produces, which is the
1505        // order the scan emits matches in.
1506        let ht = crate::ported::subst::assoc_get(&nam).unwrap_or_default();
1507        v.arr = paramvalarr(&ht, v.scanflags); // c:715
1508                                               // c:716-718 — `/* Can't take numeric slices of associative
1509                                               // arrays */ v->start = 0; v->end = numparamvals + 1;`.
1510        v.start = 0; // c:716
1511        v.end = (v.arr.len() + 1) as i32; // c:717
1512        return v.arr.clone();
1513    }
1514    Vec::new() // c:719
1515}
1516
1517/// ```c
1518/// struct value vbuf; Value v; int slice; char **arr;
1519/// if (!(v = getvalue(&vbuf, &name, 1)) || *name) return 0;
1520/// if (v->scanflags & ~SCANPM_ARRONLY) return v->end > 1;
1521/// slice = v->start != 0 || v->end != -1;
1522/// if (PM_TYPE(v->pm->node.flags) != PM_ARRAY || !slice)
1523///     return !slice && !(v->pm->node.flags & PM_UNSET);
1524/// if (!v->end) return 0;
1525/// if (!(arr = getvaluearr(v))) return 0;
1526/// return arrlen_ge(arr, v->end < 0 ? - v->end : v->end);
1527/// ```
1528/// Returns 1 if `name` resolves to a set parameter (or a non-empty
1529/// slice/element of one). Used by `[[ -v NAME ]]`/`[[ -n …]]`
1530/// dispatch in cond.c and the readonly-check inside builtin.c.
1531/// Port of `issetvar()` from `Src/params.c:732`.
1532pub fn issetvar(name: &str) -> i32 {
1533    // c:732
1534    let mut vbuf = value {
1535        pm: None,
1536        arr: Vec::new(),
1537        scanflags: 0,
1538        valflags: 0,
1539        start: 0,
1540        end: -1,
1541    };
1542    let mut cursor: &str = name;
1543    let v = match getvalue(Some(&mut vbuf), &mut cursor, 1) {
1544        // c:739
1545        Some(v) => v,
1546        None => return 0,
1547    };
1548    if !cursor.is_empty() {
1549        // c:739
1550        return 0; // c:740 no value or more chars after the variable name
1551    }
1552    // c:741 — `if (v->scanflags & ~SCANPM_ARRONLY) return v->end > 1`.
1553    // The extracted-elements branch keys on EXTRACTION scanflags. zshrs's
1554    // getvalue additionally sets SCANPM_CHECKING (the issetvar/`-v` call
1555    // passes the no-create flag) which C's getvalue does NOT leave in
1556    // v->scanflags — so mask it out here too, else a whole-array `-v arr`
1557    // (scanflags = CHECKING only, end = -1) wrongly took this branch and
1558    // returned `end > 1` = 0.
1559    if (v.scanflags as u32 & !(SCANPM_ARRONLY | SCANPM_CHECKING)) != 0 {
1560        // c:741
1561        return if v.end > 1 { 1 } else { 0 }; // c:742
1562    }
1563
1564    let slice = v.start != 0 || v.end != -1; // c:744
1565    let pm = match v.pm.as_ref() {
1566        Some(p) => p,
1567        None => {
1568            // c:2222-2224 — positional `-v N`: C sets `v->pm = argvparam`
1569            // (the PM_ARRAY over the positionals) and falls through to the
1570            // array range check below. zshrs keeps positionals in PPARAMS
1571            // rather than a real pm, so fetchvalue leaves `pm = None` with
1572            // `start = N-1`, `end = N`; range-check `end` against the
1573            // pparams length to report whether `$N` is set.
1574            if v.end >= 1 && !name.is_empty() && name.bytes().all(|b| b.is_ascii_digit()) {
1575                let len = crate::ported::builtin::PPARAMS
1576                    .lock()
1577                    .map(|p| p.len())
1578                    .unwrap_or(0);
1579                return if (v.end as usize) <= len { 1 } else { 0 };
1580            }
1581            return 0;
1582        }
1583    };
1584    if PM_TYPE(pm.node.flags as u32) != PM_ARRAY || !slice {
1585        // c:745
1586        return if !slice && (pm.node.flags as u32 & PM_UNSET) == 0 {
1587            1
1588        } else {
1589            0
1590        }; // c:746
1591    }
1592
1593    if v.end == 0 {
1594        // c:748 empty array slice
1595        return 0; // c:749
1596    }
1597    // c:751 — get the array and check end is within range
1598    let arr = getvaluearr(Some(v));
1599    if arr.is_empty() {
1600        // c:751
1601        return 0; // c:752
1602    }
1603    // c:753
1604    let bound: usize = if v.end < 0 {
1605        (-v.end) as usize
1606    } else {
1607        v.end as usize
1608    };
1609    if arrlen_ge(&arr, bound) {
1610        1
1611    } else {
1612        0
1613    }
1614}
1615
1616/// Port of `split_env_string()` from `Src/params.c:763`.
1617///
1618/// Walks `env` until either `=` or end. Returns `None` (C `0`) if:
1619///   - any byte before `=` has the high bit set (c:771-777 — names
1620///     outside the portable character set are silently rejected),
1621///   - no `=` is present (c:783-785 fall-through),
1622///   - or the name is empty (`*str == '=' && str == tenv`, c:782).
1623/// Otherwise returns `Some((name, value))` (C `1` + out-params).
1624///
1625/// Out-param style differs from C (we return a tuple); the
1626/// rejection rules are 1:1.
1627pub fn split_env_string(env: &str) -> Option<(String, String)> {
1628    // c:763
1629    if env.is_empty() {
1630        // c:763 !env
1631        return None;
1632    }
1633    let bytes = env.as_bytes();
1634    // c:770-779 — walk name bytes, reject if high bit set.
1635    let mut i = 0;
1636    while i < bytes.len() && bytes[i] != b'=' {
1637        // c:770
1638        if bytes[i] >= 128 {
1639            // c:771 (unsigned char) >= 128
1640            return None; // c:777
1641        }
1642        i += 1;
1643    }
1644    // c:780-785 — accept only if `=` was found at non-zero offset.
1645    if i > 0 && i < bytes.len() && bytes[i] == b'=' {
1646        // c:780
1647        let name = String::from_utf8_lossy(&bytes[..i]).into_owned(); // c:781-782
1648        let value = String::from_utf8_lossy(&bytes[i + 1..]).into_owned(); // c:783
1649        Some((name, value)) // c:784
1650    } else {
1651        None // c:786
1652    }
1653}
1654
1655// parameter entries as well as setting up parameter table                 // c:812
1656// entries for environment variables we inherit.                           // c:813
1657/// Direct port of `createparamtable()` from `Src/params.c:817-988`.
1658///
1659/// Walks the same five-stage init sequence as the C source:
1660///   1. Touch paramtab/realparamtab so the OnceLocks initialise
1661///      (c:835 — newparamtable(151,"paramtab")).
1662///   2. Register every `special_params[]` entry as a PM_SPECIAL
1663///      node in the global paramtab (c:838-847). EMULATE_SH/KSH
1664///      override list (`special_params_sh`) is wired below.
1665///   3. Initialise non-special params that must precede env
1666///      import: MAILCHECK / KEYTIMEOUT / LISTMAX / TMPPREFIX /
1667///      TIMEFMT / HOST / LOGNAME (c:854-879).
1668///   4. Walk std::env::vars() and import each name that is a legal
1669///      ident and not blocked via `dontimport`. Mark PM_EXPORTED
1670///      and stamp the param's env field (c:893-925).
1671///   5. Post-import wiring: HOME PM_UNSET clear + LOGNAME/SHLVL
1672///      env sync, CPUTYPE / MACHTYPE / OSTYPE / TTY / VENDOR /
1673///      ZSH_ARGZERO / ZSH_VERSION / ZSH_PATCHLEVEL (c:931-979).
1674///
1675/// The `noerrs = 2` guard (c:872) is held until the signals[] build
1676/// completes (c:1009). The rest of the C body (ALLEXPORT toggle,
1677/// set_pwd_env, signals[] build with SIGRTMIN..MAX) follows below.
1678/// Port of `extern char **environ` (POSIX, read by `createparamtable`
1679/// at Src/params.c:893). C reads the environment EXACTLY as it was at
1680/// process entry — nothing mutates `environ` before zsh walks it. In
1681/// the Rust binary, linked frameworks can rewrite the live environment
1682/// during their lazy init before our import runs (observed on macOS:
1683/// CoreFoundation recomputes `__CF_USER_TEXT_ENCODING`, so `export -p`
1684/// showed `0x1F5:0x0:0x0` while zsh, inheriting the same env, printed
1685/// the original `0x0:0:0`, and CF *adds* the variable outright when the
1686/// parent never exported it). `main()` fills this OnceLock as its first
1687/// statement; the import loops below prefer the snapshot and fall back to
1688/// the live env (lib tests never run `main`).
1689///
1690/// On macOS the snapshot is not `std::env::vars()` but the process-entry
1691/// `envp` dyld hands to a `__mod_init_func` entry, which CoreFoundation's
1692/// `setenv` cannot have grown — see `initial_env` in `bins/zshrs.rs` for
1693/// the mechanism, the measurements, and the residual value-only caveat.
1694/// Elsewhere it is the live environment, which is already the
1695/// process-entry one.
1696#[allow(non_upper_case_globals)]
1697pub static environ: OnceLock<Vec<(String, String)>> = OnceLock::new();
1698
1699/// Port of `createparamtable()` from `Src/params.c:817`.
1700pub fn createparamtable() {
1701    // c:817
1702
1703    // c:835 — `paramtab = realparamtab = newparamtable(151, "paramtab")`.
1704    let _ = paramtab();
1705    let _ = realparamtab();
1706
1707    // Helper closure (single definition; mirrors the C
1708    // `paramtab->addnode(paramtab, ztrdup(name), ip)` site).
1709    let add_special = |ip: &special_paramdef, tab: &mut hashtable_nodes<Param>| {
1710        // !!! DASH-FAITHFUL GATE (no C counterpart) !!! dash has no $RANDOM /
1711        // $SECONDS: they are ordinary variables there.
1712        if crate::dash_mode::dash_faithful() && matches!(ip.name, "RANDOM" | "SECONDS") {
1713            return;
1714        }
1715        // c:840 — `paramdef->gsu` selects which gsu_scalar vtable the
1716        // new param gets. C uses the per-IPDEF macro's BR(...) field;
1717        // since the Rust special_paramdef doesn't carry a gsu slot
1718        // yet, dispatch by name to the matching `*_GSU` constant
1719        // (HOME_GSU/IFS_GSU/...). Non-special scalars (no match)
1720        // leave gsu_s as None and fall back to strsetfn/strgetfn.
1721        let gsu_s: Option<Box<gsu_scalar>> = match ip.name {
1722            "0" => Some(Box::new(ARGZERO_GSU.clone())), // c:225-226 / IPDEF2("0", argzero_gsu, 0)
1723            "HOME" => Some(Box::new(HOME_GSU.clone())), // c:248
1724            "IFS" => Some(Box::new(IFS_GSU.clone())),   // c:245
1725            "TERM" => Some(Box::new(TERM_GSU.clone())), // c:250
1726            "TERMINFO" => Some(Box::new(TERMINFO_GSU.clone())), // c:251
1727            "TERMINFO_DIRS" => Some(Box::new(TERMINFODIRS_GSU.clone())), // c:252
1728            "WORDCHARS" => Some(Box::new(WORDCHARS_GSU.clone())), // c:249
1729            "USERNAME" => Some(Box::new(USERNAME_GSU.clone())), // c:247
1730            "KEYBOARD_HACK" => Some(Box::new(KEYBOARDHACK_GSU.clone())), // c:253
1731            "HISTCHARS" | "histchars" => Some(Box::new(HISTCHARS_GSU.clone())), // c:246
1732            "LANG" => Some(Box::new(LANG_GSU.clone())), // c:332
1733            "LC_ALL" => Some(Box::new(LC_ALL_GSU.clone())), // c:333
1734            // c:331 `# define LCIPDEF(name) IPDEF2(name, lc_blah_gsu, PM_UNSET)`
1735            // — c:334-347 lists exactly these five categories.
1736            "LC_COLLATE" | "LC_CTYPE" | "LC_MESSAGES" | "LC_NUMERIC" | "LC_TIME" => {
1737                Some(Box::new(LC_BLAH_GSU.clone())) // c:335-346
1738            }
1739            _ => None,
1740        };
1741        let pm = Box::new(param {
1742            node: hashnode {
1743                next: None,
1744                nam: ip.name.to_string(),
1745                flags: (ip.pm_type | ip.pm_flags | PM_SPECIAL) as i32,
1746            },
1747            u_data: 0,
1748            u_tied: None,
1749            u_arr: None,
1750            u_str: None,
1751            u_val: 0,
1752            u_dval: 0.0,
1753            u_hash: None,
1754            gsu_s, // c:840 gsu_s wired
1755            gsu_i: None,
1756            gsu_f: None,
1757            gsu_a: None,
1758            gsu_h: None,
1759            // c:303/351/360/361/369/413 — every INTEGER IPDEF macro
1760            // (IPDEF1, IPDEF4, IPDEF5, IPDEF5U, IPDEF6) writes `10` into
1761            // the initparam `base` slot; the scalar/array ones (IPDEF2,
1762            // IPDEF7*, IPDEF8, IPDEF9) write `0`. printparamnode's
1763            // PMTF_USE_BASE arm (c:6242-6243) prints that base after the
1764            // `integer` word, which is why zsh says `integer 10 UID`.
1765            // Hardcoding 0 here dropped the base for every special
1766            // integer on the createparamtable path (interactive/setupvals);
1767            // the `-fc` path seeds it separately, so only the REPL diverged.
1768            base: if (ip.pm_type & PM_INTEGER) != 0 {
1769                10
1770            } else {
1771                0
1772            },
1773            width: 0,
1774            env: None,
1775            // c:391/401 — IPDEF9/IPDEF8 pass the tied partner's name as
1776            // the `ename` slot (`IPDEF8("PATH", &path, "path", PM_TIED)`).
1777            // printparamnode's PM_TIED arm (c:6256-6288) looks the peer up
1778            // by `p->ename` and prints its name before this one's, giving
1779            // `tied path PATH` / `array tied PATH path`. Leaving it None
1780            // made the peer lookup fail, so the partner name vanished.
1781            ename: ip.tied_name.map(|s| s.to_string()),
1782            old: None,
1783            level: 0,
1784        });
1785        tab.insert(ip.name.to_string(), pm);
1786    };
1787
1788    // c:838-840 — `for (ip = special_params; ip->node.nam; ip++)
1789    //              paramtab->addnode(...)`. Section 1: always loaded.
1790    {
1791        let mut tab = paramtab().write().unwrap();
1792        for ip in special_params[..SPECIAL_PARAMS_ZSH_START].iter() {
1793            add_special(ip, &mut tab);
1794        }
1795    }
1796
1797    // c:840-847 — emulation branch. Under EMULATE_SH/EMULATE_KSH,
1798    // load special_params_sh (scalar versions). Otherwise load
1799    // special_params zsh-only section (the continuation past the
1800    // inner NULL sentinel).
1801    let is_sh_ksh = EMULATION(EMULATE_SH | EMULATE_KSH);
1802    {
1803        let mut tab = paramtab().write().unwrap();
1804        if is_sh_ksh {
1805            // c:841-843 — sh/ksh: scalar replacements.
1806            for ip in special_params_sh.iter() {
1807                add_special(ip, &mut tab);
1808            }
1809        } else {
1810            // c:845-847 — zsh: continuation tail (array-tied + lowercase
1811            // aliases + pipestatus).
1812            for ip in special_params[SPECIAL_PARAMS_ZSH_START..].iter() {
1813                add_special(ip, &mut tab);
1814            }
1815        }
1816    }
1817    // c:Src/params.c:113 — `zlong zsh_funcnest = MAX_FUNCTION_DEPTH;`.
1818    // The IPDEF5("FUNCNEST", &zsh_funcnest, varinteger_gsu) special
1819    // (c:359) reads this statically-initialized global, so $FUNCNEST
1820    // reports MAX_FUNCTION_DEPTH from the first instant the table
1821    // exists. zshrs models FUNCNEST as a stored integer special (no
1822    // backing global / getfn wired in add_special), which defaults to
1823    // u_val=0 — that 0 trips the funcnest guard on the very first
1824    // function call. Seed it here to mirror the C global's static
1825    // initializer. config.h MAX_FUNCTION_DEPTH=500 (Homebrew
1826    // arm-darwin oracle reports `$FUNCNEST` == 500).
1827    setiparam("FUNCNEST", 500); // c:params.c:113
1828
1829    // c:848 — `argvparam = (Param) &argvparam_pm;` is the C handle a
1830    //         positional-param fetchvalue path follows to reach
1831    //         `pparams`. The Rust port resolves $1..$N directly from
1832    //         `PPARAMS` via `value.start`/`value.end` indices (see
1833    //         fetchvalue at params.rs:6395-6407), so no separate
1834    //         Param descriptor is wired up here.
1835    *crate::ported::utils::noerrs_lock().lock().unwrap() = 2; // c:872 noerrs = 2
1836
1837    // c:858-860 — standard non-special params (must precede env import).
1838    setiparam("MAILCHECK", 60); // c:858
1839    setiparam("KEYTIMEOUT", 40); // c:859
1840    setiparam("LISTMAX", 100); // c:860
1841
1842    // c:870-871 — TMPPREFIX / TIMEFMT defaults. C wraps each string
1843    // through ztrdup_metafy() to escape Meta bytes before storing in
1844    // the param table; the Rust port mirrors this.
1845    setsparam("TMPPREFIX", &ztrdup_metafy(DEFAULT_TMPPREFIX)); // c:870
1846    setsparam("TIMEFMT", &ztrdup_metafy(DEFAULT_TIMEFMT)); // c:871
1847
1848    // c:Src/init.c:1214-1215 — `nullcmd = ztrdup("cat");
1849    // readnullcmd = ztrdup(DEFAULT_READNULLCMD);`. In C these are
1850    // globals that the NULLCMD/READNULLCMD IPDEF specials read
1851    // lazily; the stored-param port instead needs the value written
1852    // into the table. The add_special() loop above re-inserts these
1853    // specials with a null u_str, so the seed MUST run after it — do
1854    // it here (before the env-import loop below) so an exported
1855    // NULLCMD/READNULLCMD still wins, exactly like TIMEFMT. Without
1856    // this, the interactive/setupvals path (which calls
1857    // createparamtable, unlike -fc) left NULLCMD unset: sourcing a
1858    // completion cache that uses the `$(<<\EOF ...)` NULLCMD idiom
1859    // (e.g. _docker's docker_subcommands cache) hit "redirection with
1860    // no command" and populated 0 matches under `use-cache on`.
1861    setsparam("NULLCMD", "cat"); // c:1214
1862    setsparam("READNULLCMD", crate::ported::config_h::DEFAULT_READNULLCMD); // c:1215
1863
1864    // c:873-876 — HOST from gethostname() (ztrdup_metafy wrap c:875).
1865    let mut host_buf = [0u8; 256];
1866    let host_rc = unsafe { libc::gethostname(host_buf.as_mut_ptr() as *mut libc::c_char, 256) };
1867    let hostname = if host_rc == 0 {
1868        std::ffi::CStr::from_bytes_until_nul(&host_buf)
1869            .ok()
1870            .and_then(|c| c.to_str().ok())
1871            .unwrap_or("")
1872            .to_string()
1873    } else {
1874        String::new()
1875    };
1876    setsparam("HOST", &ztrdup_metafy(&hostname)); // c:875
1877
1878    // c:878-882 — LOGNAME from `getlogin()` libc syscall (with
1879    // \`cached_username\` as fallback when DISABLE_DYNAMIC_NSS).
1880    //
1881    // The previous Rust port read \`env::var(\"LOGNAME\")\` /
1882    // \`env::var(\"USER\")\` — different source. \`getlogin\` returns the
1883    // kernel's record of the controlling-terminal login user; env
1884    // LOGNAME/USER is whatever the parent process passed in (can be
1885    // spoofed). For audit / SUID-aware code paths, the kernel's view
1886    // is the right one.
1887    let logname = unsafe {
1888        let p = libc::getlogin();
1889        if p.is_null() {
1890            String::new()
1891        } else {
1892            std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned()
1893        }
1894    }; // c:880 getlogin()
1895    let logname = if logname.is_empty() {
1896        // c:882 — `ztrdup(cached_username)` fallback.
1897        get_username()
1898    } else {
1899        logname
1900    };
1901    setsparam("LOGNAME", &ztrdup_metafy(&logname)); // c:878
1902
1903    // c:891 — pushheap() / c:921 — popheap(). Wraps the env-import
1904    // loop so per-iter allocations land on the heap zone.
1905    pushheap(); // c:891
1906
1907    // c:893-924 — environment import loop. Walk the process-entry
1908    // `environ` snapshot like C, not the live (possibly framework-
1909    // mutated) environment — see the `environ` static above.
1910    let environ_vars: Vec<(String, String)> = environ
1911        .get()
1912        .cloned()
1913        .unwrap_or_else(|| env::vars().collect());
1914    for (iname, ivalue) in environ_vars {
1915        if iname.is_empty() {
1916            continue;
1917        }
1918        // c:897 — leading-digit reject (`!idigit(*iname)`).
1919        if iname.as_bytes()[0].is_ascii_digit() {
1920            continue;
1921        }
1922        // c:897 — must be a valid identifier.
1923        if !isident(&iname) {
1924            continue;
1925        }
1926        // c:897 — `!strchr(iname, '[')` reject subscripted names.
1927        if iname.contains('[') {
1928            continue;
1929        }
1930        // c:902-906 — block if PM_DONTIMPORT-family flags say so.
1931        let blocked = {
1932            let tab = paramtab().read().unwrap();
1933            tab.get(&iname)
1934                .map(|pm| dontimport(pm.node.flags) != 0)
1935                .unwrap_or(false)
1936        };
1937        if blocked {
1938            continue;
1939        }
1940        // c:907-908 — assignsparam(..., ASSPM_ENV_IMPORT).
1941        let metafied = metafy(&ivalue);
1942        let _ = assignsparam(&iname, &metafied, ASSPM_ENV_IMPORT);
1943        // c:909-915 — stamp PM_EXPORTED and the env-side string.
1944        let mut tab = paramtab().write().unwrap();
1945        if let Some(pm) = tab.get_mut(&iname) {
1946            pm.node.flags |= PM_EXPORTED as i32;
1947            let env_str = if pm.node.flags & PM_SPECIAL as i32 != 0 {
1948                // c:912 — `pm->env = mkenvstr(pm->node.nam,
1949                // getsparam(pm->node.nam), pm->node.flags)`. For
1950                // special params the C body re-fetches the
1951                // canonical string via getsparam; we use ivalue
1952                // here (already metafied above).
1953                mkenvstr(&iname, &ivalue, pm.node.flags)
1954            } else {
1955                // c:914 — `pm->env = ztrdup(*envp2)` for non-special:
1956                // direct env-line copy.
1957                format!("{}={}", iname, ivalue)
1958            };
1959            pm.env = Some(env_str);
1960        }
1961    }
1962
1963    popheap(); // c:921
1964
1965    // c:933-944 — HOME / LOGNAME / SHLVL post-import wiring.
1966    //
1967    // C body (verbatim):
1968    //   pm = paramtab->getnode(paramtab, "HOME");
1969    //   if (EMULATION(EMULATE_ZSH)) {
1970    //       pm->node.flags &= ~PM_UNSET;
1971    //       if (!(pm->node.flags & PM_EXPORTED))
1972    //           addenv(pm, home);
1973    //   } else if (!home)
1974    //       pm->node.flags |= PM_UNSET;
1975    //   pm = paramtab->getnode(paramtab, "LOGNAME");
1976    //   if (!(pm->node.flags & PM_EXPORTED))
1977    //       addenv(pm, pm->u.str);
1978    //   pm = paramtab->getnode(paramtab, "SHLVL");
1979    //   sprintf(buf, "%d", (int)++shlvl);
1980    //   addenv(pm, buf);
1981
1982    // c:938-945 — HOME. EMULATE_ZSH path clears PM_UNSET and
1983    // addenv(home) when not already exported; non-zsh path sets
1984    // PM_UNSET when `home` is empty/unset.
1985    let is_zsh = EMULATION(EMULATE_ZSH);
1986    let home_val = home_lock().lock().expect("home poisoned").clone();
1987    let home_action: Option<bool> = {
1988        let mut tab = paramtab().write().unwrap();
1989        if let Some(pm) = tab.get_mut("HOME") {
1990            if is_zsh {
1991                // c:939
1992                pm.node.flags &= !(PM_UNSET as i32); // c:941
1993                if pm.node.flags & PM_EXPORTED as i32 == 0 {
1994                    // c:942
1995                    Some(true)
1996                } else {
1997                    Some(false)
1998                }
1999            } else if home_val.is_empty() {
2000                // c:944
2001                pm.node.flags |= PM_UNSET as i32; // c:945
2002                Some(false)
2003            } else {
2004                Some(false)
2005            }
2006        } else {
2007            None
2008        }
2009    };
2010    if let Some(true) = home_action {
2011        addenv("HOME", &home_val); // c:943
2012    }
2013
2014    // c:946-948 — LOGNAME. If not already exported, addenv(pm, pm->u.str).
2015    let logname_export: Option<String> = {
2016        let tab = paramtab().read().unwrap();
2017        tab.get("LOGNAME").and_then(|pm| {
2018            if pm.node.flags & PM_EXPORTED as i32 == 0 {
2019                pm.u_str.clone()
2020            } else {
2021                None
2022            }
2023        })
2024    };
2025    if let Some(ustr) = logname_export {
2026        addenv("LOGNAME", &ustr); // c:948
2027    }
2028
2029    // c:949-953 — SHLVL: unconditionally addenv with the incremented
2030    // value. C uses the \`shlvl\` integer global (IPDEF5 declared at
2031    // params.c:358 with varinteger_gsu) which was populated during
2032    // env-import. C: \`++shlvl\` then \`sprintf(buf, \"%d\", (int)shlvl)\`.
2033    //
2034    // The previous Rust port read SHLVL fresh from env::var; the
2035    // canonical read is through paramtab (which has the parsed
2036    // integer post-import). Falls back to env for the rare case
2037    // where paramtab hasn't seen the import yet.
2038    let new_shlvl: i32 = getsparam("SHLVL")
2039        .or_else(|| env::var("SHLVL").ok())
2040        .and_then(|s| s.parse().ok())
2041        .unwrap_or(0)
2042        + 1; // c:951 `++shlvl`
2043    setiparam("SHLVL", new_shlvl as i64);
2044    addenv("SHLVL", &new_shlvl.to_string()); // c:953
2045
2046    // c:949-967 — CPUTYPE / MACHTYPE / OSTYPE / TTY / VENDOR /
2047    // ZSH_ARGZERO / ZSH_VERSION / ZSH_PATCHLEVEL. C body wraps each
2048    // through ztrdup_metafy() — Rust mirrors that. CPUTYPE is set
2049    // from uname()'s `machine` field at runtime (c:957-961); the
2050    // other three (MACHTYPE / OSTYPE / VENDOR) come from config.h
2051    // values frozen at configure-time (c:961, c:963, c:964).
2052    let utsname = nix::sys::utsname::uname().ok();
2053    let cputype = utsname
2054        .as_ref()
2055        .map(|u| u.machine().to_string_lossy().to_string())
2056        .unwrap_or_else(|| "unknown".to_string());
2057    setsparam("CPUTYPE", &ztrdup_metafy(&cputype)); // c:954/960
2058    setsparam(
2059        // c:961
2060        "MACHTYPE",
2061        &ztrdup_metafy(MACHTYPE),
2062    );
2063    setsparam(
2064        // c:962
2065        "OSTYPE",
2066        &ztrdup_metafy(OSTYPE),
2067    );
2068    let tty_str = {
2069        let p = unsafe { libc::ttyname(0) };
2070        if !p.is_null() {
2071            unsafe { std::ffi::CStr::from_ptr(p) }
2072                .to_string_lossy()
2073                .to_string()
2074        } else {
2075            String::new()
2076        }
2077    };
2078    setsparam("TTY", &ztrdup_metafy(&tty_str)); // c:963
2079    setsparam(
2080        // c:964
2081        "VENDOR",
2082        &ztrdup_metafy(VENDOR),
2083    );
2084    let argv0 = env::args().next().unwrap_or_default();
2085    setsparam("ZSH_ARGZERO", &ztrdup(&argv0)); // c:965 (ztrdup, not _metafy: posixzero)
2086    setsparam("ZSH_VERSION", &ztrdup_metafy(ZSH_VERSION)); // c:966 (Config/version.mk VERSION via patchlevel::ZSH_VERSION)
2087    setsparam("ZSH_PATCHLEVEL", &ztrdup_metafy(ZSH_PATCHLEVEL)); // c:967
2088                                                                 // zshrs-only identity. No C counterpart. Surfaced so scripts can
2089                                                                 // detect zshrs (vs. upstream zsh) cleanly without inspecting a
2090                                                                 // `-test` suffix on `$ZSH_VERSION`. See `patchlevel::ZSHRS_VERSION`
2091                                                                 // for the value and bug #73 in docs/BUGS.md for the rationale.
2092                                                                 //
2093                                                                 // Suppressed whenever the zsh-compatible namespace must not
2094                                                                 // carry zshrs-original names, so `${(k)parameters}` matches
2095                                                                 // reference zsh's name set (PM_HIDE doesn't filter from the (k)
2096                                                                 // listing path — C's scanpmparameters only skips PM_UNSET, not
2097                                                                 // PM_HIDE; outright skipping the setsparam call is the only way
2098                                                                 // to keep the name out of the listing). Direct access falls back
2099                                                                 // to an empty value, same as any other unset name.
2100                                                                 //
2101                                                                 // `hide_ext_builtins()` is that predicate: `--zsh` OR the
2102                                                                 // `ZSHRS_HIDE_EXT_BUILTINS` measurement knob the parity
2103                                                                 // harnesses already set (extensions/ext_builtins.rs:48). Gating
2104                                                                 // on `--zsh` ALONE left ZSHRS_VERSION in `${(ko)parameters}` for
2105                                                                 // the native binary, which is what the harnesses actually run.
2106    if !crate::ext_builtins::hide_ext_builtins() {
2107        setsparam(
2108            "ZSHRS_VERSION",
2109            &ztrdup_metafy(crate::ported::patchlevel::ZSHRS_VERSION),
2110        );
2111    }
2112
2113    // c:968-979 — `setaparam("signals", sigptr = zalloc((TRAPCOUNT
2114    // + 1) * sizeof(char *))); t = sigs; while (t - sigs <= SIGCOUNT)
2115    // *sigptr++ = ztrdup_metafy(*t++); { for (sig = SIGRTMIN; sig <=
2116    // SIGRTMAX; sig++) *sigptr++ = ztrdup_metafy(rtsigname(sig, 0));
2117    // } while ((*sigptr++ = ztrdup_metafy(*t++))) ;`. Builds the
2118    // $signals array: indices 0..=SIGCOUNT walked from the static
2119    // sigs[] name table, then SIGRTMIN..SIGRTMAX names, then the
2120    // trailing tail (DEBUG / ERR / EXIT / ZERR sentinels).
2121    // c:signames.c sigs[] (generated) — index 0 is "EXIT", entries
2122    // 1..=SIGCOUNT in PLATFORM SIGNAL-NUMBER order, tail "ZERR",
2123    // "DEBUG" (zsh.h SIGZERR/SIGDEBUG). SIGS is declared in Linux
2124    // textual order — sort by libc number to reproduce the generated
2125    // table on every platform. Same construction as vm_helper.rs —
2126    // keep in sync.
2127    let mut by_num: Vec<(&str, i32)> = SIGS.to_vec();
2128    by_num.sort_by_key(|&(_, n)| n);
2129    let mut signals_arr: Vec<String> = Vec::with_capacity(by_num.len() + 3);
2130    signals_arr.push(ztrdup_metafy("EXIT")); // c:sigs[0]
2131    for &(name, _num) in by_num.iter() {
2132        signals_arr.push(ztrdup_metafy(name));
2133    }
2134    // RT-signal range (Linux-only; macOS SIGS table already includes
2135    // the realtime names and rtsigname returns "" out of range).
2136    #[cfg(target_os = "linux")]
2137    {
2138        for sig in libc::SIGRTMIN()..=libc::SIGRTMAX() {
2139            let nm = crate::ported::signals::rtsigname(sig);
2140            if !nm.is_empty() {
2141                signals_arr.push(ztrdup_metafy(&nm));
2142            }
2143        }
2144    }
2145    signals_arr.push(ztrdup_metafy("ZERR")); // c:sigs tail
2146    signals_arr.push(ztrdup_metafy("DEBUG")); // c:sigs tail
2147    {
2148        let mut tab = paramtab().write().unwrap();
2149        let pm = Box::new(param {
2150            node: hashnode {
2151                next: None,
2152                nam: "signals".to_string(),
2153                // c:974 builds it with plain `setaparam`, i.e.
2154                // createparam(name, PM_ARRAY) — `signals` is an ORDINARY
2155                // array holding a snapshot of the signal names, not a
2156                // special with a gsu. Stamping PM_SPECIAL made
2157                // `${(t)signals}` read `array-special` against zsh's
2158                // bare `array`.
2159                flags: PM_ARRAY as i32,
2160            },
2161            u_data: 0,
2162            u_tied: None,
2163            u_arr: Some(signals_arr),
2164            u_str: None,
2165            u_val: 0,
2166            u_dval: 0.0,
2167            u_hash: None,
2168            gsu_s: None,
2169            gsu_i: None,
2170            gsu_f: None,
2171            gsu_a: None,
2172            gsu_h: None,
2173            base: 0,
2174            width: 0,
2175            env: None,
2176            ename: None,
2177            old: None,
2178            level: 0,
2179        });
2180        tab.insert("signals".to_string(), pm);
2181    }
2182
2183    *crate::ported::utils::noerrs_lock().lock().unwrap() = 0; // c:1009 noerrs = 0
2184}
2185
2186/// Parallel storage for PM_HASHED parameter values. `param.u_hash`
2187/// is typed `Option<HashTable>` per Src/zsh.h:1841 but the full
2188/// HashTable substrate isn't wired yet; the assoc-array values live
2189/// here keyed on param name until that lands.
2190static PARAMTAB_HASHED_STORAGE_INNER: OnceLock<
2191    Mutex<crate::cow_map::CowHashMap<String, IndexMap<String, String>>>,
2192> = OnceLock::new();
2193
2194/// Port of `assigngetset()` from `Src/params.c:994`. C body
2195/// installs the standard get/set/unset vtable matching the
2196/// param's PM_TYPE so subsequent assignment dispatches go
2197/// through `pm->gsu.X->setfn`.
2198pub fn assigngetset(pm: &mut param) {
2199    match PM_TYPE(pm.node.flags as u32) {
2200        x if x == PM_SCALAR || x == PM_NAMEREF => {
2201            pm.gsu_s = Some(Box::new(gsu_scalar {
2202                getfn: strgetfn,
2203                setfn: strsetfn,
2204                unsetfn: stdunsetfn,
2205            }));
2206        }
2207        x if x == PM_INTEGER => {
2208            pm.gsu_i = Some(Box::new(gsu_integer {
2209                getfn: intgetfn,
2210                setfn: intsetfn,
2211                unsetfn: stdunsetfn,
2212            }));
2213        }
2214        x if x == PM_EFLOAT || x == PM_FFLOAT => {
2215            pm.gsu_f = Some(Box::new(gsu_float {
2216                getfn: floatgetfn,
2217                setfn: floatsetfn,
2218                unsetfn: stdunsetfn,
2219            }));
2220        }
2221        x if x == PM_ARRAY => {
2222            pm.gsu_a = Some(Box::new(gsu_array {
2223                getfn: arrgetfn,
2224                setfn: arrsetfn,
2225                unsetfn: stdunsetfn,
2226            }));
2227        }
2228        x if x == PM_HASHED => {
2229            pm.gsu_h = Some(Box::new(gsu_hash {
2230                getfn: hashgetfn,
2231                setfn: hashsetfn,
2232                unsetfn: stdunsetfn,
2233            }));
2234        }
2235        _ => {
2236            // c:1015 — DPUTS(1, "BUG: tried to create param node without valid flag")
2237            DPUTS!(true, "BUG: tried to create param node without valid flag");
2238            // c:1015
2239        }
2240    }
2241}
2242
2243/// Port of `createparam()` from `Src/params.c:1030`. C body
2244/// (~130 lines, see comment header at c:1020-1027) creates a
2245/// parameter so that it can be assigned to. Returns NULL if the
2246/// parameter already exists or can't be created, otherwise
2247/// returns the new node. If a parameter of the same name exists
2248/// in an outer scope, it is hidden by the new one. An already
2249/// existing node at the current level may be "created" and
2250/// returned provided it is unset and not special. If the
2251/// parameter can't be created because it already exists,
2252/// PM_UNSET is cleared.
2253///
2254/// Faithful port covers:
2255/// - PM_HASHELEM / PM_EXPORTED tweak when paramtab != realparamtab (c:1034)
2256/// - PM_RO_BY_DESIGN read-only rejection (c:1043-1052)
2257/// - PM_NAMEREF chain follow via `resolve_nameref_rec` (c:1062-1104)
2258/// - hidden vs reuse-old branches (c:1108-1147)
2259/// - `pm->node.flags = flags & ~PM_LOCAL` finalization (c:1155)
2260/// - `assigngetset(pm)` for non-special params (c:1157-1158)
2261///
2262/// Paramtab-backed branches (c:1034 paramtab compare, c:1038
2263/// gethashnode2, c:1144-1146 paramtab.removenode/addnode) cannot
2264/// fully execute until the paramtab vtable lands; they are
2265/// preserved as architectural intent. The faithful behaviour
2266/// emerges as soon as paramtab is wired (no signature drift
2267/// at this site).
2268pub fn createparam(
2269    // c:1030
2270    name: &str,
2271    mut flags: i32,
2272) -> Option<Param> {
2273    // c:1034-1035 — when paramtab != realparamtab (we're inside
2274    // a hash-element scope), strip PM_EXPORTED + add PM_HASHELEM.
2275    // Without paramtab/realparamtab live yet, this branch is
2276    // skipped — the caller is expected to be in the
2277    // realparamtab scope which is the common case.
2278
2279    // c:1037 — `if (name != nulstring) { ... } else { hcalloc; nulstring }`
2280    // c:1038-1041 — oldpm = gethashnode2(paramtab, name)
2281    //   Without paramtab backend, we cannot consult the table; treat
2282    //   the param as new. The PM_RO_BY_DESIGN / PM_NAMEREF / hidden
2283    //   branches (c:1043-1147) collapse to "allocate fresh".
2284    // c:1037-1041 — `oldpm = gethashnode2(paramtab, name)`. Look up
2285    // any existing Param at this name so the c:1108/1135 branches
2286    // can decide reuse-vs-shadow. PM_RO_BY_DESIGN / PM_NAMEREF
2287    // chase branches (c:1043-1104) elided — covered when nameref
2288    // / readonly-by-design Params are wired.
2289    let mut oldpm: Option<Param> = if !name.is_empty() {
2290        paramtab().read().ok().and_then(|t| t.get(name).cloned())
2291    } else {
2292        None
2293    };
2294
2295    // c:1064-1080 — PM_RO_BY_DESIGN rejection, verbatim:
2296    //
2297    //     if (oldpm && (oldpm->node.flags & PM_RO_BY_DESIGN)) {
2298    //         if (!(flags & PM_LOCAL)) {
2299    //             /* Must call the API for namerefs and specials to work */
2300    //             pm = (Param) paramtab->getnode2(paramtab, oldpm->node.nam);
2301    //             if (!pm || ((pm->node.flags & PM_NAMEREF) &&
2302    //                         pm->level != locallevel)) {
2303    //                 zerr("%s: can't modify read-only parameter", name);
2304    //                 return NULL;
2305    //             }
2306    //         }
2307    //         /**
2308    //          * Implementation note: In the case of a readonly nameref,
2309    //          * the right thing might be to insert a new global into
2310    //          * the paramtab and point the local pm->old at it, rather
2311    //          * than error.  That is why gethashnode2() is called
2312    //          * first, to avoid skipping up the stack prematurely.
2313    //          **/
2314    //     }
2315    //
2316    // `paramtab->getnode2` is `getprivatenode2`
2317    // (c:Src/Modules/param_private.c:617, installed by setup_ at c:679)
2318    // whenever zsh/param/private is loaded, which is what makes a private
2319    // belonging to an OUTER scope resolve to NULL here and turn a deeper
2320    // scope's assignment into this error instead of a silent new global
2321    // (V10private.ztst:41,42). Without the block zshrs fell through to
2322    // assignstrvalue's generic PM_READONLY guard, whose wording
2323    // (`read-only variable: q`) is a different message.
2324    if let Some(op) = oldpm.clone() {
2325        // c:1064
2326        if (op.node.flags as u32 & PM_RO_BY_DESIGN) != 0 && (flags as u32 & PM_LOCAL) == 0 {
2327            // c:1064-1065
2328            // c:1067 — `pm = paramtab->getnode2(paramtab, oldpm->node.nam);`
2329            let visible =
2330                crate::ported::modules::param_private::getprivatenode2(&*op as *const param);
2331            let reject = if visible.is_null() {
2332                true // c:1068 `!pm`
2333            } else {
2334                let vpm: &param = unsafe { &*visible };
2335                (vpm.node.flags as u32 & PM_NAMEREF) != 0
2336                    && vpm.level != locallevel.load(Ordering::Relaxed) as i32
2337                // c:1068-1069
2338            };
2339            if reject {
2340                zerr(&format!("{}: can't modify read-only parameter", name)); // c:1071
2341                return None; // c:1072
2342            }
2343        }
2344    }
2345
2346    // c:1086-1125 — the PM_NAMEREF chase. Previously elided; ported
2347    // here verbatim.
2348    //
2349    //     if (oldpm && !(flags & PM_NAMEREF) &&
2350    //         (oldpm->level == locallevel ?
2351    //          !(oldpm->node.flags & PM_RO_BY_DESIGN) : !(flags & PM_LOCAL)) &&
2352    //         (oldpm->node.flags & PM_NAMEREF) &&
2353    //         (!(oldpm->node.flags & PM_UNSET) ||
2354    //          (oldpm->node.flags & PM_DECLARED))) {
2355    //
2356    // c:1091-1095 — "Here we only have to deal with namerefs that refer
2357    // to not-yet-defined or unset variable. All other namerefs have
2358    // already been taken care of by the resolve_nameref in
2359    // typeset_single. It's unclear why these can't be handled there
2360    // too."
2361    //
2362    // C rebinds the local `name` pointer (c:1105) / the local `oldpm`
2363    // (c:1121) and lets the reuse arm below act on the RETARGETED node;
2364    // because that arm keeps the same struct, the retargeted node stays
2365    // in `paramtab` under its OWN key. zshrs's table is name-keyed and
2366    // re-inserts at the end, so the rebind has to carry the key too.
2367    let mut name_owned: String;
2368    let mut name: &str = name;
2369    if let Some(op) = oldpm.clone() {
2370        let opf = op.node.flags as u32;
2371        let ll = locallevel.load(Ordering::Relaxed) as i32;
2372        // c:1086-1090
2373        if (flags as u32 & PM_NAMEREF) == 0
2374            && (if op.level == ll {
2375                (opf & PM_RO_BY_DESIGN) == 0 // c:1088
2376            } else {
2377                (flags as u32 & PM_LOCAL) == 0 // c:1088
2378            })
2379            && (opf & PM_NAMEREF) != 0 // c:1089
2380            && ((opf & PM_UNSET) == 0 || (opf & PM_DECLARED) != 0)
2381        // c:1090
2382        {
2383            // c:1097 — `Param lastpm = resolve_nameref_rec(oldpm, NULL, 1);`
2384            let lastpm = resolve_nameref_rec(Some(op), None, 1);
2385            if let Some(lastpm) = lastpm {
2386                // c:1098
2387                let lf = lastpm.node.flags as u32;
2388                // c:1099-1101
2389                if (lf & PM_NAMEREF) != 0 && ((lf & PM_UNSET) == 0 || (lf & PM_DECLARED) != 0) {
2390                    // c:1102 — `char *refname = GETREFNAME(lastpm);`
2391                    let refname = lastpm.u_str.clone().unwrap_or_default();
2392                    if !refname.is_empty() {
2393                        // c:1103 — `if (refname && *refname)`
2394                        // c:1104 — nameref pointing to a not-yet-defined variable
2395                        name_owned = refname; // c:1105 `name = refname;`
2396                        name = &name_owned;
2397                        oldpm = None; // c:1106
2398                    } else {
2399                        // c:1108 — nameref pointing to an uninitialized nameref
2400                        if (lf & PM_READONLY) == 0 {
2401                            // c:1109
2402                            if (flags & !(PM_LOCAL as i32)) != 0 {
2403                                // c:1110
2404                                // c:1111 — Only plain scalar assignment allowed
2405                                // c:1112-1113 — Differs from ksh93u+
2406                                zerr(&format!("{}: can't change type of named reference", name));
2407                                return None; // c:1114
2408                            }
2409                        }
2410                        return Some(lastpm); // c:1117
2411                    }
2412                } else {
2413                    // c:1120 — nameref pointing to an unset local
2414                    DPUTS!(
2415                        (lf & PM_UNSET) == 0,                // c:1121
2416                        "BUG: local parameter is not unset"  // c:1122
2417                    );
2418                    name_owned = lastpm.node.nam.clone(); // Rust-only: name-keyed table
2419                    name = &name_owned;
2420                    oldpm = Some(lastpm); // c:1123
2421                }
2422            }
2423        }
2424    }
2425
2426    if !name.is_empty() {
2427        // c:1149-1150 — `if (isset(ALLEXPORT) && !(flags & PM_HASHELEM)) flags |= PM_EXPORTED;`
2428        if isset(ALLEXPORT) && (flags as u32 & PM_HASHELEM) == 0 {
2429            flags |= PM_EXPORTED as i32;
2430        }
2431    }
2432
2433    // c:1108 — `if (oldpm && (oldpm->level == locallevel || !(flags
2434    // & PM_LOCAL)))`: reuse the existing Param in place. c:1135 —
2435    // else allocate a fresh pm and chain pm.old = oldpm (the
2436    // local-shadow path). The reuse arm just returns the existing
2437    // pm with reset base/width; the shadow arm does the chain
2438    // installation that endparamscope later unwinds.
2439    let cur_locallevel = locallevel.load(Ordering::Relaxed);
2440    // c:1106-1107 — DPUTS(oldpm && oldpm->level > locallevel,
2441    //                    "BUG: old local parameter not deleted");
2442    DPUTS!(
2443        // c:1106
2444        match &oldpm {
2445            // c:1106
2446            Some(op) => op.level > cur_locallevel, // c:1106
2447            None => false,                         // c:1106
2448        },
2449        "BUG: old local parameter not deleted" // c:1107
2450    );
2451    let reuse = match &oldpm {
2452        Some(op) => op.level == cur_locallevel || (flags as u32 & PM_LOCAL) == 0,
2453        None => false,
2454    };
2455
2456    // c:1130-1152 — an existing node at this level is only taken over when
2457    // it is unset and not special; otherwise createparam declines:
2458    //
2459    //     if (isset(POSIXBUILTINS) && (oldpm->node.flags & PM_READONLY)) {
2460    //         zerr("read-only variable: %s", name);
2461    //         return NULL;
2462    //     }
2463    //     if (!(oldpm->node.flags & PM_UNSET) ||
2464    //         (oldpm->node.flags & PM_SPECIAL) ||
2465    //         /* POSIXBUILTINS horror: we need to retain 'export' flags */
2466    //         (isset(POSIXBUILTINS) && (oldpm->node.flags & PM_EXPORTED))) {
2467    //         if (oldpm->node.flags & PM_RO_BY_DESIGN) { zerr(...); return NULL; }
2468    //         oldpm->node.flags &= ~PM_UNSET;
2469    //         if ((oldpm->node.flags & PM_SPECIAL) && oldpm->ename) {
2470    //             Param altpm = (Param) paramtab->getnode(paramtab, oldpm->ename);
2471    //             if (altpm)
2472    //                 altpm->node.flags &= ~PM_UNSET;
2473    //         }
2474    //         return NULL;
2475    //     }
2476    //
2477    // "If the parameter can't be created because it already exists, the
2478    // PM_UNSET flag is cleared" (c:1045-1046). The caller then finds the
2479    // revived node with getnode — e.g. assignnparam's c:3696 fallback — so an
2480    // unset special such as SHLVL keeps its PM_SPECIAL|PM_EXPORTED identity
2481    // when `(( SHLVL++ ))` recreates it.
2482    if reuse {
2483        let op = oldpm.as_ref().unwrap(); // reuse=true requires Some
2484        let opf = op.node.flags as u32;
2485        if isset(crate::ported::zsh_h::POSIXBUILTINS) && (opf & PM_READONLY) != 0 {
2486            // c:1131
2487            zerr(&format!("read-only variable: {}", name)); // c:1132
2488            return None; // c:1133
2489        }
2490        if (opf & PM_UNSET) == 0
2491            || (opf & PM_SPECIAL) != 0
2492            || (isset(crate::ported::zsh_h::POSIXBUILTINS) && (opf & PM_EXPORTED) != 0)
2493        {
2494            // c:1135-1138
2495            if (opf & PM_RO_BY_DESIGN) != 0 {
2496                // c:1139
2497                zerr(&format!("{}: can't modify read-only parameter", name)); // c:1140-1141
2498                return None; // c:1142
2499            }
2500            if let Ok(mut tab) = paramtab().write() {
2501                if let Some(live) = tab.get_mut(name) {
2502                    live.node.flags &= !(PM_UNSET as i32); // c:1144
2503                }
2504                if (opf & PM_SPECIAL) != 0 {
2505                    // c:1145
2506                    if let Some(alt) = op.ename.as_deref() {
2507                        if let Some(altpm) = tab.get_mut(alt) {
2508                            // c:1146-1149
2509                            altpm.node.flags &= !(PM_UNSET as i32); // c:1149
2510                        }
2511                    }
2512                }
2513            }
2514            return None; // c:1151
2515        }
2516    }
2517
2518    // c:Src/builtin.c:2608-2609 — set by the shadow arm below when the
2519    // tied peer is already local at this level (c:2394-2408), which makes
2520    // `pm->old` carry PM_NORESTORE and so suppresses the
2521    // "re-initialise the special parameter to empty" step:
2522    //     } else if (newspecial != NS_NONE &&
2523    //                !(pm->old->node.flags & (PM_NORESTORE|PM_READONLY))) {
2524    //         ... pm->gsu.a->setfn(pm, mkarray(NULL)); ...
2525    // C keeps the SAME Param struct for a special, so "not zeroed" means
2526    // the live value simply stays; zshrs allocates a fresh shadow struct,
2527    // so the live value has to be carried over explicitly.
2528    let mut inherit_live_value = false;
2529
2530    // c:1137-1146 — the shadow arm removes the displaced node from the
2531    // bucket chain BEFORE re-adding the fresh one:
2532    //     if ((pm->old = oldpm)) {
2533    //         if (oldpm->env) delenv(oldpm);
2534    //         paramtab->removenode(paramtab, name);
2535    //     }
2536    //     paramtab->addnode(paramtab, ztrdup(name), pm);
2537    // `addhashnode2` (Src/hashtable.c:189-213) replaces a key IN PLACE, so
2538    // without the removenode the name keeps its old chain position; with it
2539    // the name moves to the FRONT of its bucket (c:216-218). That position is
2540    // observable — `${(k)parameters}` walks bucket 0..hsize-1, chain
2541    // head→tail (`scanmatchtable`, Src/hashtable.c:426-435) — and drives the
2542    // group/column layout `_parameters` produces.
2543    let mut shadow_displaced = false;
2544
2545    let mut pm: Param = if reuse {
2546        // c:1132-1134 — `pm = oldpm; pm->base = pm->width = 0;
2547        // oldpm = pm->old;` Reuse the entry already in paramtab.
2548        let mut existing = oldpm.unwrap(); // safe: reuse=true requires Some
2549        existing.base = 0; // c:1133
2550        existing.width = 0; // c:1133
2551        existing
2552    } else {
2553        // c:1136 zshcalloc(sizeof *pm) — fresh allocation; chain the
2554        // outer Param into pm.old (c:1137) so endparamscope can
2555        // restore it. c:1144 paramtab->removenode is implicit since
2556        // we re-insert below.
2557        //
2558        // c:Src/builtin.c:2382-2424 newspecial path — for PM_SPECIAL
2559        // shadows (`local IFS=...`), the C source allocates a
2560        // separate `tpm`, calls `copyparam(tpm, pm, 1)` which uses
2561        // the GSU getfn to read the current value into tpm.u.str,
2562        // then sets `pm->old = tpm`. zshrs's createparam path moves
2563        // `oldpm` into pm.old as-is; for specials whose value lives
2564        // in a global (ifs_lock, paramtab-external) the bare
2565        // `oldpm.u_str` is empty and endparamscope can't restore the
2566        // real outer value. Snapshot the current value via the GSU
2567        // getfn now so the saved chain carries the right string.
2568        // Bug #8 in docs/BUGS.md (`local IFS=:` leaked past return).
2569        //
2570        // c:Src/builtin.c:2382-2424 copyparam for PM_HASHED — same
2571        // shape, different storage. zshrs's PM_HASHED data lives in
2572        // the parallel `paramtab_hashed_storage` map keyed by name (no
2573        // scope dimension), so a `local -A h` shadow that writes
2574        // through set_assoc would clobber the outer scope's bag and
2575        // endparamscope's pm.old restoration alone wouldn't recover
2576        // it. Push the current paramtab_hashed_storage[name] onto the
2577        // shadow stack BEFORE the fresh pm gets installed, so when
2578        // endparamscope unwinds the PM_HASHED stale entry it can pop
2579        // and restore. Bug #415.
2580        let oldpm = if let Some(mut op) = oldpm {
2581            // c:1137 — `if ((pm->old = oldpm))` guards the c:1144
2582            // `paramtab->removenode(paramtab, name)` applied below.
2583            shadow_displaced = true;
2584            // c:Src/builtin.c:2394-2411 — the tied-pair NORESTORE test, run
2585            // BEFORE the copyparam snapshot because it REPLACES it:
2586            //     if (pm->ename &&
2587            //         (pm2 = (Param) paramtab->getnode(paramtab, pm->ename)) &&
2588            //         pm2->level == locallevel) {
2589            //         /* This is getting silly, but anyway:  if one of a
2590            //          * path/PATH pair has already been made local at the
2591            //          * current level, we have to make sure that the other one
2592            //          * does not have its value saved:  since that comes from
2593            //          * an internal variable it will already reflect the local
2594            //          * value, so restoring it on exit would be wrong. */
2595            //         tpm->node.flags = pm->node.flags | PM_NORESTORE;
2596            //     } else {
2597            //         copyparam(tpm, pm, 1);
2598            //     }
2599            // The C test is only reached from the `newspecial != NS_NONE` arm
2600            // (c:2377); the caller signals that decision by ORing PM_SPECIAL
2601            // into `flags` (see the `keep_special` computation at
2602            // builtin.rs:5681, which is c:2083-2085 verbatim).
2603            let tie_peer_local = (flags as u32 & PM_SPECIAL) != 0
2604                && op.ename.as_deref().is_some_and(|peer| {
2605                    paramtab()
2606                        .read()
2607                        .ok()
2608                        .and_then(|t| t.get(peer).map(|p2| p2.level == cur_locallevel))
2609                        .unwrap_or(false) // c:2396 pm2->level == locallevel
2610                });
2611            if tie_peer_local {
2612                // c:2408 — `tpm->node.flags = pm->node.flags | PM_NORESTORE;`
2613                // (and NO copyparam, so no value snapshot is taken).
2614                op.node.flags |= PM_NORESTORE as i32;
2615                inherit_live_value = true;
2616            } else if (op.node.flags as u32 & (PM_SPECIAL | PM_TIED)) != 0 {
2617                // c:Src/builtin.c:2382-2424 copyparam — snapshot the
2618                // CURRENT live value into the shadow chain. PM_TIED
2619                // scalars (FPATH/PATH/CDPATH…) included: their value
2620                // derives from the tied array's global storage, which
2621                // the local's writes flow through — without the
2622                // snapshot, `f() { local FPATH=/tmp }; f` left the
2623                // GLOBAL fpath at ( /tmp ) and every later autoload
2624                // failed (zinit's :zinit-tmp-subst-autoload does
2625                // exactly this dance). Fall back to the name-routed
2626                // getsparam when the pm carries no scalar gsu.
2627                // c:Src/builtin.c:2410 `copyparam(tpm, pm, 1)` copies the
2628                // `u` UNION out of the struct it already holds — it never
2629                // resolves the NAME, so it cannot run `getparamnode` →
2630                // `loadparamnode` (c:Src/params.c:563-585) and cannot
2631                // `ensurefeature` the owning module. C is explicit about this
2632                // for the whole builtin: c:Src/builtin.c:3096-3097 comments the
2633                // literal-arg lookup as "getnode2() to avoid autoloading", and
2634                // the `-m` loop at c:3085-3088 hands `typeset_single` the pm
2635                // POINTER it already has. zshrs's snapshot below goes through
2636                // `getaparam`/`getsparam`, which DO resolve the name and boot
2637                // the module — so a bare `local zsh_scheduled_events` (or the
2638                // `typeset -h +g -m '*'` stress at B02typeset.ztst:37) loaded
2639                // zsh/sched and zsh/zleparameter, where `zmodload -e zsh/sched`
2640                // answers 1 in zsh and 0 in zshrs. It also stuck the name in
2641                // MATERIALIZED_MODULE_PARAMS for the rest of the shell, so a
2642                // later `readonly -p` listed `typeset -g -ar keymaps` /
2643                // `typeset -g -ar zsh_scheduled_events` (B02typeset.ztst:47).
2644                // An unbooted module row has no value to preserve — in C it is
2645                // a PM_AUTOLOAD stub whose `u.str` is just the module name — so
2646                // skip the snapshot entirely for it. Same predicate as the
2647                // print-side gate in `printparamnode` below; kept inlined
2648                // rather than shared because build.rs forbids Rust-only helper
2649                // fns under src/ported/.
2650                let unbooted_module_row = crate::vm_helper::AUTOLOAD_PARAMS
2651                    .iter()
2652                    .find(|(pnam, _)| *pnam == op.node.nam)
2653                    .is_some_and(|(_, modname)| {
2654                        crate::ported::module::MODULESTAB
2655                            .lock()
2656                            .ok()
2657                            .map(|t| {
2658                                // c:Src/module.c:218-241 — "boot ran" is
2659                                // MOD_INIT_B && !MOD_UNLOAD.
2660                                !t.modules.get(*modname).is_some_and(|md| {
2661                                    (md.node.flags & crate::ported::zsh_h::MOD_INIT_B) != 0
2662                                        && (md.node.flags & crate::ported::zsh_h::MOD_UNLOAD) == 0
2663                                })
2664                            })
2665                            .unwrap_or(false)
2666                    });
2667                if unbooted_module_row {
2668                    // c:2410 — nothing to copy; the stub carries no value.
2669                } else if (PM_TYPE(op.node.flags as u32) & PM_ARRAY) != 0 {
2670                    // ARRAY side of a tied pair (fpath/path/cdpath):
2671                    // snapshot the ELEMENT VECTOR. Forcing the scalar
2672                    // getsparam fallback here stored Some("") and the
2673                    // endparamscope refire then assigned that empty
2674                    // scalar — zinit's autoload stubs (`local -a
2675                    // fpath; fpath=( … )`) collapsed the GLOBAL fpath
2676                    // to one empty element on every stub exit.
2677                    if let Some(arr) = getaparam(&op.node.nam) {
2678                        op.u_arr = Some(arr);
2679                    }
2680                } else {
2681                    let getfn_ptr = op.gsu_s.as_ref().map(|g| g.getfn);
2682                    if let Some(getfn) = getfn_ptr {
2683                        op.u_str = Some(getfn(&op));
2684                    } else if let Some(v) = getsparam(&op.node.nam) {
2685                        op.u_str = Some(v);
2686                    }
2687                }
2688            }
2689            // c:Src/builtin.c:2386-2425 — the newspecial shadow keeps the
2690            // SAME Param, so `pm->u.hash` still points at the special's own
2691            // table and the local reads through it. The magic module hashes
2692            // (Src/Modules/parameter.c partab[]: commands / functions /
2693            // aliases / …) have no `paramtab_hashed_storage` backing at all —
2694            // `gethkparam` (params.rs:5919) reaches them by falling THROUGH
2695            // that map to the row's `scanfn`. Inserting an empty local bag
2696            // here therefore did not "shadow" such a hash, it BLANKED it:
2697            // `local -A +h commands` read 0 keys where zsh reads the live
2698            // command table. Only the newspecial case is excluded (the caller
2699            // ORs PM_SPECIAL into `flags` per c:2083-2085); a plain
2700            // `local -A commands` still decays to an empty local assoc, which
2701            // is what zsh does too.
2702            let keeps_special_hash = (flags as u32 & PM_SPECIAL) != 0
2703                && crate::ported::modules::parameter::PARTAB
2704                    .iter()
2705                    .any(|e| e.name == name);
2706            // c:Src/params.c:3800 + c:3908-3925 — the unwind of a plain local
2707            // is `unsetparam_pm` → `pm->gsu.s->unsetfn(pm, exp)` → stdunsetfn's
2708            // `case PM_HASHED: pm->gsu.h->setfn(pm, NULL)` (hashsetfn, c:4045,
2709            // `deleteparamtable(pm->u.hash)`). That switch dispatches on the
2710            // type of the LOCAL being destroyed and never looks at `pm->old`,
2711            // because in C the pairs hang off the local's own `u.hash`
2712            // (c:1157 `zshcalloc` leaves the fresh local's slot NULL, so the
2713            // outer's table is still hanging off the `pm->old` struct,
2714            // untouched). RUST-ONLY HAZARD: zshrs keeps the pairs in the
2715            // name-keyed `paramtab_hashed_storage` with no scope dimension, so
2716            // the save/clear here is what stands in for both halves — and
2717            // gating it on the OUTER being PM_HASHED had no C counterpart. A
2718            // `typeset -A H` under an outer `local H` (or `local -a H`)
2719            // therefore pushed no frame, so nothing ever removed the row:
2720            //   outer(){ local H; inner; print ${(t)H} ${#H} }
2721            //   inner(){ typeset -A H; H[x]=1 }
2722            //   outer; print ${(t)H}
2723            // read `scalar-local 1` inside and left `association` behind in
2724            // GLOBAL scope after `outer` returned. Fire whenever EITHER side is
2725            // hashed: outer-hashed needs the outer's bag preserved, and
2726            // local-hashed needs the local's bag torn down.
2727            if ((op.node.flags as u32 & PM_HASHED) != 0 || (flags as u32 & PM_HASHED) != 0)
2728                && (flags as u32 & PM_LOCAL) != 0
2729                && !keeps_special_hash
2730            {
2731                // Push current paramtab_hashed_storage[name] (Some/None)
2732                // onto the shadow stack so endparamscope can restore.
2733                // Then CLEAR the storage so the local shadow starts
2734                // with an empty bag — without this, `local -A h` (no
2735                // value) leaves the outer's data visible and a
2736                // subsequent `h[x]=v` appends to it instead of
2737                // creating a fresh local assoc. C's copyparam handles
2738                // this via separate `tpm` / `pm` u.hash slots so the
2739                // pm.u.hash that fresh writes go through is
2740                // zero-initialised; zshrs's parallel storage is one
2741                // map per name, so the save+clear pair is the
2742                // equivalent.
2743                let saved: Option<IndexMap<String, String>> = paramtab_hashed_storage()
2744                    .lock()
2745                    .ok()
2746                    .and_then(|m| m.get(name).cloned());
2747                let stk_mtx =
2748                    PARAMTAB_HASHED_SHADOW_STACK.get_or_init(|| Mutex::new(HashMap::new()));
2749                if let Ok(mut stk) = stk_mtx.lock() {
2750                    // The frame carries the level of the local it belongs to
2751                    // (the same `cur_locallevel` stamped into `pm.level` below,
2752                    // c:Src/builtin.c:2576 `pm->level = locallevel`), so
2753                    // endparamscope pops a frame only for the scope that
2754                    // actually pushed one. Without that tag the pop side had to
2755                    // re-derive the push condition from the param flags, and
2756                    // the two spellings drifted apart — which is how the leak
2757                    // above survived.
2758                    stk.entry(name.to_string())
2759                        .or_default()
2760                        .push((cur_locallevel, saved));
2761                }
2762                if let Ok(mut m) = paramtab_hashed_storage().lock() {
2763                    // c:Src/params.c:2270 — `if (PM_TYPE(pm->node.flags) &
2764                    // (PM_ARRAY|PM_HASHED))`: a subscripted read dispatches on
2765                    // the TYPE OF THE NODE paramtab returns, then `getindex`
2766                    // (c:2282 / c:2112). Only a shadow that is ITSELF PM_HASHED
2767                    // owns a bag in this parallel storage; a NON-hash shadow
2768                    // must leave NO row at all, or `assoc_contains`
2769                    // (subst.rs, `contains_key`) still answers "association"
2770                    // for the empty map and every subscripted read of the local
2771                    // ARRAY routes to the hash path and comes back empty:
2772                    //
2773                    //   g(){ local -a h; h=(a b c); print ${h[2]} }
2774                    //   f(){ local -A h; h[x]=1; g }; f
2775                    //   zsh: b     zshrs: <empty>
2776                    //
2777                    // That is `Completion/Base/Core/_normal:4`'s `local -A opts`
2778                    // shadowed by `_ruby`'s `local -a opts`, whose
2779                    // `$opts[(r)*-d\[*]` (sh:158) then found nothing and
2780                    // dropped `-d` from `irb -`. `endparamscope` already
2781                    // restores `Some(map) => insert` / `None => remove`, so
2782                    // removing the row here is undone on scope exit.
2783                    if (flags as u32 & PM_HASHED) != 0 {
2784                        m.insert(name.to_string(), IndexMap::new());
2785                    } else {
2786                        m.remove(name);
2787                    }
2788                }
2789            }
2790            // c:Src/params.c:1138-1144 — the shadow arm, where C spells the
2791            // intent out: "needed to avoid freeing oldpm, but we do take it out
2792            // of the environment when it's hidden" — `if (oldpm->env)
2793            // delenv(oldpm);`. Without it the name stays in `environ` for the
2794            // whole life of the local, so the local's assignment republished
2795            // over that existing entry and CHILD PROCESSES saw the local value:
2796            //   export EV=out; f(){ local EV=in; sh -c 'echo [$EV]' }; f
2797            // printed `[in]` where zsh prints `[]`. Note the FLAG was already
2798            // correct (`${(t)EV}` = `scalar-local`, not exported) — only the
2799            // environment entry leaked, which is why nothing parameter-side
2800            // could see it. docs/BUGS.md #1040. The symmetric re-export of the
2801            // outer value on scope exit is #1038, in endparamscope.
2802            if (flags as u32 & PM_LOCAL) != 0
2803                && (op.env.is_some() || (op.node.flags as u32 & PM_EXPORTED) != 0)
2804            {
2805                delenvvalue(name); // c:1142 delenv(oldpm)
2806            }
2807            Some(op)
2808        } else {
2809            None
2810        };
2811        Box::new(param {
2812            node: hashnode {
2813                next: None,
2814                nam: name.to_string(),
2815                flags: 0,
2816            },
2817            u_data: 0,
2818            u_tied: None,
2819            u_arr: None,
2820            u_str: None,
2821            u_val: 0,
2822            u_dval: 0.0,
2823            u_hash: None,
2824            gsu_s: None,
2825            gsu_i: None,
2826            gsu_f: None,
2827            gsu_a: None,
2828            gsu_h: None,
2829            base: 0,
2830            width: 0,
2831            env: None,
2832            // c:Src/builtin.c:2385-2393 — for the newspecial shadow (flagged by
2833            // the caller ORing PM_SPECIAL into `flags`) C keeps the special's
2834            // OWN struct and moves only the saved state into `tpm`, so
2835            // `pm->ename` — the tied peer, MANPATH→manpath — survives. A plain
2836            // parameter is `zshcalloc`ed (c:1136) with no peer.
2837            ename: if (flags as u32 & PM_SPECIAL) != 0 {
2838                oldpm.as_ref().and_then(|o| o.ename.clone())
2839            } else {
2840                None
2841            },
2842            old: oldpm, // c:1137 pm->old = oldpm
2843            // c:1136 — C: `pm = zshcalloc(sizeof *pm)`. calloc
2844            // zeroes pm.level so a freshly created GLOBAL assignment
2845            // (`x=foo` inside a function) gets level=0 and survives
2846            // endparamscope. The `pm->level = locallevel` set happens
2847            // ONLY through builtin.c:2576 (PM_LOCAL path: `local x=…`).
2848            level: if (flags as u32 & PM_LOCAL) != 0 {
2849                cur_locallevel
2850            } else {
2851                0
2852            },
2853        })
2854    };
2855
2856    pm.node.flags = flags & !(PM_LOCAL as i32); // c:1155
2857    if (pm.node.flags as u32 & PM_SPECIAL) == 0 {
2858        // c:1157
2859        assigngetset(&mut pm); // c:1158
2860    }
2861    // c:Src/params.c:1146 — when shadowing a special parameter
2862    // (e.g. `local IFS=...` inside a function), the new pm must
2863    // inherit the canonical special-var GSU (ifssetfn etc.) so
2864    // writes route through the global storage that `$IFS`
2865    // expansion reads. Without this, the shadow's setfn was the
2866    // generic strsetfn and `local IFS=:` updated paramtab.u_str
2867    // but NOT the canonical `ifs` global — `$IFS` expansion in
2868    // the same function read the default value through ifsgetfn.
2869    //
2870    // Detect special-var names and override the gsu_s + stamp
2871    // PM_SPECIAL. Mirrors the back-fill at assignsparam:4981 but
2872    // covers the create-time path (local / typeset of fresh
2873    // shadow), not just the assign-existing path.
2874    // A shadow that typeset_single did NOT keep special (`local -h HOME`,
2875    // c:Src/builtin.c:2084-2086: -h leaves newspecial NS_NONE) is an
2876    // ordinary parameter: c:1155-1158 gives it plain accessors only.
2877    if !name.is_empty() && !(shadow_displaced && (flags as u32 & PM_SPECIAL) == 0) {
2878        let special_gsu: Option<Box<gsu_scalar>> = match name {
2879            "HOME" => Some(Box::new(HOME_GSU.clone())),
2880            "IFS" => Some(Box::new(IFS_GSU.clone())),
2881            "TERM" => Some(Box::new(TERM_GSU.clone())),
2882            "TERMINFO" => Some(Box::new(TERMINFO_GSU.clone())),
2883            "TERMINFO_DIRS" => Some(Box::new(TERMINFODIRS_GSU.clone())),
2884            "WORDCHARS" => Some(Box::new(WORDCHARS_GSU.clone())),
2885            "USERNAME" => Some(Box::new(USERNAME_GSU.clone())),
2886            "KEYBOARD_HACK" => Some(Box::new(KEYBOARDHACK_GSU.clone())),
2887            "HISTCHARS" | "histchars" => Some(Box::new(HISTCHARS_GSU.clone())),
2888            "LANG" => Some(Box::new(LANG_GSU.clone())), // c:332
2889            "LC_ALL" => Some(Box::new(LC_ALL_GSU.clone())), // c:333
2890            "LC_COLLATE" | "LC_CTYPE" | "LC_MESSAGES" | "LC_NUMERIC" | "LC_TIME" => {
2891                Some(Box::new(LC_BLAH_GSU.clone())) // c:335-346
2892            }
2893            _ => None,
2894        };
2895        if let Some(gsu) = special_gsu {
2896            pm.gsu_s = Some(gsu);
2897            pm.node.flags |= PM_SPECIAL as i32;
2898        }
2899    }
2900    // c:Src/builtin.c:2087-2089 + 2381-2392 — when `local` shadows a
2901    // PM_SPECIAL parameter, typeset_single sets `newspecial = NS_NORMAL` and
2902    // the apply branch keeps the SAME struct: "For specials, we keep the same
2903    // struct but zero everything. Maybe it would be easier to create a new
2904    // struct but copy the get/set methods." So the local inherits the
2905    // shadowed param's accessors AND its type — it does not decay to a plain
2906    // scalar.
2907    //
2908    // The name table above only enumerates special SCALARS, so every INTEGER
2909    // special fell through and was created generic: `local HISTSIZE=5` built a
2910    // `scalar-local` whose setter never reached the real HISTSIZE storage, so
2911    // the assignment was silently dropped and `${(t)HISTSIZE}` read
2912    // `scalar-local` where zsh reports `integer-local-special`. Same for
2913    // SAVEHIST / SECONDS / RANDOM / LINES / COLUMNS. Inherit from the shadowed
2914    // param generically rather than growing the list — that is what C does.
2915    // docs/BUGS.md #1039 (D).
2916    // c:Src/builtin.c:2083-2085 — `newspecial`. The caller that KNOWS the
2917    // `off` mask (bin_typeset's per-arg loop) evaluates the full test and
2918    // signals the answer by ORing PM_SPECIAL into `flags`; createparam only
2919    // sees `on`, so it cannot re-derive the `& ~off` term itself.
2920    let caller_keeps_special = (flags as u32 & PM_SPECIAL) != 0;
2921    // c:Src/params.c:1155-1158 — C's createparam ends with
2922    //     pm->node.flags = flags & ~PM_LOCAL;
2923    //     if (!(pm->node.flags & PM_SPECIAL)) assigngetset(pm);
2924    // and inherits NOTHING from `oldpm`. Keeping the special's accessors and
2925    // type is done by typeset_single's `newspecial` arm (c:2381-2425), which
2926    // only runs when c:2083-2085 chose NS_NORMAL. So the inheritance below
2927    // must fire ONLY when the caller signalled that decision by ORing
2928    // PM_SPECIAL into `flags`. The old `(pm.flags & PM_SPECIAL) == 0 ||`
2929    // disjunction made it fire for the NS_NONE case too, so `-h` (which is
2930    // exactly the flag that suppresses NS_NORMAL, c:2083 `!(on & PM_HIDE)`)
2931    // could not hide a special: `f(){ typeset -h +g LINENO }` produced
2932    // `integer-local-readonly-hide-special` where zsh gives
2933    // `scalar-local-hide`, and the following `unset LINENO` then failed with
2934    // "read-only variable".
2935    if caller_keeps_special {
2936        let inherited = pm.old.as_ref().and_then(|old| {
2937            // c:2087-2089 — a HIDDEN special (PM_HIDE, e.g. the `commands`
2938            // special-hash) is NOT kept special when a `local`/`typeset`
2939            // shadows it: C sets newspecial=NS_NORMAL for such a name, so the
2940            // shadow decays to the plainly-requested type. Re-stamping it
2941            // special made `local -a commands` in the brew completer fail with
2942            // "can't change type of a special parameter".
2943            // …unless the statement also carries `+h` (PM_HIDE in `off`),
2944            // which cancels c:2084's `& ~off` term and preserves the special —
2945            // `local -A +h commands` reads `association-local-special` in zsh.
2946            if (old.node.flags as u32 & PM_SPECIAL) != 0
2947                && ((old.node.flags as u32 & PM_HIDE) == 0 || caller_keeps_special)
2948            {
2949                Some((
2950                    old.gsu_s.clone(),
2951                    old.gsu_i.clone(),
2952                    old.gsu_f.clone(),
2953                    // c:Src/builtin.c:2384 `on |= pm->node.flags & PM_TIED;` rides
2954                    // along with the type: the shadow of MANPATH/PATH is still the
2955                    // tied half, so bin_typeset's re-tie test (c:2899) sees it.
2956                    PM_TYPE(old.node.flags as u32) | (old.node.flags as u32 & PM_TIED),
2957                ))
2958            } else {
2959                None
2960            }
2961        });
2962        if let Some((gs, gi, gf, ty)) = inherited {
2963            pm.gsu_s = gs;
2964            pm.gsu_i = gi;
2965            pm.gsu_f = gf;
2966            // Re-stamp the type from the shadowed special: createparam was
2967            // called with the caller's flags (PM_SCALAR for a bare `local X=1`),
2968            // which would otherwise override the special's real type.
2969            pm.node.flags &= !(PM_TYPE(u32::MAX) as i32);
2970            pm.node.flags |= PM_SPECIAL as i32 | ty as i32;
2971        }
2972    }
2973    // c:Src/builtin.c:2608-2633 — the zeroing step is SKIPPED when
2974    // `pm->old` carries PM_NORESTORE, i.e. when the tied peer is already
2975    // local at this level. In C nothing further is needed: `pm` is the
2976    // special's own struct and its value lives in the internal variable
2977    // the tie writes through, so it already holds the peer's local value.
2978    // zshrs's shadow is a fresh struct, so read the live value across.
2979    // The old node is still installed in paramtab at this point (the
2980    // addnode below is what displaces it), so the plain getters see it.
2981    if inherit_live_value {
2982        match PM_TYPE(pm.node.flags as u32) {
2983            t if t == PM_ARRAY => {
2984                if let Some(arr) = getaparam(name) {
2985                    pm.u_arr = Some(arr);
2986                }
2987            }
2988            t if t == PM_SCALAR => {
2989                if let Some(val) = getsparam(name) {
2990                    pm.u_str = Some(val);
2991                }
2992            }
2993            _ => {}
2994        }
2995    }
2996    // c:1146 `paramtab->addnode(paramtab, ztrdup(name), pm)`. For
2997    // the reuse arm this overwrites the same entry; for the shadow
2998    // arm it installs the new chained pm on top of the (now-
2999    // displaced) old.
3000    if !name.is_empty() {
3001        let cloned = pm.clone();
3002        // c:Src/builtin.c:2381-2451 — the `newspecial != NS_NONE` arm is an
3003        // if-arm that RETURNS through c:2444-2451; it never falls through to
3004        // the c:2519-2525 `createparam` call. C therefore keeps the special's
3005        // own node — already installed in paramtab — exactly where it is and
3006        // only pushes a copy of the old values down into `pm->old`
3007        // ("For specials, we keep the same struct but zero everything",
3008        // c:2390-2394). zshrs builds that shadow through createparam instead,
3009        // so the removenode below has to be suppressed for it or the special
3010        // would move to the head of its chain where zsh leaves it in place.
3011        // `keep_special` (builtin.rs:6032, c:2087-2089) is the caller's
3012        // newspecial signal; PM_REMOVABLE specials (zle/compsys params, whose
3013        // C sites DO call createparam — Src/Zle/complete.c:1300,
3014        // Src/Zle/zle_params.c:200) are not part of it.
3015        let newspecial_shadow = caller_keeps_special
3016            && (flags as u32 & PM_REMOVABLE) == 0
3017            && (flags as u32 & PM_LOCAL) != 0;
3018        let mut tab = paramtab().write().unwrap();
3019        if shadow_displaced && !newspecial_shadow {
3020            tab.remove(name); // c:1144
3021        }
3022        tab.insert(name.to_string(), pm); // c:1146
3023        drop(tab);
3024        return Some(cloned);
3025    }
3026    Some(pm) // c:1159
3027}
3028
3029/// Empty special-hash sentinel.
3030/// Port of `shempty()` from Src/params.c:1166. The C source uses
3031/// it as a no-op getfn callback for special hashes that need an
3032/// addressable function pointer but no actual work. Provided here
3033/// so future callers that match the C source's signature can call
3034/// it directly.
3035pub fn shempty() {}
3036
3037/// Port of `setsparam()` from `Src/params.c:3350`.
3038/// C body: `return assignsparam(s, val, ASSPM_WARN);`
3039/// WARNING: param names don't match C — Rust=() vs C=(s, val)
3040pub fn setsparam(s: &str, val: &str) -> Option<Param> {
3041    assignsparam(s, val, ASSPM_WARN as i32) // c:3352
3042}
3043
3044/// Port of `createspecialhash()` from `Src/params.c:1182`.
3045/// Creates a PM_SPECIAL|PM_HASHED parameter with the supplied get
3046/// and scan callbacks, attaches an empty hash table, and returns
3047/// the new Param (or None if `createparam` fails).
3048///
3049/// C body wiring:
3050///   - `pm = createparam(name, PM_SPECIAL|PM_HASHED|flags)` (c:1186)
3051///   - If shadowing an old param at function scope, `pm->level =
3052///     locallevel` (c:1204-1205) so the old one is exposed after
3053///     leaving the fn.
3054///   - `pm->gsu.h = (flags & PM_READONLY) ? &stdhash_gsu :
3055///     &nullsethash_gsu` (c:1206-1207)
3056///   - `pm->u.hash = newhashtable(0, name, NULL)` (c:1208) with
3057///     no-op add/empty/remove/free callbacks (`shempty`) plus the
3058///     supplied `get` / `scan` callbacks.
3059///
3060/// The Rust port drops `GetNodeFunc` / `ScanTabFunc` fn-pointer
3061/// parameters because the Rust HashTable model uses owned
3062/// HashMap<String, T> rather than C-style vtable dispatch; the
3063/// returned Param carries the empty hash and PM_HASHED flag so
3064/// callers can fill it via the standard array/hash setfn path.
3065pub fn createspecialhash(name: &str, flags: i32) -> Option<Param> {
3066    // c:1186 — `createparam(name, PM_SPECIAL|PM_HASHED|flags)`.
3067    let mut pm = createparam(name, (PM_SPECIAL | PM_HASHED) as i32 | flags)?;
3068
3069    // c:1204-1205 — if shadowing an old param, set level=locallevel.
3070    if pm.old.is_some() {
3071        // C: `pm->level = locallevel`. The previous Rust port had
3072        // `let ll = 0_i32;` as a hardcoded placeholder — meaning
3073        // shadowed special-hash params (`fpath`, `path`, `psvar`,
3074        // etc. assigned inside a function via local) would NEVER
3075        // get their level tagged for restoration. After the function
3076        // returned, the original param would be inaccessible because
3077        // the shadow record's level (always 0) wouldn't trigger the
3078        // endparamscope unset. Now reads the canonical `locallevel`
3079        // global from params.rs (matching the C global).
3080        pm.level = locallevel.load(Ordering::Relaxed) as i32;
3081        // c:1205
3082    }
3083
3084    // c:1206-1207 — GSU selection. We can't set the gsu_h pointer
3085    // without the full GSU port wired; leave it None and let the
3086    // standard setfn dispatch route through the existing hashsetfn
3087    // / nullsethashfn helpers.
3088
3089    // c:1208 — `pm->u.hash = newhashtable(0, name, NULL)`. Rust
3090    // stores an empty HashTable in u_hash. The C body then sets
3091    // hash/empty/add/get/get2/remove/disable/enable/free/print
3092    // callbacks (c:1210-1221) which in our Rust model are implicit
3093    // (HashMap handles add/get/remove; freenode is Drop).
3094    let ht = Box::new(hashtable {
3095        hsize: 0,
3096        ct: 0,
3097        nodes: Vec::new(),
3098        tmpdata: 0,
3099        hash: None,
3100        emptytable: None,
3101        filltable: None,
3102        cmpnodes: None,
3103        addnode: None,
3104        getnode: None,
3105        getnode2: None,
3106        removenode: None,
3107        disablenode: None,
3108        enablenode: None,
3109        freenode: None,
3110        printnode: None,
3111        scantab: None,
3112    });
3113    pm.u_hash = Some(ht);
3114    let _ = name;
3115
3116    Some(pm) // c:1223
3117}
3118
3119/// ```c
3120/// tpm->node.flags = pm->node.flags;
3121/// tpm->base = pm->base;
3122/// tpm->width = pm->width;
3123/// tpm->level = pm->level;
3124/// if (!fakecopy) {
3125///     tpm->old = pm->old;
3126///     tpm->node.flags &= ~PM_SPECIAL;
3127/// }
3128/// switch (PM_TYPE(pm->node.flags)) {
3129/// case PM_SCALAR: case PM_NAMEREF:
3130///     tpm->u.str = ztrdup(pm->gsu.s->getfn(pm)); break;
3131/// case PM_INTEGER:
3132///     tpm->u.val = pm->gsu.i->getfn(pm); break;
3133/// case PM_EFLOAT: case PM_FFLOAT:
3134///     tpm->u.dval = pm->gsu.f->getfn(pm); break;
3135/// case PM_ARRAY:
3136///     tpm->u.arr = zarrdup(pm->gsu.a->getfn(pm)); break;
3137/// case PM_HASHED:
3138///     tpm->u.hash = copyparamtable(pm->gsu.h->getfn(pm), pm->node.nam);
3139///     break;
3140/// }
3141/// if (!fakecopy)
3142///     assigngetset(tpm);
3143/// ```
3144/// Copies `pm`'s value + level/base/width/flags into `tpm`.
3145/// `fakecopy = 1` means we're saving a snapshot (e.g. for special
3146/// param scope-save) and don't need callable get/set callbacks; in
3147/// that case `tpm->old`/PM_SPECIAL are preserved untouched and
3148/// `assigngetset` is skipped.
3149/// Port of `copyparam()` from `Src/params.c:1236`.
3150/// WARNING: param names don't match C — Rust=(pm, fakecopy) vs C=(tpm, pm, fakecopy)
3151pub fn copyparam(
3152    // c:1236
3153    tpm: &mut param,
3154    pm: &mut param,
3155    fakecopy: i32,
3156) {
3157    tpm.node.flags = pm.node.flags; // c:1244
3158    tpm.base = pm.base; // c:1245
3159    tpm.width = pm.width; // c:1246
3160    tpm.level = pm.level; // c:1247
3161    if fakecopy == 0 {
3162        // c:1248
3163        tpm.old = pm.old.take(); // c:1249
3164        tpm.node.flags &= !(PM_SPECIAL as i32); // c:1250
3165    }
3166    match PM_TYPE(pm.node.flags as u32) {
3167        // c:1252
3168        t if t == PM_SCALAR || t == PM_NAMEREF => {
3169            // c:1255 — `tpm->u.str = ztrdup(pm->gsu.s->getfn(pm));`.
3170            // C dispatches through the GSU getfn pointer so PM_SPECIAL
3171            // params (IFS, PATH, HOME, ...) return their canonical
3172            // global value (ifsgetfn reads `ifs_lock`, etc.). Without
3173            // this dispatch, `local IFS=:` saved an empty pm.u_str
3174            // (the bare strgetfn read) and endparamscope could not
3175            // restore the real outer value on scope exit (bug #8 in
3176            // docs/BUGS.md).
3177            let getfn_ptr = pm.gsu_s.as_ref().map(|g| g.getfn);
3178            tpm.u_str = Some(if let Some(getfn) = getfn_ptr {
3179                getfn(pm)
3180            } else {
3181                strgetfn(pm)
3182            });
3183        }
3184        t if t == PM_INTEGER => {
3185            // c:1257
3186            tpm.u_val = intgetfn(pm); // c:1258
3187        }
3188        t if t == PM_EFLOAT || t == PM_FFLOAT => {
3189            // c:1260-1261
3190            tpm.u_dval = floatgetfn(pm); // c:1262
3191        }
3192        t if t == PM_ARRAY => {
3193            // c:1264
3194            tpm.u_arr = Some(arrgetfn(pm)); // c:1265
3195        }
3196        t if t == PM_HASHED => {
3197            // c:1267
3198            // copyparamtable(pm->gsu.h->getfn(pm), pm->node.nam)            // c:1268
3199            tpm.u_hash = copyparamtable(pm.u_hash.as_ref(), &pm.node.nam);
3200        }
3201        _ => {}
3202    }
3203    if fakecopy == 0 {
3204        // c:1280
3205        assigngetset(tpm); // c:1281
3206    }
3207}
3208
3209// ---------------------------------------------------------------------------
3210// Utility functions
3211// ---------------------------------------------------------------------------
3212
3213/// Check if string is valid identifier (from params.c isident)
3214// Return 1 if the string s is a valid identifier, else return 0.         // c:1288
3215/// Port of `isident()` from `Src/params.c:1288`.
3216/// `isident` — see implementation.
3217pub fn isident(s: &str) -> bool {
3218    use crate::ported::utils::itype_end;
3219    use crate::ported::ztype_h::{IIDENT, INAMESPC};
3220
3221    let b = s.as_bytes();
3222    // c:1313 — `if (!*s)` — empty string is definitely not valid
3223    if b.is_empty() {
3224        return false; // c:1314
3225    }
3226
3227    // c:1316-1319 — This partly duplicates code in itype_end(), but we need to
3228    // distinguish the leading namespace at this point to check the
3229    // correctness of the identifier that follows
3230    //
3231    // C walks `char *s` / `char *ss` as pointers; the Rust port tracks the
3232    // same two cursors as BYTE OFFSETS into `s` (`s_off` / `ss_off`).
3233    let mut s_off = 0usize;
3234    if b[0] == b'.' {
3235        // c:1320
3236        if b.get(1).is_some_and(|c| c.is_ascii_digit()) {
3237            // c:1321
3238            return false; // c:1322 — Namespace must not start with a digit
3239        }
3240        // c:1323-1325 — Reject identifiers beginning with a digit in namespaces.
3241        // Move this out below this block to also reject v.1x form.
3242        // c:1326 — `if ((ss = itype_end(s + (*s == '.'), IIDENT, 0)))`
3243        let ss = 1 + itype_end(&s[1..], IIDENT as u32, false);
3244        if b.get(ss) == Some(&b'.') {
3245            // c:1327
3246            if b.get(ss + 1).is_none() {
3247                // c:1328
3248                return false; // c:1329
3249            }
3250            if b[ss + 1].is_ascii_digit() {
3251                // c:1330
3252                s_off = ss + 1; // c:1331
3253            }
3254        }
3255    }
3256
3257    let ss_off: usize;
3258    if b[s_off].is_ascii_digit() {
3259        // c:1336
3260        // c:1337 — If the first character is `s' is a digit, then all must be
3261        s_off += 1; // c:1338 — `++s`
3262        let mut i = s_off; // c:1338 — `ss = ++s`
3263        while i < b.len() {
3264            // c:1338
3265            if !b[i].is_ascii_digit() {
3266                // c:1339
3267                break; // c:1340
3268            }
3269            i += 1;
3270        }
3271        ss_off = i;
3272    } else {
3273        // c:1341
3274        // c:1342 — Find the first character in `s' not in the iident type table
3275        ss_off = s_off + itype_end(&s[s_off..], INAMESPC, false); // c:1343
3276    }
3277
3278    // c:1346-1347 — If the next character is not [, then it is
3279    // definitely not a valid identifier.
3280    if ss_off >= b.len() {
3281        // c:1348
3282        return true; // c:1349
3283    }
3284    if s_off == ss_off {
3285        // c:1350
3286        return false; // c:1351
3287    }
3288    if b[ss_off] != b'[' {
3289        // c:1352
3290        return false; // c:1353
3291    }
3292
3293    // c:1355 — Require balanced [ ] pairs with something between
3294    // c:1356-1358 — `if (!(ss = parse_subscript(++ss, 1, ']')))
3295    //          parse_subscript(++ss, 1, ']'))) return 0;`
3296    // Subscript MUST be balanced — `foo[` (missing `]`)
3297    // is NOT a valid identifier. The previous Rust port
3298    // accepted `[` at the end unconditionally, missing
3299    // the balanced-pair requirement.
3300    //
3301    // Routing through the full `parse_subscript` (which
3302    // drives a nested lex context) would be overkill at
3303    // this site — a simple bracket-balance walk over the
3304    // remaining bytes suffices. Count `[` / `]` and require
3305    // the depth to return to 0 before end-of-string.
3306    let mut depth = 1i32;
3307    // c:Src/params.c:1334 — `if (!(ss = parse_subscript(++ss,
3308    // 1, ']'))) return 0;`. C's parse_subscript rejects empty
3309    // subscripts: `h[]` returns NULL. Mirror by tracking
3310    // whether ANY non-bracket char appears before the depth
3311    // returns to 0. Without this, `h[]=val` was accepted as a
3312    // valid assoc element write — but zsh errors
3313    // `not an identifier: h[]` (bug #288 in docs/BUGS.md).
3314    //
3315    // c:Src/params.c parse_subscript (params.c:1480+) also
3316    // recognises backslash-escaped brackets: `A[\[k\]]` is a
3317    // single subscript with key `[k]`. The `\[` doesn't count
3318    // toward bracket depth and `\]` doesn't close. Track a
3319    // bslash flag so the depth walk matches C semantics.
3320    let mut saw_content = false;
3321    // Bug fix: `s.split('[').skip(1).next()` only returned
3322    // the segment between the first and second `[`, dropping
3323    // everything after. Use find()+slice to get the entire
3324    // tail starting after the first `[`.
3325    // c:1356 — `parse_subscript(++ss, …)`: the walk starts at the
3326    // byte AFTER the `[` that the name walk stopped on (`ss_off`),
3327    // not at the first `[` anywhere in `s` — with a namespace name
3328    // the two can differ once `s` has been advanced at c:1331.
3329    let tail_start = Some(ss_off + 1);
3330    let saw_close = tail_start.map(|start| &s[start..]).is_some_and(|tail| {
3331        let mut bslash = false;
3332        for ch in tail.chars() {
3333            if bslash {
3334                // c:parse_subscript — escaped char is
3335                // content, doesn't affect depth.
3336                saw_content = true;
3337                bslash = false;
3338                continue;
3339            }
3340            match ch {
3341                '\\' => {
3342                    bslash = true;
3343                    saw_content = true;
3344                }
3345                '[' => {
3346                    depth += 1;
3347                    saw_content = true;
3348                }
3349                ']' => {
3350                    depth -= 1;
3351                    if depth == 0 {
3352                        return true;
3353                    }
3354                    saw_content = true;
3355                }
3356                _ => saw_content = true,
3357            }
3358        }
3359        false
3360    });
3361    if !saw_content {
3362        return false; // c:1334 empty subscript rejected
3363    }
3364    saw_close
3365}
3366
3367/// Subscript-argument parser.
3368///
3369/// Port of `getarg()` from `Src/params.c:1367`. The C function is a
3370/// 618-line monolith handling the entire `[...]` body of a
3371/// subscripted parameter expansion.
3372///
3373/// Ported phases:
3374///   - Flag-block parse (c:1389-1480) — extract `(...)` chars.
3375///   - Hash pattern search (c:1581-1660) when `assoc` is `Some`.
3376///   - Array pattern search (c:1672-1719) when `arr` is `Some`.
3377///   - Scalar word-mode arm (c:1761-1797) when `scalar` is `Some`.
3378///
3379/// Later C phases not yet exercised by this entry point:
3380///   - Brace-depth walk to closing `]` (c:1507-1535)
3381///   - parsestr + singsub on subscript body (c:1545-1580)
3382///   - mathevalarg integer parse (c:1601-1604)
3383///   - Multibyte char-search arm (c:1798-1985)
3384pub(crate) fn getarg<'a>(
3385    idx: &'a str,
3386    arr: Option<&[String]>,
3387    assoc: Option<&IndexMap<String, String>>,
3388    scalar: Option<&str>,
3389) -> Option<getarg_out<'a>> {
3390    let rest = idx.strip_prefix('(')?;
3391    // c:Src/params.c:1389-1391 — the flag scan walks `s` from just past
3392    // the `(` and stops at the FIRST `)`/Outpar; everything after it is
3393    // the search PATTERN, which C hands straight to patcompile (c:1697).
3394    // A `[` therefore has no meaning to the flag scanner at all: it is
3395    // either an ordinary pattern char-class (`(r)[dUurRtT]`) or, if it
3396    // shows up among the flags themselves, an unknown flag that the
3397    // `_ => bad` arm below already rejects the same way C's default arm
3398    // does (c:1477-1483). A blanket `rest.contains('[')` bail therefore
3399    // rejected every legitimate char-class pattern: `${${x}[(r)[abc]]}`
3400    // returned None here, and paramsubst's subexp arm (subst.rs:6447-6463)
3401    // falls back to the WHOLE value on None — so `_typeset`'s
3402    // `${${(s::)use[$i]}[(r)[dUurRtT]]}` answered non-empty for every
3403    // character and appended `$func` to every allargs key, dropping the
3404    // specs whose key has no `f`/`p` variant (`functions -W`, `autoload -k`).
3405    if rest.starts_with(')') {
3406        return None;
3407    }
3408    // Flag scanner per zshparam(1) "Subscript Flags" /
3409    // params.c:1389-1480 switch:
3410    //   r/R (reverse value-search → value/all values),
3411    //   i/I (value-search → key/all keys),
3412    //   k/K (key-search → value/all values),
3413    //   e (exact match — disables glob),
3414    //   n<DELIM>EXPR<DELIM> (Nth match — params.c:1452-1463),
3415    //   b<DELIM>EXPR<DELIM> (begin offset — params.c:1464-1475),
3416    //   w (word index on scalar),
3417    //   f (word index split by newline; alias for `w` + sep="\n"),
3418    //   p (escapes for next get_strarg),
3419    //   s<DELIM>SEP<DELIM> (split-by-separator).
3420    // The `n` / `b` / `s` forms use `get_strarg`'s balanced-delimiter
3421    // pair: any non-flag char closes its pair (`(n.5.)`, `(n:5:)` etc.).
3422    let bytes = rest.as_bytes();
3423    let mut i: usize = 0;
3424    let mut num: i64 = 1;
3425    let mut beg: i64 = 0;
3426    let mut has_beg = false;
3427    let flags_start = 0_usize;
3428    let mut flags_end = 0_usize;
3429    let mut bad = false;
3430    // Sequential direction state per c:1390-1483 (see the flag arm below).
3431    let mut seq_ind = false;
3432    let mut seq_down = false;
3433    // c:1440-1447 `word` and c:1447/1479-1495 `sep`; `(p)` (c:1476) print-decodes
3434    // a following `(s:SEP:)`.
3435    let mut word = false;
3436    let mut sep_explicit: Option<String> = None;
3437    let mut escapes = false;
3438    while i < bytes.len() && bytes[i] != b')' {
3439        let c = bytes[i] as char;
3440        match c {
3441            'r' | 'R' | 'i' | 'I' | 'e' | 'k' | 'K' | 'w' | 'f' | 'p' => {
3442                // c:Src/params.c:1390-1483 — the switch is SEQUENTIAL and each
3443                // direction arm RESETS the others, so only the LAST direction
3444                // letter survives: `(Ir)` ends down=0 (FIRST match) while `(rI)`
3445                // ends down=1 (LAST). Tracking it HERE rather than re-scanning
3446                // the finished `flags` slice matters because that slice still
3447                // contains the `n`/`b` ARGUMENT text (e.g. "n:2:"), whose
3448                // characters must not be re-read as flags. `keymatch` is gated
3449                // on ishash (c:1400/1405); on the array path it stays 0, so k/K
3450                // reduce to r/R. Bug #1050.
3451                match c {
3452                    'r' | 'k' => {
3453                        seq_ind = false;
3454                        seq_down = false;
3455                    }
3456                    'R' | 'K' => {
3457                        seq_ind = false;
3458                        seq_down = true;
3459                    }
3460                    'i' => {
3461                        seq_ind = true;
3462                        seq_down = false;
3463                    }
3464                    'I' => {
3465                        seq_ind = true;
3466                        seq_down = true;
3467                    }
3468                    'w' => word = true, // c:1440
3469                    'f' => {
3470                        word = true;
3471                        sep_explicit = Some("\n".to_string()); // c:1447
3472                    }
3473                    'p' => escapes = true, // c:1476
3474                    _ => {}
3475                }
3476                i += 1;
3477                flags_end = i;
3478            }
3479            'n' | 'b' => {
3480                // Consume `n<DELIM>NUM<DELIM>` per c:1432 get_strarg.
3481                if i + 1 >= bytes.len() {
3482                    bad = true;
3483                    break;
3484                }
3485                let delim = bytes[i + 1];
3486                let arg_start = i + 2;
3487                let mut arg_end = arg_start;
3488                while arg_end < bytes.len() && bytes[arg_end] != delim {
3489                    arg_end += 1;
3490                }
3491                if arg_end >= bytes.len() {
3492                    bad = true;
3493                    break;
3494                }
3495                // c:Src/params.c:1452-1463 (`n`) / c:1464-1475 (`b`) — the text
3496                // between the `get_strarg` delimiters is an ARITHMETIC
3497                // expression, not a decimal literal. C evaluates it:
3498                // c:1458 `num = mathevalarg(s + arglen, &d);` and
3499                // c:1471 `if ((beg = mathevalarg(s + arglen, &d)) > 0) beg--;`.
3500                // This port ran `str::parse` and bailed out of the WHOLE
3501                // function with `ok()?` when it failed, so any non-literal
3502                // argument discarded the entire flag group and the raw
3503                // subscript text fell through to the math lexer instead —
3504                // `_git-archive` sh:7's `words[(b:CURRENT-1:I)--format=*]`
3505                // reported "bad math expression" where zsh searches from the
3506                // computed offset. Keep the decimal fast path (mathevalarg of
3507                // "2" is 2) and route every other form through the canonical
3508                // evaluator, exactly as the c:1597 numeric bound below does.
3509                // mathevalarg owns its own diagnostics, so an empty argument
3510                // still raises c:Src/math.c:1530-1532's "bad math expression:
3511                // empty string" rather than being silently dropped here.
3512                let arg = std::str::from_utf8(&bytes[arg_start..arg_end]).ok()?;
3513                let parsed: i64 = match arg.trim().parse::<i64>() {
3514                    Ok(n) => n, // fast path; identical to mathevalarg on a decimal literal
3515                    Err(_) => crate::ported::math::mathevalarg(arg), // c:1458 / c:1471
3516                };
3517                if c == 'n' {
3518                    num = if parsed == 0 { 1 } else { parsed };
3519                } else {
3520                    has_beg = true;
3521                    beg = if parsed > 0 { parsed - 1 } else { parsed };
3522                }
3523                i = arg_end + 1;
3524                flags_end = i;
3525            }
3526            's' => {
3527                // c:1479-1495 — `s<DELIM>SEP<DELIM>` via get_strarg; the flag
3528                // scan continues after it, so `(ws.:.i)` and `(s.:.w)` both
3529                // reach the word arms below.
3530                let Some(&delim) = bytes.get(i + 1).filter(|b| b.is_ascii()) else {
3531                    bad = true;
3532                    break;
3533                };
3534                let Some(len) = bytes[i + 2..].iter().position(|&b| b == delim) else {
3535                    bad = true;
3536                    break;
3537                };
3538                let arg = &rest[i + 2..i + 2 + len];
3539                sep_explicit = Some(if escapes {
3540                    crate::ported::utils::getkeystring_with(
3541                        arg,
3542                        (crate::ported::zsh_h::GETKEY_OCTAL_ESC | crate::ported::zsh_h::GETKEY_EMACS)
3543                            as u32,
3544                        None,
3545                    )
3546                    .0 // c:1490
3547                } else {
3548                    arg.to_string() // c:1494
3549                });
3550                i += len + 3;
3551                flags_end = i;
3552            }
3553            _ => {
3554                bad = true;
3555                break;
3556            }
3557        }
3558    }
3559    // c:1477-1483 — flag-error fallback: reset all flags, treat as no
3560    // subscript flags.
3561    if bad {
3562        return None;
3563    }
3564    if i >= bytes.len() || bytes[i] != b')' {
3565        return None;
3566    }
3567    if flags_end == flags_start {
3568        return None;
3569    }
3570    let flags = &rest[flags_start..flags_end];
3571    let pat = &rest[i + 1..];
3572
3573    // c:1488-1491 — negative `num` flips the search direction.
3574    let neg_num_flips = num < 0;
3575    if neg_num_flips {
3576        num = -num;
3577    }
3578
3579    // Phase 3 — hash pattern search arm (c:1581-1660 / 1672-1734).
3580    // Per C source case-arms:
3581    //   `r`: rev=1 → match against VALUES, return matching VALUE
3582    //   `R`: rev+down=1 → match VALUES, return ALL matching VALUEs
3583    //   `i`: rev+ind=1 → match VALUES, return KEY of first match
3584    //   `I`: rev+ind+down=1 → match VALUES, return ALL matching KEYs
3585    //   `k`: keymatch+rev=1 → match KEYS, return VALUE of first match
3586    //   `K`: keymatch+rev+down=1 → match KEYS, return ALL matching VALUEs
3587    if let Some(map) = assoc {
3588        let exact = flags.contains('e');
3589        let key_match = flags.contains('k') || flags.contains('K');
3590        let return_index = seq_ind; // c:1412/1416 ind, sequential
3591                                    // c:Src/params.c — on a HASH, i/I/k/K all match against KEYS while
3592                                    // r/R match against VALUES (zsh 5.9: `${h[(i)KEY]}`→KEY,
3593                                    // `${h[(r)VAL]}`→VAL, `${h[(i)VAL]}`→empty). k/K are exact key
3594                                    // compares (pprog=NULL, c:1707-1708); i/I glob the key; r/R glob the
3595                                    // value; (e) forces exact. Return: i/I→KEY, k/K→VALUE, r/R→VALUE.
3596        let match_against_key = key_match || return_index;
3597        let key_glob = return_index; // i/I glob the key; k/K are exact-only
3598                                     // c:Src/params.c:1689,1708-1729 — the value/key SCAN only runs when
3599                                     // `v->scanflags` is set, which requires a search-direction flag
3600                                     // (i/I/r/R/k/K). With none of those (e.g. a bare `(e)key`), C never
3601                                     // builds scanflags: getindex falls back to a plain `gethashnode`
3602                                     // exact KEY lookup. `(e)` (quote_arg, c:1450) only makes that key
3603                                     // literal (no glob). Without this, `(e)*` wrongly scanned VALUES for
3604                                     // an exact "*" and returned empty instead of the value at key "*".
3605        let has_search = flags.contains('i')
3606            || flags.contains('I')
3607            || flags.contains('r')
3608            || flags.contains('R')
3609            || flags.contains('k')
3610            || flags.contains('K');
3611        if !has_search {
3612            return Some(getarg_out::Value(Value::str(
3613                map.get(pat).cloned().unwrap_or_default(),
3614            )));
3615        }
3616        // C params.c:1488-1491 — negative `num` flips `down`. Since
3617        // R/I/K already set down=1, neg_num XORs the bit (r/i/k +
3618        // neg → return_all; R/I/K + neg → single-match again).
3619        let is_uppercase = flags.contains('I') || flags.contains('R') || flags.contains('K');
3620        let return_all = is_uppercase ^ neg_num_flips;
3621
3622        // c:1740-1747 — `b<NUM>` start offset on the values array. The
3623        // hash is iterated in insertion order (IndexMap); skip first
3624        // `beg` entries before counting matches.
3625        let len = map.len() as i64;
3626        let mut start = beg;
3627        if start < 0 {
3628            start += len;
3629        }
3630        if !return_all && start >= len {
3631            return Some(getarg_out::Value(Value::str("")));
3632        }
3633        let skip = if start < 0 { 0 } else { start as usize };
3634
3635        // Per C params.c:1707-1709 + zsh 5.9 empirical:
3636        //   k/K — keymatch path: pprog=NULL, no glob; exact key
3637        //         lookup. `(K)*` returns "" because there's no key
3638        //         literally named "*".
3639        //   r/R/i/I — value path: pprog=patcompile, glob/exact.
3640        // c:params.c:1697 — `pprog = patcompile(s, 0, NULL)` runs ONCE
3641        // before the scan loop. Compiling inside the per-entry compare
3642        // re-tokenized + re-compiled the pattern for EVERY element —
3643        // `${history[(R)(#i)*pat*]}` over a 566k-entry history ran
3644        // ~4 CPU-minutes per hsmw ^R (the reported freeze) instead of
3645        // one compile + 566k pattry calls.
3646        let compiled_pat = if exact || (match_against_key && !key_glob) {
3647            None
3648        } else {
3649            patcompile(
3650                &{
3651                    let mut __pat_tok = crate::pattern_data_escape::escape_data_backslashes(pat); // c:1725 tokenize(s): a backslash tokenize leaves raw is DATA (c:Src/glob.c:3651)
3652                    crate::ported::glob::tokenize(&mut __pat_tok);
3653                    __pat_tok
3654                },
3655                PAT_HEAPDUP as i32,
3656                None,
3657            )
3658        };
3659        let key_compare = |target: &str| -> bool {
3660            if key_match && !exact {
3661                // c:Src/params.c:653-660 — SCANPM_KEYMATCH (set for `k`/`K`
3662                // at c:1714-1715) inverts the usual direction: the STORED KEY
3663                // is compiled as the pattern and matched against the SUBSCRIPT
3664                // text (`scanstr`), not the other way round:
3665                //     char *tmp = dupstring(v.pm->node.nam);
3666                //     tokenize(tmp); remnulargs(tmp);
3667                //     if (!(prog = patcompile(tmp, 0, NULL)) ||
3668                //         !pattry(prog, scanstr)) return;
3669                // That is the whole point of the flag — `_netcat` stores glob
3670                // keys (`'*-l*'`, `'*-i secs*'`, …) and asks
3671                // `$optionmap[(K)$help]` for the options the binary's `-h`
3672                // output mentions. A plain `target == pat` compare answered no
3673                // for every key, so `nc -<TAB>`/`netstat -<TAB>` fell back to
3674                // the raw assoc instead of the help-filtered option set.
3675                // C compiles per node here too (c:654-659), so the one-shot
3676                // `compiled_pat` above (which the r/R value scan needs for its
3677                // 566k-entry history case) does not apply.
3678                patcompile(
3679                    &{
3680                        let mut t = target.to_string();
3681                        crate::ported::glob::tokenize(&mut t);
3682                        t
3683                    },
3684                    PAT_HEAPDUP as i32,
3685                    None,
3686                )
3687                .map_or(false, |p| pattry(&p, pat))
3688            } else if exact || (match_against_key && !key_glob) {
3689                // (e) literal key compare.
3690                target == pat
3691            } else {
3692                // i/I glob the key; r/R glob the value.
3693                compiled_pat.as_ref().map_or(false, |p| pattry(p, target))
3694            }
3695        };
3696        if return_all {
3697            let mut out: Vec<String> = Vec::new();
3698            for (k, v) in map.iter().skip(skip) {
3699                let target = if match_against_key {
3700                    k.as_str()
3701                } else {
3702                    v.as_str()
3703                };
3704                if key_compare(target) {
3705                    // i/I → KEY; k/K → VALUE; r/R → VALUE.
3706                    out.push(if return_index { k.clone() } else { v.clone() });
3707                }
3708            }
3709            // c:Src/params.c:1724-1734 — the MATCHMANY forms (`I`/`R`/`K`, or a
3710            // negative `n` that flips `down`) set SCANPM_MATCHMANY, so getindex
3711            // takes the `getvaluearr(v)` branch and the expansion is ARRAY-
3712            // shaped: unquoted `$optionmap[(K)$help]` yields one word per match,
3713            // which is how `_netcat` feeds `_arguments` twelve separate option
3714            // specs. Joining here collapsed all twelve into a single word and
3715            // `comparguments` rejected it ("invalid option definition"). Array
3716            // and scalar callers are both served: `Value::Array::to_str`
3717            // (fusevm value.rs:149-151) joins with " ", the exact string this
3718            // used to return.
3719            return Some(getarg_out::Value(Value::array(
3720                out.into_iter().map(Value::str).collect(),
3721            )));
3722        }
3723        // c:1753 — `!--num` skips matches until the Nth.
3724        let mut remaining = num;
3725        for (k, v) in map.iter().skip(skip) {
3726            let target = if match_against_key {
3727                k.as_str()
3728            } else {
3729                v.as_str()
3730            };
3731            if key_compare(target) {
3732                remaining -= 1;
3733                if remaining == 0 {
3734                    return Some(getarg_out::Value(Value::str(if return_index {
3735                        k.clone()
3736                    } else {
3737                        v.clone()
3738                    })));
3739                }
3740            }
3741        }
3742        return Some(getarg_out::Value(Value::str("")));
3743    }
3744
3745    // Phase 2 — array pattern search arm (c:1672-1719). The C body
3746    // does `pprog = patcompile(s, 0, NULL)` then forward/reverse
3747    // `for (r = 1 + beg, p = ta + beg; *p; r++, p++) if (pprog &&
3748    // pattry(pprog, *p)) return r`.
3749    if let Some(arr) = arr {
3750        // C params.c:1761-1797 — `(w)N` / `(f)N` word-mode arm.
3751        // `getstrvalue(v)` joins the array; `sepsplit` re-splits by
3752        // sep (`f` → "\n", `w` → IFS-default whitespace, `s:SEP:`
3753        // → user sep), then the Nth split word is returned. So
3754        // `arr=("a b" "c d"); ${arr[(w)2]}` → "b" (joined "a b c d",
3755        // split → ["a","b","c","d"], pick idx 1).
3756        if flags.contains('w') || flags.contains('f') {
3757            if let Ok(n) = pat.parse::<i64>() {
3758                let sep_chars: &[char] = if flags.contains('f') {
3759                    &['\n']
3760                } else {
3761                    &[' ', '\t', '\n']
3762                };
3763                let joined = arr.join(" ");
3764                let words: Vec<&str> = joined
3765                    .split(|c: char| sep_chars.contains(&c))
3766                    .filter(|w| !w.is_empty())
3767                    .collect();
3768                let len = words.len() as i64;
3769                let idx_into = if n > 0 {
3770                    (n - 1) as usize
3771                } else if n < 0 {
3772                    let off = len + n;
3773                    if off < 0 {
3774                        return Some(getarg_out::Value(Value::str("")));
3775                    }
3776                    off as usize
3777                } else {
3778                    return Some(getarg_out::Value(Value::str("")));
3779                };
3780                return Some(getarg_out::Value(Value::str(
3781                    words
3782                        .get(idx_into)
3783                        .map(|s| s.to_string())
3784                        .unwrap_or_default(),
3785                )));
3786            }
3787        }
3788        let exact = flags.contains('e');
3789        let word = flags.contains('w') || flags.contains('f');
3790        let _ = word;
3791        let return_index = seq_ind; // c:1412/1416 ind, sequential
3792                                    // c:Src/params.c:2091 — `if (start > 0 && (isset(KSHARRAYS) ||
3793                                    // (v->pm->node.flags & PM_HASHED))) start--;`. The search loop below
3794                                    // yields a 1-BASED index, but under KSHARRAYS the shell's indices are
3795                                    // 0-based, so every index that leaves here has to be shifted down. This
3796                                    // was missed entirely, which showed up two ways:
3797                                    //   `setopt ksharrays; a=(x y z); ${a[(i)y]}` → 2, zsh says 1, and the
3798                                    //     no-match answer came out len+1 (4) instead of len (3);
3799                                    //   a RANGE bound built from a search (`${a[(r)b,(r)d]}`) fed the
3800                                    //     unshifted index straight into the 0-based slice machinery, so the
3801                                    //     window slid one element right — `c d e` for zsh's `b c d`.
3802                                    // The `> 0` guard is what keeps the `(I)` no-match answer at 0.
3803                                    // (PM_HASHED is not folded in here: an assoc `(i)` returns the KEY, not
3804                                    // an index, so there is nothing to shift — verified against zsh.)
3805        let ksh_idx = |i: i64| -> String {
3806            if i > 0 && isset(KSHARRAYS) {
3807                (i - 1).to_string()
3808            } else {
3809                i.to_string()
3810            }
3811        };
3812        // C params.c:1575 `if (!rev)` — without a direction flag
3813        // (r/R/i/I/k/K), getarg does NOT enter the search loop on
3814        // arrays; pat is mathevalarg'd as an integer index instead.
3815        // Verified empirically: `arr=(foo bar); ${arr[(e)foo]}`
3816        // returns empty in real zsh (mathevalarg fails, no element).
3817        let any_search_flag = flags.contains('r')
3818            || flags.contains('R')
3819            || flags.contains('i')
3820            || flags.contains('I')
3821            || flags.contains('k')
3822            || flags.contains('K');
3823        if !any_search_flag {
3824            return None;
3825        }
3826        // c:1488-1491 — negative `num` flips reverse direction.
3827        // c:1488-1491 — negative num flips the direction; `down` itself is the
3828        // sequential last-direction-letter value, not an order-blind contains().
3829        let reverse = seq_down ^ neg_num_flips;
3830        // C params.c:1668-1685 implicit `*` wrap fires only when
3831        // `v->scanflags` is unset; in standard subscript callsites
3832        // scanflags IS set, so the wrap does NOT engage. Verified
3833        // empirically: `arr=(foobar baz); ${arr[(r)foo]}` returns
3834        // empty in real zsh (exact match), not "foobar". Pattern is
3835        // used verbatim — globbing only when user supplies `*`.
3836        let pat_used: &str = pat;
3837
3838        // c:1740-1760 — `b<NUM>` starting offset + bounds checks.
3839        // beg is already 0-based after parse (parsed-1 for positive).
3840        let len = arr.len() as i64;
3841        let mut start = beg;
3842        if start < 0 {
3843            start += len;
3844        }
3845        // c:1743-1747 — out-of-bounds returns.
3846        if reverse {
3847            if start < 0 {
3848                return Some(getarg_out::Value(if return_index {
3849                    Value::str("0")
3850                } else {
3851                    Value::str("")
3852                }));
3853            }
3854        } else if start >= len {
3855            return Some(getarg_out::Value(if return_index {
3856                Value::str(ksh_idx(arr.len() as i64 + 1))
3857            } else {
3858                Value::str("")
3859            }));
3860        }
3861        // c:1750-1751 — reverse w/o explicit b starts from len-1.
3862        if reverse && !has_beg {
3863            start = len - 1;
3864        }
3865        // c:1748 — `if (beg >= 0 && beg < len) { …search… }`
3866        if start >= len {
3867            return Some(getarg_out::Value(if return_index {
3868                Value::str("0")
3869            } else {
3870                Value::str("")
3871            }));
3872        }
3873        let iter: Box<dyn Iterator<Item = (usize, &String)>> = if reverse {
3874            // c:1752 — `for (p = ta + beg; p >= ta; p--)`: clamp start
3875            // into the valid range then walk backwards. An empty array
3876            // has no element to walk (C's loop body never dereferences a
3877            // valid `p`), so yield an empty iterator rather than indexing
3878            // `arr[..=0]` on a zero-length slice.
3879            if arr.is_empty() {
3880                Box::new(std::iter::empty())
3881            } else {
3882                let s_idx = if start < 0 { 0 } else { start as usize };
3883                let s_idx = s_idx.min(arr.len() - 1);
3884                Box::new(arr[..=s_idx].iter().enumerate().rev())
3885            }
3886        } else {
3887            // c:1757 — `for (p = ta + beg; *p; p++)`: skip first beg.
3888            let s_idx = if start < 0 { 0 } else { start as usize };
3889            Box::new(arr.iter().enumerate().skip(s_idx))
3890        };
3891        // c:1758 — `!--num` skips matches until the Nth.
3892        // c:1697 — pattern compiled ONCE before the scan (see the
3893        // assoc arm above; same per-element recompile hazard).
3894        let compiled_arr_pat = if exact {
3895            None
3896        } else {
3897            patcompile(
3898                &{
3899                    let mut __pat_tok = crate::pattern_data_escape::escape_data_backslashes(pat_used); // c:1725 tokenize(s): a backslash tokenize leaves raw is DATA (c:Src/glob.c:3651)
3900                    crate::ported::glob::tokenize(&mut __pat_tok);
3901                    __pat_tok
3902                },
3903                PAT_HEAPDUP as i32,
3904                None,
3905            )
3906        };
3907        let mut remaining = num;
3908        for (i, s) in iter {
3909            let hit = if exact {
3910                s == pat
3911            } else {
3912                compiled_arr_pat.as_ref().map_or(false, |p| pattry(p, s))
3913            };
3914            if hit {
3915                remaining -= 1;
3916                if remaining == 0 {
3917                    return Some(getarg_out::Value(if return_index {
3918                        Value::str(ksh_idx(i as i64 + 1))
3919                    } else {
3920                        Value::str(s.clone())
3921                    }));
3922                }
3923            }
3924        }
3925        return Some(getarg_out::Value(if return_index {
3926            // zsh: `i` returns len+1 if not found, `I` returns 0.
3927            if flags.contains('I') {
3928                Value::str("0")
3929            } else {
3930                Value::str(ksh_idx(arr.len() as i64 + 1))
3931            }
3932        } else {
3933            Value::str("")
3934        }));
3935    }
3936
3937    // C params.c:1761-1797 — scalar word-mode arm. `(w)N` joins
3938    // the source string and re-splits by sep (whitespace by default
3939    // for `w`, "\n" for `f`). When `pat` is a numeric N, the Nth
3940    // word is returned. Pattern-search variants on scalars use the
3941    // c:1798-1980 char-search arm ported below.
3942    if let Some(s) = scalar {
3943        // c:1783 `sepsplit(s, sep, 1, 1)`: with no separator the split is on
3944        // IFS whitespace and yields no empty word; an explicit one keeps the
3945        // empties between adjacent separators. Each entry is a char range.
3946        let s_chars: Vec<char> = s.chars().collect();
3947        let sepsplit_ranges = || -> Vec<(usize, usize)> {
3948            let sep: Vec<char> = sep_explicit.as_deref().unwrap_or("").chars().collect();
3949            let sep_len_at = |i: usize| -> usize {
3950                if sep_explicit.is_none() {
3951                    matches!(s_chars[i], ' ' | '\t' | '\n') as usize
3952                } else if !sep.is_empty() && s_chars[i..].starts_with(&sep) {
3953                    sep.len()
3954                } else {
3955                    0
3956                }
3957            };
3958            let (mut ta, mut i, mut start) = (Vec::new(), 0usize, 0usize);
3959            while i < s_chars.len() {
3960                match sep_len_at(i) {
3961                    0 => i += 1,
3962                    l => {
3963                        ta.push((start, i));
3964                        i += l;
3965                        start = i;
3966                    }
3967                }
3968            }
3969            ta.push((start, s_chars.len()));
3970            if sep_explicit.is_none() {
3971                ta.retain(|&(a, b)| a < b);
3972            }
3973            ta
3974        };
3975        let word_text = |(a, b): (usize, usize)| -> String { s_chars[a..b].iter().collect() };
3976        if word {
3977            if let Ok(n) = pat.parse::<i64>() {
3978                let words: Vec<String> = sepsplit_ranges()
3979                    .into_iter()
3980                    .filter(|&(a, b)| a < b)
3981                    .map(word_text)
3982                    .collect();
3983                // c:1636-1648 — KSHARRAYS shifts a non-negative index and a
3984                // negative one counts from the end. Since workers/54773 an
3985                // index outside [1, wordcount] yields nothing, like any other
3986                // out-of-range subscript: `${${:-a b c}[(w)5]}`, `(w)-5` and
3987                // `(w)0` are all empty.
3988                let len = words.len() as i64;
3989                let mut r = if isset(KSHARRAYS) && n >= 0 { n + 1 } else { n };
3990                if r < 0 {
3991                    r += len + 1; // c:1645-1646
3992                }
3993                return Some(getarg_out::Value(Value::str(if r < 1 || r > len {
3994                    String::new() // c:1647-1648 `if (r < 1 || r > i) return 0;`
3995                } else {
3996                    words[r as usize - 1].clone()
3997                })));
3998            }
3999        }
4000        let any_search = flags.contains('r')
4001            || flags.contains('R')
4002            || flags.contains('i')
4003            || flags.contains('I')
4004            || flags.contains('k') // c:1422-1426 (keymatch = ishash = 0 ⇒ rev)
4005            || flags.contains('K'); // c:1427-1431 (keymatch = ishash = 0 ⇒ rev+down)
4006        // c:2091 — a search index leaves 0-based under KSHARRAYS.
4007        let ksh_idx = |i: i64| -> i64 {
4008            if i > 0 && isset(KSHARRAYS) {
4009                i - 1
4010            } else {
4011                i
4012            }
4013        };
4014        // c:1782-1818 — a search flag with a word flag matches whole WORDS
4015        // (no implicit trailing Star: c:1689 skips it for `word`), then maps the
4016        // word number back to a character offset through `findword`.
4017        if word && any_search && sep_explicit.as_deref() != Some("") {
4018            let down = seq_down ^ neg_num_flips; // c:1509-1512
4019            let ta = sepsplit_ranges();
4020            let visited: Vec<(usize, usize)> =
4021                ta.iter().copied().filter(|&(a, b)| a < b).collect(); // findword skips empties
4022            let exact = flags.contains('e');
4023            let prog = if exact {
4024                None
4025            } else {
4026                let mut tok = crate::pattern_data_escape::escape_data_backslashes(pat); // c:1725 tokenize(s): a raw backslash tokenize declines is DATA (c:Src/glob.c:3651)
4027                crate::ported::glob::tokenize(&mut tok);
4028                patcompile(&tok, PAT_HEAPDUP as i32, None) // c:1727
4029            };
4030            let untok = crate::lex::untokenize(pat); // c:1709 quote_arg
4031            let hit = |k: usize| -> bool {
4032                let w = word_text(ta[k]);
4033                if exact {
4034                    w == untok
4035                } else {
4036                    prog.as_ref().is_some_and(|p| pattry(p, &w))
4037                }
4038            };
4039            let len = ta.len() as i64;
4040            let b0 = if beg < 0 { beg + len } else { beg }; // c:1785-1786
4041            // c:1791 `return len + 1` — straight out, `*w` never set.
4042            let direct = !down && b0 >= len;
4043            let mut left = num;
4044            let mut nth = |k: &usize| {
4045                hit(*k) && {
4046                    left -= 1;
4047                    left == 0
4048                }
4049            };
4050            let r: i64 = if (down && b0 < 0) || (!direct && !(b0 >= 0 && b0 < len)) {
4051                0 // c:1788-1789
4052            } else if direct {
4053                len + 1
4054            } else if down {
4055                let top = if has_beg { b0 } else { len - 1 }; // c:1794-1795
4056                (0..=top as usize).rev().find(&mut nth).map_or(0, |k| k as i64 + 1) // c:1796-1800
4057            } else {
4058                (b0 as usize..ta.len()).find(&mut nth).map_or(0, |k| k as i64 + 1) // c:1802-1806
4059            };
4060            // c:1811-1816 — the r-th visited word gives the 1-based offset; its
4061            // extent is `strlen(ta[i])`, `i` counting findword's visits.
4062            let (off, end) = if direct {
4063                (r, r)
4064            } else if r > 0 && (r as usize) <= visited.len() {
4065                let k = r as usize - 1;
4066                let a = visited[k].0 as i64;
4067                (a + 1, a + (ta[k].1 - ta[k].0) as i64)
4068            } else {
4069                (0, 0)
4070            };
4071            return Some(getarg_out::Value(Value::str(if seq_ind {
4072                ksh_idx(off).to_string()
4073            } else if off > 0 {
4074                // getindex: offset through `*w`, or the one character at the
4075                // offset when `*w` was never set.
4076                let a = off as usize - 1;
4077                let e = (end.max(off) as usize).min(s_chars.len());
4078                s_chars.get(a..e).map(|c| c.iter().collect()).unwrap_or_default()
4079            } else {
4080                String::new()
4081            })));
4082        }
4083        // C params.c:1798-1980 — scalar char-search arm. `(i)/(I)/
4084        // (r)/(R)` on a scalar runs a sliding-window glob match over
4085        // the CHARACTER array (`s_chars`), so positions are character-
4086        // based — matching C's prevcharlen/nextcharlen cursor stepping
4087        // (c:1948-1971) under MULTIBYTE. (i)/(I) return the 1-based
4088        // char position of the first/last match; (r)/(R) return the
4089        // char at the match. Verified against zsh: `s=ábc; ${s[(i)b]}`
4090        // → 2 (char pos), not 3 (byte pos).
4091        // c:Src/params.c:1422-1431 — `case 'k'` / `case 'K'` set `keymatch =
4092        // ishash`, and c:1402-1403 makes `ishash` 0 for a non-hash, so on a
4093        // SCALAR they leave only `rev` (and `down` for `K`): they ARE `r`/`R`
4094        // here, and c:1707's `if (!keymatch)` still compiles the pattern.
4095        // Omitting them left `${s[(k)pat]}` short of the c:1819 char-search arm.
4096        if any_search {
4097            let return_index = seq_ind; // c:1412/1416 ind, sequential
4098            let want_last = flags.contains('I') || flags.contains('R') || flags.contains('K');
4099            // Negative `num` flips direction (c:1488-1491).
4100            let want_last = want_last ^ neg_num_flips;
4101            let n = s_chars.len();
4102            let positions: Box<dyn Iterator<Item = usize>> = if want_last {
4103                Box::new((0..=n).rev())
4104            } else {
4105                Box::new(0..=n)
4106            };
4107            // c:1929+ / c:1964 — `!--num` skips matches until the Nth.
4108            // Per `b<NUM>` (c:1740-1747) — start from offset, only
4109            // when has_beg is set. Without `b`, walk all positions.
4110            let beg_idx_opt: Option<usize> = if has_beg {
4111                let beg_norm = if beg < 0 { beg + n as i64 } else { beg };
4112                Some(if beg_norm < 0 {
4113                    0
4114                } else {
4115                    (beg_norm as usize).min(n)
4116                })
4117            } else {
4118                None
4119            };
4120            // c:1689-1706 — a SCALAR search compiles the pattern with an
4121            // implicit trailing `Star` appended (c:1698 `d[l] = Star`)
4122            // and then tries it against the SUFFIX at each position
4123            // (c:1955-1976 `pattry(pprog, t)` where `t` walks forward
4124            // through the string), so a hit means "the pattern matches a
4125            // prefix of what starts here" — which is why
4126            // `s=barfooxyz; ${s[(i)foo]}` is 4.
4127            //
4128            // Testing every SPAN instead (`s[start..start+len]` for all
4129            // len) answers the same, but builds O(n²) candidate strings
4130            // of average length n/2: `${${(k)parameters}[(r)PATH]}` —
4131            // a 6KB scalar — ran for over 40 seconds where zsh replies
4132            // instantly. Compile once, then match suffixes by slice.
4133            let compiled_span_pat = if flags.contains('e') {
4134                None
4135            } else {
4136                patcompile(
4137                    &{
4138                        let mut __pat_tok = crate::pattern_data_escape::escape_data_backslashes(pat); // c:1725 tokenize(s): a backslash tokenize leaves raw is DATA (c:Src/glob.c:3651)
4139                        crate::ported::glob::tokenize(&mut __pat_tok);
4140                        __pat_tok.push(crate::ported::zsh_h::Star); // c:1698
4141                        __pat_tok
4142                    },
4143                    PAT_HEAPDUP as i32,
4144                    None,
4145                )
4146            };
4147            // Byte offset of each character, so a suffix is a slice of
4148            // `s` rather than a fresh String per position.
4149            let char_offsets: Vec<usize> = s
4150                .char_indices()
4151                .map(|(i, _)| i)
4152                .chain(std::iter::once(s.len()))
4153                .collect();
4154            let mut found: Option<(usize, usize)> = None;
4155            let mut remaining = num;
4156            'outer: for start in positions {
4157                if let Some(b_idx) = beg_idx_opt {
4158                    if want_last {
4159                        if start > b_idx {
4160                            continue;
4161                        }
4162                    } else if start < b_idx {
4163                        continue;
4164                    }
4165                }
4166                let suffix = &s[char_offsets[start]..];
4167                let hit = if flags.contains('e') {
4168                    // c:1687-1702 — `(e)` untokenizes the pattern and
4169                    // still appends the Star, i.e. a literal prefix test.
4170                    suffix.starts_with(pat)
4171                } else {
4172                    compiled_span_pat
4173                        .as_ref()
4174                        .map_or(false, |p| pattry(p, suffix))
4175                };
4176                if hit {
4177                    remaining -= 1;
4178                    if remaining == 0 {
4179                        // c:1957 `!--num` — the position IS the answer.
4180                        found = Some((start, start));
4181                        break 'outer;
4182                    }
4183                }
4184            }
4185            return Some(getarg_out::Value(match (found, return_index) {
4186                (Some((s_pos, _)), true) => Value::str(ksh_idx(s_pos as i64 + 1).to_string()),
4187                // C params.c:1798-1980 char-search returns the char AT
4188                // the match position, not the full matched substring.
4189                // Verified empirically: `s="barfooxyz"; ${s[(r)foo]}`
4190                // returns "f" in real zsh, not "foo".
4191                (Some((s_pos, _)), false) => Value::str(
4192                    s_chars
4193                        .get(s_pos)
4194                        .map(|c| c.to_string())
4195                        .unwrap_or_default(),
4196                ),
4197                (None, true) => Value::str(if flags.contains('i') {
4198                    ksh_idx(n as i64 + 1).to_string()
4199                } else {
4200                    "0".to_string()
4201                }),
4202                (None, false) => Value::str(String::new()),
4203            }));
4204        }
4205    }
4206
4207    // No search context — return parsed flags for caller dispatch.
4208    Some(getarg_out::Flags { flags, rest: pat })
4209}
4210
4211/// Port of `getindex()` from `Src/params.c:2001`. Returns 0 on
4212/// success, non-zero on parse error. C body parses `[N]`/`[N,M]`/
4213/// `[(flags)pat]` after a Value's name and updates v->start/end/
4214/// scanflags.
4215/// Direct port of `int getindex(char **pptr, Value v, int
4216/// scanflags)` from `Src/params.c:2001-2167`. Parses the bracket
4217/// subscript after a Value's name and updates v->start/v->end/
4218/// v->scanflags. Returns 0 on success, 1 on parse error.
4219///
4220/// Handles:
4221///   - `[*]` / `[@]` — full range, with `[@]` setting
4222///     SCANPM_ISVAR_AT (c:2027-2032).
4223///   - `[N]` / `[N,M]` — operands evaluated through `mathevalarg`
4224///     (c:1618), KSH_ARRAYS adjustment (c:1619-1620), and the
4225///     KSHZEROSUBSCRIPT zero-subscript rule (c:2126-2150).
4226///   - Hash subscripts: exact key (c:1575-1595) and the `(r R k K i I
4227///     e n)` search flags (c:1391-1760), parsed inline because this
4228///     port's `getarg` has no `Value` out-parameter.
4229///   - Array `(i)`/`(I)` index searches (c:2110-2114) and `(r)`/`(R)`
4230///     element searches, resolved through `getarg`.
4231///   - Subscripts index by character, so the MB_METACHARLEN
4232///     previous/next-length offsets of c:2144-2145 are always 1.
4233pub fn getindex(pptr: &mut &str, v: &mut value, scanflags: i32) -> i32 {
4234    // c:2001
4235
4236    let s = *pptr;
4237    // c:2006 — `*s++ = '['`. Caller asserts s[0] is '[' (or its
4238    // tokenised form Inbrack); skip it. The byte test here read
4239    // `s.as_bytes()[0] != 0xa9`, which is neither `Inbrack` (U+e191,
4240    // encoded 0xC2 0x91 in a Rust `str`) nor anything else in the
4241    // token range, so a lexer-tokenised subscript never got past this
4242    // guard. Compare the leading CHARACTER instead.
4243    let lbrack = match s.chars().next() {
4244        Some(c) if c == '[' || c == crate::ported::zsh_h::Inbrack => c,
4245        _ => return 1,
4246    };
4247    let after_lbrack = &s[lbrack.len_utf8()..];
4248
4249    // c:2008 — `parse_subscript(s, dq, ']')`. Routes through the
4250    // existing lex-layer port at `crate::ported::lex::parse_subscript`
4251    // which honours `[...]` / `(...)` / `{...}` nesting and single/
4252    // double quoting (parse/src/lex.rs:3074).
4253    //
4254    // c:Src/lex.c:1749 `untokenize(t = dupstring_wlen(s, l));` plus
4255    // c:2011-2018 (`if (itok(*tbrack)) *tbrack = ztokens[...]`) mean
4256    // every offset C computes from here on indexes UNTOKENISED text.
4257    // C gets that for free because its tokens are single bytes that
4258    // untokenize in place; zshrs's are non-ASCII `char`s whose UTF-8
4259    // length shrinks from 2 to 1, so the offsets `parse_subscript`
4260    // returns do not index the tokenised string. Untokenize first and
4261    // work in that string, then map the end offset back by CHARACTER
4262    // count — untokenize is strictly one char in, one char out — to
4263    // advance `*pptr` inside the caller's still-tokenised text.
4264    let untok = crate::ported::lex::untokenize(after_lbrack);
4265    let close_pos = parse_subscript(&untok, ']');
4266    let close_pos = match close_pos {
4267        Some(p) => p,
4268        None => {
4269            // c:2020 — `zerr("invalid subscript")`.
4270            zerr("invalid subscript");
4271            *pptr = ""; // c:2021
4272            return 1; // c:2022
4273        }
4274    };
4275    let body = &untok[..close_pos];
4276    // c:2156/2164 — `*tbrack = ']'; *pptr = s`, i.e. the text just past
4277    // the closing bracket, in the ORIGINAL (tokenised) string.
4278    let past_close: &str = {
4279        let nchars = body.chars().count() + 1; // body + the `]` itself
4280        match after_lbrack.char_indices().nth(nchars) {
4281            Some((i, _)) => &after_lbrack[i..],
4282            None => "",
4283        }
4284    };
4285
4286    // c:2027 — special-case `[*]` / `[@]`.
4287    if body == "*" || body == "@" {
4288        if body == "@" && (v.scanflags != 0 || v.pm.is_none()) {
4289            // c:2028
4290            v.scanflags |= SCANPM_ISVAR_AT as i32; // c:2029
4291        }
4292        v.start = 0; // c:2030
4293        v.end = -1; // c:2031
4294                    // c:2156 — `*tbrack = ']'; *pptr = s` (s points past `]`).
4295        *pptr = past_close;
4296        return 0; // c:2160
4297    }
4298
4299    let _ = scanflags;
4300    // c:2058-2114 — flag subscripts `[(i)pat]` / `[(I)pat]` on an
4301    // array. The numeric parser below can't handle these; route
4302    // through getarg (c:2058 `getarg(&s, &inv, v, 0, ...)`). The
4303    // index-returning reverse flags (i/I) yield the 1-based array
4304    // index of the match (len+1 / 0 when not found). C's getindex
4305    // inv branch (c:2110-2114) turns that into a single-element
4306    // access: `v->valflags |= VALFLAG_INV; v->scanflags = 0;
4307    // v->start = start; v->end = start + 1`. issetvar then range-
4308    // checks `end` against the array length, so `arr[(i)x]` (index
4309    // = len+1, out of range) correctly reports unset. Non-index
4310    // flags (r/R/k/K) still fall through to the substitution
4311    // pipeline's getarg.
4312    // c:1597-1615 — hash subscript resolution. For a PM_HASHED param
4313    // the subscript is either an exact KEY (c:1575-1595) or a flag
4314    // SEARCH over the keys/values (c:1686-1735).
4315    // Resolve the PM_HASHED test into an owned name so the `v.pm` borrow is
4316    // released before the arm below mutates `v`.
4317    let hashed_name = v.pm.as_ref().and_then(|p| {
4318        if PM_TYPE(p.node.flags as u32) == PM_HASHED {
4319            Some(p.node.nam.clone())
4320        } else {
4321            None
4322        }
4323    });
4324    {
4325        if let Some(name) = hashed_name {
4326            let past_bracket = past_close;
4327
4328            // ---------------------------------------------------------
4329            // c:1391-1483 — the subscript flag block. C parses it inside
4330            // `getarg`, but this port's `getarg` has no `Value` parameter
4331            // (params.rs `getarg`: `(idx, arr, assoc, scalar)`) so it can
4332            // neither read nor set `v->scanflags`, which is what selects
4333            // KEYS-vs-VALUES output for the scan. Parse it here, where the
4334            // Value is in hand, exactly as C's switch does: the switch is
4335            // SEQUENTIAL and every direction arm RESETS the others, so only
4336            // the LAST direction letter survives (`(ir)` matches values,
4337            // `(ri)` matches keys).
4338            // ---------------------------------------------------------
4339            let mut rev = false; // c:1394 — any search flag seen
4340            let mut ind = false; // c:1412/1416 — i/I: match the KEY
4341            let mut down = false; // c:1397 — R/I/K: every match, not the first
4342            let mut keymatch = false; // c:1400/1405 — k/K: the stored KEY is the pattern
4343            let mut quote_arg = false; // c:1449 — (e): literal compare, no glob
4344            let mut num: i64 = 1; // c:1430 — (n<D>NUM<D>): Nth match
4345            let mut flag_err = false; // c:1477 — unknown flag
4346            let mut pat: &str = body;
4347
4348            if body.starts_with('(') {
4349                let bytes = body.as_bytes();
4350                let mut i = 1usize;
4351                while i < bytes.len() && bytes[i] != b')' {
4352                    match bytes[i] as char {
4353                        'r' => {
4354                            // c:1394-1396
4355                            rev = true;
4356                            keymatch = false;
4357                            down = false;
4358                            ind = false;
4359                        }
4360                        'R' => {
4361                            // c:1397-1399
4362                            rev = true;
4363                            down = true;
4364                            keymatch = false;
4365                            ind = false;
4366                        }
4367                        'k' => {
4368                            // c:1400-1404 — `keymatch = ishash` (always true here)
4369                            keymatch = true;
4370                            rev = true;
4371                            down = false;
4372                            ind = false;
4373                        }
4374                        'K' => {
4375                            // c:1405-1410
4376                            keymatch = true;
4377                            rev = true;
4378                            down = true;
4379                            ind = false;
4380                        }
4381                        'i' => {
4382                            // c:1411-1414
4383                            rev = true;
4384                            ind = true;
4385                            down = false;
4386                            keymatch = false;
4387                        }
4388                        'I' => {
4389                            // c:1415-1418
4390                            rev = true;
4391                            ind = true;
4392                            down = true;
4393                            keymatch = false;
4394                        }
4395                        'e' => quote_arg = true, // c:1449-1451
4396                        'w' | 'f' | 'p' => {
4397                            // c:1419-1428/1448 — `word`/`sep`/`escapes`. Word
4398                            // mode only applies to a SCALAR value (c:1601
4399                            // `if (word && !v->scanflags)`), which a PM_HASHED
4400                            // Value never is, so accept and ignore rather than
4401                            // taking C's `flagerr` path.
4402                        }
4403                        'n' | 'b' | 's' => {
4404                            // c:1429-1447/1471 — `get_strarg`'s balanced
4405                            // `<DELIM>ARG<DELIM>` pair, used by `n` (Nth
4406                            // match), `b` (begin offset) and `s` (word
4407                            // separator). `b`/`s` only steer the NON-scan
4408                            // array/scalar walks (c:1601-1760), which a hash
4409                            // scan never reaches. `n`'s SIGN does matter
4410                            // (c:1487-1490 flips `down`), so parse that one.
4411                            let is_n = bytes[i] == b'n';
4412                            if i + 1 >= bytes.len() {
4413                                flag_err = true;
4414                                break;
4415                            }
4416                            let delim = bytes[i + 1];
4417                            let arg_start = i + 2;
4418                            let mut arg_end = arg_start;
4419                            while arg_end < bytes.len() && bytes[arg_end] != delim {
4420                                arg_end += 1;
4421                            }
4422                            if arg_end >= bytes.len() {
4423                                flag_err = true;
4424                                break;
4425                            }
4426                            if is_n {
4427                                // c:1459 — `num = mathevalarg(...); if (!num) num = 1;`
4428                                num = body[arg_start..arg_end].trim().parse().unwrap_or(1);
4429                                if num == 0 {
4430                                    num = 1;
4431                                }
4432                            }
4433                            i = arg_end;
4434                        }
4435                        _ => {
4436                            // c:1476-1483 — `flagerr:` resets every flag and
4437                            // rewinds, so the whole subscript is re-read as
4438                            // plain key text.
4439                            flag_err = true;
4440                            break;
4441                        }
4442                    }
4443                    i += 1;
4444                }
4445                if !flag_err && i < bytes.len() && bytes[i] == b')' {
4446                    pat = &body[i + 1..]; // c:1485 `if (s != *str) s++;`
4447                } else {
4448                    flag_err = true;
4449                }
4450            }
4451            if flag_err {
4452                // c:1478-1482 — `num = 1; word = rev = ind = down = keymatch = 0;`
4453                rev = false;
4454                ind = false;
4455                down = false;
4456                keymatch = false;
4457                quote_arg = false;
4458                num = 1;
4459                pat = body;
4460            }
4461            // c:1487-1490 — `if (num < 0) { down = !down; num = -num; }`
4462            if num < 0 {
4463                down = !down;
4464            }
4465
4466            if !rev {
4467                // c:1575-1595 — no search flag: the subscript is an exact KEY.
4468                // C rebinds `v->pm` to the ELEMENT's own Param
4469                // (`ht->getnode(ht, s)`), or on a miss to a fresh
4470                // `PM_SCALAR|PM_UNSET` one, then leaves the whole-value
4471                // bounds. That element type is what makes the read collapse:
4472                // `getvaluearr` has no arm for PM_SCALAR (c:719) and
4473                // `getarrvalue`'s `v->start == 0 && v->end == -1` early-out
4474                // (c:2565) hands the empty result straight back, while
4475                // `IS_UNSET_VALUE` (c:472-474) catches the miss. Materialise
4476                // the same element node here — this port keeps assoc values as
4477                // plain strings, so build the `PM_SCALAR` node C's hash
4478                // carries (c:4156-4166).
4479                // c:1585 — `if (!(v->pm = (Param) ht->getnode(ht, s)))`. C
4480                // resolves an exact key with ONE hash-node lookup; it never
4481                // enumerates the table. For a PM_HASHED SPECIAL (`history`,
4482                // `functions`, `commands`, `parameters`, … — the partab[] rows
4483                // at Src/Modules/parameter.c:2235+) `ht->getnode` is that
4484                // row's `getfn`, e.g. `getpmhistory` (c:Src/Modules/
4485                // parameter.c:1156), which is a single `quietgethist`.
4486                // Routing this through `assoc_get` instead materialized the
4487                // WHOLE association to read one key: on `$history[$num]` that
4488                // meant paging the entire HISTFILE into the ring and building
4489                // a 574k-entry map for one event, seconds per completion.
4490                // Only the magic rows change path here — a plain assoc still
4491                // reads through `assoc_get`, since its values live in
4492                // `paramtab_hashed_storage` and carry no getfn.
4493                // c:570-575 — nameref deref before the read (what `assoc_get`
4494                // does on its own way in).
4495                let hname = match resolve_nameref_name(&name, None) {
4496                    nameref_resolution::Target { name: t_, .. } => t_,
4497                    _ => name.clone(),
4498                };
4499                let entry = if paramtab_hashed_storage()
4500                    .lock()
4501                    .map_or(true, |st| st.contains_key(hname.as_str()))
4502                {
4503                    crate::ported::subst::assoc_get(&hname).and_then(|m| m.get(pat).cloned())
4504                } else {
4505                    crate::vm_helper::partab_get(&hname, pat) // c:1585 ht->getnode
4506                };
4507                let mut elem = param::default();
4508                elem.node.nam = pat.to_string();
4509                elem.node.flags = PM_SCALAR as i32;
4510                match entry {
4511                    Some(val) => elem.u_str = Some(val),        // c:1585
4512                    None => elem.node.flags |= PM_UNSET as i32, // c:1588
4513                }
4514                v.pm = Some(Box::new(elem));
4515                v.scanflags = 0; // c:1591 (`*inv` is 0 on this path)
4516                v.start = 0; // c:1592
4517                v.end = -1; // c:1594
4518                *pptr = past_bracket; // c:2164
4519                return 0; // c:2166
4520            }
4521
4522            // ---------------------------------------------------------
4523            // c:1493-1512 — resolve which SIDE of each entry is emitted.
4524            // The caller's WANTKEYS/WANTVALS win when present (that is how
4525            // `compadd -k 'h[(R)pat]'` asks for the KEYS of VALUE-matching
4526            // entries, Src/Zle/compcore.c:2030-2033); otherwise the
4527            // direction flag picks.
4528            // ---------------------------------------------------------
4529            let sf = v.scanflags as u32;
4530            if (sf & SCANPM_WANTKEYS) == 0 && (sf & SCANPM_WANTVALS) == 0 && v.scanflags != 0 {
4531                if ind {
4532                    // c:1503-1505
4533                    v.scanflags |= SCANPM_WANTKEYS as i32;
4534                    v.scanflags &= !(SCANPM_WANTVALS as i32);
4535                } else if rev {
4536                    // c:1506
4537                    v.scanflags |= SCANPM_WANTVALS as i32;
4538                }
4539                if !down && keymatch {
4540                    // c:1511 — `if (!down && keymatch && ishash)`
4541                    v.scanflags &= !(SCANPM_MATCHMANY as i32);
4542                }
4543            }
4544
4545            // c:1564-1572 — `parsestr(&s); singsub(&s)` on the subscript
4546            // body, so a `$var` inside the pattern is expanded before it is
4547            // compiled.
4548            let mut s: String = pat.to_string();
4549            if s.contains('$') || s.contains('`') {
4550                s = crate::ported::subst::singsub(&s); // c:1571
4551            }
4552
4553            // c:1686-1708 — build the pattern program once, before the scan.
4554            // `k`/`K` leave it NULL (c:1707-1708): SCANPM_KEYMATCH compiles
4555            // the STORED KEY per node instead (c:653-659).
4556            let pprog = if keymatch {
4557                None // c:1708
4558            } else {
4559                let mut p = s.clone();
4560                if !quote_arg {
4561                    crate::ported::glob::tokenize(&mut p); // c:1704
4562                }
4563                crate::ported::glob::remnulargs(&mut p); // c:1705
4564                patcompile(&p, PAT_HEAPDUP as i32, None) // c:1706
4565            };
4566
4567            // c:1710-1735 — the hash scan. `v->scanflags` is always non-zero
4568            // for a PM_HASHED value (fetchvalue c:2274-2280 promotes 0 to
4569            // SCANPM_ARRONLY), so C's `else` array-walk arm is unreachable
4570            // from here.
4571            if !keymatch && pprog.is_none() {
4572                // c:1717-1718 — `if (!pprog) return 1;` aborts before the
4573                // scan, leaving `v->arr` NULL. An uncompilable pattern can
4574                // match nothing; report it the way the exact-key miss above
4575                // does so no consumer re-derives a full-table scan.
4576                *pptr = past_bracket;
4577                v.pm = None;
4578                return 0;
4579            }
4580            // c:1712-1713 — `scanprog = pprog; scanstr = s;`. This port's
4581            // statics hold the pattern TEXT (`scanparamvals` compiles it),
4582            // so store `s` and leave SCANPROG unset for the keymatch case
4583            // exactly as C leaves `pprog` NULL there.
4584            *scanprog_lock().lock().unwrap() = if keymatch { None } else { Some(s.clone()) };
4585            *scanstr_lock().lock().unwrap() = Some(s);
4586            if keymatch {
4587                v.scanflags |= SCANPM_KEYMATCH as i32; // c:1715
4588            } else if ind {
4589                v.scanflags |= SCANPM_MATCHKEY as i32; // c:1720
4590            } else {
4591                v.scanflags |= SCANPM_MATCHVAL as i32; // c:1722
4592            }
4593            if down {
4594                v.scanflags |= SCANPM_MATCHMANY as i32; // c:1725
4595            }
4596            // c:1726 — `ta = getvaluearr(v)` runs the scan and leaves the
4597            // result in `v->arr` with `v->start`/`v->end` covering all of it
4598            // (c:716-717), which is what C's getindex tail (c:2151-2153)
4599            // then re-derives from getarg's `r`/`*w`.
4600            let _ = getvaluearr(Some(&mut *v)); // c:1726
4601            *scanprog_lock().lock().unwrap() = None; // c:1732
4602            *scanstr_lock().lock().unwrap() = None;
4603
4604            // ---------------------------------------------------------
4605            // c:2128-2160 — getindex's non-inverse tail. C runs it on
4606            // every subscript; this port used to return straight out of
4607            // the hash scan and skip it, so `v->scanflags` was never
4608            // cleared and `assignstrvalue`'s c:2701 guard could not tell
4609            // "the search matched, this is a slice" from "the search
4610            // matched nothing".
4611            //
4612            // The two numbers C feeds it come from `getarg`:
4613            //   `start` is getarg's return — 1 on both hash exits, the
4614            //   `return 1` at c:1754 (scan produced a result) and the
4615            //   `return !down` at c:1760 (it did not, and !down is 1
4616            //   whenever that line is reachable: a `down` scan always
4617            //   satisfies c:1747 through SCANPM_MATCHMANY and leaves by
4618            //   c:1754).
4619            //   `we` is `*w`, assigned ONLY on the c:1754 exit as
4620            //   `v->end`, which `getvaluearr` has just set to
4621            //   `numparamvals + 1` (c:717). On the c:1760 exit it keeps
4622            //   the caller's 0.
4623            // ---------------------------------------------------------
4624            let sf = v.scanflags as u32; // c:2175
4625            let start: i32 = 1; // c:2036 (getarg → c:1754 / c:1760)
4626                                // c:1747-1750 — `if ((ta = getvaluearr(v)) && (*ta ||
4627                                // ((v->scanflags & SCANPM_MATCHMANY) && (v->scanflags &
4628                                // (SCANPM_MATCHKEY|SCANPM_MATCHVAL|SCANPM_KEYMATCH)))))`.
4629            let we: i32 = if !v.arr.is_empty()
4630                || ((sf & SCANPM_MATCHMANY) != 0
4631                    && (sf & (SCANPM_MATCHKEY | SCANPM_MATCHVAL | SCANPM_KEYMATCH)) != 0)
4632            {
4633                v.end // c:1752 `*w = v->end;`
4634            } else {
4635                0
4636            };
4637            // c:2131 — `com = (*s == ',')`. Never true for a hash: the
4638            // c:1515 walk does not stop at a comma when `ishash`.
4639            let mut com = false; // c:2131
4640            let end: i32 = if we != 0 { we } else { start }; // c:2135
4641            if start != end {
4642                com = true; // c:2137-2138
4643            }
4644            // c:2144-2145 — `if (start > 0) start -= startprevlen;` with the
4645            // initial `*prevcharlen = 1` (c:1405): the hash exits never
4646            // reach c:1650's character walk.
4647            let start = start - 1;
4648            if v.scanflags != 0
4649                && !com
4650                && ((v.scanflags as u32 & SCANPM_MATCHMANY) == 0
4651                    || (v.scanflags as u32 & (SCANPM_MATCHKEY | SCANPM_MATCHVAL | SCANPM_KEYMATCH))
4652                        == 0)
4653            {
4654                v.scanflags = 0; // c:2179
4655            }
4656            v.start = start; // c:2180
4657            v.end = end; // c:2181
4658            *pptr = past_bracket; // c:2164
4659            return 0; // c:2166
4660        }
4661    }
4662    if body.starts_with('(') {
4663        let pm_is_array =
4664            v.pm.as_ref()
4665                .map_or(false, |p| PM_TYPE(p.node.flags as u32) == PM_ARRAY);
4666        if pm_is_array {
4667            let flag_part = &body[1..body.find(')').unwrap_or(1)];
4668            let return_index = flag_part.contains('i') || flag_part.contains('I');
4669            if return_index {
4670                let arr: Vec<String> = v.pm.as_ref().map(|p| arrgetfn(p)).unwrap_or_default();
4671                if let Some(getarg_out::Value(val)) = getarg(body, Some(&arr), None, None) {
4672                    if let Ok(idx) = val.to_str().parse::<i64>() {
4673                        // c:2110-2114 — inv branch single-element access.
4674                        v.valflags |= VALFLAG_INV;
4675                        v.scanflags = 0;
4676                        v.start = idx as i32;
4677                        v.end = (idx + 1) as i32;
4678                        *pptr = past_close;
4679                        return 0;
4680                    }
4681                }
4682            } else if flag_part.contains('r')
4683                || flag_part.contains('R')
4684                || flag_part.contains('k')
4685                || flag_part.contains('K')
4686            {
4687                // c:1736-1760 — the ARRAY search arm (`ishash` false, so
4688                // c:1711's hash scan is skipped and `ta = getarrvalue(v)` is
4689                // walked directly). C's `getarg` returns the 1-based INDEX
4690                // `r` of the match and getindex's tail (c:2151-2153) turns it
4691                // into `start`/`end`; this port's `getarg` has no Value
4692                // out-param and hands back the matched ELEMENT(S) instead, so
4693                // put them in `v->arr` — the same cache `getvaluearr` reads
4694                // first (c:711-712) — and set the bounds to cover exactly
4695                // them. `k`/`K` degrade to `r`/`R` here because C gates
4696                // `keymatch = ishash` (c:1401/1406).
4697                let arr: Vec<String> = v.pm.as_ref().map(|p| arrgetfn(p)).unwrap_or_default();
4698                if let Some(getarg_out::Value(val)) = getarg(body, Some(&arr), None, None) {
4699                    let hits: Vec<String> = match val {
4700                        // c:1724-1734 — the MATCHMANY forms are array-shaped.
4701                        Value::Array(parts) => parts.iter().map(|p| p.to_str()).collect(),
4702                        other => {
4703                            // c:1758 — a miss returns index 0, i.e. no
4704                            // element; this port signals it with "".
4705                            let s = other.to_str();
4706                            if s.is_empty() {
4707                                Vec::new()
4708                            } else {
4709                                vec![s]
4710                            }
4711                        }
4712                    };
4713                    // c:1394-1399 — the LAST direction letter decides `down`
4714                    // (every arm resets the others); `k`/`K` act as `r`/`R`
4715                    // on an array (c:1401/1406 `keymatch = ishash`).
4716                    let down = flag_part
4717                        .chars()
4718                        .rev()
4719                        .find(|c| matches!(c, 'r' | 'R' | 'k' | 'K'))
4720                        .is_some_and(|c| c.is_ascii_uppercase());
4721                    if hits.is_empty() && down {
4722                        // c:1758-1760 / c:1782 — a reverse search that matches
4723                        // nothing returns index 0, so getindex's tail sees
4724                        // `start == 0 && end == 0` (c:2126) and the
4725                        // zero-subscript rule decides what that means:
4726                        if crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHZEROSUBSCRIPT) {
4727                            // c:2134-2140 — "Treat this as accessing the
4728                            // first element of the array."
4729                            v.start = 0;
4730                            v.end = 1; // c:2140 `end = startnextlen`
4731                        } else {
4732                            // c:2141-2150 — strict: an empty range.
4733                            v.valflags |= VALFLAG_EMPTY; // c:2147
4734                            v.start = -1; // c:2148
4735                            v.end = 0;
4736                        }
4737                        *pptr = past_close; // c:2164
4738                        return 0; // c:2166
4739                    }
4740                    v.start = 0; // c:2144-2145 (getarg's 1, less startprevlen)
4741                    v.end = hits.len() as i32; // c:2135
4742                    v.arr = hits;
4743                    *pptr = past_close; // c:2164
4744                    return 0; // c:2166
4745                }
4746            }
4747        }
4748    }
4749    // c:2035-2040 — general path: getarg() would parse the start
4750    // index. The Rust `getarg` has a different signature (flag
4751    // dispatcher returning getarg_out, not C's char**+int*+zlong
4752    // out-params), so the bracket-subscript here inline-parses
4753    // the simple cases: `N`, `N,M`, `-N`. Flag-based subscripts
4754    // (`[(I)pat]`, `[(r)val]`) still route through getarg
4755    // separately when called by the substitution pipeline.
4756
4757    let (start_str, end_str) = match body.split_once(',') {
4758        Some((a, b)) => (a, Some(b)),
4759        None => (body, None),
4760    };
4761    // c:Src/params.c:1597 — `r = mathevalarg(s, &s);`. A non-hashed
4762    // subscript operand is an ARITHMETIC expression, not a decimal
4763    // literal: `a[i]`, `a[1+1]`, `a[$#x]` all reach getarg's numeric
4764    // arm and are handed to `mathevalarg`. This port bailed out with
4765    // `return 0` (leaving `v` untouched) whenever `str::parse` failed,
4766    // which silently swallowed BOTH of C's outcomes — an undefined
4767    // name evaluating to 0 (c:Src/math.c:1534) and the empty operand
4768    // that `mathevalarg` rejects outright with `zerr("bad math
4769    // expression: empty string")` (c:Src/math.c:1530-1532). Keep the
4770    // decimal fast path (matheval of "5" is 5) and route everything
4771    // else through the canonical evaluator so both outcomes survive.
4772    let evalarg = |t: &str| -> i64 {
4773        let r = match t.parse::<i64>() {
4774            Ok(n) => n, // fast path; identical to mathevalarg on a decimal literal
4775            Err(_) => crate::ported::math::mathevalarg(t), // c:1618
4776        };
4777        // c:1619-1620 — `if (isset(KSHARRAYS) && r >= 0) r++;`. getarg
4778        // runs this for both the start (a2=0) and the `,end` (a2=1) operand.
4779        if crate::ported::zsh_h::isset(KSHARRAYS) && r >= 0 {
4780            r + 1
4781        } else {
4782            r
4783        }
4784    };
4785    let start: i64 = evalarg(start_str);
4786    let mut end: i64 = match end_str {
4787        // c:2112 — `end = getarg(&s, &inv, v, 1, ...)`, i.e. the same
4788        // arithmetic evaluation for the range's second operand.
4789        Some(s) => evalarg(s),
4790        // c:2114 — `end = we ? we : start;`
4791        None => start,
4792    };
4793
4794    let mut start = start;
4795    let mut com = end_str.is_some() || start != end; // c:2131-2138
4796
4797    // c:2144-2145 — `if (start > 0) start -= startprevlen;`. getarg
4798    // returned the 1-based position just PAST the start character
4799    // (c:1650-1655 walks `r` characters); `startprevlen` is that last
4800    // character's length, so this turns the position into the 0-based
4801    // offset every consumer (getstrvalue c:2362/c:2527, setstrvalue,
4802    // glob's `first`) indexes with. This port subscripts by character,
4803    // where that length is always one — including for an array, whose
4804    // getarg leaves `*prevcharlen` at its initial 1 (c:1405).
4805    if start > 0 {
4806        start -= 1; // c:2145
4807    } else if start == 0 && end == 0 {
4808        // c:2126
4809        // c:2134 — `if (isset(KSHZEROSUBSCRIPT))` non-strict mode.
4810        // Treats `a[0]` as the first element (end = startnextlen,
4811        // which is 1 for ASCII). c:2141-2150 strict mode keeps the
4812        // VALFLAG_EMPTY + start=-1 sentinel for empty access.
4813        if crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHZEROSUBSCRIPT) {
4814            end = 1; // c:2140 — `end = startnextlen` (1 for ASCII)
4815        } else {
4816            v.valflags |= VALFLAG_EMPTY; // c:2147
4817            start = -1; // c:2148
4818            com = true; // c:2170
4819        }
4820    }
4821    // c:2156-2158 — clear scanflags for non-comma simple subscript
4822    // when match flags absent.
4823    if v.scanflags != 0
4824        && !com
4825        && (v.scanflags as u32 & SCANPM_MATCHMANY == 0
4826            || v.scanflags as u32 & (SCANPM_MATCHKEY | SCANPM_MATCHVAL | SCANPM_KEYMATCH) == 0)
4827    {
4828        v.scanflags = 0;
4829    }
4830    let _ = (SCANPM_ISVAR_AT, SCANPM_WANTINDEX, VALFLAG_INV);
4831    v.start = start as i32; // c:2159
4832    v.end = end as i32; // c:2160
4833
4834    // c:2164-2165 — advance `*pptr` past the close bracket.
4835    *pptr = past_close;
4836    0 // c:2166
4837}
4838
4839/// Port of `getvalue()` from `Src/params.c:2173`. C body:
4840/// `return fetchvalue(v, pptr, bracks, SCANPM_CHECKING);` — pure
4841/// wrapper around `fetchvalue` with the SCANPM_CHECKING flag set
4842/// so unset params don't trigger creation.
4843pub fn getvalue<'a>(
4844    v: Option<&'a mut value>,
4845    pptr: &mut &str,
4846    bracks: i32,
4847) -> Option<&'a mut value> {
4848    fetchvalue(v, pptr, bracks, SCANPM_CHECKING as i32)
4849}
4850
4851/// Port of `fetchvalue()` from `Src/params.c:2180`.
4852///
4853/// Walks the parameter expression starting at `*pptr`, consuming
4854/// the identifier (or special-char like `?`/`#`/`$`/`!`/`@`/`*`/
4855/// `-`) and updating `*pptr` to point past the name. Looks up the
4856/// param in paramtab and populates the Value's pm/start/end/
4857/// scanflags fields.
4858///
4859/// Identifier, special-char and digit names are parsed and looked up.
4860/// Nameref resolution (the `getnode` vs `getnode2` choice on
4861/// SCANPM_NONAMEREF, c:2227-2236), bracket subscripts (`getindex` at
4862/// c:2288) and the SCANPM_ARRONLY scanflags promotion for hash/array
4863/// params are handled. C's fetchvalue has no separate nameref-slice
4864/// path; `getparamnode` (c:570-575) resolves references through
4865/// `resolve_nameref`.
4866pub fn fetchvalue<'a>(
4867    // c:2180
4868    v: Option<&'a mut value>,
4869    pptr: &mut &str,
4870    bracks: i32,
4871    scanflags: i32,
4872) -> Option<&'a mut value> {
4873    let s = *pptr;
4874    let bytes = s.as_bytes();
4875    if bytes.is_empty() {
4876        return None; // c:2214 fall-through
4877    }
4878    let c = bytes[0];
4879    let mut ppar: i32 = 0;
4880    let mut end_pos = 0usize;
4881
4882    if c.is_ascii_digit() {
4883        // c:2190
4884        // c:2191-2194 — zstrtol parse of positional parameter index.
4885        if bracks >= 0 {
4886            let mut idx = 0;
4887            while idx < bytes.len() && bytes[idx].is_ascii_digit() {
4888                ppar = ppar * 10 + (bytes[idx] - b'0') as i32;
4889                idx += 1;
4890            }
4891            end_pos = idx;
4892        } else {
4893            // c:2194 — single-digit positional ($0..$9 short form).
4894            ppar = (c - b'0') as i32;
4895            end_pos = 1;
4896        }
4897    } else if itype_end(s, crate::ported::ztype_h::IIDENT as u32, false) > 0 {
4898        // c:2196 — `itype_end(s, IIDENT, 0)` — walk identifier chars.
4899        end_pos = itype_end(s, crate::ported::ztype_h::IIDENT as u32, false);
4900    } else if matches!(c, b'?' | b'#' | b'$' | b'!' | b'@' | b'*' | b'-') {
4901        // c:2198-2210
4902        end_pos = 1;
4903    } else {
4904        return None; // c:2213
4905    }
4906
4907    let name = &s[..end_pos];
4908    *pptr = &s[end_pos..];
4909
4910    if ppar > 0 {
4911        // c:2217-2225 positional
4912        if let Some(v) = v {
4913            *v = value {
4914                pm: None,
4915                arr: Vec::new(),
4916                scanflags: 0,
4917                valflags: 0,
4918                start: ppar - 1,
4919                end: ppar,
4920            };
4921            return Some(v);
4922        }
4923        return None;
4924    }
4925
4926    // c:2227-2236 — paramtab lookup honouring SCANPM_NONAMEREF for
4927    // getnode vs getnode2 (the second skips nameref resolution).
4928    let pm = {
4929        let tab = paramtab().read().unwrap();
4930        let key = if name == "0" { "0" } else { name };
4931        tab.get(key).cloned()
4932    };
4933    // c:2227 — the lookup is `paramtab->getnode`, i.e. getparamnode
4934    // (c:570-572), which runs loadparamnode on a PM_AUTOLOAD stub so the
4935    // first fetch of `zmodload -ap MOD NAME`'s NAME loads MOD (or reports
4936    // "autoloading module MOD failed to define parameter: NAME").
4937    // loadparamnode does not read its table argument (zshrs has one
4938    // paramtab), so the stub-only branch hands it a fresh empty one.
4939    let pm = match pm {
4940        Some(p) if (p.node.flags as u32 & PM_AUTOLOAD) != 0 => newparamtable(0, "paramtab")
4941            .and_then(|ht| loadparamnode(&ht, Some(p), name)),
4942        other => other,
4943    };
4944    let pm = pm?; // c:2237-2241
4945
4946    // c:2241-2243 — `if (PM_UNSET && !PM_DECLARED) return NULL`.
4947    if pm.node.flags & PM_UNSET as i32 != 0 && pm.node.flags & PM_DECLARED as i32 == 0 {
4948        return None;
4949    }
4950
4951    // c:2246-2270 — nameref deref. Partially handled: we route
4952    // through resolve_nameref if PM_NAMEREF is set and the caller
4953    // didn't pass SCANPM_NONAMEREF.
4954    let pm = if pm.node.flags & PM_NAMEREF as i32 != 0 && (scanflags as u32) & SCANPM_NONAMEREF == 0
4955    {
4956        resolve_nameref(Some(pm))?
4957    } else {
4958        pm
4959    };
4960
4961    // c:430 `IPDEF9("argv", &pparams, NULL, 0)` — the positional alias's
4962    // array getter is `arrvargetfn` (c:4231-4236), which dereferences the
4963    // LIVE `pparams`. zshrs keeps that vector in `builtin::PPARAMS` and the
4964    // node's `u_arr` is stale, so every getindex/getarg/getstrvalue reader
4965    // of this Value (they all go through `arrgetfn`) saw the wrong words:
4966    // `(( argv[(i)-] ))` answered 1 where zsh answers 2.
4967    let mut pm = pm;
4968    if matches!(name, "argv" | "@" | "*") {
4969        pm.u_arr = Some(PPARAMS.lock().map(|p| p.clone()).unwrap_or_default());
4970    }
4971
4972    if let Some(v) = v {
4973        // c:2274-2282 — populate Value from pm.
4974        *v = value {
4975            pm: Some(pm.clone()),
4976            arr: Vec::new(),
4977            scanflags: 0,
4978            valflags: 0,
4979            start: 0,
4980            end: -1,
4981        };
4982        let pmflags = pm.node.flags;
4983        let isvar_at = name == "@";
4984        if PM_TYPE(pmflags as u32) & (PM_ARRAY | PM_HASHED) != 0 {
4985            // c:2274-2280 — scanflags overload for hashed arrays.
4986            let mut sf = scanflags;
4987            if isvar_at {
4988                sf |= SCANPM_ISVAR_AT as i32;
4989            }
4990            if sf == 0 {
4991                sf = SCANPM_ARRONLY as i32;
4992            }
4993            v.scanflags = sf;
4994        }
4995        // c:2289-2293 — bracket-subscript dispatch. When the unparsed
4996        // remainder starts with `[` (or the lexer's `Inbrack` token),
4997        // hand off to `getindex` which fills `v.start`/`v.end`/
4998        // `v.scanflags` and advances `pptr`.
4999        if bracks > 0 && (pptr.starts_with('[') || pptr.starts_with(Inbrack)) {
5000            if getindex(pptr, v, scanflags) != 0 {
5001                // c:2290
5002                return Some(v); // c:2292
5003            }
5004        } else if (scanflags & SCANPM_ASSIGNING as i32) == 0
5005            && v.scanflags != 0
5006            // c:2288 `itype_end(t, INAMESPC, 1) != t` — the clamp needs an identifier
5007            // name; `$@` / `$*` are not one, so `"${*:-x}"` keeps every word.
5008            && name.chars().next().is_some_and(|c| c.is_alphabetic() || c == '_')
5009            && isset(KSHARRAYS)
5010        {
5011            // c:2294-2296 — KSHARRAYS implicit `[0]` for bare arr.
5012            v.end = 1;
5013            v.scanflags = 0;
5014        } else {
5015        }
5016        return Some(v);
5017    }
5018    None
5019}
5020
5021/// Port of `getstrvalue()` from `Src/params.c:2335`.
5022/// Full C body dispatches on `PM_TYPE(v->pm->node.flags)`:
5023/// PM_HASHED (KSH path: `[0]` index lookup), PM_ARRAY (sepjoin
5024/// when v->scanflags else `ss[v->start]`), PM_INTEGER (`convbase`),
5025/// PM_EFLOAT|PM_FFLOAT (`convfloat`), PM_SCALAR|PM_NAMEREF
5026/// (`pm->gsu.s->getfn(pm)`). Then PM_LEFT/PM_RIGHT_B/PM_RIGHT_Z
5027/// padding when VALFLAG_SUBST is set.
5028pub fn getstrvalue(v: Option<&mut value>) -> String {
5029    let v = match v {
5030        Some(v) => v,
5031        None => return String::new(),
5032    };
5033    // c:2344-2348 — `if (VALFLAG_INV && !PM_HASHED) return sprintf("%d", v->start)`.
5034    if (v.valflags & VALFLAG_INV) != 0 {
5035        let hashed =
5036            v.pm.as_ref()
5037                .map(|p| (p.node.flags as u32 & PM_HASHED) != 0)
5038                .unwrap_or(false);
5039        if !hashed {
5040            return v.start.to_string();
5041        }
5042    }
5043    let pm = match v.pm.as_mut() {
5044        Some(p) => p,
5045        None => return String::new(),
5046    };
5047    let t = PM_TYPE(pm.node.flags as u32);
5048    let pmflags = pm.node.flags as u32;
5049
5050    // c:2350-2370 — PM_TYPE dispatch.
5051    let mut s: String = if t == PM_HASHED && v.scanflags == 0 && EMULATION(EMULATE_KSH) {
5052        // c:Src/params.c:2351-2358 — `case PM_HASHED: if (!v->scanflags &&
5053        // EMULATION(EMULATE_KSH))` — a bare `$assoc` (no subscript) under
5054        // KSH EMULATION is `${assoc[0]}`: it builds `s = "[0]"`, does a
5055        // KEY-"0" `getindex`, and returns that element's value. So it is
5056        // EMPTY unless the hash actually holds a key "0". This is
5057        // emulation-gated, NOT KSHARRAYS-option-gated: `emulate -L ksh;
5058        // typeset -A h=(a 1 b 2); print $h` is empty, whereas `setopt
5059        // ksharrays; …; print $h` falls through below to the first bucket
5060        // value `1`. (`setopt ksharrays` keeps v->scanflags non-zero for a
5061        // bare assoc, so it never reaches this arm; the `scanflags==0`
5062        // guard mirrors the C `!v->scanflags` "impossible unless emulating
5063        // ksh" invariant.)
5064        paramtab_hashed_storage()
5065            .lock()
5066            .ok()
5067            .and_then(|store| store.get(&pm.node.nam).and_then(|m| m.get("0").cloned()))
5068            .unwrap_or_default()
5069    } else if t == PM_HASHED || t == PM_ARRAY {
5070        // c:2359-2369 (PM_ARRAY, and the ksh-emulation PM_HASHED fall-through).
5071        // Both arms end in `return s;` (c:2369): an element is neither
5072        // padded nor cut by the scalar tail below.
5073        let arr = arrgetfn(pm);
5074        if v.scanflags != 0 {
5075            // c:2361
5076            return arr.join(" ");
5077        } else {
5078            let mut start = v.start;
5079            if start < 0 {
5080                start += arr.len() as i32;
5081            } // c:2364
5082            if start < 0 || (start as usize) >= arr.len() {
5083                // c:2365-2366
5084                return String::new();
5085            } else {
5086                return arr[start as usize].clone();
5087            }
5088        }
5089    } else if t == PM_INTEGER {
5090        // c:2371
5091        // c:2373 — `convbase(buf, pm->gsu.i->getfn(pm), pm->base)`.
5092        // The previous Rust port used `intgetfn(pm).to_string()` (naked
5093        // base-10). With `convbase` now ported (params.rs:6577), honor
5094        // `pm.base` so `typeset -i 16 x=255` renders as `0xff` rather
5095        // than `255` per zsh's `$x`-expansion + `typeset -p`.
5096        // !!! POSIX-FAITHFUL GATE !!! zsh gives integer variables a per-var
5097        // output base (`(( z=0xff ))` → `typeset -i16 z=255` → `$z` prints
5098        // `16#FF`). bash/ksh/POSIX have no such feature: integers always
5099        // print in decimal. Under `--sh`/`--ksh`/`--dash`/`--bash`
5100        // (posix_faithful) force base 10 so `(( z=0xff )); echo $z` → `255`,
5101        // matching the real shell. `--... --zsh` keeps zsh's based display.
5102        let base = if pm.base > 0 && !crate::dash_mode::posix_faithful() {
5103            pm.base // c:2373 pm->base
5104        } else {
5105            10
5106        };
5107        convbase_underscore(
5108            intgetfn(pm),
5109            base,
5110            pm.width, // c:2373 pm->width for underscore grouping
5111        )
5112    } else if t == PM_EFLOAT || t == PM_FFLOAT {
5113        // c:2366-2370 — `convfloat(getfn(pm), pm->base, pm->node.flags,
5114        // NULL)`. The format flag (PM_EFLOAT → e-notation, PM_FFLOAT →
5115        // fixed) and the precision (`pm->base` holds digits for floats)
5116        // BOTH come straight from the param. The previous Rust port used
5117        // `convfloat_underscore(floatgetfn(pm), pm.width)`, which drops
5118        // the format flag (convfloat_underscore calls `convfloat(d,0,0)`
5119        // → 'g' default) and wrongly applies width grouping that C does
5120        // NOT do here (underscore grouping happens in typeset -p output
5121        // c:2866, not getstrvalue). That made getstrvalue render a
5122        // default `float f=2.5` as "2.5" instead of "2.500000000e+00",
5123        // surfacing when `assignaparam`'s AUGMENT prepend (c:3350) routes
5124        // an old float value through getstrvalue.
5125        convfloat(floatgetfn(pm), pm.base, pm.node.flags as u32)
5126    } else if t == PM_SCALAR || t == PM_NAMEREF {
5127        // c:2380
5128        strgetfn(pm)
5129    } else {
5130        // c:2384
5131        DPUTS!(true, "BUG: param node without valid type"); // c:2385
5132        String::new() // c:2386 s = "" (line c:2384)
5133    };
5134
5135    // c:2390-2538 — VALFLAG_SUBST padding (PM_LEFT / PM_RIGHT_B /
5136    // PM_RIGHT_Z). Multibyte is approximated via `chars().count()`
5137    // (codepoint count) since the Rust port stores strings as
5138    // UTF-8 rather than the C meta-byte encoding.
5139    if v.valflags & VALFLAG_SUBST != 0 {
5140        let pad_flags = pmflags & (PM_LEFT | PM_RIGHT_B | PM_RIGHT_Z);
5141        if pad_flags != 0 {
5142            let fwidth = if pm.width > 0 {
5143                pm.width as usize
5144            } else {
5145                s.chars().count()
5146            };
5147            if pad_flags == PM_LEFT || pad_flags == (PM_LEFT | PM_RIGHT_Z) {
5148                // c:2393-2424 — left-justify: optional zero/blank trim,
5149                // truncate to fwidth, right-pad with spaces.
5150                let trimmed: &str = if pad_flags & PM_RIGHT_Z != 0 {
5151                    s.trim_start_matches('0')
5152                } else {
5153                    s.trim_start_matches(|c: char| c == ' ' || c == '\t')
5154                };
5155                let len = trimmed.chars().count();
5156                let take = len.min(fwidth);
5157                let mut out: String = trimmed.chars().take(take).collect();
5158                if fwidth > take {
5159                    out.extend(std::iter::repeat(' ').take(fwidth - take));
5160                }
5161                s = out;
5162            } else if pad_flags & (PM_RIGHT_B | PM_RIGHT_Z) != 0 {
5163                // c:2426-2510 — right-justify with optional zero-padding
5164                // honouring leading-blank/minus/0x/base# prefix
5165                // detection for numeric values.
5166                let charlen = s.chars().count();
5167                if charlen < fwidth {
5168                    let mut zero = true;
5169                    let mut valprefend: usize = 0;
5170                    let numeric_pm = (pmflags & (PM_INTEGER | PM_EFLOAT | PM_FFLOAT)) != 0;
5171                    if pad_flags & PM_RIGHT_Z != 0 {
5172                        // c:2446-2466 — find the prefix to keep
5173                        // (blanks → minus → 0x / base#).
5174                        let bytes = s.as_bytes();
5175                        let mut t = 0usize;
5176                        while t < bytes.len() && (bytes[t] == b' ' || bytes[t] == b'\t') {
5177                            t += 1; // c:2446-2447
5178                        }
5179                        if numeric_pm && t < bytes.len() && bytes[t] == b'-' {
5180                            t += 1; // c:2454-2455
5181                        }
5182                        if (pmflags & PM_INTEGER) != 0 {
5183                            let cbases = optlookup("cbases") > 0;
5184                            if cbases
5185                                && t + 1 < bytes.len()
5186                                && bytes[t] == b'0'
5187                                && bytes[t + 1] == b'x'
5188                            {
5189                                t += 2; // c:2462-2463
5190                            } else if let Some(hash_off) =
5191                                bytes[t..].iter().position(|&b| b == b'#')
5192                            {
5193                                t += hash_off + 1; // c:2464-2465
5194                            }
5195                        }
5196                        valprefend = t;
5197                        if t == bytes.len() {
5198                            zero = false; // c:2468-2469
5199                        } else if !numeric_pm && !bytes[t].is_ascii_digit() {
5200                            zero = false; // c:2473-2474
5201                        }
5202                    }
5203                    // c:2483 — pad char picks: ' ' if PM_RIGHT_B or
5204                    // numeric-prefix detection failed, else '0'.
5205                    let pad_char = if (pad_flags & PM_RIGHT_B) != 0 || !zero {
5206                        ' '
5207                    } else {
5208                        '0'
5209                    };
5210                    let need = fwidth - charlen;
5211                    let prefix = &s[..valprefend];
5212                    let rest = &s[valprefend..];
5213                    let mut out = String::with_capacity(need + s.len());
5214                    out.push_str(prefix); // c:2491
5215                    out.extend(std::iter::repeat(pad_char).take(need)); // c:2483-2485
5216                    out.push_str(rest); // c:2492-2493
5217                    s = out;
5218                } else if charlen > fwidth {
5219                    // c:2496-2500 — truncate from the front to fit fwidth
5220                    // codepoints (C uses MB_METACHARLEN; Rust uses chars).
5221                    let skip = charlen - fwidth;
5222                    s = s.chars().skip(skip).collect();
5223                }
5224            }
5225        }
5226        // c:2497-2506 — still inside the VALFLAG_SUBST block, right after
5227        // the padding switch. PM_LOWER / PM_UPPER are READ-time attributes:
5228        // storage keeps the value verbatim and only a substitution folds it.
5229        // PM_UPPER is skipped for PM_NAMEREF (the string is a target name).
5230        match pmflags & (PM_LOWER | PM_UPPER) {
5231            f if f == PM_LOWER => {
5232                s = casemodify(&s, CASMOD_LOWER); // c:2499
5233            }
5234            f if f == PM_UPPER => {
5235                if (pmflags & PM_NAMEREF) == 0 {
5236                    // c:2502
5237                    s = casemodify(&s, CASMOD_UPPER); // c:2503
5238                }
5239            }
5240            _ => {}
5241        }
5242    }
5243
5244    // c:2507-2508 — the whole value.
5245    if v.start == 0 && v.end == -1 {
5246        return s;
5247    }
5248
5249    // c:2510-2532 — a subscripted scalar: cut `s` to [start, end). C's
5250    // offsets are bytes of the metafied string (getarg converted the
5251    // character subscript, c:1650-1655); this port's getindex yields
5252    // character offsets, so the cut walks characters and each
5253    // MB_METACHARLEN (c:2520) is one.
5254    let chars: Vec<char> = s.chars().collect();
5255    let len = chars.len() as i32; // c:2510
5256    if v.start < 0 {
5257        // c:2511-2515
5258        v.start += len;
5259        if v.start < 0 {
5260            v.start = 0;
5261        }
5262    }
5263    if v.end < 0 {
5264        // c:2516-2522
5265        v.end += len;
5266        if v.end >= 0 && v.end < len {
5267            v.end += 1; // c:2520 `v->end += MB_METACHARLEN(eptr)`
5268        }
5269    }
5270    if v.start > len {
5271        return String::new(); // c:2524-2525
5272    }
5273    let tail = &chars[v.start as usize..]; // c:2526
5274    if v.end <= v.start {
5275        String::new() // c:2528-2529
5276    } else if v.end - v.start <= len - v.start {
5277        tail[..(v.end - v.start) as usize].iter().collect() // c:2530-2531
5278    } else {
5279        tail.iter().collect()
5280    }
5281}
5282
5283/// Slice an indexed array using zsh 1-based inclusive semantics.
5284/// Port of `getarrvalue()` from `Src/params.c:2548` — the slice
5285/// branch that resolves the start/end pair into a Vec. Negative
5286/// indices count from the end (`-1` is the last element);
5287/// out-of-range bounds collapse to empty (`${a[5,10]}` on len=3
5288/// returns empty, not clamped); `start > end` returns empty.
5289///
5290/// 0 has asymmetric meaning per C source's getarrvalue:
5291///   start=0 → "before first element" → resolved to 1
5292///   end=0   → "before first element" → empty slice
5293/// WARNING: param names don't match C — Rust=(arr, start, end) vs C=(v)
5294pub fn getarrvalue(arr: &[String], start: i64, end: i64) -> Vec<String> {
5295    let len = arr.len() as i64;
5296    // c:Src/params.c:2570-2585 getarrvalue — a slice whose START is at or
5297    // beyond the array end does NOT collapse to an empty array; it returns
5298    // EMPTY-STRING padding from the global `nular` (a one-element `{"", NULL}`
5299    // array), so the result is capped at a SINGLE empty element. The C
5300    // branch order (after `v->start += arrlen` / `v->end += arrlen + 1` for
5301    // negatives) is: `v->end <= v->start` → 0 elems; else `v->start < 0` → 1
5302    // elem; else `arrlen_le(s, v->start)` → `v->end - (v->start + 1)` elems
5303    // (capped by nular's length to ≤ 1). Only observable via `${#}` (counts
5304    // the empty element → `${#arr[1,2]}` is 1 for `arr=()`) and quoted splat;
5305    // unquoted array-capture elides the empty word. Mirror it here so the
5306    // out-of-range cases that previously returned `[]` pad correctly.
5307    let nular_pad = || -> Vec<String> {
5308        let v_start0 = start - 1; // C v->start is 0-based (subscript - 1)
5309        let v_end = if end < 0 { end + len + 1 } else { end }; // c:2565
5310        let v_start = if v_start0 < 0 {
5311            v_start0 + len
5312        } else {
5313            v_start0
5314        }; // c:2563
5315        if v_end <= v_start {
5316            Vec::new() // c:2578 (empty/inverted range)
5317        } else if v_start < 0 {
5318            vec![String::new()] // c:2580 (arrdup_max(nular, 1))
5319        } else if v_start >= len {
5320            // c:2582 — arrdup_max(nular, v->end - (v->start + 1)); nular has a
5321            // single element, so the count is capped at 1.
5322            if v_end - (v_start + 1) >= 1 {
5323                vec![String::new()]
5324            } else {
5325                Vec::new()
5326            }
5327        } else {
5328            Vec::new()
5329        }
5330    };
5331    if len == 0 {
5332        return nular_pad();
5333    }
5334    // Out-of-range starts (positive past len, or negative below
5335    // -len) return nular padding per Src/params.c getarrvalue's
5336    // slice-resolution branches (c:2570-2585).
5337    if start > len {
5338        return nular_pad();
5339    }
5340    if end < 0 && (len + end + 1) < 1 {
5341        return nular_pad();
5342    }
5343    if start < 0 && end < 0 && start > end {
5344        return nular_pad();
5345    }
5346    if start < 0 && start < -len {
5347        return nular_pad();
5348    }
5349    let resolve_start = |i: i64| -> i64 {
5350        if i < 0 {
5351            (len + i + 1).max(1)
5352        } else if i == 0 {
5353            1
5354        } else {
5355            i.min(len)
5356        }
5357    };
5358    let resolve_end = |i: i64| -> i64 {
5359        if i < 0 {
5360            (len + i + 1).max(0)
5361        } else if i == 0 {
5362            0
5363        } else {
5364            i.min(len)
5365        }
5366    };
5367    let s = resolve_start(start);
5368    let e = resolve_end(end);
5369    if e < 1 || s > e {
5370        return Vec::new();
5371    }
5372    let s_idx = (s - 1) as usize;
5373    let e_idx = e as usize;
5374    arr[s_idx..e_idx.min(arr.len())].to_vec()
5375}
5376
5377// ---------------------------------------------------------------------------
5378// Parameter table
5379// ---------------------------------------------------------------------------
5380
5381/// Parameter table.
5382/// Port of the `paramtab` HashTable Src/params.c maintains —
5383/// `createparamtable()` (line 817) initializes it with all the
5384/// IPDEF*-declared special params; `createparam()` (line 1030)
5385/// adds user variables.
5386// ---------------------------------------------------------------------------
5387// Free functions matching the C API
5388// ---------------------------------------------------------------------------
5389
5390/// Port of `getintvalue()` from `Src/params.c:2601`.
5391/// C body:
5392/// ```c
5393/// if (!v) return 0;
5394/// if (v->valflags & VALFLAG_INV) return v->start;
5395/// if (v->scanflags) {
5396///     char **arr = getarrvalue(v);
5397///     if (arr) { char *scal = sepjoin(arr, NULL, 1); return mathevali(scal); }
5398///     return 0;
5399/// }
5400/// if (PM_TYPE(v->pm->node.flags) == PM_INTEGER)
5401///     return v->pm->gsu.i->getfn(v->pm);
5402/// if (v->pm->node.flags & (PM_EFLOAT|PM_FFLOAT))
5403///     return (zlong)v->pm->gsu.f->getfn(v->pm);
5404/// return mathevali(getstrvalue(v));
5405/// ```
5406pub fn getintvalue(v: Option<&mut value>) -> i64 {
5407    let v = match v {
5408        Some(v) => v,
5409        None => return 0,
5410    };
5411    if (v.valflags & VALFLAG_INV) != 0 {
5412        return v.start as i64;
5413    }
5414    if v.scanflags != 0 {
5415        // c:2599-2605 — `arr = getarrvalue(v); if (arr) { scal = sepjoin(arr,
5416        // NULL, 1); return mathevali(scal); } else return 0;`. Same
5417        // array/slice selection as getnumvalue below.
5418        if v.pm.is_none() {
5419            return 0;
5420        }
5421        let whole = !v.arr.is_empty() || (v.start == 0 && v.end == -1);
5422        let (start, end) = (v.start as i64, v.end as i64);
5423        let full = getvaluearr(Some(&mut *v));
5424        let arr = if whole {
5425            full
5426        } else {
5427            getarrvalue(&full, if start >= 0 { start + 1 } else { start }, end)
5428        };
5429        let scal = crate::ported::utils::sepjoin(&arr, None);
5430        return mathevali(&scal).unwrap_or_else(|e| {
5431            zerr(&e);
5432            0
5433        });
5434    }
5435    let pm = match v.pm.as_mut() {
5436        Some(p) => p,
5437        None => return 0,
5438    };
5439    if PM_TYPE(pm.node.flags as u32) == PM_INTEGER {
5440        return intgetfn(pm);
5441    }
5442    if (pm.node.flags as u32 & (PM_EFLOAT | PM_FFLOAT)) != 0 {
5443        return floatgetfn(pm) as i64;
5444    }
5445    // c:2618 — `return mathevali(getstrvalue(v));`. The previous
5446    // Rust port used `s.parse::<i64>().unwrap_or(0)` which silently
5447    // returned 0 for any non-trivial arithmetic on the scalar
5448    // value side (e.g. `typeset x="1+2"; ((y = x))` would yield
5449    // y=0 instead of 3). Route through `math::mathevali` to
5450    // match C's arithmetic-expression evaluation.
5451    let pm = v.pm.as_mut().unwrap();
5452    let s = strgetfn(pm);
5453    mathevali(&s).unwrap_or(0) // c:2618 mathevali(...)
5454}
5455
5456/// Port of `getnumvalue()` from `Src/params.c:2624`. Returns an
5457/// `mnumber` (tagged int/float). C body dispatches on `valflags &
5458/// VALFLAG_INV` (returns start as int), `scanflags` (sepjoin →
5459/// matheval), then PM_TYPE: PM_INTEGER → mn.l = pm->gsu.i->getfn,
5460/// PM_EFLOAT|PM_FFLOAT → mn.type=MN_FLOAT; mn.d = pm->gsu.f->getfn,
5461/// else matheval(getstrvalue(v)).
5462pub fn getnumvalue(v: Option<&mut value>) -> mnumber {
5463    let v = match v {
5464        Some(v) => v,
5465        None => {
5466            return mnumber {
5467                l: 0,
5468                d: 0.0,
5469                type_: MN_INTEGER,
5470            }
5471        }
5472    };
5473    if (v.valflags & VALFLAG_INV) != 0 {
5474        return mnumber {
5475            l: v.start as i64,
5476            d: 0.0,
5477            type_: MN_INTEGER,
5478        };
5479    }
5480    if v.scanflags != 0 {
5481        // c:2626-2632 — `arr = getarrvalue(v); if (arr) { scal = sepjoin(arr,
5482        // NULL, 1); return matheval(scal); } else mn.u.l = 0;`. An array
5483        // search such as `(( a[(r)2*] + 1 ))` is evaluated from the joined
5484        // elements; the port returned 0 for every scanflags value.
5485        if v.pm.is_none() {
5486            return mnumber {
5487                l: 0,
5488                d: 0.0,
5489                type_: MN_INTEGER,
5490            };
5491        }
5492        // c:2556 — `s = getvaluearr(v)`, which returns `v->arr` first (c:731).
5493        // !!! WARNING: RUST-ONLY SLICE RULE !!! this port's getindex `(r)`/`(R)`
5494        // arm stores exactly the matched elements in `v.arr` (params.rs
5495        // getindex), where C stores an index into the whole array; those
5496        // elements ARE the slice, so they are taken whole. Otherwise C's
5497        // c:2557-2558 whole-array test, then the slice. `getarrvalue` here
5498        // takes a 1-based start; a non-negative `v.start` is getindex's
5499        // 0-based offset (c:2145), a negative one already counts from the end.
5500        let whole = !v.arr.is_empty() || (v.start == 0 && v.end == -1);
5501        let (start, end) = (v.start as i64, v.end as i64);
5502        let full = getvaluearr(Some(&mut *v));
5503        let arr = if whole {
5504            full
5505        } else {
5506            getarrvalue(&full, if start >= 0 { start + 1 } else { start }, end)
5507        };
5508        let scal = crate::ported::utils::sepjoin(&arr, None);
5509        // c:2630 — `return matheval(scal);`; a malformed join is reported
5510        // by the evaluator (zerr) and reads as zero.
5511        return matheval(&scal).unwrap_or_else(|e| {
5512            zerr(&e);
5513            mnumber {
5514                l: 0,
5515                d: 0.0,
5516                type_: MN_INTEGER,
5517            }
5518        });
5519    }
5520    let pm = match v.pm.as_mut() {
5521        Some(p) => p,
5522        None => {
5523            return mnumber {
5524                l: 0,
5525                d: 0.0,
5526                type_: MN_INTEGER,
5527            }
5528        }
5529    };
5530    let t = PM_TYPE(pm.node.flags as u32);
5531    if t == PM_INTEGER {
5532        return mnumber {
5533            l: intgetfn(pm),
5534            d: 0.0,
5535            type_: MN_INTEGER,
5536        };
5537    }
5538    if t == PM_EFLOAT || t == PM_FFLOAT {
5539        return mnumber {
5540            l: 0,
5541            d: floatgetfn(pm),
5542            type_: MN_FLOAT,
5543        };
5544    }
5545    // c:2639 — `return matheval(getstrvalue(v));`. getstrvalue, not the
5546    // raw scalar getter: it selects the array ELEMENT (c:2362) and cuts a
5547    // subscripted scalar (c:2510-2532), so `a=(1+2 x); $(( a[1] ))` is 3
5548    // and `b=12; $(( b[2] ))` is 2. Reading `strgetfn(pm)` gave an
5549    // array's (empty) scalar slot and a scalar's whole value.
5550    let s = getstrvalue(Some(v));
5551    matheval(&s) // c:2639 matheval(...)
5552        .unwrap_or_else(|e| {
5553            zerr(&e);
5554            mnumber {
5555                l: 0,
5556                d: 0.0,
5557                type_: MN_INTEGER,
5558            }
5559        })
5560}
5561
5562/// Port of `export_param()` from `Src/params.c:2653`.
5563///
5564/// C body converts `pm`'s value to its scalar form per `PM_TYPE`:
5565///   PM_INTEGER:        convbase(buf, getfn, pm->base)
5566///   PM_EFLOAT/FFLOAT:  convfloat(getfn, pm->base, pm->node.flags, NULL)
5567///   PM_SCALAR/etc.:    gsu.s->getfn(pm)
5568/// Then calls `addenv(pm, val)`. PM_ARRAY/PM_HASHED early-return.
5569///
5570/// The previous Rust port used `format!("{}", intgetfn(pm))` for
5571/// integers and `format!("{}", floatgetfn(pm))` for floats — Rust's
5572/// DEFAULT formatting. C uses convbase/convfloat which respect
5573/// `pm.base` and `pm.flags`:
5574///   - `typeset -i16 x=255; export x` should put "16#FF" in the
5575///     env (per pm.base==16). The previous Rust port wrote "255".
5576///   - `typeset -F3 y=3.14; export y` should put "3.140" (per
5577///     pm.base==3 precision + PM_FFLOAT flag). Rust wrote "3.14".
5578///
5579/// Both formatter ports exist (`params::convbase`, `utils::convfloat`).
5580/// Wire them so the env-side representation matches C.
5581pub fn export_param(pm: &mut param) {
5582    // c:2653
5583    let t = PM_TYPE(pm.node.flags as u32);
5584    if (t & (PM_ARRAY | PM_HASHED)) != 0 {
5585        // c:2659 array/hash skip
5586        return;
5587    }
5588    let val: String = if t == PM_INTEGER {
5589        // c:2664 — `convbase(buf, pm->gsu.i->getfn(pm), pm->base)`.
5590        let base = if pm.base > 0 { pm.base } else { 10 };
5591        convbase(intgetfn(pm), base as u32) // c:2664
5592    } else if (pm.node.flags as u32 & (PM_EFLOAT | PM_FFLOAT)) != 0 {
5593        // c:2668 — `convfloat(pm->gsu.f->getfn(pm), pm->base,
5594        //                     pm->node.flags, NULL)`.
5595        convfloat(floatgetfn(pm), pm.base, pm.node.flags as u32)
5596        // c:2668
5597    } else if (pm.node.flags as u32 & PM_SPECIAL) != 0 {
5598        // c:2670 — `val = pm->gsu.s->getfn(pm)`, the param's OWN getfn.
5599        //
5600        // A PM_SPECIAL scalar keeps its value in a process global reached
5601        // through that getfn — `term`, `home`, `wordchars`, `ifs`,
5602        // `keyboardhackchar` (`Src/params.c:5185` and neighbours) — and
5603        // NOT in `pm->u.str`, which stays empty for them. Calling
5604        // `strgetfn` (c:4031 `return pm->u.str`) unconditionally exported
5605        // an EMPTY string for every one of them: an assignment to `TERM`
5606        // wrote `TERM=` into the environment, so `clear`, `tput`, `less`
5607        // and `vim` in the next child reported no terminal while `$TERM`
5608        // itself still read `xterm-256color` out of the global.
5609        //
5610        // `lookup_special_var` is the same getfn dispatch `getsparam`
5611        // (c:3163 `getstrvalue`) uses, so the exported text and the text
5612        // the shell reports for the parameter cannot disagree. It answers
5613        // `None` for a PM_UNSET special and for a PM_SPECIAL whose value
5614        // really does live in `u.str` (`PATH`, …) — both fall back to
5615        // `strgetfn`, which is that param's getfn.
5616        lookup_special_var(&pm.node.nam).unwrap_or_else(|| strgetfn(pm))
5617    } else {
5618        strgetfn(pm) // c:2670
5619    };
5620    addenv(&pm.node.nam, &val);
5621    // c:2672 `addenv(pm, val)` → c:5477-5478 —
5622    //     pm->env = newenv;
5623    //     pm->node.flags |= PM_EXPORTED;
5624    // C's `addenv` takes the Param and marks it; this port's `addenv`
5625    // takes `(name, value)` and cannot, so the two lines it owes live at
5626    // its Param-holding caller. Without the flag the only caller that
5627    // reaches here with an UNexported param — `assignstrvalue`'s
5628    // `isset(ALLEXPORT)` tail at c:2836-2841 — put the value in the
5629    // environment and left the parameter reading `scalar`, so
5630    // `setopt allexport; typeset v=1; v=2; print ${(t)v}` answered
5631    // `scalar` where zsh answers `scalar-export`, and `export -p` did not
5632    // list it.
5633    pm.node.flags |= PM_EXPORTED as i32; // c:5478
5634    pm.env = Some(val);
5635}
5636
5637/// Port of `setstrvalue()` from `Src/params.c:2685`. C body is a
5638/// one-liner: `assignstrvalue(v, val, 0);` — the real workhorse
5639/// is `assignstrvalue` (params.c:2692).
5640pub fn setstrvalue(v: Option<&mut value>, val: &str) {
5641    assignstrvalue(v, Some(val.to_string()), 0);
5642}
5643
5644/// 1:1 port of the C body covering: EXECOPT short-circuit,
5645/// PM_READONLY/PM_HASHED/VALFLAG_EMPTY guards, PM_UNSET clear,
5646/// per-PM_TYPE dispatch including the SCALAR/NAMEREF subscript
5647/// splice (KSHARRAYS-aware index normalization, MULTIBYTE end
5648/// adjust, full-string overwrite vs in-place memcpy fast path,
5649/// AUTONAMEDIRS/PM_NAMEDDIR re-registration), PM_INTEGER (with
5650/// ASSPM_ENV_IMPORT → `zstrtol_underscore`, else `mathevali`,
5651/// `lastbase` propagation), PM_EFLOAT/PM_FFLOAT (env vs `matheval`,
5652/// MN_FLOAT/MN_INTEGER coercion), PM_ARRAY (single-element wrap
5653/// via `setarrvalue`), PM_HASHED (`foundparam` indirection); then
5654/// `setscope(pm)`, errflag/env/ALLEXPORT/PM_ARRAY/ename gate, and
5655/// `export_param`. Width tracking for PM_LEFT/PM_RIGHT_B/PM_RIGHT_Z
5656/// preserved.
5657/// Port of `assignstrvalue()` from `Src/params.c:2692`.
5658pub fn assignstrvalue(v: Option<&mut value>, val: Option<String>, flags: i32) {
5659    if unset(EXECOPT) {
5660        return;
5661    }
5662
5663    let v = match v {
5664        Some(v) => v,
5665        None => return,
5666    };
5667    let pm = match v.pm.as_mut() {
5668        Some(p) => p,
5669        None => return,
5670    };
5671
5672    if (pm.node.flags as u32 & PM_READONLY) != 0 {
5673        // c:2701 — `zerr("read-only variable: %s", pm->node.nam)`.
5674        zerr(&format!("read-only variable: {}", pm.node.nam)); // c:2701
5675        return;
5676    }
5677    if (pm.node.flags as u32 & PM_HASHED) != 0
5678        && (v.scanflags as u32 & (SCANPM_MATCHMANY | SCANPM_ARRONLY)) != 0
5679    {
5680        // c:2706 — `zerr("%s: attempt to set slice of associative array", ...)`.
5681        zerr(&format!(
5682            "{}: attempt to set slice of associative array",
5683            pm.node.nam
5684        )); // c:2706
5685        return;
5686    }
5687    if (v.valflags & VALFLAG_EMPTY) != 0 {
5688        // c:2710 — `zerr("%s: assignment to invalid subscript range", ...)`.
5689        zerr(&format!(
5690            "{}: assignment to invalid subscript range",
5691            pm.node.nam
5692        )); // c:2710
5693        return;
5694    }
5695    pm.node.flags &= !(PM_UNSET as i32);
5696
5697    let mut val = val;
5698    match PM_TYPE(pm.node.flags as u32) {
5699        t if t == PM_SCALAR || t == PM_NAMEREF => {
5700            let v_str = val.take().unwrap_or_default();
5701            // c:2748 — no PM_LOWER / PM_UPPER arm exists in setstrvalue.
5702            // The C source folds case in exactly two places, neither of
5703            // them a write path: `getstrvalue`'s VALFLAG_SUBST tail
5704            // (c:2497-2506 — the READ done for a parameter substitution)
5705            // and `copyenvstr` (c:5434-5442 — the env mirror). Storage is
5706            // verbatim, so dropping the attribute exposes the original
5707            // text again (`typeset -l v=ABC; typeset +l v; print $v` → ABC)
5708            // and `typeset -p` — which reads through `printparamvalue`'s
5709            // direct `gsu.s->getfn` (c:6049), not getstrvalue — reports
5710            // `typeset -l v=ABC`. Folding here made all three wrong.
5711            if v.start == 0 && v.end == -1 {
5712                // c:2748 — `v->pm->gsu.s->setfn(v->pm, val);`. C
5713                // dispatches through the param's GSU vtable so
5714                // PM_SPECIAL params route to their canonical setfn
5715                // (homesetfn, ifssetfn, ...). The Rust port stores
5716                // the vtable on `pm.gsu_s` (set in `createparamtable`
5717                // via `gsu_scalar_for_special`); when set, dispatch
5718                // through it. When unset, fall back to the default
5719                // `strsetfn` path (C's stdscalar_gsu.setfn).
5720                // c:2742-2746 — ASSPM_AUGMENT: prepend the existing
5721                // value before storing. Without this, `PATH+=":/foo"`
5722                // would replace PATH instead of appending.
5723                let final_str = if (flags & ASSPM_AUGMENT) != 0 {
5724                    // c:2742-2746 — the old text comes from `gsu.s->getfn`, not
5725                    // from the raw slot: a tied scalar (`typeset -T FOO foo`)
5726                    // keeps its value in the partner array, so `FOO+=:d` read
5727                    // an empty `u_str` and dropped the existing elements.
5728                    let prev = match pm.gsu_s.as_ref().map(|g| g.getfn) {
5729                        Some(getfn) => getfn(pm),
5730                        None => pm.u_str.clone().unwrap_or_default(),
5731                    };
5732                    format!("{}{}", prev, v_str)
5733                } else {
5734                    v_str
5735                };
5736                let len = final_str.len();
5737                let setfn_ptr = pm.gsu_s.as_ref().map(|g| g.setfn);
5738                if let Some(setfn) = setfn_ptr {
5739                    setfn(pm, final_str); // c:2748
5740                } else {
5741                    strsetfn(pm, final_str); // c:2748 (default)
5742                }
5743                if (pm.node.flags as u32 & (PM_LEFT | PM_RIGHT_B | PM_RIGHT_Z)) != 0
5744                    && pm.width == 0
5745                {
5746                    pm.width = len as i32;
5747                }
5748            } else {
5749                // Subscript splice (c:Src/params.c:2748+ assignstrvalue). In C
5750                // `v->start`/`v->end` are BYTE offsets into the metafied string
5751                // (getindex already converted the character subscript to a byte
5752                // offset via MB_METACHARLEN). zshrs's subscript resolution
5753                // instead yields CHARACTER positions, so operate on the char
5754                // sequence throughout — otherwise a multibyte scalar value
5755                // (e.g. a Powerline glyph U+E0B0, 3 bytes) panics when the byte
5756                // slice `z[..start]` lands inside a codepoint. p10k's
5757                // `_p9k_get_icon` hit exactly this (crashing the shell).
5758                let z = strgetfn(pm);
5759                let z_chars: Vec<char> = z.chars().collect();
5760                let zlen = z_chars.len() as i32;
5761                let mut start = v.start;
5762                let mut end = v.end;
5763                if (v.valflags & VALFLAG_INV) != 0 && !isset(KSHARRAYS) {
5764                    start -= 1;
5765                    end -= 1;
5766                }
5767                if start < 0 {
5768                    start += zlen;
5769                    if start < 0 {
5770                        start = 0;
5771                    }
5772                }
5773                if start > zlen {
5774                    start = zlen;
5775                }
5776                if end < 0 {
5777                    end += zlen;
5778                    if end < 0 {
5779                        end = 0;
5780                    } else if end >= zlen {
5781                        end = zlen;
5782                    } else {
5783                        // MULTIBYTE branch: increment by metachar length;
5784                        // single-byte path increments by 1.
5785                        end += 1;
5786                    }
5787                }
5788                // c:2763-2766 (54700) — `if (v->end < v->start) v->end =
5789                // v->start; else if (v->end > oldlen) v->end = oldlen;`: a
5790                // reversed range (`a[6,3]=xyz`) inserts at start and keeps
5791                // the whole tail.
5792                if end < start {
5793                    end = start;
5794                } else if end > zlen {
5795                    end = zlen;
5796                }
5797                // Slice by CHARACTER index into z_chars (never a raw byte
5798                // slice of z, which would split a multibyte codepoint).
5799                let s = (start.max(0) as usize).min(z_chars.len());
5800                let e = (end.max(0) as usize).min(z_chars.len());
5801                let mut x = String::with_capacity(z.len() + v_str.len());
5802                x.extend(z_chars[..s].iter());
5803                x.push_str(&v_str);
5804                if e <= z_chars.len() {
5805                    x.extend(z_chars[e..].iter());
5806                }
5807                // c:3997-4000 — strsetfn does the PM_NAMEDDIR / adduserdir step.
5808                strsetfn(pm, x);
5809            }
5810        }
5811        t if t == PM_INTEGER => {
5812            if let Some(ref s) = val {
5813                let ival: i64 = if (flags & ASSPM_ENV_IMPORT) != 0 {
5814                    s.parse::<i64>().unwrap_or(0)
5815                } else {
5816                    // c:Src/params.c:2774 — `mathevali(val)`. C's
5817                    // matheval calls zerr (Src/math.c:1462+) on parse
5818                    // failures, which sets `errflag |= ERRFLAG_ERROR`
5819                    // and the caller propagates the abort. The Rust
5820                    // port returns Result<i64,String>; `unwrap_or(0)`
5821                    // silently swallowed "operator expected at 'def'"
5822                    // and stored 0 instead. Bug #75 in docs/BUGS.md.
5823                    //
5824                    // Mirror the C semantic: surface the error via
5825                    // zerr (which sets errflag), then fall back to 0
5826                    // for the stored value (C also stores whatever the
5827                    // partial parse computed, which is typically 0).
5828                    match mathevali(s) {
5829                        Ok(v) => v,
5830                        Err(msg) => {
5831                            zerr(&msg);
5832                            0
5833                        }
5834                    }
5835                };
5836                // c:2775-2778 — `if (flags & ASSPM_AUGMENT) pm->u.val += val.l;
5837                //                else pm->u.val = val.l;`. The augment
5838                // path is what makes `integer x=42; x+=8` store 50 instead
5839                // of 8. Without this the integer `+=` operator silently
5840                // replaced.
5841                let final_val = if (flags & ASSPM_AUGMENT) != 0 {
5842                    pm.u_val.wrapping_add(ival)
5843                } else {
5844                    ival
5845                };
5846                intsetfn(pm, final_val);
5847                if (pm.node.flags as u32 & (PM_LEFT | PM_RIGHT_B | PM_RIGHT_Z)) != 0
5848                    && pm.width == 0
5849                {
5850                    pm.width = s.len() as i32;
5851                }
5852                // !!! POSIX-FAITHFUL GATE !!! zsh gives an integer var the
5853                // output base of the literal it was assigned (`(( z=0xff ))`
5854                // → base 16 → `$z` prints `16#FF`). bash/ksh/POSIX have no
5855                // such per-var base — integers always print decimal. Under
5856                // posix_faithful, never attach a base so `echo $z` → `255`.
5857                if pm.base == 0 && !crate::dash_mode::posix_faithful() {
5858                    let lb = lastbase();
5859                    if lb != -1 {
5860                        pm.base = lb;
5861                    }
5862                }
5863            }
5864        }
5865        t if t == PM_EFLOAT || t == PM_FFLOAT => {
5866            if let Some(ref s) = val {
5867                let mn = if (flags & ASSPM_ENV_IMPORT) != 0 {
5868                    mnumber {
5869                        l: 0,
5870                        d: s.parse::<f64>().unwrap_or(0.0),
5871                        type_: MN_FLOAT,
5872                    }
5873                } else {
5874                    // c:Src/params.c — float assignment runs the RHS
5875                    // through matheval; on parse failure, zsh emits the
5876                    // engine's diagnostic ("bad math expression: operator
5877                    // expected at `abc'") via zerr. Mirror the integer
5878                    // arm above which already calls zerr on Err. Bug
5879                    // #506 — zshrs previously swallowed the error and
5880                    // stored 0.0 silently for `float f="3.14abc"`.
5881                    match matheval(s) {
5882                        Ok(v) => v,
5883                        Err(msg) => {
5884                            zerr(&msg);
5885                            mnumber {
5886                                l: 0,
5887                                d: 0.0,
5888                                type_: MN_FLOAT,
5889                            }
5890                        }
5891                    }
5892                };
5893                let d = if (mn.type_ & MN_FLOAT) != 0 {
5894                    mn.d
5895                } else {
5896                    mn.l as f64
5897                };
5898                // c:2775-2778 — ASSPM_AUGMENT path: `float x=1.5; x+=0.25`
5899                // adds 0.25 to the current u_dval rather than replacing.
5900                // Mirrors the integer-augment block above; without it
5901                // `+=` was a plain `=`.
5902                let final_d = if (flags & ASSPM_AUGMENT) != 0 {
5903                    pm.u_dval + d
5904                } else {
5905                    d
5906                };
5907                floatsetfn(pm, final_d);
5908                if (pm.node.flags as u32 & (PM_LEFT | PM_RIGHT_B | PM_RIGHT_Z)) != 0
5909                    && pm.width == 0
5910                {
5911                    pm.width = s.len() as i32;
5912                }
5913            }
5914        }
5915        t if t == PM_ARRAY => {
5916            // c:2826-2828 — `char **ss = zalloc(2*sizeof(char*));
5917            // ss[0]=val; ss[1]=NULL; setarrvalue(v, ss);` — wrap the
5918            // single value in a 1-element array. The C-faithful
5919            // setarrvalue takes &mut Value; we already hold a &mut
5920            // borrow of pm from v.pm.as_mut() higher up, so inline
5921            // the dispatch directly against pm here to avoid the
5922            // double-borrow.
5923            let one = vec![val.take().unwrap_or_default()];
5924            if v.start == 0 && v.end == -1 {
5925                if isset(KSHARRAYS) {
5926                    // c:Src/params.c getvalue — under KSHARRAYS a bare
5927                    // array name addresses element 0 (ksh semantics), so
5928                    // a SCALAR assignment `a=X` to an existing array sets
5929                    // the FIRST element keeping the rest (a=(X second)),
5930                    // not a whole-array replace. The non-KSHARRAYS path
5931                    // already reset the array to a scalar before reaching
5932                    // here (assignsparam c:3179-3192, gated on
5933                    // unset(KSHARRAYS)); only the KSHARRAYS case arrives
5934                    // with the param still PM_ARRAY.
5935                    let arr = pm.u_arr.get_or_insert_with(Vec::new);
5936                    let first = one.into_iter().next().unwrap_or_default();
5937                    if arr.is_empty() {
5938                        arr.push(first);
5939                    } else {
5940                        arr[0] = first;
5941                    }
5942                } else {
5943                    // c:2922 — full replace.
5944                    pm.u_arr = Some(one);
5945                }
5946            } else {
5947                // c:2933+ — slice splice path with bounds adjust.
5948                let arr = pm.u_arr.get_or_insert_with(Vec::new);
5949                let len = arr.len() as i64;
5950                let start_raw = v.start as i64;
5951                let end_raw = v.end as i64;
5952                let start = if start_raw < 0 {
5953                    (len + start_raw + 1).max(0)
5954                } else {
5955                    start_raw
5956                };
5957                let end = if end_raw < 0 {
5958                    (len + end_raw + 1).max(0)
5959                } else {
5960                    end_raw
5961                };
5962                let start_idx = (start.max(1) - 1) as usize;
5963                let end_idx = end.max(0) as usize;
5964                while arr.len() < start_idx {
5965                    arr.push(String::new());
5966                }
5967                let end_idx = end_idx.min(arr.len());
5968                if start_idx <= end_idx {
5969                    arr.splice(start_idx..end_idx, one);
5970                } else {
5971                    for (i, x) in one.into_iter().enumerate() {
5972                        if start_idx + i < arr.len() {
5973                            arr[start_idx + i] = x;
5974                        } else {
5975                            arr.push(x);
5976                        }
5977                    }
5978                }
5979            }
5980        }
5981        t if t == PM_HASHED => {
5982            // c:2831-2843 — the value still refers to the WHOLE hash, so
5983            // the only writable target is the member `scanparamvals`
5984            // stashed in `foundparam` (c:663). With no such member the
5985            // assignment is an attempt to overwrite the association
5986            // itself with a scalar:
5987            //
5988            //     if (foundparam == NULL) {
5989            //         zerr("%s: attempt to set associative array to scalar",
5990            //              v->pm->node.nam);
5991            //         zsfree(val);
5992            //         return;
5993            //     } else
5994            //         foundparam->gsu.s->setfn(foundparam, val);
5995            //
5996            // This arm used to be inert, so a reverse subscript whose
5997            // search found nothing (`h[(r)nomatch]=Z`) silently created a
5998            // literal key instead of reporting the error.
5999            match foundparam() {
6000                None => {
6001                    // c:2835-2836
6002                    zerr(&format!(
6003                        "{}: attempt to set associative array to scalar",
6004                        pm.node.nam
6005                    ));
6006                    return; // c:2838
6007                }
6008                Some(nam) => {
6009                    // c:2841 — `foundparam->gsu.s->setfn(foundparam, val)`.
6010                    // This port keeps assoc members as plain strings in
6011                    // `paramtab_hashed_storage` rather than as Params with
6012                    // their own `strsetfn` (see the same substitution at
6013                    // c:4113 `arrhashsetfn`), so the member write is a
6014                    // store into that map.
6015                    let mut store = paramtab_hashed_storage().lock().unwrap();
6016                    store
6017                        .entry(pm.node.nam.clone())
6018                        .or_default()
6019                        .insert(nam, val.take().unwrap_or_default()); // c:2841
6020                }
6021            }
6022            set_foundparam(None);
6023        }
6024        _ => {}
6025    }
6026    setscope(pm);
6027    if errflag.load(Ordering::Relaxed) != 0
6028        || ((pm.env.is_none()
6029            && (pm.node.flags as u32 & PM_EXPORTED) == 0
6030            && !(isset(ALLEXPORT) && (pm.node.flags as u32 & PM_HASHELEM) == 0))
6031            || (pm.node.flags as u32 & PM_ARRAY) != 0
6032            || pm.ename.is_some())
6033    {
6034        return;
6035    }
6036    export_param(pm);
6037}
6038
6039/// Port of `setnumvalue()` from `Src/params.c:2856`. C body
6040/// dispatches on `PM_TYPE(v->pm->node.flags)`:
6041/// PM_SCALAR/PM_NAMEREF/PM_ARRAY → convbase_underscore /
6042/// convfloat_underscore + setstrvalue; PM_INTEGER →
6043/// `pm->gsu.i->setfn(pm, val.u.l)`; PM_EFLOAT|PM_FFLOAT →
6044/// `pm->gsu.f->setfn(pm, val.u.d)`. EXECOPT/PM_READONLY checks
6045/// at top.
6046pub fn setnumvalue(v: Option<&mut value>, val: mnumber) {
6047    // c:2860 — `if (unset(EXECOPT)) return;`. In NO_EXEC mode, param
6048    // mutations must be skipped so dry-run shell evaluation doesn't
6049    // leak state into the param table. The previous Rust port skipped
6050    // this check; `zsh -n -c '(( x=5 ))'` would mutate $x silently.
6051    if unset(EXECOPT) {
6052        // c:2860
6053        return;
6054    }
6055    let v = match v {
6056        Some(v) => v,
6057        None => return,
6058    };
6059    let pm = match v.pm.as_mut() {
6060        Some(p) => p,
6061        None => return,
6062    };
6063    if (pm.node.flags as u32 & PM_READONLY) != 0 {
6064        zerr(&format!("read-only variable: {}", pm.node.nam)); // c:2862
6065        return;
6066    }
6067    let t = PM_TYPE(pm.node.flags as u32);
6068    if t == PM_SCALAR || t == PM_NAMEREF || t == PM_ARRAY {
6069        // c:2862-2872 — convbase_underscore for integers (honors
6070        // pm.base for the radix prefix + pm.width for underscore
6071        // grouping), convfloat_underscore for floats. The previous
6072        // Rust port computed `val.l.to_string()` then DROPPED the
6073        // result via `let _ = s;` — meaning a numeric assignment
6074        // to a SCALAR param stored NOTHING. `typeset s; (( s = 42 ))`
6075        // would leave $s empty.
6076        let s = if (val.type_ & MN_INTEGER) != 0 {
6077            // c:2862
6078            // c:2864 — `convbase_underscore(val.u.l, pm->base, pm->width)`.
6079            convbase_underscore(val.l, if pm.base > 0 { pm.base } else { 10 }, pm.width)
6080        } else {
6081            // c:2867
6082            // c:2869 — `convfloat_underscore(val.u.d, pm->width)`.
6083            convfloat_underscore(val.d, pm.width)
6084        };
6085        pm.u_str = Some(s); // c:2871 setstrvalue → store
6086    } else if t == PM_INTEGER {
6087        // c:2874 — `pm->gsu.i->setfn(pm, val.u.l)`. For MN_FLOAT
6088        // input, C truncates to integer via `(zlong)val.u.d`.
6089        let n = if (val.type_ & MN_INTEGER) != 0 {
6090            val.l
6091        } else {
6092            crate::zlong_cast::zlong_from_double(val.d)
6093        };
6094        // c:2874 dispatches the param's OWN integer set-function. Most
6095        // integer params carry `varinteger_gsu`, whose setfn is the plain
6096        // store below — but `$LINES` / `$COLUMNS` are declared
6097        // `IPDEF5("COLUMNS", &zterm_columns, zlevar_gsu)`
6098        // (c:Src/params.c:362-363), and `zlevarsetfn` (c:4226) additionally
6099        // publishes the value into the `zterm_lines` / `zterm_columns`
6100        // GLOBAL that every C-side reader consults.
6101        //
6102        // The port inlined the store and never dispatched, which left
6103        // `zlevarsetfn` with zero call sites: an explicit `COLUMNS=61` moved
6104        // the parameter and nothing else. Readers that go through
6105        // `adjustcolumns()` (c:1858, a cached read of `zterm_columns`) kept
6106        // the geometry the last `adjustwinsize` probe cached — so in any
6107        // shell that had probed a tty, `COLUMNS=61` was ignored by the
6108        // completion-list layout while `$COLUMNS` itself read 61.
6109        if matches!(pm.node.nam.as_str(), "LINES" | "COLUMNS") {
6110            zlevarsetfn(pm, n); // c:2874 via zlevar_gsu
6111        } else {
6112            pm.u_val = n; // c:2874 via varinteger_gsu
6113        }
6114    } else if t == PM_EFLOAT || t == PM_FFLOAT {
6115        // c:2878 — `pm->gsu.f->setfn(pm, val.u.d)`. MN_INTEGER input
6116        // gets promoted via `(double)val.u.l`.
6117        pm.u_dval = if (val.type_ & MN_INTEGER) != 0 {
6118            val.l as f64
6119        } else {
6120            val.d
6121        };
6122    }
6123}
6124
6125/// Port of `setarrvalue()` from `Src/params.c:2895`. Sets an array (or assoc-array via
6126/// arrhashsetfn) into the param identified by v.pm, honouring
6127/// PM_READONLY / type-guards / VALFLAG_EMPTY rejections and the
6128/// slice-bounds adjust for `[N,M]` subscripts.
6129///
6130/// C dispatch:
6131///   - !EXECOPT → silent return (c:2897-2898)
6132///   - PM_READONLY → zerr + return (c:2899-2904)
6133///   - !PM_ARRAY && !PM_HASHED → zerr (c:2905-2911)
6134///   - VALFLAG_EMPTY → zerr (c:2913-2917)
6135///   - start==0,end==-1 && PM_HASHED → arrhashsetfn(0) (c:2919-2922)
6136///   - start==0,end==-1 && PM_ARRAY → gsu.a->setfn (c:2922-2923)
6137///   - start==-1,end==0 && PM_HASHED → arrhashsetfn(AUGMENT) (c:2925-2928)
6138///   - PM_HASHED with other bounds → zerr slice-of-assoc (c:2929-2932)
6139///   - PM_ARRAY with slice → bounds adjust + splice (c:2933+)
6140///
6141/// VALFLAG_INV + !KSHARRAYS off-by-one (c:2938-2942) is ported below.
6142/// PM_UNIQUE dedupe (c:2966-2967) is ported at the tail. C's
6143/// setarrvalue has no ASSPM_AUGMENT handling for slices; the only
6144/// AUGMENT arm is the whole-hash `[-1,0]` form (c:2925-2928).
6145pub fn setarrvalue(v: &mut value, val: Vec<String>) {
6146    // c:2895
6147    // c:2897-2898 — `if (unset(EXECOPT)) return;`. Match the same
6148    // NO_EXEC bail as setnumvalue at c:2860. Without it,
6149    // `zsh -n -c 'arr=(a b c)'` would mutate arr during a parse-
6150    // only run.
6151    if unset(EXECOPT) {
6152        // c:2897
6153        return;
6154    }
6155
6156    let pm = match v.pm.as_mut() {
6157        Some(p) => p,
6158        None => return,
6159    };
6160
6161    // c:2899-2904 — PM_READONLY rejection.
6162    if pm.node.flags & PM_READONLY as i32 != 0 {
6163        zerr(&format!("read-only variable: {}", pm.node.nam));
6164        return;
6165    }
6166    // c:2905-2911 — type guard.
6167    let t = PM_TYPE(pm.node.flags as u32);
6168    if t & (PM_ARRAY | PM_HASHED) == 0 {
6169        zerr(&format!(
6170            "{}: attempt to assign array value to non-array",
6171            pm.node.nam
6172        ));
6173        return;
6174    }
6175    // c:2913-2917 — VALFLAG_EMPTY rejection.
6176    if v.valflags & VALFLAG_EMPTY != 0 {
6177        zerr(&format!(
6178            "{}: assignment to invalid subscript range",
6179            pm.node.nam
6180        ));
6181        return;
6182    }
6183
6184    // c:2919-2932 — full-replace / AUGMENT / hash-slice-reject paths.
6185    if v.start == 0 && v.end == -1 {
6186        if t == PM_HASHED {
6187            // c:2920 — arrhashsetfn(pm, val, 0).
6188            arrhashsetfn(pm, val, 0);
6189        } else {
6190            // c:2922 — `pm->gsu.a->setfn(pm, val)`. Route through
6191            // arrsetfn so PM_UNIQUE dedupe + arrfixenv side-effects
6192            // fire (params.c:4066-4076).
6193            arrsetfn(pm, val);
6194        }
6195        return;
6196    }
6197    if v.start == -1 && v.end == 0 && t == PM_HASHED {
6198        arrhashsetfn(pm, val, ASSPM_AUGMENT);
6199        return;
6200    }
6201    if t == PM_HASHED {
6202        zerr(&format!(
6203            "{}: attempt to set slice of associative array",
6204            pm.node.nam
6205        ));
6206        return;
6207    }
6208
6209    // c:2938-2942 — VALFLAG_INV + !KSHARRAYS off-by-one. Inverse
6210    // subscripts (`a[(i)pat]=val`) are 1-based when KSHARRAYS is
6211    // off; shift start/end down by 1 to match the 0-based slice
6212    // arithmetic below.
6213    if v.valflags & VALFLAG_INV != 0 && !isset(KSHARRAYS) {
6214        if v.start > 0 {
6215            v.start -= 1;
6216        }
6217        v.end -= 1;
6218    }
6219
6220    // c:2933+ — PM_ARRAY slice path.
6221    let arr = pm.u_arr.get_or_insert_with(Vec::new);
6222    let len = arr.len() as i64;
6223    // c:2944-2949 — negative start: add pre_assignment_length; clamp to 0.
6224    // !!! WARNING: RUST-ONLY INDEX CONVENTION — C's getindex has already made a
6225    // positive start 0-based (`start -= startprevlen`, c:2144-2145), but this
6226    // port keeps it 1-based (`start_idx = start - 1` below; the fusevm splice
6227    // passes 1-based starts and assignaparam lifts getindex's). So a
6228    // negative start, which C turns into a 0-based index here, becomes the
6229    // matching 1-based one: `+ 1`, clamped at 1 instead of 0. Using C's 0-based
6230    // result made `typeset a[-1]=(z)` overwrite the last TWO elements.
6231    let start = if v.start < 0 {
6232        (len + v.start as i64 + 1).max(1)
6233    } else {
6234        v.start as i64
6235    };
6236    // c:2950-2953 — negative end: add pre_assignment_length + 1; clamp to 0.
6237    let end = if v.end < 0 {
6238        (len + v.end as i64 + 1).max(0)
6239    } else {
6240        v.end as i64
6241    };
6242    let start_idx = (start.max(1) - 1) as usize;
6243    let end_idx = end.max(0) as usize;
6244
6245    // c:2980 — pad with empty strings up to start.
6246    while arr.len() < start_idx {
6247        arr.push(String::new());
6248    }
6249
6250    // c:2940-2943 — `if (v->end < v->start || v->start > oldlen)
6251    //                    v->end = v->start; else if (v->end > oldlen)
6252    //                    v->end = oldlen;`. A REVERSED range (start > end,
6253    // e.g. `a[4,2]=(42 43 44)`) collapses to an EMPTY range at start, so
6254    // the splice INSERTS the value there keeping every element
6255    // ("1 2 3 42 43 44 4 5") — it does NOT overwrite from start. The
6256    // previous Rust port took a custom `start_idx > end_idx` else-branch
6257    // that overwrote/extended from start, dropping the tail. `clamp`
6258    // implements both C arms: lower-bound start_idx (reversed → empty),
6259    // upper-bound arr.len() (end past tail → clamp). After the pad above
6260    // arr.len() >= start_idx, so the clamp range is always valid.
6261    let end_idx = end_idx.clamp(start_idx, arr.len());
6262    let val_len = val.len(); // c:3030 post-assign sanity
6263    let pre_len = arr.len(); // c:3030 (snapshot)
6264                             // c:2989-2998 — splice val into [start..end] range.
6265    arr.splice(start_idx..end_idx, val);
6266    // c:3030 — DPUTS2(p - new != post_assignment_length, …). The
6267    // post-splice arr.len() is C's `p - new`; expected follows C's
6268    // post_assignment_length = start + vallen + MAX(0, oldlen - end).
6269    let expected = pre_len - (end_idx - start_idx) + val_len; // c:3030
6270    DPUTS2!(
6271        // c:3030
6272        arr.len() != expected, // c:3030
6273        "setarrvalue: wrong allocation: {} 1= {}",
6274        expected,
6275        arr.len() // c:3030-3031
6276    );
6277
6278    // c:2966-2967 — `if (pm->node.flags & PM_UNIQUE) arrunique(pm->u.arr);`
6279    // Dedupe in-place preserving first occurrence. Without this,
6280    // `typeset -U arr; arr[2]=foo` leaves duplicates that violate the
6281    // PM_UNIQUE invariant.
6282    if (pm.node.flags as u32 & PM_UNIQUE) != 0 {
6283        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
6284        arr.retain(|s| seen.insert(s.clone())); // c:2967
6285    }
6286    // c:Src/params.c:2922 — a SUBSCRIPT-RANGE assignment (`path[i,j]=(…)`,
6287    // `path[N]=(…)`, `path[i,j]=()`) reaches here (the whole-replace form
6288    // returned early via arrsetfn above). For a tied colon-array the scalar
6289    // side must re-derive, just like the single-element path in
6290    // assignsparam; see TIED_COLON_ARRAYS. Snapshot after the splice.
6291    let tie_sync: Option<(&'static str, String)> = TIED_COLON_ARRAYS
6292        .iter()
6293        .find(|(a, _)| *a == pm.node.nam)
6294        .map(|(_, scalar)| (*scalar, pm.u_arr.as_deref().unwrap_or(&[]).join(":")));
6295    if let Some((scalar, joined)) = tie_sync {
6296        assignsparam(scalar, &joined, 0);
6297    }
6298}
6299
6300/// Retrieve integer parameter.
6301/// Port of `getiparam()` from `Src/params.c:3044`. C: getvalue +
6302/// getintvalue. Our adaptation reads the scalar string and parses;
6303/// returns 0 on missing or unparseable, matching getintvalue's
6304/// failure-returns-0 convention (params.c:2601).
6305pub fn getiparam(s: &str) -> i64 {
6306    // c:Src/params.c:348 + c:4202 — `IPDEF4("HISTCMD", &curhist)` binds
6307    // `intvargetfn` (`return *pm->u.valptr;`) to `&curhist`
6308    // (`Src/hist.c:88`), so the value is the LIVE history counter, never
6309    // stored on the node. zshrs's `createparamtable` inserts HISTCMD as a
6310    // plain PM_INTEGER whose `u_val` nothing writes, so this must precede
6311    // the `u_val` fast path below — otherwise every read reports the 0 the
6312    // node was created with.
6313    if s == "HISTCMD" {
6314        return crate::ported::hist::curhist.load(Ordering::SeqCst); // c:348 + c:4202
6315    }
6316    // c:362-363 + c:4202 — `$COLUMNS`/`$LINES` are the other valptr-bound
6317    // specials, and for the same reason they have to be resolved through
6318    // `intvargetfn` before the `u_val` fast path below. `unset COLUMNS`
6319    // leaves the node in the table flagged PM_UNSET (c:3877, PM_SPECIAL
6320    // without PM_REMOVABLE), and an unset parameter reads empty rather than
6321    // as the global, so the flag is consulted first.
6322    if s == "COLUMNS" || s == "LINES" {
6323        if let Ok(tab) = paramtab().read() {
6324            if let Some(pm) = tab.get(s) {
6325                if (pm.node.flags as u32 & PM_UNSET) == 0 && !is_unset_special(s) {
6326                    return intvargetfn(pm); // c:4202
6327                }
6328            }
6329        }
6330    }
6331    // C also honours PM_INTEGER's `pm->u.val` payload directly when
6332    // the param is typed numeric; check paramtab first for that case.
6333    if let Ok(tab) = paramtab().read() {
6334        if let Some(pm) = tab.get(s) {
6335            if (pm.node.flags as u32 & PM_INTEGER) != 0 {
6336                return pm.u_val;
6337            }
6338        }
6339    }
6340    getsparam(s)
6341        .and_then(|s| s.parse::<i64>().ok())
6342        .unwrap_or(0)
6343}
6344
6345/// Retrieve numeric (int-or-float) parameter.
6346/// Port of `getnparam()` from `Src/params.c:3058`. C returns an
6347/// `mnumber` (tagged int/float union); our adaptation returns
6348/// `(i64, f64, bool)` where the bool is true for float. Unset
6349/// returns `(0, 0.0, false)`, matching the MN_INTEGER zero
6350/// fallback in the C source's not-found branch.
6351pub fn getnparam(s: &str) -> (i64, f64, bool) {
6352    // c:Src/params.c:348 + c:4202 — same valptr-bound special as in
6353    // `getiparam` above: IPDEF4 binds `intvargetfn` to `&curhist`
6354    // (`Src/hist.c:88`), so read the live counter rather than the
6355    // never-written `u_val` on the paramtab node.
6356    if s == "HISTCMD" {
6357        let v = crate::ported::hist::curhist.load(Ordering::SeqCst); // c:348 + c:4202
6358        return (v, v as f64, false);
6359    }
6360    // c:362-363 + c:4202 — the same valptr-bound pair, resolved through
6361    // `intvargetfn`; see `getiparam` for the PM_UNSET gate.
6362    if s == "COLUMNS" || s == "LINES" {
6363        if let Ok(tab) = paramtab().read() {
6364            if let Some(pm) = tab.get(s) {
6365                if (pm.node.flags as u32 & PM_UNSET) == 0 && !is_unset_special(s) {
6366                    let v = intvargetfn(pm); // c:4202
6367                    return (v, v as f64, false);
6368                }
6369            }
6370        }
6371    }
6372    if let Ok(tab) = paramtab().read() {
6373        if let Some(pm) = tab.get(s) {
6374            let fl = pm.node.flags as u32;
6375            if (fl & (PM_EFLOAT | PM_FFLOAT)) != 0 {
6376                return (pm.u_dval as i64, pm.u_dval, true);
6377            }
6378            if (fl & PM_INTEGER) != 0 {
6379                return (pm.u_val, pm.u_val as f64, false);
6380            }
6381        }
6382    }
6383    let s = match getsparam(s) {
6384        Some(s) => s,
6385        None => return (0, 0.0, false),
6386    };
6387    if s.contains('.') || s.contains('e') || s.contains('E') {
6388        if let Ok(f) = s.parse::<f64>() {
6389            return (f as i64, f, true);
6390        }
6391    }
6392    if let Ok(i) = s.parse::<i64>() {
6393        return (i, i as f64, false);
6394    }
6395    (0, 0.0, false)
6396}
6397
6398/// Port of `getsparam()` from `Src/params.c:3076`.
6399///
6400/// C body:
6401/// ```c
6402/// char *getsparam(char *s) {
6403///     struct value vbuf;
6404///     Value v = getvalue(&vbuf, &s, 0);
6405///     if (!v) return NULL;
6406///     return getstrvalue(v);
6407/// }
6408/// ```
6409///
6410/// `getvalue` (params.c:2173) builds a `Value` for the parameter,
6411/// dispatching through `Param.gsu->getfn` for special parameters.
6412/// `getstrvalue` (params.c:2335) extracts the scalar form: for
6413/// PM_INTEGER calls `pm->gsu.i->getfn(pm)` and convbase's the
6414/// result; for PM_SCALAR calls `pm->gsu.s->getfn(pm)`; for
6415/// PM_ARRAY joins the elements.
6416///
6417/// **Sole funnel.** Every scalar parameter read in zshrs routes
6418/// through this fn — `subst.rs` parameter expansion AND
6419/// `fusevm_bridge::expand_param` both call `getsparam`. The
6420/// dispatch chain lives in exactly one place, mirroring C's
6421/// "every read goes through getsparam" architecture.
6422///
6423/// Lookup order (mirrors C's `getvalue` → `getstrvalue` cascade):
6424/// 1. **GSU dispatch** via [`lookup_special_var`] — special
6425///    parameters route through their getfn callback (`uidgetfn` /
6426///    `randomgetfn` / `usernamegetfn` / etc.). Same role as
6427///    C's `Param.gsu->getfn` virtual dispatch.
6428/// 2. **Local variable** — `variables[name]`. C reads `pm->u.str`
6429///    for PM_SCALAR; here we hold the scalar in the variables
6430///    HashMap.
6431/// 3. **Environment fallback** — `std::env::var(name)`. C imports
6432///    env vars into the param table at startup so they go through
6433///    the same dispatch as everything else; zshrs reads from the
6434///    OS env on miss to match.
6435/// 4. **Array → scalar** — `arrays[name].join(" ")`. Mirrors
6436///    C's PM_ARRAY case in getstrvalue (params.c:2358) which
6437///    joins via `sepjoin(ss, NULL, 1)`.
6438///
6439// Retrieve a scalar (string) parameter                                     // c:3076
6440/// Returns `None` only if all four paths miss (parameter genuinely
6441/// unset).
6442pub fn getsparam(name: &str) -> Option<String> {
6443    // c:3076
6444    // 1. GSU dispatch — `Param.gsu->getfn(pm)` equivalent. Special
6445    //    parameters (UID/RANDOM/USERNAME/...) live behind getfn
6446    //    hooks that the table read below would otherwise miss.
6447    if let Some(v) = lookup_special_var(name) {
6448        return Some(v);
6449    }
6450    // 1b. c:Src/params.c:570-575 — getparamnode resolves PM_NAMEREF
6451    //     chains before the value read (`pm = resolve_nameref(pm)`).
6452    //     Redirect the lookup to the resolved target.
6453    // c:570-575 getparamnode → resolve_nameref: scalar-read
6454    // redirection through a nameref chain, INLINE (former
6455    // nameref_read_redirect, single-caller fold 2026-06-12).
6456    if let Some(res) = (|name: &str| -> Option<Option<String>> {
6457        if !is_nameref(name) {
6458            return None;
6459        }
6460        match resolve_nameref_name(name, None) {
6461            nameref_resolution::NotRef => None,
6462            nameref_resolution::Placeholder(..)
6463            | nameref_resolution::SelfRef
6464            | nameref_resolution::OutOfScope => Some(None),
6465            nameref_resolution::Target {
6466                name: t,
6467                subscript,
6468                pm,
6469                level,
6470            } => {
6471                let pm = match pm {
6472                    Some(p) => p,
6473                    None => return Some(None), // dangling target
6474                };
6475                // c:2261-2263 — fetchvalue drops an unset referent unless it
6476                // is DECLARED; a declared one still has its element read.
6477                let f = pm.node.flags as u32;
6478                if (f & PM_UNSET) != 0 && (subscript.is_none() || (f & PM_DECLARED) == 0) {
6479                    return Some(None);
6480                }
6481                match subscript {
6482                    None => {
6483                        // Visible binding → recurse through the normal
6484                        // read path (padding, gsu, specials all apply).
6485                        let visible_level = paramtab()
6486                            .read()
6487                            .ok()
6488                            .and_then(|tab| tab.get(&t).map(|p| p.level));
6489                        if visible_level == Some(level) {
6490                            Some(getsparam(&t))
6491                        } else {
6492                            // Hidden (old-chain) binding — read the clone.
6493                            Some(param_scalar_value(&pm))
6494                        }
6495                    }
6496                    Some(k) => Some(nameref_element_read(&pm, &t, &k)),
6497                }
6498            }
6499        }
6500    })(name)
6501    {
6502        return res;
6503    }
6504    // 2. Paramtab read — `(Value)gethashnode2(paramtab, name)`.
6505    //    Walk the global paramtab for the named param, returning
6506    //    `pm->u.str` for PM_SCALAR/PM_NAMEREF or `sepjoin(pm->u.arr)`
6507    //    for PM_ARRAY (matches `getstrvalue` at params.c:2358).
6508    // c:2352 — set when the node is PM_ARRAY but its elements live behind a
6509    // MODULE getfn instead of `u.arr`; the dispatch runs after the read guard
6510    // below is dropped (see the arm that sets it).
6511    // c:Src/params.c:570-572 — getvalue reaches the node through
6512    // getparamnode, whose loadparamnode loads the owning module of a
6513    // PM_AUTOLOAD stub (`zmodload -ap MOD NAME`) before the value is read;
6514    // when the module does not define NAME the stub is gone and the read
6515    // fails. Taken before the read guard below: the load re-enters paramtab.
6516    let autoload_stub = paramtab()
6517        .read()
6518        .ok()
6519        .and_then(|t| t.get(name).filter(|p| (p.node.flags as u32 & PM_AUTOLOAD) != 0).cloned());
6520    if let Some(stub) = autoload_stub {
6521        // loadparamnode does not read its table argument (one paramtab).
6522        let loaded = newparamtable(0, "paramtab").and_then(|ht| loadparamnode(&ht, Some(stub), name));
6523        if loaded.is_none() {
6524            return None;
6525        }
6526    }
6527    let mut needs_partab_dispatch = false;
6528    if let Ok(tab) = paramtab().read() {
6529        if let Some(pm) = tab.get(name) {
6530            // c:Src/Modules/param_private.c:568-617 + c:678 — C swaps
6531            // `realparamtab->getnode` to getprivatenode when
6532            // zsh/param/private is active, so every lookup skips
6533            // private params belonging to an OUTER scope when read
6534            // from a DEEPER one (`private x` in f is invisible to g
6535            // called by f; g sees the global). zshrs's HashMap
6536            // paramtab has no getnode vtable, so call the canonical
6537            // walk at the lookup site. SAFETY: getprivatenode only
6538            // follows the `pm->old` chain read-only; the chain is
6539            // owned by this entry, which stays alive under the read
6540            // guard held for the rest of this block.
6541            let visible =
6542                crate::ported::modules::param_private::getprivatenode(&**pm as *const param); // c:678 getnode hook
6543            if visible.is_null() {
6544                return None; // c:609 walk exhausted — no visible node
6545            }
6546            let pm: &param = unsafe { &*visible };
6547            // c:Src/params.c:2335-2358 — `if (pm->node.flags & PM_UNSET)
6548            // return ""`. Unset specials kept in paramtab (PM_SPECIAL
6549            // retention path, c:3911) read as empty, not as their stale
6550            // u.val / u.str. Without this, `unset SECONDS; echo
6551            // "[$SECONDS]"` printed `[0]` instead of `[]` — the
6552            // PM_UNSET pm fell through to the integer path and
6553            // returned convbase(0). Bug #418 in docs/BUGS.md.
6554            if (pm.node.flags as u32 & PM_UNSET) != 0 {
6555                return None;
6556            }
6557            let t = PM_TYPE(pm.node.flags as u32);
6558            // c:2390-2538 — when PM_LEFT/PM_RIGHT_B/PM_RIGHT_Z + width
6559            // are set, getstrvalue (called from getsparam in C) applies
6560            // padding. Mirror that here so `$var` expansion respects
6561            // `typeset -Z N` / `-L N` / `-R N` even when the caller
6562            // didn't go through getstrvalue. Numeric prefix detection
6563            // (leading blanks/minus/0x/base#) follows params.rs:3156.
6564            let pad_flags = (pm.node.flags as u32) & (PM_LEFT | PM_RIGHT_B | PM_RIGHT_Z);
6565            // c:Src/params.c:2358 — getstrvalue for a PM_SCALAR
6566            // dispatches through `pm->gsu.s->getfn(pm)` so tied /
6567            // colon-array / special-callback scalars (PATH/path,
6568            // user `typeset -T` pairs) return their live computed
6569            // value rather than a stale cached `u_str`. Reads of
6570            // `u_str` for scalars without a GSU vtable (the common
6571            // case) fall through unchanged. Bug #24 in docs/BUGS.md.
6572            let raw: Option<String> = if t == PM_INTEGER {
6573                let base = if pm.base > 0 { pm.base } else { 10 };
6574                // c:2364 `convbase(buf, v->pm->gsu.i->getfn(v->pm), …)` — a
6575                // module integer (`$SRANDOM`) answers through its getfn.
6576                let val = pm.gsu_i.as_ref().map_or(pm.u_val, |g| (g.getfn)(pm));
6577                Some(convbase(val, base as u32))
6578            } else if t == PM_EFLOAT || t == PM_FFLOAT {
6579                Some(convfloat(pm.u_dval, pm.base, pm.node.flags as u32))
6580            } else if t == PM_SCALAR && pm.gsu_s.is_some() {
6581                pm.gsu_s.as_ref().map(|gsu| (gsu.getfn)(pm))
6582            } else if let Some(s) = pm.u_str.as_ref() {
6583                // c:2350-2357 — getstrvalue switches on PM_TYPE FIRST: a
6584                // PM_ARRAY node's scalar value is
6585                // `sepjoin(getvaluearr(v), NULL, 1)`, never a scalar field.
6586                // `getvaluearr` (c:724-732) reads the elements through
6587                // `v->pm->gsu.a->getfn(v->pm)` — `arrgetfn` (c:4054 `return
6588                // pm->u.arr`) for an ordinary array, but a MODULE getfn
6589                // (`reswordsgetfn`, Src/Modules/parameter.c:878) for the
6590                // zsh/parameter magic arrays, which carry no `u.arr` at all.
6591                // That is the same split `getaparam` spells out below.
6592                // Reading `u_str` for those returned the seeded PM_SPECIAL
6593                // stub's EMPTY string, so every colon modifier — whose test
6594                // is `vunset = isarr ? !*aval : !*val` (c:Src/subst.c:3188) —
6595                // saw a null value: `${reswords:-D}` / `${keymaps:+S}` /
6596                // `${(P)r:-D}` took the unset branch where zsh takes the set
6597                // one. Gated on "the stub is empty and this name really is a
6598                // PARTAB_ARRAY row" so no node that already answers keeps its
6599                // answer.
6600                if s.is_empty() && t == PM_ARRAY && pm.u_arr.is_none() {
6601                    needs_partab_dispatch = crate::ported::modules::parameter::PARTAB_ARRAY
6602                        .iter()
6603                        .any(|e| e.name == name); // c:2352
6604                }
6605                if needs_partab_dispatch {
6606                    None
6607                } else {
6608                    Some(s.clone())
6609                }
6610            } else {
6611                match pm.u_arr.as_ref() {
6612                    // c:2354 — stdarray gsu → arrgetfn (c:4054).
6613                    // c:2353 — `s = sepjoin(ss, NULL, 1)`: the scalar view of
6614                    // an array joins with `$IFS[1]`, not with a hardcoded
6615                    // space (sepjoin's NULL sep, c:Src/utils.c:3928-3945).
6616                    // This value is what every DQ pattern operator matches
6617                    // against, so `IFS=-; a=(a b); print -r -- "${a#a}"` was
6618                    // stripping from "a b" and printing " b" where zsh joins
6619                    // to "a-b" first and prints "-b".
6620                    Some(arr) => Some(crate::ported::utils::sepjoin(arr, None)),
6621                    None => {
6622                        // Same module-getfn dispatch as above for a stub that
6623                        // carries neither string nor array storage.
6624                        if t == PM_ARRAY {
6625                            needs_partab_dispatch = crate::ported::modules::parameter::PARTAB_ARRAY
6626                                .iter()
6627                                .any(|e| e.name == name); // c:2352
6628                        }
6629                        None
6630                    }
6631                }
6632            };
6633            if let Some(mut s) = raw {
6634                if pad_flags != 0 && pm.width > 0 {
6635                    let fwidth = pm.width as usize;
6636                    let numeric_pm =
6637                        (pm.node.flags as u32 & (PM_INTEGER | PM_EFLOAT | PM_FFLOAT)) != 0;
6638                    if pad_flags == PM_LEFT || pad_flags == (PM_LEFT | PM_RIGHT_Z) {
6639                        let trimmed: &str = if pad_flags & PM_RIGHT_Z != 0 {
6640                            s.trim_start_matches('0')
6641                        } else {
6642                            s.trim_start_matches(|c: char| c == ' ' || c == '\t')
6643                        };
6644                        let len = trimmed.chars().count();
6645                        let take = len.min(fwidth);
6646                        let mut out: String = trimmed.chars().take(take).collect();
6647                        if fwidth > take {
6648                            out.extend(std::iter::repeat(' ').take(fwidth - take));
6649                        }
6650                        s = out;
6651                    } else if pad_flags & (PM_RIGHT_B | PM_RIGHT_Z) != 0 {
6652                        let charlen = s.chars().count();
6653                        if charlen < fwidth {
6654                            let mut zero = true;
6655                            let mut valprefend: usize = 0;
6656                            if pad_flags & PM_RIGHT_Z != 0 {
6657                                let bytes = s.as_bytes();
6658                                let mut tpos = 0usize;
6659                                while tpos < bytes.len()
6660                                    && (bytes[tpos] == b' ' || bytes[tpos] == b'\t')
6661                                {
6662                                    tpos += 1;
6663                                }
6664                                if numeric_pm && tpos < bytes.len() && bytes[tpos] == b'-' {
6665                                    tpos += 1;
6666                                }
6667                                if (pm.node.flags as u32 & PM_INTEGER) != 0
6668                                    && tpos + 1 < bytes.len()
6669                                    && bytes[tpos] == b'0'
6670                                    && bytes[tpos + 1] == b'x'
6671                                {
6672                                    tpos += 2;
6673                                } else if (pm.node.flags as u32 & PM_INTEGER) != 0 {
6674                                    if let Some(hash_off) =
6675                                        bytes[tpos..].iter().position(|&b| b == b'#')
6676                                    {
6677                                        tpos += hash_off + 1;
6678                                    }
6679                                }
6680                                valprefend = tpos;
6681                                if tpos == bytes.len() {
6682                                    zero = false;
6683                                } else if !numeric_pm && !bytes[tpos].is_ascii_digit() {
6684                                    zero = false;
6685                                }
6686                            }
6687                            let pad_char = if (pad_flags & PM_RIGHT_B) != 0 || !zero {
6688                                ' '
6689                            } else {
6690                                '0'
6691                            };
6692                            let pad_count = fwidth - charlen;
6693                            let prefix = &s[..valprefend];
6694                            let rest = &s[valprefend..];
6695                            let mut out = String::with_capacity(fwidth);
6696                            out.push_str(prefix);
6697                            out.extend(std::iter::repeat(pad_char).take(pad_count));
6698                            out.push_str(rest);
6699                            s = out;
6700                        } else if charlen > fwidth {
6701                            // c:Src/params.c:2496-2500 — right-justify
6702                            // with charlen > fwidth: truncate from the
6703                            // FRONT to fit fwidth codepoints. Bug #100
6704                            // in docs/BUGS.md — the getsparam path had
6705                            // the pad arm but missed the truncation
6706                            // arm. The parallel getstrvalue path at
6707                            // ~line 3355 has it; this is the
6708                            // missing-symmetry fix.
6709                            let skip = charlen - fwidth;
6710                            s = s.chars().skip(skip).collect();
6711                        }
6712                    }
6713                }
6714                // c:2497-2506 — the tail of getstrvalue's VALFLAG_SUBST
6715                // block, immediately after the padding switch:
6716                //     switch (v->pm->node.flags & (PM_LOWER | PM_UPPER)) {
6717                //     case PM_LOWER: s = casemodify(s, CASMOD_LOWER); break;
6718                //     case PM_UPPER:
6719                //         if (!(v->pm->node.flags & PM_NAMEREF))
6720                //             s = casemodify(s, CASMOD_UPPER);
6721                //         break;
6722                //     }
6723                // `switch` on the masked pair means BOTH bits set folds
6724                // neither way — bin_typeset clears the opposite bit
6725                // (c:Src/builtin.c:2731-2734) so that state is unreachable
6726                // from `typeset`, but a raw flag write could produce it.
6727                // PM_UPPER is skipped for a nameref because the stored
6728                // string is the *target's name*, not a value.
6729                match pm.node.flags as u32 & (PM_LOWER | PM_UPPER) {
6730                    f if f == PM_LOWER => {
6731                        s = casemodify(&s, CASMOD_LOWER); // c:2499
6732                    }
6733                    f if f == PM_UPPER => {
6734                        if (pm.node.flags as u32 & PM_NAMEREF) == 0 {
6735                            // c:2502
6736                            s = casemodify(&s, CASMOD_UPPER); // c:2503
6737                        }
6738                    }
6739                    _ => {}
6740                }
6741                return Some(s);
6742            }
6743        }
6744    }
6745    if needs_partab_dispatch {
6746        // c:2352-2354 — `ss = getvaluearr(v); s = sepjoin(ss, NULL, 1);` over
6747        // the module getfn's array. Dispatched here, outside the paramtab read
6748        // guard, because `partab_array_get` re-locks `paramtab` for its shadow
6749        // check — the same deferral `getaparam` documents below.
6750        return crate::vm_helper::partab_array_get(name).map(|a| a.join(" ")); // c:2354
6751    }
6752    // 3. Env fallback — C imports env into paramtab at init so the
6753    //    read above would hit. If the import hasn't happened yet
6754    //    (e.g. during very early init) fall back to the live env.
6755    env::var(name).ok()
6756}
6757
6758/// Port of `getsparam_u()` from `Src/params.c:3088`. C body
6759/// (c:3091-3094):
6760/// ```c
6761/// /* getsparam() returns pointer into global params table, so ... */
6762/// if ((s = getsparam(s)))
6763///     return unmeta(s);    /* returns static pointer to copy */
6764/// return s;
6765/// ```
6766///
6767/// The previous Rust "port" was an entirely fabricated impl — it
6768/// took `Option<&mut value>` and gated on `PM_TYPE == PM_SCALAR`,
6769/// which matches no part of the C body. C just calls getsparam(s)
6770/// and unmeta's the resulting string. No callers existed because
6771/// no caller's type fit the bogus signature.
6772///
6773/// Real use case: locale setters (c:4847, c:4867, c:4882, c:4917)
6774/// call `getsparam_u("LC_ALL")` / `getsparam_u("LANG")` to read the
6775/// param as a Meta-stripped C string suitable for `setlocale`.
6776pub fn getsparam_u(s: &str) -> Option<String> {
6777    // c:3089
6778    // c:3092 — `if ((s = getsparam(s))) return unmeta(s);`
6779    getsparam(s).map(|v| unmeta(&v))
6780}
6781
6782/// Port of `getaparam()` from `Src/params.c:3100`.
6783///
6784/// C body:
6785/// ```c
6786/// struct value vbuf;
6787/// Value v;
6788/// if (!idigit(*s) && (v = getvalue(&vbuf, &s, 0)) &&
6789///     PM_TYPE(v->pm->node.flags) == PM_ARRAY)
6790///     return v->pm->gsu.a->getfn(v->pm);
6791/// return NULL;
6792/// ```
6793///
6794/// The previous Rust port was a fabrication: signature was
6795/// `Option<&mut value> -> Option<Vec<String>>`, taking an already-
6796/// resolved Value pointer rather than the C-canonical name string.
6797/// No caller used it because the bogus signature fit nothing — and
6798/// the in-tree `savematch` at modules/zutil.rs:30 hardcoded `a = None`
6799/// because the existing API couldn't be threaded through.
6800///
6801/// Real C use: name lookup. e.g. `getaparam("match")` returns the
6802/// `$match` array from the regex-match callouts (Modules/zutil.c:45).
6803pub fn getaparam(name: &str) -> Option<Vec<String>> {
6804    // c:3101
6805    // c:3107 — `if (idigit(*s))` reject digit-first names. C
6806    // `getvalue` would also reject these later, but the explicit
6807    // check matches C's flow.
6808    if name.starts_with(|c: char| c.is_ascii_digit()) {
6809        // c:3107
6810        return None;
6811    }
6812    // c:Src/params.c:570-575 — getvalue→fetchvalue→getparamnode
6813    // resolves PM_NAMEREF before the type check.
6814    // c:570-575 getparamnode → resolve_nameref: array-read
6815    // redirection through a nameref chain, INLINE (former
6816    // nameref_aread_redirect, single-caller fold 2026-06-12).
6817    if let Some(res) = (|name: &str| -> Option<Option<Vec<String>>> {
6818        if !is_nameref(name) {
6819            return None;
6820        }
6821        match resolve_nameref_name(name, None) {
6822            nameref_resolution::NotRef => None,
6823            nameref_resolution::Placeholder(..)
6824            | nameref_resolution::SelfRef
6825            | nameref_resolution::OutOfScope => Some(None),
6826            nameref_resolution::Target {
6827                name: t,
6828                subscript,
6829                pm,
6830                level,
6831            } => {
6832                let pm = match pm {
6833                    Some(p) => p,
6834                    None => return Some(None),
6835                };
6836                if (pm.node.flags as u32 & PM_UNSET) != 0 {
6837                    return Some(None);
6838                }
6839                if subscript.is_some() {
6840                    // c:3107-3110 — getvalue with bracks 0: fetchvalue's
6841                    // reference slice leaves `[sub]` unparsed (c:2264-2267),
6842                    // so c:2297 `if (!bracks && *s) return NULL` and the
6843                    // read is not an array one.
6844                    return Some(None);
6845                }
6846                if PM_TYPE(pm.node.flags as u32) != PM_ARRAY {
6847                    return Some(None);
6848                }
6849                let visible_level = paramtab()
6850                    .read()
6851                    .ok()
6852                    .and_then(|tab| tab.get(&t).map(|p| p.level));
6853                if visible_level == Some(level) {
6854                    Some(getaparam(&t))
6855                } else {
6856                    Some(pm.u_arr.clone())
6857                }
6858            }
6859        }
6860    })(name)
6861    {
6862        return res;
6863    }
6864    // c:3107-3109 — `getvalue(&vbuf, &s, 0)` resolves the name to a
6865    // paramtab entry. Then PM_TYPE check + `pm->u.arr` return.
6866    //
6867    // c:3107 is `v->pm->gsu.a->getfn(v->pm)` — a VTABLE dispatch, not a
6868    // raw `u.arr` read. For an ordinary array `gsu.a` is `stdarray_gsu`
6869    // whose getfn is `arrgetfn` (c:4054 `return pm->u.arr`), which is
6870    // what the `u_arr` branch below models. But the zsh/parameter magic
6871    // arrays (`reswords`, `dis_reswords`, `patchars`, `dis_patchars`,
6872    // `funcstack`, `dirstack`, `historywords`, …) carry a MODULE getfn
6873    // instead (`reswordsgetfn` at Src/Modules/parameter.c:878) and have
6874    // no `u.arr` at all — their value is computed on demand. zshrs keeps
6875    // those rows in the separate `PARTAB_ARRAY` table
6876    // (modules/parameter.rs:4644) rather than in a gsu slot on the pm,
6877    // so the dispatch has to be spelled out. `partab_array_get`
6878    // (vm_helper.rs:4998) is the single existing entry point for it —
6879    // it applies the same `magic_special_shadowed` filter + module gate
6880    // the parameter-expansion path uses (subst.rs:20646, :20749).
6881    //
6882    // Without this, `getaparam("reswords")` returned None because the
6883    // seeded PM_SPECIAL stub has `u_arr: None` (vm_helper.rs:5139), so
6884    // `compadd -k reswords` produced ZERO matches and `which <TAB>` lost
6885    // the entire reserved-words group.
6886    let mut needs_partab_dispatch = false;
6887    // c:4010-4013 `arrgetfn` — a PM_ARRAY node whose value slot is NULL is
6888    // served as the EMPTY array (`&nullarray`, c:4006), not as "no array".
6889    // Tracked so the PARTAB dispatch below still gets its chance first.
6890    let mut declared_empty_array = false;
6891    if let Ok(tab) = paramtab().read() {
6892        if let Some(pm) = tab.get(name) {
6893            // c:Src/Modules/param_private.c:678 — the getnode hook
6894            // applies to every paramtab lookup; same canonical walk
6895            // as getsparam (SAFETY: read-only old-chain walk under
6896            // the held read guard).
6897            let visible =
6898                crate::ported::modules::param_private::getprivatenode(&**pm as *const param);
6899            if visible.is_null() {
6900                return None;
6901            }
6902            let pm: &param = unsafe { &*visible };
6903            if PM_TYPE(pm.node.flags as u32) == PM_ARRAY {
6904                // c:3108
6905                // c:392-393,430 — `*`, `@` and `argv` are IPDEF9 nodes on
6906                // `&pparams`; their gsu.a getfn is `arrvargetfn`
6907                // (c:4231-4236), which reads the LIVE positional vector.
6908                // zshrs keeps that vector in `builtin::PPARAMS`; the node's
6909                // `u_arr` is stale, so `shift 2 argv` saw the wrong words.
6910                if matches!(name, "argv" | "@" | "*") {
6911                    return Some(PPARAMS.lock().map(|p| p.clone()).unwrap_or_default());
6912                }
6913                if let Some(arr) = pm.u_arr.as_ref() {
6914                    // c:3109 — gsu.a == stdarray_gsu → arrgetfn (c:4054).
6915                    return Some(arr.clone());
6916                }
6917                // c:3107 — non-stdarray gsu.a: the value lives behind
6918                // the module getfn. Dispatch AFTER dropping the read
6919                // guard: `partab_array_get` re-locks `paramtab` for the
6920                // shadow check, and the getfns themselves read the
6921                // shell's tables.
6922                needs_partab_dispatch = crate::ported::modules::parameter::PARTAB_ARRAY
6923                    .iter()
6924                    .any(|e| e.name == name);
6925                declared_empty_array = !needs_partab_dispatch;
6926            }
6927        }
6928    }
6929    if needs_partab_dispatch {
6930        return crate::vm_helper::partab_array_get(name); // c:3107
6931    }
6932    if declared_empty_array {
6933        // c:4012 — `return pm->u.arr ? pm->u.arr : &nullarray;`. A
6934        // declared-but-unassigned `local -a` has a NULL slot (createparam
6935        // zshcalloc's it, c:1136) with stdarray_gsu installed (c:1158), so C
6936        // reads it back as a ZERO-LENGTH array. Returning None made such a
6937        // name look scalar to every caller.
6938        return Some(Vec::new());
6939    }
6940    None // c:3110
6941}
6942
6943/// Port of `gethparam()` from `Src/params.c:3115`.
6944///
6945/// C body:
6946/// ```c
6947/// struct value vbuf;
6948/// Value v;
6949/// if (!idigit(*s) && (v = getvalue(&vbuf, &s, 0)) &&
6950///     PM_TYPE(v->pm->node.flags) == PM_HASHED)
6951///     return paramvalarr(v->pm->gsu.h->getfn(v->pm), SCANPM_WANTVALS);
6952/// return NULL;
6953/// ```
6954///
6955/// Same fabricated-port family as the prior `getaparam`/`getsparam_u`
6956/// fixes: previous Rust sig took `Option<&mut value>` instead of the
6957/// canonical name string, with no real callers. Fixed sig + body
6958/// that resolves the name through paramtab and returns the values
6959/// vector when PM_HASHED.
6960///
6961/// NOTE: zshrs's paramtab stores hash-params via `pm->u_hash` (a
6962/// `HashTable` struct that's a generic bucket-array container). The
6963/// canonical C path threads through `gsu.h->getfn(pm)` → `paramvalarr`
6964/// which extracts the value side of each key-value pair. zshrs's
6965/// canonical assoc backing lives in `paramtab_hashed_storage` (an
6966/// IndexMap<String, String> keyed by param name); read the values
6967/// directly from there as the C macro's
6968/// `paramvalarr(hashgetfn(pm), SCANPM_WANTVALS)` resolves to a
6969/// values walk over the same backing.
6970pub fn gethparam(name: &str) -> Option<Vec<String>> {
6971    // c:3117
6972    if name.starts_with(|c: char| c.is_ascii_digit()) {
6973        // c:3122
6974        return None;
6975    }
6976    // c:3118 — `paramvalarr(pm->gsu.h->getfn(pm), SCANPM_WANTVALS)`: the
6977    // hash getter runs BEFORE the scan. For `$compstate` that is
6978    // `get_compstate` (`Src/Zle/complete.c:1357`), handing back an inner
6979    // hash whose NODES carry their own per-key gsu getters
6980    // (`nmatches_gsu`, `unambig_gsu`, `unambig_curs_gsu`,
6981    // `unambig_pos_gsu`, `insert_pos_gsu`, `listlines_gsu`,
6982    // `compqstack_gsu` — c:1236-1252), which fire during the scan. Those
6983    // ten values are computed live and stored nowhere.
6984    //
6985    // zshrs reads assoc values straight out of `paramtab_hashed_storage`,
6986    // so recompute them into that map here, at the same point C invokes
6987    // the getters. Without it `${(@kv)compstate}` — and therefore
6988    // `_lastcomp` (`_main_complete` sh:407) — had no `unambiguous`,
6989    // `unambiguous_cursor`, `unambiguous_positions`, `insert_positions`,
6990    // `list_lines`, `list_max`, `all_quotes`, `ignored` or `vared` entry
6991    // at all.
6992    if name == crate::ported::zle::complete::COMPSTATENAME {
6993        for k in crate::ported::zle::compcore::LIVE_COMPSTATE_KEYS {
6994            if let Some(v) = crate::ported::zle::compcore::get_compstate_str(k) {
6995                crate::ported::zle::compcore::set_compstate_str(k, &v);
6996            }
6997        }
6998    }
6999
7000    // c:Src/params.c:570-575 — nameref deref before the type check.
7001    let resolved = match crate::ported::params::resolve_nameref_name(name, None) {
7002        crate::ported::params::nameref_resolution::Target { name: t_, .. } => t_,
7003        _ => name.to_string(),
7004    };
7005    let name: &str = &resolved;
7006    // c:Src/params.c:717 — `paramvalarr(v->pm->gsu.h->getfn(v->pm),
7007    // v->scanflags)`. For a SPECIALPMDEF magic hash (`commands`,
7008    // `functions`, `builtins`, `parameters`, `jobtexts`, …) that `getfn`
7009    // hands back a FAKE HashTable whose `scantab` is the module's
7010    // `scanpm*` fn (Src/module.c createspecialhash), so the SCAN below —
7011    // not any stored map — is the backing. zshrs keeps ordinary assoc
7012    // contents in the name-keyed `paramtab_hashed_storage` map, and the
7013    // seeded row for a magic name left an EMPTY map there which answered
7014    // first and shadowed the scanfn: `compadd -k commands`
7015    // (Completion/Unix/Type/_path_commands sh:103) returned ZERO matches
7016    // in a fresh shell, so command-name completion offered builtins only
7017    // until some other `$commands` read had gone through the scanfn
7018    // (which runs `fillcmdnamtable` under HASH_LIST_ALL,
7019    // Src/Modules/parameter.c:253). A `local -A commands` shadow makes
7020    // `magic_special_shadowed` true and the map wins again.
7021    //
7022    // Computed BEFORE the `paramtab()` read below: `magic_special_shadowed`
7023    // takes its own `paramtab().read()`, and a recursive read while a
7024    // writer is queued deadlocks on a writer-preferring RwLock.
7025    let magic_ = crate::ported::modules::parameter::PARTAB
7026        .iter()
7027        .any(|e_| e_.name == name)
7028        && !crate::vm_helper::magic_special_shadowed(name);
7029    // !!! RUST-ONLY LOCK NOTE (C has no locks here) !!!
7030    // Resolve visibility + type under `paramtab()`, then DROP the guard:
7031    // the PARTAB scan below reaches `createparam`, which wants
7032    // `paramtab().write()`, and a write request from a thread already
7033    // holding a read deadlocks. Same reasoning as `gethkparam`.
7034    //
7035    // c:Src/Modules/param_private.c:678 — getnode hook; same canonical
7036    // walk as getsparam/getaparam. (Values below still come from the
7037    // name-keyed hashed storage — a single slot per name; per-scope
7038    // assoc shadowing is a storage-model limitation predating this walk.)
7039    let (hidden_, is_hashed_) = match paramtab().read() {
7040        Ok(tab) => match tab.get(name) {
7041            Some(pm) => {
7042                let visible =
7043                    crate::ported::modules::param_private::getprivatenode(&**pm as *const param);
7044                if visible.is_null() {
7045                    (true, false)
7046                } else {
7047                    let pm: &param = unsafe { &*visible };
7048                    (false, PM_TYPE(pm.node.flags as u32) == PM_HASHED)
7049                }
7050            }
7051            None => (false, false),
7052        },
7053        Err(_) => (false, false),
7054    };
7055    if hidden_ {
7056        return None;
7057    }
7058    {
7059        {
7060            if is_hashed_ {
7061                // c:3123
7062                // c:3124 — `paramvalarr(hashgetfn(pm), SCANPM_WANTVALS)`.
7063                // Read values directly from the canonical hashed-storage
7064                // backing — IndexMap iteration matches C's hashtable
7065                // walk order (insertion-stable). When the storage hasn't
7066                // been populated (empty hash, fresh declaration), return
7067                // an empty Vec so the C "param exists, no entries" shape
7068                // is preserved (vs returning None which means "param
7069                // doesn't exist").
7070                if !magic_ {
7071                    let store = paramtab_hashed_storage().lock().ok()?;
7072                    if let Some(m) = store.get(name) {
7073                        return Some(m.values().cloned().collect()); // c:3124
7074                    }
7075                }
7076                // c:3124 — for SPECIALPMDEF magic hashes (jobtexts/jobstates/
7077                // jobdirs/parameters/commands/…, Src/Modules/parameter.c) the
7078                // `getfn` is the module's scan fn, not hashgetfn, so there is
7079                // no paramtab_hashed_storage backing to read. `gethkparam`
7080                // below already falls back to the partab row's scanfn for the
7081                // KEYS; without the mirror image here the VALUES came back
7082                // empty, and every caller that pairs the two got keys with
7083                // blank values. `_jobs` is the visible one: with `sleep 300 &`
7084                // running it read `jobtexts` as [("1", "")], so
7085                // `kill %<TAB>` added a bare `%` where zsh adds `%sleep` —
7086                // and `kill %s<TAB>` deleted the typed `s`.
7087                if let Some(vals) = (|| -> Option<Vec<String>> {
7088                    let e_ = crate::ported::modules::parameter::PARTAB
7089                        .iter()
7090                        .find(|e_| e_.name == name)?;
7091                    if let Some(m_) = e_.module {
7092                        if !crate::ported::module::MODULESTAB
7093                            .lock()
7094                            .map(|t| t.is_loaded(m_))
7095                            .unwrap_or(false)
7096                        {
7097                            return None;
7098                        }
7099                    }
7100                    thread_local! {
7101                        static VALS_: std::cell::RefCell<Vec<String>> =
7102                            const { std::cell::RefCell::new(Vec::new()) };
7103                    }
7104                    fn cb_(pm: &crate::ported::zsh_h::param, _f: i32) {
7105                        // c:665-670 — `paramvals[numparamvals] =
7106                        // getstrvalue(&v)`, the same read scanparamvals does.
7107                        VALS_.with(|v| v.borrow_mut().push(strgetfn(pm)));
7108                    }
7109                    crate::vm_helper::mark_module_param_used(name);
7110                    VALS_.with(|v| v.borrow_mut().clear());
7111                    (e_.scanfn)(
7112                        std::ptr::null_mut(),
7113                        Some(cb_),
7114                        crate::ported::zsh_h::SCANPM_WANTVALS as i32,
7115                    );
7116                    Some(VALS_.with(|v| v.borrow().clone()))
7117                })() {
7118                    return Some(vals); // c:3124
7119                }
7120                return Some(Vec::new()); // c:3124
7121            }
7122        }
7123    }
7124    None // c:3125
7125}
7126
7127/// Port of `gethkparam()` from `Src/params.c:3130`.
7128/// Same as `gethparam` but `paramvalarr(..., SCANPM_WANTKEYS)`.
7129pub fn gethkparam(name: &str) -> Option<Vec<String>> {
7130    // c:3131
7131    if name.starts_with(|c: char| c.is_ascii_digit()) {
7132        // c:3136
7133        return None;
7134    }
7135    // c:3138 — `paramvalarr(pm->gsu.h->getfn(pm), SCANPM_WANTKEYS)`: same
7136    // getfn-before-scan contract as `gethparam` above; see the note there
7137    // for why `$compstate`'s gsu-backed keys have to be recomputed here.
7138    if name == crate::ported::zle::complete::COMPSTATENAME {
7139        for k in crate::ported::zle::compcore::LIVE_COMPSTATE_KEYS {
7140            if let Some(v) = crate::ported::zle::compcore::get_compstate_str(k) {
7141                crate::ported::zle::compcore::set_compstate_str(k, &v);
7142            }
7143        }
7144    }
7145    // c:Src/params.c:717 — same magic-hash rule as `gethparam` above: the
7146    // scanfn IS the backing for a PARTAB name, so an empty
7147    // `paramtab_hashed_storage` row must not answer for it. Computed
7148    // before the `paramtab()` read for the same recursive-lock reason.
7149    let magic_ = crate::ported::modules::parameter::PARTAB
7150        .iter()
7151        .any(|e_| e_.name == name)
7152        && !crate::vm_helper::magic_special_shadowed(name);
7153    // !!! RUST-ONLY LOCK NOTE (C has no locks here) !!!
7154    // Read the TYPE under `paramtab()`, then DROP the guard before the
7155    // storage read or the PARTAB scan below. The scan's
7156    // `mark_module_param_used` → `materialize_module_param` →
7157    // `createparam` takes `paramtab().write()`, and asking for a write
7158    // while this thread still holds a read deadlocks on the
7159    // writer-preferring queue lock (`std::sys::sync::rwlock::queue`).
7160    // The scan arm was only reachable for names with no
7161    // `paramtab_hashed_storage` row before, so the hazard sat latent
7162    // until the magic-hash routing above started using it.
7163    let is_hashed_ = paramtab()
7164        .read()
7165        .ok()
7166        .and_then(|tab| {
7167            tab.get(name)
7168                .map(|pm| PM_TYPE(pm.node.flags as u32) == PM_HASHED) // c:3137
7169        })
7170        .unwrap_or(false);
7171    {
7172        {
7173            if is_hashed_ {
7174                // c:3137
7175                // c:3138 — `paramvalarr(pm->gsu.h->getfn(pm),
7176                // SCANPM_WANTKEYS)`. Same backing as gethparam —
7177                // return keys instead of values. Empty-storage
7178                // fallback identical: Some(empty Vec) for "exists,
7179                // no entries" shape.
7180                if !magic_ {
7181                    let store = paramtab_hashed_storage().lock().ok()?;
7182                    if let Some(m) = store.get(name) {
7183                        return Some(m.keys().cloned().collect()); // c:3138
7184                    }
7185                }
7186                // c:3138 — for SPECIALPMDEF magic hashes (parameters/
7187                // options/commands/…, Src/Modules/parameter.c) the
7188                // `getfn` is the module's scan fn, not hashgetfn;
7189                // there's no paramtab_hashed_storage backing. The
7190                // partab_scan_keys port (vm_helper.rs:3486) is that
7191                // scan. Without this, `${(k)parameters[PATH]}` saw an
7192                // empty key set and returned "" where zsh prints PATH.
7193                if let Some(keys) = (|| -> Option<Vec<String>> {
7194                    // c:Src/Modules/parameter.c — special-hash scantab
7195                    // key enumeration via the partab[] row's scanfn
7196                    // (former bridge partab_scan_keys, inlined; the
7197                    // captureless callback is the C ScanFunc ABI).
7198                    let e_ = crate::ported::modules::parameter::PARTAB
7199                        .iter()
7200                        .find(|e_| e_.name == name)?;
7201                    if let Some(m_) = e_.module {
7202                        if !crate::ported::module::MODULESTAB
7203                            .lock()
7204                            .map(|t| t.is_loaded(m_))
7205                            .unwrap_or(false)
7206                        {
7207                            return None;
7208                        }
7209                    }
7210                    thread_local! {
7211                        static KEYS_: std::cell::RefCell<Vec<String>> =
7212                            const { std::cell::RefCell::new(Vec::new()) };
7213                    }
7214                    fn cb_(pm: &crate::ported::zsh_h::param, _f: i32) {
7215                        KEYS_.with(|k| k.borrow_mut().push(pm.node.nam.clone()));
7216                    }
7217                    // c:Src/params.c:589-594 getparamnode → c:563-585
7218                    // loadparamnode — resolving the NAME runs
7219                    // ensurefeature(mn, "p:", nam), so a PM_AUTOLOAD stub stops
7220                    // reading "undefined" (c:Src/Modules/parameter.c:48-50).
7221                    // The non-inlined `vm_helper::partab_scan_keys` marks here;
7222                    // this inlined copy dropped it, so `compadd -Qk builtins` /
7223                    // `compadd -k functions` (Completion/Zsh/Type/_command_names
7224                    // sh:34-35) left both names typed "undefined".
7225                    crate::vm_helper::mark_module_param_used(name);
7226                    KEYS_.with(|k| k.borrow_mut().clear());
7227                    // c:3138 — `paramvalarr(…, SCANPM_WANTKEYS)`.
7228                    (e_.scanfn)(
7229                        std::ptr::null_mut(),
7230                        Some(cb_),
7231                        crate::ported::zsh_h::SCANPM_WANTKEYS as i32,
7232                    );
7233                    Some(KEYS_.with(|k| k.borrow().clone()))
7234                })() {
7235                    return Some(keys); // c:3138
7236                }
7237                return Some(Vec::new()); // c:3138
7238            }
7239        }
7240    }
7241    None // c:3139
7242}
7243
7244/// Port of `check_warn_pm()` from `Src/params.c:3158`.
7245///
7246/// C body emits the WARN_CREATE_GLOBAL / WARN_NESTED_VAR
7247/// diagnostic when a function-local creates/passes a non-local
7248/// param with the matching shell options set.
7249///
7250/// The previous Rust port handled the GATE logic correctly but
7251/// SKIPPED the diagnostic emit, claiming the `funcstack` global
7252/// wasn't ported. But `FUNCSTACK`
7253/// IS ported (`Mutex<Vec<funcstack>>`). Wire the walk:
7254///   for (i = funcstack; i; i = i->prev)
7255///       if (i->tp == FS_FUNC) {
7256///           msg = created ?
7257///               "%s parameter %s created globally in function %s" :
7258///               "%s parameter %s set in enclosing scope in function %s";
7259///           zwarn(msg, pmtype, pm->node.nam, i->name);
7260///           break;
7261///       }
7262///
7263/// Without the diagnostic, `setopt WARN_CREATE_GLOBAL` had no
7264/// observable effect — the whole point of the option is the
7265/// user-visible warning.
7266pub fn check_warn_pm(pm: &param, pmtype: &str, created: i32, may_warn_about_nested_vars: i32) {
7267    // c:3160
7268    if may_warn_about_nested_vars == 0 && created == 0 {
7269        // c:3165
7270        return;
7271    }
7272    // `locallevel` is the canonical `pub static` above (port of
7273    // params.c:54). `forklevel` is the ported global at vm_helper
7274    // (port of exec.c:1052) set to locallevel at every entersubsh().
7275    let cur_local: i32 = locallevel.load(Ordering::Relaxed);
7276    let forklevel: i32 = FORKLEVEL.load(Ordering::Relaxed); // c:1052 (Src/exec.c)
7277    if created != 0 && isset(WARNCREATEGLOBAL) {
7278        // c:3168
7279        if cur_local <= forklevel || pm.level != 0 {
7280            // c:3169
7281            return;
7282        }
7283    } else if created == 0 && isset(WARNNESTEDVAR) {
7284        // c:3171
7285        if pm.level >= cur_local {
7286            // c:3172
7287            return;
7288        }
7289    } else {
7290        return;
7291    }
7292    if (pm.node.flags as u32 & (PM_SPECIAL | PM_NAMEREF)) != 0 {
7293        // c:3177
7294        return;
7295    }
7296    // c:3180-3190 — walk funcstack, emit zwarn at first FS_FUNC.
7297    let stack = match FUNCSTACK.lock() {
7298        Ok(s) => s,
7299        Err(_) => return,
7300    };
7301    for frame in stack.iter().rev() {
7302        // c:3180 walk most-recent-first
7303        if frame.tp == FS_FUNC {
7304            // c:3181 FS_FUNC
7305            let msg = if created != 0 {
7306                // c:3185
7307                format!(
7308                    "{} parameter {} created globally in function {}",
7309                    pmtype, pm.node.nam, frame.name
7310                )
7311            } else {
7312                // c:3187
7313                format!(
7314                    "{} parameter {} set in enclosing scope in function {}",
7315                    pmtype, pm.node.nam, frame.name
7316                )
7317            };
7318            zwarn(&msg); // c:3189
7319            break; // c:3190
7320        }
7321    }
7322}
7323
7324// intgetfn / strgetfn drift wrappers removed — replaced below with
7325// real C-shape ports `intgetfn(pm: &param) -> i64` (Src/params.c:3993)
7326// and `strgetfn(pm: &param) -> String` (Src/params.c:4029) that read
7327// directly from the union fields `pm->u.val` / `pm->u.str`.
7328
7329// ---------------------------------------------------------------------------
7330// Tests
7331// ---------------------------------------------------------------------------
7332
7333// ===========================================================
7334// Methods moved verbatim from src/ported/vm_helper because their
7335// C counterpart's source file maps 1:1 to this Rust module.
7336// Phase: params
7337// ===========================================================
7338
7339// BEGIN moved-from-exec-rs
7340// (impl ShellExecutor block moved to src/exec_shims.rs — see file marker)
7341
7342// END moved-from-exec-rs
7343
7344// ===========================================================
7345// Free ported moved verbatim from src/ported/vm_helper.
7346// ===========================================================
7347// BEGIN moved-from-exec-rs (free ported)
7348/// Subscript-argument result.
7349///
7350/// `Flags` carries the parsed flag chars and the remaining subscript
7351/// text (the pattern after `(...)`); the caller dispatches the
7352/// search itself. `Value` is the result of an in-getarg array/hash
7353/// pattern search — direct port of getarg's pprog/pattry arm at
7354/// Src/params.c:1672-1719 (array) and 1581-1660 (hash).
7355// `enum getarg_out` is a Rust extension to express the dual-mode
7356// return of `getarg`. C `getarg` (`Src/params.c:1367`) writes back
7357// via out-pointers (`int *inv`, `Value v`, `zlong *w`, ...) and
7358// returns `int`. The Rust port collapses those into one sum-typed
7359// return: `Flags` carries the parsed flag chars + remaining
7360// subscript when no search ran; `Value` carries the search result
7361// from the pprog/pattry arms at c:1581-1660 (hash) / c:1672-1719
7362// (array). Naming kept lowercase to mark this as a port-shape helper
7363// rather than a C-mirrored struct.
7364/// `getarg_out` — see variants.
7365#[allow(non_camel_case_types)]
7366pub enum getarg_out<'a> {
7367    Flags { flags: &'a str, rest: &'a str },
7368    Value(Value),
7369}
7370
7371/// Port of `assignsparam(char *s, char *val, int flags)` from `Src/params.c:3193`. C signature:
7372/// `mod_export Param assignsparam(char *s, char *val, int flags)`.
7373///
7374/// `s` may carry an embedded `[...]` subscript (matching C's
7375/// `strchr(s, '[')` parse). The function operates on the global
7376/// `paramtab` (Src/params.c:515), creating/mutating `Param`
7377/// entries in place. Branches preserved 1:1 with C:
7378///   - c:3203 `isident(s)` — reject non-identifier names.
7379///   - c:3209 `queue_signals()`.
7380///   - c:3210 subscripted path: c:3212 `getvalue` lookup,
7381///     c:3213 `createparam(t, PM_ARRAY)` on miss, c:3216
7382///     PM_READONLY guard, c:3227 ASSPM_WARN drop, c:3228 clear
7383///     PM_DEFAULTED, c:3231 `v = NULL` then re-dispatch by type.
7384///   - c:3232 non-subscripted: c:3233 `getvalue` → c:3234
7385///     `createparam(t, PM_SCALAR)`; c:3236-3250 array/hash type-flip
7386///     to PM_SCALAR (when not PM_SPECIAL|PM_TIED, not KSHARRAYS,
7387///     not ASSPM_AUGMENT) via `resetparam(v->pm, PM_SCALAR)`.
7388///   - c:3258 PM_NAMEREF → c:3259 `valid_refname(val, flags)` guard.
7389///   - c:3269 clear PM_DEFAULTED.
7390///   - c:3343 `assignstrvalue(v, val, flags)`.
7391///   - c:3344 `unqueue_signals()`; c:3345 return v->pm.
7392///
7393/// `+=` (ASSPM_AUGMENT) and `check_warn_pm` (c:3266-3268) are handled
7394/// in the branches of the body below; the parameter table is the typed
7395/// `hashtable_nodes<Param>` store (see PARAMTAB_INNER).
7396/// c:Src/params.c:395-422 — the IPDEF8 PM_TIED colon-array pairs. A
7397/// SUBSCRIPT modification of the ARRAY side (`path[N]=x`, `path[i,j]=(…)`,
7398/// `unset path[N]`) must re-derive the tied SCALAR side, exactly as C's
7399/// array setfn does after setarrvalue rebuilds the array. zshrs lacks the
7400/// per-name GSU vtable, so the subscript paths carry the tie explicitly via
7401/// this const map (the mirror of the scalar→array cascade); each site joins
7402/// the updated array with `:` and writes the scalar through assignsparam.
7403pub(crate) const TIED_COLON_ARRAYS: &[(&str, &str)] = &[
7404    ("path", "PATH"),
7405    ("fpath", "FPATH"),
7406    ("manpath", "MANPATH"),
7407    ("cdpath", "CDPATH"),
7408    ("psvar", "PSVAR"),
7409    ("module_path", "MODULE_PATH"),
7410    ("fignore", "FIGNORE"),
7411    ("mailpath", "MAILPATH"),
7412    // c:Src/Modules/watch.c:697-701 — zsh/watch's pair, PM_TIED since
7413    // upstream b2fb112ea6 ("54383: Fix WATCH/watch tying").
7414    ("watch", "WATCH"),
7415];
7416
7417/// Port of `assignsparam()` from `Src/params.c:3193`.
7418pub fn assignsparam(s: &str, val: &str, flags: i32) -> Option<Param> {
7419    // c:3203 `if (!isident(s)) { zerr; errflag |= ERRFLAG_ERROR; return NULL; }`
7420    if !isident(s) {
7421        zerr(&format!("not an identifier: {}", s)); // c:3204
7422        errflag.fetch_or(
7423            // c:3206
7424            ERRFLAG_ERROR,
7425            Ordering::Relaxed,
7426        );
7427        return None; // c:3207
7428    }
7429    // !!! RUST-ONLY: provenance tap. Records the scalar write against
7430    // the lineage ledger when `s` is a tracked name. Placed at the
7431    // funnel head rather than at one of the 30-odd exits — a write
7432    // rejected further down (read-only variable) therefore shows in the
7433    // lineage as an attempted assign, which is documented in
7434    // docs/PROVENANCE.md. Costs one relaxed atomic load otherwise.
7435    if crate::provenance::active() {
7436        let kind = if (flags & ASSPM_AUGMENT) != 0 {
7437            "append"
7438        } else {
7439            "assign"
7440        };
7441        crate::provenance::on_param_write(s, kind, val);
7442    }
7443    // !!! WARNING: RUST-ONLY ARM — ZLE GSU adapter !!!
7444    // C's makezleparams (Src/Zle/zle_params.c:194) installs LIVE GSU
7445    // setters for the editing specials; zshrs snapshots copies into
7446    // the paramtab (extensions/zle_param_sync.rs), so sequential
7447    // widget mutations read STALE peers (`LBUFFER=tpl; CURSOR=n;
7448    // RBUFFER=${RBUFFER:len}` scrambled zsh-expand's multiline
7449    // snippet). Route these writes through the live editor and let
7450    // live_write re-publish the derived family. Only fires inside an
7451    // active widget scope; live_write's guard blocks its own
7452    // re-publish writes.
7453    // ASSPM_AUGMENT must be resolved BEFORE the live write: `+=`
7454    // routes here with only the APPENDED text as `val`, and writing
7455    // that raw would ASSIGN it (`LBUFFER+=\"` in zsh-autopair's
7456    // _ap-self-insert nuked everything typed before the quote —
7457    // `echo hi "` collapsed to `""`). C never has this problem: its
7458    // augment concatenation happens before gsu.s->setfn dispatch
7459    // (Src/params.c:2742-2748). Concatenate against the LIVE editor
7460    // value (CURSOR augments arithmetically, matching PM_INTEGER).
7461    let zle_special = matches!(s, "BUFFER" | "LBUFFER" | "RBUFFER" | "CURSOR");
7462    let zle_augmented: String;
7463    let zle_val: &str = if zle_special && (flags & ASSPM_AUGMENT) != 0 {
7464        use crate::ported::zle::zle_params as zp;
7465        zle_augmented = match s {
7466            "BUFFER" => format!("{}{}", zp::get_buffer(), val),
7467            "LBUFFER" => format!("{}{}", zp::get_lbuffer(), val),
7468            "RBUFFER" => format!("{}{}", zp::get_rbuffer(), val),
7469            // c:2775-2778 — integer augment is arithmetic add. The added
7470            // value is an ARITHMETIC EXPRESSION, not a literal: `CURSOR+=n`
7471            // has to evaluate `n` the way any PM_INTEGER assignment does.
7472            _ => {
7473                let old = zp::get_cursor() as i64;
7474                let add = crate::ported::math::mathevali(val.trim()).unwrap_or(0);
7475                (old + add).to_string()
7476            }
7477        };
7478        &zle_augmented
7479    } else {
7480        val
7481    };
7482    // CURSOR is PM_INTEGER (zle_params.rs:70, c:147), so its RHS is an
7483    // ARITHMETIC EXPRESSION — `CURSOR=end` means "the value of end", not the
7484    // string "end". The three string specials above pass through untouched,
7485    // but routing CURSOR to live_write handed it the raw word, which parsed
7486    // as 0. MARK is not in this list and so kept going down the PM_INTEGER
7487    // path, which is why `MARK=found; CURSOR=end` — the last two lines of
7488    // zsh's own select-quoted — set the mark correctly and the cursor to 0,
7489    // making every quote/bracket text object select from the mark to the
7490    // start of the line.
7491    let zle_evaluated: Option<String> = if s == "CURSOR" {
7492        // A bad expression is not a silent 0: fall through to the ordinary
7493        // assignment path, which reports it the way any arithmetic error is.
7494        crate::ported::math::mathevali(zle_val.trim())
7495            .ok()
7496            .map(|n| n.to_string())
7497    } else {
7498        None
7499    };
7500    let zle_ok = s != "CURSOR" || zle_evaluated.is_some();
7501    let zle_val: &str = zle_evaluated.as_deref().unwrap_or(zle_val);
7502    if zle_special && zle_ok && crate::zle_param_sync::live_write(s, zle_val) {
7503        return getsparam(s).map(|_| {
7504            Box::new(crate::ported::zsh_h::param {
7505                node: crate::ported::zsh_h::hashnode {
7506                    next: None,
7507                    nam: s.to_string(),
7508                    flags: 0,
7509                },
7510                u_data: 0,
7511                u_tied: None,
7512                u_arr: None,
7513                u_str: Some(zle_val.to_string()),
7514                u_val: 0,
7515                u_dval: 0.0,
7516                u_hash: None,
7517                gsu_s: None,
7518                gsu_i: None,
7519                gsu_f: None,
7520                gsu_a: None,
7521                gsu_h: None,
7522                base: 0,
7523                width: 0,
7524                env: None,
7525                ename: None,
7526                old: None,
7527                level: 0,
7528            })
7529        });
7530    }
7531    // c:3233/3252 — `getvalue(&vbuf, &s, 1)` routes through
7532    // fetchvalue which resolves PM_NAMEREF chains (c:2247-2270).
7533    // Mirror by redirecting the assignment to the resolved target.
7534    {
7535        let base = s.split('[').next().unwrap_or(s);
7536        if crate::ported::params::is_nameref(base) {
7537            match crate::ported::params::resolve_nameref_name(base, None) {
7538                crate::ported::params::nameref_resolution::SelfRef => {
7539                    // zerr emitted inside the walk (c:6341-6343).
7540                    errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed);
7541                    return None;
7542                }
7543                crate::ported::params::nameref_resolution::OutOfScope => {
7544                    // c:1108-1118 — createparam refuses the existing
7545                    // ref; assignment fails (status 1, no errflag —
7546                    // c:3255 is commented out in the C source).
7547                    return None;
7548                }
7549                crate::ported::params::nameref_resolution::Placeholder(last, plevel) => {
7550                    // c:3233 — `fetchvalue(&vbuf, &t, 1, SCANPM_ASSIGNING)`
7551                    // is handed the RESOLVED endpoint by `getparamnode`
7552                    // (c:570-575) and then applies c:2262-2263
7553                    //     if (!pm || ((pm->node.flags & PM_UNSET) &&
7554                    //                 !(pm->node.flags & PM_DECLARED)))
7555                    //         return NULL;
7556                    // A chain ending on a reference that `unset -n` left
7557                    // `PM_UNSET` with PM_DECLARED cleared therefore reads
7558                    // as ABSENT and takes c:3234 `createparam(t,
7559                    // PM_SCALAR)`, whose c:1120-1123 arm retargets
7560                    // `oldpm` to that endpoint — the value becomes an
7561                    // ordinary scalar ON THE ENDPOINT, not a new refname.
7562                    // Re-entering through the endpoint's own name is the
7563                    // name-keyed equivalent of C's pointer retarget.
7564                    {
7565                        let endpoint_visible = paramtab()
7566                            .read()
7567                            .ok()
7568                            .and_then(|t| {
7569                                let mut cur = t.get(&last).map(|b| &**b);
7570                                while let Some(p) = cur {
7571                                    if p.level == plevel {
7572                                        let f = p.node.flags as u32;
7573                                        return Some((f & PM_UNSET) == 0 || (f & PM_DECLARED) != 0);
7574                                    }
7575                                    cur = p.old.as_deref();
7576                                }
7577                                None
7578                            })
7579                            .unwrap_or(true); // c:2262-2263
7580                        if !endpoint_visible {
7581                            // c:1062-1084 + c:1117-1120 — createparam is
7582                            // called with the REFERENCE's name; it takes
7583                            // the unset endpoint as `oldpm`, and a private's
7584                            // PM_RO_BY_DESIGN rejects the write under that
7585                            // name (Src/Modules/param_private.c:174).
7586                            let endpoint_ro = paramtab().read().ok().is_some_and(|t| {
7587                                let mut cur = t.get(&last).map(|b| &**b);
7588                                while let Some(p) = cur {
7589                                    if p.level == plevel {
7590                                        return (p.node.flags as u32 & PM_RO_BY_DESIGN) != 0;
7591                                    }
7592                                    cur = p.old.as_deref();
7593                                }
7594                                false
7595                            });
7596                            if endpoint_ro {
7597                                let refnam = s.split('[').next().unwrap_or(s);
7598                                zerr(&format!("{}: can't modify read-only parameter", refnam)); // c:1118
7599                                return None;
7600                            }
7601                            let mut new_s = last.clone();
7602                            if let Some(us) = s.find('[').map(|i| &s[i..]) {
7603                                new_s.push_str(us);
7604                            }
7605                            return assignsparam(&new_s, val, flags);
7606                        }
7607                    }
7608                    // Chain ends at a placeholder/unset ref: the value
7609                    // becomes its new refname (assignstrvalue
7610                    // PM_NAMEREF arm, c:2712-2717 + c:3258).
7611                    // assignstrvalue PM_NAMEREF arm, INLINE per Src/params.c:2690-2720
7612                    // (former vm_helper::nameref_assign_refname,
7613                    // single-caller inline 2026-06-12):
7614                    // c:2696-2697 read-only; c:3258-3264 valid_refname;
7615                    // c:2717 SETREFNAME; c:2845 setscope.
7616                    return (|| -> Option<Param> {
7617                        let last_n: &str = &last;
7618                        let snap = {
7619                            let tab = paramtab().read().ok()?;
7620                            let mut cur = tab.get(last_n).map(|b| &**b);
7621                            let mut found = None;
7622                            while let Some(p) = cur {
7623                                if p.level == plevel {
7624                                    found = Some((p.node.flags, p.node.nam.clone()));
7625                                    break;
7626                                }
7627                                cur = p.old.as_deref();
7628                            }
7629                            found
7630                        };
7631                        let (flags, nam) = snap?;
7632                        if (flags as u32 & PM_READONLY) != 0 {
7633                            // c:2696-2697
7634                            zerr(&format!("read-only variable: {}", nam));
7635                            return None;
7636                        }
7637                        if !val.is_empty() && !valid_refname(val, flags) {
7638                            // c:3219-3226
7639                            zerr(&format!("invalid variable name: {}", val));
7640                            errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed);
7641                            return None;
7642                        }
7643                        {
7644                            let mut tab = paramtab().write().ok()?;
7645                            // The placeholder may be hidden under a same-name
7646                            // local (upscope walked past it): write that node.
7647                            let mut pm: &mut param = tab.get_mut(last_n)?.as_mut();
7648                            while pm.level != plevel {
7649                                pm = pm.old.as_deref_mut()?;
7650                            }
7651                            pm.base = 0; // c:6376 shape (rebind resets scope info)
7652                            pm.width = 0;
7653                            pm.u_str = Some(val.to_string()); // c:2717 SETREFNAME
7654                            pm.node.flags &= !(PM_DEFAULTED as i32); // c:3269 + c:2712
7655                        }
7656                        // c:2845 — setscope(v->pm): self-reference detection + base.
7657                        if setscope_by_name(last_n, Some(plevel)) != 0 {
7658                            errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed);
7659                            return None;
7660                        }
7661                        let tab = paramtab().read().ok()?;
7662                        let mut cur = tab.get(last_n).map(|b| &**b);
7663                        while let Some(p) = cur {
7664                            if p.level == plevel {
7665                                return Some(Box::new(p.clone()));
7666                            }
7667                            cur = p.old.as_deref();
7668                        }
7669                        None
7670                    })();
7671                }
7672                crate::ported::params::nameref_resolution::Target {
7673                    name: t,
7674                    subscript: rsub,
7675                    pm: rpm,
7676                    level,
7677                } => {
7678                    // Rebuild the target expression: ref subscript
7679                    // first (REFSLICE, c:2264-2268), then any caller
7680                    // subscript.
7681                    let user_sub = s.find('[').map(|i| &s[i..]);
7682                    // Hidden (old-chain) binding — the upscope walk
7683                    // (c:6455) resolved past the visible binding;
7684                    // write through the chain node directly.
7685                    if rsub.is_none() && user_sub.is_none() && rpm.is_some() {
7686                        let visible_level = paramtab()
7687                            .read()
7688                            .ok()
7689                            .and_then(|tb| tb.get(&t).map(|p| p.level));
7690                        if visible_level != Some(level) {
7691                            // upscope write (c:6455 landed on a pm->old
7692                            // node), INLINE (former nameref_hidden_scalar_assign).
7693                            return (|name: &str, level: i32, val: &str| -> Option<Param> {
7694                                let mut tab = paramtab().write().ok()?;
7695                                let mut node: &mut param = tab.get_mut(name)?.as_mut();
7696                                while node.level != level {
7697                                    node = node.old.as_mut()?.as_mut();
7698                                }
7699                                if (node.node.flags as u32 & PM_READONLY) != 0 {
7700                                    drop(tab); // zerr redraws ZLE, which reads paramtab
7701                                    zerr(&format!("read-only variable: {}", name)); // c:2696
7702                                    return None;
7703                                }
7704                                let t = PM_TYPE(node.node.flags as u32);
7705                                // c:3178-3184 — an array (not `+=`) or hash
7706                                // target that is not special/tied is
7707                                // `resetparam(v->pm, PM_SCALAR)`, and
7708                                // resetparam refuses a pm that is not the
7709                                // visible binding (c:3740-3746).
7710                                let nf = node.node.flags as u32;
7711                                if (((nf & PM_ARRAY) != 0 && (flags & ASSPM_AUGMENT) == 0)
7712                                    || (nf & PM_HASHED) != 0)
7713                                    && (nf & (PM_SPECIAL | PM_TIED)) == 0
7714                                    && !isset(KSHARRAYS)
7715                                    // c:2262-2263 — an unset, undeclared pm is
7716                                    // never fetched, so it takes createparam.
7717                                    && ((nf & PM_UNSET) == 0 || (nf & PM_DECLARED) != 0)
7718                                {
7719                                    drop(tab); // zerr redraws ZLE, which reads paramtab
7720                                    zerr(&format!("can't change type of hidden variable: {}", name)); // c:3745
7721                                    errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed); // c:3187
7722                                    return None;
7723                                }
7724                                if t == PM_INTEGER {
7725                                    // c:Src/params.c:4007 intsetfn via mathevali.
7726                                    node.u_val = crate::ported::math::mathevali(val).unwrap_or(0);
7727                                } else if t == PM_EFLOAT || t == PM_FFLOAT {
7728                                    node.u_dval = val.trim().parse().unwrap_or(0.0);
7729                                } else {
7730                                    // c:3236-3250 — array/hash → scalar type flip through ref.
7731                                    let type_mask = PM_TYPE(u32::MAX) as i32;
7732                                    node.node.flags =
7733                                        (node.node.flags & !type_mask) | PM_SCALAR as i32;
7734                                    node.u_arr = None;
7735                                    node.u_str = Some(val.to_string());
7736                                }
7737                                node.node.flags &= !((PM_UNSET | PM_DECLARED) as i32); // c:2712 + c:3269
7738                                Some(Box::new(node.clone()))
7739                            })(&t, level, val);
7740                        }
7741                    }
7742                    let mut new_s = t.clone();
7743                    if let Some(rs) = &rsub {
7744                        new_s.push('[');
7745                        new_s.push_str(rs);
7746                        new_s.push(']');
7747                    }
7748                    if let Some(us) = user_sub {
7749                        new_s.push_str(us);
7750                    }
7751                    if new_s != s {
7752                        // c:2264-2287 — through a reference slice the Value's
7753                        // pm is the ELEMENT (getarg's c:1584-1588 createparam
7754                        // in the hash's table), so c:3270 `v->pm->node.flags
7755                        // &= ~PM_DEFAULTED` never touches the base: a
7756                        // TYPESET_TO_UNSET hash keeps reading as unset to
7757                        // `typeset -p` while its element is stored. The direct
7758                        // `name[sub]=` path below clears it on the base
7759                        // (c:3229), so put the bits back.
7760                        let base_defaulted = if rsub.is_some() {
7761                            paramtab()
7762                                .read()
7763                                .ok()
7764                                .and_then(|tb| tb.get(&t).map(|p| p.node.flags as u32 & PM_DEFAULTED))
7765                                .unwrap_or(0)
7766                        } else {
7767                            0
7768                        };
7769                        let r = assignsparam(&new_s, val, flags);
7770                        if base_defaulted == PM_DEFAULTED && r.is_some() {
7771                            if let Ok(mut tb) = paramtab().write() {
7772                                if let Some(p) = tb.get_mut(&t) {
7773                                    p.node.flags |= PM_DEFAULTED as i32;
7774                                }
7775                            }
7776                        }
7777                        return r;
7778                    }
7779                }
7780                crate::ported::params::nameref_resolution::NotRef => {}
7781            }
7782        }
7783    }
7784    // c:Src/params.c randomsetfn / secondssetfn — re-assigning a
7785    // regenerator-style special clears the PM_UNSET flag so the
7786    // getfn becomes live again. zshrs's lookup_special_var uses a
7787    // side-set for this (no real pm node), so clear it here. Bug #417.
7788    if matches!(
7789        s,
7790        "RANDOM" | "SECONDS" | "EPOCHSECONDS" | "EPOCHREALTIME" | "TTYIDLE" | "ERRNO"
7791    ) {
7792        clear_unset_special(s);
7793    }
7794    queue_signals(); // c:3209
7795
7796    // c:3210 — `strchr(s, '[')`. Split the leading name from the
7797    // subscript while preserving C's `*ss = '\0'` / `*ss = '['`
7798    // restore semantics: the Rust port works on `&str` slices so
7799    // there's no in-place null-terminator dance, but the parse
7800    // shape is identical.
7801    // c:Src/params.c:3210 — `strchr(s, '[')` finds the subscript
7802    // opener; C's parse_subscript (params.c:1480+) then walks to the
7803    // matching `]` respecting backslash-escapes and nesting. zshrs's
7804    // previous `s.rfind(']')` picked the LAST `]` which works for
7805    // simple `name[key]` but mis-bounds `A[\[k\]]` (where the key
7806    // contains escaped brackets).
7807    let (name, subscript) = match s.find('[') {
7808        Some(i) => {
7809            // Walk forward from `i + 1` matching brackets with
7810            // escape-awareness per parse_subscript semantics.
7811            let mut depth = 1i32;
7812            let mut close_byte: Option<usize> = None;
7813            let mut bslash = false;
7814            let mut byte_off = i + 1;
7815            for ch in s[i + 1..].chars() {
7816                if bslash {
7817                    bslash = false;
7818                    byte_off += ch.len_utf8();
7819                    continue;
7820                }
7821                match ch {
7822                    '\\' => bslash = true,
7823                    '[' => depth += 1,
7824                    ']' => {
7825                        depth -= 1;
7826                        if depth == 0 {
7827                            close_byte = Some(byte_off);
7828                            break;
7829                        }
7830                    }
7831                    _ => {}
7832                }
7833                byte_off += ch.len_utf8();
7834            }
7835            let close = close_byte.unwrap_or(s.len());
7836            let key_end = if close > i { close } else { s.len() };
7837            (&s[..i], Some(&s[i + 1..key_end]))
7838        }
7839        None => (s, None),
7840    };
7841    // c:Src/params.c:1537-1598 (getarg) — the subscript text is scanned
7842    // for `ispecial` characters (`needtok`, c:1562-1563); if there are any
7843    // it is re-lexed as a double-quote-like string (`parsestr`, c:1587)
7844    // and substituted (`singsub`, c:1592), then untokenized before the
7845    // key lookup (c:1598). So only the escapes that lexing honours go:
7846    // `\[k\]` → `[k]`, `\\` → `\`, `\$` → `$`, while `a\b` and `a\"b`
7847    // keep their backslash, and a `$name` in the key expands.
7848    //
7849    // A flag subscript (`(r)pat`, …) keeps the older blanket escape strip:
7850    // its pattern side is outside this fix.
7851    let subscript_owned: Option<String> = subscript.map(|key| {
7852        if !key.starts_with('(') {
7853            // !!! RUST-ONLY: C gets this key from getindex's in-place
7854            // `parse_subscript(s, dq, ']')` (c:2008), which lexes `\` before
7855            // a bracket, paren or brace into Bnull; getarg's loop keeps those
7856            // Bnulls (c:1540-1547) and `remnulargs` (c:1582) deletes them.
7857            // `lex::parse_subscript` returns only the end offset, so drop
7858            // exactly those backslashes here. `\\` stays a pair for parsestr.
7859            let mut k = String::with_capacity(key.len());
7860            let mut it = key.chars().peekable();
7861            while let Some(ch) = it.next() {
7862                if ch == '\\' {
7863                    match it.peek() {
7864                        Some('[' | ']' | '(' | ')' | '{' | '}') => continue,
7865                        Some(&next) => {
7866                            k.push(ch);
7867                            k.push(next);
7868                            it.next();
7869                            continue;
7870                        }
7871                        None => {}
7872                    }
7873                }
7874                k.push(ch);
7875            }
7876            if k.bytes().any(crate::ported::ztype_h::ispecial) {
7877                // c:1562-1563
7878                match crate::ported::lex::parsestr(&k) {
7879                    Ok(t) => k = crate::ported::subst::singsub(&t), // c:1587-1592
7880                    Err(_) => return key.to_string(),               // c:1588 `return 0`
7881                }
7882            }
7883            return crate::ported::lex::untokenize(&k); // c:1598
7884        }
7885        let mut out = String::with_capacity(key.len());
7886        let mut bslash = false;
7887        for ch in key.chars() {
7888            if bslash {
7889                out.push(ch);
7890                bslash = false;
7891            } else if ch == '\\' {
7892                bslash = true;
7893            } else {
7894                out.push(ch);
7895            }
7896        }
7897        if bslash {
7898            out.push('\\');
7899        }
7900        out
7901    });
7902    let subscript: Option<&str> = subscript_owned.as_deref();
7903
7904    // c:Src/params.c — a bare all-digit parameter name (`2=X`,
7905    // `2+=end`) addresses the positional parameter $N (1-based). C maps
7906    // digit names onto the special `argv` value (isident accepts them,
7907    // c:1336-1340); zshrs keeps positionals in PPARAMS, so store there
7908    // directly — the same backing the `argv[N]=`/`@[N]=` path uses
7909    // below. `$0` (n==0) is the shell/script name, not a positional, so
7910    // it falls through to the normal param path. `+=` concatenates onto
7911    // the existing positional (ASSPM_AUGMENT scalar append, c:3270-3276).
7912    if subscript.is_none() && !name.is_empty() && name.bytes().all(|b| b.is_ascii_digit()) {
7913        if let Ok(n) = name.parse::<usize>() {
7914            if n >= 1 {
7915                if let Ok(mut pp) = crate::ported::builtin::PPARAMS.lock() {
7916                    while pp.len() < n {
7917                        pp.push(String::new());
7918                    }
7919                    let idx = n - 1;
7920                    if (flags & ASSPM_AUGMENT) != 0 {
7921                        let cur = pp[idx].clone();
7922                        pp[idx] = format!("{}{}", cur, val);
7923                    } else {
7924                        pp[idx] = val.to_string();
7925                    }
7926                }
7927                unqueue_signals();
7928                return Some(Box::new(param {
7929                    node: hashnode {
7930                        next: None,
7931                        nam: name.to_string(),
7932                        flags: PM_SCALAR as i32,
7933                    },
7934                    u_data: 0,
7935                    u_tied: None,
7936                    u_arr: None,
7937                    u_str: Some(val.to_string()),
7938                    u_val: 0,
7939                    u_dval: 0.0,
7940                    u_hash: None,
7941                    gsu_s: None,
7942                    gsu_i: None,
7943                    gsu_f: None,
7944                    gsu_a: None,
7945                    gsu_h: None,
7946                    base: 0,
7947                    width: 0,
7948                    env: None,
7949                    ename: None,
7950                    old: None,
7951                    level: 0,
7952                }));
7953            }
7954        }
7955    }
7956
7957    // c:Src/Modules/parameter.c — magic associative-array assignment
7958    // forms: `functions[name]=body`, `aliases[name]=value`,
7959    // `dis_functions[name]=body`, `saliases[name]=value`,
7960    // `galiases[name]=value`, `dis_aliases[name]=value`, etc.
7961    // These reach assignsparam as `name[key]=value` shape and must
7962    // dispatch to the canonical setpmfunction / setpmalias hooks
7963    // BEFORE the generic paramtab subscript store. Without this
7964    // route, the assignment lands in paramtab_hashed_storage as
7965    // a normal assoc element and the corresponding function/alias
7966    // is never actually defined in shfunctab/aliastab.
7967    // c:Src/params.c:2264 — `if (!pm ...) return NULL`. `unset
7968    // functions` on a name that is still the `PM_AUTOLOAD` stub removes
7969    // the paramtab node outright (c:3874, see bin_unset), so a later
7970    // `functions[k]=v` no longer finds ANY node: `getvalue` builds a
7971    // fresh `PM_SCALAR|PM_UNSET` param (c:1609) whose `getindex` rejects
7972    // the string subscript with c:2700 "assignment to invalid subscript
7973    // range". Skipping the magic dispatch here routes the assignment
7974    // into that generic path, which already emits exactly that message.
7975    let magic_unbound = |name: &str| -> bool {
7976        crate::ported::modules::parameter::PARTAB
7977            .iter()
7978            .any(|e| e.name == name)
7979            && paramtab().read().map_or(false, |t| t.get(name).is_none())
7980    };
7981    // c:Src/params.c:1135-1144 — createparam's reuse arm: an assignment
7982    // to a name whose node is PM_UNSET but PM_SPECIAL takes the
7983    // `oldpm->node.flags &= ~PM_UNSET` branch and returns NULL, i.e. the
7984    // EXISTING special node is revived and the assignment proceeds
7985    // through it (c:1048 "If the parameter is not created because it
7986    // already exists, the PM_UNSET flag is cleared"). That is how
7987    // `unset functions; functions[gg]=body` re-activates the row after a
7988    // MATERIALIZED unset kept the node (c:3851-3852). zshrs's magic
7989    // dispatch below writes the real table directly and never runs
7990    // createparam, so clear the bit here or every later read stays gated.
7991    if crate::ported::modules::parameter::PARTAB
7992        .iter()
7993        .any(|e| e.name == name)
7994    {
7995        if let Ok(mut tab) = paramtab().write() {
7996            if let Some(pm) = tab.get_mut(name) {
7997                if (pm.node.flags as u32 & PM_SPECIAL) != 0 {
7998                    pm.node.flags &= !(PM_UNSET as i32); // c:1144
7999                }
8000            }
8001        }
8002    }
8003    if let Some(key) = subscript.filter(|_| !magic_unbound(name)) {
8004        // c:1410 + c:3251 + c:3343 — a subscript FLAG GROUP has to be
8005        // resolved before any of the magic per-name writes below, which
8006        // treat the subscript as a plain key. C never gets there: the
8007        // `getvalue(&vbuf, &t, 1)` at c:3251 runs `getindex`/`getarg`
8008        // first, so `aliases[(r)bar]=Z` is a search over the association
8009        // and `assignstrvalue` rejects it (c:2701-2706 / c:2831-2839)
8010        // instead of inventing an alias literally named `(r)bar`.
8011        // Non-search groups ((e)/(w)/(n:N:)/(p)) resolve to the key with
8012        // the group consumed (c:1596-1616) and carry on below.
8013        let key_owned: String;
8014        let key: &str = if (key.starts_with('(') || key.starts_with(crate::ported::zsh_h::Inpar))
8015            && paramtab()
8016                .read()
8017                .ok()
8018                .and_then(|t| {
8019                    t.get(name)
8020                        .map(|pm| PM_TYPE(pm.node.flags as u32) == PM_HASHED)
8021                })
8022                .unwrap_or(false)
8023        {
8024            let pm = paramtab().read().ok().and_then(|t| t.get(name).cloned());
8025            let mut vv = value {
8026                pm,
8027                arr: Vec::new(),
8028                scanflags: SCANPM_ARRONLY as i32, // c:2274-2280
8029                valflags: 0,
8030                start: 0,
8031                end: -1, // c:2279
8032            };
8033            let bracketed = format!("[{}]", key); // c:2281
8034            let mut sp: &str = &bracketed;
8035            if getindex(&mut sp, &mut vv, 0) != 0 {
8036                unqueue_signals(); // c:3220
8037                return None;
8038            }
8039            if vv
8040                .pm
8041                .as_ref()
8042                .map_or(false, |p| PM_TYPE(p.node.flags as u32) == PM_HASHED)
8043            {
8044                // c:3343 — the search left the value on the whole
8045                // association; assignstrvalue raises the diagnostic.
8046                assignstrvalue(Some(&mut vv), Some(val.to_string()), flags);
8047                unqueue_signals();
8048                return None;
8049            }
8050            key_owned = vv
8051                .pm
8052                .as_ref()
8053                .map_or_else(|| key.to_string(), |p| p.node.nam.clone()); // c:1596-1616
8054            &key_owned
8055        } else {
8056            key
8057        };
8058        // c:Src/params.c:3153-3155 — the subscripted branch of
8059        // `assignsparam` NUL-terminates at the `[` and calls
8060        // `getvalue(&vbuf, &s, 1)` on the bare NAME. That resolves the
8061        // node through `getparamnode` → `loadparamnode` (c:563-585),
8062        // clearing PM_AUTOLOAD on the `zsh/parameter` stub. zshrs
8063        // models PM_AUTOLOAD as a side-set (vm_helper's
8064        // MATERIALIZED_MODULE_PARAMS), and the magic dispatch below
8065        // never runs `getvalue`, so the name stayed a "stub" after the
8066        // write: a later `unset "nameddirs[qq]"` /
8067        // `unset "functions[ff]"` then took bin_unset's
8068        // `module_param_is_autoload_stub` arm (builtin.rs:8483,
8069        // C's c:3896-3919 scalar arm → "assignment to invalid
8070        // subscript range") instead of the c:3891-3895 PM_HASHED arm,
8071        // leaving the entry in nameddirtab / shfunctab.
8072        if crate::ported::modules::parameter::PARTAB
8073            .iter()
8074            .any(|e| e.name == name)
8075        {
8076            crate::vm_helper::mark_module_param_used(name); // c:3155 getvalue → c:571 loadparamnode
8077                                                            // c:Src/params.c:3159-3165 — `if (v->pm->node.flags &
8078                                                            // PM_READONLY) { zerr("read-only variable: %s",
8079                                                            // v->pm->node.nam); … return NULL; }`. The check runs on the
8080                                                            // node getvalue just returned, BEFORE any element write, so
8081                                                            // `readonly commands; commands[x]=/y` is rejected in C. The
8082                                                            // magic dispatch below writes the real shell table directly
8083                                                            // and never consulted the node's flags, so a user
8084                                                            // `readonly`-ed magic assoc silently accepted element
8085                                                            // writes. Name-keyed `is_readonly_magic` further down only
8086                                                            // covers the PM_READONLY_SPECIAL rows, not user readonly.
8087            let ro = paramtab()
8088                .read()
8089                .ok()
8090                .and_then(|t| {
8091                    t.get(name)
8092                        .map(|pm| (pm.node.flags as u32 & PM_READONLY) != 0)
8093                })
8094                .unwrap_or(false);
8095            if ro {
8096                zerr(&format!("read-only variable: {}", name)); // c:3160
8097                errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed);
8098                unqueue_signals(); // c:3163
8099                return None; // c:3164
8100            }
8101        }
8102        match name {
8103            "functions" => {
8104                use crate::ported::zsh_h::hashnode;
8105                use crate::ported::zsh_h::param as ParamStruct;
8106                // c:Src/params.c:3270-3276 — ASSPM_AUGMENT on PM_SCALAR
8107                // appends raw text to the existing value. The
8108                // `functions[name]` magic-assoc is scalar-typed under
8109                // the hood (each entry is a function-body string). For
8110                // `functions[f]+="echo b"`, fetch the existing body
8111                // from shfunctab and concatenate before calling
8112                // setpmfunction. Without this, `+=` silently no-ops.
8113                // Bug #323 in docs/BUGS.md.
8114                let final_body = if (flags & ASSPM_AUGMENT) != 0 {
8115                    let existing = crate::ported::hashtable::shfunctab_lock()
8116                        .read()
8117                        .ok()
8118                        .and_then(|tab| tab.get(key).and_then(|shf| shf.body.clone()))
8119                        .unwrap_or_default();
8120                    format!("{}{}", existing, val)
8121                } else {
8122                    val.to_string()
8123                };
8124                let pm: Box<ParamStruct> = Box::new(ParamStruct {
8125                    node: hashnode {
8126                        next: None,
8127                        nam: key.to_string(),
8128                        flags: 0,
8129                    },
8130                    u_data: 0,
8131                    u_tied: None,
8132                    u_arr: None,
8133                    u_str: None,
8134                    u_val: 0,
8135                    u_dval: 0.0,
8136                    u_hash: None,
8137                    gsu_s: None,
8138                    gsu_i: None,
8139                    gsu_f: None,
8140                    gsu_a: None,
8141                    gsu_h: None,
8142                    base: 0,
8143                    width: 0,
8144                    env: None,
8145                    ename: None,
8146                    old: None,
8147                    level: 0,
8148                });
8149                crate::ported::modules::parameter::setpmfunction(pm.clone(), final_body);
8150                unqueue_signals();
8151                return Some(pm);
8152            }
8153            "dis_functions" => {
8154                use crate::ported::zsh_h::hashnode;
8155                use crate::ported::zsh_h::param as ParamStruct;
8156                let pm: Box<ParamStruct> = Box::new(ParamStruct {
8157                    node: hashnode {
8158                        next: None,
8159                        nam: key.to_string(),
8160                        flags: 0,
8161                    },
8162                    u_data: 0,
8163                    u_tied: None,
8164                    u_arr: None,
8165                    u_str: None,
8166                    u_val: 0,
8167                    u_dval: 0.0,
8168                    u_hash: None,
8169                    gsu_s: None,
8170                    gsu_i: None,
8171                    gsu_f: None,
8172                    gsu_a: None,
8173                    gsu_h: None,
8174                    base: 0,
8175                    width: 0,
8176                    env: None,
8177                    ename: None,
8178                    old: None,
8179                    level: 0,
8180                });
8181                crate::ported::modules::parameter::setpmdisfunction(pm.clone(), val.to_string());
8182                unqueue_signals();
8183                return Some(pm);
8184            }
8185            "aliases" => {
8186                // c:Src/Modules/parameter.c — install a regular
8187                // alias via canonical aliastab. Use createaliasnode
8188                // with default flags (no ALIAS_GLOBAL / ALIAS_SUFFIX).
8189                if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
8190                    let node = crate::ported::hashtable::createaliasnode(key, val, 0u32);
8191                    tab.add(node);
8192                }
8193                unqueue_signals();
8194                return Some(Box::new(crate::ported::zsh_h::param {
8195                    node: crate::ported::zsh_h::hashnode {
8196                        next: None,
8197                        nam: key.to_string(),
8198                        flags: 0,
8199                    },
8200                    u_data: 0,
8201                    u_tied: None,
8202                    u_arr: None,
8203                    u_str: Some(val.to_string()),
8204                    u_val: 0,
8205                    u_dval: 0.0,
8206                    u_hash: None,
8207                    gsu_s: None,
8208                    gsu_i: None,
8209                    gsu_f: None,
8210                    gsu_a: None,
8211                    gsu_h: None,
8212                    base: 0,
8213                    width: 0,
8214                    env: None,
8215                    ename: None,
8216                    old: None,
8217                    level: 0,
8218                }));
8219            }
8220            "galiases" => {
8221                // c:Src/Modules/parameter.c — global alias.
8222                if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
8223                    let node = crate::ported::hashtable::createaliasnode(
8224                        key,
8225                        val,
8226                        crate::ported::zsh_h::ALIAS_GLOBAL as u32,
8227                    );
8228                    tab.add(node);
8229                }
8230                unqueue_signals();
8231                return Some(Box::new(crate::ported::zsh_h::param {
8232                    node: crate::ported::zsh_h::hashnode {
8233                        next: None,
8234                        nam: key.to_string(),
8235                        flags: 0,
8236                    },
8237                    u_data: 0,
8238                    u_tied: None,
8239                    u_arr: None,
8240                    u_str: Some(val.to_string()),
8241                    u_val: 0,
8242                    u_dval: 0.0,
8243                    u_hash: None,
8244                    gsu_s: None,
8245                    gsu_i: None,
8246                    gsu_f: None,
8247                    gsu_a: None,
8248                    gsu_h: None,
8249                    base: 0,
8250                    width: 0,
8251                    env: None,
8252                    ename: None,
8253                    old: None,
8254                    level: 0,
8255                }));
8256            }
8257            "saliases" => {
8258                // c:Src/Modules/parameter.c — suffix alias.
8259                if let Ok(mut tab) = crate::ported::hashtable::sufaliastab_lock().write() {
8260                    let node = crate::ported::hashtable::createaliasnode(
8261                        key,
8262                        val,
8263                        crate::ported::zsh_h::ALIAS_SUFFIX as u32,
8264                    );
8265                    tab.add(node);
8266                }
8267                unqueue_signals();
8268                return Some(Box::new(crate::ported::zsh_h::param {
8269                    node: crate::ported::zsh_h::hashnode {
8270                        next: None,
8271                        nam: key.to_string(),
8272                        flags: 0,
8273                    },
8274                    u_data: 0,
8275                    u_tied: None,
8276                    u_arr: None,
8277                    u_str: Some(val.to_string()),
8278                    u_val: 0,
8279                    u_dval: 0.0,
8280                    u_hash: None,
8281                    gsu_s: None,
8282                    gsu_i: None,
8283                    gsu_f: None,
8284                    gsu_a: None,
8285                    gsu_h: None,
8286                    base: 0,
8287                    width: 0,
8288                    env: None,
8289                    ename: None,
8290                    old: None,
8291                    level: 0,
8292                }));
8293            }
8294            "options" => {
8295                // c:Src/Modules/parameter.c:926 setpmoption — userspace
8296                // `options[X]=on|off` toggles option X via dosetopt, not
8297                // a generic assoc-write. Build a synthetic Param whose
8298                // node.nam carries the option name and dispatch to the
8299                // canonical setpmoption port (`src/ported/modules/
8300                // parameter.rs:1620`), which calls optlookup + dosetopt.
8301                use crate::ported::zsh_h::hashnode;
8302                use crate::ported::zsh_h::param as ParamStruct;
8303                let pm: Box<ParamStruct> = Box::new(ParamStruct {
8304                    node: hashnode {
8305                        next: None,
8306                        nam: key.to_string(),
8307                        flags: 0,
8308                    },
8309                    u_data: 0,
8310                    u_tied: None,
8311                    u_arr: None,
8312                    u_str: None,
8313                    u_val: 0,
8314                    u_dval: 0.0,
8315                    u_hash: None,
8316                    gsu_s: None,
8317                    gsu_i: None,
8318                    gsu_f: None,
8319                    gsu_a: None,
8320                    gsu_h: None,
8321                    base: 0,
8322                    width: 0,
8323                    env: None,
8324                    ename: None,
8325                    old: None,
8326                    level: 0,
8327                });
8328                crate::ported::modules::parameter::setpmoption(pm.clone(), val.to_string());
8329                unqueue_signals();
8330                return Some(pm);
8331            }
8332            "commands" => {
8333                // c:Src/Modules/parameter.c:151-160 setpmcommand —
8334                // `commands[name]=path` installs a HASHED Cmdnam
8335                // node in cmdnamtab (`cn->node.flags = HASHED;
8336                // cn->u.cmd = ztrdup(value); cmdnamtab->addnode`),
8337                // exactly like `hash name=path`. zsh ACCEPTS the
8338                // write (verified vs zsh 5.9: rc=0, readback works,
8339                // whence/hash see the entry). Build a synthetic
8340                // Param whose node.nam carries the command name and
8341                // dispatch to the canonical setpmcommand port.
8342                // Bug #375.
8343                use crate::ported::zsh_h::hashnode;
8344                use crate::ported::zsh_h::param as ParamStruct;
8345                let pm: Box<ParamStruct> = Box::new(ParamStruct {
8346                    node: hashnode {
8347                        next: None,
8348                        nam: key.to_string(),
8349                        flags: 0,
8350                    },
8351                    u_data: 0,
8352                    u_tied: None,
8353                    u_arr: None,
8354                    u_str: None,
8355                    u_val: 0,
8356                    u_dval: 0.0,
8357                    u_hash: None,
8358                    gsu_s: None,
8359                    gsu_i: None,
8360                    gsu_f: None,
8361                    gsu_a: None,
8362                    gsu_h: None,
8363                    base: 0,
8364                    width: 0,
8365                    env: None,
8366                    ename: None,
8367                    old: None,
8368                    level: 0,
8369                });
8370                crate::ported::modules::parameter::setpmcommand(pm.clone(), val.to_string());
8371                unqueue_signals();
8372                return Some(pm);
8373            }
8374            "nameddirs" => {
8375                // c:Src/Modules/parameter.c:1534-1545 setpmnameddir —
8376                // `nameddirs[name]=dir` installs a Nameddir node in
8377                // nameddirtab (`nd->node.flags = 0; nd->dir = value;
8378                // nameddirtab->addnode(...)`), exactly like
8379                // `hash -d name=dir`, so `~name` expands afterwards.
8380                // The SPECIALPMDEF for `nameddirs`
8381                // (Src/Modules/parameter.c:2301-2302) carries NO
8382                // PM_READONLY_SPECIAL and its element gsu is
8383                // `pmnamedir_gsu = { strgetfn, setpmnameddir,
8384                // unsetpmnameddir }` (c:1607-1608), i.e. the write is
8385                // legal in C. Verified vs zsh 5.9: `nameddirs[qq]=/tmp`
8386                // → rc=0, `print ~qq` → /tmp, `hash -d` lists qq.
8387                // Dispatch to the canonical setpmnameddir port so the
8388                // write reaches nameddirtab (a generic assoc store
8389                // would leave `~qq` silently broken).
8390                use crate::ported::zsh_h::hashnode;
8391                use crate::ported::zsh_h::param as ParamStruct;
8392                let pm: Box<ParamStruct> = Box::new(ParamStruct {
8393                    node: hashnode {
8394                        next: None,
8395                        nam: key.to_string(),
8396                        flags: 0,
8397                    },
8398                    u_data: 0,
8399                    u_tied: None,
8400                    u_arr: None,
8401                    u_str: None,
8402                    u_val: 0,
8403                    u_dval: 0.0,
8404                    u_hash: None,
8405                    gsu_s: None,
8406                    gsu_i: None,
8407                    gsu_f: None,
8408                    gsu_a: None,
8409                    gsu_h: None,
8410                    base: 0,
8411                    width: 0,
8412                    env: None,
8413                    ename: None,
8414                    old: None,
8415                    level: 0,
8416                });
8417                crate::ported::modules::parameter::setpmnameddir(pm.clone(), val.to_string());
8418                unqueue_signals();
8419                return Some(pm);
8420            }
8421            "mapfile" => {
8422                // c:Src/Modules/mapfile.c:68 setpmmapfile —
8423                // `mapfile[fname]=value` WRITES `value` to the file
8424                // named by the key (creating it if needed). The setfn
8425                // is ported; route the element-assign to it (the
8426                // PartabHashEntry dispatch carries only get/scan).
8427                use crate::ported::zsh_h::hashnode;
8428                use crate::ported::zsh_h::param as ParamStruct;
8429                crate::ported::modules::mapfile::setpmmapfile(key, val, false);
8430                let pm: Box<ParamStruct> = Box::new(ParamStruct {
8431                    node: hashnode {
8432                        next: None,
8433                        nam: key.to_string(),
8434                        flags: 0,
8435                    },
8436                    u_data: 0,
8437                    u_tied: None,
8438                    u_arr: None,
8439                    u_str: None,
8440                    u_val: 0,
8441                    u_dval: 0.0,
8442                    u_hash: None,
8443                    gsu_s: None,
8444                    gsu_i: None,
8445                    gsu_f: None,
8446                    gsu_a: None,
8447                    gsu_h: None,
8448                    base: 0,
8449                    width: 0,
8450                    env: None,
8451                    ename: None,
8452                    old: None,
8453                    level: 0,
8454                });
8455                unqueue_signals();
8456                return Some(pm);
8457            }
8458            _ => {}
8459        }
8460    }
8461
8462    // c:Src/params.c:3262 IPDEF9 — `argv`, `@`, `*` are aliases for
8463    // the global `pparams` (positional parameter vector). Whole-
8464    // array writes (`argv=(...)`) route through assignaparam at
8465    // params.rs:5487 which updates PPARAMS. Subscripted writes
8466    // (`argv[N]=val`) must do the same — without this, the value
8467    // landed in paramtab's u_arr but `$@`/`$1`/`$2`/... continued
8468    // reading from PPARAMS, so the update was invisible. Bug #281
8469    // in docs/BUGS.md.
8470    if let Some(key) = subscript {
8471        if matches!(name, "argv" | "@" | "*") {
8472            if let Ok(idx) = key.trim().parse::<i64>() {
8473                if let Ok(mut pp) = crate::ported::builtin::PPARAMS.lock() {
8474                    let len = pp.len() as i64;
8475                    let kshzero =
8476                        crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHZEROSUBSCRIPT);
8477                    if idx == 0 && !isset(KSHARRAYS) && !kshzero {
8478                        drop(pp); // zerr redraws ZLE; release the lock first
8479                        zerr(&format!("{}: assignment to invalid subscript range", name));
8480                        unqueue_signals();
8481                        return None;
8482                    }
8483                    let real_idx = if idx < 0 {
8484                        (len + idx).max(0) as usize
8485                    } else if isset(KSHARRAYS) {
8486                        idx.max(0) as usize
8487                    } else {
8488                        (idx - 1).max(0) as usize
8489                    };
8490                    while pp.len() <= real_idx {
8491                        pp.push(String::new());
8492                    }
8493                    pp[real_idx] = val.to_string();
8494                }
8495                unqueue_signals();
8496                return Some(Box::new(param {
8497                    node: hashnode {
8498                        next: None,
8499                        nam: name.to_string(),
8500                        flags: PM_ARRAY as i32,
8501                    },
8502                    u_data: 0,
8503                    u_tied: None,
8504                    u_arr: None,
8505                    u_str: None,
8506                    u_val: 0,
8507                    u_dval: 0.0,
8508                    u_hash: None,
8509                    gsu_s: None,
8510                    gsu_i: None,
8511                    gsu_f: None,
8512                    gsu_a: None,
8513                    gsu_h: None,
8514                    base: 0,
8515                    width: 0,
8516                    env: None,
8517                    ename: None,
8518                    old: None,
8519                    level: 0,
8520                }));
8521            }
8522        }
8523    }
8524
8525    // c:Src/Modules/parameter.c — read-only magic-assoc names. C
8526    // zsh's SPECIALPMDEF / createspecialhash sets PM_READONLY on
8527    // these so userspace assignment errors with `read-only
8528    // variable: NAME`. `vm_helper::init_partab_params` strips
8529    // PM_READONLY off the paramtab stub to allow INTERNAL writes
8530    // (function-call funcstack push, etc.) — see comment at
8531    // vm_helper.rs:2799. The trade-off is that USERSPACE writes
8532    // now slip through too. Intercept here, AFTER the writable-
8533    // magic-assoc dispatch arms above (functions, aliases,
8534    // dis_aliases, galiases, saliases, dis_functions) but BEFORE
8535    // the generic paramtab subscript store, so the userspace path
8536    // emits the canonical diagnostic and exits non-zero. Internal
8537    // table mutations that bypass `assignsparam` stay free. Bug
8538    // #242 in docs/BUGS.md.
8539    // c:Src/params.c:2688-2689 — `if (v->pm->node.flags & PM_READONLY)
8540    // zerr("read-only variable: %s", ...)` reads the flag off the NODE
8541    // `fetchvalue` returned. After `unset parameters` there is no node
8542    // (c:3874, see bin_unset), so C never reaches that test: `getindex`
8543    // rejects the string subscript on the fresh `PM_SCALAR|PM_UNSET`
8544    // param first (c:2700 "assignment to invalid subscript range").
8545    // Keep this name-keyed stand-in in step with the node's existence.
8546    if subscript.is_some() && !magic_unbound(name) {
8547        // `options` is intentionally NOT in this list — C zsh's
8548        // `setpmoption`/`setpmoptions` (Src/Modules/parameter.c:926-979)
8549        // accept "on"/"off" writes and translate them to dosetopt calls.
8550        // The `options` arm above this readonly-list check routes
8551        // `options[X]=on|off` writes through the canonical setpmoption
8552        // port, so by the time we reach here `options` has already
8553        // returned. Bug #342.
8554        // `commands` is intentionally NOT in this list — C zsh's
8555        // `setpmcommand` (Src/Modules/parameter.c:151-160) ACCEPTS
8556        // `commands[name]=path` and installs a HASHED Cmdnam node in
8557        // cmdnamtab (same effect as `hash name=path`). Verified vs
8558        // zsh 5.9: `commands[x]=/y` → rc=0, ${commands[x]} → /y,
8559        // `whence x` → /y. The `commands` arm above routes through
8560        // the canonical setpmcommand port. Bug #375.
8561        // `nameddirs` is intentionally NOT in this list — its
8562        // SPECIALPMDEF (Src/Modules/parameter.c:2301-2302) has no
8563        // PM_READONLY_SPECIAL and its element gsu (c:1607-1608
8564        // `pmnamedir_gsu = { strgetfn, setpmnameddir,
8565        // unsetpmnameddir }`) accepts the write, installing a
8566        // nameddirtab node. Verified vs zsh 5.9: `nameddirs[qq]=/tmp`
8567        // → rc=0, `print ~qq` → /tmp. The `nameddirs` arm above routes
8568        // through the canonical setpmnameddir port. `userdirs` STAYS
8569        // read-only: c:1670/1694 give it `PM_READONLY` +
8570        // `nullsetscalar_gsu`.
8571        let is_readonly_magic = matches!(
8572            name,
8573            "builtins"
8574                | "modules"
8575                | "parameters"
8576                | "dis_builtins"
8577                | "history"
8578                | "historywords"
8579                | "jobtexts"
8580                | "jobstates"
8581                | "jobdirs"
8582                | "userdirs"
8583                | "usergroups"
8584                | "widgets"
8585                | "functions_source"
8586                | "dis_functions_source"
8587                | "terminfo"
8588                | "termcap"
8589        );
8590        if is_readonly_magic {
8591            zerr(&format!("read-only variable: {}", name));
8592            errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed);
8593            unqueue_signals();
8594            return None;
8595        }
8596    }
8597
8598    // Subscripted path (c:3210-3231).
8599    if let Some(key) = subscript {
8600        // c:params.c:1601 (getarg) — a non-hashed parameter's subscript is an
8601        // ARITHMETIC expression. Resolve it to a numeric index HERE, before
8602        // the paramtab write lock below, because mathevalarg reads parameters
8603        // and would deadlock against a held write lock. Associative arrays use
8604        // the raw string key instead, so only pre-evaluate for non-hashed.
8605        let is_hashed = {
8606            let flagged = {
8607                let tab = paramtab().read().unwrap();
8608                tab.get(name)
8609                    .map(|pm| (pm.node.flags as u32 & PM_HASHED) != 0)
8610                    .unwrap_or(false)
8611            };
8612            // c:Src/Zle/complete.c:1340 — `$compstate` is created PM_HASHED by
8613            // makecompparams for the duration of a completion function, so the
8614            // node itself answers this test. zshrs keeps an association's
8615            // pairs in the side store `paramtab_hashed_storage()`; a name with a
8616            // row there is an association too. A bare `compstate` name is NOT:
8617            // outside completion zsh has no such parameter and
8618            // `compstate[list]=list` takes the createparam(PM_ARRAY) path below
8619            // (c:3061) and errors "assignment to invalid subscript range".
8620            flagged
8621                || paramtab_hashed_storage()
8622                    .lock()
8623                    .map(|s| s.contains_key(name))
8624                    .unwrap_or(false)
8625        };
8626        // c:Src/params.c:2048-2053 — getindex's FIRST test on a subscript body:
8627        //     if ((s[0] == '*' || s[0] == '@') && s + 1 == tbrack) {
8628        //         if ((v->scanflags || IS_UNSET_VALUE(v)) && s[0] == '@')
8629        //             v->scanflags |= SCANPM_ISVAR_AT;
8630        //         v->start = 0;
8631        //         v->end = -1;
8632        //         s += 2;
8633        //     } else { … start = getarg(&s, …); … }
8634        // A bare `@` / `*` subscript names the WHOLE value, and `getarg` — the
8635        // only place the subscript is arithmetic-evaluated — is never reached.
8636        // The port had no such arm, so the subscript fell through to
8637        // `mathevalarg`, which answered `bad math expression: operand expected
8638        // at `@'` and aborted the whole expansion: `e=(); print "${e[@]:=}"`
8639        // errored where zsh assigns `('')` and prints one empty word (paramsubst
8640        // reaches this through c:Src/subst.c:3313 `setsparam(idbeg, …)`, whose
8641        // `idbeg` still carries the `[@]`).
8642        let is_whole_value = key == "@" || key == "*"; // c:2048
8643        let resolved_idx: i64 = if is_hashed || is_whole_value {
8644            0 // unused: hashed params key by name, `[@]`/`[*]` by start/end
8645        } else {
8646            key.parse::<i64>().unwrap_or_else(|_| {
8647                // c:Src/params.c:2058 getarg → c:1585-1593 — a subscript that
8648                // is not a plain literal is parameter-expanded (singsub) BEFORE
8649                // arithmetic evaluation. When assignsparam is reached with a
8650                // raw, UNEXPANDED subscript — e.g. `setsparam("pgid[$#pgid+1]",
8651                // …)` from the sysread/read builtins, whose target string the
8652                // command compiler never pre-expanded — `$#pgid` / `$n` /
8653                // `${#a}` must be resolved here, or mathevalarg sees a literal
8654                // `$` and yields 0 → "invalid subscript range" (gitstatus's
8655                // `_gitstatus_daemon_p9k_` hit this on every prompt). The
8656                // shell-level `a[$n+1]=v` form already arrives expanded, so its
8657                // numeric key takes the parse fast-path above and skips this.
8658                let expanded = crate::ported::subst::singsub(&key);
8659                crate::ported::math::mathevalarg(&expanded)
8660            })
8661        };
8662        let mut tab = paramtab().write().unwrap();
8663        let exists = tab.contains_key(name); // c:3212
8664        if !exists {
8665            // c:3213 `createparam(t, PM_ARRAY); created = 1;` — but when the
8666            // name is a hash-storage-backed associative (e.g. `$compstate`),
8667            // create it PM_HASHED so the dispatch below routes to the assoc
8668            // element store instead of the numeric-subscript array path.
8669            let (create_flags, create_arr) = if is_hashed {
8670                (PM_HASHED as i32, None)
8671            } else {
8672                (PM_ARRAY as i32, Some(Vec::new()))
8673            };
8674            let pm: Param = Box::new(param {
8675                node: hashnode {
8676                    next: None,
8677                    nam: name.to_string(),
8678                    flags: create_flags,
8679                },
8680                u_data: 0,
8681                u_tied: None,
8682                u_arr: create_arr,
8683                u_str: None,
8684                u_val: 0,
8685                u_dval: 0.0,
8686                u_hash: None,
8687                gsu_s: None,
8688                gsu_i: None,
8689                gsu_f: None,
8690                gsu_a: None,
8691                gsu_h: None,
8692                base: 0,
8693                width: 0,
8694                env: None,
8695                ename: None,
8696                old: None,
8697                level: 0,
8698            });
8699            tab.insert(name.to_string(), pm);
8700        } else {
8701            // c:3216 `if (v->pm->node.flags & PM_READONLY)`.
8702            let pm = tab.get(name).unwrap();
8703            if (pm.node.flags as u32 & PM_READONLY) != 0 {
8704                let nam = pm.node.nam.clone();
8705                // Release the write guard before zerr: zerr -> trashzle ->
8706                // zrefresh reads paramtab, and std RwLock is not reentrant.
8707                drop(tab);
8708                zerr(&format!("read-only variable: {}", nam)); // c:3217
8709                unqueue_signals(); // c:3220
8710                return None; // c:3221
8711            }
8712        }
8713        // c:3231 `v = NULL;` — re-dispatch by storage type.
8714        let pm = tab.get_mut(name).unwrap();
8715        // c:3171 `v->pm->node.flags &= ~PM_DEFAULTED;` — see the
8716        // PM_SPECIAL note on the same clear at c:3211 below.
8717        let keep_dontimport =
8718            (pm.node.flags as u32 & (PM_SPECIAL | PM_DONTIMPORT)) == (PM_SPECIAL | PM_DONTIMPORT);
8719        pm.node.flags &= !(PM_DEFAULTED as i32); // c:3171
8720        if keep_dontimport {
8721            pm.node.flags |= PM_DONTIMPORT as i32;
8722        }
8723        if is_whole_value {
8724            // c:2051-2052 left `v->start = 0, v->end = -1`, so the write is
8725            // c:Src/params.c:2700-2706 / :2930-2946 `assignstrvalue` on the
8726            // WHOLE value rather than on one element.
8727            match PM_TYPE(pm.node.flags as u32) {
8728                // c:2700-2706 — `if (PM_TYPE(v->pm->node.flags) == PM_HASHED) {
8729                // zerr("%s: attempt to set slice of associative array", …); … }`
8730                x if x == PM_HASHED => {
8731                    let nam = pm.node.nam.clone();
8732                    drop(tab); // zerr redraws ZLE, which reads paramtab
8733                    zerr(&format!("{}: attempt to set slice of associative array", nam)); // c:2701
8734                    errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed);
8735                    unqueue_signals();
8736                    return None;
8737                }
8738                // c:2721-2733 — a scalar's whole-value slice is the whole
8739                // string, so the param stays a SCALAR: `s=abc; s[@]=X` leaves
8740                // `${(t)s}` as `scalar` with the value `X`.
8741                x if x == PM_SCALAR => {
8742                    pm.u_str = Some(val.to_string());
8743                    pm.u_arr = None;
8744                }
8745                // c:2930-2946 `setarrvalue` with start 0 / end -1 replaces the
8746                // array with the assigned value: `e=(); e[@]=v` is `(v)`, one
8747                // element, and an UNSET name was created PM_ARRAY at c:3156.
8748                _ => {
8749                    pm.u_arr = Some(vec![val.to_string()]);
8750                    pm.u_str = None;
8751                }
8752            }
8753        } else if (pm.node.flags as u32 & PM_HASHED) != 0 {
8754            // c:3251 + c:3343 — `getvalue(&vbuf, &t, 1)` re-reads the
8755            // FULL `name[subscript]` text so `getindex` can resolve the
8756            // subscript, then `assignstrvalue` performs the write and
8757            // raises the association-specific diagnostics. This arm used
8758            // to skip both and store the subscript text verbatim, so
8759            // `aliases[(r)bar]=Z` silently invented a key named `(r)bar`
8760            // where zsh reports a slice assignment.
8761            //
8762            // c:1410 — `if (v->pm && (*s == '(' || *s == Inpar))` is the
8763            // only thing that needs the SOURCE spelling, and on this path
8764            // `key` still is it (callers hand assignsparam the unexpanded
8765            // subscript). Without a flag group there is nothing for
8766            // `getindex` to do beyond the c:1596-1616 exact-key rebind,
8767            // so the key goes straight to the member store.
8768            if key.starts_with('(') || key.starts_with(crate::ported::zsh_h::Inpar) {
8769                let mut vv = value {
8770                    pm: Some(pm.clone()),
8771                    arr: Vec::new(),
8772                    // c:2274-2280 — no caller flags → SCANPM_ARRONLY.
8773                    scanflags: SCANPM_ARRONLY as i32,
8774                    valflags: 0,
8775                    start: 0,
8776                    end: -1, // c:2279
8777                };
8778                drop(tab);
8779                let bracketed = format!("[{}]", key); // c:2281
8780                let mut sp: &str = &bracketed;
8781                if getindex(&mut sp, &mut vv, 0) != 0 {
8782                    unqueue_signals(); // c:3220
8783                    return None;
8784                }
8785                let elem_is_hash = vv
8786                    .pm
8787                    .as_ref()
8788                    .map_or(false, |p| PM_TYPE(p.node.flags as u32) == PM_HASHED);
8789                if elem_is_hash {
8790                    // c:3343 — the search left the value on the whole
8791                    // association; assignstrvalue raises c:2701 / c:2831.
8792                    assignstrvalue(Some(&mut vv), Some(val.to_string()), flags);
8793                    unqueue_signals();
8794                    return None;
8795                }
8796                // c:1596-1616 — non-search group: the rebound element's
8797                // name is the key with the group consumed.
8798                let ekey = vv
8799                    .pm
8800                    .as_ref()
8801                    .map_or_else(|| key.to_string(), |p| p.node.nam.clone());
8802                let mut store = paramtab_hashed_storage().lock().unwrap();
8803                store
8804                    .entry(name.to_string())
8805                    .or_default()
8806                    .insert(ekey, val.to_string()); // c:2841
8807                unqueue_signals();
8808                return paramtab().read().ok().and_then(|t| t.get(name).cloned());
8809            }
8810            // PM_HASHED element store. `param.u_hash` is typed
8811            // `Option<HashTable>` per Src/zsh.h:1841 but the
8812            // HashTable runtime backing isn't wired; the assoc-array
8813            // values live in a parallel storage keyed on param name
8814            // (`paramtab_hashed_storage()`).
8815            let mut store = paramtab_hashed_storage().lock().unwrap();
8816            store
8817                .entry(name.to_string())
8818                .or_default()
8819                .insert(key.to_string(), val.to_string()); // c:2841
8820        } else {
8821            // Non-hashed param: subscript already arithmetic-resolved above
8822            // (c:params.c:1601). zsh never auto-creates an assoc here, so
8823            // `as[k1]=bb` on undeclared `as` uses idx 0 → invalid range.
8824            let idx = resolved_idx;
8825            // c:Src/params.c:2748-2789 — PM_SCALAR + numeric subscript
8826            // SPLICES the value into the scalar's char string
8827            // (`a=hello; a[2]=X` → `hXllo`). Only PM_ARRAY does
8828            // element-store at this subscript. Bug #589 in
8829            // docs/BUGS.md: zshrs's subscript store always treated
8830            // numeric subscript as array-store, so `a[2]=X` on a
8831            // scalar cleared `u_str` and put "X" at array slot 1,
8832            // leaving `$a` displaying as `" X"` (empty + space + X)
8833            // when joined.
8834            let pm_type = PM_TYPE(pm.node.flags as u32);
8835            if pm_type == PM_SCALAR {
8836                // c:2748+ scalar splice — replace chars at index.
8837                let s = pm.u_str.clone().unwrap_or_default();
8838                let chars: Vec<char> = s.chars().collect();
8839                let len = chars.len() as i64;
8840                let kshzero = crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHZEROSUBSCRIPT);
8841                if idx == 0 && !isset(KSHARRAYS) && !kshzero {
8842                    drop(tab); // zerr redraws ZLE, which reads paramtab
8843                    zerr(&format!("{}: assignment to invalid subscript range", name));
8844                    unqueue_signals();
8845                    return None;
8846                }
8847                // 1-based forward, negative-from-end. KSHARRAYS = 0-based.
8848                // c:Src/params.c:2134 — under KSHZEROSUBSCRIPT a `[0]`
8849                // subscript "is treated as accessing the first element", so it
8850                // must land on real index 0, NOT on `0 - 1 = -1`. Falling into
8851                // the negative branch made `s[0]=X` INSERT at the front
8852                // ("Xhello") instead of replacing the first character
8853                // ("Xello"). Only the zero case shifts: `[1]` stays 1-based
8854                // (`idx - 1`), and KSHARRAYS is already 0-based above.
8855                let raw = if idx < 0 {
8856                    len + idx
8857                } else if isset(KSHARRAYS) {
8858                    idx
8859                } else if idx == 0 && kshzero {
8860                    0
8861                } else {
8862                    idx - 1
8863                };
8864                let mut out = String::with_capacity(s.len() + val.len());
8865                if raw < 0 {
8866                    // c:Src/params.c:2721-2733 (assignstrvalue) — a negative
8867                    // subscript that points PAST the start of the string
8868                    // (`a[-4]` on "abc") clamps BOTH start and end to 0, so
8869                    // the slice old[0..0] is empty and val is INSERTED at the
8870                    // front (overwriting nothing): `a[-4]="x"` → "xabc". The
8871                    // prior port clamped to index 0 and OVERWROTE char 0.
8872                    out.push_str(val);
8873                    out.extend(chars.iter());
8874                } else {
8875                    // Replace one char at real_idx with val. If real_idx is
8876                    // past the end, append (C clamps end to zlen and concats).
8877                    let real_idx = (raw as usize).min(chars.len());
8878                    out.extend(chars[..real_idx].iter());
8879                    out.push_str(val);
8880                    if real_idx < chars.len() {
8881                        out.extend(chars[real_idx + 1..].iter());
8882                    }
8883                }
8884                pm.u_str = Some(out);
8885            } else {
8886                // PM_ARRAY + numeric subscript (c:3357 `assignaparam`).
8887                // c:Src/params.c:2125-2150 + c:2911 — `a[0]=val` under
8888                // default zsh semantics (no KSHARRAYS, no KSHZEROSUBSCRIPT)
8889                // produces VALFLAG_EMPTY in getarg, which setarrvalue
8890                // then rejects with "assignment to invalid subscript
8891                // range".
8892                let kshzero = crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHZEROSUBSCRIPT);
8893                if idx == 0 && !isset(KSHARRAYS) && !kshzero {
8894                    drop(tab); // zerr redraws ZLE, which reads paramtab
8895                    zerr(&format!("{}: assignment to invalid subscript range", name)); // c:2911 (effective)
8896                    unqueue_signals();
8897                    return None;
8898                }
8899                // c:2966-2967 — capture PM_UNIQUE before the mutable
8900                // borrow so the post-splice dedup below can fire.
8901                let uniq = (pm.node.flags as u32 & PM_UNIQUE) != 0;
8902                let arr = pm.u_arr.get_or_insert_with(Vec::new);
8903                let len = arr.len() as i64;
8904                // c:Src/params.c:2134 — same KSHZEROSUBSCRIPT rule as the
8905                // scalar splice above: `[0]` accesses the FIRST element, so it
8906                // maps to real index 0. Computing `idx - 1 = -1` sent it down
8907                // the negative-subscript branch, which PREPENDS: `a[0]=Z` on
8908                // (1 2 3) gave (Z 1 2 3) where zsh replaces element one and
8909                // gives (Z 2 3).
8910                let real_idx = if idx < 0 {
8911                    len + idx
8912                } else if isset(KSHARRAYS) {
8913                    idx
8914                } else if idx == 0 && kshzero {
8915                    0
8916                } else {
8917                    idx - 1
8918                };
8919                if real_idx < 0 {
8920                    // c:Src/params.c:2930-2946 (setarrvalue) — a negative
8921                    // subscript that points PAST the start of the array
8922                    // (`a[-7]` on a 5-element array) clamps BOTH start and
8923                    // end to 0, so the splice `old[start..end]` =
8924                    // `old[0..0]` replaces nothing and the value is
8925                    // INSERTED at the front. The prior port clamped to
8926                    // index 0 and OVERWROTE element 0 (`42 2 3 4 5`);
8927                    // zsh prepends keeping every element (`42 1 2 3 4 5`).
8928                    arr.insert(0, val.to_string());
8929                } else {
8930                    let real_idx = real_idx as usize;
8931                    while arr.len() <= real_idx {
8932                        arr.push(String::new());
8933                    }
8934                    arr[real_idx] = val.to_string();
8935                }
8936                // c:2966-2967 — `if (pm->node.flags & PM_UNIQUE)
8937                // arrunique(pm->u.arr)`. A subscript element assignment to a
8938                // PM_UNIQUE array dedupes in-place (first occurrence wins):
8939                // `typeset -aU u=(a b c); u[2]=c` → (a c). setarrvalue
8940                // already does this at its tail; the single-index element
8941                // path bypassed it.
8942                if uniq {
8943                    let mut seen: std::collections::HashSet<String> =
8944                        std::collections::HashSet::new();
8945                    arr.retain(|s| seen.insert(s.clone())); // c:2967
8946                }
8947                pm.u_str = None;
8948            }
8949        }
8950        let cloned = pm.clone();
8951        drop(tab);
8952        unqueue_signals(); // c:3344
8953                           // c:Src/params.c:2922 — `pm->gsu.a->setfn(pm, val)`. In C a
8954                           // SUBSCRIPT assignment (`path[2]=x`) rebuilds the whole array
8955                           // and hands it to the param's array setfn, so a tied
8956                           // colon-array (path→PATH, fpath→FPATH, …) re-derives its
8957                           // scalar/env side. zshrs's element path above wrote `u_arr`
8958                           // directly and skipped the setfn, so `path[2]=/NEW` left
8959                           // $PATH stale (`echo $PATH` showed the pre-edit value). Mirror
8960                           // `arrsetfn`'s ename sync here. zshrs carries the tie via the
8961                           // explicit colon-array name map (mirror of the scalar→array
8962                           // cascade at params.rs:~6396); rejoin the array with `:` and
8963                           // write the scalar side so `echo $PATH` sees the edit.
8964                           // c:Src/params.c:2922 — a tied colon-array element assignment
8965                           // re-derives the scalar side (path→PATH etc.); see
8966                           // TIED_COLON_ARRAYS. The direct-u_arr element path above skipped it.
8967        let base = name.split('[').next().unwrap_or(name);
8968        if let Some((_, scalar)) = TIED_COLON_ARRAYS.iter().find(|(a, _)| *a == base) {
8969            let joined = cloned.u_arr.as_deref().unwrap_or(&[]).join(":");
8970            assignsparam(scalar, &joined, 0);
8971        }
8972        return Some(cloned); // c:3345
8973    }
8974
8975    // c:3232 non-subscripted branch.
8976    let mut tab = paramtab().write().unwrap();
8977    // c:3233 — `if (!(v = fetchvalue(&vbuf, &t, 1, SCANPM_ASSIGNING)))
8978    //             { createparam(t, PM_SCALAR); created = 1; }`.
8979    // `fetchvalue` is not a plain table lookup: c:2264-2265 —
8980    //     if (pm->node.flags & PM_UNSET && !(pm->node.flags & PM_DECLARED))
8981    //         return NULL;
8982    // — so a node left `PM_UNSET` with `PM_DECLARED` cleared reads as
8983    // ABSENT here and takes the createparam arm, which reuses the same
8984    // struct (c:1155 `pm = oldpm`) but OVERWRITES its flags
8985    // (c:1176 `pm->node.flags = flags & ~PM_LOCAL;`). That is how
8986    // `typeset -n ref; unset -n ref; ref=str` produces an ordinary
8987    // scalar: `unsetparam_pm` clears PM_DECLARED (c:3798 "like ksh, not
8988    // like bash") and keeps the local node (c:3851), and this lookup
8989    // then drops the stale PM_NAMEREF along with the rest of the flags.
8990    //
8991    // Applied to every node type, as in C. `typeset_single` keeps its
8992    // attributes because it reaches here only after c:Src/builtin.c:2521
8993    // `createparam(pname, on & ~PM_READONLY)` re-typed the unset struct
8994    // with the full requested mask (which clears PM_UNSET); a bare
8995    // `x=val` after `integer x; unset x` gets a plain scalar, which is
8996    // also what exec.c:2619 GLOB_ASSIGN relies on (`unsetparam(name)` "to
8997    // force it to be recreated as either scalar or array").
8998    let existing = match tab.get_mut(name) {
8999        None => false,
9000        Some(pm)
9001            if (pm.node.flags as u32 & PM_RO_BY_DESIGN) != 0
9002                && crate::ported::modules::param_private::getprivatenode(&**pm as *const param)
9003                    .is_null() =>
9004        {
9005            // c:2233-2235 — fetchvalue's `paramtab->getnode` is getprivatenode
9006            // (Src/Modules/param_private.c:678), which hides a private of an
9007            // enclosing scope, so c:3234 createparam runs and its c:1045-1052
9008            // RO_BY_DESIGN check (getnode2 is NULL too) rejects the write.
9009            drop(tab); // zerr redraws ZLE, which reads paramtab
9010            zerr(&format!("{}: can't modify read-only parameter", name)); // c:1049
9011            unqueue_signals(); // c:3241
9012            return None; // c:3242
9013        }
9014        Some(pm) => {
9015            let f = pm.node.flags as u32;
9016            if (f & PM_UNSET) == 0 || (f & PM_DECLARED) != 0 {
9017                true // c:2264-2265 — fetchvalue finds a set/declared node
9018            } else if isset(crate::ported::zsh_h::POSIXBUILTINS) && (f & PM_READONLY) != 0 {
9019                // c:1131-1133 — createparam's reuse arm (always taken here:
9020                // the PM_SCALAR flags carry no PM_LOCAL) rejects a read-only
9021                // node first: `setopt posixbuiltins; readonly v; v=1`.
9022                drop(tab); // zerr redraws ZLE, which reads paramtab
9023                zerr(&format!("read-only variable: {}", name)); // c:1132
9024                unqueue_signals(); // c:3241
9025                return None; // c:3242
9026            } else if (f & PM_SPECIAL) != 0
9027                || (isset(crate::ported::zsh_h::POSIXBUILTINS) && (f & PM_EXPORTED) != 0)
9028            {
9029                // c:1135-1150 — createparam declines to replace an unset
9030                // SPECIAL (or, under POSIXBUILTINS, exported) node: it
9031                // clears PM_UNSET and returns NULL, and c:3240's second
9032                // getvalue then finds the revived node.
9033                if (f & PM_RO_BY_DESIGN) != 0 {
9034                    drop(tab); // zerr redraws ZLE, which reads paramtab
9035                    zerr(&format!("{}: can't modify read-only parameter", name)); // c:1140
9036                    unqueue_signals(); // c:3241
9037                    return None; // c:3242
9038                }
9039                pm.node.flags &= !(PM_UNSET as i32); // c:1144
9040                true
9041            } else {
9042                false // c:3234 — reuse arm overwrites the flags (c:1176)
9043            }
9044        }
9045    };
9046    let created_now = !existing; // c:3232 createparam path sets `created = 1`
9047    if !existing {
9048        // c:3234 `createparam(t, PM_SCALAR); created = 1;`
9049        let mut pm_flags = PM_SCALAR as i32;
9050        if isset(ALLEXPORT) {
9051            // c:1149-1150 (ALLEXPORT path)
9052            pm_flags |= PM_EXPORTED as i32;
9053        }
9054        // c:1135-1160 — `createparam` installs gsu via the special-
9055        // params table when the name matches a PM_SPECIAL entry. C
9056        // walks `paramtab->getnode(name)` first, but for fresh
9057        // creations the gsu pointer comes from the IPDEF macro the
9058        // paramdef ships with. Mirror by looking up the name in the
9059        // special-scalar GSU table — same end-state as if
9060        // createparamtable had run.
9061        let gsu_s: Option<Box<gsu_scalar>> = match name {
9062            "0" => {
9063                pm_flags |= PM_SPECIAL as i32;
9064                Some(Box::new(ARGZERO_GSU.clone())) // c:225-226 / IPDEF2("0", argzero_gsu, 0)
9065            }
9066            "HOME" => {
9067                pm_flags |= PM_SPECIAL as i32;
9068                Some(Box::new(HOME_GSU.clone())) // c:248
9069            }
9070            "IFS" => {
9071                pm_flags |= PM_SPECIAL as i32;
9072                Some(Box::new(IFS_GSU.clone())) // c:245
9073            }
9074            "TERM" => {
9075                pm_flags |= PM_SPECIAL as i32;
9076                Some(Box::new(TERM_GSU.clone())) // c:250
9077            }
9078            "TERMINFO" => {
9079                pm_flags |= PM_SPECIAL as i32;
9080                Some(Box::new(TERMINFO_GSU.clone())) // c:251
9081            }
9082            "TERMINFO_DIRS" => {
9083                pm_flags |= PM_SPECIAL as i32;
9084                Some(Box::new(TERMINFODIRS_GSU.clone())) // c:252
9085            }
9086            "WORDCHARS" => {
9087                pm_flags |= PM_SPECIAL as i32;
9088                Some(Box::new(WORDCHARS_GSU.clone())) // c:249
9089            }
9090            "USERNAME" => {
9091                pm_flags |= PM_SPECIAL as i32;
9092                Some(Box::new(USERNAME_GSU.clone())) // c:247
9093            }
9094            "KEYBOARD_HACK" => {
9095                pm_flags |= PM_SPECIAL as i32;
9096                Some(Box::new(KEYBOARDHACK_GSU.clone())) // c:253
9097            }
9098            "HISTCHARS" | "histchars" => {
9099                pm_flags |= PM_SPECIAL as i32;
9100                Some(Box::new(HISTCHARS_GSU.clone())) // c:246
9101            }
9102            "LANG" => {
9103                pm_flags |= PM_SPECIAL as i32;
9104                Some(Box::new(LANG_GSU.clone())) // c:332
9105            }
9106            "LC_ALL" => {
9107                pm_flags |= PM_SPECIAL as i32;
9108                Some(Box::new(LC_ALL_GSU.clone())) // c:333
9109            }
9110            "LC_COLLATE" | "LC_CTYPE" | "LC_MESSAGES" | "LC_NUMERIC" | "LC_TIME" => {
9111                pm_flags |= PM_SPECIAL as i32;
9112                Some(Box::new(LC_BLAH_GSU.clone())) // c:335-346
9113            }
9114            _ => None,
9115        };
9116        // c:1152-1155 — createparam's REUSE arm:
9117        //     if (oldpm && (oldpm->level == locallevel ||
9118        //                   !(flags & PM_LOCAL))) {
9119        //         ...
9120        //         pm = oldpm;
9121        //         pm->base = pm->width = 0;
9122        // C keeps the SAME struct, so `pm->level` and the `pm->old`
9123        // shadow chain survive; only base/width are zeroed and the
9124        // flags are overwritten wholesale at c:1176. A node that is
9125        // still in `paramtab` but read as absent by c:2241-2243
9126        // (`PM_UNSET && !PM_DECLARED`) lands here, and rebuilding it at
9127        // `level: 0` would silently promote a LOCAL to a global —
9128        // `typeset -n ref; unset -n ref; ref=str` printed
9129        // `typeset -g ref=str` instead of `typeset ref=str`.
9130        let (reuse_level, reuse_old) = tab
9131            .get(name)
9132            .map(|old| (old.level, old.old.clone())) // c:1155
9133            .unwrap_or((0, None)); // c:1157 zshcalloc — genuinely new name
9134        let pm: Param = Box::new(param {
9135            node: hashnode {
9136                next: None,
9137                nam: name.to_string(),
9138                flags: pm_flags,
9139            },
9140            u_data: 0,
9141            u_tied: None,
9142            u_arr: None,
9143            u_str: Some(String::new()),
9144            u_val: 0,
9145            u_dval: 0.0,
9146            u_hash: None,
9147            gsu_s, // c:1149 special gsu wired
9148            gsu_i: None,
9149            gsu_f: None,
9150            gsu_a: None,
9151            gsu_h: None,
9152            base: 0,  // c:1155
9153            width: 0, // c:1155
9154            env: None,
9155            ename: None,
9156            old: reuse_old,     // c:1155 (same struct in C)
9157            level: reuse_level, // c:1155 (same struct in C)
9158        });
9159        tab.insert(name.to_string(), pm);
9160    } else {
9161        let pm = tab.get(name).unwrap();
9162        // c:3216 PM_READONLY guard for an existing param.
9163        if (pm.node.flags as u32 & PM_READONLY) != 0 {
9164            let nam = pm.node.nam.clone();
9165            drop(tab); // zerr redraws ZLE, which reads paramtab
9166            zerr(&format!("read-only variable: {}", nam)); // c:3217
9167            unqueue_signals(); // c:3220
9168            return None; // c:3221
9169        }
9170        // c:1135-1160 — back-fill the gsu_s vtable on existing
9171        // entries that pre-dated `createparamtable` (e.g. env-import
9172        // path inserted HOME/IFS/etc via the non-special branch
9173        // before the special-param table was populated). Without
9174        // this back-fill, those params stay None-gsu forever and
9175        // assignstrvalue falls through to the default `strsetfn`
9176        // path, bypassing homesetfn/ifssetfn — so the canonical
9177        // C globals (`home`, `ifs`, ...) drift from paramtab.
9178        let pm = tab.get_mut(name).unwrap();
9179        if pm.gsu_s.is_none() {
9180            let new_gsu: Option<Box<gsu_scalar>> = match name {
9181                "0" => Some(Box::new(ARGZERO_GSU.clone())), // c:225-226
9182                "HOME" => Some(Box::new(HOME_GSU.clone())), // c:248
9183                "IFS" => Some(Box::new(IFS_GSU.clone())),   // c:245
9184                "TERM" => Some(Box::new(TERM_GSU.clone())), // c:250
9185                "TERMINFO" => Some(Box::new(TERMINFO_GSU.clone())), // c:251
9186                "TERMINFO_DIRS" => Some(Box::new(TERMINFODIRS_GSU.clone())), // c:252
9187                "WORDCHARS" => Some(Box::new(WORDCHARS_GSU.clone())), // c:249
9188                "USERNAME" => Some(Box::new(USERNAME_GSU.clone())), // c:247
9189                "KEYBOARD_HACK" => Some(Box::new(KEYBOARDHACK_GSU.clone())), // c:253
9190                "HISTCHARS" | "histchars" => Some(Box::new(HISTCHARS_GSU.clone())), // c:246
9191                "LANG" => Some(Box::new(LANG_GSU.clone())), // c:332
9192                "LC_ALL" => Some(Box::new(LC_ALL_GSU.clone())), // c:333
9193                "LC_COLLATE" | "LC_CTYPE" | "LC_MESSAGES" | "LC_NUMERIC" | "LC_TIME" => {
9194                    Some(Box::new(LC_BLAH_GSU.clone())) // c:335-346
9195                }
9196                _ => None,
9197            };
9198            if new_gsu.is_some() {
9199                pm.gsu_s = new_gsu;
9200                pm.node.flags |= PM_SPECIAL as i32;
9201            }
9202        }
9203        let pm = tab.get(name).unwrap();
9204        // c:3179-3192 — an existing PM_ARRAY on a non-AUGMENT scalar
9205        // assignment, or a PM_HASHED on ANY scalar assignment (`+=`
9206        // included: `typeset -A h; h+=x` leaves h="x"), non-special,
9207        // non-tied, unset KSHARRAYS → `resetparam(v->pm, PM_SCALAR)`.
9208        let f = pm.node.flags as u32;
9209        let augment_bit = (flags & ASSPM_AUGMENT) != 0;
9210        let resets = ((f & PM_ARRAY) != 0 && !augment_bit) || (f & PM_HASHED) != 0;
9211        let is_special_or_tied = (f & (PM_SPECIAL | PM_TIED)) != 0;
9212        if resets && !is_special_or_tied && !isset(KSHARRAYS) {
9213            // c:3242 — flip type to PM_SCALAR, drop array/hash slots.
9214            let pm_mut = tab.get_mut(name).unwrap();
9215            pm_mut.node.flags =
9216                (pm_mut.node.flags & !(PM_TYPE(u32::MAX) as i32)) | PM_SCALAR as i32;
9217            pm_mut.u_arr = None;
9218            paramtab_hashed_storage().lock().unwrap().remove(name);
9219        }
9220    }
9221
9222    // c:3258-3266 `if (*val && (v->pm->node.flags & PM_NAMEREF))`.
9223    let pm = tab.get(name).unwrap();
9224    if !val.is_empty() && (pm.node.flags as u32 & PM_NAMEREF) != 0 {
9225        if !valid_refname(val, pm.node.flags) {
9226            // c:3259
9227            drop(tab); // zerr redraws ZLE, which reads paramtab
9228            zerr(&format!("invalid variable name: {}", val)); // c:3221
9229            errflag.fetch_or(
9230                // c:3263
9231                ERRFLAG_ERROR,
9232                Ordering::Relaxed,
9233            );
9234            unqueue_signals(); // c:3262
9235            return None; // c:3264
9236        }
9237    }
9238
9239    // c:3266-3268 `if (flags & ASSPM_WARN) check_warn_pm(v->pm, "scalar", created, 1);`
9240    if (flags & ASSPM_WARN) != 0 {
9241        if let Some(pm_ref) = tab.get(name) {
9242            check_warn_pm(pm_ref, "scalar", created_now as i32, 1); // c:3268
9243        }
9244    }
9245    // c:3211 `v->pm->node.flags &= ~PM_DEFAULTED;`
9246    //
9247    // c:Src/zsh.h:1926-1927 — `PM_DONTIMPORT` and `PM_DECLARED` are the
9248    // SAME bit (1<<22), and `PM_DEFAULTED` is `PM_DECLARED|PM_UNSET`
9249    // (c:1933). So this clear also wipes the do-not-import marker that
9250    // `createparamtable`'s environment-import loop reads through
9251    // `dontimport()` (c:934, c:815-828).
9252    //
9253    // In C that is harmless: the values behind the PM_SPECIAL
9254    // parameters are seeded by writing their backing globals in
9255    // `setupvals` (`ifs`, `wordchars`, `zunderscore`, `zoptind`,
9256    // `bangchar`/`hatchar`/`hashchar` — c:Src/init.c:1212-1224), which
9257    // runs BEFORE `createparamtable` (c:Src/init.c:1286) and never
9258    // touches a paramtab node, so no special ever reaches this line
9259    // before the import. zshrs keeps those values on the paramtab node
9260    // itself, so its startup seeds DO reach here — and `_`, `IFS`,
9261    // `OPTIND`, `histchars`, `path` and `fpath` lost the bit before the
9262    // import could read it (an inherited `_`/`IFS` was then imported and
9263    // exported: `typeset -p _` printed `export _=…` and `_` appeared in
9264    // `export -p` / `typeset +x -r`, where zsh shows nothing).
9265    //
9266    // Keep the bit when it can only mean PM_DONTIMPORT — i.e. on a
9267    // PM_SPECIAL node that already carries it. Every C reader of
9268    // PM_DECLARED is gated on PM_UNSET as well (c:Src/builtin.c:2038,
9269    // c:2925; c:Src/params.c:5894, c:6140; c:Src/subst.c:2813-2814) and
9270    // the value store below clears PM_UNSET (c:2704), so retaining it
9271    // changes nothing those readers can observe.
9272    let pm = tab.get_mut(name).unwrap(); // c:3211
9273    let keep_dontimport =
9274        (pm.node.flags as u32 & (PM_SPECIAL | PM_DONTIMPORT)) == (PM_SPECIAL | PM_DONTIMPORT);
9275    pm.node.flags &= !(PM_DEFAULTED as i32); // c:3211
9276    if keep_dontimport {
9277        pm.node.flags |= PM_DONTIMPORT as i32;
9278    }
9279
9280    // c:3343 `assignstrvalue(v, val, flags)`. C aliases `v->pm`
9281    // through to the param in the hash table; Rust's borrow rules
9282    // forbid holding `&mut Param` and wrapping it in `value.pm:
9283    // Option<Param>` at once. Previous port used `tab.remove(name)`
9284    // to take ownership during dispatch, then re-insert — but
9285    // assignstrvalue's PM_INTEGER arm calls `mathevali(val)` which
9286    // looks up identifiers via paramtab. With the param removed,
9287    // `X=X+1` evaluated `X` as unset (=0) and stored `0+1=1`.
9288    // Symptom: `typeset -gi X=5; X=X+1; echo $X` printed 1 instead
9289    // of 6 — broke self-referential integer arithmetic which p10k
9290    // uses for counters, frame depth, hook chain index, etc.
9291    //
9292    // Fix: clone the Param into the value struct so the original
9293    // stays in the table during mathevali's identifier lookup,
9294    // then overwrite the table entry with the mutated clone.
9295    let pm_clone = tab.get(name).unwrap().clone(); // c:3343
9296    drop(tab); // release write lock — assignstrvalue may take it
9297    let mut v = value {
9298        // c:3343
9299        pm: Some(pm_clone), // c:3343
9300        arr: Vec::new(),    // c:3343
9301        scanflags: 0,       // c:3343
9302        valflags: 0,        // c:3343
9303        start: 0,           // c:3343
9304        end: -1,            // c:3343
9305    }; // c:3343
9306    assignstrvalue(Some(&mut v), Some(val.to_string()), flags); // c:3343
9307    let cloned = v.pm.as_ref().cloned(); // c:3345
9308    if let Some(pm_back) = v.pm {
9309        // c:3343
9310        paramtab()
9311            .write()
9312            .unwrap()
9313            .insert(name.to_string(), pm_back); // c:3343
9314    } // c:3343
9315      // c:Src/params.c pathsetfn / fpathsetfn / manpathsetfn /
9316      // cdpathsetfn — when the SCALAR side of a tied colon-array
9317      // pair is assigned, the canonical setfn split-rebuilds the
9318      // ARRAY side via `splitstring(value, ":", &globalarr)`. zshrs
9319      // lacks per-name GSU setfns for these, so the array stayed
9320      // stale after `PATH=/a:/b`. Mirror the split cascade
9321      // explicitly for the full IPDEF8 PM_TIED colonarr list
9322      // (c:Src/params.c:395-422): PATH↔path, FPATH↔fpath,
9323      // MANPATH↔manpath, CDPATH↔cdpath, PSVAR↔psvar,
9324      // MODULE_PATH↔module_path, FIGNORE↔fignore,
9325      // MAILPATH↔mailpath. Bug #423/#424.
9326      //
9327      // The split is the special's colonarr GSU setfn writing the internal
9328      // `path`-style array, so it only exists on the SPECIAL node:
9329      // c:Src/params.c:406 `IPDEF8("PATH", &path, "path", PM_TIED)` and, under
9330      // sh/ksh emulation, c:459 `IPDEF8("PATH", &path, NULL, 0)` both point
9331      // the special at the same internal array (zshrs keeps that array as the
9332      // `path` parameter). A `typeset -h PATH` local (c:Src/builtin.c:2083-2085
9333      // leaves newspecial NS_NONE, so createparam c:1157 gives it the plain
9334      // `stdscalar_gsu`) stores a plain string and never touches the array.
9335      // `cloned` is the node assignstrvalue just wrote.
9336    let assigned_tied = cloned
9337        .as_ref()
9338        .is_some_and(|p| (p.node.flags as u32 & PM_SPECIAL) != 0);
9339    let alt: Option<&str> = match name {
9340        "PATH" => Some("path"),
9341        "FPATH" => Some("fpath"),
9342        "MANPATH" => Some("manpath"),
9343        "CDPATH" => Some("cdpath"),
9344        "PSVAR" => Some("psvar"),
9345        "MODULE_PATH" => Some("module_path"),
9346        "FIGNORE" => Some("fignore"),
9347        "MAILPATH" => Some("mailpath"),
9348        // c:Src/Modules/watch.c:697-701 — PM_TIED since upstream b2fb112ea6.
9349        "WATCH" => Some("watch"),
9350        _ => None,
9351    };
9352    if let (Some(alt_name), true) = (alt, assigned_tied) {
9353        // c:Src/params.c colonarrsetfn splits the value the scalar now HOLDS;
9354        // under `+=` that is old + appended, not the appended text alone
9355        // (`PATH+=:/y` left `path=(/y)`).
9356        let stored = cloned.as_ref().and_then(|p| p.u_str.as_deref()).unwrap_or(val);
9357        let parts: Vec<String> = stored.split(':').map(String::from).collect();
9358        if let Ok(mut tab) = paramtab().write() {
9359            // c:Src/hashtable.c:157 — `ht->addnode(ht, ztrdup(nam), pm)`
9360            // only when the name isn't already a node; an existing node
9361            // keeps its chain position and is mutated in place.
9362            if tab.get(alt_name).is_none() {
9363                let pm = Box::new(param {
9364                    node: hashnode {
9365                        next: None,
9366                        nam: alt_name.to_string(),
9367                        flags: (PM_ARRAY | PM_SPECIAL) as i32,
9368                    },
9369                    u_data: 0,
9370                    u_tied: None,
9371                    u_arr: Some(Vec::new()),
9372                    u_str: None,
9373                    u_val: 0,
9374                    u_dval: 0.0,
9375                    u_hash: None,
9376                    gsu_s: None,
9377                    gsu_i: None,
9378                    gsu_f: None,
9379                    gsu_a: None,
9380                    gsu_h: None,
9381                    base: 0,
9382                    width: 0,
9383                    env: None,
9384                    ename: None,
9385                    old: None,
9386                    level: 0,
9387                });
9388                tab.insert(alt_name.to_string(), pm);
9389            }
9390            let entry = tab.get_mut(alt_name).expect("inserted above");
9391            entry.u_arr = Some(parts);
9392            entry.u_str = None;
9393        }
9394        // c:Src/params.c:5291 — `if (t == path) cmdnamtab->emptytable
9395        // (cmdnamtab)`. PATH change invalidates the command-name cache.
9396        if name == "PATH" {
9397            crate::ported::hashtable::emptycmdnamtable();
9398        }
9399    }
9400    // c:Src/params.c colonarrsetfn (the REVERSE of the split-cascade above) —
9401    // a SCALAR assignment to a tied colon-ARRAY name (`path=/x`) coerces to a
9402    // 1-element array and must re-derive its tied env SCALAR (PATH). The array
9403    // assign (`path=(...)`, params.rs:4548) and element assign
9404    // (`path[2]=x`, :6230) already sync; the plain scalar-assign path did not,
9405    // so `path=/x; echo $PATH` left PATH stale while `typeset -T` ties worked.
9406    let tied_scalar: Option<&str> = TIED_COLON_ARRAYS
9407        .iter()
9408        .find(|(arr, _)| *arr == name)
9409        .map(|(_, sc)| *sc);
9410    if let Some(sc) = tied_scalar.filter(|_| assigned_tied) {
9411        // The freshly stored value joined with ':' (a scalar assign is a
9412        // 1-element array → just the value).
9413        let joined = {
9414            let tab = paramtab().read().unwrap();
9415            tab.get(name)
9416                .map(|p| match p.u_arr.as_deref() {
9417                    Some(a) => a.join(":"),
9418                    None => p.u_str.clone().unwrap_or_default(),
9419                })
9420                .unwrap_or_else(|| val.to_string())
9421        };
9422        if let Ok(mut tab) = paramtab().write() {
9423            // c:Src/hashtable.c:157 — add only when absent; an existing
9424            // node is mutated in place, keeping its chain position.
9425            if tab.get(sc).is_none() {
9426                let pm = Box::new(param {
9427                    node: hashnode {
9428                        next: None,
9429                        nam: sc.to_string(),
9430                        flags: (PM_SCALAR | PM_SPECIAL) as i32,
9431                    },
9432                    u_data: 0,
9433                    u_tied: None,
9434                    u_arr: None,
9435                    u_str: None,
9436                    u_val: 0,
9437                    u_dval: 0.0,
9438                    u_hash: None,
9439                    gsu_s: None,
9440                    gsu_i: None,
9441                    gsu_f: None,
9442                    gsu_a: None,
9443                    gsu_h: None,
9444                    base: 0,
9445                    width: 0,
9446                    env: None,
9447                    ename: None,
9448                    old: None,
9449                    level: 0,
9450                });
9451                tab.insert(sc.to_string(), pm);
9452            }
9453            let entry = tab.get_mut(sc).expect("inserted above");
9454            entry.u_str = Some(joined.clone());
9455            entry.u_arr = None;
9456        }
9457        // Keep the process environment in sync so command resolution and
9458        // child processes see the new PATH/FPATH/etc.
9459        std::env::set_var(sc, &joined);
9460        if sc == "PATH" {
9461            crate::ported::hashtable::emptycmdnamtable();
9462        }
9463    }
9464    // c:Src/params.c — the prompt specials are IPDEF7 entries that share one
9465    // backing pointer: `{"PS1",…,&prompt}`, `{"PROMPT",…,&prompt}` and
9466    // `{"prompt",…,&prompt}` are the SAME storage, so a write through any one
9467    // of them is visible through all three (zshparam(1): "prompt <S> <Z>
9468    // Same as PS1"). Likewise PS2/PROMPT2 (&prompt2), PS3/PROMPT3, PS4/PROMPT4,
9469    // RPS1/RPROMPT (&rprompt) and RPS2/RPROMPT2 (&rprompt2).
9470    // zshrs lacks PM_ALIAS — mirror the scalar write to every other member of
9471    // the group. Bug #518.
9472    //
9473    // The lowercase `prompt` member is load-bearing for completion: zpwr's
9474    // `$fpath` `_parameters` lists each parameter with `${(P)name}` as its
9475    // description, so a `prompt` that reads back empty made `pr<TAB>` render
9476    // `prompt  -- ` where zsh renders `prompt  -- <$PS1>`.
9477    const ALIAS_GROUPS: &[&[&str]] = &[
9478        &["PS1", "PROMPT", "prompt"],
9479        &["PS2", "PROMPT2"],
9480        &["PS3", "PROMPT3"],
9481        &["PS4", "PROMPT4"],
9482        &["RPS1", "RPROMPT"],
9483        &["RPS2", "RPROMPT2"],
9484    ];
9485    let alias_group: &[&str] = ALIAS_GROUPS
9486        .iter()
9487        .copied()
9488        .find(|g| g.contains(&name))
9489        .unwrap_or(&[]);
9490    // Mirror the value the assignment actually STORED, re-read from the
9491    // table — not the incoming `val`. Under `ASSPM_AUGMENT` (`NAME+=frag`,
9492    // c:Src/params.c:3320-3332) `val` is only the appended fragment, so
9493    // mirroring it made the siblings hold the fragment alone: powerlevel10k
9494    // builds `PROMPT` chunk-by-chunk with `+=`, leaving `$PS1` — the name
9495    // `zleread` prompts with (input.rs:589) — holding just the LAST chunk,
9496    // so only the final `╰─ … ❯` line of a multi-line p10k prompt was drawn.
9497    let mirrored: Option<String> = if alias_group.is_empty() {
9498        None
9499    } else {
9500        paramtab()
9501            .read()
9502            .ok()
9503            .and_then(|tab| tab.get(name).map(|p| match p.u_arr.as_deref() {
9504                Some(a) => a.join(" "),
9505                None => p.u_str.clone().unwrap_or_default(),
9506            }))
9507    };
9508    let mirrored = mirrored.unwrap_or_else(|| val.to_string());
9509    for other in alias_group.iter().copied().filter(|n| *n != name) {
9510        if let Ok(mut tab) = paramtab().write() {
9511            // c:Src/hashtable.c:157 — add only when absent; an existing
9512            // node is mutated in place, keeping its chain position.
9513            if tab.get(other).is_none() {
9514                let pm = Box::new(param {
9515                    node: hashnode {
9516                        next: None,
9517                        nam: other.to_string(),
9518                        flags: PM_SCALAR as i32,
9519                    },
9520                    u_data: 0,
9521                    u_tied: None,
9522                    u_arr: None,
9523                    u_str: Some(String::new()),
9524                    u_val: 0,
9525                    u_dval: 0.0,
9526                    u_hash: None,
9527                    gsu_s: None,
9528                    gsu_i: None,
9529                    gsu_f: None,
9530                    gsu_a: None,
9531                    gsu_h: None,
9532                    base: 0,
9533                    width: 0,
9534                    env: None,
9535                    ename: None,
9536                    old: None,
9537                    level: 0,
9538                });
9539                tab.insert(other.to_string(), pm);
9540            }
9541            let entry = tab.get_mut(other).expect("inserted above");
9542            entry.u_str = Some(mirrored.clone());
9543            entry.u_arr = None;
9544        }
9545    }
9546    unqueue_signals(); // c:3344
9547    cloned // c:3345
9548}
9549
9550// `VarAttr` struct + `VarKind` enum + `impl VarAttr::format_zsh`
9551// DELETED. C zsh stores typeset attributes as bare `PM_*` bit
9552// flags on `Param.node.flags` (`Src/zsh.h` PM_* + `Src/params.c`
9553// flag tests); the `${(t)var}` flag report (`typeprintparam` at
9554// `Src/builtin.c:3050+`) writes those bits to a string directly
9555// against the `Param.node.flags` int.
9556//
9557// Both types had zero external use sites — pure dead-code carryover
9558// from an earlier vm_helper refactor. The PM_* bit constants are at
9559// `zsh_h.rs:1340+` and the `(t)` formatting routes through
9560// `typeset_print_flags` (when wired) reading bare `Param.node.flags`.
9561
9562// ===========================================================
9563// Special-parameter GSU (get/set/unset) callbacks ported from
9564// Src/params.c.
9565//
9566// C zsh stores per-special-param state in file-static globals
9567// (`ifs`, `home`, `term`, `histsiz`, etc.) and dispatches getfn/
9568// setfn/unsetfn callbacks through `Param.gsu->getfn` etc. zshrs's
9569// param storage is per-evaluator HashMaps on `ShellExecutor`, so
9570// the C globals are reproduced as `OnceLock<Mutex<…>>` module
9571// statics here, with the get/set ported mutating the static.
9572//
9573// The GSU dispatch callbacks for non-special arr/hash/int/float/str
9574// params, the param-table mutators and the scope helpers operate on
9575// the `param` struct (zsh_h.rs) and its `gsu_*` function-pointer
9576// fields.
9577// ===========================================================
9578
9579// -----------------------------------------------------------
9580// Module statics — one per C global referenced by the special-
9581// param callbacks below. All initialised lazily on first read.
9582// -----------------------------------------------------------
9583
9584// `Src/params.c:515  mod_export HashTable paramtab, realparamtab;`
9585//
9586// `realparamtab` always points to the shell's global parameter
9587// table. `paramtab` normally aliases it; it is temporarily
9588// redirected during associative-array key iteration
9589// (`Src/params.c:508-513` — "paramtab is sometimes temporarily
9590// changed to point at another table").
9591//
9592// Per PORT_PLAN.md Phase 3, bucket-2 read-mostly tables use
9593// `RwLock` so parallel readers (every `$VAR` expansion, every
9594// completion lookup) don't serialize. Writers (assignments,
9595// scope pushes/pops, function-local declarations) take the
9596// exclusive write lock. `OnceLock` provides the single-static
9597// guarantee without an `Arc` allocation since the table lives
9598// for the process lifetime.
9599//
9600// Entries are keyed on `node.nam` (the canonical `param` struct
9601// lives in `zsh_h.rs`). The full `HashTable` substrate (vtable
9602// callbacks, intrusive `next` chain, scope-stacked iterators) is
9603// replaced by the typed map below, which is the operative storage.
9604//
9605// Node storage is `hashtable_nodes<Param>` — the port of the
9606// open-hashed bucket array `newparamtable(151, "paramtab")` builds
9607// (`Src/params.c:538` → `newhashtable` at `Src/hashtable.c:100`,
9608// called from `createparamtable` at `Src/params.c:854`). C's
9609// `newparamtable` differs from a plain `newhashtable` only in the
9610// callback slots it installs (`getnode = getparamnode` for the
9611// PM_AUTOLOAD stub path, `freenode = freeparamnode`,
9612// `printnode = printparamnode`, `addnode = addhashnode`,
9613// `getnode2 = gethashnode2`, `removenode = removehashnode`,
9614// c:545-556) — the node storage itself is the same bucket array,
9615// so the walk order is `hasher(nam) % 151` + front-insertion +
9616// the ×4 `expandhashtable` at `ct >= 2 * hsize`.
9617//
9618// That walk is user-visible: `${(k)parameters}` scans this table
9619// (`Src/Modules/parameter.c` `scanparamtab`), and `_parameters`
9620// feeds those matches to `compadd` in that order, which decides the
9621// `join_clines` common-prefix fold. A `FastMap` here produced an
9622// order matching zsh on zero of ~479 names.
9623static PARAMTAB_INNER: OnceLock<RwLock<hashtable_nodes<Param>>> = OnceLock::new();
9624static REALPARAMTAB_INNER: OnceLock<RwLock<hashtable_nodes<Param>>> = OnceLock::new();
9625
9626/// Array parameter assignment (no subscript).
9627///
9628/// Port of `assignaparam()` from `Src/params.c:3357`. Writes an array value into paramtab
9629/// and returns the new/updated Param.
9630///
9631/// Ported semantics:
9632///   - PM_READONLY rejection (c:3370-3381)
9633///   - PM_NAMEREF type-change reject (c:3395-3398)
9634///   - ASSPM_AUGMENT (`a+=val`) preserve-old prepend (c:3404-3412)
9635///   - PM_UNIQUE dedupe (c:3401)
9636///   - element-wise `a[k]=v` slice path pre-check (c:3373-3389)
9637///   - PM_HASHED slice rejection (c:3384-3391)
9638///
9639/// Pending (rare paths):
9640///   - resetparam from non-array (c:3415-3420) — handled implicitly
9641///     by the type-mask rewrite below; matches C observable behavior.
9642pub fn assignaparam(name: &str, val: Vec<String>, flags: i32) -> Option<Param> {
9643    // c:3357
9644    // c:3366-3370 — `if (!isident(s)) { zerr; return NULL }`.
9645    if !isident(name) {
9646        zerr(&format!("not an identifier: {}", name));
9647        errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed);
9648        return None;
9649    }
9650    // !!! RUST-ONLY: provenance tap (see assignsparam). An array write
9651    // records the joined element list as the value summary — the same
9652    // shape `${arr}` would produce — so a lineage that flows through
9653    // `arr=( $(cmd) )` stays readable.
9654    if crate::provenance::active() {
9655        let kind = if (flags & ASSPM_AUGMENT) != 0 {
9656            "append"
9657        } else {
9658            "array"
9659        };
9660        crate::provenance::on_param_write(name, kind, &val.join(" "));
9661    }
9662    // c:Src/params.c:2922-2923 — `pm->gsu.a->setfn(pm, val)`: a special
9663    // PM_ARRAY param (e.g. `dirstack`) routes a whole-array assignment
9664    // through its GSU setter instead of plain paramtab storage. zshrs
9665    // keeps these magic arrays in PARTAB_ARRAY (not paramtab), so a
9666    // bare `dirstack=(...)` would otherwise land in the executor's
9667    // generic array store and never reach `dirssetfn` (so the dir stack
9668    // stayed empty). Dispatch writable PARTAB_ARRAY specials here. Only
9669    // whole-name (non-subscripted) assignment; `dirstack[1]=x` keeps the
9670    // existing subscript path.
9671    // c:Src/params.c:2264 — `if (!pm ...) return NULL`. Once `unset
9672    // dirstack` has removed the node (c:3874, see bin_unset), the name
9673    // has no special binding left, so `dirstack=(a b)` must create an
9674    // ORDINARY array via createparam below instead of calling
9675    // `dirssetfn`. Same gate as the subscript arm in assignsparam.
9676    if !name.contains('[') {
9677        if let Some(entry) = crate::ported::modules::parameter::PARTAB_ARRAY
9678            .iter()
9679            .find(|e| e.name == name)
9680            .filter(|_| paramtab().read().map_or(true, |t| t.get(name).is_some()))
9681        {
9682            if let Some(setfn) = entry.setfn {
9683                // c:Src/params.c:1135-1144 — see the PM_HASHED twin
9684                // below: createparam's reuse arm clears PM_UNSET on a
9685                // PM_SPECIAL node so a write after a materialized
9686                // `unset` revives the row.
9687                if let Ok(mut tab) = paramtab().write() {
9688                    if let Some(pm) = tab.get_mut(name) {
9689                        pm.node.flags &= !(PM_UNSET as i32); // c:1144
9690                    }
9691                }
9692                setfn(std::ptr::null_mut(), val.clone()); // c:2922 gsu.a->setfn
9693                let mut pm = param::default();
9694                pm.node.nam = name.to_string();
9695                pm.node.flags = (PM_ARRAY | PM_SPECIAL) as i32;
9696                pm.u_arr = Some(val);
9697                return Some(Box::new(pm));
9698            }
9699        }
9700    }
9701    // c:3392 — `fetchvalue(&vbuf, &s, 1, SCANPM_ASSIGNING)` resolves
9702    // PM_NAMEREF chains; c:3395-3398 rejects an unresolvable ref.
9703    {
9704        let base = name.split('[').next().unwrap_or(name);
9705        if crate::ported::params::is_nameref(base) {
9706            match crate::ported::params::resolve_nameref_name(base, None) {
9707                crate::ported::params::nameref_resolution::SelfRef => {
9708                    errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed);
9709                    return None;
9710                }
9711                crate::ported::params::nameref_resolution::OutOfScope => {
9712                    // see assignsparam — silent failure, status 1.
9713                    return None;
9714                }
9715                crate::ported::params::nameref_resolution::Placeholder(last, _) => {
9716                    // c:3334 — the reject at c:3337 only fires when
9717                    // `fetchvalue` RETURNED a value. `getparamnode`
9718                    // (c:570-575) hands fetchvalue the RESOLVED endpoint,
9719                    // and fetchvalue then applies c:2262-2263
9720                    //     if (!pm || ((pm->node.flags & PM_UNSET) &&
9721                    //                 !(pm->node.flags & PM_DECLARED)))
9722                    //         return NULL;
9723                    // so a chain ending on a reference that `unset -n`
9724                    // left `PM_UNSET` with PM_DECLARED cleared reads as
9725                    // ABSENT: c:3335 `createparam(t, PM_ARRAY)` runs and
9726                    // c:1120-1123 retargets `oldpm` to that endpoint, so
9727                    // the array lands on it as an ordinary local array.
9728                    // Reaching the same end state through the endpoint's
9729                    // own name is the name-keyed equivalent of C's
9730                    // pointer retarget.
9731                    let endpoint_visible = paramtab()
9732                        .read()
9733                        .ok()
9734                        .and_then(|t| {
9735                            t.get(&last).map(|p| {
9736                                let f = p.node.flags as u32;
9737                                (f & PM_UNSET) == 0 || (f & PM_DECLARED) != 0
9738                            })
9739                        })
9740                        .unwrap_or(true); // c:2262-2263
9741                    if !endpoint_visible {
9742                        let mut new_s = last.clone();
9743                        if let Some(us) = name.find('[').map(|i| &name[i..]) {
9744                            new_s.push_str(us);
9745                        }
9746                        return assignaparam(&new_s, val, flags);
9747                    }
9748                    // c:3396 — message uses the ORIGINAL assigned name
9749                    // (`t = s` saved at c:3361).
9750                    zwarn(&format!("{}: can't change type of a named reference", base));
9751                    return None;
9752                }
9753                crate::ported::params::nameref_resolution::Target {
9754                    name: t,
9755                    subscript: rsub,
9756                    pm: rpm,
9757                    level,
9758                } => {
9759                    let user_sub = name.find('[').map(|i| &name[i..]);
9760                    // Hidden (old-chain) binding — upscope write.
9761                    if rsub.is_none() && user_sub.is_none() && rpm.is_some() {
9762                        let visible_level = paramtab()
9763                            .read()
9764                            .ok()
9765                            .and_then(|tb| tb.get(&t).map(|p| p.level));
9766                        if visible_level != Some(level) {
9767                            // upscope write, INLINE (former
9768                            // nameref_hidden_array_assign).
9769                            return (|name: &str, level: i32, val: Vec<String>| -> Option<Param> {
9770                                let mut tab = paramtab().write().ok()?;
9771                                let mut node: &mut param = tab.get_mut(name)?.as_mut();
9772                                while node.level != level {
9773                                    node = node.old.as_mut()?.as_mut();
9774                                }
9775                                if (node.node.flags as u32 & PM_READONLY) != 0 {
9776                                    drop(tab); // zerr redraws ZLE, which reads paramtab
9777                                    zerr(&format!("read-only variable: {}", name)); // c:3370-3381
9778                                    return None;
9779                                }
9780                                // c:3341-3355 — a non-array, non-hash target
9781                                // that is not special/tied is
9782                                // `resetparam(v->pm, PM_ARRAY)`, which
9783                                // refuses a pm that is not the visible
9784                                // binding (c:3740-3746).
9785                                let nf = node.node.flags as u32;
9786                                if (PM_TYPE(nf) & (PM_ARRAY | PM_HASHED)) == 0
9787                                    && (nf & (PM_SPECIAL | PM_TIED)) == 0
9788                                    // c:2262-2263 — an unset, undeclared pm is
9789                                    // never fetched, so it takes createparam.
9790                                    && ((nf & PM_UNSET) == 0 || (nf & PM_DECLARED) != 0)
9791                                {
9792                                    drop(tab); // zerr redraws ZLE, which reads paramtab
9793                                    zerr(&format!("can't change type of hidden variable: {}", name)); // c:3745
9794                                    errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed); // c:3357
9795                                    return None;
9796                                }
9797                                let type_mask = PM_TYPE(u32::MAX) as i32;
9798                                node.node.flags = (node.node.flags & !type_mask) | PM_ARRAY as i32;
9799                                node.u_str = None;
9800                                node.u_arr = Some(val);
9801                                node.node.flags &= !((PM_UNSET | PM_DECLARED) as i32);
9802                                Some(Box::new(node.clone()))
9803                            })(&t, level, val);
9804                        }
9805                    }
9806                    let mut new_s = t.clone();
9807                    if let Some(rs) = &rsub {
9808                        new_s.push('[');
9809                        new_s.push_str(rs);
9810                        new_s.push(']');
9811                    }
9812                    if let Some(us) = user_sub {
9813                        new_s.push_str(us);
9814                    }
9815                    if new_s != name {
9816                        return assignaparam(&new_s, val, flags);
9817                    }
9818                }
9819                crate::ported::params::nameref_resolution::NotRef => {}
9820            }
9821        }
9822    }
9823
9824    // c:3375 — `if ((ss = strchr(s, '['))) { ... slice path ... }`
9825    //          Extract the base name when there's a subscript and
9826    //          dispatch to the slice-rejection / slice-write path.
9827    if let Some(_bracket) = name.find('[') {
9828        let base = name.split('[').next().unwrap_or(name);
9829        // c:3384-3391 — slice into a HASHED → zerr.
9830        let is_hashed = {
9831            let tab = paramtab().read().unwrap();
9832            tab.get(base)
9833                .map(|pm| (pm.node.flags as u32 & PM_HASHED) != 0)
9834                .unwrap_or(false)
9835        };
9836        if is_hashed {
9837            zerr(&format!(
9838                "{}: attempt to set slice of associative array",
9839                base
9840            ));
9841            errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed);
9842            return None;
9843        }
9844        // Slice into a non-existent param → create as PM_ARRAY (c:3377-3382).
9845        let exists = paramtab().read().unwrap().contains_key(base);
9846        if !exists {
9847            createparam(base, PM_ARRAY as i32)?;
9848        }
9849        // c:3364-3370 — `if (!v) if (!(v = fetchvalue(&vbuf, &t, 1,
9850        // SCANPM_ASSIGNING))) { … return NULL; }` then c:3434
9851        // `setarrvalue(v, val)`: the subscript resolves to a start/end
9852        // range (getindex) and the slice is spliced. The old early return
9853        // wrote nothing, so `typeset a[2,4]=(x y)` — whose only route here is
9854        // typeset_single c:2486-2491 — left the array untouched.
9855        // !!! WARNING: RUST-ONLY WRITE-BACK — fetchvalue hands out a clone of
9856        // the paramtab node (C mutates `v->pm` in place), so the spliced node
9857        // is stored back under the base name.
9858        let mut vbuf = value {
9859            pm: None,
9860            arr: Vec::new(),
9861            scanflags: 0,
9862            valflags: 0,
9863            start: 0,
9864            end: -1,
9865        };
9866        let mut cursor: &str = name;
9867        let v = fetchvalue(Some(&mut vbuf), &mut cursor, 1, SCANPM_ASSIGNING as i32)?; // c:3365
9868        // !!! RUST-ONLY INDEX CONVENTION — getindex leaves C's 0-based start
9869        // (c:2145), but `setarrvalue` here keeps the 1-based start its fusevm
9870        // splice caller passes (see its c:2944 WARNING). Lift a non-negative
9871        // start by one; VALFLAG_INV starts are 1-based already (c:2938).
9872        if v.start >= 0 && (v.valflags & VALFLAG_INV) == 0 {
9873            v.start += 1;
9874        }
9875        setarrvalue(v, val); // c:3434
9876        let spliced = v.pm.take();
9877        if let Some(pm) = spliced {
9878            paramtab().write().unwrap().insert(base.to_string(), pm);
9879        }
9880        return paramtab().read().unwrap().get(base).cloned();
9881    }
9882
9883    // c:3391-3394 — fetchvalue / createparam(PM_ARRAY) if missing.
9884    let (existed, prior_scalar, prior_flags) = {
9885        let tab = paramtab().read().unwrap();
9886        match tab.get(name) {
9887            Some(pm) => {
9888                // c:3350 — the ASSPM_AUGMENT prepend uses
9889                // `ztrdup(getstrvalue(v))`, the type-aware string form of
9890                // the old value (PM_INTEGER → base-formatted, PM_FFLOAT/
9891                // PM_EFLOAT → convfloat, PM_SCALAR → strgetfn). Reading
9892                // `u_str` alone loses it for numeric params — u_str is
9893                // None for `integer`/`float`, so `integer i=1; i+=(2 3)`
9894                // dropped the "1". Route scalar-type targets through
9895                // getstrvalue so the conversion-to-array keeps the value.
9896                let f = pm.node.flags as u32;
9897                let ps = if f & (PM_ARRAY | PM_HASHED | PM_SPECIAL) == 0 {
9898                    let mut pv = value {
9899                        pm: Some(pm.clone()),
9900                        arr: Vec::new(),
9901                        scanflags: 0,
9902                        valflags: 0,
9903                        start: 0,
9904                        end: -1,
9905                    };
9906                    Some(getstrvalue(Some(&mut pv))) // c:3350
9907                } else {
9908                    pm.u_str.clone()
9909                };
9910                // c:3334 — `fetchvalue(&vbuf, &s, 1, SCANPM_ASSIGNING)`,
9911                // whose c:2241-2243 guard
9912                //     if (pm->node.flags & PM_UNSET &&
9913                //         !(pm->node.flags & PM_DECLARED))
9914                //         return NULL;
9915                // reports a node left PM_UNSET with PM_DECLARED cleared as
9916                // ABSENT, so c:3335 `createparam(t, PM_ARRAY)` runs and
9917                // c:1176 `pm->node.flags = flags & ~PM_LOCAL;` wipes the
9918                // stale PM_NAMEREF instead of c:3337 rejecting the
9919                // assignment. `typeset -n ref; unset -n ref; ref=(a b)`
9920                // is the case that reaches this.
9921                // Applied to every type, like the twin gate in `assignsparam`.
9922                // An unset SPECIAL (or POSIXBUILTINS-exported) node is the
9923                // one createparam does not replace (c:1135-1150): it clears
9924                // PM_UNSET and the c:3368 second fetchvalue finds it again;
9925                // that revival is applied just below, outside this read lock.
9926                let fetch_visible = (f & PM_UNSET) == 0 || (f & PM_DECLARED) != 0; // c:2264-2265
9927                let revive = !fetch_visible
9928                    && ((f & PM_SPECIAL) != 0
9929                        || (isset(crate::ported::zsh_h::POSIXBUILTINS) && (f & PM_EXPORTED) != 0));
9930                if fetch_visible || revive {
9931                    (true, ps, pm.node.flags)
9932                } else {
9933                    (false, None, 0)
9934                }
9935            }
9936            None => (false, None, 0),
9937        }
9938    };
9939    if existed
9940        && (prior_flags as u32 & PM_UNSET) != 0
9941        && (prior_flags as u32 & PM_DECLARED) == 0
9942    {
9943        // c:1139-1142 — createparam's reuse arm on a RO_BY_DESIGN node (an
9944        // outer-scope private hidden by scopeprivate) errors, and the c:3368
9945        // re-fetch then finds nothing: return NULL.
9946        if (prior_flags as u32 & PM_RO_BY_DESIGN) != 0 {
9947            zerr(&format!("{}: can't modify read-only parameter", name));
9948            return None;
9949        }
9950        if let Ok(mut tab) = paramtab().write() {
9951            let mut alt: Option<String> = None;
9952            if let Some(pm) = tab.get_mut(name) {
9953                pm.node.flags &= !(PM_UNSET as i32); // c:1144
9954                if (pm.node.flags as u32 & PM_SPECIAL) != 0 {
9955                    alt = pm.ename.clone(); // c:1145
9956                }
9957            }
9958            if let Some(altpm) = alt.as_deref().and_then(|a| tab.get_mut(a)) {
9959                altpm.node.flags &= !(PM_UNSET as i32); // c:1146-1149
9960            }
9961        }
9962    }
9963    // c:3397-3400 — PM_NAMEREF: can't change type of a named reference.
9964    if existed && (prior_flags as u32 & PM_NAMEREF) != 0 {
9965        zwarn(&format!("{}: can't change type of a named reference", name));
9966        return None;
9967    }
9968    let created_now = !existed; // c:3393 createparam path sets `created = 1`
9969    if !existed {
9970        createparam(name, PM_ARRAY as i32)?;
9971    }
9972
9973    // c:3370-3381 PM_READONLY rejection — C routes through setarrvalue
9974    // → arrsetfn which emits "read-only variable: X" and returns NULL.
9975    // Rust write path bypasses gsu setfn, so mirror the check here.
9976    if existed && (prior_flags as u32 & PM_READONLY) != 0 {
9977        zerr(&format!("read-only variable: {}", name));
9978        errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed);
9979        return None;
9980    }
9981    // c:3395-3401 — the type-reset arm only fires for params that are
9982    // NOT (PM_ARRAY|PM_HASHED) and NOT PM_SPECIAL|PM_TIED. A special
9983    // param falls THROUGH the reset: `v` stays set and the assignment
9984    // reaches setarrvalue (c:3585), which dispatches:
9985    //   - PM_HASHED whole-assign (v->start==0, v->end==-1) →
9986    //     arrhashsetfn (c:2918-2920) → pm->gsu.h->setfn(pm, ht) — for
9987    //     the zsh/parameter specials that's setpmoptions /
9988    //     setpmcommands / setaliases / setfunctions / setpmnameddirs
9989    //     (Src/Modules/parameter.c).
9990    //   - non-array, non-hash special (e.g. $SECONDS) → zerr
9991    //     "%s: attempt to assign array value to non-array" (c:2905).
9992    // The previous Rust arm rejected EVERY non-array special with
9993    // "can't change type of a special parameter" — a message C's
9994    // assignaparam never emits. Gap #2 2026-06-12.
9995    if existed {
9996        let pm_type = prior_flags as u32 & PM_TYPE(u32::MAX);
9997        if pm_type != PM_ARRAY && (prior_flags as u32 & PM_SPECIAL) != 0 {
9998            if pm_type == PM_HASHED {
9999                // c:3544-3560 — under ASSPM_KEY_VALUE, associative
10000                // arrays strictly enforce `[key]=value` syntax: walk
10001                // in strides of 3; every stride must start with a
10002                // Marker (a Marker can only introduce a
10003                // Marker/key/value triad, never appear by accident).
10004                if (flags & ASSPM_KEY_VALUE) != 0 {
10005                    let mut i = 0usize;
10006                    while i < val.len() {
10007                        if !val[i].starts_with(Marker) {
10008                            zerr("bad [key]=value syntax for associative array");
10009                            errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed);
10010                            return None;
10011                        }
10012                        i += 3;
10013                    }
10014                }
10015                // c:4124-4131 (arrhashsetfn) — count non-Marker
10016                // entries; odd → zerr + abort. zsh 5.9 truth:
10017                // `options=(noglob)` → "bad set of key/value pairs
10018                // for associative array", rc=1, script aborts.
10019                let alen = val
10020                    .iter()
10021                    .filter(|s| !s.starts_with(Marker as char))
10022                    .count();
10023                if alen % 2 != 0 {
10024                    zerr("bad set of key/value pairs for associative array");
10025                    errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed);
10026                    return None;
10027                }
10028                // c:4141-4166 (arrhashsetfn pair walk) — flatten the
10029                // value list to (key, value) pairs; `[k]=v` triples
10030                // arrive as [Marker, k, v].
10031                let mut pairs: Vec<(String, String)> = Vec::with_capacity(alen / 2);
10032                let mut it = val.iter();
10033                while let Some(first) = it.next() {
10034                    let key = if first.starts_with(Marker as char) {
10035                        match it.next() {
10036                            Some(k) => k.clone(),
10037                            None => break,
10038                        }
10039                    } else {
10040                        first.clone()
10041                    };
10042                    let v = it.next().cloned().unwrap_or_default();
10043                    pairs.push((key, v));
10044                }
10045                // c:Src/params.c:1135-1144 — createparam's reuse arm
10046                // revives a PM_UNSET|PM_SPECIAL node
10047                // (`oldpm->node.flags &= ~PM_UNSET`) instead of making a
10048                // new param, which is how `unset functions;
10049                // functions=(k v)` re-activates the row after a
10050                // MATERIALIZED unset kept the node (c:3851-3852). The
10051                // gsu dispatch below writes the real table directly and
10052                // never runs createparam, so clear the bit here.
10053                if let Ok(mut tab) = paramtab().write() {
10054                    if let Some(pm) = tab.get_mut(name) {
10055                        pm.node.flags &= !(PM_UNSET as i32); // c:1144
10056                    }
10057                }
10058                // pm->gsu.h->setfn(pm, ht) — per-name dispatch to the
10059                // canonical Src/Modules/parameter.c setfn ports. The
10060                // synthetic Param mirrors the established per-element
10061                // pattern (assignsparam "options"/"commands" arms).
10062                let synth: Param = Box::new(param {
10063                    node: hashnode {
10064                        next: None,
10065                        nam: name.to_string(),
10066                        flags: prior_flags,
10067                    },
10068                    u_data: 0,
10069                    u_tied: None,
10070                    u_arr: None,
10071                    u_str: None,
10072                    u_val: 0,
10073                    u_dval: 0.0,
10074                    u_hash: None,
10075                    gsu_s: None,
10076                    gsu_i: None,
10077                    gsu_f: None,
10078                    gsu_a: None,
10079                    gsu_h: None,
10080                    base: 0,
10081                    width: 0,
10082                    env: None,
10083                    ename: None,
10084                    old: None,
10085                    level: 0,
10086                });
10087                use crate::ported::modules::parameter as pmod;
10088                match name {
10089                    // SPECIALPMDEF entries with writable hash gsu
10090                    // (Src/Modules/parameter.c:2235-2298, no
10091                    // PM_READONLY_SPECIAL):
10092                    "options" => pmod::setpmoptions(synth.clone(), &pairs), // c:2285
10093                    "commands" => pmod::setpmcommands(synth.clone(), &pairs), // c:2238
10094                    "functions" => pmod::setpmfunctions(synth.clone(), &pairs), // c:2263
10095                    "dis_functions" => pmod::setpmdisfunctions(synth.clone(), &pairs), // c:2245
10096                    "aliases" => pmod::setpmraliases(synth.clone(), &pairs), // c:2235
10097                    "dis_aliases" => pmod::setpmdisraliases(synth.clone(), &pairs), // c:2241
10098                    "galiases" => pmod::setpmgaliases(synth.clone(), &pairs), // c:2269
10099                    "dis_galiases" => pmod::setpmdisgaliases(synth.clone(), &pairs), // c:2249
10100                    "saliases" => pmod::setpmsaliases(synth.clone(), &pairs), // c:2293
10101                    "dis_saliases" => pmod::setpmdissaliases(synth.clone(), &pairs), // c:2255
10102                    "nameddirs" => pmod::setpmnameddirs(synth.clone(), &pairs), // c:2283
10103                    _ => {
10104                        // PM_READONLY_SPECIAL hashed specials (builtins,
10105                        // modules, parameters, history, jobtexts, ...) —
10106                        // C's PM_READONLY check (setarrvalue c:2900-2903)
10107                        // rejects before any setfn. zsh 5.9 truth:
10108                        // `modules=(a b)` → "read-only variable: modules",
10109                        // rc=1. zshrs strips PM_READONLY off the paramtab
10110                        // stubs (vm_helper init), so match by family.
10111                        zerr(&format!("read-only variable: {}", name));
10112                        errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed);
10113                        return None;
10114                    }
10115                }
10116                return Some(synth);
10117            }
10118            // c:2905-2909 (setarrvalue) — non-hash special target.
10119            // zsh 5.9 truth: `SECONDS=(1 2)` → "SECONDS: attempt to
10120            // assign array value to non-array", rc=1.
10121            zerr(&format!(
10122                "{}: attempt to assign array value to non-array",
10123                name
10124            ));
10125            errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed);
10126            return None;
10127        }
10128    }
10129
10130    // c:3436-3541 — ASSPM_KEY_VALUE on an ordinary array: the value
10131    // list contains Marker / index / value triads (from
10132    // keyvalpairelement, Src/subst.c:49) interleaved with plain
10133    // elements. Resolve them into a dense array:
10134    //   - First pass (c:3465-3486): matheval each index; reject < 0,
10135    //     and 0 unless KSH_ARRAYS (zerr "bad subscript for direct
10136    //     array assignment"); KSH_ARRAYS keeps 0-based, otherwise
10137    //     1-based → decrement; track `nextind` (a plain element lands
10138    //     just after the previously placed one) and `maxlen`.
10139    //   - Allocate `fullval` of maxlen (c:3487-3495), pre-filled with
10140    //     the existing elements under ASSPM_AUGMENT (c:3496-3502).
10141    //   - Second pass (c:3503-3525): place each value; a `Marker +`
10142    //     triad concatenates onto the slot's current value (c:3510-
10143    //     3515 bicat); plain elements advance nextind.
10144    //   - Unset slots become "" — no sparse arrays (c:3530-3537).
10145    // C returns straight after `setarrvalue(v, fullval)` (c:3538-
10146    // 3540): the resolved vec REPLACES the whole array, so the tail
10147    // AUGMENT prepend below must not run again — clear the flag.
10148    let mut val = val;
10149    let mut flags = flags;
10150    // !!! WARNING: RUST-ONLY ARM — ZLE GSU adapter (array twin of the
10151    // assignsparam arm for BUFFER/LBUFFER/RBUFFER/CURSOR) !!!
10152    // C installs `region_highlight` with a live GSU
10153    // (Src/Zle/zle_params.c:132-133,169 `region_highlight_gsu`): a widget's assignment runs
10154    // set_region_highlight (Src/Zle/zle_refresh.c:488), which parses each
10155    // entry, and every read runs get_region_highlight (c:430), which
10156    // re-renders it — so `memo=someplugin,futureattribute=x` reads back as
10157    // `memo=someplugin`. zshrs stores the published copy in paramtab, so
10158    // inside an active widget scope route the write through the setter and
10159    // store what the getter renders. `+=` is resolved against the current
10160    // value first, as C's setarrvalue does before calling the setfn.
10161    if name == "region_highlight"
10162        && (flags & ASSPM_KEY_VALUE) == 0
10163        && crate::zle_param_sync::active()
10164        && !crate::zle_param_sync::in_live_write()
10165    {
10166        let full: Vec<String> = if (flags & ASSPM_AUGMENT) != 0 {
10167            let mut cur = getaparam(name).unwrap_or_default();
10168            cur.extend(std::mem::take(&mut val));
10169            cur
10170        } else {
10171            std::mem::take(&mut val)
10172        };
10173        crate::ported::zle::zle_refresh::set_region_highlight(Some(&full)); // c:zle_refresh.c:488
10174        val = crate::ported::zle::zle_refresh::get_region_highlight(&param::default()); // c:zle_refresh.c:430
10175        flags &= !ASSPM_AUGMENT;
10176    }
10177    if (flags & ASSPM_KEY_VALUE) != 0 {
10178        let ksh = crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS);
10179        // c:3399 — `origptr = v->pm->gsu.a->getfn(v->pm)`: the prior
10180        // elements come from the parameter's ARRAY GETTER, not from the
10181        // node's own storage. For the IPDEF9 positional alias
10182        // `argv`/`@`/`*` (c:392-393, c:430 `IPDEF9("argv", &pparams,
10183        // NULL, 0)`) that getter is `arrvargetfn` (c:4231-4235,
10184        // `*((char ***)pm->u.data)`), which dereferences `pparams` —
10185        // the LIVE positional-parameter vector. zshrs keeps `pparams`
10186        // in `builtin::PPARAMS` and leaves this node's `u_arr` empty,
10187        // so a plain `u_arr` read here returns a stale global vector.
10188        let orig: Vec<String> = if (flags & ASSPM_AUGMENT) == 0 {
10189            Vec::new()
10190        } else if matches!(name, "argv" | "@" | "*") {
10191            // c:3399 via c:4231 arrvargetfn — read `pparams`.
10192            PPARAMS.lock().map(|p| p.clone()).unwrap_or_default()
10193        } else if existed {
10194            let tab = paramtab().read().unwrap();
10195            tab.get(name)
10196                .and_then(|pm| pm.u_arr.clone())
10197                .unwrap_or_default()
10198        } else {
10199            Vec::new()
10200        };
10201        let mut maxlen = orig.len(); // c:3460-3462
10202        let mut nextind: i64 = 0; // c:3464
10203        let mut subscripts: Vec<i64> = Vec::new(); // c:3455-3456
10204        let mut i = 0usize;
10205        while i < val.len() {
10206            // c:3466-3481
10207            if val[i].starts_with(Marker) {
10208                let key_str = val.get(i + 1).cloned().unwrap_or_default();
10209                let idx = match crate::ported::math::mathevali(&key_str) {
10210                    Ok(n) => n,
10211                    Err(e) => {
10212                        // C mathevali zerr's internally (Src/math.c);
10213                        // surface the message and abort the assign.
10214                        zerr(&e);
10215                        errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed);
10216                        return None;
10217                    }
10218                };
10219                if idx < 0 || (!ksh && idx == 0) {
10220                    // c:3468-3474
10221                    zerr(&format!(
10222                        "bad subscript for direct array assignment: {}",
10223                        key_str
10224                    ));
10225                    errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed);
10226                    return None;
10227                }
10228                let ix = if ksh { idx } else { idx - 1 }; // c:3475-3476
10229                subscripts.push(ix);
10230                nextind = ix + 1; // c:3477
10231                i += 3; // c:3479
10232            } else {
10233                nextind += 1; // c:3481-3482
10234                i += 1;
10235            }
10236            if nextind > maxlen as i64 {
10237                // c:3484-3485
10238                maxlen = nextind as usize;
10239            }
10240        }
10241        // c:3487-3495 fullval = zshcalloc((maxlen+1) * sizeof(char*)).
10242        let mut fullval: Vec<Option<String>> = vec![None; maxlen];
10243        // c:3496-3502 — AUGMENT: copy the original elements in first.
10244        for (j, o) in orig.iter().enumerate() {
10245            fullval[j] = Some(o.clone());
10246        }
10247        // Second pass — c:3503-3525.
10248        let mut si = 0usize;
10249        let mut nextind = 0usize;
10250        let mut i = 0usize;
10251        while i < val.len() {
10252            if val[i].starts_with(Marker) {
10253                let augment_elt = val[i].len() > Marker.len_utf8(); // c:3507 `(*aptr)[1] == '+'`
10254                let ix = subscripts[si] as usize;
10255                si += 1;
10256                let old = fullval[ix].take();
10257                fullval[ix] = Some(match old {
10258                    // c:3510-3512 bicat(old, value)
10259                    Some(o) if augment_elt => format!("{}{}", o, val[i + 2]),
10260                    _ => val[i + 2].clone(),
10261                });
10262                nextind = ix + 1; // c:3516
10263                i += 3;
10264            } else {
10265                fullval[nextind] = Some(val[i].clone()); // c:3519-3520
10266                nextind += 1;
10267                i += 1;
10268            }
10269        }
10270        // c:3530-3537 — unfilled slots become "".
10271        val = fullval.into_iter().map(|o| o.unwrap_or_default()).collect();
10272        // c:3538-3540 — setarrvalue(v, fullval); return. The orig
10273        // elements are already merged in; don't prepend them again.
10274        flags &= !ASSPM_AUGMENT;
10275    }
10276
10277    // c:3402-3412 — ASSPM_AUGMENT preserve-old prepend. When the
10278    // previous value was a scalar (not array/hashed) and we're
10279    // augmenting (`a+=val`), prepend that scalar's string form as
10280    // val[0]. Only fires when the existing param is not PM_UNSET.
10281    let was_scalar_array_target = existed
10282        && prior_flags & (PM_ARRAY | PM_HASHED) as i32 == 0
10283        && prior_flags & PM_SPECIAL as i32 == 0;
10284    if (flags & ASSPM_AUGMENT) != 0 && was_scalar_array_target && prior_flags & PM_UNSET as i32 == 0
10285    {
10286        if let Some(old_scalar) = prior_scalar {
10287            val.insert(0, old_scalar); // c:3408-3411
10288        }
10289    }
10290
10291    // c:3511-3515 — ASSPM_AUGMENT on the IPDEF9 positional alias
10292    // `argv` / `@` / `*` (c:392-393, c:430 `IPDEF9("argv", &pparams,
10293    // NULL, 0)`).
10294    //
10295    //     if (v->start == 0 && v->end == -1) {
10296    //         if (PM_TYPE(v->pm->node.flags) & PM_ARRAY) {
10297    //             v->start = arrlen(v->pm->gsu.a->getfn(v->pm));
10298    //             v->end = v->start + 1;
10299    //         }
10300    //
10301    // The append offset comes from the parameter's ARRAY GETTER. For
10302    // these three names the getter is `arrvargetfn` (c:4231-4235,
10303    // `char **arrptr = *((char ***)pm->u.data);` over `&pparams`) and
10304    // the setter is `arrvarsetfn` (c:4246-4262, `*dptr = x;`) — the
10305    // node has NO storage of its own, `pparams` (c:59, `char **pparams;
10306    // /* $argv */`) IS the storage, and `doshfunc` swaps that vector
10307    // per call so the value is function-local.
10308    //
10309    // zshrs keeps `pparams` in `builtin::PPARAMS` and leaves this
10310    // node's `u_arr` empty, so the generic `u_arr` read below returned
10311    // an empty prior array: `f() { argv+=(X) }; f a b` stored just
10312    // `(X)`, discarding the positionals, and — because `u_arr` is a
10313    // process-global slot with no per-call swap — each later call
10314    // appended to the PREVIOUS call's leftovers (`$#` counted
10315    // 1, 2, 3 … across three identical calls where zsh counts 4 every
10316    // time). Read the live vector instead; the node is PM_SPECIAL |
10317    // PM_ARRAY and is never unset (c:430), so the append always fires
10318    // and needs none of the `existed` / PM_UNSET gates below.
10319    if (flags & ASSPM_AUGMENT) != 0 && matches!(name, "argv" | "@" | "*") {
10320        // c:3514 `arrlen(v->pm->gsu.a->getfn(v->pm))` → c:4231 arrvargetfn.
10321        let mut appended: Vec<String> = PPARAMS.lock().map(|p| p.clone()).unwrap_or_default();
10322        appended.extend(val); // c:3528 setarrvalue writes past the tail
10323        val = appended;
10324    }
10325    // c:3570-3585 — ASSPM_AUGMENT on an existing PM_ARRAY target:
10326    // append rather than replace. C bumps v->start to arrlen(existing)
10327    // and v->end to start+1 so setarrvalue writes past the tail.
10328    // zshrs writes through pm.u_arr without the value struct, so do
10329    // the equivalent here: prepend the existing array elements to the
10330    // new val so the final stored vec is [old..., new...].
10331    else if (flags & ASSPM_AUGMENT) != 0
10332        && existed
10333        && (prior_flags as u32 & PM_ARRAY) != 0
10334        && (prior_flags as u32 & PM_UNSET) == 0
10335    {
10336        // Fast in-place append. `arr+=(x)` in a loop is the hot path.
10337        // The general branch below clones the prior array, re-collects
10338        // [old…, new…], re-stores it, and then clones the whole `param`
10339        // for the return value — four O(n) copies per call, so N appends
10340        // are O(N²) (this is the "terrible slowness for array append"
10341        // the profiler pinned on `Vec<String>::clone` under
10342        // `assignaparam`). For a plain array — no special setfn, no env
10343        // tie (`ename`), no PM_UNIQUE dedupe, no colon-tie, not the
10344        // `argv`/`@`/`*` positional alias — push the new elements
10345        // straight onto `pm.u_arr` under the write lock (amortized O(k))
10346        // and return a cheap non-None marker. Every non-test caller uses
10347        // the return only via `.is_none()` for the error path.
10348        let is_special = (prior_flags as u32 & (PM_SPECIAL | PM_UNIQUE)) != 0;
10349        let is_positional = name == "argv" || name == "@" || name == "*";
10350        let is_colon_tie = TIED_COLON_ARRAYS.iter().any(|(a, _)| *a == name);
10351        if !is_special && !is_positional && !is_colon_tie {
10352            let mut tab = paramtab().write().unwrap();
10353            if let Some(pm) = tab.get_mut(name) {
10354                if pm.ename.is_none() && (pm.node.flags as u32 & (PM_SPECIAL | PM_UNIQUE)) == 0 {
10355                    pm.u_arr.get_or_insert_with(Vec::new).extend(val);
10356                    pm.u_str = None;
10357                    pm.u_hash = None;
10358                    // c:2712 setarrvalue head — clears UNSET on first write.
10359                    pm.node.flags &= !((PM_UNSET | PM_DECLARED) as i32);
10360                    let mut marker = param::default();
10361                    marker.node.nam = name.to_string();
10362                    marker.node.flags = pm.node.flags;
10363                    return Some(Box::new(marker));
10364                }
10365            }
10366            // Param vanished or grew a tie under the lock — fall back to
10367            // the general read-modify-write path below.
10368            drop(tab);
10369        }
10370        let prior_arr = {
10371            let tab = paramtab().read().unwrap();
10372            tab.get(name)
10373                .and_then(|pm| pm.u_arr.clone())
10374                .unwrap_or_default()
10375        };
10376        let appended: Vec<String> = prior_arr.into_iter().chain(val.into_iter()).collect();
10377        val = appended;
10378    }
10379
10380    // c:3432 `if (flags & ASSPM_WARN) check_warn_pm(v->pm, "array", created, may_warn_about_nested_vars);`
10381    // c:3372 — `may_warn_about_nested_vars = !(flags & ASSPM_AUGMENT)`.
10382    if (flags & ASSPM_WARN) != 0 {
10383        let may_nested = if (flags & ASSPM_AUGMENT) != 0 { 0 } else { 1 };
10384        if let Some(pm_ref) = paramtab().read().unwrap().get(name) {
10385            check_warn_pm(pm_ref, "array", created_now as i32, may_nested); // c:3432
10386        }
10387    }
10388
10389    // c:3434 — setarrvalue(v, val): store array in pm.u_arr.
10390    let mut tab = paramtab().write().unwrap();
10391    let pm = tab.get_mut(name)?;
10392    let uniq = pm.node.flags & PM_UNIQUE as i32 != 0; // c:3401
10393    if pm.node.flags & PM_SPECIAL as i32 == 0 {
10394        let type_mask = PM_ARRAY | PM_INTEGER | PM_EFLOAT | PM_FFLOAT | PM_HASHED | PM_NAMEREF;
10395        pm.node.flags = (pm.node.flags & !type_mask as i32) | PM_ARRAY as i32;
10396    }
10397    // c:3401 — preserve PM_UNIQUE through the type change, then let
10398    // arrsetfn dedupe via the actual write.
10399    if uniq {
10400        pm.node.flags |= PM_UNIQUE as i32;
10401    }
10402    let val_final = if uniq { simple_arrayuniq(val) } else { val };
10403    // c:3434 — `setarrvalue(v, val);` → `gsu.a->setfn(pm, val)` for
10404    // PM_SPECIAL params (PATH/path → pathsetfn rebuilds $PATH cstring;
10405    // FPATH/fpath → fpathsetfn rebuilds; MANPATH etc.). Without this
10406    // dispatch, env var pairs (PATH vs path) drift apart on array
10407    // assignment. Fall back to direct u_arr write when no gsu_a is
10408    // wired (regular non-special arrays).
10409    let setfn_ptr = pm.gsu_a.as_ref().map(|g| g.setfn);
10410    // c:Src/params.c:3434 — `setarrvalue(v, val)` calls `gsu.a->setfn(pm, val)`.
10411    // The canonical arrsetfn (params.rs:6584) writes `pm->u_arr` then,
10412    // if `pm->ename` is set, calls `arrfixenv` which re-acquires the
10413    // paramtab lock. We're holding the WRITE lock here — that would
10414    // deadlock the RWLock. Inline the storage write under the held
10415    // lock and defer arrfixenv to AFTER drop(tab). Bug #600.
10416    pm.u_arr = Some(val_final.clone());
10417    pm.u_str = None;
10418    pm.u_hash = None;
10419    // c:3374 `v->pm->node.flags &= ~PM_DEFAULTED;` — a declared-but-unset
10420    // (TYPESET_TO_UNSET) array becomes set on its first assignment.
10421    // PM_DEFAULTED is `PM_DECLARED|PM_UNSET` (c:Src/zsh.h:1933), hence
10422    // the two-bit mask here.
10423    //
10424    // PM_DECLARED is the same bit as PM_DONTIMPORT (c:Src/zsh.h:1926-1927);
10425    // keep it on a PM_SPECIAL node that carries it, where it can only be
10426    // the do-not-import marker the environment-import loop reads
10427    // (c:934). Full rationale on the scalar twin of this clear in
10428    // `assignsparam` (c:3211). Without it the IPDEF9 arrays `path` /
10429    // `fpath` (c:425-432 — `PM_ARRAY|PM_SPECIAL|PM_DONTIMPORT`) lost the
10430    // bit while being seeded at startup.
10431    let keep_dontimport =
10432        (pm.node.flags as u32 & (PM_SPECIAL | PM_DONTIMPORT)) == (PM_SPECIAL | PM_DONTIMPORT);
10433    pm.node.flags &= !((PM_UNSET | PM_DECLARED) as i32); // c:3374
10434    if keep_dontimport {
10435        pm.node.flags |= PM_DONTIMPORT as i32;
10436    }
10437    // c:Src/params.c:4027-4029 — `if (pm->ename && x) arrfixenv(pm->ename, x);`
10438    // arrsetfn runs this UNCONDITIONALLY; it does not consult the gsu. Gating
10439    // it on a wired `gsu_a` setfn meant a USER tie — whose array half
10440    // bin_typeset builds with no gsu at all (builtin.rs, `apm`) — never
10441    // republished to the environment: `local -xT OUTER outer; outer=(i n n e r)`
10442    // read back as `i:n:n:e:r` inside the shell while every child saw `$OUTER`
10443    // empty. arrfixenv itself returns early unless the scalar half is
10444    // PM_EXPORTED (c:5311-5312), so this is a no-op for unexported ties.
10445    let _ = setfn_ptr;
10446    let ename_for_envsync: Option<String> = pm.ename.clone();
10447    let cloned = pm.clone();
10448    drop(tab);
10449    // c:Src/params.c:5285 arrfixenv — deferred from inside the lock
10450    // (see Bug #600 above). Acquires its own paramtab read+write
10451    // locks; safe to call now that we've dropped the write lock.
10452    if let Some(ename) = ename_for_envsync {
10453        arrfixenv(&ename, Some(&val_final));
10454    }
10455    // c:Src/params.c:3434 — `setarrvalue(v, val)` runs the array's setfn, and
10456    // for a colon-tied special that setfn republishes the joined value to its
10457    // paired SCALAR. zshrs routes that through `ename` + `arrfixenv` above, but
10458    // only when a `gsu_a` setfn exists — and path/fpath/cdpath/… are registered
10459    // from the `special_paramdef` DATA table (params.rs:1027) with no gsu
10460    // pointers at all, so `ename_for_envsync` was None and the scalar never
10461    // moved. A GLOBAL `path=(…)` hides the bug because it compiles to
10462    // BUILTIN_SET_ARRAY in the bridge, which carries the tie itself; a
10463    // `local path=(…)` is a typeset DECLARATION and lands here instead, so
10464    // `$PATH` — and therefore command lookup inside the function — kept the
10465    // outer value. docs/BUGS.md #1039 A.
10466    //
10467    // Writes the scalar straight into paramtab rather than calling
10468    // `assignsparam`, matching the existing sync sites: assignsparam carries
10469    // the scalar→array half of the same tie, so routing through it would
10470    // recurse.
10471    if let Some((_, sc)) = TIED_COLON_ARRAYS.iter().find(|(a, _)| *a == name) {
10472        let joined = val_final.join(":");
10473        if let Ok(mut tab) = paramtab().write() {
10474            if let Some(entry) = tab.get_mut(*sc) {
10475                entry.u_str = Some(joined.clone());
10476                entry.u_arr = None;
10477            }
10478        }
10479        // Keep the process environment and the command hash in step, exactly as
10480        // the scalar-side sync does — without the rehash, `command -v` inside
10481        // the function still resolves against the stale PATH.
10482        std::env::set_var(sc, &joined);
10483        if *sc == "PATH" {
10484            crate::ported::hashtable::emptycmdnamtable();
10485        }
10486    }
10487    // c:Src/params.c:3262 IPDEF9 — \`argv\`/\`@\`/\`*\` are aliases for
10488    // the C global \`pparams\` (the positional parameter vector).
10489    // assignaparam("argv", [...]) in C writes through the array's
10490    // setfn which mutates \`pparams\` directly. zshrs's pparams lives
10491    // in builtin::PPARAMS; mirror the write so \`argv=(...)\` updates
10492    // \$1/\$2/.../\$# correctly.
10493    if name == "argv" || name == "@" || name == "*" {
10494        if let Ok(mut pp) = crate::ported::builtin::PPARAMS.lock() {
10495            *pp = val_final.clone();
10496        }
10497    }
10498    let _ = val_final;
10499    Some(cloned)
10500}
10501
10502/// Set array parameter.
10503/// Port of `setaparam()` from `Src/params.c:3595` — single-line wrapper
10504/// around `assignaparam(s, val, ASSPM_WARN)`. C body:
10505/// ```c
10506/// mod_export Param setaparam(char *s, char **val) {
10507///     return assignaparam(s, val, ASSPM_WARN);
10508/// }
10509/// ```
10510///
10511/// `ASSPM_WARN` (params.c:104) drives the WARN_CREATE_GLOBAL /
10512/// WARN_NESTED_VAR diagnostics inside `assignaparam` →
10513/// `check_warn_pm` (params.rs:4428).
10514/// WARNING: param names don't match C — Rust=() vs C=(s, val)
10515pub fn setaparam(name: &str, val: Vec<String>) -> Option<Param> {
10516    // !!! RUST-ONLY PLACEMENT !!! C's assignaparam keeps an existing ordinary
10517    // PM_HASHED hashed — c:3341-3342 `else if (!(PM_TYPE(v->pm->node.flags) &
10518    // (PM_ARRAY|PM_HASHED)) && ...)` excludes it from the reset — and c:3434
10519    // `setarrvalue` hands the words to `arrhashsetfn` (c:2918-2920) as
10520    // key/value pairs; `sethparam` is that chain (c:3651). The port's
10521    // assignaparam instead rewrites the type to PM_ARRAY, and `typeset -a h`
10522    // on an existing assoc relies on that rewrite (C converts in
10523    // typeset_single before assigning), so the C behaviour is applied at this
10524    // entry point, which typeset does not use. Without it
10525    // `typeset -A h; read -A h <<< 'k v'` and `zstat -A h -n` made `h` a
10526    // plain array (D07multibyte.ztst, workers/50150).
10527    let hashed = paramtab()
10528        .read()
10529        .ok()
10530        .and_then(|tab| tab.get(name).map(|pm| pm.node.flags as u32))
10531        .map(|f| PM_TYPE(f) == PM_HASHED && f & (PM_SPECIAL | PM_UNSET) == 0)
10532        .unwrap_or(false);
10533    if hashed {
10534        return sethparam(name, val); // c:3434 → c:2920 arrhashsetfn
10535    }
10536    // c:3766 — `return assignaparam(s, val, ASSPM_WARN)`.
10537    assignaparam(name, val, ASSPM_WARN)
10538}
10539
10540/// Port of `sethparam()` from `Src/params.c:3602`. Writes an associative array (flat
10541/// alternating key,value list) into paramtab + the parallel
10542/// `paramtab_hashed_storage` table; returns the new Param.
10543///
10544/// Ported C semantics:
10545///   - PM_READONLY rejection (c:3625 via setarrvalue chain in C; here inline)
10546///   - PM_SPECIAL type-change reject (c:3637)
10547/// Pending:
10548///   - resetparam(PM_HASHED) for non-special type-change (rare)
10549pub fn sethparam(name: &str, val: Vec<String>) -> Option<Param> {
10550    // c:3611-3615 — `if (!isident(s)) { zerr; return NULL }`.
10551    if !isident(name) {
10552        zerr(&format!("not an identifier: {}", name));
10553        return None;
10554    }
10555    // !!! RUST-ONLY: provenance tap (see assignsparam).
10556    if crate::provenance::active() {
10557        crate::provenance::on_param_write(name, "assoc", &val.join(" "));
10558    }
10559    // c:3630 — `fetchvalue(&vbuf, &s, 1, SCANPM_ASSIGNING)` resolves
10560    // PM_NAMEREF chains (same shape as assignaparam c:3392-3398).
10561    if crate::ported::params::is_nameref(name) {
10562        match crate::ported::params::resolve_nameref_name(name, None) {
10563            crate::ported::params::nameref_resolution::SelfRef => {
10564                errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed);
10565                return None;
10566            }
10567            crate::ported::params::nameref_resolution::OutOfScope => {
10568                return None;
10569            }
10570            crate::ported::params::nameref_resolution::Placeholder(..) => {
10571                zwarn(&format!("{}: can't change type of a named reference", name));
10572                return None;
10573            }
10574            crate::ported::params::nameref_resolution::Target {
10575                name: t,
10576                subscript: None,
10577                ..
10578            } => {
10579                if t != name {
10580                    return sethparam(&t, val);
10581                }
10582            }
10583            _ => {}
10584        }
10585    }
10586    // c:3617-3621 — `if (strchr(s, '[')) { zerr; return NULL }`.
10587    if name.contains('[') {
10588        zerr("nested associative arrays not yet supported");
10589        return None;
10590    }
10591
10592    // c:3625 — PM_READONLY rejection. C routes through gsu.h->setfn
10593    // which checks readonly inside hashsetfn / arrhashsetfn; Rust
10594    // bypasses that path, so check explicitly here.
10595    {
10596        let tab = paramtab().read().unwrap();
10597        if let Some(pm) = tab.get(name) {
10598            if (pm.node.flags as u32 & PM_READONLY) != 0 {
10599                drop(tab); // zerr redraws ZLE, which reads paramtab
10600                zerr(&format!("read-only variable: {}", name));
10601                errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed);
10602                return None;
10603            }
10604            // c:3637 — `if (PM_TYPE(pm->node.flags) != PM_HASHED &&
10605            //              (pm->node.flags & PM_SPECIAL)) { zerr; return; }`
10606            // Can't change type of a PM_SPECIAL non-hashed param.
10607            let pm_type = pm.node.flags as u32 & PM_TYPE(u32::MAX);
10608            if pm_type != PM_HASHED && (pm.node.flags as u32 & PM_SPECIAL) != 0 {
10609                drop(tab); // zerr redraws ZLE, which reads paramtab
10610                zerr(&format!(
10611                    "{}: can't change type of a special parameter",
10612                    name
10613                ));
10614                errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed);
10615                return None;
10616            }
10617        }
10618    }
10619
10620    // c:3625 — fetchvalue / createparam(PM_HASHED) if missing.
10621    let exists = paramtab().read().unwrap().contains_key(name);
10622    let checkcreate = !exists; // c:3626 `checkcreate = 1;`
10623    if !exists {
10624        createparam(name, PM_HASHED as i32)?;
10625    }
10626
10627    // c:3649 `check_warn_pm(v->pm, "associative array", checkcreate, 1);`
10628    // — sethparam always warns (no ASSPM_WARN gate in C).
10629    if let Some(pm_ref) = paramtab().read().unwrap().get(name) {
10630        check_warn_pm(pm_ref, "associative array", checkcreate as i32, 1); // c:3649
10631    }
10632
10633    // c:3651 — `setarrvalue(v, val);` — full-replace dispatch for a
10634    // PM_HASHED param (setarrvalue c:2919-2920) collapses to
10635    // `arrhashsetfn(v->pm, val, 0)`, which owns the odd-count gate
10636    // (c:4128-4131, zerr + ERRFLAG_ERROR) and the pair walk.
10637    // !!! LOCK DISCIPLINE — NO GSU CALLBACK MAY RUN UNDER THE GUARD !!!
10638    // Same rule, and the same reasoning, as the long note in `assignnparam`:
10639    // C holds no lock, so `arrhashsetfn` (c:4113) is free to reach anything,
10640    // and it does — its odd-pair rejection at c:4129-4131 calls `zerr`, which
10641    // is not a leaf (zwarning → zleentry(ZLE_CMD_TRASH) → the ZLE repaint,
10642    // which reads parameters and takes this very RwLock). `typeset -A h; h=(a
10643    // b c)` in an interactive shell was therefore one `zerr` away from parking
10644    // the shell on itself. Commit the flags under the guard, detach, release,
10645    // then dispatch — the hash contents go to `paramtab_hashed_storage`, a
10646    // separate map, so there is nothing to publish back.
10647    let mut staged = {
10648        let mut tab = paramtab().write().unwrap();
10649        let pm = tab.get_mut(name)?;
10650        if pm.node.flags & PM_SPECIAL as i32 == 0 {
10651            let type_mask = PM_ARRAY | PM_INTEGER | PM_EFLOAT | PM_FFLOAT | PM_HASHED | PM_NAMEREF;
10652            pm.node.flags = (pm.node.flags & !type_mask as i32) | PM_HASHED as i32;
10653        }
10654        // c:3590 — `v->pm->node.flags &= ~PM_DEFAULTED;` before the
10655        // setarrvalue: an assignment clears the PM_UNSET|PM_DECLARED state
10656        // a TYPESET_TO_UNSET declaration (`typeset -A h`) left behind, so a
10657        // later `typeset -p h` prints the value instead of the bare
10658        // declaration.
10659        pm.node.flags &= !(PM_DEFAULTED as i32); // c:3590
10660        pm.u_arr = None;
10661        pm.u_str = None;
10662        (**pm).clone()
10663    };
10664    // ---- guard released ----
10665    arrhashsetfn(&mut staged, val, 0); // c:3651 via setarrvalue c:2920
10666
10667    // c:3652-3653 — `unqueue_signals(); return v->pm;` — C returns
10668    // the param even when arrhashsetfn errored; the failure travels
10669    // via errflag (callers like the SET_ARRAY bridge check it).
10670    Some(Box::new(staged))
10671}
10672
10673// -----------------------------------------------------------
10674// Param-table mutators / scope / nameref helpers.
10675// `Src/params.c` calls these against the global `paramtab`; here they
10676// operate on the typed `paramtab()` store.
10677// -----------------------------------------------------------
10678
10679/// Port of `assignnparam()` from `Src/params.c:3664`. C body
10680/// looks up the param via `gethashnode2(realparamtab, s)`,
10681/// dispatches on PM_TYPE: PM_INTEGER → `intsetfn(pm, val.u.l)`;
10682/// PM_FFLOAT/EFLOAT → `floatsetfn(pm, val.u.d)`; otherwise
10683/// `assignstrvalue(&v, conv_to_string(val), flags)`. Signature
10684/// mirrors C `mnumber val`.
10685/// flow: isident guard → unset(EXECOPT) bail → `getvalue(&vbuf,&s,1)`
10686/// → if existing array/hashed (non-special, non-tied, non-KSHARRAYS,
10687/// no subscript) → unsetparam_pm + recreate → else if no value →
10688/// `createparam(t, type)` (POSIXIDENTIFIERS gates SCALAR vs
10689/// MN_INTEGER→PM_INTEGER else PM_FFLOAT) → second `getvalue` →
10690/// `check_warn_pm` if ASSPM_WARN → clear PM_DEFAULTED → `setnumvalue`
10691/// → return pm. The create-new-param branch calls `createparam`
10692/// and returns None only when the parameter cannot be found or created.
10693pub fn assignnparam(s: &str, val: mnumber, flags: i32) -> Option<Box<param>> {
10694    // c:3666 `if (!isident(s)) { zerr; errflag |= ERRFLAG_ERROR; return NULL; }`
10695    if !isident(s) {
10696        zerr(&format!("not an identifier: {}", s)); // c:3667
10697        errflag.fetch_or(
10698            // c:3669
10699            ERRFLAG_ERROR,
10700            Ordering::Relaxed,
10701        );
10702        return None; // c:3670
10703    }
10704    if unset(EXECOPT) {
10705        return None;
10706    }
10707    let mut vbuf = value {
10708        pm: None,
10709        arr: Vec::new(),
10710        scanflags: 0,
10711        valflags: 0,
10712        start: 0,
10713        end: -1,
10714    };
10715    let mut cursor: &str = s;
10716    let has_sub = s.contains('[');
10717    let mut was_unset = false;
10718    let v = getvalue(Some(&mut vbuf), &mut cursor, 1);
10719    let need_create = match v {
10720        Some(ref vv) => {
10721            if let Some(pm) = vv.pm.as_ref() {
10722                let f = pm.node.flags as u32;
10723                if (f & (PM_ARRAY | PM_HASHED)) != 0
10724                    && (f & (PM_SPECIAL | PM_TIED)) == 0
10725                    && unset(KSHARRAYS)
10726                    && !has_sub
10727                {
10728                    // unsetparam_pm(vv.pm, 0, 1);
10729                    was_unset = true;
10730                    true
10731                } else {
10732                    false
10733                }
10734            } else {
10735                true
10736            }
10737        }
10738        None => true,
10739    };
10740    if need_create {
10741        // c:3686-3716 — create the parameter (or revive the existing node
10742        // createparam declines), then store the number through the same
10743        // setnumvalue path a reassignment takes.
10744        // c:Src/params.c assignnparam — createparam picks the type as
10745        //   ss ? PM_ARRAY : isset(POSIXIDENTIFIERS) ? PM_SCALAR
10746        //     : (val.type & MN_INTEGER) ? PM_INTEGER : PM_FFLOAT
10747        // So `let` / `(( … ))` do NOT auto-type a NEW variable as integer when
10748        // POSIXIDENTIFIERS is set — the var is a plain scalar holding the
10749        // decimal string, so a later `y=abc` keeps the string (plain zsh, with
10750        // POSIXIDENTIFIERS off, arith-coerces it to 0). `typeset -i` is
10751        // unaffected: it pre-creates the integer param, so `need_create` is
10752        // false there. POSIXIDENTIFIERS is set by BOTH `emulate sh` and
10753        // `emulate ksh` (and their `--sh`/`--ksh` drop-ins), off in zsh — so
10754        // this correctly covers every non-zsh leg. Verified: `let 'y=5';
10755        // y=abc; print $y` → `abc` in ksh93/mksh AND zsh `emulate sh`/`ksh`;
10756        // only plain zsh → `0`. Also fixes `(( x=010 ))` → scalar `8` (was the
10757        // integer `8#10`) under emulate sh.
10758        if isset(crate::ported::zsh_h::POSIXIDENTIFIERS) {
10759            let sval = if val.type_ == MN_FLOAT {
10760                convfloat_underscore(val.d, 0)
10761            } else {
10762                convbase_underscore(val.l, 10, 0)
10763            };
10764            return assignsparam(s, &sval, flags);
10765        }
10766        // c:3678-3685 — a plain (non-special, untied) array or hash that is
10767        // assigned a number without a subscript is removed first so it can be
10768        // recreated as a number:
10769        //     unsetparam_pm(v->pm, 0, 1);
10770        //     was_unset = 1;
10771        if was_unset {
10772            unsetparam(s); // c:3681
10773        }
10774        let new_type = if val.type_ == MN_FLOAT {
10775            PM_FFLOAT // c:3690
10776        } else {
10777            PM_INTEGER // c:3690
10778        };
10779        // c:3686-3690 — `ss = strchr(s, '['); if (ss) *ss = '\0';
10780        // pm = createparam(t, ss ? PM_ARRAY : ... );`
10781        let base_name: &str = match s.find('[') {
10782            Some(i) => &s[..i],
10783            None => s,
10784        };
10785        let created = createparam(base_name, if has_sub { PM_ARRAY as i32 } else { new_type as i32 });
10786        // c:3691-3695 — `if (errflag) { unqueue_signals(); return NULL; }`
10787        if (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0 {
10788            return None;
10789        }
10790        // c:3696-3701 — `if (!pm && !(pm = paramtab->getnode(paramtab, t)))`:
10791        // createparam declines for a node that already exists (a special, or a
10792        // set parameter) after clearing its PM_UNSET (c:1130-1152); the
10793        // assignment then lands on that node.
10794        let found = created.is_some()
10795            || paramtab()
10796                .read()
10797                .map(|t| t.contains_key(base_name))
10798                .unwrap_or(false);
10799        if !found {
10800            zerr(&format!("{}: parameter not found", base_name)); // c:3699
10801            return None; // c:3701
10802        }
10803        if !has_sub && val.type_ != MN_FLOAT {
10804            // c:3704-3705 — `else if (val.type & MN_INTEGER) pm->base = outputradix;`
10805            if let Ok(mut tab) = paramtab().write() {
10806                if let Some(pm) = tab.get_mut(base_name) {
10807                    pm.base = crate::ported::math::outputradix();
10808                }
10809            }
10810        }
10811        // c:3706-3711 — the second `getvalue(&vbuf, &t, 1)`; the value write
10812        // (c:3716 `setnumvalue(v, val)`) is the reassign path below.
10813        // c:3712-3713 — `if (flags & ASSPM_WARN) check_warn_pm(v->pm,
10814        // "numeric", !was_unset, 1);`. The new parameter was CREATED, so
10815        // WARN_CREATE_GLOBAL reports `(( n=8 ))` in a function exactly as
10816        // it reports the scalar `n=8`.
10817        if (flags & ASSPM_WARN) != 0 {
10818            let snapshot = paramtab().read().ok().and_then(|t| t.get(base_name).cloned());
10819            if let Some(pm) = snapshot {
10820                check_warn_pm(&pm, "numeric", (!was_unset) as i32, 1);
10821            }
10822        }
10823    } else if (flags & ASSPM_WARN) != 0 {
10824        // c:3714-3715
10825        if let Some(ref vv) = v {
10826            if let Some(ref pm) = vv.pm {
10827                check_warn_pm(pm, "numeric", 0, 1);
10828            }
10829        }
10830    }
10831    // The reassign path: getvalue gave us a cloned pm inside the value
10832    // buffer. setnumvalue mutates that clone but the write doesn't
10833    // propagate back to paramtab. Write through paramtab directly so
10834    // reassignments stick — same shape as `assignsparam`'s c:3343
10835    // `assignstrvalue(v, val, flags)` path which mutates paramtab in
10836    // place.
10837    //
10838    // !!! LOCK DISCIPLINE — NO GSU CALLBACK MAY RUN UNDER THE GUARD !!!
10839    //
10840    // C holds no lock here at all, so `Src/params.c` gives no hint that the
10841    // dispatch is dangerous. `setnumvalue` (c:2846) hands the setfn a pointer
10842    // to the LIVE node — `v->pm->gsu.i->setfn(v->pm, ...)` (c:2870) — and the
10843    // setfn is free to read or write any OTHER parameter while it runs.
10844    // Several do: `zlevarsetfn` (c:4226) re-enters `adjustwinsize`,
10845    // `histsizesetfn` (c:4974) resizes the history, and every arm that can
10846    // fail reaches `zerr`, which is not a leaf either (zwarning →
10847    // zleentry(ZLE_CMD_TRASH) → the ZLE repaint, which reads parameters).
10848    // `paramtab()` is a `std::sync::RwLock` and is NOT reentrant, so any of
10849    // those under the write guard parks the shell on itself — blocked in
10850    // `semaphore_wait_trap`, not spinning. That shipped twice already: once as
10851    // the `$LINES` startup hang that silenced every `zsh/zpty`-spawned shell
10852    // (fixed in 5929e82b81 by removing a Rust-only return value from
10853    // `adjustwinsize` — the callee, not the pattern), and once as the
10854    // `read-only variable` report below.
10855    //
10856    // So the guard is released BEFORE the dispatch, and the order C commits in
10857    // is preserved exactly:
10858    //
10859    //   1. under the guard, commit everything C has already written to the
10860    //      node by the time `setnumvalue` is entered — the `~PM_DEFAULTED`
10861    //      clear (c:3671) and the `pm->base` inheritance (c:2801) — and take
10862    //      the readonly rejection (c:2852), which C also takes before any
10863    //      dispatch;
10864    //   2. release the guard;
10865    //   3. run the setfn (c:2870 / c:2876) against a detached node, which is
10866    //      what C's pointer-to-the-live-node amounts to once no lock stands in
10867    //      the way. The setfn may now take the paramtab lock itself, in either
10868    //      mode, as deeply as it likes;
10869    //   4. re-take the guard only to publish the union slot the setfn wrote.
10870    //
10871    // Step 4 is the one place this port can still differ from C: a setfn that
10872    // reassigned ITS OWN parameter through the table would have that write
10873    // overwritten by the publish. No setfn reachable from here does (every arm
10874    // of `intsetfn` either writes `pm` directly or writes a global), and a
10875    // future one must not. There is deliberately no lock-ordering rule to
10876    // violate instead — after step 2 no paramtab guard is held at all, so a
10877    // setfn cannot deadlock against this caller no matter what it touches.
10878    //
10879    // `staged` carries the node between the three phases.
10880    let mut staged: param;
10881    let t: u32;
10882    {
10883        let Ok(mut tab) = paramtab().write() else {
10884            return None;
10885        };
10886        if let Some(pm) = tab.get_mut(s) {
10887            // c:Src/params.c — setnumvalue (the C function this
10888            // reassign-path mirrors) eventually calls setfn which
10889            // goes through assignstrvalue (params.c:2899-2904) where
10890            // PM_READONLY rejection lives. The Rust port writes
10891            // u_val / u_dval / u_str directly, bypassing that path,
10892            // so the readonly check has to happen here. Without it,
10893            // `(( x++ ))` / `let "x = ..."` could mutate readonly
10894            // params silently. Bug #154 in docs/BUGS.md.
10895            if (pm.node.flags as u32 & PM_READONLY) != 0 {
10896                // zerr internally sets ERRFLAG_ERROR via the
10897                // c:194 path in Src/utils.c. Match the existing
10898                // readonly check at params.rs:3951 (assignstrvalue
10899                // arm) — same call shape, same behavior.
10900                //
10901                // The name is copied and the paramtab guard DROPPED before
10902                // reporting. `zerr` is not a leaf: it reaches zwarning ->
10903                // zleentry -> zle_main_entry -> trashzle -> zrefresh, and the
10904                // refresh reads a parameter (getaparam -> is_nameref), which
10905                // takes this same RwLock. std's RwLock is not reentrant, so
10906                // reporting under the write guard deadlocked the shell against
10907                // itself on one thread — a live interactive session was caught
10908                // parked in lock_contended with exactly that 98-frame stack.
10909                // Reached whenever a widget publishes a read-only ZLE
10910                // parameter (makezleparams -> setiparam -> here), which is why
10911                // it presented as "the prompt froze".
10912                let nam = pm.node.nam.clone();
10913                drop(tab);
10914                zerr(&format!("read-only variable: {}", nam));
10915                return None;
10916            }
10917            // c:3671 `v->pm->node.flags &= ~PM_DEFAULTED;` — PM_DECLARED
10918            // shares its bit with PM_DONTIMPORT (c:Src/zsh.h:1926-1927);
10919            // keep it on a PM_SPECIAL node that carries it. Full
10920            // rationale on the same clear in `assignsparam` (c:3211).
10921            let keep_dontimport = (pm.node.flags as u32 & (PM_SPECIAL | PM_DONTIMPORT))
10922                == (PM_SPECIAL | PM_DONTIMPORT);
10923            pm.node.flags &= !(PM_DEFAULTED as i32); // c:3671
10924            if keep_dontimport {
10925                pm.node.flags |= PM_DONTIMPORT as i32;
10926            }
10927            t = PM_TYPE(pm.node.flags as u32);
10928            if t == PM_INTEGER {
10929                // c:Src/params.c:2801 — `if (!v->pm->base && lastbase
10930                // != -1) v->pm->base = lastbase;`. After setfn the C
10931                // path falls through `setstrvalue(v, NULL)` which
10932                // inherits the source numeric base from `lastbase`
10933                // when the param doesn't yet have an explicit base.
10934                // Mirror that here so `(( x = 0xFF ))` on an existing
10935                // integer param updates pm.base to 16, displaying as
10936                // `16#FF` instead of decimal `255`. Bug #175 in
10937                // docs/BUGS.md. The create path already inherits at
10938                // params.rs:5759-5768; this is the reassign-path
10939                // equivalent.
10940                // Posix-faithful: no per-var output base (see the create-path
10941                // gate above) — bash/ksh/POSIX integers print decimal.
10942                //
10943                // This stays INSIDE the guard because C has already applied it
10944                // to the live node by the time the setfn runs, and a setfn is
10945                // entitled to read `pm->base`.
10946                if pm.base == 0 && !crate::dash_mode::posix_faithful() {
10947                    let lb = crate::ported::math::lastbase();
10948                    if lb > 0 {
10949                        pm.base = lb;
10950                    }
10951                }
10952            }
10953            // c:2856-2880 — `setnumvalue`'s type switch. The STORAGE half of
10954            // each arm lands here, under the guard, because that is where C
10955            // lands it: C's setfn writes through the live node pointer, so the
10956            // value is visible to everything that runs afterwards without any
10957            // handoff. Doing it here also means the common case needs no second
10958            // guard at all — see the publish below.
10959            match t {
10960                // c:2870-2871 — `(val.type & MN_INTEGER) ? val.u.l : (zlong) val.u.d`.
10961                // This is `intsetfn`'s own body (c:4007 `pm->u.val = x`) for
10962                // every name it does not special-case; the queued dispatch
10963                // below adds the side effects only the special names have.
10964                PM_INTEGER => {
10965                    pm.u_val = if val.type_ == MN_FLOAT {
10966                        crate::zlong_cast::zlong_from_double(val.d)
10967                    } else {
10968                        val.l
10969                    }
10970                }
10971                // c:2876-2877 — MN_INTEGER input promotes to f64.
10972                PM_EFLOAT | PM_FFLOAT => {
10973                    pm.u_dval = if val.type_ == MN_FLOAT {
10974                        val.d
10975                    } else {
10976                        val.l as f64
10977                    }
10978                }
10979                // c:2860-2867 — convbase/convfloat → `setstrvalue(v, ztrdup(p))`.
10980                PM_SCALAR | PM_NAMEREF | PM_ARRAY => {
10981                    pm.u_str = Some(if val.type_ == MN_FLOAT {
10982                        convfloat_underscore(val.d, pm.width)
10983                    } else {
10984                        convbase_underscore(val.l, if pm.base > 0 { pm.base } else { 10 }, pm.width)
10985                    })
10986                }
10987                _ => {}
10988            }
10989            // Phase 1 ends: everything C had written to the node before
10990            // entering `setnumvalue` (c:2846) is committed. Detach and release.
10991            staged = (**pm).clone();
10992        } else {
10993            return None;
10994        }
10995    }
10996    // ---- guard released; no paramtab lock is held from here on ----
10997    //
10998    // Phase 2 — the setfn dispatch this whole restructure exists for. Only the
10999    // integer arm has one: c:2870 `v->pm->gsu.i->setfn(v->pm, ...)`. It is now
11000    // free to take the paramtab lock in either mode, as deeply as it likes.
11001    //
11002    // For `$COLUMNS` / `$LINES` it lands in `zlevarsetfn` (c:362-363
11003    // `IPDEF5(..., zlevar_gsu)`), whose c:4230 `*p = x` and c:4232
11004    // `adjustwinsize(2 + (p == &zterm_columns))` now BOTH run, in C's order,
11005    // from C's one call site. `assignnparam` used to open-code the `*p = x`
11006    // half by name and call `adjustwinsize` itself after `drop(tab)` — a
11007    // name-keyed special case covering only the two names somebody had already
11008    // been bitten by, which left every other parameter-reading setfn
11009    // (`intsecondssetfn`'s `zwarn`, the `uidsetfn` / `euidsetfn` / `gidsetfn` /
11010    // `egidsetfn` failure reports) still dispatching under the guard.
11011    let committed = staged.u_val;
11012    if t == PM_INTEGER {
11013        intsetfn(&mut staged, committed); // c:2870
11014    }
11015    // Phase 3 — publish, IF the setfn wrote a value phase 1 had not already
11016    // committed. C needs no equivalent at all: its setfn wrote through the live
11017    // node pointer. No setfn reachable from here produces a different value —
11018    // each either stores exactly what it was handed or leaves `pm` alone and
11019    // writes a global — so this costs no second guard on any path the shell
11020    // takes today, which is what keeps a `(( i++ ))` loop at the acquisition
11021    // count it had before. The comparison, not a name list, is what decides:
11022    // a future setfn that DOES compute a value is published correctly without
11023    // anyone having to remember to add it here.
11024    //
11025    // Only the value slot moves, never the flags — the setfn may have unset or
11026    // retyped the node through the table (`unsetparam`, `assignsparam`), and
11027    // stamping a stale flag word back over that would undo it. A node the setfn
11028    // removed outright is left removed; the detached copy goes back to the
11029    // caller, which is the pointer C would still be holding.
11030    if t == PM_INTEGER && staged.u_val != committed {
11031        if let Ok(mut tab) = paramtab().write() {
11032            if let Some(pm) = tab.get_mut(s) {
11033                pm.u_val = staged.u_val;
11034            }
11035        }
11036    }
11037    Some(Box::new(staged)) // c:3674 `return v->pm;`
11038}
11039
11040/// Port of `setnparam()` from `Src/params.c:3744`.
11041///
11042/// C body (c:3747-3748):
11043/// ```c
11044/// return assignnparam(s, val, ASSPM_WARN);
11045/// ```
11046///
11047/// Single-line wrapper around `assignnparam` with ASSPM_WARN flags.
11048///
11049/// The previous Rust port took `(s: &str, val: f64) -> ()` — losing
11050/// the integer branch (callers couldn't set integer params via
11051/// `setnparam`) AND the Param return. No real callers existed because
11052/// the fabricated sig fit nothing. Match C exactly: `(s, val)` where
11053/// `val` is the canonical `mnumber` tagged union, returning the
11054/// resulting Param.
11055pub fn setnparam(s: &str, val: mnumber) -> Option<Param> {
11056    assignnparam(s, val, ASSPM_WARN as i32) // c:3748
11057}
11058
11059/// Port of `assigniparam()` from `Src/params.c:3753`.
11060///
11061/// C body (c:3757-3760):
11062/// ```c
11063/// mnumber mnval;
11064/// mnval.type = MN_INTEGER;
11065/// mnval.u.l = val;
11066/// return assignnparam(s, mnval, flags);
11067/// ```
11068///
11069/// Two divergences in the previous Rust port:
11070///   1. Dropped the `flags` arg — caller-supplied flags (e.g.
11071///      ASSPM_AUGMENT for `+= int`) couldn't be threaded through;
11072///      every call hardcoded ASSPM_WARN regardless.
11073///   2. Returned void instead of Param — losing the new param
11074///      pointer the caller may want to read back.
11075pub fn assigniparam(s: &str, val: i64, flags: i32) -> Option<Param> {
11076    // c:3757-3759 — `mnumber{ .type = MN_INTEGER, .u.l = val }`.
11077    let mnval = mnumber {
11078        l: val,
11079        d: 0.0,
11080        type_: MN_INTEGER,
11081    };
11082    // c:3760 — `return assignnparam(s, mnval, flags);`
11083    assignnparam(s, mnval, flags) // c:3760
11084}
11085
11086/// Port of `setiparam()` from `Src/params.c:3765`.
11087///
11088/// C body (c:3769-3772):
11089/// ```c
11090/// mnumber mnval;
11091/// mnval.type = MN_INTEGER;
11092/// mnval.u.l = val;
11093/// return assignnparam(s, mnval, ASSPM_WARN);
11094/// ```
11095///
11096/// The previous Rust port stringified to decimal and routed through
11097/// `assignsparam` — which CREATES THE PARAM AS PM_SCALAR. C creates
11098/// as PM_INTEGER. `setiparam("x", 5)` followed by `typeset -p x`:
11099///   - C: \`typeset -i x=5\`
11100///   - Old Rust: \`typeset x=5\`
11101///
11102/// `assignnparam` IS now ported (params.rs:4403). Route through it
11103/// matching C exactly so integer-typed params get created with the
11104/// right PM_INTEGER flag.
11105pub fn setiparam(s: &str, val: i64) -> Option<Param> {
11106    // c:3770-3771 — `mnumber{ .type = MN_INTEGER, .u.l = val }`.
11107    let mnval = mnumber {
11108        l: val,
11109        d: 0.0,
11110        type_: MN_INTEGER,
11111    };
11112    // c:3772 — `return assignnparam(s, mnval, ASSPM_WARN);`
11113    assignnparam(s, mnval, ASSPM_WARN as i32) // c:3772
11114}
11115
11116/// Port of `setiparam_no_convert()` from `Src/params.c:3781`. C
11117/// source comment: "If the target is already an integer, this
11118/// gets converted back. Low technology rules." It uses convbase
11119/// to render decimal then calls assignsparam.
11120/// WARNING: param names don't match C — Rust=() vs C=(s, val)
11121pub fn setiparam_no_convert(s: &str, val: i64) -> Option<Param> {
11122    assignsparam(s, &val.to_string(), ASSPM_WARN as i32)
11123}
11124
11125/// Port of `resetparam()` from `Src/params.c:3796`. C body:
11126/// ```c
11127/// char *s = pm->node.nam;
11128/// queue_signals();
11129/// if (pm != (Param)(paramtab == realparamtab ?
11130///        paramtab->getnode2(paramtab, s) :
11131///        paramtab->getnode(paramtab, s))) {
11132///     unqueue_signals();
11133///     zerr("can't change type of hidden variable: %s", s);
11134///     return 1;
11135/// }
11136/// s = dupstring(s);
11137/// unsetparam_pm(pm, 0, 1);
11138/// unqueue_signals();
11139/// createparam(s, flags);
11140/// return 0;
11141/// ```
11142/// Tears `pm` down + recreates it with `flags` so the next
11143/// assignment lands in a fresh slot of the requested type. Used
11144/// by `assignsparam` when the type-flag of an existing param
11145/// changes (e.g. `typeset -i x; x="abc"` resets x back to scalar).
11146///
11147/// The `paramtab->getnode` reachability check at c:3800 catches
11148/// the hidden-shadow case (a local var hiding the global `pm` we
11149/// were handed) — without the paramtab vtable we skip the check
11150/// and proceed to unset+create.
11151pub fn resetparam(pm: &mut param, flags: i32) -> i32 {
11152    // c:3796
11153    let s = pm.node.nam.clone(); // c:3796
11154    queue_signals(); // c:3799
11155                     // c:3800-3807 — paramtab->getnode2 / getnode reachability check.
11156                     // Without paramtab vtable wired we cannot detect the hidden-
11157                     // variable case, so we proceed; a future port of paramtab
11158                     // adds the check at this site.
11159    unsetparam_pm(pm, 0, 1); // c:3819
11160    unqueue_signals(); // c:3819
11161    let _ = createparam(&s, flags); // c:3819
11162    0 // c:3819
11163}
11164
11165/// Port of `unsetparam()` from `Src/params.c:3819`.
11166///
11167/// C body:
11168/// ```c
11169/// Param pm;
11170/// queue_signals();
11171/// if ((pm = (Param)(paramtab == realparamtab ?
11172///         paramtab->getnode2(paramtab, s) :
11173///         paramtab->getnode(paramtab, s))))
11174///     unsetparam_pm(pm, 0, 1);
11175/// unqueue_signals();
11176/// ```
11177///
11178/// The previous Rust port took `(variables, arrays, assoc_arrays,
11179/// name)` operating on EXTERNAL HashMap storage — a SubstState-
11180/// era stale signature. C operates on the canonical `paramtab`
11181/// global. No live callers used the old 4-arg form (all use
11182/// `paramtab().write().remove(...)` directly), so renaming is
11183/// safe.
11184pub fn unsetparam(name: &str) -> i32 {
11185    // !!! RUST-ONLY: provenance tap — the unset is the last event in a
11186    // tracked parameter's lineage; the chain itself is kept so
11187    // `provenance NAME` still explains what the value was.
11188    if crate::provenance::active() {
11189        crate::provenance::on_param_unset(name);
11190    }
11191    // c:3819 — C's unsetparam is void and discards unsetparam_pm's
11192    // status; bin_unset (c:Src/builtin.c:3952-3953) does the
11193    // paramtab lookup itself and calls `if (unsetparam_pm(pm, 0, 1))
11194    // returnval = 1;`. The Rust bin_unset routes through this
11195    // wrapper for the bridge plumbing (tied names, hashed-storage
11196    // shadow, special regenerators), so the rejection status is
11197    // surfaced here instead: 1 = readonly rejection, 0 = unset ok.
11198    // c:Src/params.c:3853-3935 — unsetparam_pm's tied-alt-name
11199    // removal block. zsh's PATH/path, FPATH/fpath, MANPATH/manpath,
11200    // CDPATH/cdpath, PSVAR/psvar pairs are tied (`pm->ename` points
11201    // to the alt name) — unsetting one must clear the other or
11202    // command lookup keeps finding binaries via the surviving `path`
11203    // array even after `unset PATH`. The generic `ename` cascade lives in
11204    // unsetparam_pm (it covers user `typeset -T` ties). The canonical
11205    // special pairs keep derived state outside the node, so they are
11206    // mirrored explicitly here, making `unset PATH` a security boundary.
11207    // Bug #416.
11208    let tied_alt: Option<&str> = match name {
11209        "PATH" => Some("path"),
11210        "path" => Some("PATH"),
11211        "FPATH" => Some("fpath"),
11212        "fpath" => Some("FPATH"),
11213        "MANPATH" => Some("manpath"),
11214        "manpath" => Some("MANPATH"),
11215        "CDPATH" => Some("cdpath"),
11216        "cdpath" => Some("CDPATH"),
11217        "PSVAR" => Some("psvar"),
11218        "psvar" => Some("PSVAR"),
11219        "MODULE_PATH" => Some("module_path"),
11220        "module_path" => Some("MODULE_PATH"),
11221        "FIGNORE" => Some("fignore"),
11222        "fignore" => Some("FIGNORE"),
11223        "MAILPATH" => Some("mailpath"),
11224        "mailpath" => Some("MAILPATH"),
11225        // c:Src/Modules/watch.c:697-701 — PM_TIED since upstream b2fb112ea6.
11226        "WATCH" => Some("watch"),
11227        "watch" => Some("WATCH"),
11228        _ => None,
11229    };
11230    queue_signals(); // c:3825
11231                     // c:3826-3831 — `if ((pm = ... getnode2 ...) && !(pm->node.flags
11232                     // & PM_NAMEREF)) unsetparam_pm(pm, 0, 1);`.
11233                     //
11234                     // Two divergences in the previous Rust port:
11235                     //   1. Missing PM_NAMEREF check — `unsetparam("ref")` where `ref`
11236                     //      is a nameref would remove the ref alias itself. C explicitly
11237                     //      skips nameref params here (they're cleared via the
11238                     //      ref-specific path, not the value-side unset).
11239                     //   2. Bypassed `unsetparam_pm` — removed the entry directly from
11240                     //      paramtab without running the readonly-guard at c:3850, the
11241                     //      stdunsetfn dispatch at c:3870, or the `pm->old` scope
11242                     //      restore. `typeset -r x=foo; unset x` would silently succeed
11243                     //      in Rust where C rejects with `read-only variable: x`.
11244                     // c:Src/params.c:3853 — flag regenerator-style specials as unset
11245                     // so subsequent reads via lookup_special_var skip the getfn.
11246                     // RANDOM/SECONDS/EPOCH*/TTYIDLE/ERRNO have no paramtab pm node in
11247                     // zshrs (they're lookup_special_var libc shims), so the standard
11248                     // unsetparam_pm path below doesn't catch them. Bug #417/#418.
11249    if matches!(
11250        name,
11251        "RANDOM" | "SECONDS" | "EPOCHSECONDS" | "EPOCHREALTIME" | "TTYIDLE" | "ERRNO"
11252    ) {
11253        mark_unset_special(name);
11254    }
11255    // c:Src/params.c:3850 — `if (pm->node.flags & PM_READONLY) { zerr;
11256    // return 1; }`. Read-only specials (LINENO, HISTCMD, PPID, etc.)
11257    // have PM_READONLY in their special_paramdef entry but no paramtab
11258    // pm node by default, so the standard PM_READONLY check inside
11259    // unsetparam_pm never fires for them. Walk the special_params
11260    // table directly to catch these. Bug #419.
11261    // …but ONLY when the name has no live node. C tests the FLAG ON THE NODE
11262    // (`pm->node.flags & PM_READONLY`), so a shadow that hides the special —
11263    // `typeset -h +g LINENO` builds a plain `scalar-local-hide` per
11264    // Src/builtin.c:2083-2085 — is unsettable. The by-name table walk ignored
11265    // the shadow and rejected it, which is what made B02typeset's
11266    // `typeset -h +g -m [[:alpha:]_]*; unset -m [[:alpha:]_]*` abort.
11267    let live_readonly: Option<bool> = paramtab().read().ok().and_then(|t| {
11268        t.get(name)
11269            .map(|pm| (pm.node.flags as u32 & PM_READONLY) != 0)
11270    });
11271    let is_readonly_special = match live_readonly {
11272        Some(ro) => ro, // c:3850 — the node governs itself
11273        None => special_params
11274            .iter()
11275            .any(|ip| ip.name == name && (ip.pm_flags & PM_READONLY) != 0),
11276    };
11277    if is_readonly_special {
11278        // !!! BASH-MODE (no C counterpart) !!! bash: `unset: NAME: cannot unset:
11279        // readonly variable`, status 1, script continues (zsh zerr aborts the line).
11280        if crate::dash_mode::bash_mode() || crate::dash_mode::korn_mode() {
11281            crate::ported::utils::zwarnnam("unset", &format!("{name}: cannot unset: readonly variable"));
11282            unqueue_signals();
11283            return 1;
11284        }
11285        zerr(&format!("read-only variable: {}", name));
11286        unqueue_signals();
11287        return 1; // c:3854 — unsetparam_pm's readonly rejection status
11288    }
11289    let mut retval = 0i32;
11290    // c:Src/params.c:3855 — `if (pm->ename && !altflag) altremove =
11291    // ztrdup(pm->ename)`. User ties created by `typeset -T FOO foo`
11292    // cross-link the two params via `pm.ename` (each side names the
11293    // other); the static `tied_alt` map above only covers the
11294    // canonical PM_SPECIAL pairs (PATH/path …). Capture the unset
11295    // param's ename here so the cascade below can clear the tied
11296    // partner for user ties too.
11297    // c:3855 — `if (pm->ename && !altflag)`: the cascade is driven by the
11298    // LIVE node's `ename`, which only a genuinely tied param carries. Capture
11299    // PM_TIED alongside so the static `tied_alt` map below can be gated the
11300    // same way: a shadow that HIDES a tied special (`typeset -h +g PATH`
11301    // builds a plain `scalar-local-hide`, Src/builtin.c:2083-2085) has no tie
11302    // and must not drag its partner down with it.
11303    let (found, is_nameref, param_ename, param_tied, param_special) = {
11304        let tab = paramtab().read().unwrap();
11305        match tab.get(name) {
11306            Some(pm) => (
11307                true,
11308                (pm.node.flags as u32 & PM_NAMEREF) != 0,
11309                pm.ename.clone(),
11310                (pm.node.flags as u32 & PM_TIED) != 0,
11311                (pm.node.flags as u32 & PM_SPECIAL) != 0,
11312            ),
11313            None => (false, false, None, false, false),
11314        }
11315    };
11316    if found && !is_nameref {
11317        // c:3826-3830
11318        // c:3831 — `unsetparam_pm(pm, 0, 1)`. Take an owned copy out
11319        // of paramtab so we can mutate it (unsetparam_pm wants
11320        // &mut), run the readonly-guard + env teardown, then re-insert
11321        // or fully remove based on the readonly path.
11322        // The node stays in the table while `unsetparam_pm` runs, as in C —
11323        // c:3874 `removenode` is the LAST step — so the tied-partner cascade
11324        // (c:3805-3836) and the partner's setfn still see this name bound.
11325        let mut pm_owned = paramtab().read().unwrap().get(name).cloned().unwrap();
11326        let rejected = unsetparam_pm(&mut pm_owned, 0, 1); // c:3831
11327        if rejected != 0 {
11328            retval = 1; // c:Src/builtin.c:3952-3953 surfaced to bin_unset
11329                        // Readonly rejection — restore the entry so the state
11330                        // is unchanged.
11331            paramtab()
11332                .write()
11333                .unwrap()
11334                .insert(name.to_string(), pm_owned);
11335        } else if pm_owned.old.is_some()
11336            || (pm_owned.level > 0 && locallevel.load(Ordering::Relaxed) as i32 >= pm_owned.level)
11337        {
11338            // c:Src/params.c:3892-3925 — when the unset'd pm is a
11339            // local that shadowed an outer binding (chained via
11340            // pm.old by `addparam` at c:1137), the local pm STAYS
11341            // in paramtab with PM_UNSET set so the current scope's
11342            // reads see "unset" (empty). The pm.old chain is
11343            // preserved so endparamscope can uncover the outer
11344            // when the local scope ends. Without this re-insert,
11345            // either:
11346            //   - the outer would be uncovered immediately (wrong:
11347            //     zsh hides the outer until scope end), or
11348            //   - pm.old would be dropped (wrong: outer never
11349            //     comes back).
11350            // c:3911-3913 — `if ((pm->level && locallevel >= pm->level)
11351            // ...) return 0;` — locals are kept in the table marked
11352            // PM_UNSET even WITHOUT an outer binding ("foo() { local
11353            // bar; unset bar; } makes the global bar available? The
11354            // following makes the answer no"), and `typeset -p bar`
11355            // still finds the node (prints nothing, status 0).
11356            paramtab()
11357                .write()
11358                .unwrap()
11359                .insert(name.to_string(), pm_owned);
11360        } else if (pm_owned.node.flags as u32 & PM_SPECIAL) != 0
11361            && (pm_owned.node.flags as u32 & PM_REMOVABLE) == 0
11362        {
11363            // c:Src/params.c:3911-3913 — `if ((pm->flags &
11364            // (PM_SPECIAL|PM_REMOVABLE)) == PM_SPECIAL) return 0;`.
11365            // PM_SPECIAL params (SECONDS, RANDOM, HOME, IFS, ...)
11366            // stay in paramtab with PM_UNSET set after unset. A
11367            // subsequent re-assign (`SECONDS=100`) finds the same
11368            // pm via createparam's `oldpm` lookup, hits the reuse
11369            // arm (c:1132), preserves the PM_INTEGER|PM_SPECIAL
11370            // flags + gsu vtable, so intsetfn's name-dispatch
11371            // fires and routes through intsecondssetfn. Without
11372            // this, the special pm was dropped, a fresh PM_SCALAR
11373            // pm was created for the re-assignment, the value
11374            // landed in pm.u_str, and lookup_special_var kept
11375            // reading the time-since-shtimer delta. Bug #418 in
11376            // docs/BUGS.md.
11377            //
11378            // PM_UNSET is already stamped by unsetparam_pm at
11379            // line 6491; keep it on the re-inserted pm so reads
11380            // still see "unset" until re-assignment clears it
11381            // (clear_unset_special at line 4756 handles the
11382            // regenerator-style unset_specials set; the pm flag
11383            // is cleared inside assignsparam's value-write arm
11384            // via stdunsetfn's symmetric set/clear convention).
11385            paramtab()
11386                .write()
11387                .unwrap()
11388                .insert(name.to_string(), pm_owned);
11389        }
11390        else {
11391            // No pm.old + no rejection + not PM_SPECIAL → drop entirely
11392            // (c:3874 `paramtab->removenode(paramtab, pm->node.nam)`).
11393            paramtab().write().unwrap().remove(name);
11394        }
11395    }
11396    // c:Src/params.c:3905-3935 — tied-alt removal. Cascade the
11397    // unset to the paired name (PATH↔path etc.). Also clear the OS
11398    // env mirror since command lookup at the syscall level reads
11399    // the inherited libc environ.
11400    // Static canonical pair (PATH↔path …) takes precedence; otherwise
11401    // fall back to the unset param's `ename` (user `typeset -T` ties).
11402    // c:3855 — gate on the LIVE tie. `found && !param_tied` is a node that
11403    // exists but is not tied (typically a `-h` shadow of PATH/path), and C
11404    // would leave the partner alone; only fall back to the static pair map
11405    // when the name has no node at all (the pre-createparamtable `-fc` path).
11406    // A live node carrying `ename` was already cascaded by `unsetparam_pm`
11407    // itself (c:3805-3836); only the static-pair fallback for a name with no
11408    // node, or a tie with no `ename`, is left to the block below.
11409    let cascaded = found && param_ename.is_some() && !param_special;
11410    let effective_alt: Option<String> = if found && !param_tied && param_ename.is_none() {
11411        None
11412    } else {
11413        tied_alt.map(|s| s.to_string()).or(param_ename)
11414    };
11415    if let Some(alt) = effective_alt.as_deref() {
11416        let alt_present = !cascaded
11417            && paramtab()
11418                .read()
11419                .map(|t| t.contains_key(alt))
11420                .unwrap_or(false);
11421        if alt_present {
11422            if let Some(mut alt_pm) = paramtab().write().ok().and_then(|mut t| t.remove(alt)) {
11423                let _ = unsetparam_pm(&mut alt_pm, 1, 1); // c:3828
11424                // c:Src/params.c:3892-3925 — the partner obeys the same
11425                // keep-the-node rules as the primary: a local that shadows an
11426                // outer binding stays in the table marked PM_UNSET so
11427                // endparamscope can uncover the outer. Dropping it outright
11428                // took the whole `pm.old` chain with it, so unsetting a
11429                // shadowed `PATH` erased the GLOBAL `path` for good.
11430                // c:3911-3913 — `(pm->node.flags & (PM_SPECIAL|PM_REMOVABLE))
11431                // == PM_SPECIAL` also keeps the node: a tied special stays in
11432                // the table, PM_UNSET, with its tie and gsu intact, so a later
11433                // `manpath=(…)` revives the SPECIAL pair and a following
11434                // `unset MANPATH` cascades to `manpath` again. Dropping it
11435                // made the revival a plain untied array that survived the
11436                // partner's unset. `$+manpath` reads the PM_UNSET flag
11437                // (subst.rs, c:Src/subst.c:2805).
11438                let keep = alt_pm.old.is_some()
11439                    || (alt_pm.level > 0
11440                        && locallevel.load(Ordering::Relaxed) as i32 >= alt_pm.level)
11441                    || (alt_pm.node.flags as u32 & (PM_SPECIAL | PM_REMOVABLE)) == PM_SPECIAL; // c:3911-3913
11442                if keep {
11443                    // The executor-side array/assoc bag (see the else arm)
11444                    // holds the value outside the node; the unset empties it.
11445                    crate::ported::exec::unset_array(alt);
11446                    crate::ported::exec::unset_assoc(alt);
11447                    paramtab().write().unwrap().insert(alt.to_string(), alt_pm);
11448                } else {
11449                    // c:3874 `paramtab->removenode(paramtab, pm->node.nam)` —
11450                    // in C that is the only store the name lives in. zshrs
11451                    // keeps a parallel executor-side array/assoc bag that
11452                    // `exec::array` consults BEFORE getaparam, so dropping the
11453                    // paramtab node alone left `unset PATH; print $+path`
11454                    // reading 1 (a plain `array`, not the special) where zsh
11455                    // reads 0.
11456                    crate::ported::exec::unset_array(alt);
11457                    crate::ported::exec::unset_assoc(alt);
11458                }
11459            }
11460        }
11461        env::remove_var(alt);
11462        env::remove_var(name);
11463        // c:Src/params.c:5291 — `if (t == path) cmdnamtab->emptytable
11464        // (cmdnamtab)`. The hashed-cmdnam cache holds absolute paths
11465        // resolved via the prior PATH search; without clearing,
11466        // `unset PATH; ls` still hits the cached `/bin/ls` entry and
11467        // exec succeeds — defeating the security boundary the unset
11468        // is supposed to establish. Bug #416.
11469        if matches!(name, "PATH" | "path") {
11470            crate::ported::hashtable::emptycmdnamtable();
11471        }
11472    }
11473    unqueue_signals(); // c:3832
11474    retval
11475}
11476
11477/// Unset parameter (from params.c unsetparam_pm)
11478/// Port of `unsetparam_pm()` from `Src/params.c:3841`. Runs the
11479/// readonly guard, `pm->gsu.s->unsetfn(pm, exp)`, `delenv`, and the
11480/// tied-alternate (`pm->ename`) teardown when `!altflag`. The
11481/// c:3851-3890 `removenode`/`addnode` postlude and `pm->old`
11482/// resurrection for `pm` itself is done by the callers, which own the
11483/// node (see `unsetparam`).
11484#[allow(unused_variables)]
11485pub fn unsetparam_pm(pm: &mut param, altflag: i32, exp: i32) -> i32 {
11486    // c:3850 — `if ((pm->node.flags & PM_READONLY) && pm->level <= locallevel)`.
11487    let cur_ll = locallevel.load(Ordering::Relaxed) as i32; // c:3850 locallevel
11488    if (pm.node.flags as u32 & PM_READONLY) != 0 && pm.level <= cur_ll {
11489        // c:3850
11490        // c:3852 — `zerr("read-only %s: %s", ...)`. Emit diagnostic
11491        // so users see why the unset failed.
11492        let kind = if (pm.node.flags as u32 & PM_NAMEREF) != 0 {
11493            // c:3852
11494            "reference"
11495        } else {
11496            "variable"
11497        };
11498        // !!! BASH/KSH-MODE (no C counterpart) !!! bash reports `unset: NAME:
11499        // cannot unset: readonly variable` and carries on (status 1); zsh's
11500        // zerr aborts the rest of the command line.
11501        if crate::dash_mode::bash_mode() || crate::dash_mode::korn_mode() {
11502            crate::ported::utils::zwarnnam(
11503                "unset",
11504                &format!("{}: cannot unset: readonly {}", pm.node.nam, kind),
11505            );
11506            return 1;
11507        }
11508        zerr(&format!("read-only {}: {}", kind, pm.node.nam));
11509        return 1; // c:3854
11510    }
11511    // c:3793-3796 — `if (pm->ename && !altflag) altremove = ztrdup(pm->ename);
11512    // else altremove = NULL;`. Captured before the unsetfn, which clears a
11513    // tied param's `ename` (stdunsetfn c:3932-3938).
11514    //
11515    // !!! DEVIATION — the cascade below runs only for a NON-special pm !!!
11516    // The tied specials (PATH/path, FPATH/fpath, MANPATH/manpath, …) keep
11517    // derived state outside the node (the executor array bag, cmdnamtab, the
11518    // environ mirror); `unsetparam`'s static-pair block below owns their
11519    // partner teardown, and running this generic cascade for them left
11520    // `unset PATH; print $+path` reading 1 where zsh reads 0. User
11521    // `typeset -T` ties have no such state and take C's path.
11522    let altremove: Option<String> =
11523        if altflag == 0 && (pm.node.flags as u32 & PM_SPECIAL) == 0 {
11524            pm.ename.clone()
11525        } else {
11526            None
11527        };
11528    pm.node.flags &= !(PM_DECLARED as i32); // c:3868
11529                                            // c:3870 — WHICH unsetfn `pm->gsu.s->unsetfn` is comes from
11530                                            // createspecialhash (c:1227-1228): a SPECIALPMDEF hash whose
11531                                            // `partab[]` row supplies NO gsu keeps the default
11532                                            // `nullsethash_gsu` unless it is PM_READONLY (which takes
11533                                            // `stdhash_gsu`), and `nullsethash_gsu`'s unsetfn is `nullunsetfn`
11534                                            // (c:196-197 → c:4143-4145), whose body is EMPTY. So `unset` on
11535                                            // such a row does nothing at all: no PM_UNSET, node untouched,
11536                                            // value still readable — `zmodload zsh/langinfo; unset langinfo`
11537                                            // leaves `${#langinfo}` at 55 in zsh. Rows that DO carry a gsu get
11538                                            // it stamped over the default by addparamdef
11539                                            // (c:Src/module.c:1080-1110) and so unset through `stdunsetfn`.
11540                                            // `langinfo` (c:Src/Modules/langinfo.c:447 `SPECIALPMDEF("langinfo",
11541                                            // 0, NULL, getlanginfo, scanlanginfo)`) is the only `partab[]` row
11542                                            // that is both gsu-less and non-readonly; every other gsu-less row
11543                                            // is PM_READONLY_SPECIAL and is rejected at c:3786 above before
11544                                            // reaching here.
11545    let nullsethash_gsu = pm.node.nam == "langinfo"; // c:1227-1228
11546    if nullsethash_gsu {
11547        return 0; // c:3870 nullunsetfn — empty body
11548    }
11549    // c:3870 — a `private` parameter carries the zsh/param/private GSU
11550    // closure, whose unsetfn (pps_unsetfn and its siblings,
11551    // c:Src/Modules/param_private.c:311-322) only runs the wrapped unsetfn
11552    // when `locallevel <= pm->level` and, on an EXPLICIT unset, sets
11553    // PM_DECLARED again: `() { private x=1; unset x; x=2 }` must find the
11554    // node through fetchvalue (c:2264-2265) instead of re-creating it over
11555    // a PM_RO_BY_DESIGN struct.
11556    let private_closure =
11557        crate::ported::modules::param_private::is_private(pm as *const param) != 0;
11558    if (pm.node.flags as u32 & PM_UNSET) == 0 || (pm.node.flags as u32 & PM_REMOVABLE) != 0 {
11559        if private_closure {
11560            if cur_ll <= pm.level {
11561                stdunsetfn(pm, exp); // c:param_private.c:317-318 gsu->unsetfn
11562            }
11563            if exp != 0 {
11564                pm.node.flags |= PM_DECLARED as i32; // c:param_private.c:320
11565            }
11566        } else {
11567            // c:3870 — `pm->gsu.s->unsetfn(pm, exp)` — open-coded to stdunsetfn.
11568            stdunsetfn(pm, exp);
11569        }
11570    }
11571    if pm.env.is_some() {
11572        delenv(&pm.node.nam); // c:3872 delenv(pm)
11573        pm.env = None;
11574    }
11575    // c:3804-3836 — "remove it under its alternate name if necessary".
11576    // Every caller hands this fn a node it has already copied out of (or
11577    // removed from) paramtab and holds no guard, so the partner is taken
11578    // out of the table, unset, and put back (or not) here.
11579    if let Some(alt) = altremove.as_deref() {
11580        // c:3806 — `altpm = (Param) paramtab->getnode(paramtab, altremove);`
11581        let top = paramtab().write().ok().and_then(|mut t| t.remove(alt));
11582        if let Some(mut top) = top {
11583            // c:3815-3821 — `if (altpm && !(altpm->node.flags & PM_SPECIAL))
11584            // while (altpm && altpm->level > pm->level) { oldpm = altpm;
11585            // altpm = altpm->old; }` — tied parameters are at the same local
11586            // level as each other, so skip locals hidden above that level.
11587            let mut depth = 0usize;
11588            let mut reached = true;
11589            if (top.node.flags as u32 & PM_SPECIAL) == 0 {
11590                let mut cur: &param = &top;
11591                while cur.level > pm.level {
11592                    match cur.old.as_deref() {
11593                        Some(o) => {
11594                            cur = o;
11595                            depth += 1;
11596                        }
11597                        None => {
11598                            reached = false; // c:3819 altpm = NULL
11599                            break;
11600                        }
11601                    }
11602                }
11603            }
11604            if reached {
11605                if depth == 0 {
11606                    // c:3828 — `unsetparam_pm(altpm, 1, exp);` on the visible node.
11607                    let _ = unsetparam_pm(&mut top, 1, exp);
11608                    // c:3851-3890 postlude for the partner, which this port
11609                    // leaves to whoever took the node out of paramtab: the
11610                    // same keep-or-drop rule `unsetparam` applies to its
11611                    // static-pair fallback below (see the note there on why
11612                    // a global tied special is dropped, not kept).
11613                    let keep = top.old.is_some()
11614                        || (top.level > 0
11615                            && locallevel.load(Ordering::Relaxed) as i32 >= top.level);
11616                    if keep {
11617                        paramtab().write().unwrap().insert(alt.to_string(), top);
11618                    } else {
11619                        crate::ported::exec::unset_array(alt); // c:3874 removenode
11620                        crate::ported::exec::unset_assoc(alt);
11621                    }
11622                } else {
11623                    let mut oldpm: &mut param = &mut top;
11624                    for _ in 1..depth {
11625                        oldpm = oldpm.old.as_deref_mut().unwrap();
11626                    }
11627                    if oldpm.old.as_ref().is_some_and(|a| a.level == 0) {
11628                        // c:3823-3827 — `if (oldpm && !altpm->level) {
11629                        // oldpm->old = NULL; altpm->level = 1; }` — detach the
11630                        // global from the shadow chain so removenode is not
11631                        // called; the detached node is then unset and freed.
11632                        let mut altpm = oldpm.old.take().unwrap();
11633                        altpm.level = 1; // c:3826
11634                        let _ = unsetparam_pm(&mut altpm, 1, exp); // c:3828
11635                    } else if let Some(altpm) = oldpm.old.as_deref_mut() {
11636                        // c:3828 — a hidden local at pm's level stays in the
11637                        // chain marked PM_UNSET (c:3851-3853 keeps locals).
11638                        let _ = unsetparam_pm(altpm, 1, exp);
11639                    }
11640                    paramtab().write().unwrap().insert(alt.to_string(), top);
11641                }
11642                pm.node.flags |= PM_UNSET as i32; // c:3829 "so we must repeat this"
11643            } else {
11644                paramtab().write().unwrap().insert(alt.to_string(), top);
11645            }
11646        }
11647        // c:3833-3836 — `if (!(pm->node.flags & PM_SPECIAL)) {
11648        // pm->gsu.s = &stdscalar_gsu; pm->node.flags &= ~PM_ARRAY; }`
11649        if (pm.node.flags as u32 & PM_SPECIAL) == 0 {
11650            pm.gsu_s = Some(Box::new(gsu_scalar {
11651                getfn: strgetfn,
11652                setfn: strsetfn,
11653                unsetfn: stdunsetfn,
11654            }));
11655            pm.node.flags &= !(PM_ARRAY as i32);
11656        }
11657    }
11658    // The c:3851-3890 removenode/addnode postlude for `pm` itself stays with
11659    // the callers, which own the node (see `unsetparam`).
11660    pm.node.flags |= PM_UNSET as i32;
11661    0
11662}
11663
11664
11665// -----------------------------------------------------------
11666// GSU dispatch callbacks — direct ports against `param.u_*`
11667// fields. C source in Src/params.c:4002.
11668// -----------------------------------------------------------
11669
11670/// Port of `intgetfn()` from `Src/params.c:3993`. C body:
11671/// `return pm->u.val;`
11672pub fn intgetfn(pm: &param) -> i64 {
11673    pm.u_val
11674}
11675
11676/// Port of `intsetfn()` from `Src/params.c:4002`. C body:
11677/// `pm->u.val = x;`
11678pub fn intsetfn(pm: &mut param, x: i64) {
11679    // c:Src/params.c:4575 — PM_SPECIAL integers have per-name gsu_i->setfn
11680    // hooks: SECONDS routes through intsecondssetfn (shtimer math),
11681    // RANDOM seeds the PRNG, etc. The default intsetfn is the fallback
11682    // for non-special integers (pm->u.val write).
11683    //
11684    // Rust port lookup_special_var dispatches GETTERS by name; the
11685    // setters need symmetric dispatch so `SECONDS=N` actually moves
11686    // shtimer instead of writing u.val which intsecondsgetfn never
11687    // reads. Without this, `$SECONDS` always read the time-since-shtimer
11688    // delta regardless of writes.
11689    // Name-based dispatch (not flag-based): some assignment paths
11690    // construct a fresh param shell for tc and lose PM_SPECIAL.
11691    match pm.node.nam.as_str() {
11692        "SECONDS" => {
11693            intsecondssetfn(x);
11694            return;
11695        }
11696        // c:Src/params.c:364-365 —
11697        //   IPDEF6("TRY_BLOCK_ERROR",     &try_errflag,  varinteger_gsu)
11698        //   IPDEF6("TRY_BLOCK_INTERRUPT", &try_interrupt, varinteger_gsu)
11699        // varinteger_gsu's setfn is `intvarsetfn` (c:4213), whose body is
11700        // `*pm->u.valptr = x;` — i.e. the assignment writes the loop.c
11701        // GLOBAL itself, not just `pm->u.val`. That is the entire
11702        // mechanism behind `TRY_BLOCK_ERROR=0` inside an `always` block:
11703        // `exectry` re-reads the global after the always-list runs
11704        // (c:Src/loop.c:774 `if (try_errflag) errflag |= ERRFLAG_ERROR;`)
11705        // and a user-zeroed global swallows the try-block's error.
11706        //
11707        // The Rust port cannot carry C's `u.valptr` raw pointer, so the
11708        // bound global is reached by name — the same asymmetry the
11709        // SECONDS / RANDOM arms below already resolve this way. Without
11710        // it, `TRY_BLOCK_ERROR=0` wrote `pm.u_val` while every reader
11711        // (params.rs's special-var getter, exectry) kept consulting the
11712        // atomic, so the assignment was a no-op.
11713        "TRY_BLOCK_ERROR" => {
11714            crate::ported::r#loop::try_errflag.store(x, Ordering::Relaxed); // c:4213
11715            intvarsetfn(pm, x); // c:4213 `*pm->u.valptr = x;`
11716            return;
11717        }
11718        "TRY_BLOCK_INTERRUPT" => {
11719            crate::ported::r#loop::try_interrupt.store(x, Ordering::Relaxed); // c:4213
11720            intvarsetfn(pm, x); // c:4213
11721            return;
11722        }
11723        // c:Src/params.c:362-363 —
11724        //   IPDEF5("COLUMNS", &zterm_columns, zlevar_gsu),
11725        //   IPDEF5("LINES", &zterm_lines, zlevar_gsu),
11726        // whose setfn is `zlevarsetfn` (c:4176): `*p = x` writes the
11727        // `zterm_columns` / `zterm_lines` GLOBAL through the same pointer the
11728        // getter reads, then `adjustwinsize(2 + (p == &zterm_columns))`
11729        // re-derives the terminal state. Exactly the TRY_BLOCK_* shape above:
11730        // the port cannot carry `u.valptr`, so the bound global is reached by
11731        // name. `zlevarsetfn` existed with no caller, so `COLUMNS=40` moved
11732        // only `pm.u_val` while every geometry reader kept the old width.
11733        "COLUMNS" | "LINES" => {
11734            zlevarsetfn(pm, x); // c:4176
11735            return;
11736        }
11737        // c:Src/params.c:4552 randomsetfn — `RANDOM=N` calls
11738        // srand(N). Without this dispatch, $RANDOM writes only u.val
11739        // and the next read returns rand()'s next value from the
11740        // PROCESS-START seed, not the user-requested seed. Same
11741        // name-based dispatch shape as SECONDS above.
11742        "RANDOM" => {
11743            randomsetfn(x);
11744            return;
11745        }
11746        // c:Src/params.c:4698 uidsetfn / c:4719 euidsetfn / c:4740
11747        // gidsetfn / c:4761 egidsetfn — `UID=N` / `EUID=N` /
11748        // `GID=N` / `EGID=N` attempt the corresponding setuid /
11749        // seteuid / setgid / setegid syscall and emit
11750        // `failed to change [effective ]{user,group} ID: ERRNO`
11751        // on failure. Bug #254 in docs/BUGS.md. Same name-based
11752        // dispatch shape as SECONDS/RANDOM above.
11753        "UID" => {
11754            uidsetfn(x);
11755            return;
11756        }
11757        "EUID" => {
11758            euidsetfn(x);
11759            return;
11760        }
11761        "GID" => {
11762            gidsetfn(x);
11763            return;
11764        }
11765        "EGID" => {
11766            egidsetfn(x);
11767            return;
11768        }
11769        // c:Src/params.c:4974 histsizesetfn / c:4998 savehistsizesetfn —
11770        // `HISTSIZE=N` / `SAVEHIST=N` must update the canonical
11771        // `histsiz` / `savehistsiz` globals AND clamp to >= 1 +
11772        // call `resizehistents()` so the in-memory history buffer
11773        // shrinks/grows. Without this dispatch the assignment
11774        // lands in `pm.u_val` and `histsizegetfn` (which reads the
11775        // global) keeps returning the un-touched default. Bug #520.
11776        "HISTSIZE" => {
11777            histsizesetfn(x);
11778            return;
11779        }
11780        "SAVEHIST" => {
11781            savehistsizesetfn(x);
11782            return;
11783        }
11784        _ => {}
11785    }
11786    pm.u_val = x;
11787}
11788
11789/// Port of `floatgetfn()` from `Src/params.c:4011`. C body:
11790/// `return pm->u.dval;`
11791pub fn floatgetfn(pm: &param) -> f64 {
11792    pm.u_dval
11793}
11794
11795/// Port of `floatsetfn()` from `Src/params.c:4020`. C body:
11796/// `pm->u.dval = x;`
11797pub fn floatsetfn(pm: &mut param, x: f64) {
11798    // c:Src/params.c:4603 floatsecondssetfn — PM_SPECIAL float SECONDS
11799    // routes through shtimer math. Symmetric with intsetfn's SECONDS
11800    // dispatch above and lookup_special_var's getter.
11801    // c:Src/params.c:4603 floatsecondssetfn — PM_SPECIAL float SECONDS
11802    // routes through shtimer math. Symmetric with intsetfn's SECONDS
11803    // dispatch above and lookup_special_var's getter. Some assignment
11804    // paths construct a fresh `param` shell for tc (type-conversion)
11805    // and lose PM_SPECIAL, so name-only dispatch is the safe fallback.
11806    if pm.node.nam == "SECONDS" {
11807        floatsecondssetfn(x);
11808        return;
11809    }
11810    pm.u_dval = x;
11811}
11812
11813/// Port of `strgetfn()` from `Src/params.c:4029`. C body:
11814/// `return pm->u.str ? pm->u.str : (char *) hcalloc(1);`
11815pub fn strgetfn(pm: &param) -> String {
11816    pm.u_str.clone().unwrap_or_default()
11817}
11818
11819/// Port of `strsetfn()` from `Src/params.c:4038`.
11820///
11821/// C body (c:4043-4051):
11822/// ```c
11823/// zsfree(pm->u.str); pm->u.str = x;
11824/// if (!(pm->node.flags & PM_HASHELEM) &&
11825///     ((pm->node.flags & PM_NAMEDDIR) || isset(AUTONAMEDIRS))) {
11826///     pm->node.flags |= PM_NAMEDDIR;
11827///     adduserdir(pm->node.nam, x, 0, 0);
11828/// }
11829/// ```
11830///
11831/// The C body fires the `adduserdir` path when EITHER `PM_NAMEDDIR`
11832/// is already set OR the `AUTONAMEDIRS` option is on. The previous
11833/// Rust port only fired when PM_NAMEDDIR was already set, missing
11834/// the AUTONAMEDIRS auto-create branch entirely. With `setopt
11835/// AUTONAMEDIRS`, every scalar assignment to a path-shaped value
11836/// should register a named-directory entry for `~name` expansion;
11837/// the Rust port silently dropped that behavior.
11838pub fn strsetfn(pm: &mut param, x: String) {
11839    pm.u_str = Some(x.clone()); // c:3995 pm->u.str = x
11840    // c:3997-3998 — `if (!(pm->node.flags & PM_HASHELEM) && !pm->level &&
11841    //     ((pm->node.flags & PM_NAMEDDIR) || isset(AUTONAMEDIRS)))`. A local
11842    // (level > 0) never names a directory (workers/54309).
11843    if (pm.node.flags as u32 & PM_HASHELEM) == 0
11844        && pm.level == 0
11845        && ((pm.node.flags as u32 & PM_NAMEDDIR) != 0 || isset(AUTONAMEDIRS))
11846    // c:3998
11847    {
11848        pm.node.flags |= PM_NAMEDDIR as i32; // c:3999
11849        adduserdir(&pm.node.nam, &x, 0, false); // c:4000
11850    }
11851}
11852
11853/// Port of `arrgetfn()` from `Src/params.c:4057`. C body:
11854/// `return pm->u.arr ? pm->u.arr : &nullarray;`
11855pub fn arrgetfn(pm: &param) -> Vec<String> {
11856    pm.u_arr.clone().unwrap_or_default()
11857}
11858
11859/// Port of `arrsetfn()` from `Src/params.c:4066`. C body frees
11860/// the old array, applies PM_UNIQUE filter via `uniqarray()`, then
11861/// stores. Calls `arrfixenv(ename, x)` for tied colon-arrays.
11862pub fn arrsetfn(pm: &mut param, x: Vec<String>) {
11863    let val = if (pm.node.flags as u32 & PM_UNIQUE) != 0 {
11864        simple_arrayuniq(x)
11865    } else {
11866        x
11867    };
11868    pm.u_arr = Some(val.clone());
11869    if let Some(ename) = pm.ename.clone() {
11870        arrfixenv(&ename, Some(&val));
11871    }
11872}
11873
11874/// Port of `hashgetfn()` from `Src/params.c:4084`. C body:
11875/// `return pm->u.hash;`
11876pub fn hashgetfn(pm: &param) -> Option<&HashTable> {
11877    pm.u_hash.as_ref()
11878}
11879
11880/// Port of `hashsetfn()` from `Src/params.c:4093`. C body:
11881/// `if (pm->u.hash && pm->u.hash != x) deleteparamtable(pm->u.hash);
11882///  pm->u.hash = x;`
11883pub fn hashsetfn(pm: &mut param, x: HashTable) {
11884    pm.u_hash = Some(x);
11885}
11886
11887/// Port of `arrhashsetfn()` from `Src/params.c:4113`. Set callback for
11888/// assoc arrays: takes a flat `[k1, v1, k2, v2, ...]` value list
11889/// and turns it into a hash.
11890///
11891/// C body:
11892///   1. Count non-Marker entries; if odd, error c:4128-4131.
11893///   2. Under ASSPM_AUGMENT, fetch existing hash via getfn
11894///      (c:4134-4137); otherwise allocate fresh via
11895///      newparamtable(17, name).
11896///   3. Walk pairs: each value (k, v) becomes a PM_SCALAR|PM_UNSET
11897///      child param `createparam(k)`, then `assignstrvalue(v->pm,
11898///      val, eltflags)` (c:4140-4166).
11899///   4. `pm->gsu.h->setfn(pm, ht)` to install (c:4168).
11900///
11901/// Storage model: C's per-pair `createparam(k)` + `assignstrvalue`
11902/// builds child Params inside a fresh `newparamtable`; zshrs's assoc
11903/// values live in the `paramtab_hashed_storage` IndexMap keyed by the
11904/// owning param's name, so the pair walk writes there. `pm.u_hash`
11905/// stays untouched — the IndexMap is the authoritative store (same
11906/// contract sethparam/gethparam already use).
11907pub fn arrhashsetfn(
11908    // c:4113
11909    pm: &mut param,
11910    val: Vec<String>,
11911    flags: i32,
11912) {
11913    // c:4124-4127 — count non-Marker entries.
11914    let alen: usize = val
11915        .iter()
11916        .filter(|s| !s.starts_with(Marker as char))
11917        .count();
11918
11919    // c:4129-4131 — odd count → error.
11920    if alen % 2 != 0 {
11921        zerr("bad set of key/value pairs for associative array");
11922        return;
11923    }
11924
11925    // c:4135-4139 — ASSPM_AUGMENT starts from the existing hash
11926    // (`ht = paramtab = pm->gsu.h->getfn(pm)`); otherwise a fresh
11927    // table (`newparamtable(17, pm->node.nam)`).
11928    let mut map: IndexMap<String, String> = if (flags & ASSPM_AUGMENT) != 0 {
11929        paramtab_hashed_storage()
11930            .lock()
11931            .unwrap()
11932            .get(&pm.node.nam)
11933            .cloned()
11934            .unwrap_or_default() // c:4136
11935    } else {
11936        IndexMap::new() // c:4138-4139
11937    };
11938
11939    // c:4141-4166 — pair walk. keyvalpairelement (Src/subst.c:49)
11940    // emits `[Marker, key, value]` triples for `[k]=v` / `[k]+=v`
11941    // forms ("Either all elements have Marker or none. Checked in
11942    // caller." c:4144); plain input is flat `[k, v, ...]` pairs.
11943    let mut it = val.into_iter();
11944    while let Some(first) = it.next() {
11945        let (elt_augment, key) = if first.starts_with(Marker as char) {
11946            // c:4145-4151 — `(*aptr)[1] == '+'` → per-element append
11947            // (ASSPM_AUGMENT via the setsparam INT_MAX trick).
11948            let aug = first[Marker.len_utf8()..].starts_with('+');
11949            match it.next() {
11950                Some(k) => (aug, k),
11951                None => break,
11952            }
11953        } else {
11954            (false, first)
11955        };
11956        let v = it.next().unwrap_or_default(); // c:4166 assignstrvalue value
11957        if elt_augment {
11958            // c:4147-4150 — `[k]+=v` appends to the existing element.
11959            map.entry(key).or_default().push_str(&v);
11960        } else {
11961            // c:4156-4166 — createparam(k, PM_SCALAR|PM_UNSET) +
11962            // assignstrvalue: plain insert in the IndexMap model.
11963            map.insert(key, v);
11964        }
11965    }
11966
11967    // c:4168-4169 — `pm->gsu.h->setfn(pm, ht)` installs the table.
11968    paramtab_hashed_storage()
11969        .lock()
11970        .unwrap()
11971        .insert(pm.node.nam.clone(), map);
11972    // c:4170 — free(val). Rust drops automatically.
11973}
11974
11975/// Port of `nullstrsetfn()` from `Src/params.c:4180`. C body:
11976/// `zsfree(x);` — frees but doesn't store. Rust drop handles free.
11977#[allow(unused_variables)]
11978pub fn nullstrsetfn(pm: &mut param, x: String) {}
11979
11980/// Port of `nullunsetfn()` from `Src/params.c:4192`. C body: empty.
11981#[allow(unused_variables)]
11982pub fn nullunsetfn(pm: &mut param, exp: i32) {}
11983
11984/// Port of `stdunsetfn()` from `Src/params.c:3955`. C body:
11985/// dispatches `pm->gsu->setfn(pm, NULL)` per `PM_TYPE`, clears
11986/// `PM_TIED`/frees ename for tied params, sets PM_UNSET.
11987///
11988/// Rust port mirrors C semantics: clears the union slot and sets
11989/// PM_UNSET. The GSU vtable callbacks are stored on `param` as
11990/// `Option<Gsu*>` (zsh_h:760-764) but the dispatch uses callback
11991/// fn-ptrs that aren't generally registered yet, so we open-code
11992/// the "setfn(pm, NULL)" effect by zeroing the matching union
11993/// member instead of calling through the vtable.
11994#[allow(unused_variables)]
11995pub fn stdunsetfn(pm: &mut param, exp: i32) {
11996    match PM_TYPE(pm.node.flags as u32) {
11997        PM_SCALAR | PM_NAMEREF => {
11998            pm.u_str = None;
11999            // c:3913-3914 — `if (pm->gsu.s->setfn) pm->gsu.s->setfn(pm, NULL);`.
12000            // !!! RUST-ONLY DISPATCH !!! The port's `gsu_scalar::setfn` takes a
12001            // `String` and cannot receive NULL, so the NULL call is spelled out
12002            // for the one special whose setfn gives NULL a meaning: IFS's
12003            // `ifssetfn` (c:4745-4750) stores `ifs = NULL`, and `inittyptab`
12004            // then splits on the default separators (c:Src/utils.c:4207/4216).
12005            // Without it `IFS=:; unset IFS; s="p q"; print -rl -- ${=s}` kept
12006            // splitting on `:` and printed `p q` (zsh: `p` `q`).
12007            if pm.node.nam == "IFS" && pm.node.flags as u32 & PM_SPECIAL != 0 {
12008                *ifs_lock().lock().expect("ifs poisoned") = None; // c:4748
12009                inittyptab(); // c:4749
12010            }
12011            // The locale setfns are the other specials that give NULL a
12012            // meaning: `lc_allsetfn` (c:4831 `if (!x || !*x)`) falls back to
12013            // `setlang($LANG)`, `lcsetfn` (c:4866) to `$LANG` for its category,
12014            // and `langsetfn` re-runs `setlang`. All three already treat "" as
12015            // NULL, so "" stands in for C's NULL here. Without this,
12016            // `LC_ALL=C; unset LC_ALL` left the process in the C locale.
12017            if pm.node.flags as u32 & PM_SPECIAL != 0 {
12018                match pm.node.nam.as_str() {
12019                    "LC_ALL" => {
12020                        // c:4873 `strsetfn(pm, x)` empties the ONE node that
12021                        // setlang's c:4847 `getsparam_u("LC_ALL")` then reads.
12022                        // The port's `pm` is a detached copy of the table row,
12023                        // so clear the row too or setlang still sees "C" and
12024                        // returns without restoring $LANG's locale.
12025                        if let Ok(mut tab) = paramtab().write() {
12026                            if let Some(p) = tab.get_mut("LC_ALL") {
12027                                p.u_str = None;
12028                            }
12029                        }
12030                        lc_allsetfn(pm, String::new()) // c:3914 → c:4871
12031                    }
12032                    "LANG" => langsetfn(pm, String::new()),     // c:3914 → c:4896
12033                    n if LC_NAMES.iter().any(|(ln, _)| *ln == n) => {
12034                        lcsetfn(pm, String::new()) // c:3914 → c:4904
12035                    }
12036                    _ => {}
12037                }
12038                pm.u_str = None;
12039            }
12040        }
12041        PM_ARRAY => {
12042            pm.u_arr = None;
12043            // c:3917-3920 — the PM_ARRAY arm is `pm->gsu.a->setfn(pm, NULL)`,
12044            // and for the three `IPDEF9(…, &pparams, …)` rows
12045            // (c:Src/params.c:392,393,430 — `*`, `@`, `argv`) that setfn is
12046            // `arrvarsetfn` (c:189-190 `vararray_gsu`). Its body does NOT
12047            // touch `pm->u.arr`: c:4248 `char ***dptr = (char ***)pm->u.data;`
12048            // then c:4259-4260 `if ((pm->node.flags & PM_SPECIAL) && !x)
12049            // *dptr = mkarray(NULL);` — so the GLOBAL positional vector is
12050            // replaced by an EMPTY array (not a NULL pointer; the row carries
12051            // PM_SPECIAL from IPDEF9).
12052            //
12053            // zshrs keeps that vector in `builtin::PPARAMS` rather than on the
12054            // node — the same split `assignaparam` mirrors for the write side
12055            // (params.rs:9548) and `printparamnode` for the print side
12056            // (params.rs:13874). Without the mirror here `unset argv` stamped
12057            // PM_UNSET on a node nothing reads and left `$@` / `$#` holding
12058            // the old positionals.
12059            //
12060            // `*` and `@` are unreachable through this path — both carry
12061            // PM_READONLY_SPECIAL (c:392-393) so `unsetparam_pm` rejects them
12062            // at c:3850, and `unset '*'` never gets that far anyway
12063            // (`isident` fails, c:Src/builtin.c:3878). Only c:430's `argv`,
12064            // whose IPDEF9 flag word is 0, is unsettable. They are matched
12065            // together because they are one parameter.
12066            if matches!(pm.node.nam.as_str(), "argv" | "*" | "@") {
12067                if let Ok(mut pp) = crate::ported::builtin::PPARAMS.lock() {
12068                    pp.clear(); // c:4260 mkarray(NULL)
12069                }
12070            }
12071        }
12072        PM_HASHED => {
12073            // c:3922-3925 — `pm->gsu.h->setfn(pm, NULL)`, which for the
12074            // standard hash gsu (c:194 `{ hashgetfn, hashsetfn, stdunsetfn }`)
12075            // is `hashsetfn` (c:4045-4050): `if (pm->u.hash && pm->u.hash != x)
12076            // deleteparamtable(pm->u.hash); pm->u.hash = x;` — with `x` NULL,
12077            // the association's pairs are DESTROYED and the slot cleared.
12078            //
12079            // !!! WARNING: RUST-ONLY HAZARD (the structures do NOT match) !!!
12080            // In C the pairs hang off the Param itself, so clearing `u.hash`
12081            // IS the teardown. zshrs keeps them in `paramtab_hashed_storage`,
12082            // a side map keyed by NAME, and `arrhashsetfn` — the only writer
12083            // of whole associations (c:4065, reached from `sethparam` →
12084            // `setarrvalue` c:2908) — never populates `pm.u_hash` at all. So
12085            // clearing `u_hash` here freed a slot nothing reads, and this arm
12086            // was inert: the pairs outlived the unset and every subscripted
12087            // read still found them.
12088            //
12089            // Callers papered over that one at a time. `bin_typeset`'s `+a`/
12090            // `+A` arm said so outright — "unsetparam alone can leave the
12091            // paramtab_hashed_storage entry, so a later scalar deref still saw
12092            // the joined values" — and `bin_unset`'s ordinary `unset NAME` arm
12093            // carried its own copy. Doing it here instead is what C does, and
12094            // it puts the teardown on the same node the rest of this function
12095            // is tearing down.
12096            //
12097            // Keyed by name, because that is how the store is keyed; this is
12098            // the same dimension mismatch recorded on the store's own
12099            // declaration. It is safe against the one place where name is not
12100            // enough — a `local -A` shadow — because that path does not come
12101            // through here at all: `endparamscope` unlinks the popped node
12102            // directly and restores the outer scope's row from
12103            // `PARAMTAB_HASHED_SHADOW_STACK`, never calling any unsetfn.
12104            pm.u_hash = None;
12105            if let Ok(mut m) = paramtab_hashed_storage().lock() {
12106                m.remove(pm.node.nam.as_str()); // c:4047 deleteparamtable
12107            }
12108        }
12109        _ => {
12110            if (pm.node.flags as u32 & PM_SPECIAL) == 0 {
12111                pm.u_str = None;
12112            }
12113        }
12114    }
12115    if (pm.node.flags as u32 & (PM_SPECIAL | PM_TIED)) == PM_TIED {
12116        pm.ename = None;
12117        pm.node.flags &= !(PM_TIED as i32);
12118    }
12119    pm.node.flags |= PM_UNSET as i32;
12120}
12121
12122// -----------------------------------------------------------
12123// "Null" callbacks — no-op getfn/setfn/unsetfn slots used for
12124// read-only or write-only special params.
12125// -----------------------------------------------------------
12126
12127/// Port of `nullintsetfn()` from `Src/params.c:4187`. C body:
12128/// empty (no-op setter for read-only int params).
12129#[allow(unused_variables)]
12130pub fn nullintsetfn(pm: &mut param, x: i64) {}
12131
12132/// Port of `nullsethashfn()` from `Src/params.c:4104`. C body:
12133/// `deleteparamtable(x);` — frees the supplied table, doesn't store.
12134#[allow(unused_variables)]
12135pub fn nullsethashfn(pm: &mut param, x: HashTable) {
12136    // Rust drop semantics free `x` when this scope ends.
12137}
12138
12139// -----------------------------------------------------------
12140// Generic special-param GSU callbacks (`u.valptr` / `u.data`).
12141// C source uses raw pointer indirection through `pm->u.data`/
12142// `pm->u.valptr` — Rust port stores the global's name in `u_str`
12143// (lookup key) since we can't carry raw pointers across an FFI
12144// boundary safely. The lookup-table integration ships with the
12145// special-params init code (Src/params.c:4213 createparamtable).
12146// -----------------------------------------------------------
12147
12148/// Port of `intvargetfn()` from `Src/params.c:4202`. C body:
12149/// `return *pm->u.valptr;`
12150pub fn intvargetfn(pm: &param) -> i64 {
12151    // c:4156 — `return *pm->u.valptr;`. For most IPDEF4/IPDEF5 specials the
12152    // pointer aims at a global that zshrs models as an atomic rather than as
12153    // storage on the node, so the read has to name that global. `$COLUMNS`
12154    // and `$LINES` are `IPDEF5("COLUMNS", &zterm_columns, zlevar_gsu)` /
12155    // `IPDEF5("LINES", &zterm_lines, zlevar_gsu)` (c:362-363).
12156    //
12157    // The alias runs both ways in C and nothing synchronises the halves:
12158    // `zlevarsetfn` (c:4176 `*p = x`) writes the global through the same
12159    // pointer, and `adjustwinsize`'s `setiparam("COLUMNS", zterm_columns)`
12160    // (c:1934) exists only to re-export an environment COLUMNS — it is
12161    // guarded by `zgetenv("COLUMNS")` precisely because the parameter itself
12162    // needs no update.
12163    //
12164    // zshrs ported only the SET half (`zlevarsetfn` mirrors into
12165    // `ZTERM_COLUMNS`), so a read answered from the node's `u_val` — a
12166    // snapshot, and a snapshot can be rolled back. `adjustwinsize` runs from
12167    // the SIGWINCH handler, which zshrs can enter while the shell is inside a
12168    // command substitution's saved state, so the handler's `setiparam` write
12169    // was discarded when that state was restored while the `ZTERM_*` globals
12170    // kept the new size. A completer that forked a helper across a resize
12171    // then read `$COLUMNS` as the OLD width while its matches were counted
12172    // against the NEW one: `_git_commands` pads every display string to
12173    // `${(r.COLUMNS-4.)…}` (Functions/Completion/Unix/_git:6890), so
12174    // `git <TAB>` across a 24x80 -> 24x60 resize asked to see "all 152
12175    // possibilities (236 lines)" for 152 one-line matches. The same gap made
12176    // `local COLUMNS` read 0 where zsh reads the live width.
12177    match pm.node.nam.as_str() {
12178        "COLUMNS" => crate::ported::utils::ZTERM_COLUMNS.load(Ordering::SeqCst) as i64, // c:362
12179        "LINES" => crate::ported::utils::ZTERM_LINES.load(Ordering::SeqCst) as i64, // c:363
12180        _ => pm.u_val,
12181    }
12182}
12183
12184/// Port of `intvarsetfn()` from `Src/params.c:4213`. C body:
12185/// `*pm->u.valptr = x;`
12186pub fn intvarsetfn(pm: &mut param, x: i64) {
12187    pm.u_val = x;
12188}
12189
12190/// Port of `zlevarsetfn()` from `Src/params.c:4224`. C body sets
12191/// the int and triggers `adjustwinsize` for LINES/COLUMNS.
12192/// Port of `zlevarsetfn(Param pm, zlong x)` from `Src/params.c:4226`.
12193/// C body: `*p = x; if (p == &zterm_lines || p == &zterm_columns)
12194/// adjustwinsize(2 + (p == &zterm_columns));`
12195///
12196/// The `from` argument to `adjustwinsize` is documented at
12197/// `Src/utils.c:1883-1887`: 0=signal, 1=manual, 2=LINES callback,
12198/// 3=COLUMNS callback. Each value selects a different code path
12199/// inside `adjustwinsize` — for example, `from=2` skips the
12200/// COLUMNS-specific ioctl, and `from=3` skips the LINES path.
12201///
12202/// The previous Rust port passed `0` for both LINES and COLUMNS,
12203/// which triggered the FULL `getwinsz` ioctl + both adjustlines
12204/// AND adjustcolumns calls AND the potential setiparam recursion
12205/// — diverging from C's narrow "just adjust the one axis we
12206/// changed" semantics. Effect: setting `LINES=80` would re-issue
12207/// `setiparam("COLUMNS", ...)` recursively, churning the
12208/// paramtab for no reason.
12209pub fn zlevarsetfn(pm: &mut param, x: i64) {
12210    // c:4226
12211    pm.u_val = x; // c:4230 *p = x;
12212                  // c:4230 — `p` IS `&zterm_lines` / `&zterm_columns`
12213                  // (Src/params.c:362-363 `IPDEF5("COLUMNS", &zterm_columns,
12214                  // zlevar_gsu)`), so C's single `*p = x` publishes the value
12215                  // to the parameter AND to the global every other C reader
12216                  // consults. zshrs's param carries its own `u_val` copy, so
12217                  // the same write has to be made twice. It belongs HERE and
12218                  // not inside `adjustwinsize`, because `adjustwinsize`
12219                  // early-returns at c:1900-1901 when `SHTTY == -1` — a
12220                  // non-interactive `COLUMNS=40` would otherwise never reach
12221                  // the global, while C honours it.
12222                  // c:4231-4232 — `2 + (p == &zterm_columns)` selects 2 for LINES
12223                  // (zterm_lines) and 3 for COLUMNS (zterm_columns).
12224    if pm.node.nam == "LINES" {
12225        crate::ported::utils::ZTERM_LINES.store(x as i32, Ordering::SeqCst); // c:4230
12226        let _ = adjustwinsize(2); // c:4232 LINES path
12227    } else if pm.node.nam == "COLUMNS" {
12228        crate::ported::utils::ZTERM_COLUMNS.store(x as i32, Ordering::SeqCst); // c:4230
12229        let _ = adjustwinsize(3); // c:4232 COLUMNS path
12230    }
12231}
12232
12233/// Port of `strvarsetfn()` from `Src/params.c:4249`. C body:
12234/// `zsfree(*q); *q = x;` where `q = (char **)pm->u.data`.
12235pub fn strvarsetfn(pm: &mut param, x: Option<String>) {
12236    pm.u_str = x;
12237}
12238
12239/// Port of `strvargetfn()` from `Src/params.c:4263`. C body:
12240/// `s = *((char **)pm->u.data); return s ? s : hcalloc(1);`
12241pub fn strvargetfn(pm: &param) -> String {
12242    pm.u_str.clone().unwrap_or_default()
12243}
12244
12245/// Port of `arrvargetfn()` from `Src/params.c:4279`. C body:
12246/// `arrptr = *((char ***)pm->u.data); return arrptr ?: &nullarray;`
12247pub fn arrvargetfn(pm: &param) -> Vec<String> {
12248    pm.u_arr.clone().unwrap_or_default()
12249}
12250
12251/// Port of `arrvarsetfn()` from `Src/params.c:4294`. The previous body skipped three of
12252/// the four canonical C arms:
12253///   1. PM_UNIQUE → uniqarray (was ported via simple_arrayuniq).
12254///   2. PM_SPECIAL + null x → `*dptr = mkarray(NULL)` so a tied
12255///      array set to NULL becomes a writable empty array, not a
12256///      dangling null (was missing).
12257///   3. `pm->ename` set → `arrfixenv(ename, x)` syncs the colon-
12258///      joined env var partner (was missing — breaks PATH/path,
12259///      FPATH/fpath, etc. when set via the array form).
12260///   4. `pm->ename` set + null x + `*dptr == path` → invalidate
12261///      pathchecked so the next path resolution re-walks (was
12262///      missing).
12263pub fn arrvarsetfn(pm: &mut param, x: Option<Vec<String>>) {
12264    // c:4296 `char ***dptr = (char ***)pm->u.data;`
12265    // c:4298-4299 — `if (*dptr != x) freearray(*dptr);` Rust Vec drop
12266    // on reassignment handles freeing automatically.
12267    // c:4300-4301 — `if (pm->node.flags & PM_UNIQUE) uniqarray(x);`
12268    let uniq_applied: Option<Vec<String>> = match x {
12269        Some(v) if (pm.node.flags as u32 & PM_UNIQUE) != 0 => Some(simple_arrayuniq(v)),
12270        other => other,
12271    };
12272    // c:4302-4310 — PM_SPECIAL + NULL → mkarray(NULL); else assign.
12273    let final_val: Vec<String> = match uniq_applied {
12274        Some(v) => v, // c:4310 `*dptr = x;`
12275        None => {
12276            if (pm.node.flags as u32 & PM_SPECIAL) != 0 {
12277                crate::ported::utils::mkarray(None) // c:4308 `mkarray(NULL)`
12278            } else {
12279                Vec::new() // c:4310 — null case for non-special: empty.
12280            }
12281        }
12282    };
12283    // c:4311-4316 — ename sync.
12284    if let Some(ename) = pm.ename.clone() {
12285        // c:4311 `if (pm->ename)`
12286        if !final_val.is_empty() || pm.u_arr.is_some() {
12287            // c:4312-4313 — `if (x) arrfixenv(pm->ename, x);`
12288            arrfixenv(&ename, Some(&final_val));
12289        } else if pm.node.nam == "path" {
12290            // c:4314-4315 — `else if (*dptr == path) pathchecked = path;`
12291            // — invalidate the path-resolver cache. Rust port uses an
12292            // AtomicUsize sentinel; storing 0 marks "must re-walk".
12293            crate::ported::hashtable::pathchecked.store(0, Ordering::SeqCst);
12294        }
12295    }
12296    pm.u_arr = Some(final_val);
12297}
12298
12299/// Array to colon-separated path — inverse of `colonsplit`.
12300/// Port of `colonarrgetfn()` from `Src/params.c:4321` (joins the array
12301/// stored in `pm->u.colon` back into the `:`-form for env).
12302/// WARNING: param names don't match C — Rust=(arr) vs C=(pm)
12303pub fn colonarrgetfn(arr: &[String]) -> String {
12304    arr.join(":")
12305}
12306
12307/// Port of `colonarrsetfn()` from `Src/params.c:4329`. C body
12308/// splits the colon-string into an array and stores via the
12309/// generic arrvarsetfn.
12310pub fn colonarrsetfn(pm: &mut param, x: Option<String>) {
12311    let uniq = (pm.node.flags as u32 & PM_UNIQUE) != 0; // c:4339
12312                                                        // c:4339-4341 — `arrvarsetfn(pm, x ? colonsplit(...) : NULL);`
12313                                                        // The None branch must pass `None` (not `Some(Vec::new())`) so the
12314                                                        // PM_SPECIAL + NULL → mkarray(NULL) arm in arrvarsetfn fires.
12315    let arr = x.map(|s| colonsplit(&s, uniq)); // c:4339
12316    arrvarsetfn(pm, arr);
12317}
12318
12319/// Port of `tiedarrgetfn()` from `Src/params.c:4348`. C body:
12320///   `struct tieddata *dptr = (struct tieddata *)pm->u.data;`
12321///   `return *dptr->arrptr ? zjoin(*dptr->arrptr, …) : "";`
12322///
12323/// C's `pm->u.data->arrptr` is a raw pointer into the partner array
12324/// param's storage. The Rust port can't hold a pointer into another
12325/// paramtab entry's heap, so the partner lookup goes via
12326/// `pm.ename` → paramtab → `apm.u_arr`. For backwards compatibility
12327/// with callers that set `pm.u_arr` directly (the pre-fix code path
12328/// at c:4348 that's still in some Rust call sites), fall back to
12329/// the scalar's own `u_arr` when `ename` is None or the partner is
12330/// missing. Bug #24 in docs/BUGS.md.
12331pub fn tiedarrgetfn(pm: &param) -> Vec<String> {
12332    if let Some(ename) = pm.ename.as_deref() {
12333        if let Ok(tab) = paramtab().read() {
12334            if let Some(apm) = tab.get(ename) {
12335                // The partner is found by NAME, so the lookup lands on whatever
12336                // binding is currently visible — including a LOCAL shadow that
12337                // is no part of the tie. C links the pair by struct pointer, so
12338                // `local -a v=(a b)` inside a function cannot change what `$V`
12339                // reads; zshrs published the local through the scalar half
12340                // (`$V` became `a:b` where zsh keeps `g1:g2`). Note the local
12341                // is genuinely untied — `${(t)v}` is `array-local`, matching
12342                // zsh — so the flags were already right and only this read was
12343                // wrong. Walk the shadow chain (`old`) to the binding that
12344                // actually carries PM_TIED. docs/BUGS.md #1039 B.
12345                //
12346                // A local SPECIAL is unaffected: it keeps PM_TIED through the
12347                // newspecial inheritance (`${(t)path}` is
12348                // `array-local-tied-special`), so `local path=(…)` still
12349                // publishes to `$PATH` as #1039 A requires.
12350                let mut cand: Option<&param> = Some(apm);
12351                while let Some(p) = cand {
12352                    if (p.node.flags as u32 & PM_TIED) != 0 {
12353                        if let Some(arr) = p.u_arr.as_ref() {
12354                            return arr.clone();
12355                        }
12356                        break;
12357                    }
12358                    cand = p.old.as_deref();
12359                }
12360            }
12361        }
12362    }
12363    pm.u_arr.clone().unwrap_or_default()
12364}
12365
12366/// Port of `tiedarrsetfn()` from `Src/params.c:4357`. Setter for a colon-array-tied
12367/// scalar (PATH/CDPATH/MAILPATH/etc.).
12368///
12369/// C body:
12370///   1. Free the existing tied array (`*dptr->arrptr`) at c:4363.
12371///   2. If no array but an `ename` exists, clear PM_DEFAULTED on
12372///      the tied array param (c:4365-4368).
12373///   3. If `x` is non-null: build a 1-or-2-byte separator from
12374///      `dptr->joinchar` (Meta-quoting if needed, c:4371-4380),
12375///      `sepsplit(x, sepbuf, 0, 0)` into the array (c:4381), and
12376///      uniqarray() if PM_UNIQUE (c:4382-4383). Free `x` (c:4384).
12377///   4. Else: `*dptr->arrptr = NULL` (c:4385-4386).
12378///   5. If `pm->ename` is set, call `arrfixenv(pm->name, arrptr)`
12379///      to sync env (c:4387-4388).
12380///
12381/// The Rust port treats `u_arr` as the tied array storage and
12382/// uses `':'` as the joinchar default (matches PATH/CDPATH/FPATH
12383/// /MAILPATH/PSVAR/MODULE_PATH which all use colon separators —
12384/// the joinchar field on the C-side tieddata wasn't ported to the
12385/// Rust Param struct yet).
12386pub fn tiedarrsetfn(pm: &mut param, x: Option<String>) {
12387    // c:4357
12388
12389    // c:4361-4368 — free old / clear PM_DEFAULTED on tied counterpart.
12390    if pm.u_arr.is_none() {
12391        if let Some(ename) = pm.ename.clone() {
12392            // c:4365
12393            let mut tab = paramtab().write().unwrap();
12394            if let Some(altpm) = tab.get_mut(&ename) {
12395                // c:4366
12396                altpm.node.flags &= !(PM_DEFAULTED as i32); // c:4367
12397            }
12398        }
12399    }
12400
12401    // c:4369-4386 — split + assign. C writes through `*dptr->arrptr`
12402    // which is a pointer INTO THE PARTNER ARRAY param's storage. The
12403    // Rust port can't hold raw pointers across paramtab entries, so
12404    // when `pm.ename` is set, write the split result to the partner's
12405    // `u_arr` via paramtab (bug #24). When `pm.ename` is None (older
12406    // call sites or non-tied use), keep the scalar's own `u_arr`
12407    // up-to-date for legacy callers.
12408    // c:4370-4380 — single-byte separator built from `dptr->joinchar`
12409    // on the tieddata riding `pm->u.data` (Rust: typed `u_tied` view);
12410    // joinchar==0 → empty sepbuf; no tieddata → `:` default (the
12411    // PM_SPECIAL colon-tied params, c:5314-5315).
12412    let sepbuf: String = match pm.u_tied.as_deref() {
12413        // c:4320-4332 — `if (imeta(dptr->joinchar)) { sepbuf[0] = Meta;
12414        // sepbuf[1] = dptr->joinchar ^ 32; } else sepbuf[0] = dptr->joinchar;`
12415        // !!! WARNING: RUST-ONLY ARM — zshrs keeps NUL unmetafied inside values,
12416        // so a NUL join character is the raw `\0`, not C's `Meta ' '` pair.
12417        Some(td) => match td.joinchar as u8 {
12418            0 => "\0".to_string(),
12419            b if crate::ported::utils::imeta_byte(b) => [char::from(Meta), char::from(b ^ 32)].iter().collect(),
12420            b => char::from(b).to_string(), // c:4330
12421        },
12422        None => ":".to_string(),              // c:5314-5315
12423    };
12424    let arr_opt: Option<Vec<String>> = if let Some(s) = x {
12425        // c:4369
12426        // c:4333 — `*dptr->arrptr = sepsplit(x, sepbuf, 0, 0);`. sepbuf holds
12427        // the join character itself, NUL included, so a NUL-tied scalar splits
12428        // on NUL; a value with no NUL stays one element.
12429        let split: Vec<String> = crate::ported::utils::sepsplit(&s, Some(&sepbuf), true);
12430        // c:4382-4383 — uniqarray if PM_UNIQUE.
12431        let split = if pm.node.flags & PM_UNIQUE as i32 != 0 {
12432            // c:4382
12433            uniqarray(split) // c:4383
12434        } else {
12435            split
12436        };
12437        Some(split)
12438    } else {
12439        None
12440    };
12441    if let Some(ename) = pm.ename.clone() {
12442        if let Ok(mut tab) = paramtab().write() {
12443            if let Some(apm) = tab.get_mut(&ename) {
12444                apm.u_arr = arr_opt.clone(); // c:4381
12445            }
12446        }
12447        // c:4352 — zjoin writes the raw joinchar byte; joinchar==0
12448        // joins with NUL (measured on 5.9.1: `s=(x y); print -rn
12449        // "$S"` → `x\0y`), not with the empty split-sepbuf.
12450        let joinsep = if sepbuf.is_empty() {
12451            "\0"
12452        } else {
12453            sepbuf.as_str()
12454        };
12455        pm.u_str = arr_opt.as_ref().map(|a| a.join(joinsep));
12456    } else {
12457        pm.u_arr = arr_opt;
12458    }
12459
12460    // c:4387-4388 — `if (pm->ename) arrfixenv(pm->name, *dptr->arrptr)`.
12461    if pm.ename.is_some() {
12462        let nam = pm.node.nam.clone();
12463        // Pull the live array out of the partner for env sync.
12464        let snap = paramtab().read().ok().and_then(|t| {
12465            t.get(pm.ename.as_deref().unwrap())
12466                .and_then(|p| p.u_arr.clone())
12467        });
12468        arrfixenv(&nam, snap.as_deref());
12469    }
12470}
12471
12472/// Port of `tiedarrunsetfn()` from `Src/params.c:4393`. C body
12473/// frees the tied storage and calls stdunsetfn.
12474/// Direct port of `void tiedarrunsetfn(Param pm, UNUSED(int exp))`
12475/// from `Src/params.c:4393`. Special unset for tied arrays:
12476/// frees tieddata, ename, clears PM_TIED, sets PM_UNSET.
12477///
12478/// C body:
12479///   pm->gsu.s->setfn(pm, NULL);             // c:4393
12480///   zfree(pm->u.data, sizeof(tieddata));    // c:4393
12481///   pm->u.data = NULL;                      // c:4393
12482///   zsfree(pm->ename);                      // c:4393
12483///   pm->ename = NULL;                       // c:4393
12484///   pm->flags &= ~PM_TIED;                  // c:4393
12485///   pm->flags |= PM_UNSET;                  // c:4393
12486pub fn tiedarrunsetfn(pm: &mut param, _exp: i32) {
12487    // c:4393
12488    // c:4400 — invoke the scalar setfn with NULL (frees backing array).
12489    tiedarrsetfn(pm, None);
12490    // c:4401-4403 — drop tieddata.
12491    pm.u_data = 0;
12492    pm.u_arr = None;
12493    // c:4404-4405 — `zsfree(pm->ename); pm->ename = NULL`.
12494    pm.ename = None;
12495    // c:4406-4407 — flag toggles.
12496    pm.node.flags &= !(PM_TIED as i32);
12497    pm.node.flags |= PM_UNSET as i32;
12498}
12499
12500// -----------------------------------------------------------
12501// Array uniq helpers.
12502// -----------------------------------------------------------
12503
12504/// Port of `simple_arrayuniq()` from `Src/params.c:4412`. C body:
12505/// O(n^2) dedupe in place — first occurrence wins.
12506/// WARNING: param names don't match C — Rust=(x) vs C=(x, freeok)
12507pub fn simple_arrayuniq(x: Vec<String>) -> Vec<String> {
12508    let mut seen: HashSet<String> = HashSet::new();
12509    let mut out = Vec::with_capacity(x.len());
12510    for s in x {
12511        if seen.insert(s.clone()) {
12512            out.push(s);
12513        }
12514    }
12515    out
12516}
12517
12518/// Port of `arrayuniq_freenode()` from `Src/params.c:4443`. C
12519/// body: `zsfree(((Pathnode)hn)->name); zfree(hn, sizeof…);` —
12520/// the freenode callback for the temporary HashTable `arrayuniq`
12521/// builds. Rust drop semantics handle this; no-op shim.
12522/// is `(void)hn;` — intentional no-op; passed as freenode callback
12523/// to scratch hashtable used by `arrayuniq` so existing entries
12524/// aren't freed when the table is torn down.
12525/// WARNING: param names don't match C — Rust=() vs C=(hn)
12526/// WARNING: param names don't match C — Rust=() vs C=(pm, x)
12527pub fn arrayuniq_freenode() {}
12528
12529/// Port of `newuniqtable()` from `Src/params.c:4450`. C body allocates a `HashTable`
12530/// named "arrayuniq" with the standard hasher/cmpnodes/
12531/// add/get/remove/disable/enable function pointers plus
12532/// `arrayuniq_freenode` as the freenode callback (which is a
12533/// no-op — see c:4443). Rust returns a `HashSet<String>` with
12534/// the size hint pre-allocated; the freenode-callback role is
12535/// implicit (Drop runs on HashSet teardown without freeing
12536/// borrowed strings).
12537pub fn newuniqtable(size: i64) -> HashSet<String> {
12538    // c:4450
12539    HashSet::with_capacity(size.max(0) as usize) // c:4450 newhashtable(size, ...)
12540}
12541
12542/// Port of `arrayuniq()` from `Src/params.c:4473`. First-wins dedupe of `x`,
12543/// in-place. C uses simple O(n²) scan for arrays under 10
12544/// entries, switching to a HashTable for larger arrays. `freeok`
12545/// controls whether to `zsfree()` duplicates (only safe when
12546/// caller owns the strings — Rust drop semantics handle it).
12547///
12548/// Signature note: C takes `char **x` + in-place mutation; Rust
12549/// takes owned `Vec<String>` and returns the deduped result.
12550/// `freeok` is preserved but is a no-op in Rust (drops free
12551/// automatically). The hashtable / simple-loop tiering follows
12552/// the same threshold (10) as C.
12553pub fn arrayuniq(x: Vec<String>, freeok: i32) -> Vec<String> {
12554    // c:4473
12555    let _ = freeok;
12556    let array_size = x.len();
12557    if array_size == 0 {
12558        // c:4481
12559        return x;
12560    }
12561    // c:4482-4486 — small-array fallback to simple_arrayuniq.
12562    if array_size < 10 {
12563        // c:4482
12564        return simple_arrayuniq(x); // c:4484
12565    }
12566    // c:4483 — `if (!(ht = newuniqtable(array_size + 1)))` — Rust
12567    // newuniqtable never fails, but mirror the C order of allocation.
12568    let mut ht = newuniqtable(array_size as i64 + 1);
12569    // c:4487-4507 — walk + first-wins.
12570    let mut out: Vec<String> = Vec::with_capacity(array_size);
12571    for s in x {
12572        // c:4487 walk
12573        if ht.insert(s.clone()) {
12574            // c:4488 gethashnode2 + addhashnode2
12575            out.push(s); // c:4495 *write_it = *it
12576        }
12577        // else: dup — drop the value (c:4502 zsfree if freeok).
12578    }
12579    drop(ht); // c:4523 deletehashtable
12580    out
12581}
12582
12583/// Remove duplicate elements from array while preserving order.
12584/// Port of `uniqarray()` from `Src/params.c:4514`.
12585/// WARNING: param names don't match C — Rust=(arr) vs C=(x)
12586pub fn uniqarray(arr: Vec<String>) -> Vec<String> {
12587    let mut seen = HashSet::new();
12588    arr.into_iter().filter(|s| seen.insert(s.clone())).collect()
12589}
12590
12591/// Port of `zhuniqarray()` from `Src/params.c:4523`. Wraps `arrayuniq` with `freeok=0`.
12592/// (C body is literally `arrayuniq(x, 0);`.)
12593pub fn zhuniqarray(x: Vec<String>) -> Vec<String> {
12594    // c:4523
12595    arrayuniq(x, 0) // c:4523
12596}
12597
12598/// Port of `poundgetfn()` from `Src/params.c:4534`. C body:
12599/// `return arrlen(pparams);`
12600/// WARNING: param names don't match C — Rust=() vs C=(pm)
12601pub fn poundgetfn() -> i64 {
12602    pparams_lock().lock().expect("pparams poisoned").len() as i64
12603}
12604
12605/// Port of `randomgetfn()` from `Src/params.c:4543`. C body:
12606/// `return rand() & 0x7fff;`
12607/// WARNING: param names don't match C — Rust=() vs C=(pm)
12608pub fn randomgetfn() -> i64 {
12609    (unsafe { libc::rand() } & 0x7fff) as i64
12610}
12611
12612/// Port of `randomsetfn()` from `Src/params.c:4552`. C body:
12613/// `srand((unsigned int)v);`
12614/// WARNING: param names don't match C — Rust=(v) vs C=(pm, v)
12615pub fn randomsetfn(v: i64) {
12616    unsafe { libc::srand(v as libc::c_uint) };
12617}
12618
12619// -----------------------------------------------------------
12620// SECONDS / EPOCHSECONDS family — backed by SHTIMER static.
12621// -----------------------------------------------------------
12622
12623/// Port of `intsecondsgetfn()` from `Src/params.c:4561`. C body:
12624/// `return (zlong)(now.tv_sec - shtimer.tv_sec - …);`
12625/// WARNING: param names don't match C — Rust=() vs C=(pm)
12626pub fn intsecondsgetfn() -> i64 {
12627    // c:4563 — `shtimer` is initialized at shell startup (zsh.h
12628    // mod_export). Force shtimer init BEFORE reading `now` so the
12629    // lazy-init race doesn't make `now < shtimer` on first call
12630    // (which produced -1 from the nsec borrow-from-sec adjustment).
12631    let timer = *shtimer_lock().lock().expect("shtimer poisoned");
12632    let now = SystemTime::now()
12633        .duration_since(UNIX_EPOCH)
12634        .unwrap_or_default();
12635    let now_sec = now.as_secs() as i64;
12636    let timer_sec = timer.as_secs() as i64;
12637    let now_nsec = now.subsec_nanos() as i64;
12638    let timer_nsec = timer.subsec_nanos() as i64;
12639    let diff = now_sec - timer_sec - i64::from(now_nsec < timer_nsec);
12640    // c:4565 — clamp negative-diff (lazy-init or clock skew) to 0
12641    // so \$SECONDS reads as a non-negative count of elapsed seconds
12642    // from shell start. zsh's shtimer is set in main() before any
12643    // user code runs, guaranteeing now >= shtimer; the Rust lazy
12644    // init makes this stricter via .max(0).
12645    diff.max(0)
12646}
12647
12648/// Port of `intsecondssetfn()` from `Src/params.c:4575`. C body:
12649/// ```c
12650/// diff = (zlong)now.tv_sec - x;
12651/// shtimer.tv_sec = diff;
12652/// if ((zlong)shtimer.tv_sec != diff)
12653///     zwarn("SECONDS truncated on assignment");
12654/// shtimer.tv_nsec = now.tv_nsec;
12655/// ```
12656/// WARNING: param names don't match C — Rust=(x) vs C=(pm, x)
12657pub fn intsecondssetfn(x: i64) {
12658    let now = SystemTime::now()
12659        .duration_since(UNIX_EPOCH)
12660        .unwrap_or_default();
12661    let now_sec = now.as_secs() as i64;
12662    let new_sec = now_sec - x;
12663    // c:4587 — C uses `zwarn` (informational), NOT `zerr` (fatal).
12664    // The C body STORES `diff` unconditionally then emits the warning
12665    // if truncation lost information. Rust port previously used `zerr`
12666    // and early-returned (skipping the store) — divergent from C.
12667    if new_sec < 0 {
12668        zwarn("SECONDS truncated on assignment");
12669        // c:4585 — C still stores; Rust represents shtimer as Duration
12670        // which is non-negative. We clamp to zero to preserve the
12671        // "store-anyway" semantic for the time-display path, even
12672        // though the negative-time case is unrepresentable.
12673        *shtimer_lock().lock().expect("shtimer poisoned") = Duration::new(0, now.subsec_nanos());
12674        return;
12675    }
12676    *shtimer_lock().lock().expect("shtimer poisoned") =
12677        Duration::new(new_sec as u64, now.subsec_nanos());
12678}
12679
12680/// Port of `floatsecondsgetfn()` from `Src/params.c:4591`. C body:
12681/// `return (double)(now-tv_sec - shtimer.tv_sec) + nsec/1e9;`
12682/// WARNING: param names don't match C — Rust=() vs C=(pm)
12683pub fn floatsecondsgetfn() -> f64 {
12684    // Read shtimer BEFORE `now`, forcing its lazy init first — intsecondsgetfn
12685    // (above) documents the same ordering requirement: with the opposite order
12686    // a first call can observe `now < shtimer` and go backwards.
12687    let timer = *shtimer_lock().lock().expect("shtimer poisoned");
12688    let now = SystemTime::now()
12689        .duration_since(UNIX_EPOCH)
12690        .unwrap_or_default();
12691    // c:4595-4596 — `(double)(now.tv_sec - shtimer.tv_sec) +
12692    //                (double)(now.tv_nsec - shtimer.tv_nsec) / 1000000000.0`
12693    // C subtracts the two fields SEPARATELY and SIGNED, so a `now` behind
12694    // `shtimer` simply yields a negative reading. This was
12695    // `(now - timer).as_secs_f64()`, and Duration - Duration PANICS on
12696    // underflow rather than going negative — `overflow when subtracting
12697    // durations`. It had never fired because the float type was unreachable:
12698    // `typeset -F SECONDS` was silently ignored (bin_typeset never called
12699    // setsecondstype), so nothing ever selected this getter. Wiring that up
12700    // turned a silent no-op into a crash, which is how this surfaced.
12701    (now.as_secs() as f64 - timer.as_secs() as f64)
12702        + (now.subsec_nanos() as f64 - timer.subsec_nanos() as f64) / 1_000_000_000.0
12703}
12704
12705/// Port of `floatsecondssetfn()` from `Src/params.c:4603`. C body:
12706/// `shtimer.tv_sec = now.tv_sec - (zlong)x; shtimer.tv_nsec = now.tv_nsec - (x-int)*1e9;`
12707/// WARNING: param names don't match C — Rust=(x) vs C=(pm, x)
12708pub fn floatsecondssetfn(x: f64) {
12709    let now = SystemTime::now()
12710        .duration_since(UNIX_EPOCH)
12711        .unwrap_or_default();
12712    let new = now
12713        .checked_sub(Duration::from_secs_f64(x))
12714        .unwrap_or_default();
12715    *shtimer_lock().lock().expect("shtimer poisoned") = new;
12716}
12717
12718/// Port of `getrawseconds()` from `Src/params.c:4615`. C body:
12719/// `return (double)shtimer.tv_sec + (double)shtimer.tv_nsec / 1e9;`
12720pub fn getrawseconds() -> f64 {
12721    shtimer_lock()
12722        .lock()
12723        .expect("shtimer poisoned")
12724        .as_secs_f64()
12725}
12726
12727/// Port of `setrawseconds()` from `Src/params.c:4622`. C body:
12728/// `shtimer.tv_sec = (zlong)x; shtimer.tv_nsec = (x-int)*1e9;`
12729pub fn setrawseconds(x: f64) {
12730    *shtimer_lock().lock().expect("shtimer poisoned") = Duration::from_secs_f64(x);
12731}
12732
12733/// Port of `setsecondstype()` from `Src/params.c:4630`. C body
12734/// flips the `gsu.f`/`gsu.i` callback pointer based on the new
12735/// param-flag bitset.
12736///
12737/// WARNING: zshrs has no Param/GSU dispatch table yet — the
12738/// "promotion between integer/float seconds" logic happens via
12739/// pm->gsu pointer swaps in C. Returns 0 to signal success;
12740/// callers can assume the type change is recorded by the caller's
12741/// own bookkeeping until the GSU table lands.
12742/// WARNING: param names don't match C — Rust=(on, off) vs C=(pm, on, off)
12743pub fn setsecondstype(
12744    // c:4630
12745    pm: &mut param,
12746    on: i32,
12747    off: i32,
12748) -> i32 {
12749    // c:4632 — `int newflags = (pm->flags | on) & ~off`.
12750    let newflags = (pm.node.flags | on) & !off;
12751    // c:4633 — `int tp = PM_TYPE(newflags)`.
12752    let tp = PM_TYPE(newflags as u32);
12753    // c:4635-4638 / 4639-4642 — float vs integer GSU pointer swap.
12754    if tp == PM_EFLOAT || tp == PM_FFLOAT {
12755        // c:4635
12756        // C: `pm->gsu.f = &floatseconds_gsu`. GSU table not yet
12757        // wired in the Rust port; record the type by clearing
12758        // any integer GSU.
12759        pm.gsu_i = None;
12760        // pm.gsu_f = Some(floatseconds_gsu) — pending GSU port.
12761    } else if tp == PM_INTEGER {
12762        // c:4639
12763        // C: `pm->gsu.i = &intseconds_gsu`.
12764        pm.gsu_f = None;
12765        // pm.gsu_i = Some(intseconds_gsu) — pending GSU port.
12766    } else {
12767        return 1; // c:4644
12768    }
12769    pm.node.flags = newflags; // c:4645
12770    0 // c:4646
12771}
12772
12773// -----------------------------------------------------------
12774// $USERNAME
12775// -----------------------------------------------------------
12776
12777/// Port of `usernamegetfn()` from `Src/params.c:4653`. C body:
12778/// Port of `usernamegetfn(UNUSED(Param pm))` from Src/params.c:4655.
12779/// C body: `return get_username();`. C's `get_username()`
12780/// (Src/utils.c:1075) walks `getuid() != cached_uid` and
12781/// refreshes the cache via `getpwuid()` on mismatch — so a
12782/// USERNAME read AFTER an `setuid()` call sees the NEW
12783/// username, not the stale cache.
12784///
12785/// The previous Rust port returned `cached_username_lock()`
12786/// directly without the refresh, so a script that called
12787/// setuid(3) (or USER changed externally via setuid binary)
12788/// would keep returning the old username.
12789///
12790pub fn usernamegetfn(_pm: &param) -> String {
12791    // c:4655
12792    // c:4658 — `return get_username();`. Route through the
12793    // canonical refresh-on-uid-change accessor at utils.rs.
12794    get_username() // c:4658
12795}
12796
12797/// Port of `usernamesetfn()` from `Src/params.c:4662`. C body:
12798/// `getpwnam(x); setgid; setuid; cached_uid = pswd->pw_uid;`
12799///
12800/// WARNING: the SUID-changing path requires getpwnam(3) which
12801/// crosses an unsafe FFI boundary not yet wrapped here. The
12802/// cached-name update is performed; uid/gid changes still need
12803/// porting of the `pwd.h` getpwnam wrapper.
12804pub fn usernamesetfn(_pm: &mut param, x: String) {
12805    // c:4662
12806    // c:4662 — `if (x && (pswd = getpwnam(x)) && pswd->pw_uid != cached_uid)`.
12807    let target = std::ffi::CString::new(x.as_bytes()).ok();
12808    if let Some(cstr) = target {
12809        unsafe {
12810            let pwd = libc::getpwnam(cstr.as_ptr()); // c:4666
12811            if !pwd.is_null() {
12812                // c:4666 — C reads `cached_uid` (a global initialized
12813                // to `getuid()` at init.c:1219 — the REAL uid, NOT
12814                // the effective one). The previous Rust port used
12815                // `geteuid()` which diverges when running setuid
12816                // (geteuid != getuid) — the shell would erroneously
12817                // try to change to a uid it's already at, or skip
12818                // a needed change. Match C exactly: use `getuid()`.
12819                let cached_uid = libc::getuid(); // c:4666 cached_uid = getuid()
12820                if (*pwd).pw_uid != cached_uid {
12821                    // c:4666
12822                    // c:4670-4672 — initgroups(x, pswd->pw_gid).
12823                    let _ = libc::initgroups(cstr.as_ptr(), (*pwd).pw_gid as _);
12824                    // c:4671 — setgid(pswd->pw_gid).
12825                    if libc::setgid((*pwd).pw_gid) != 0 {
12826                        // c:4673
12827                        zwarn(&format!(
12828                            "failed to change group ID: {}",
12829                            crate::ported::utils::zsh_errno_msg(
12830                                std::io::Error::last_os_error().raw_os_error().unwrap_or(0),
12831                            )
12832                        ));
12833                    } else if libc::setuid((*pwd).pw_uid) != 0 {
12834                        // c:4675
12835                        // c:4675-4676 — setuid failed.
12836                        zwarn(&format!(
12837                            "failed to change user ID: {}",
12838                            crate::ported::utils::zsh_errno_msg(
12839                                std::io::Error::last_os_error().raw_os_error().unwrap_or(0),
12840                            )
12841                        ));
12842                    } else {
12843                        // c:4677-4681 — cache update.
12844                        let name_cstr = std::ffi::CStr::from_ptr((*pwd).pw_name);
12845                        let name_str = name_cstr.to_string_lossy().to_string();
12846                        *cached_username_lock().lock().expect("username poisoned") =
12847                            ztrdup_metafy(&name_str);
12848                    }
12849                }
12850            }
12851        }
12852    }
12853    // c:4683 — `zsfree(x)`; Rust drop handles it.
12854    drop(x);
12855}
12856
12857// -----------------------------------------------------------
12858// libc-backed callbacks (UID/GID/EUID/EGID/errno/RANDOM/TTYIDLE).
12859// -----------------------------------------------------------
12860
12861/// Port of `uidgetfn()` from `Src/params.c:4689`. C body:
12862/// `return getuid();`
12863/// WARNING: param names don't match C — Rust=() vs C=(pm)
12864pub fn uidgetfn() -> i64 {
12865    unsafe { libc::getuid() as i64 }
12866}
12867
12868// `termflags` from Src/init.c — bitmap of terminal-state flags. Set
12869// from term_reinit_from_pm and consulted by ZLE before first paint.
12870/// `TERMFLAGS` static.
12871/// Starts as TERM_UNKNOWN (0x02) — c:Src/init.c:1103 `termflags =
12872/// TERM_UNKNOWN;` in init_setup. Cleared by init_term() on success
12873/// (c:Src/init.c:802-803); promptexpand/zleread lazily call
12874/// init_term() when the bit is still set (c:Src/prompt.c:189-190,
12875/// c:Src/Zle/zle_main.c:1260-1261).
12876pub static TERMFLAGS: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0x02);
12877// `TERM_UNKNOWN` re-exported from canonical zsh_h.rs (port of
12878// `Src/zsh.h:1986`). The local declaration here had the value
12879// `1 << 0 = 0x01` — which is C's TERM_BAD (Src/zsh.h:1985), NOT
12880// TERM_UNKNOWN. The canonical TERM_UNKNOWN value is 0x02.
12881//
12882// Callers reading `crate::ported::params::TERM_UNKNOWN` got the
12883// TERM_BAD bit; the params.rs term-init path fired
12884// `TERMFLAGS.fetch_or(TERM_UNKNOWN)` which actually set TERM_BAD,
12885// while the prompt.rs guard at line 441 imported the correct
12886// (0x02) value from zsh_h.rs — so the two paths disagreed silently
12887// about which bit means "unknown terminal".
12888
12889/// Port of `uidsetfn()` from `Src/params.c:4698`. C body:
12890/// `if (setuid((uid_t)x)) zerr("failed to change user ID: %e", errno);`
12891/// C body (2 lines):
12892///   `if (setuid((uid_t)x)) zerr("failed to change user ID: %e", errno);`
12893/// WARNING: param names don't match C — Rust=(x) vs C=(pm, x)
12894pub fn uidsetfn(x: i64) {
12895    // c:4698
12896    if unsafe { libc::setuid(x as libc::uid_t) } != 0 {
12897        // c:4701 — `zerr("failed to change user ID: %e", errno)`.
12898        // C's `%e` formatter consumes errno and prints
12899        // `strerror(errno)` with the system's casing (typically
12900        // lowercase on macOS/Linux). Rust's
12901        // `std::io::Error::last_os_error()` displays the same
12902        // text but with capital first letter + `(os error N)`
12903        // suffix, diverging from zsh. Mirror the C format by
12904        // calling strerror via libc directly. Bug #254 in
12905        // docs/BUGS.md.
12906        let errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
12907        let msg = unsafe {
12908            let p = libc::strerror(errno);
12909            std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned()
12910        };
12911        zerr(&format!("failed to change user ID: {}", msg.to_lowercase())); // c:4702
12912    }
12913}
12914
12915/// Port of `euidgetfn()` from `Src/params.c:4710`. C body:
12916/// `return geteuid();`
12917/// WARNING: param names don't match C — Rust=() vs C=(pm)
12918pub fn euidgetfn() -> i64 {
12919    unsafe { libc::geteuid() as i64 }
12920}
12921
12922/// Port of `euidsetfn()` from `Src/params.c:4719`. C body:
12923/// `if (seteuid((uid_t)x)) zerr("failed to change effective user ID: %e", errno);`
12924/// WARNING: param names don't match C — Rust=(x) vs C=(pm, x)
12925pub fn euidsetfn(x: i64) {
12926    // c:4719
12927    if unsafe { libc::seteuid(x as libc::uid_t) } != 0 {
12928        // c:4722 — strerror format to match C zerr's `%e`. See
12929        // uidsetfn above.
12930        let errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
12931        let msg = unsafe {
12932            let p = libc::strerror(errno);
12933            std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned()
12934        };
12935        zerr(&format!(
12936            "failed to change effective user ID: {}",
12937            msg.to_lowercase()
12938        )); // c:4723
12939    }
12940}
12941
12942/// Port of `gidgetfn()` from `Src/params.c:4731`. C body: `return getgid();`
12943/// WARNING: param names don't match C — Rust=() vs C=(pm)
12944pub fn gidgetfn() -> i64 {
12945    unsafe { libc::getgid() as i64 }
12946}
12947
12948/// Port of `gidsetfn()` from `Src/params.c:4740`. C body:
12949/// `if (setgid((gid_t)x)) zerr("failed to change group ID: %e", errno);`
12950/// WARNING: param names don't match C — Rust=(x) vs C=(pm, x)
12951pub fn gidsetfn(x: i64) {
12952    // c:4740
12953    if unsafe { libc::setgid(x as libc::gid_t) } != 0 {
12954        // c:4743 — strerror format to match C zerr's `%e`. See
12955        // uidsetfn above.
12956        let errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
12957        let msg = unsafe {
12958            let p = libc::strerror(errno);
12959            std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned()
12960        };
12961        zerr(&format!(
12962            "failed to change group ID: {}",
12963            msg.to_lowercase()
12964        )); // c:4744
12965    }
12966}
12967
12968/// Port of `egidgetfn()` from `Src/params.c:4752`. C body: `return getegid();`
12969/// WARNING: param names don't match C — Rust=() vs C=(pm)
12970pub fn egidgetfn() -> i64 {
12971    unsafe { libc::getegid() as i64 }
12972}
12973
12974/// Port of `egidsetfn()` from `Src/params.c:4761`. C body:
12975/// `if (setegid((gid_t)x)) zerr("failed to change effective group ID: %e", errno);`
12976/// WARNING: param names don't match C — Rust=(x) vs C=(pm, x)
12977pub fn egidsetfn(x: i64) {
12978    // c:4761
12979    if unsafe { libc::setegid(x as libc::gid_t) } != 0 {
12980        // c:4764 — strerror format to match C zerr's `%e`. See
12981        // uidsetfn above.
12982        let errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
12983        let msg = unsafe {
12984            let p = libc::strerror(errno);
12985            std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned()
12986        };
12987        zerr(&format!(
12988            "failed to change effective group ID: {}",
12989            msg.to_lowercase()
12990        )); // c:4765
12991    }
12992}
12993
12994/// Port of `ttyidlegetfn()` from `Src/params.c:4771`. C body:
12995/// ```c
12996/// struct stat ttystat;
12997/// if (SHTTY == -1 || fstat(SHTTY, &ttystat)) return -1;
12998/// return time(NULL) - ttystat.st_atime;
12999/// ```
13000/// Rust port reads stdin (fd 0) — closest match to `SHTTY` the
13001/// shell tracks as the controlling-tty fd. Returns -1 if stdin is
13002/// not a tty.
13003/// WARNING: param names don't match C — Rust=() vs C=(pm)
13004pub fn ttyidlegetfn() -> i64 {
13005    // c:4776 — `if (SHTTY == -1 || fstat(SHTTY, &ttystat)) return -1;`
13006    // The previous Rust port hardcoded fd 0 (stdin) which is wrong
13007    // when SHTTY was opened on a non-stdin file descriptor (e.g.
13008    // `zsh < script` where stdin is a file but the controlling tty
13009    // was opened separately). C tracks the actual SHTTY fd.
13010    let shtty = SHTTY.load(Ordering::SeqCst);
13011    if shtty == -1 {
13012        // c:4776
13013        return -1;
13014    }
13015    let mut st: libc::stat = unsafe { std::mem::zeroed() };
13016    if unsafe { libc::fstat(shtty, &mut st) } != 0 {
13017        // c:4776
13018        return -1;
13019    }
13020    let now = SystemTime::now()
13021        .duration_since(UNIX_EPOCH)
13022        .unwrap_or_default()
13023        .as_secs() as i64;
13024    now - st.st_atime as i64 // c:4779
13025}
13026
13027// -----------------------------------------------------------
13028// $IFS / $HOME / $TERM / $WORDCHARS / $TERMINFO / $TERMINFO_DIRS
13029// $KEYBOARD_HACK / $HISTCHARS / $_  — string-state callbacks.
13030// -----------------------------------------------------------
13031
13032/// Port of `ifsgetfn()` from `Src/params.c:4784`. C body: `return ifs;`
13033pub fn ifsgetfn(_pm: &param) -> String {
13034    // c:4787 `return ifs;` — NULL (unset) reads as the empty string.
13035    ifs_lock().lock().expect("ifs poisoned").clone().unwrap_or_default()
13036}
13037
13038/// Port of `ifssetfn()` from `Src/params.c:4793`. C body:
13039/// `zsfree(ifs); ifs = x; inittyptab();`
13040pub fn ifssetfn(_pm: &mut param, x: String) {
13041    *ifs_lock().lock().expect("ifs poisoned") = Some(x);
13042    // c:4795 — `inittyptab()` rebuilds the typtab[] ISEP/IWSEP bits
13043    // from the new IFS. Without this, every word-split path stays
13044    // pinned to the old separator set and silently mis-splits.
13045    inittyptab();
13046}
13047
13048// -----------------------------------------------------------
13049// Locale callbacks: $LANG, $LC_*, setlang
13050// -----------------------------------------------------------
13051
13052/// Port of `clear_mbstate()` from `Src/params.c:4831`. C body:
13053/// `mb_charinit(); clear_shiftstate();`
13054///
13055/// WARNING: zshrs uses Rust's UTF-8 native handling so multibyte
13056/// state machines aren't kept; this is a no-op pinned to the
13057/// C name for parity.
13058/// (under `MULTIBYTE_SUPPORT`):
13059/// ```c
13060/// mb_charinit();        /* utils.c */
13061/// clear_shiftstate();   /* pattern.c */
13062/// ```
13063/// Resets the mbstate_t globals after LC_CTYPE changes (NetBSD-9
13064/// requires this). Rust port forwards to the matching helpers.
13065pub fn clear_mbstate() {
13066    // c:Src/params.c:4732+ — `#ifdef MULTIBYTE_SUPPORT
13067    //   mb_charinit();        /* utils.c */
13068    //   clear_shiftstate();   /* pattern.c */
13069    // #endif`
13070    // Both helpers are ported (utils.rs:526 and pattern.rs:190). The
13071    // pattern.rs version is currently a no-op (shiftstate machine
13072    // not stored) and utils.rs::mb_charinit resets the mbstate_t
13073    // tracking. Wire them through so a future locale-change hook
13074    // routes through this one entry point per c:Src/params.c
13075    // setlang(c:4842) which calls clear_mbstate() between setlocale
13076    // and the per-LC_* re-apply loop.
13077    crate::ported::utils::mb_charinit(); // c:utils.c mb_charinit
13078    crate::ported::pattern::clear_shiftstate(); // c:pattern.c:327 clear_shiftstate
13079}
13080
13081/// Port of `static struct localename lc_names[]` from `Src/params.c:4805-4825`.
13082/// C body:
13083/// ```c
13084/// static struct localename {
13085///     char *name;
13086///     int category;
13087/// } lc_names[] = {
13088///     {"LC_COLLATE", LC_COLLATE},
13089///     {"LC_CTYPE", LC_CTYPE},
13090///     {"LC_MESSAGES", LC_MESSAGES},
13091///     {"LC_NUMERIC", LC_NUMERIC},
13092///     {"LC_TIME", LC_TIME},
13093///     {NULL, 0}
13094/// };
13095/// ```
13096///
13097/// The C source guards each entry under `#ifdef LC_*`; libc on
13098/// macOS/Linux defines all five so the Rust port simply lists them.
13099const LC_NAMES: &[(&str, libc::c_int)] = &[
13100    ("LC_COLLATE", libc::LC_COLLATE),   // c:4810
13101    ("LC_CTYPE", libc::LC_CTYPE),       // c:4813
13102    ("LC_MESSAGES", libc::LC_MESSAGES), // c:4816
13103    ("LC_NUMERIC", libc::LC_NUMERIC),   // c:4819
13104    ("LC_TIME", libc::LC_TIME),         // c:4822
13105];
13106
13107/// Port of `setlang()` from `Src/params.c:4840`.
13108///
13109/// C body (c:4842-4869):
13110/// ```c
13111/// if ((x2 = getsparam_u("LC_ALL")) && *x2) return;
13112/// setlocale(LC_ALL, x ? unmeta(x) : "");
13113/// clear_mbstate();
13114/// queue_signals();
13115/// for (ln = lc_names; ln->name; ln++)
13116///     if ((x = getsparam_u(ln->name)) && *x)
13117///         setlocale(ln->category, x);
13118/// unqueue_signals();
13119/// inittyptab();
13120/// ```
13121///
13122/// The previous Rust port skipped the actual `setlocale(LC_ALL, ...)`
13123/// libc call and just set the LANG env var. C invokes libc
13124/// setlocale to actually change the program's locale state —
13125/// required so any libc calls during shell execution (e.g.,
13126/// `iswctype`, `mbrtowc`) use the new locale's classification.
13127///
13128/// Also skipped: the per-LC_* override loop (c:4866-4868) which
13129/// re-applies category-specific settings after the global
13130/// LC_ALL set. The Rust port doesn't yet have the lc_names
13131/// table, but we can at least respect the canonical sequence.
13132pub fn setlang(x: Option<&str>) {
13133    // c:4842
13134    // c:4847 — `if ((x2 = getsparam_u("LC_ALL")) && *x2) return;`
13135    if let Some(lc_all) = getsparam_u("LC_ALL") {
13136        // c:4847
13137        if !lc_all.is_empty() {
13138            return;
13139        }
13140    }
13141    // c:4860 — `setlocale(LC_ALL, x ? unmeta(x) : "");`
13142    let locale_arg = match x {
13143        Some(s) => unmeta(s),
13144        None => String::new(),
13145    };
13146    // The previous Rust port skipped the libc setlocale call.
13147    // Without it, libc's locale state (used by iswctype, mbrtowc,
13148    // etc.) stays pinned to whatever the shell inherited from
13149    // its parent — diverging from C which actively changes the
13150    // running program's locale.
13151    let cstr = std::ffi::CString::new(locale_arg.as_bytes()).unwrap_or_default();
13152    unsafe {
13153        libc::setlocale(libc::LC_ALL, cstr.as_ptr()); // c:4860
13154    }
13155    // Mirror to env so subsequent `getsparam("LANG")` reads agree.
13156    if let Some(s) = x {
13157        // c:Src/params.c:5354 — setenv via the C-named zputenv
13158        let _ = zputenv(&format!("{}={}", "LANG", s));
13159    }
13160    clear_mbstate(); // c:4861
13161                     // c:4863-4867 — `for (ln = lc_names; ln->name; ln++) if ((x =
13162                     // getsparam_u(ln->name)) && *x) setlocale(ln->category, x);`
13163                     // After the global LC_ALL setlocale, any explicitly-set LC_*
13164                     // category overrides its slot. The previous Rust port skipped
13165                     // this loop, so `LC_NUMERIC=tr_TR.UTF-8 LANG=C` would leave
13166                     // numeric formatting on C rather than tr_TR.
13167    for (name, category) in LC_NAMES {
13168        // c:4863
13169        if let Some(val) = getsparam_u(name) {
13170            // c:4866 getsparam_u
13171            if !val.is_empty() {
13172                let cat_cstr = std::ffi::CString::new(val.as_bytes()).unwrap_or_default();
13173                unsafe {
13174                    libc::setlocale(*category, cat_cstr.as_ptr()); // c:4867
13175                }
13176            }
13177        }
13178    }
13179    // c:4868 — `inittyptab();`. The locale change may shift which
13180    // bytes are isalpha/isalnum/etc under the typtab init, so the
13181    // table must be rebuilt.
13182    inittyptab();
13183}
13184
13185/// Port of `lc_allsetfn()` from `Src/params.c:4871`.
13186///
13187/// C body (c:4873-4894):
13188/// ```c
13189/// strsetfn(pm, x);
13190/// if (!x || !*x) {
13191///     x = getsparam_u("LANG");
13192///     if (x && *x) {
13193///         queue_signals();
13194///         setlang(x);
13195///         unqueue_signals();
13196///     }
13197/// } else {
13198///     setlocale(LC_ALL, unmeta(x));
13199///     clear_mbstate();
13200///     inittyptab();
13201/// }
13202/// ```
13203///
13204/// The previous Rust port for the non-empty case set the env
13205/// var via `env::set_var("LC_ALL", &s)` but skipped THREE
13206/// pieces:
13207///   1. `setlocale(LC_ALL, unmeta(x))` — actively changes the
13208///      program's locale per c:4890.
13209///   2. `unmeta(x)` — strips Meta-encoded bytes before passing
13210///      to libc setlocale per c:4890.
13211///   3. `inittyptab()` — rebuilds the typtab for the new
13212///      LC_CTYPE per c:4892.
13213///
13214pub fn lc_allsetfn(pm: &mut param, x: String) {
13215    // c:4823
13216    // c:4825 — `strsetfn(pm, x);`. `lc_all_gsu`'s getfn is the plain
13217    // `strgetfn` (c:261-262), so the param slot is the only storage this
13218    // value has; skipping the store would make `$LC_ALL` read back empty.
13219    strsetfn(pm, x.clone()); // c:4825
13220                             // c:4831 — `if (!x || !*x)`: C's `char *x` is NULL for an unset and ""
13221                             // for an empty assignment, and both take the LANG fallback. A GSU setfn
13222                             // always receives a String, so the two collapse into `is_empty()`.
13223    match x {
13224        s if s.is_empty() => {
13225            // c:4831
13226            // c:4831-4838 — empty x falls back to setlang(getsparam_u("LANG")).
13227            setlang(getsparam_u("LANG").as_deref()); // c:4832
13228        }
13229        s => {
13230            // c:4889 — `setlocale(LC_ALL, unmeta(x));`
13231            let unmeta = unmeta(&s); // c:4889 unmeta(x)
13232            let cstr = std::ffi::CString::new(unmeta.as_bytes()).unwrap_or_default();
13233            unsafe {
13234                libc::setlocale(libc::LC_ALL, cstr.as_ptr()); // c:4890
13235            }
13236            // c:Src/params.c:5354 — setenv via the C-named zputenv
13237            let _ = zputenv(&format!("{}={}", "LC_ALL", &s));
13238            clear_mbstate(); // c:4891
13239                             // c:4892 — `inittyptab();` rebuild typtab for new LC_CTYPE.
13240            inittyptab(); // c:4892
13241        }
13242    }
13243}
13244
13245/// Port of `langsetfn()` from `Src/params.c:4896`. C body:
13246/// `strsetfn(pm, x); setlang(unmeta(x));`
13247///
13248/// `unmeta(x)` strips Meta-encoding before passing to libc
13249/// `setlocale` — locale names are normally ASCII but Meta bytes
13250/// in the assigned value (from a `LANG="$value"` round-trip
13251/// through metafied param storage) would otherwise reach
13252/// setlocale literally. The previous Rust port passed raw `x`
13253/// without unmeta'ing — divergent.
13254///
13255/// `strsetfn(pm, x)` stores the value in the param slot — `lang_gsu`
13256/// pairs this setter with the plain `strgetfn` (c:259-260), so the slot
13257/// is the only storage `$LANG` has.
13258pub fn langsetfn(pm: &mut param, x: String) {
13259    // c:4848
13260    strsetfn(pm, x.clone()); // c:4850
13261                             // c:4851 — `setlang(unmeta(x));`. Strip Meta bytes before
13262                             // passing to libc setlocale.
13263    let unmeta_x = unmeta(&x); // c:4851 unmeta(x)
13264    setlang(Some(&unmeta_x));
13265}
13266
13267/// Port of `lcsetfn()` from `Src/params.c:4904`. C body
13268/// (c:4912-4931):
13269/// ```c
13270/// strsetfn(pm, x);
13271/// if ((x2 = getsparam("LC_ALL")) && *x2) return;
13272/// queue_signals();
13273/// if (!x || !*x) x = getsparam("LANG");
13274/// if (x && *x) {
13275///     for (ln = lc_names; ln->name; ln++)
13276///         if (!strcmp(ln->name, pm->node.nam))
13277///             setlocale(ln->category, unmeta(x));
13278/// }
13279/// unqueue_signals();
13280/// clear_mbstate();
13281/// inittyptab();
13282/// ```
13283///
13284/// Two divergences in the previous Rust port:
13285///   1. Missed `inittyptab()` call at c:4932 — LC_CTYPE changes
13286///      shift which bytes are isalpha/iblank/isep, but the
13287///      typtab stayed pinned to the prior locale's classes.
13288///      `setopt POSIX_BUILTINS; LC_NUMERIC=tr_TR.UTF-8; ...`
13289///      would still classify with the old C locale's tables.
13290///   2. The Meta-unmeta'ing on the value passed to setlocale
13291///      wasn't applied. C uses `setlocale(cat, unmeta(x))`.
13292pub fn lcsetfn(pm: &mut param, x: String) {
13293    // c:4856
13294    // c:4861 — `strsetfn(pm, x);` runs BEFORE the `$LC_ALL` early return, so
13295    // the value is stored even when the locale is not applied.
13296    strsetfn(pm, x.clone()); // c:4861
13297                             // c:4876 — `!strcmp(ln->name, pm->node.nam)` matches the CATEGORY by the
13298                             // parameter's own name; read it here, after the store's borrow ends.
13299    let pm_name = pm.node.nam.clone(); // c:4876 pm->node.nam
13300                                       // c:4862-4863 — `if ((x2 = getsparam("LC_ALL")) && *x2) return;`.
13301    if let Some(lc_all) = getsparam("LC_ALL") {
13302        // c:4862
13303        if !lc_all.is_empty() {
13304            return;
13305        }
13306    }
13307    // c:4866-4867 — `if (!x || !*x) x = getsparam("LANG");`.
13308    let val = Some(x)
13309        .filter(|s| !s.is_empty())
13310        .or_else(|| getsparam("LANG").filter(|s| !s.is_empty())); // c:4867
13311                                                                  // c:4924-4928 — apply `setlocale(category, unmeta(x))` for the
13312                                                                  // matching LC_* category. The previous Rust port skipped the
13313                                                                  // actual libc setlocale call and only wrote the env var, so
13314                                                                  // assigning `LC_NUMERIC=tr_TR.UTF-8` never flipped libc's
13315                                                                  // numeric-formatting category.
13316    if let Some(v) = val {
13317        let unmeta = unmeta(&v); // c:4928 unmeta(x)
13318                                 // c:Src/params.c:5354 — setenv via the C-named zputenv
13319        let _ = zputenv(&format!("{}={}", pm_name, &unmeta));
13320        for (name, category) in LC_NAMES {
13321            // c:4875
13322            if *name == pm_name {
13323                // c:4876 strcmp
13324                let cstr = std::ffi::CString::new(unmeta.as_bytes()).unwrap_or_default();
13325                unsafe {
13326                    libc::setlocale(*category, cstr.as_ptr()); // c:4927
13327                }
13328                break;
13329            }
13330        }
13331    }
13332    // c:4930 — `clear_mbstate();` — LC_CTYPE may have changed.
13333    clear_mbstate();
13334    // c:4931 — `inittyptab();` — rebuild typtab classifications.
13335    // The previous Rust port skipped this; char-classification
13336    // predicates would stay pinned to the prior locale's class
13337    // set even after `LC_CTYPE=` was assigned.
13338    inittyptab(); // c:4931
13339}
13340
13341/// Port of `argzerosetfn()` from `Src/params.c:4937`. Setter for `$0` —
13342/// POSIX mode rejects assignment (read-only), zsh mode replaces
13343/// `argzero`.
13344///
13345/// C body:
13346///   if (x) {
13347///     if (isset(POSIXARGZERO))
13348///       zerr("read-only variable: 0");
13349///     else {
13350///       zsfree(argzero);
13351///       argzero = ztrdup(x);
13352///     }
13353///     zsfree(x);
13354///   }
13355/// Port of `argzerosetfn(UNUSED(Param pm), char *x)` from `Src/params.c:4937`.
13356/// `pm` is UNUSED in C (the `argzero` global is updated regardless of
13357/// the Param the assignment hit), but the parameter is preserved here
13358/// to match the C signature so this fn can wire into the GsuScalar.setfn
13359/// slot directly (see ARGZERO_GSU at line ~8307).
13360pub fn argzerosetfn(_pm: &mut param, x: String) {
13361    // c:4937
13362    // c:4937 — if (x).
13363    if !x.is_empty() {
13364        // c:4940 — isset(POSIXARGZERO) reject.
13365        if isset(POSIXARGZERO) {
13366            zerr("read-only variable: 0"); // c:4941
13367        } else {
13368            // c:4943-4944 — zsfree(argzero); argzero = ztrdup(x).
13369            set_argzero(Some(ztrdup(&x)));
13370        }
13371        // c:4946 — `zsfree(x)`. Rust drop handles via move.
13372    }
13373}
13374
13375// -----------------------------------------------------------
13376// $0 / $#
13377// -----------------------------------------------------------
13378
13379/// Port of `argzerogetfn()` from `Src/params.c:4954`. C body:
13380///     `return isset(POSIXARGZERO) ? posixzero : argzero;`
13381///
13382/// Both `argzero` and `posixzero` live in `utils.rs` (OnceLock storage).
13383/// After `exec -a foo` or function-call argv-rewrite, `$0` under
13384/// POSIXARGZERO reports the ORIGINAL startup `argv[0]`, not the
13385/// rewritten name. `pm` is UNUSED in C; signature preserved for the
13386/// GsuScalar.getfn slot at ARGZERO_GSU (line ~8307).
13387pub fn argzerogetfn(_pm: &param) -> String {
13388    if isset(POSIXARGZERO) {
13389        // c:4958
13390        posixzero().unwrap_or_default() // c:4959
13391    } else {
13392        argzero().unwrap_or_default() // c:4960
13393    }
13394}
13395
13396// -----------------------------------------------------------
13397// $HISTSIZE / $SAVEHIST
13398// -----------------------------------------------------------
13399
13400/// Port of `histsizegetfn()` from `Src/params.c:4965`. C body: `return histsiz;`
13401/// WARNING: param names don't match C — Rust=() vs C=(pm)
13402pub fn histsizegetfn() -> i64 {
13403    *histsiz_lock().lock().expect("histsiz poisoned")
13404}
13405
13406/// Port of `histsizesetfn()` from `Src/params.c:4974`. C body:
13407/// `if ((histsiz = v) < 1) histsiz = 1; resizehistents();`
13408///
13409/// The previous Rust port noted `resizehistents()` as "pending the
13410/// history-table port", but `resizehistents`
13411/// IS available — was a stale comment. Without the resize call,
13412/// setting HISTSIZE to a smaller value left the in-memory ring
13413/// over-sized until the next implicit prune (next entry added).
13414/// Wired the call now per c:4977.
13415/// WARNING: param names don't match C — Rust=(v) vs C=(pm, v)
13416pub fn histsizesetfn(v: i64) {
13417    *histsiz_lock().lock().expect("histsiz poisoned") = v.max(1);
13418    // c:4977 — mirror into the hist.rs atomic so resizehistents()
13419    // sees the new size, then trigger the prune.
13420    histsiz.store(v.max(1), Ordering::SeqCst);
13421    resizehistents(); // c:4977
13422}
13423
13424/// Port of `savehistsizegetfn()` from `Src/params.c:4985`. C body:
13425/// `return savehistsiz;`
13426/// WARNING: param names don't match C — Rust=() vs C=(pm)
13427pub fn savehistsizegetfn() -> i64 {
13428    *savehistsiz_lock().lock().expect("savehistsiz poisoned")
13429}
13430
13431/// Port of `savehistsizesetfn()` from `Src/params.c:4994`. C body:
13432/// `if ((savehistsiz = v) < 0) savehistsiz = 0;`
13433///
13434/// The Rust port has TWO mirrors of `savehistsiz`: a `Mutex<i64>`
13435/// in params.rs (read by `savehistsizegetfn`) AND an AtomicI64
13436/// in hist.rs (read by the history-file writer at
13437/// `Src/hist.c:savehistfile` per c:3878). The previous Rust port
13438/// only wrote to the params.rs lock; `hist.rs::savehistsiz`
13439/// stayed pinned to its initial 0 value, so `SAVEHIST=10000`
13440/// would store the limit in `savehistsiz_lock` (visible to
13441/// `$SAVEHIST` reads) but the history-file writer would still
13442/// cap at the original AtomicI64 value (effectively saving zero
13443/// lines). Sync both storages so reads + writes agree.
13444///
13445/// WARNING: param names don't match C — Rust=(v) vs C=(pm, v)
13446pub fn savehistsizesetfn(v: i64) {
13447    // c:4994
13448    let clamped = v.max(0); // c:4998
13449    *savehistsiz_lock().lock().expect("savehistsiz poisoned") = clamped;
13450    // Mirror to hist.rs::savehistsiz so the writer-side cap
13451    // matches the just-assigned value. C uses a single global;
13452    // the Rust port's twin-storage requires sync writes.
13453    savehistsiz.store(clamped, Ordering::SeqCst);
13454    // c:4994
13455}
13456
13457/// Port of `errnosetfn()` from `Src/params.c:5004`. C body:
13458/// `errno = (int)x; if ((zlong)errno != x) zwarn("errno truncated on assignment");`
13459///
13460/// Rust note: `errno` is a libc thread-local; Rust uses `std::io::Error`
13461/// which captures the *last* call. To set errno for subsequent
13462/// `last_os_error()` reads on macOS / Linux, write through the libc
13463/// `__error()`/`__errno_location()` accessor.
13464/// C body (Src/params.c:5004):
13465///     `errno = (int)x;
13466///      if ((zlong)errno != x) zwarn("errno truncated on assignment");`
13467/// WARNING: param names don't match C — Rust=(x) vs C=(pm, x)
13468pub fn errnosetfn(x: i64) {
13469    // c:5004
13470    let truncated = x as i32;
13471    unsafe {
13472        *errno_ptr() = truncated;
13473    } // c:5006 errno = (int)x
13474      // c:5009-5010 — C uses `zwarn` (informational), NOT `zerr`. The
13475      // store happens unconditionally; the warning fires only on
13476      // truncation. Previously used `zerr` — divergent.
13477    if truncated as i64 != x {
13478        // c:5008
13479        zwarn("errno truncated on assignment"); // c:5009
13480    }
13481}
13482
13483/// !!! WARNING: RUST-ONLY HELPER !!!
13484/// No C counterpart: C reads `errno` through the `<errno.h>` macro, which the
13485/// compiler expands to the per-platform getter. Rust's libc exposes both spellings
13486/// (`__error()` on macOS, `__errno_location()` on Linux) as raw FFI, so this shim
13487/// picks the right one per target and keeps `errnogetfn`/`errnosetfn` one-liners.
13488#[inline]
13489unsafe fn errno_ptr() -> *mut libc::c_int {
13490    #[cfg(target_os = "macos")]
13491    {
13492        libc::__error()
13493    }
13494    #[cfg(target_os = "linux")]
13495    {
13496        libc::__errno_location()
13497    }
13498    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
13499    {
13500        std::ptr::null_mut()
13501    }
13502}
13503
13504/// Port of `errnogetfn()` from `Src/params.c:5015`. C body: `return errno;`
13505///
13506/// Reads the libc errno directly through the per-platform accessor
13507/// (matching C's `return errno;` semantics).
13508/// WARNING: param names don't match C — Rust=() vs C=(pm)
13509pub fn errnogetfn() -> i64 {
13510    let p = unsafe { errno_ptr() }; // c:5017 return errno
13511    if p.is_null() {
13512        // Non-Linux/macOS fallback: best-effort via std API.
13513        std::io::Error::last_os_error().raw_os_error().unwrap_or(0) as i64
13514    } else {
13515        unsafe { *p as i64 }
13516    }
13517}
13518
13519/// Port of `keyboardhackgetfn()` from `Src/params.c:5024`. C body:
13520/// `static char buf[2]; buf[0] = keyboardhackchar; return buf;`
13521pub fn keyboardhackgetfn(_pm: &param) -> String {
13522    let c = *keyboardhack_lock().lock().expect("keyboardhack poisoned");
13523    if c == 0 {
13524        String::new()
13525    } else {
13526        (c as char).to_string()
13527    }
13528}
13529
13530/// Port of `keyboardhacksetfn()` from `Src/params.c:5038`. C body:
13531/// ```c
13532/// if (x) {
13533///     unmetafy(x, &len);
13534///     if (len > 1) { len = 1; zwarn("Only one KEYBOARD_HACK character can be defined"); }
13535///     for (i = 0; i < len; i++)
13536///         if (!isascii((unsigned char) x[i])) {
13537///             zwarn("KEYBOARD_HACK can only contain ASCII characters");
13538///             return;
13539///         }
13540///     keyboardhackchar = len ? (unsigned char) x[0] : '\0';
13541/// } else
13542///     keyboardhackchar = '\0';
13543/// ```
13544///
13545/// The C source `unmetafy(x, &len)` strips Meta-encoded prefix
13546/// bytes (collapsing every `Meta + (b^32)` pair to the original
13547/// byte) BEFORE the length and ASCII checks. The previous Rust
13548/// port skipped unmetafy, so:
13549///   - `len > 1` warning fired on every assignment of a Meta-
13550///     encoded single byte (the byte-length was 2 pre-unmetafy).
13551///   - ASCII check ran against the raw Meta byte (0x83) instead
13552///     of the demetafied result, falsely rejecting valid ASCII
13553///     characters that happened to round-trip through Meta
13554///     encoding in the assignment pipeline.
13555///
13556pub fn keyboardhacksetfn(_pm: &mut param, x: String) {
13557    // c:5040
13558    // c:5044 — `unmetafy(x, &len)` — strip Meta-encoded pairs.
13559    // Run on the byte buffer so the protocol matches C's pointer
13560    // walk; the Rust `unmeta()` helper does the same fold.
13561    let unmeta = unmeta(&x); // c:5044 unmetafy(x)
13562    let bytes = unmeta.as_bytes();
13563    // c:5046-5049 — `if (len > 1) { len = 1; zwarn(...); }`. The
13564    // length check happens AFTER unmetafy so a 2-byte Meta pair
13565    // representing a single byte doesn't trigger the warning.
13566    if bytes.len() > 1 {
13567        zwarn("Only one KEYBOARD_HACK character can be defined");
13568    }
13569    let c = bytes.first().copied().unwrap_or(0);
13570    // c:5050-5054 — ASCII check runs on the unmetafied byte, NOT
13571    // the raw Meta byte. With unmetafy now in place this works as
13572    // C intended.
13573    if c >= 0x80 {
13574        // c:5051 !isascii(...)
13575        zwarn("KEYBOARD_HACK can only contain ASCII characters");
13576        return;
13577    }
13578    // c:5056 — `keyboardhackchar = len ? (unsigned char) x[0] : '\0';`
13579    *keyboardhack_lock().lock().expect("keyboardhack poisoned") = c;
13580}
13581
13582/// Port of `histcharsgetfn()` from `Src/params.c:5064`. C body:
13583/// ```c
13584/// static char buf[4];
13585/// buf[0] = bangchar; buf[1] = hatchar; buf[2] = hashchar; buf[3] = '\0';
13586/// return buf;
13587/// ```
13588/// Reads from the three canonical atomic globals
13589/// (`crate::ported::hist::{bangchar, hatchar, hashchar}`) to mirror C
13590/// which reads from three separate `unsigned char` globals.
13591pub fn histcharsgetfn(_pm: &param) -> String {
13592    let b = bangchar.load(Ordering::SeqCst) as u8;
13593    let h = hatchar.load(Ordering::SeqCst) as u8;
13594    let p = hashchar.load(Ordering::SeqCst) as u8;
13595    // c:5068-5073 — terminal NUL trims unset chars (default-`!^#` is
13596    // 3 non-NUL bytes); explicit NULs are skipped to match C `buf[3]
13597    // = '\0'` C-string truncation semantics.
13598    let mut s = String::new();
13599    for &byte in &[b, h, p] {
13600        if byte != 0 {
13601            s.push(byte as char);
13602        }
13603    }
13604    s
13605}
13606
13607/// Port of `histcharssetfn()` from `Src/params.c:5079`. C body
13608/// validates ASCII, takes up to 3 chars; defaults `!^#` if NULL.
13609///
13610/// C `unmetafy(x, &len)` (c:5086) strips Meta-encoded pairs BEFORE
13611/// the length truncation and ASCII guard. The previous Rust port
13612/// skipped unmetafy entirely:
13613///   - `len > 3` truncation ran on raw byte length, so a Meta-pair
13614///     would inflate the byte count and skip valid chars.
13615///   - ASCII check ran against raw Meta bytes (0x83), falsely
13616///     rejecting valid round-tripped values.
13617///
13618pub fn histcharssetfn(_pm: &mut param, x: String) {
13619    // c:5081
13620    // C signature is `histcharssetfn(Param pm, char *x)`. The `x == NULL`
13621    // arm (c:5100-5103, `bangchar = '!'; hashchar = '#'; hatchar = '^'`)
13622    // has no caller: the gsu is `{ histcharsgetfn, histcharssetfn,
13623    // stdunsetfn }` (c:238-239), so `unset histchars` never reaches it, and
13624    // every write, including scanendscope's restore, hands over a string.
13625    // An EMPTY string is not NULL: `len` is 0, so all three characters
13626    // become '\0' (c:5095-5097) and `$histchars` reads empty, which is what
13627    // `histchars=` and `local +h histchars` (c:Src/builtin.c:2617
13628    // `setfn(pm, ztrdup(""))`) produce in zsh. Treating "" as NULL restored
13629    // `!^#` instead.
13630    let new_chars: [u8; 3] = {
13631        let s = x;
13632        {
13633            // c:5086 — `unmetafy(x, &len)`. Strip Meta pairs first.
13634            let unmeta = unmeta(&s); // c:5086 unmetafy(x)
13635            let bytes = unmeta.as_bytes();
13636            // c:5087-5088 — `if (len > 3) len = 3;`. Truncation
13637            // applies AFTER unmetafy.
13638            let bytes = if bytes.len() > 3 { &bytes[..3] } else { bytes };
13639            for &b in bytes.iter() {
13640                if b >= 0x80 {
13641                    // c:5090-5093
13642                    // c:5091 — C uses `zwarn` (informational), NOT
13643                    // `zerr` (fatal). Function returns early without
13644                    // updating any globals.
13645                    zwarn("HISTCHARS can only contain ASCII characters");
13646                    return;
13647                }
13648            }
13649            // c:5095-5097 — `bangchar = x[0]; hatchar = x[1]; hashchar = x[2]`.
13650            // C uses `len ? x[0] : '\0'` etc — for short strings the
13651            // unset bytes are NUL.
13652            let mut chars = [0u8; 3];
13653            for (i, &b) in bytes.iter().enumerate() {
13654                chars[i] = b;
13655            }
13656            chars
13657        }
13658    };
13659    // c:5079 — set histchars table.
13660    *histchars_lock().lock().expect("histchars poisoned") = new_chars;
13661    // c:5095-5097 — `bangchar = x[0]; hatchar = x[1]; hashchar = x[2]`.
13662    // Sync all three per-char atomic globals so lex/hist callers
13663    // see the new HISTCHARS. (Previously hashchar was a `const char`
13664    // in lex.rs — promoted to atomic this iteration.)
13665    bangchar.store(new_chars[0] as i32, Ordering::SeqCst);
13666    hatchar.store(new_chars[1] as i32, Ordering::SeqCst);
13667    hashchar.store(new_chars[2] as i32, Ordering::SeqCst);
13668    // c:5104 — `inittyptab();`. The bangchar special bit in typtab
13669    // depends on the current `bangchar` global; reseed.
13670    inittyptab();
13671}
13672
13673// ---------------------------------------------------------------------
13674// Special-scalar GSU vtables — port of `Src/params.c:217-256` `stdscalar_gsu`,
13675// `home_gsu`, `ifs_gsu`, etc. Each entry pairs the canonical
13676// getfn/setfn/unsetfn for a PM_SPECIAL scalar. `createparamtable`
13677// copies the matching gsu into each special param's `gsu_s` slot so
13678// `assignstrvalue` dispatches via `pm->gsu.s->setfn(pm, val)`
13679// (params.c:2748) exactly like C.
13680// ---------------------------------------------------------------------
13681
13682/// Port of `static const struct gsu_scalar argzero_gsu` from `Src/params.c:225-226`.
13683/// `{ argzerogetfn, argzerosetfn, nullunsetfn }`. Both functions' Rust
13684/// signatures match C exactly (with `pm` UNUSED), so they wire into
13685/// the GSU vtable directly — no Rust-only wrappers. The $0 Param picks
13686/// this up at `add_special` (line ~1555) and `assignsparam` routes
13687/// `0=value` through the canonical setter via the gsu_s.setfn dispatch
13688/// at params.rs:3767.
13689pub const ARGZERO_GSU: gsu_scalar = gsu_scalar {
13690    // c:225-226
13691    getfn: argzerogetfn,
13692    setfn: argzerosetfn,
13693    unsetfn: nullunsetfn,
13694};
13695
13696/// Port of `static const struct gsu_scalar home_gsu` from `Src/params.c:248`.
13697pub const HOME_GSU: gsu_scalar = gsu_scalar {
13698    // c:248
13699    getfn: homegetfn,
13700    setfn: homesetfn,
13701    unsetfn: stdunsetfn,
13702};
13703
13704/// Port of `static const struct gsu_scalar ifs_gsu` from `Src/params.c:245`.
13705pub const IFS_GSU: gsu_scalar = gsu_scalar {
13706    // c:245
13707    getfn: ifsgetfn,
13708    setfn: ifssetfn,
13709    unsetfn: stdunsetfn,
13710};
13711
13712/// Port of `static const struct gsu_scalar term_gsu` from `Src/params.c:250`.
13713pub const TERM_GSU: gsu_scalar = gsu_scalar {
13714    // c:250
13715    getfn: termgetfn,
13716    setfn: termsetfn,
13717    unsetfn: stdunsetfn,
13718};
13719
13720/// Port of `static const struct gsu_scalar terminfo_gsu` from `Src/params.c:251`.
13721pub const TERMINFO_GSU: gsu_scalar = gsu_scalar {
13722    // c:251
13723    getfn: terminfogetfn,
13724    setfn: terminfosetfn,
13725    unsetfn: stdunsetfn,
13726};
13727
13728/// Port of `static const struct gsu_scalar terminfodirs_gsu`
13729/// from `Src/params.c:252`.
13730pub const TERMINFODIRS_GSU: gsu_scalar = gsu_scalar {
13731    // c:252
13732    getfn: terminfodirsgetfn,
13733    setfn: terminfodirssetfn,
13734    unsetfn: stdunsetfn,
13735};
13736
13737/// Port of `static const struct gsu_scalar wordchars_gsu`
13738/// from `Src/params.c:249`.
13739pub const WORDCHARS_GSU: gsu_scalar = gsu_scalar {
13740    // c:249
13741    getfn: wordcharsgetfn,
13742    setfn: wordcharssetfn,
13743    unsetfn: stdunsetfn,
13744};
13745
13746/// Port of `static const struct gsu_scalar username_gsu`
13747/// from `Src/params.c:247`.
13748pub const USERNAME_GSU: gsu_scalar = gsu_scalar {
13749    // c:247
13750    getfn: usernamegetfn,
13751    setfn: usernamesetfn,
13752    unsetfn: stdunsetfn,
13753};
13754
13755/// Port of `static const struct gsu_scalar keyboardhack_gsu`
13756/// from `Src/params.c:253`.
13757pub const KEYBOARDHACK_GSU: gsu_scalar = gsu_scalar {
13758    // c:253
13759    getfn: keyboardhackgetfn,
13760    setfn: keyboardhacksetfn,
13761    unsetfn: stdunsetfn,
13762};
13763
13764/// Port of `static const struct gsu_scalar lang_gsu` from `Src/params.c:259-260`.
13765/// `{ strgetfn, langsetfn, stdunsetfn }` — registered by
13766/// `IPDEF2("LANG", lang_gsu, PM_UNSET)` at c:332.
13767pub const LANG_GSU: gsu_scalar = gsu_scalar {
13768    // c:259-260
13769    getfn: strgetfn,
13770    setfn: langsetfn,
13771    unsetfn: stdunsetfn,
13772};
13773
13774/// Port of `static const struct gsu_scalar lc_all_gsu` from `Src/params.c:261-262`.
13775/// `{ strgetfn, lc_allsetfn, stdunsetfn }` — registered by
13776/// `IPDEF2("LC_ALL", lc_all_gsu, PM_UNSET)` at c:333.
13777pub const LC_ALL_GSU: gsu_scalar = gsu_scalar {
13778    // c:261-262
13779    getfn: strgetfn,
13780    setfn: lc_allsetfn,
13781    unsetfn: stdunsetfn,
13782};
13783
13784/// Port of `static const struct gsu_scalar lc_blah_gsu` from `Src/params.c:257-258`.
13785/// `{ strgetfn, lcsetfn, stdunsetfn }` — the vtable every per-category
13786/// `LCIPDEF(name)` entry gets (c:331, c:334-347): LC_COLLATE, LC_CTYPE,
13787/// LC_MESSAGES, LC_NUMERIC, LC_TIME.
13788pub const LC_BLAH_GSU: gsu_scalar = gsu_scalar {
13789    // c:257-258
13790    getfn: strgetfn,
13791    setfn: lcsetfn,
13792    unsetfn: stdunsetfn,
13793};
13794
13795/// Port of `static const struct gsu_scalar histchars_gsu`
13796/// from `Src/params.c:246`.
13797pub const HISTCHARS_GSU: gsu_scalar = gsu_scalar {
13798    // c:246
13799    getfn: histcharsgetfn,
13800    setfn: histcharssetfn,
13801    unsetfn: stdunsetfn,
13802};
13803
13804/// Port of `homegetfn()` from `Src/params.c:5109`. C body: `return home;`
13805pub fn homegetfn(_pm: &param) -> String {
13806    home_lock().lock().expect("home poisoned").clone()
13807}
13808
13809/// Port of `homesetfn()` from `Src/params.c:5118`. C body:
13810/// ```c
13811/// zsfree(home);
13812/// if (x && isset(CHASELINKS) && (home = xsymlink(x, 0)))
13813///     zsfree(x);
13814/// else
13815///     home = x ? x : ztrdup("");
13816/// finddir(NULL);
13817/// ```
13818pub fn homesetfn(_pm: &mut param, x: String) {
13819    // c:5121-5126 — CHASELINKS path resolves symlinks before storing.
13820    // Falls through to the plain `x` store when CHASELINKS is off or
13821    // xsymlink fails.
13822    let resolved = if !x.is_empty() && isset(CHASELINKS) {
13823        xsymlink(&x).unwrap_or(x)
13824    } else {
13825        x
13826    };
13827    *home_lock().lock().expect("home poisoned") = resolved;
13828    // c:5127 — `finddir(NULL)` invalidates zsh's cached named-directory
13829    // lookups. zshrs's finddir port has no cache (per hashnameddir.rs
13830    // createnameddirtable note); the call is a no-op here.
13831}
13832
13833/// Port of `wordcharsgetfn()` from `Src/params.c:5132`. C body:
13834/// `return wordchars;`
13835pub fn wordcharsgetfn(_pm: &param) -> String {
13836    wordchars_lock().lock().expect("wordchars poisoned").clone()
13837}
13838
13839/// Port of `wordcharssetfn()` from `Src/params.c:5141`. C body:
13840/// `zsfree(wordchars); wordchars = x; inittyptab();`
13841pub fn wordcharssetfn(_pm: &mut param, x: String) {
13842    *wordchars_lock().lock().expect("wordchars poisoned") = x;
13843    // c:5143 — `inittyptab()` rebuilds typtab IWORD bits from the
13844    // new WORDCHARS. Without this, every IWORD lookup stays pinned
13845    // to the old set and silently mis-classifies word boundaries.
13846    inittyptab();
13847}
13848
13849/// Port of `underscoregetfn()` from `Src/params.c:5152`. C body:
13850/// `char *u = dupstring(zunderscore); untokenize(u); return u;`
13851///
13852/// C runs `untokenize(u)` on the cloned string before returning, so
13853/// ITOK bytes (Pound..Nularg per `Src/zsh.h:159-194`) in `$_` get
13854/// replaced/dropped via the canonical `ztokens[]` table. The previous
13855/// Rust port skipped untokenize entirely — every `$_` read that
13856/// included a lexer-injected token byte exposed the raw token in user
13857/// output (e.g. `$_` containing `$cmd` would surface as raw Stringg
13858/// instead of the literal `$`).
13859/// WARNING: param names don't match C — Rust=() vs C=(pm)
13860pub fn underscoregetfn() -> String {
13861    let u = zunderscore_lock()
13862        .lock()
13863        .expect("zunderscore poisoned")
13864        .clone();
13865    untokenize(&u) // c:5156 untokenize(u)
13866}
13867
13868/// Port of `term_reinit_from_pm()` from `Src/params.c:5163`.
13869/// C: `static void term_reinit_from_pm(void)` →
13870///   `if (unset(INTERACTIVE) || !*term) termflags |= TERM_UNKNOWN;
13871///    else init_term();`
13872pub fn term_reinit_from_pm() {
13873    // c:5163
13874    // c:5167 — `if (unset(INTERACTIVE) || !*term) termflags |= TERM_UNKNOWN;`
13875    let interactive = isset(INTERACTIVE);
13876    let term = term_lock().lock().map(|s| s.clone()).unwrap_or_default();
13877    if !interactive || term.is_empty() {
13878        // c:5167
13879        TERMFLAGS.fetch_or(TERM_UNKNOWN, Ordering::Relaxed); // c:5168
13880    } else {
13881        crate::ported::init::init_term(); // c:5170
13882    }
13883}
13884
13885/// Port of `termgetfn()` from `Src/params.c:5176`. C body: `return term;`
13886pub fn termgetfn(_pm: &param) -> String {
13887    term_lock().lock().expect("term poisoned").clone()
13888}
13889
13890/// Port of `termsetfn()` from `Src/params.c:5185`. C body:
13891/// `zsfree(term); term = x ? x : ""; term_reinit_from_pm();`
13892pub fn termsetfn(_pm: &mut param, x: String) {
13893    *term_lock().lock().expect("term poisoned") = x;
13894    term_reinit_from_pm();
13895}
13896
13897/// Port of `terminfogetfn()` from `Src/params.c:5196`. C body:
13898/// `return zsh_terminfo ? zsh_terminfo : "";`
13899pub fn terminfogetfn(_pm: &param) -> String {
13900    zsh_terminfo_lock()
13901        .lock()
13902        .expect("zsh_terminfo poisoned")
13903        .clone()
13904}
13905
13906/// Port of `int rprompt_indent` from `Src/init.c`. Set to 1 by
13907/// `init_term()` and reset by `rprompt_indent_unsetfn` when the
13908/// `RPROMPT_INDENT` parameter is unset.
13909pub static RPROMPT_INDENT: Mutex<i32> = Mutex::new(1);
13910
13911/// Port of `terminfosetfn()` from `Src/params.c:5205`.
13912///
13913/// Frees the old `zsh_terminfo`, stores `x`, and — only when the
13914/// parameter is exported — pushes `TERMINFO=x` into the environment
13915/// (terminfo reads the value from the env before the terminal is
13916/// reinitialised), then reinitialises the terminal.
13917pub fn terminfosetfn(pm: &mut param, x: String) {
13918    // c:5205
13919    // c:5207-5208 — `zsfree(zsh_terminfo); zsh_terminfo = x;`
13920    *zsh_terminfo_lock().lock().expect("zsh_terminfo poisoned") = x.clone();
13921    // c:5214-5215 — `if ((pm->node.flags & PM_EXPORTED) && x) addenv(pm, x);`
13922    // Only leak into the environment when $TERMINFO is actually
13923    // exported; a bare `TERMINFO=x` (no export) must not reach children.
13924    if pm.node.flags & PM_EXPORTED as i32 != 0 {
13925        let _ = zputenv(&format!("TERMINFO={}", &x)); // c:5215 addenv(pm, x)
13926    }
13927    term_reinit_from_pm(); // c:5217
13928}
13929
13930/// Port of `terminfodirsgetfn()` from `Src/params.c:5224`. C body:
13931/// `return zsh_terminfodirs ? zsh_terminfodirs : "";`
13932pub fn terminfodirsgetfn(_pm: &param) -> String {
13933    zsh_terminfodirs_lock()
13934        .lock()
13935        .expect("zsh_terminfodirs poisoned")
13936        .clone()
13937}
13938
13939/// Port of `terminfodirssetfn()` from `Src/params.c:5233`. C body
13940/// mirrors `terminfosetfn` for the TERMINFO_DIRS env var.
13941pub fn terminfodirssetfn(pm: &mut param, x: String) {
13942    // c:5233
13943    // c:5235-5236 — `zsfree(zsh_terminfodirs); zsh_terminfodirs = x;`
13944    *zsh_terminfodirs_lock()
13945        .lock()
13946        .expect("zsh_terminfodirs poisoned") = x.clone();
13947    // c:5242-5243 — `if ((pm->node.flags & PM_EXPORTED) && x) addenv(pm, x);`
13948    // Only export when $TERMINFO_DIRS is exported; a bare assignment
13949    // must not reach child processes.
13950    if pm.node.flags & PM_EXPORTED as i32 != 0 {
13951        let _ = zputenv(&format!("TERMINFO_DIRS={}", &x)); // c:5243 addenv(pm, x)
13952    }
13953    term_reinit_from_pm(); // c:5245
13954}
13955
13956// -----------------------------------------------------------
13957// $pipestatus
13958// -----------------------------------------------------------
13959
13960/// Port of `pipestatgetfn()` from `Src/params.c:5251`. C body
13961/// snapshots the `pipestats[]` C array as a heap-allocated
13962/// `char **`. Rust port returns the cloned snapshot.
13963/// WARNING: param names don't match C — Rust=() vs C=(pm)
13964pub fn pipestatgetfn() -> Vec<String> {
13965    pipestats_lock()
13966        .lock()
13967        .expect("pipestats poisoned")
13968        .iter()
13969        .map(|n| n.to_string())
13970        .collect()
13971}
13972
13973/// Port of `pipestatsetfn()` from `Src/params.c:5270`. C body:
13974/// `for (i=0; *x && i<MAX_PIPESTATS; i++) pipestats[i] = atoi(*x++); numpipestats = i;`
13975/// WARNING: param names don't match C — Rust=(x) vs C=(pm, x)
13976pub fn pipestatsetfn(x: Option<Vec<String>>) {
13977    const MAX_PIPESTATS: usize = 256;
13978    let mut guard = pipestats_lock().lock().expect("pipestats poisoned");
13979    guard.clear();
13980    if let Some(v) = x {
13981        for s in v.iter().take(MAX_PIPESTATS) {
13982            guard.push(s.parse::<i32>().unwrap_or(0));
13983        }
13984    }
13985}
13986
13987/// Port of `arrfixenv()` from `Src/params.c:5285`. C body re-syncs
13988/// the env entry for an array param after mutation, joining with
13989/// the param's `joinchar`. Rust port joins with ':' (the default
13990/// for PATH-style arrays) and updates the env var.
13991/// Direct port of `void arrfixenv(char *s, char **t)` from
13992/// `Src/params.c:5285`. Re-syncs the env-side entry for an
13993/// array parameter after mutation. Order of operations (C body):
13994///   1. If `t == path`, flush the command-name cache (c:5291).
13995///   2. Look up the param node by name (c:5294); skip if
13996///      PM_HASHELEM is set (c:5300-5301).
13997///   3. Under ALLEXPORT, mark PM_EXPORTED (c:5304); always clear
13998///      PM_DEFAULTED (c:5305).
13999///   4. Skip if not PM_EXPORTED (c:5311-5312).
14000///   5. joinchar = ':' for PM_SPECIAL else
14001///      `((struct tieddata *)pm->u.data)->joinchar` (c:5314-5318).
14002///   6. `addenv(pm, t ? zjoin(t, joinchar, 1) : "")` (c:5319).
14003pub fn arrfixenv(s: &str, t: Option<&[String]>) {
14004    // c:5285
14005
14006    // c:5291 — `if (t == path) cmdnamtab->emptytable(cmdnamtab)`.
14007    // PATH change invalidates the command-name cache.
14008    if s == "PATH" || s == "path" {
14009        emptycmdnamtable();
14010    }
14011
14012    // c:5294 — `pm = paramtab->getnode(paramtab, s)`.
14013    let pm_arc_data = {
14014        let tab = paramtab().read().unwrap();
14015        tab.get(s).map(|pm| (pm.node.flags, pm.gsu_a.is_some()))
14016    };
14017    let (flags, _has_gsu_a) = match pm_arc_data {
14018        Some(x) => x,
14019        None => {
14020            // No param yet — just sync via env::set_var as fallback.
14021            let val = t.map(|v| v.join(":")).unwrap_or_default();
14022            // c:Src/params.c:5354 — setenv via the C-named zputenv
14023            let _ = zputenv(&format!("{}={}", s, &val));
14024            return;
14025        }
14026    };
14027
14028    // c:5300-5301 — `if (pm->flags & PM_HASHELEM) return`.
14029    if flags & PM_HASHELEM as i32 != 0 {
14030        return;
14031    }
14032
14033    // c:5304 — `if (isset(ALLEXPORT)) pm->flags |= PM_EXPORTED`.
14034    let allexport = isset(ALLEXPORT);
14035    // c:5305 — `pm->flags &= ~PM_DEFAULTED` always.
14036    {
14037        let mut tab = paramtab().write().unwrap();
14038        if let Some(pm) = tab.get_mut(s) {
14039            if allexport {
14040                pm.node.flags |= PM_EXPORTED as i32;
14041            }
14042            pm.node.flags &= !(PM_DEFAULTED as i32);
14043        }
14044    }
14045
14046    // c:5311-5312 — `if (!(pm->flags & PM_EXPORTED)) return`.
14047    let new_flags = {
14048        let tab = paramtab().read().unwrap();
14049        tab.get(s).map(|pm| pm.node.flags).unwrap_or(0)
14050    };
14051    if new_flags & PM_EXPORTED as i32 == 0 {
14052        return;
14053    }
14054
14055    // c:5314-5317 — joinchar selection.
14056    let joinchar: char = if new_flags & PM_SPECIAL as i32 != 0 {
14057        ':' // c:5315
14058    } else {
14059        // c:5317 — `joinchar = (unsigned char)
14060        //     ((struct tieddata *)pm->u.data)->joinchar;`
14061        // The tieddata rides the SCALAR half of the pair (builtin.c:2982-2989),
14062        // which is exactly the `s` this function was handed. Hardcoding ':'
14063        // exported `typeset -T MORESTUFF morestuff -` as `here:we:go` instead
14064        // of `here-we-go`.
14065        paramtab()
14066            .read()
14067            .ok()
14068            .and_then(|t| {
14069                t.get(s)
14070                    .and_then(|pm| pm.u_tied.as_deref().map(|td| td.joinchar))
14071            })
14072            .map(|jc| jc as u8 as char)
14073            .unwrap_or(':')
14074    };
14075
14076    // c:5319 — `addenv(pm, t ? zjoin(t, joinchar, 1) : "")`.
14077    let joined = match t {
14078        Some(arr) => arr.join(&joinchar.to_string()),
14079        None => String::new(),
14080    };
14081    addenv(s, &joined);
14082}
14083
14084/// Port of `zputenv()` from `Src/params.c:5325` (USE_SET_UNSET_ENV branch). Splits
14085/// `str` at the first `=`, validates the name is in the portable
14086/// character set (rejects any byte >= 128), and calls
14087/// `setenv(name, value, 1)`.
14088///
14089/// C body walks `str` byte-by-byte looking for either a high-byte
14090/// (reject) or `=` (split). On a clean ASCII `name=value`, it
14091/// temporarily writes `\0` at the `=` to splice off the name,
14092/// calls setenv, then restores the `=`. On `=`-less input, it
14093/// flags via DPUTS and still calls setenv with the whole string
14094/// as the name (with value pointing at the trailing `\0`). Rust
14095/// equivalent: split, set_var; the in-place mutation isn't
14096/// observable since we copy.
14097/// Port of `zputenv(char *str)` from `Src/params.c:5325`.
14098pub fn zputenv(str: &str) -> i32 {
14099    // c:5325
14100    // c:5327 — DPUTS(!str, "Attempt to put null string into environment.")
14101    DPUTS!(
14102        str.is_empty(),
14103        "Attempt to put null string into environment."
14104    ); // c:5327
14105    if str.is_empty() {
14106        // c:5328 (after DPUTS, defensive return)
14107        return 0;
14108    }
14109    let bytes = str.as_bytes();
14110    // c:5339-5341 — walk until `=` or high byte; reject high bytes.
14111    let mut ptr = 0;
14112    while ptr < bytes.len() && bytes[ptr] != b'=' && bytes[ptr] < 128 {
14113        // c:5339
14114        ptr += 1;
14115    }
14116    if ptr < bytes.len() && bytes[ptr] >= 128 {
14117        // c:5342
14118        // c:5351 — `return 1` to reject non-portable name.
14119        return 1;
14120    }
14121    if ptr < bytes.len() {
14122        // c:5352 `else if (*ptr)`
14123        // c:5353-5355 — write `\0` at `=`, setenv(name, value), restore.
14124        let name = &str[..ptr];
14125        let value = &str[ptr + 1..];
14126        // c:Src/params.c:5354 — `setenv(name, value, 1)` uses libc
14127        // setenv which treats the value as a NUL-terminated C string.
14128        // An embedded NUL byte ends the value there. Rust's
14129        // std::env::set_var PANICS on NUL in value; emulate C's
14130        // truncate-at-NUL semantics so shell params holding raw NUL
14131        // bytes (e.g. `X=$'a\0b\0c'` for `${(ps:\0:)X}` splits) can
14132        // still propagate the leading portion to env. The full
14133        // raw value lives in the canonical param table; this is
14134        // just the libc-env mirror.
14135        let safe_value: &str = match value.find('\0') {
14136            Some(n) => &value[..n],
14137            None => value,
14138        };
14139        if name.as_bytes().contains(&b'\0') {
14140            // c: setenv on a name with NUL is malformed — C's libc
14141            // rejects, we silently drop the env mirror.
14142            return 1;
14143        }
14144        env::set_var(name, safe_value);
14145        0
14146    } else {
14147        // c:5355 else
14148        // c:5356 — DPUTS(1, "bad environment string").
14149        // With no `=`, treat `str` as a bare name with empty value.
14150        DPUTS!(true, "bad environment string"); // c:5356
14151        if str.as_bytes().contains(&b'\0') {
14152            return 1;
14153        }
14154        env::set_var(str, ""); // c:5357
14155        0
14156    }
14157}
14158
14159// NUL-safe env-mirror helper lives in src/vm_helper.rs
14160// (zputenv, Src/params.c:5325) — the C-named env writer; the
14161// src/ported/ build gate forbids non-C-named fns here.
14162
14163/// Port of `findenv()` from `Src/params.c:5391`. Walks `environ` looking for an
14164/// entry whose name component (bytes up to `=`) matches `name`.
14165/// Returns Some(index) on a match; the C source writes the
14166/// index into `*pos` and returns 1.
14167///
14168/// Rust signature differs (no out-param; returns `Option<usize>`)
14169/// — the C int-with-out-param idiom maps to `Option<index>` here.
14170/// Walks std::env::vars_os() which preserves the same ordering
14171/// as the underlying libc environ array.
14172pub fn findenv(name: &str) -> Option<usize> {
14173    // c:5391
14174    // c:5391 — `eq = strchr(name, '=')`. Strip any trailing `=value`.
14175    let nlen = name.find('=').unwrap_or(name.len()); // c:5397
14176    let bare = &name[..nlen];
14177
14178    // c:5398-5404 — walk environ until match. Use std::env::vars()
14179    // which preserves the same ordering as the underlying libc
14180    // environ.
14181    for (i, (k, _)) in env::vars_os().enumerate() {
14182        if let Some(s) = k.to_str() {
14183            if s == bare {
14184                return Some(i); // c:5401-5403
14185            }
14186        }
14187    }
14188    None // c:5406
14189}
14190
14191// -----------------------------------------------------------
14192// env management (zsh's wrapper around setenv/unsetenv).
14193// -----------------------------------------------------------
14194
14195/// Port of `zgetenv()` from `Src/params.c:5416`. C body walks
14196/// `environ` byte-by-byte. Rust port uses `std::env::var`.
14197pub fn zgetenv(name: &str) -> Option<String> {
14198    env::var(name).ok()
14199}
14200
14201/// Port of `copyenvstr()` from `Src/params.c:5434`. Unmetafies `value`
14202/// into `s` (Meta NEXT pairs collapse to NEXT^32) and applies
14203/// PM_LOWER / PM_UPPER case folding.
14204///
14205/// Two representation-driven deviations, both deliberate:
14206///
14207///  1. **No Meta un-escaping.** C param values are metafied byte
14208///     strings, so `Meta` (0x83) always introduces an escaped byte.
14209///     zshrs stores plain UTF-8 `String`s, where 0x83 is an ordinary
14210///     UTF-8 continuation byte — U+2603 SNOWMAN is `e2 98 83`. Treating
14211///     it as Meta ate the following byte and truncated the value.
14212///
14213///  2. **Fold per char, not per byte.** C's `tulower` (c:Src/utils.c:2302)
14214///     is `c &= 0xff; isupper(c) ? tolower(c) : c` — a single-byte,
14215///     locale-table fold that mangles UTF-8 (real zsh turns `É` = `c3 89`
14216///     into `e3 89` on export). Folding ASCII only keeps every byte of a
14217///     multibyte character intact. The two agree for every ASCII value;
14218///     they differ only where real zsh corrupts the encoding, which is
14219///     recorded in docs/BUGS.md rather than reproduced.
14220pub fn copyenvstr(buf: &mut String, value: &str, flags: i32) {
14221    // c:5434
14222    let flags_u = flags as u32;
14223    for ch in value.chars() {
14224        // c:5436
14225        // `char::to_ascii_*` is the exact ASCII-domain restriction of
14226        // `tulower`/`tuupper`: it rewrites A-Z / a-z and returns every
14227        // other scalar unchanged.
14228        let ch = if flags_u & PM_LOWER != 0 {
14229            ch.to_ascii_lowercase() // c:5439-5440
14230        } else if flags_u & PM_UPPER != 0 {
14231            ch.to_ascii_uppercase() // c:5441-5442
14232        } else {
14233            ch
14234        };
14235        buf.push(ch);
14236    }
14237}
14238
14239/// Port of `addenv()` from `Src/params.c:5448` (USE_SET_UNSET_ENV branch — the
14240/// portable one). C body:
14241///   1. `newenv = mkenvstr(pm->nam, value, pm->flags)` (c:5463)
14242///   2. `if (zputenv(newenv)) { free; pm->env=NULL; return }` (c:5464-5468)
14243///   3. Otherwise: `if (pm->env) free(pm->env); pm->env = newenv;
14244///      pm->flags |= PM_EXPORTED` (c:5482-5484)
14245///
14246/// Rust takes `name` instead of `Param pm` and looks up the
14247/// `pm` node internally — the C body's only reads of `pm` are
14248/// `pm->nam`, `pm->flags`, `pm->env`, all available from
14249/// paramtab. The return type changes from `void` to `i32` so
14250/// callers can chain it; 0 = success, 1 = zputenv failed.
14251pub fn addenv(name: &str, value: &str) -> i32 {
14252    // c:5448
14253
14254    // c:5463 — `newenv = mkenvstr(pm->nam, value, pm->flags)`.
14255    let flags = {
14256        let tab = paramtab().read().unwrap();
14257        tab.get(name).map(|pm| pm.node.flags).unwrap_or(0)
14258    };
14259    let newenv = mkenvstr(name, value, flags);
14260    // c:5464-5468 — `if (zputenv(newenv)) { free; pm->env=NULL; return }`.
14261    if zputenv(&newenv) != 0 {
14262        let mut tab = paramtab().write().unwrap();
14263        if let Some(pm) = tab.get_mut(name) {
14264            pm.env = None;
14265        }
14266        return 1;
14267    }
14268    // c:5482-5484 — `pm->env = newenv; pm->flags |= PM_EXPORTED`.
14269    let mut tab = paramtab().write().unwrap();
14270    if let Some(pm) = tab.get_mut(name) {
14271        pm.env = Some(newenv);
14272        pm.node.flags |= PM_EXPORTED as i32;
14273    }
14274    0
14275}
14276
14277/// Port of `mkenvstr()` from `Src/params.c:5513`. Builds `name=value`
14278/// in a fresh heap-string, where `value` is unmetafied and
14279/// case-folded according to `flags` (PM_LOWER → lower, PM_UPPER →
14280/// upper). The C source computes the unmetafied length first via
14281/// the `while (*s && (*s++ != Meta || *s++ != 32))` loop, then
14282/// allocates and writes via copyenvstr; the Rust port appends to
14283/// a `String` so the length pre-scan is implicit.
14284pub fn mkenvstr(name: &str, value: &str, flags: i32) -> String {
14285    // c:5513
14286    let mut buf = String::with_capacity(name.len() + value.len() + 2);
14287    buf.push_str(name); // c:5522 strcpy(s, name)
14288    buf.push('='); // c:5524 *s = '='
14289    if !value.is_empty() {
14290        // c:5525
14291        copyenvstr(&mut buf, value, flags); // c:5526
14292    }
14293    buf // c:5530
14294}
14295
14296/// Port of `delenvvalue()` from `Src/params.c:5542`. Removes `x` from environ by walking
14297/// to its pointer and shifting subsequent entries down one slot.
14298///
14299/// C body operates on the environ array directly. The Rust port
14300/// uses `env::remove_var(name)` since Rust's env is mediated by
14301/// libc::unsetenv internally — same shift semantics.
14302pub fn delenvvalue(name: &str) {
14303    // c:5542
14304    env::remove_var(name); // c:5542 equivalent
14305}
14306
14307/// Port of `delenv()` from `Src/params.c:5563`. Removes the param's env entry and
14308/// clears `pm->env`. Under USE_SET_UNSET_ENV (the portable
14309/// branch) the C body is:
14310///   unsetenv(pm->node.nam);
14311///   zsfree(pm->env);
14312///   pm->env = NULL;
14313///
14314/// "Note we don't remove PM_EXPORT from the flags. This may be
14315/// asking for trouble but we need to know later if we restore
14316/// this parameter to its old value." (c:5575-5577)
14317///
14318/// Rust signature drift: takes `&str` (the param name) instead
14319/// of `&mut Param`. The pm.env field is cleared via the paramtab
14320/// lookup; PM_EXPORTED is intentionally preserved per the C
14321/// comment.
14322pub fn delenv(name: &str) {
14323    // c:5563
14324    // c:5563 — `unsetenv(pm->node.nam)`.
14325    env::remove_var(name);
14326    // c:5568 / c:5572 — `pm->env = NULL`. PM_EXPORTED stays set.
14327    let mut tab = paramtab().write().unwrap();
14328    if let Some(pm) = tab.get_mut(name) {
14329        pm.env = None;
14330    }
14331}
14332
14333/// The process environment as it stood on entry to an in-process
14334/// subshell, for the subshell's exit to put back.
14335///
14336/// !!! WARNING: RUST-ONLY TYPE — C forks, and the child's `environ` dies
14337/// with it !!!
14338/// Every export writes the live `environ` array: `zputenv` (c:5320) is
14339/// `setenv(3)`, `delenvvalue` (c:5537) / `delenv` (c:5558) are
14340/// `unsetenv(3)`. In C those writes happen in the child `getoutput`
14341/// forked (`Src/exec.c:4816`), so `x=$(export v=1); env` never shows `v`.
14342/// zshrs runs `$( … )` in process, and the paramtab snapshot cannot undo
14343/// a `setenv`: the export reached every later child of the parent.
14344///
14345/// The image is taken rather than a journal of writes because `setenv`
14346/// is reached from far more places than the three above (`cd` exporting
14347/// `PWD`, `allexport`, module code, locale handling). One contiguous copy
14348/// of the bytes costs a single allocation, sized by the environment and
14349/// not by the number of variables; `restore` does nothing further unless
14350/// the body actually changed the environment.
14351#[allow(non_camel_case_types)]
14352pub struct environ_image {
14353    /// Every `NAME=VALUE` string of `environ`, in array order, each
14354    /// followed by a NUL.
14355    bytes: Vec<u8>,
14356}
14357
14358impl environ_image {
14359    /// Visit every entry of the live `environ` array, in order — the
14360    /// same array `zexecve` hands to `execve(2)` (`crate::ported::exec`).
14361    /// The bytes are only valid until the next `setenv`/`unsetenv`, so
14362    /// they are lent to `f` rather than returned.
14363    fn walk_live(mut f: impl FnMut(&[u8])) {
14364        extern "C" {
14365            static environ: *const *const libc::c_char;
14366        }
14367        unsafe {
14368            let mut p = environ;
14369            while !p.is_null() && !(*p).is_null() {
14370                f(std::ffi::CStr::from_ptr(*p).to_bytes());
14371                p = p.add(1);
14372            }
14373        }
14374    }
14375
14376    /// Split `NAME=VALUE` at its first `=`. An entry without one, or with
14377    /// an empty name, is not a variable `setenv` could have made.
14378    fn split(entry: &[u8]) -> Option<(&[u8], &[u8])> {
14379        let eq = entry.iter().position(|b| *b == b'=')?;
14380        (eq > 0).then(|| (&entry[..eq], &entry[eq + 1..]))
14381    }
14382
14383    /// Copy the live environment: one buffer, no per-variable allocation.
14384    pub fn save() -> Self {
14385        let mut bytes = Vec::new();
14386        Self::walk_live(|entry| {
14387            bytes.extend_from_slice(entry);
14388            bytes.push(0);
14389        });
14390        environ_image { bytes }
14391    }
14392
14393    /// Make the live environment equal the image again — same entries,
14394    /// same order (`env` lists them in array order).
14395    pub fn restore(self) {
14396        use std::ffi::OsStr;
14397        use std::os::unix::ffi::OsStrExt;
14398        let saved: Vec<&[u8]> = self
14399            .bytes
14400            .split(|b| *b == 0)
14401            .filter(|e| !e.is_empty())
14402            .collect();
14403        let mut live: Vec<Vec<u8>> = Vec::new();
14404        Self::walk_live(|entry| live.push(entry.to_vec()));
14405        if live.len() == saved.len() && live.iter().zip(&saved).all(|(l, s)| l == s) {
14406            return; // the body left the environment alone
14407        }
14408        let set = |entry: &[u8]| {
14409            if let Some((name, value)) = Self::split(entry) {
14410                env::set_var(OsStr::from_bytes(name), OsStr::from_bytes(value));
14411            }
14412        };
14413        let name_of = |e: &[u8]| Self::split(e).map(|(n, _)| n.to_vec());
14414        let same_names = live.len() == saved.len()
14415            && live.iter().zip(&saved).all(|(l, s)| name_of(l) == name_of(s));
14416        if same_names {
14417            // Only values changed: rewrite those in place, order intact.
14418            for (l, s) in live.iter().zip(&saved) {
14419                if l.as_slice() != *s {
14420                    set(s);
14421                }
14422            }
14423            return;
14424        }
14425        // A variable was added or removed. `setenv` appends, so the saved
14426        // order only comes back by clearing and re-adding in that order.
14427        for entry in &live {
14428            if let Some((name, _)) = Self::split(entry) {
14429                env::remove_var(OsStr::from_bytes(name));
14430            }
14431        }
14432        for entry in saved {
14433            set(entry);
14434        }
14435    }
14436}
14437
14438/// Port of `convbase_ptr()` from `Src/params.c:5586`. C body
14439/// converts `v` into base `base` (negative `base` suppresses the
14440/// "0x"/"N#" discriminator), writing the digits into `s` and
14441/// returning the digit count via `*ndigits`. Rust port returns
14442/// `(formatted_string, digit_count)` since Rust strings own
14443/// their buffer.
14444/// WARNING: param names don't match C — Rust=(v, base) vs C=(s, v, base, ndigits)
14445pub fn convbase_ptr(v: i64, base: i32) -> (String, i32) {
14446    let mut s = String::new();
14447    let mut value = v;
14448    if value < 0 {
14449        s.push('-');
14450        // c:Src/params.c — `value = -value;` on INT_MIN is UB in C
14451        // but in practice wraps back to INT_MIN. Use wrapping_neg
14452        // to avoid Rust's debug-build overflow panic. zsh prints
14453        // `-9223372036854775808` for `$((2**63))`; we mirror that.
14454        value = value.wrapping_neg();
14455    }
14456    let mut b = base;
14457    if (-1..=1).contains(&b) {
14458        b = -10;
14459    }
14460    if b > 0 {
14461        if isset(CBASES) && b == 16 {
14462            s.push_str("0x");
14463        } else if isset(CBASES) && b == 8 && isset(OCTALZEROES) {
14464            s.push('0');
14465        } else if b != 10 {
14466            s.push_str(&format!("{}#", b));
14467        }
14468    } else {
14469        b = -b;
14470    }
14471    let base_u = b as u64;
14472    let mut x = value as u64;
14473    let mut digs: i32 = 0;
14474    while x != 0 {
14475        x /= base_u;
14476        digs += 1;
14477    }
14478    if digs == 0 {
14479        digs = 1;
14480    }
14481    // Digits > 9 print UPPERCASE in zsh (`16#FF`) but LOWERCASE in the Korn
14482    // family (`16#ff`), which `--ksh`/`--mksh`/`--pdksh` are drop-ins for. zsh's
14483    // own `emulate ksh` KEEPS uppercase, so this must fire ONLY for the
14484    // real-shell-faithful ksh drop-in (`posix_faithful()`), not the zsh-STYLE
14485    // `--ksh --zsh` leg (which must match `emulate ksh`). Verified vs
14486    // `typeset -i16 v=255` (real ksh93/mksh → `16#ff`).
14487    let alpha = if crate::ported::options::emulation.load(std::sync::atomic::Ordering::Relaxed)
14488        & crate::ported::zsh_h::EMULATE_KSH
14489        != 0
14490        && crate::dash_mode::posix_faithful()
14491    {
14492        b'a'
14493    } else {
14494        b'A'
14495    };
14496    let mut digits: Vec<u8> = vec![0u8; digs as usize];
14497    let mut i = digs - 1;
14498    let mut x = value as u64;
14499    while i >= 0 {
14500        let dig = (x % base_u) as u8;
14501        digits[i as usize] = if dig < 10 {
14502            b'0' + dig
14503        } else {
14504            alpha + dig - 10
14505        };
14506        x /= base_u;
14507        i -= 1;
14508    }
14509    s.push_str(std::str::from_utf8(&digits).unwrap_or(""));
14510    (s, digs)
14511}
14512
14513// ---------------------------------------------------------------------------
14514// Integer/Float conversion (from convbase/convfloat)
14515// ---------------------------------------------------------------------------
14516
14517/// Port of `convbase()` from `Src/params.c:5632`. C body (single statement):
14518///     `convbase_ptr(s, v, base, NULL);`
14519/// Rust takes (v, base) and returns the formatted string since Rust
14520/// strings own their buffer; the discarded `ndigits` out-param of
14521/// `convbase_ptr` is `.1` of the returned tuple.
14522/// WARNING: param names don't match C — Rust=(val, base) vs C=(s, v, base)
14523pub fn convbase(val: i64, base: u32) -> String {
14524    // c:5632
14525    convbase_ptr(val, base as i32).0 // c:5634
14526}
14527
14528/// Convert integer to string with underscores for readability
14529/// Port of `convbase_underscore()` from `Src/params.c:5646`.
14530///
14531/// `base` is `i32` not `u32` because zsh uses NEGATIVE `base` values
14532/// (set by `[##N]`) to mean "emit `N`-radix digits WITHOUT the `N#`
14533/// prefix" — convbase_ptr at params.rs:7435-7451 handles the sign:
14534/// positive `b` produces `b#NNN`, negative `b` produces bare `NNN`
14535/// (absolute value of `b` is the actual radix).
14536///
14537/// Previous Rust signature `base: u32` silently dropped the sign, so
14538/// `$(([##16] 255))` (outputradix=-16) ended up as `convbase(255, 16)`
14539/// = `"16#FF"` instead of `"FF"`.
14540/// WARNING: param names don't match C — Rust=(val, base, underscore) vs C=(s, v, base, underscore)
14541pub fn convbase_underscore(val: i64, base: i32, underscore: i32) -> String {
14542    // c:5646 — `convbase_ptr(s, v, base, &ndigits)` returns BOTH the string and
14543    // the digit count EXCLUDING the base prefix (`0x`, `N#`, leading `0`).
14544    let (s, ndigits) = convbase_ptr(val, base);
14545    if underscore <= 0 {
14546        return s; // c:5648
14547    }
14548    let ndigits = ndigits.max(0) as usize;
14549
14550    // c:5654-5657 — group only the last `ndigits` chars; the prefix (`len -
14551    // ndigits` chars) is copied through untouched. The previous port re-scanned
14552    // for the first digit, which matched the `0` in `0x…` and grouped the `0x`
14553    // prefix too (`0x10000` → `0_x10_000` instead of `0x10_000`).
14554    let split = s.len().saturating_sub(ndigits);
14555    let prefix = &s[..split];
14556    let digits = &s[split..];
14557
14558    if digits.len() <= underscore as usize {
14559        return s;
14560    }
14561
14562    let u = underscore as usize;
14563    let mut result = prefix.to_string();
14564    let chars: Vec<char> = digits.chars().collect();
14565    let first_group = chars.len() % u;
14566    if first_group > 0 {
14567        result.extend(&chars[..first_group]);
14568        if first_group < chars.len() {
14569            result.push('_');
14570        }
14571    }
14572    for (i, chunk) in chars[first_group..].chunks(u).enumerate() {
14573        if i > 0 {
14574            result.push('_');
14575        }
14576        result.extend(chunk);
14577    }
14578    result
14579}
14580
14581/// Port of `convfloat()` from `Src/params.c:5690`.
14582///
14583/// C signature: `char *convfloat(double dval, int digits, int flags,
14584/// FILE *fout)` — picks `%e` / `%f` / `%g` based on PM_EFLOAT /
14585/// PM_FFLOAT (line 5705-5727), then snprintf'd with `digits` precision.
14586/// When neither E nor F flag is set, zsh uses `%.*g` with a default
14587/// of 17 significant digits (line 5712-5714). E-flag with N significant
14588/// figures decrements `digits` because `%e` counts decimal places not
14589/// significants (line 5720-5725).
14590///
14591/// Rust signature drops the `fout` parameter — every caller wanted the
14592/// returned string. IEEE specials (inf/nan) hand-formatted to `Inf`/
14593/// `-Inf`/`NaN` ahead of the snprintf, matching the C source's Inf/NaN
14594/// shortcuts at lines 5733-5736 / 5742-5744. The trailing-dot rule for
14595/// integer-valued floats (`5` -> `5.`) is added by the caller (params'
14596/// internal printing path) in C zsh; mirrored here for the no-flag case
14597/// so `MathNum::(crate::ported::math::mn_format_subst(Float(5.0)))` produces `5.` not `5`.
14598/// WARNING: param names don't match C — Rust=(dval, digits, pm_flags) vs C=(dval, digits, flags, fout)
14599pub fn convfloat(dval: f64, digits: i32, pm_flags: u32) -> String {
14600    if dval.is_infinite() {
14601        // c:5742
14602        return if dval < 0.0 {
14603            "-Inf".to_string()
14604        } else {
14605            "Inf".to_string()
14606        };
14607    }
14608    if dval.is_nan() {
14609        // c:5744
14610        return "NaN".to_string();
14611    }
14612    // !!! EMULATION-ONLY (no C counterpart) !!! ksh93 prints through `%g`:
14613    // `-E[n]` is `%.{n}g` (default 10), plain float arithmetic is `%.15g`, and
14614    // neither appends zsh's trailing dot. Measured against ksh93u+m:
14615    // `typeset -E f=1.5; print $f` -> `1.5`, `print $((2**0.5))` ->
14616    // `1.4142135623731`, `print $((5.0))` -> `5`.
14617    let ksh93 = crate::dash_mode::ksh93_mode();
14618    let (fmt_char, digits) = if ksh93 && (pm_flags & PM_EFLOAT) != 0 {
14619        ('g', if digits <= 0 { 10 } else { digits })
14620    } else if (pm_flags & PM_EFLOAT) != 0 {
14621        // c:5715
14622        let d = if digits <= 0 { 10 } else { digits }; // c:5718
14623        ('e', (d - 1).max(0)) // c:5725
14624    } else if (pm_flags & PM_FFLOAT) != 0 {
14625        // c:5716
14626        let d = if digits <= 0 { 10 } else { digits }; // c:5718
14627        ('f', d)
14628    } else {
14629        let d = if digits == 0 { if ksh93 { 15 } else { 17 } } else { digits }; // c:5713
14630        ('g', d)
14631    };
14632    // Mirror zsh's snprintf path (Src/params.c:5751) — the C source
14633    // uses `VARARR(char, buf, 512 + digits)` for %f's full integer-
14634    // part expansion. 512 + 17 = 529 covers the zsh general case;
14635    // wider buffers below for the unbounded %f.
14636    let buf_len = 512usize + digits as usize + 4;
14637    let mut buf = vec![0u8; buf_len];
14638    let fmt = match fmt_char {
14639        'e' => c"%.*e",
14640        'f' => c"%.*f",
14641        _ => c"%.*g",
14642    };
14643    // SAFETY: buf has the C-required size for any double precision; fmt
14644    // is a NUL-terminated literal; snprintf writes ASCII only.
14645    let n = unsafe {
14646        libc::snprintf(
14647            buf.as_mut_ptr() as *mut libc::c_char,
14648            buf_len,
14649            fmt.as_ptr(),
14650            digits as libc::c_int,
14651            dval,
14652        )
14653    };
14654    if n < 0 {
14655        return format!("{}", dval);
14656    }
14657    let len = (n as usize).min(buf_len - 1);
14658    buf.truncate(len);
14659    let mut s = String::from_utf8(buf).unwrap_or_else(|_| format!("{}", dval));
14660    // zsh's general-format (%g) callers (math `$(( ))` substitution)
14661    // append `.` when the output has no `e` and no `.`, so integer-
14662    // valued floats like `5` render as `5.`. PM_EFLOAT/PM_FFLOAT skip
14663    // this rule (the format spec already pins shape).
14664    if !ksh93 && fmt_char == 'g' && !s.contains('e') && !s.contains('.') {
14665        s.push('.');
14666    }
14667    s
14668}
14669
14670/// Port of `startparamscope()` from `Src/params.c:5840`.
14671/// Start a parameter scope. The C source pushes the
14672/// current scope counter so `local`-declared params disappear on function
14673/// exit. Rust port operates on the bucket-2 holder `paramtab` via a
14674/// `&mut HashTable` argument.
14675pub fn startparamscope(_table: &mut HashTable) {
14676    inc_locallevel();
14677}
14678
14679/// Port of `endparamscope()` from `Src/params.c:5857`. C signature:
14680/// `mod_export void endparamscope(void)`. Decrements `locallevel`,
14681/// pops any pushed history stack, then iterates `paramtab` calling
14682/// `scanendscope` to restore/unset every param whose `level`
14683/// exceeds the new `locallevel`. Operates on the global `paramtab`
14684/// just like C — no parameter, no fake injection wrapper.
14685pub fn endparamscope() {
14686    queue_signals();
14687    // c:5861 — `LinkList refs = locallevel < scoperefs_num ? scoperefs[locallevel] : NULL;`
14688    //          Snapshot the refs at the OLD locallevel BEFORE decrementing.
14689    let old_ll = locallevel_fn();
14690    let refs_snapshot: Vec<String> = SCOPEREFS.with(|sr| {
14691        let sr = sr.borrow();
14692        if (old_ll as usize) < sr.len() {
14693            sr[old_ll as usize].clone()
14694        } else {
14695            Vec::new()
14696        }
14697    });
14698
14699    dec_locallevel(); // c:5863 locallevel--
14700                      // c:5865 — `saveandpophiststack(0, HFILE_USE_OPTIONS);`. Pop
14701                      // all stack entries with locallevel > current.
14702    saveandpophiststack(0, HFILE_USE_OPTIONS as i32);
14703    let ll = locallevel_fn();
14704    LC_UPDATE_NEEDED.store(0, Ordering::SeqCst); // c:5860 lc_update_needed = 0 (USE_LOCALE)
14705    // c:5869 scanhashtable(paramtab, 0, 0, 0, scanendscope, 0). Walk
14706    // the live paramtab (HashMap-backed until the hashtable.c vtable
14707    // is wired) and apply scanendscope's `pm->level > locallevel`
14708    // filter, restoring the `pm.old` chain or removing the entry.
14709    // c:Src/params.c:5867-5933 — for PM_SPECIAL scalar params the
14710    // gsu setfn must be re-fired with the restored value so global
14711    // side-effects (ifs char buffer, PATH chunks, lc_update_needed
14712    // flag, etc.) get rolled back. Bug #8 in docs/BUGS.md: `local
14713    // IFS=:` inside a function left the global ifs buffer pinned
14714    // to ":" after return.
14715    //
14716    // The setfn closures often re-enter paramtab (ifssetfn → inittyptab
14717    // → paramtab.read), so we MUST drop the write lock before calling
14718    // them. Collect (name, setfn, value) into a deferred list inside
14719    // the lock, restore the pm.old chain, drop the lock, then re-fire
14720    // setfn on each special.
14721    // setfn None → name-routed restore via setsparam (PM_TIED params
14722    // whose pm carries no scalar gsu — FPATH/PATH/CDPATH…; setsparam
14723    // dispatches to the tied setter by name, refilling the tied
14724    // array's global storage).
14725    type DeferredSetfn = (String, Option<fn(&mut param, String)>, String);
14726    let mut deferred: Vec<DeferredSetfn> = Vec::new();
14727    // Array-shaped specials/tieds restore through the name-routed
14728    // array setter after the lock drops (same deadlock rationale).
14729    let mut deferred_arrays: Vec<(String, Vec<String>)> = Vec::new();
14730    if let Ok(mut tab) = paramtab().write() {
14731        let stale: Vec<(String, bool)> = tab
14732            .iter()
14733            .filter_map(|(k, pm)| {
14734                if pm.level > ll {
14735                    Some((k.clone(), (pm.node.flags as u32 & PM_HASHED) != 0))
14736                } else {
14737                    None
14738                }
14739            })
14740            .collect();
14741        for (n, was_assoc) in stale {
14742            // c:5904 — `if ((pm->node.flags & (PM_SPECIAL|PM_REMOVABLE)) ==
14743            // PM_SPECIAL)`: a non-removable special is restored THROUGH the
14744            // node already in the table (c:5933-5937 assign straight into
14745            // `pm`), so its bucket position never changes. Only the
14746            // `else unsetparam_pm(pm, 0, 0)` arm (c:5966) unlinks the node
14747            // and re-adds `pm->old` (c:3916-3920), which puts the revealed
14748            // outer binding at the FRONT of its chain. Reproduce both: skip
14749            // the remove for the special arm and let the `insert` below take
14750            // `addhashnode2`'s replace-in-place path (Src/hashtable.c:189-203).
14751            let restore_in_place = tab
14752                .get(&n)
14753                .map(|pm| (pm.node.flags as u32 & (PM_SPECIAL | PM_REMOVABLE)) == PM_SPECIAL)
14754                .unwrap_or(false);
14755            // c:5919-5922 — scanendscope's special arm (USE_LOCALE):
14756            //     if (!strncmp(pm->node.nam, "LC_", 3) ||
14757            //         !strcmp(pm->node.nam, "LANG"))
14758            //         lc_update_needed = 1;
14759            if restore_in_place && (n.starts_with("LC_") || n == "LANG") {
14760                LC_UPDATE_NEEDED.store(1, Ordering::SeqCst);
14761            }
14762            // c:scanendscope:5903 — non-special path: restore pm.old
14763            // (or remove if no outer binding existed).
14764            let popped = if restore_in_place {
14765                tab.get(&n).cloned()
14766            } else {
14767                tab.remove(&n)
14768            };
14769            if let Some(pm) = popped {
14770                let had_outer = pm.old.is_some();
14771                // The level this local was stamped with at createparam time
14772                // (c:Src/builtin.c:2576 `pm->level = locallevel`); the assoc
14773                // shadow frame pushed for it carries the same number.
14774                let popped_level = pm.level;
14775                // c:Src/params.c:3862 — scanendscope's non-special arm calls
14776                // `unsetparam_pm(pm, 0, 0)`, whose `if (pm->env) delenv(pm)`
14777                // strips the popped local's ENVIRON entry. This pop path
14778                // removes the node from the table directly instead of routing
14779                // through unsetparam_pm, so an exported local's environment
14780                // entry outlived its scope: `f(){ local -x E=1 }; f` left E=1
14781                // in the real process environment — inherited by every child —
14782                // even though paramtab itself was clean (`typeset -p E` already
14783                // reported "no such variable").
14784                let popped_exported = (pm.node.flags as u32 & PM_EXPORTED) != 0;
14785                // c:Src/params.c:3926-3934 — once the outer binding is back,
14786                // `if (oldpm->node.flags & PM_EXPORTED) export_param(oldpm)`
14787                // re-exports the OUTER value, which typeset_single had removed
14788                // from the environment (c:1143 `delenv(oldpm)`) when the local
14789                // shadowed it. Capture it before `pm.old` is moved below.
14790                let outer_export: Option<String> = pm.old.as_ref().and_then(|p| {
14791                    if (p.node.flags as u32 & PM_EXPORTED) != 0 {
14792                        Some(p.u_str.clone().unwrap_or_default())
14793                    } else {
14794                        None
14795                    }
14796                });
14797                if let Some(mut prev) = pm.old {
14798                    // c:Src/params.c:5928 — `if (!(tpm->node.flags &
14799                    // (PM_NORESTORE|PM_READONLY)))` gates the value restore.
14800                    // PM_NORESTORE is stamped by typeset_single (c:2408) when
14801                    // the OTHER half of a tied path/PATH pair was already made
14802                    // local at the same level: that half's own restore writes
14803                    // through the tie, so re-firing this half's saved value
14804                    // would clobber it with a copy that already reflects the
14805                    // local state.
14806                    // PM_READONLY belongs in the SAME gate — C skips the value
14807                    // restore outright for a read-only special, because a
14808                    // shadow never wrote through to it in the first place. Only
14809                    // the scalar arm below compensated (by dropping the flag
14810                    // around the name-routed setter); the ARRAY arm did not, so
14811                    // unwinding a scope that shadowed a read-only special ARRAY
14812                    // (`f(){ typeset -h +g dis_reswords }`, and every
14813                    // `typeset -h +g -m 'PAT'` that matches one) reached
14814                    // assignaparam and printed `read-only variable: NAME` at
14815                    // function exit.
14816                    let norestore = (prev.node.flags as u32 & (PM_NORESTORE | PM_READONLY)) != 0;
14817                    // c:Src/params.c:5924 — `pm->node.flags =
14818                    // (tpm->node.flags & ~PM_NORESTORE);` the bit never
14819                    // survives into the re-exposed outer binding.
14820                    prev.node.flags = (prev.node.flags as u32 & !PM_NORESTORE) as i32;
14821                    // c:scanendscope:5933 pm->old = tpm->old
14822                    // PM_TIED counts too — the tied array's GLOBAL
14823                    // storage was written through by the local's
14824                    // assignments; the deferred restore re-fires the
14825                    // saved value through the tied setter (by gsu
14826                    // setfn when present, else name-routed setsparam)
14827                    // so fpath/path/cdpath roll back with the scalar.
14828                    // c:5904 — a REMOVABLE special (a private, c:Src/Modules/
14829                    // param_private.c:174) is "normal": the local is unset
14830                    // and the old node re-added, with no value replay.
14831                    let restored_is_special = (prev.node.flags as u32 & (PM_SPECIAL | PM_TIED)) != 0
14832                        && (prev.node.flags as u32 & PM_REMOVABLE) == 0;
14833                    let restored_is_array = (PM_TYPE(prev.node.flags as u32) & PM_ARRAY) != 0;
14834                    // c:5961-5962 — `case PM_HASHED: pm->gsu.h->setfn(pm,
14835                    // tpm->u.hash)`. typeset_single's copy of a magic hash
14836                    // (builtin.rs, c:Src/builtin.c:2410) is carried as a
14837                    // flat key/value list in `u_arr`; take it off the node so
14838                    // the restored special does not keep a stale array value.
14839                    let restored_is_hashed = PM_TYPE(prev.node.flags as u32) == PM_HASHED;
14840                    let restored_val = prev.u_str.clone();
14841                    let restored_arr = if restored_is_hashed {
14842                        prev.u_arr.take()
14843                    } else {
14844                        prev.u_arr.clone()
14845                    };
14846                    let restored_setfn = prev.gsu_s.as_ref().map(|g| g.setfn);
14847                    tab.insert(n.clone(), prev); // restore outer binding (Box<param>)
14848                    if restored_is_special && !norestore {
14849                        if restored_is_hashed {
14850                            // c:5962 — replayed after the lock drops through
14851                            // assignaparam's whole-hash special arm, which is
14852                            // the per-row `setpm*s` dispatch (gsu.h->setfn).
14853                            if let Some(pairs) = restored_arr {
14854                                deferred_arrays.push((n.clone(), pairs));
14855                            }
14856                        } else if restored_is_array {
14857                            // ARRAY side of a tied pair — re-fire the
14858                            // saved element vector through the
14859                            // name-routed array setter so the tied
14860                            // scalar + global storage roll back too.
14861                            if let Some(arr) = restored_arr {
14862                                deferred_arrays.push((n.clone(), arr));
14863                            }
14864                        } else if let Some(val) = restored_val {
14865                            deferred.push((n.clone(), restored_setfn, val));
14866                        }
14867                    }
14868                } else if restore_in_place {
14869                    // A non-removable special with no `pm->old` cannot be
14870                    // restored in place (c:5930 DPUTS asserts `tpm` exists),
14871                    // so fall back to the c:5966 arm and unlink the node —
14872                    // the `tab.remove` above was skipped for this name.
14873                    tab.remove(&n);
14874                }
14875                // else: c:5966 unsetparam_pm — name unset entirely
14876                // RUST-ONLY: the assoc-storage shadow lives in a
14877                // parallel `paramtab_hashed_storage` map keyed by name;
14878                // endparamscope's pm removal must also clear that
14879                // shadow when no outer binding remains. Without this,
14880                // `f() { typeset -A H; H[x]=v; }; f` leaves H's data
14881                // in the shadow map even after the local pm is gone,
14882                // so `${H[x]}` outside reads "v" instead of empty.
14883                if was_assoc && !had_outer {
14884                    let _ = paramtab_hashed_storage()
14885                        .lock()
14886                        .ok()
14887                        .as_deref_mut()
14888                        .map(|m| m.remove(&n));
14889                }
14890                // RUST-ONLY: assoc-storage shadow restoration — pop the
14891                // frame createparam pushed for THIS local and put the name's
14892                // `paramtab_hashed_storage` row back the way it was before the
14893                // shadow installed. In C there is nothing to do here: the
14894                // outer's pairs never moved (they hang off the `pm->old`
14895                // struct's own `u.hash`, c:Src/params.c:1157-1158) and the
14896                // local's pairs die with the local in `stdunsetfn`'s PM_HASHED
14897                // arm (c:3922-3925 → hashsetfn c:4045). zshrs's one row per
14898                // NAME has to be unwound by hand, and this is the only place
14899                // that does it. Bug #415.
14900                //
14901                // The frame is matched by the level createparam recorded, not
14902                // by re-deriving the push condition from the flags: the two
14903                // spellings had drifted into "outer is PM_HASHED" on both
14904                // sides, so a hashed local under a NON-hashed outer pushed
14905                // nothing and popped nothing, and its row outlived every
14906                // enclosing scope (see the createparam comment). A level match
14907                // also means a frame is never stolen from an enclosing scope
14908                // when this scope pushed none — which is what kept
14909                //   typeset -A h=(a 1); f(){ local h; }; f; echo ${h[a]}
14910                // working (a non-hashed local over a hashed outer still pushes,
14911                // so it still restores `a 1`).
14912                {
14913                    let stk_mtx =
14914                        PARAMTAB_HASHED_SHADOW_STACK.get_or_init(|| Mutex::new(HashMap::new()));
14915                    // Outer Option: whether a frame belonging to this scope was
14916                    // popped at all. Inner: whether the name had a row before.
14917                    let saved: Option<Option<IndexMap<String, String>>> =
14918                        if let Ok(mut stk) = stk_mtx.lock() {
14919                            match stk.get_mut(&n) {
14920                                Some(frames)
14921                                    if frames
14922                                        .last()
14923                                        .is_some_and(|(lvl, _)| *lvl == popped_level) =>
14924                                {
14925                                    frames.pop().map(|(_, row)| row)
14926                                }
14927                                _ => None,
14928                            }
14929                        } else {
14930                            None
14931                        };
14932                    let frame_popped = saved.is_some();
14933                    if let Some(saved) = saved {
14934                        if let Ok(mut m) = paramtab_hashed_storage().lock() {
14935                            match saved {
14936                                Some(map) => {
14937                                    m.insert(n.clone(), map);
14938                                }
14939                                None => {
14940                                    m.remove(&n);
14941                                }
14942                            }
14943                        }
14944                    }
14945                    // RUST-ONLY: the same unwind for a local that became hashed
14946                    // AFTER it was declared, where createparam saw no hash on
14947                    // either side and pushed no frame:
14948                    //   d0() { local -a H; d1 }
14949                    //   d1() { local H=s; typeset -A H; H[x]=1 }
14950                    // The second declaration takes createparam's REUSE arm
14951                    // (c:Src/params.c:1154-1155 `pm = oldpm`, reached because
14952                    // `oldpm->level == locallevel`), which installs no shadow at
14953                    // all — in C there is still nothing to unwind, since the
14954                    // pairs went into the one Param that the scope pop frees.
14955                    // Here they went into the name-keyed row, which nothing
14956                    // owned. So enforce the invariant the push side states: a
14957                    // binding that is not PM_HASHED owns no row. `tab` already
14958                    // holds whatever this pop revealed, so the test reads the
14959                    // binding that is about to become visible.
14960                    if !frame_popped && had_outer {
14961                        let visible_hashed = tab
14962                            .get(&n)
14963                            .map(|p| (p.node.flags as u32 & PM_HASHED) != 0)
14964                            .unwrap_or(false);
14965                        if !visible_hashed {
14966                            if let Ok(mut m) = paramtab_hashed_storage().lock() {
14967                                m.remove(&n);
14968                            }
14969                        }
14970                    }
14971                }
14972                // Net environment effect of the two C steps above: the outer
14973                // value if it was exported, otherwise no entry at all. Uses
14974                // the raw env helpers (not export_param/unsetparam_pm) because
14975                // we still hold the paramtab write lock here and those re-enter
14976                // it; neither `env::set_var` nor `delenvvalue` touches paramtab.
14977                if popped_exported || outer_export.is_some() {
14978                    match &outer_export {
14979                        Some(v) => env::set_var(&n, v), // c:3933 export_param(oldpm)
14980                        None => delenvvalue(&n),        // c:3862 delenv(pm)
14981                    }
14982                }
14983            }
14984        }
14985    }
14986    // c:Src/params.c:5915-5933 — re-fire PM_SPECIAL setfns NOW that
14987    // the paramtab write lock is released. Each setfn takes its own
14988    // paramtab read (via inittyptab / similar) so the deferred call
14989    // is the only deadlock-safe path.
14990    for (n, setfn, val) in deferred {
14991        match setfn {
14992            Some(setfn) => {
14993                let mut pm_copy: Option<param> = None;
14994                if let Ok(tab) = paramtab().read() {
14995                    pm_copy = tab.get(&n).map(|p| (**p).clone());
14996                }
14997                if let Some(mut pm) = pm_copy {
14998                    setfn(&mut pm, val);
14999                }
15000            }
15001            // PM_TIED without a scalar gsu — name-routed: setsparam
15002            // reaches the tied setter and refills the tied array's
15003            // global storage (fpath/path/cdpath…).
15004            None => {
15005                // c:Src/params.c:5915-5933 — C restores by CALLING THE
15006                // SPECIAL'S setfn directly (`pm->gsu.s->setfn(pm, ...)`).
15007                // The `read-only variable` guard lives one level up in
15008                // `assignsparam`/`setstrvalue` (c:3216), so C's restore
15009                // never meets it: for a PM_READONLY_SPECIAL the setfn is
15010                // simply a no-op (`varint_readonly_gsu` =
15011                // `{intvargetfn, nullintsetfn, stdunsetfn}`, c:351).
15012                //
15013                // zshrs has no GSU wired for these rows, so the restore
15014                // falls back to the name-routed `setsparam`, which DOES
15015                // re-enter `assignsparam` and would emit `read-only
15016                // variable: NAME` while unwinding a scope that shadowed
15017                // one (`f() { local ARGC=2 }`). Drop the flag for the
15018                // duration of the restore so the write behaves like C's
15019                // direct setfn call, then put it back. The bit is
15020                // re-read from the node rather than assumed, so a row
15021                // that is not read-only is untouched.
15022                let ro_bit = paramtab()
15023                    .read()
15024                    .ok()
15025                    .and_then(|t| t.get(&n).map(|p| p.node.flags as u32 & PM_READONLY))
15026                    .unwrap_or(0);
15027                if ro_bit != 0 {
15028                    if let Ok(mut tab) = paramtab().write() {
15029                        if let Some(pm) = tab.get_mut(&n) {
15030                            pm.node.flags &= !(ro_bit as i32);
15031                        }
15032                    }
15033                }
15034                setsparam(&n, &val);
15035                if ro_bit != 0 {
15036                    if let Ok(mut tab) = paramtab().write() {
15037                        if let Some(pm) = tab.get_mut(&n) {
15038                            pm.node.flags |= ro_bit as i32;
15039                        }
15040                    }
15041                }
15042            }
15043        }
15044    }
15045    for (n, arr) in deferred_arrays {
15046        // c:5921 `pm->gsu.a->setfn(pm, tpm->u.arr)`.
15047        let _ = assignaparam(&n, arr, 0);
15048    }
15049    // c:5864-5880 (USE_LOCALE) — "Locale changed --- ensure it is restored."
15050    // Runs after the deferred setfn replays above, which are where this
15051    // port performs scanendscope's value restores. A shadowed LC_ALL whose
15052    // outer binding is unset replays nothing, so without this block the
15053    // process locale stayed at the local's value after the function
15054    // returned (`() { local LC_ALL=C }` left LC_CTYPE at C).
15055    if LC_UPDATE_NEEDED.load(Ordering::SeqCst) != 0 {
15056        match getsparam_u("LC_ALL").filter(|v| !v.is_empty()) {
15057            Some(val) => {
15058                let c = std::ffi::CString::new(val.as_bytes()).unwrap_or_default();
15059                unsafe {
15060                    libc::setlocale(libc::LC_ALL, c.as_ptr()); // c:5869
15061                }
15062            }
15063            None => {
15064                if let Some(val) = getsparam_u("LANG").filter(|v| !v.is_empty()) {
15065                    setlang(Some(&val)); // c:5873
15066                }
15067                for (name, category) in LC_NAMES {
15068                    // c:5874
15069                    if let Some(val) = getsparam_u(name).filter(|v| !v.is_empty()) {
15070                        let c = std::ffi::CString::new(val.as_bytes()).unwrap_or_default();
15071                        unsafe {
15072                            libc::setlocale(*category, c.as_ptr()); // c:5876
15073                        }
15074                    }
15075                }
15076            }
15077        }
15078        clear_mbstate(); // c:5879 LC_CTYPE may have changed
15079    }
15080
15081    // c:5890-5894 — `for (Param pm; refs && (pm = getlinknode(refs));) {
15082    //                   if ((pm->flags & PM_NAMEREF) && !(pm->flags & PM_UNSET) &&
15083    //                       !(pm->flags & PM_UPPER) && pm->base > locallevel) {
15084    //                       pm->base = 0; setscope(pm); } }`
15085    //               Reset PM_NAMEREF refs whose base was above the popped scope.
15086    //
15087    // setscope() recomputes pm->base from the level of the binding its
15088    // refname NOW resolves to (c:6402-6410), so a ref that pointed at a
15089    // local of the popped scope rebinds to the enclosing binding of the
15090    // same name and is pushed onto that scope's list in turn.
15091    //
15092    // SCOPEREFS records names, not structs, so the pushed structs are
15093    // found by walking the name's `old` chain; each one is addressed to
15094    // `setscope_by_name` by its level (a ref can be hidden by a same-name
15095    // local and still be on this list).
15096    if !refs_snapshot.is_empty() {
15097        let mut names: Vec<&String> = Vec::new();
15098        for name in refs_snapshot.iter() {
15099            if !names.contains(&name) {
15100                names.push(name);
15101            }
15102        }
15103        for name in names {
15104            // c:5891-5893 — the qualifying structs, reset to base 0.
15105            let mut levels: Vec<i32> = Vec::new();
15106            if let Ok(mut tab) = paramtab().write() {
15107                let mut cur = tab.get_mut(name.as_str()).map(|b| b.as_mut());
15108                while let Some(pm) = cur {
15109                    let f = pm.node.flags as u32;
15110                    if (f & PM_NAMEREF) != 0
15111                        && (f & PM_UNSET) == 0
15112                        && (f & PM_UPPER) == 0
15113                        && pm.base > ll
15114                    {
15115                        pm.base = 0; // c:5893
15116                        levels.push(pm.level);
15117                    }
15118                    cur = pm.old.as_deref_mut();
15119                }
15120            }
15121            for lvl in levels {
15122                setscope_by_name(name, Some(lvl)); // c:5894 setscope(pm)
15123            }
15124        }
15125    }
15126    // c:5896 — clear out the now-popped scope's refs list.
15127    SCOPEREFS.with(|sr| {
15128        let mut sr = sr.borrow_mut();
15129        if (old_ll as usize) < sr.len() {
15130            sr[old_ll as usize].clear();
15131        }
15132    });
15133    // c:5896-5902 — `/* Delete unset global variables that were hidden at
15134    // unset time */ if ((refs = scoperefs ? scoperefs[0] : NULL)) {
15135    // scoperefs[0] = NULL; for (...) if ((pm->node.flags & PM_UNSET) &&
15136    // !(pm->node.flags & PM_DECLARED)) unsetparam_pm(pm, 1, 0); }`
15137    let hidden_globals: Vec<String> =
15138        SCOPEREFS.with(|sr| sr.borrow_mut().first_mut().map(std::mem::take).unwrap_or_default());
15139    for name in hidden_globals {
15140        let mut tab = paramtab().write().unwrap();
15141        let Some(top) = tab.get(&name) else { continue };
15142        let dead = |p: &param| {
15143            let f = p.node.flags as u32;
15144            p.level == 0 && (f & PM_UNSET) != 0 && (f & PM_DECLARED) == 0
15145        };
15146        if dead(top) {
15147            // Uncovered: unsetparam_pm's removenode (c:3874).
15148            tab.remove(&name);
15149            drop(tab);
15150            let _ = paramtab_hashed_storage()
15151                .lock()
15152                .ok()
15153                .as_deref_mut()
15154                .map(|m| m.remove(&name));
15155        } else {
15156            let mut cur = top.old.as_deref();
15157            let mut still_hidden = false;
15158            while let Some(p) = cur {
15159                if dead(p) {
15160                    still_hidden = true;
15161                    break;
15162                }
15163                cur = p.old.as_deref();
15164            }
15165            drop(tab);
15166            if still_hidden {
15167                // c:3864-3868 — still hidden: back on scoperefs[0].
15168                SCOPEREFS.with(|sr| {
15169                    let mut sr = sr.borrow_mut();
15170                    if sr.is_empty() {
15171                        sr.resize(8, Vec::new());
15172                    }
15173                    sr[0].insert(0, name.clone());
15174                });
15175            }
15176        }
15177    }
15178    unqueue_signals();
15179}
15180
15181/// Port of `scanendscope()` from `Src/params.c:5900`. Per-node
15182/// callback used by `endparamscope` (params.c:5867 calls
15183/// `scanhashtable(paramtab, 0, 0, 0, scanendscope, 0)`) when a
15184/// function returns. C body:
15185/// ```c
15186/// Param pm = (Param)hn;
15187/// if (pm->level > locallevel) {
15188///     if ((pm->node.flags & (PM_SPECIAL|PM_REMOVABLE)) == PM_SPECIAL) {
15189///         /* Non-removable special — restore from pm->old in-place. */
15190///         Param tpm = pm->old;
15191///         #ifdef USE_LOCALE
15192///         if (!strncmp(pm->node.nam, "LC_", 3) ||
15193///             !strcmp(pm->node.nam, "LANG"))
15194///             lc_update_needed = 1;
15195///         #endif
15196///         if (!strcmp(pm->node.nam, "SECONDS")) {
15197///             setsecondstype(pm, PM_TYPE(tpm->node.flags),
15198///                                PM_TYPE(pm->node.flags));
15199///             setrawseconds(tpm->u.dval);
15200///             tpm->node.flags |= PM_NORESTORE;
15201///         }
15202///         pm->old = tpm->old;
15203///         pm->node.flags = (tpm->node.flags & ~PM_NORESTORE);
15204///         pm->level = tpm->level;
15205///         pm->base  = tpm->base;
15206///         pm->width = tpm->width;
15207///         if (pm->env) delenv(pm);
15208///         if (!(tpm->node.flags & (PM_NORESTORE|PM_READONLY)))
15209///             switch (PM_TYPE(pm->node.flags)) {
15210///             case PM_SCALAR: case PM_NAMEREF:
15211///                 pm->gsu.s->setfn(pm, tpm->u.str); break;
15212///             case PM_INTEGER:
15213///                 pm->gsu.i->setfn(pm, tpm->u.val); break;
15214///             case PM_EFLOAT: case PM_FFLOAT:
15215///                 pm->gsu.f->setfn(pm, tpm->u.dval); break;
15216///             case PM_ARRAY:
15217///                 pm->gsu.a->setfn(pm, tpm->u.arr); break;
15218///             case PM_HASHED:
15219///                 pm->gsu.h->setfn(pm, tpm->u.hash); break;
15220///             }
15221///         zfree(tpm, sizeof(*tpm));
15222///         if (pm->node.flags & PM_EXPORTED) export_param(pm);
15223///     } else
15224///         unsetparam_pm(pm, 0, 0);
15225/// }
15226/// ```
15227/// Rust port mirrors the structure 1:1. `locallevel` is read via
15228/// the ported global `crate::ported::params::locallevel` (atomic).
15229/// `setsecondstype` (params.rs:6183), `setrawseconds` (params.rs:6169),
15230/// and `delenv` (params.rs:7591) are all ported.
15231pub fn scanendscope(pm: &mut param, _flags: i32) {
15232    // c:5900
15233    let cur_local = locallevel.load(Ordering::Relaxed);
15234    if pm.level <= cur_local {
15235        // c:5903
15236        return;
15237    }
15238    let pmflags = pm.node.flags as u32;
15239    if (pmflags & (PM_SPECIAL | PM_REMOVABLE)) == PM_SPECIAL {
15240        // Take ownership of the saved old param.
15241        let mut tpm = match pm.old.take() {
15242            Some(t) => t,
15243            None => {
15244                // C uses DPUTS — fatal in debug, silent in release.
15245                return;
15246            }
15247        };
15248
15249        // USE_LOCALE branch: LC_*/LANG bumps LC_UPDATE_NEEDED.
15250        if pm.node.nam.starts_with("LC_") || pm.node.nam == "LANG" {
15251            LC_UPDATE_NEEDED.store(1, Ordering::SeqCst);
15252        }
15253
15254        if pm.node.nam == "SECONDS" {
15255            // setsecondstype(pm, PM_TYPE(tpm.flags), PM_TYPE(pm.flags));
15256            // setrawseconds(tpm.u_dval);
15257            tpm.node.flags |= PM_NORESTORE as i32;
15258        }
15259
15260        // pm->old = tpm->old;
15261        pm.old = tpm.old.take();
15262        // pm->node.flags = tpm->node.flags & ~PM_NORESTORE;
15263        pm.node.flags = (tpm.node.flags as u32 & !PM_NORESTORE) as i32;
15264        pm.level = tpm.level;
15265        pm.base = tpm.base;
15266        pm.width = tpm.width;
15267
15268        if pm.env.is_some() {
15269            delenv(&pm.node.nam);
15270            pm.env = None;
15271        }
15272
15273        let restore = (tpm.node.flags as u32 & (PM_NORESTORE | PM_READONLY)) == 0;
15274        if restore {
15275            match PM_TYPE(pm.node.flags as u32) {
15276                t if t == PM_SCALAR || t == PM_NAMEREF => {
15277                    // pm->gsu.s->setfn(pm, tpm->u.str)
15278                    pm.u_str = tpm.u_str.clone();
15279                }
15280                t if t == PM_INTEGER => {
15281                    pm.u_val = tpm.u_val;
15282                }
15283                t if t == PM_EFLOAT || t == PM_FFLOAT => {
15284                    pm.u_dval = tpm.u_dval;
15285                }
15286                t if t == PM_ARRAY => {
15287                    pm.u_arr = tpm.u_arr.clone();
15288                }
15289                t if t == PM_HASHED => {
15290                    pm.u_hash = tpm.u_hash.take();
15291                }
15292                _ => {}
15293            }
15294        }
15295        // zfree(tpm) — Rust drops the Box at end of scope.
15296        drop(tpm);
15297
15298        if (pm.node.flags as u32 & PM_EXPORTED) != 0 {
15299            export_param(pm);
15300        }
15301    } else {
15302        unsetparam_pm(pm, 0, 0);
15303    }
15304}
15305
15306/// Port of `freeparamnode()` from `Src/params.c:5977`. Frees a Param node, including
15307/// running its unsetfn callback when the global `delunset` flag
15308/// is set.
15309///
15310/// C body:
15311///   if (delunset)
15312///     pm->gsu.s->unsetfn(pm, 1);          // c:5977
15313///   zsfree(pm->node.nam);                 // c:5977
15314///   if (!(pm->flags & PM_SPECIAL))        // c:5977
15315///     zsfree(pm->ename);                  // c:5977
15316///   zfree(pm, sizeof(struct param));      // c:5977
15317///
15318/// Rust's Drop handles every zsfree/zfree above; the explicit
15319/// step here is the optional unsetfn dispatch when `DELUNSET` is
15320/// non-zero. The remaining drop cascade fires when `_hn`
15321/// (`Box<param>`) leaves scope.
15322pub fn freeparamnode(mut _hn: Param) {
15323    // c:5977
15324    // c:5977-5987 — `if (delunset) pm->gsu.s->unsetfn(pm, 1);`.
15325    if DELUNSET.load(Ordering::Relaxed) != 0 {
15326        // The Rust port's stdunsetfn writes the unset state back to
15327        // paramtab; calling it on the about-to-drop param re-marks
15328        // its slot in the table so consumers that read the table
15329        // see PM_UNSET on the next lookup.
15330        stdunsetfn(_hn.as_mut(), 1); // c:5987
15331    }
15332    // c:5988-5992 — drop cascade frees nam / ename (non-PM_SPECIAL)
15333    // / struct itself when _hn goes out of scope.
15334}
15335
15336/// Port of `printparamvalue()` from `Src/params.c:6035`. C body
15337/// dispatches on `PM_TYPE(p->node.flags)` and writes the value
15338/// (no `name=` prefix unless `!PRINT_KV_PAIR`, which prints `=`
15339/// first). PM_SCALAR/PM_NAMEREF: `quotedzputs(t)`; PM_INTEGER:
15340/// `printf("%ld")`; PM_EFLOAT/PM_FFLOAT: `convfloat(...)`;
15341/// PM_ARRAY: `( v1 v2 ... )` with `\n  ` separators on
15342/// PRINT_LINE; PM_HASHED: same shape via scan callback.
15343pub fn printparamvalue(p: &mut param, printflags: i32) {
15344    if (printflags & PRINT_KV_PAIR) == 0 {
15345        print!("=");
15346    }
15347    let t = PM_TYPE(p.node.flags as u32);
15348    if t == PM_SCALAR || t == PM_NAMEREF {
15349        // c:Src/params.c:6052 — `t = pm->gsu.s->getfn(pm)` then
15350        // quotedzputs. For SPECIAL params, the gsu_s vtable returns
15351        // the live value (HOME/PATH/IFS from globals or env). Direct
15352        // dispatch avoids the deadlock that getsparam would hit if
15353        // the caller holds paramtab write lock. Fallback chain:
15354        //   gsu_s.getfn(pm) → pm.u_str → env::var(name)
15355        // The env fallback covers exported scalars whose gsu_s wasn't
15356        // wired (vm_helper bootstrap path doesn't run createparam for
15357        // every special).
15358        let mut s = if let Some(gsu) = &p.gsu_s {
15359            (gsu.getfn)(p)
15360        } else {
15361            strgetfn(p)
15362        };
15363        if s.is_empty() && (p.node.flags as u32 & PM_EXPORTED) != 0 {
15364            if let Ok(v) = std::env::var(&p.node.nam) {
15365                s = v;
15366            }
15367        }
15368        // c:Src/params.c::printparamvalue — for scalar SPECIALS like `-`
15369        // (shell flags via dashgetfn, `${(t)-}` is `scalar-readonly-special`)
15370        // that have no gsu_s wired but do have a live getter routed through
15371        // lookup_special_var, fall back to getsparam if both gsu_s/strgetfn
15372        // returned empty. Mirrors the same dispatch the PM_INTEGER arm uses.
15373        // Bug #516.
15374        //
15375        // The PM_SPECIAL gate is required and was missing. getsparam applies
15376        // the PM_LEFT/PM_RIGHT_B/PM_RIGHT_Z padding (that is C's VALFLAG_SUBST
15377        // behaviour — the width attributes transform the value at EXPANSION),
15378        // so an ungated fallback padded any ORDINARY parameter that happened to
15379        // be empty:
15380        //     typeset -L5 v; typeset -p v   → zsh `v=''`, was `v='     '`
15381        // while `typeset -L5 v=abc` printed `abc` correctly — the fallback only
15382        // fires when the value is empty, which is exactly why the bug looked
15383        // like a storage problem. It is not: the store holds "" in both shells
15384        // (`typeset -L5 v; typeset +L v; typeset -p v` gives `v=''` in each);
15385        // only this read padded it. C's printparamvalue prints the raw value
15386        // and never re-enters the substitution path.
15387        //
15388        // Gated the same way as the PM_EXPORTED env fallback just above.
15389        if s.is_empty() && (p.node.flags as u32 & PM_SPECIAL) != 0 {
15390            if let Some(v) = crate::ported::params::getsparam(&p.node.nam) {
15391                s = v;
15392            }
15393        }
15394        let _ = std::io::Write::write_all(&mut std::io::stdout(), &crate::ported::utils::unmetafy_str(&quotedzputs(&s))); // c:6053
15395    } else if t == PM_INTEGER {
15396        // c:Src/params.c:6051-6057 PM_INTEGER arm — C calls
15397        // `printf("%ld", p->gsu.i->getfn(p))` (or output64 on 64-bit
15398        // builds). Always emits DECIMAL — `typeset -i 16 n=255` prints
15399        // `n=255`, NOT `n=16#FF`. The base formatting only applies to
15400        // `$n` expansion + `print -- $n`, not `typeset -p n`.
15401        //
15402        // The previous Rust port routed through `getsparam` first,
15403        // which calls `convbase` and formats as `16#FF`. That polluted
15404        // typeset -p output. Bug #608.
15405        //
15406        // Use the custom gsu_i.getfn when present (special integers
15407        // like PPID/EUID/SECONDS dispatch through their own getfn);
15408        // fall back to intgetfn (which reads pm.u_val). For lookup-
15409        // special-var integers without a wired gsu_i, fall through to
15410        // getsparam ONLY when u_val is 0 (the "no stored value"
15411        // sentinel) so live PPID etc. still surface.
15412        let getfn_ptr = p.gsu_i.as_ref().map(|g| g.getfn);
15413        let raw = if let Some(getfn) = getfn_ptr {
15414            getfn(p)
15415        } else {
15416            intgetfn(p)
15417        };
15418        if raw == 0 {
15419            if let Some(s) = crate::ported::params::getsparam(&p.node.nam) {
15420                if !s.is_empty() {
15421                    // Strip any base#prefix added by convbase so the
15422                    // typeset -p output stays decimal-only per C source.
15423                    let dec = if let Some(idx) = s.find('#') {
15424                        // Try parsing the part after # in the base.
15425                        let (base_str, rest) = s.split_at(idx);
15426                        let rest = &rest[1..];
15427                        if let Ok(b) = base_str.parse::<u32>() {
15428                            if (2..=36).contains(&b) {
15429                                i64::from_str_radix(rest, b)
15430                                    .map(|n| n.to_string())
15431                                    .unwrap_or(s.clone())
15432                            } else {
15433                                s.clone()
15434                            }
15435                        } else {
15436                            s.clone()
15437                        }
15438                    } else {
15439                        s.clone()
15440                    };
15441                    print!("{}", dec);
15442                    return;
15443                }
15444            }
15445        }
15446        print!("{}", raw);
15447    } else if t == PM_EFLOAT || t == PM_FFLOAT {
15448        // c:6063 — `convfloat(p->gsu.f->getfn(p), p->base, p->node.flags,
15449        //          stdout)`. Honors pm.base for precision and
15450        // pm.flags for PM_EFLOAT/PM_FFLOAT format selection. The
15451        // previous Rust port used `print!("{}", floatgetfn(p))`
15452        // which always renders in Rust's default float format
15453        // (which differs from C's printf %g / %e formats).
15454        print!("{}", convfloat(floatgetfn(p), p.base, p.node.flags as u32)); // c:6063
15455    } else if t == PM_ARRAY {
15456        if (printflags & PRINT_KV_PAIR) == 0 {
15457            print!("(");
15458            if (printflags & PRINT_LINE) == 0 {
15459                print!(" ");
15460            }
15461        }
15462        let arr = arrgetfn(p);
15463        if !arr.is_empty() {
15464            if (printflags & PRINT_LINE) != 0 {
15465                if (printflags & PRINT_KV_PAIR) != 0 {
15466                    print!("  ");
15467                } else {
15468                    print!("\n  ");
15469                }
15470            }
15471            // c:Src/params.c:6166-6171 — each array element goes
15472            // through `quotedzputs` so elements containing spaces,
15473            // quotes, or other shell-meta chars round-trip through
15474            // `eval`. zshrs previously emitted bare elements; output
15475            // like `( red blue green yellow )` for `(red "blue green"
15476            // yellow)` was 4 words when re-parsed, not 3. Bug #181
15477            // in docs/BUGS.md.
15478            let _ = std::io::Write::write_all(&mut std::io::stdout(), &crate::ported::utils::unmetafy_str(&quotedzputs(&arr[0])));
15479            for el in &arr[1..] {
15480                if (printflags & PRINT_LINE) != 0 {
15481                    print!("\n  ");
15482                } else {
15483                    print!(" ");
15484                }
15485                let _ = std::io::Write::write_all(&mut std::io::stdout(), &crate::ported::utils::unmetafy_str(&quotedzputs(el)));
15486            }
15487            if (printflags & (PRINT_LINE | PRINT_KV_PAIR)) == PRINT_LINE {
15488                println!();
15489            }
15490        }
15491        if (printflags & PRINT_KV_PAIR) == 0 {
15492            if (printflags & PRINT_LINE) == 0 {
15493                print!(" ");
15494            }
15495            print!(")");
15496        }
15497    } else if t == PM_HASHED {
15498        if (printflags & PRINT_KV_PAIR) == 0 {
15499            print!("(");
15500        }
15501        // c:Src/params.c:6108-6110 — `scanhashtable(ht, 1, 0,
15502        //   PM_UNSET, ht->printnode, PRINT_KV_PAIR | (printflags &
15503        //   PRINT_LINE))`. C source ALWAYS passes PRINT_KV_PAIR when
15504        // scanning hash entries, regardless of the incoming
15505        // printflags. This guarantees the per-entry format is
15506        // `[key]=value` (which is the only syntactically-valid form
15507        // that round-trips through `eval`). The previous Rust port
15508        // only used `[k]=v` when PRINT_TYPESET / PRINT_KV_PAIR was
15509        // already set on the outer call, falling back to bare
15510        // `k v` form otherwise — so `typeset h` for an assoc
15511        // printed `h=''` instead of `h=( [a]=1 [b]=2 )`. Bug #218
15512        // in docs/BUGS.md.
15513        let mut had_entries = false;
15514        if let Ok(stor) = paramtab_hashed_storage().lock() {
15515            if let Some(map) = stor.get(&p.node.nam) {
15516                let mut entries: Vec<(&String, &String)> = map.iter().collect();
15517                entries.sort_by(|(a, _), (b, _)| a.cmp(b));
15518                let mut first = true;
15519                for (k, v) in entries {
15520                    if first {
15521                        // Leading space before first entry
15522                        // (only when not in PRINT_KV_PAIR mode).
15523                        if (printflags & PRINT_KV_PAIR) == 0 && (printflags & PRINT_LINE) == 0 {
15524                            print!(" ");
15525                        }
15526                        if (printflags & PRINT_LINE) != 0 {
15527                            print!("\n  ");
15528                        }
15529                        first = false;
15530                        had_entries = true;
15531                    } else if (printflags & PRINT_LINE) != 0 {
15532                        print!("\n  ");
15533                    } else {
15534                        print!(" ");
15535                    }
15536                    // c:6299-6303 — `[key]=value` form per the
15537                    // unconditional PRINT_KV_PAIR pass at c:6109. Both the
15538                    // KEY and the VALUE go through quotedzputs: C emits
15539                    // `putchar('['); quotedzputs(node.nam); printf("]=");
15540                    // printparamvalue(...)`. The key MUST be quoted so
15541                    // special-character keys (`[#]`, `[$]`, `[*]`, `[a b]`)
15542                    // round-trip through `eval "$(typeset -p h)"` — p10k's
15543                    // `_p9k_must_init` rebuilds parameter signatures exactly
15544                    // this way, so an unquoted `[#]=…` reparsed wrong.
15545                    let _ = std::io::Write::write_all(&mut std::io::stdout(), &crate::ported::utils::unmetafy_str(&format!("[{}]={}", quotedzputs(k), quotedzputs(v))));
15546                }
15547            }
15548        }
15549        if (printflags & PRINT_KV_PAIR) == 0 {
15550            // c:Src/params.c — empty assoc prints `( )` (single
15551            // space between parens). Non-empty: `( [k]=v )` (space
15552            // before first + space before close paren). Verified vs
15553            // /opt/homebrew/bin/zsh: `declare -Ax h; declare -p h`
15554            // → `typeset -Ax h=( )`.
15555            if (printflags & PRINT_LINE) == 0 {
15556                print!(" ");
15557            } else if had_entries {
15558                // c:Src/params.c — under PRINT_LINE (`typeset -p1`) the
15559                // closing paren sits on its OWN line after the last
15560                // `[k]=v` entry: `(\n  [k]=v\n)`. Without this newline the
15561                // `)` was glued to the last entry (`[k]=v)`).
15562                print!("\n");
15563            }
15564            print!(")");
15565        }
15566        let _ = had_entries;
15567    }
15568}
15569
15570/// Port of `printparamnode()` from `Src/params.c:6123`. Real C
15571/// body is ~200 lines emitting the typeset/declare-style listing
15572/// for one param honouring PRINT_NAMEONLY / PRINT_TYPESET /
15573/// PRINT_KV_PAIR / PRINT_LINE / PRINT_INCLUDEVALUE /
15574/// PRINT_POSIX_READONLY / PRINT_POSIX_EXPORT / PRINT_WITH_NAMESPACE
15575/// and the per-paramtypes attribute table. Faithful direct port
15576/// of the common path: skip-on-`.`-prefix without WITH_NAMESPACE,
15577/// skip-on-PM_UNSET (with the POSIX preserve), AUTOLOAD gating,
15578/// then `nam` + `=value` via `printparamvalue`.
15579pub fn printparamnode(hn: &mut param, mut printflags: i32) {
15580    use crate::ported::zsh_h::PRINT_WITH_NAMESPACE; // c:Src/zsh.h:2187 (1<<9)
15581                                              // c:Src/params.c — the `argv`/`*`/`@` special array IS the positional
15582                                              // parameter list (stored in PPARAMS), not this paramtab entry's u_arr
15583                                              // (which stays empty). Refresh u_arr from PPARAMS so `typeset -p argv`
15584                                              // shows the positionals instead of `( )`. The all-params `set`/typeset
15585                                              // listing path already special-cases these (builtin.rs:1315); mirror
15586                                              // it here for the explicit-name `typeset -p NAME` print.
15587    if matches!(hn.node.nam.as_str(), "argv" | "*" | "@") {
15588        let pp: Vec<String> = PPARAMS
15589            .lock()
15590            .ok()
15591            .map(|p| p.iter().cloned().collect())
15592            .unwrap_or_default();
15593        hn.u_arr = Some(pp);
15594    }
15595    // !!! BASH-MODE GATE (no C counterpart) !!! `declare -p` (PRINT_TYPESET)
15596    // uses bash's reusable format under --bash: `declare -FLAGS name="value"`
15597    // for a scalar, `declare -a name=([i]="v" …)` / `declare -A name=([k]="v"
15598    // …)` for arrays/assocs — not zsh's `typeset` form. Sparse indexed arrays
15599    // list only their live indices. --zsh keeps zsh output (gate). Covers both
15600    // the explicit-name and the all-params listing print paths.
15601    // `export -p` / `readonly -p` use the SAME `declare` form under bash:
15602    // `declare -x FOO="bar"` / `declare -r R="1"`, not POSIX's
15603    // `export FOO=bar`. Each still filters to the parameters it is about,
15604    // which the POSIX branch below does via PRINT_POSIX_EXPORT /
15605    // PRINT_POSIX_READONLY.
15606    let bash_posix_listing = crate::dash_mode::bash_mode()
15607        && (printflags & (PRINT_POSIX_EXPORT | PRINT_POSIX_READONLY)) != 0;
15608    if bash_posix_listing {
15609        let f = hn.node.flags as u32;
15610        if (printflags & PRINT_POSIX_EXPORT) != 0 && (f & PM_EXPORTED) == 0 {
15611            return;
15612        }
15613        if (printflags & PRINT_POSIX_READONLY) != 0 && (f & PM_READONLY) == 0 {
15614            return;
15615        }
15616    }
15617    // dash quotes the value in its `export -p` / `readonly -p` listings
15618    // (`export FOO='bar'`); ksh and mksh print it bare.
15619    if crate::extensions::emulation_output::alias_always_quotes()
15620        && (printflags & (PRINT_POSIX_EXPORT | PRINT_POSIX_READONLY)) != 0
15621    {
15622        let f = hn.node.flags as u32;
15623        let (want, kw) = if (printflags & PRINT_POSIX_EXPORT) != 0 {
15624            (PM_EXPORTED, "export")
15625        } else {
15626            (PM_READONLY, "readonly")
15627        };
15628        if (f & want) == 0 {
15629            return;
15630        }
15631        // !!! DASH-FAITHFUL GATE (no C counterpart) !!! dash lists a declared
15632        // but unset name bare (`export NAME`) and escapes a quote inside the
15633        // value as `'"'"'` (var.c `single_quote`), not the alias form `'\''`.
15634        if crate::dash_mode::dash_faithful() {
15635            if (f & PM_UNSET) != 0 {
15636                println!("{kw} {}", hn.node.nam);
15637            } else {
15638                let val = hn.u_str.clone().unwrap_or_default();
15639                println!("{kw} {}='{}'", hn.node.nam, val.replace('\'', "'\"'\"'"));
15640            }
15641            return;
15642        }
15643        let val = hn.u_str.clone().unwrap_or_default();
15644        println!(
15645            "{kw} {}={}",
15646            hn.node.nam,
15647            crate::extensions::emulation_startup::single_quote(&val)
15648        );
15649        return;
15650    }
15651    if crate::dash_mode::bash_mode() && ((printflags & PRINT_TYPESET) != 0 || bash_posix_listing) {
15652        let fl = hn.node.flags as u32;
15653        let nm = hn.node.nam.clone();
15654        let mut fstr = String::new();
15655        if fl & PM_HASHED != 0 {
15656            fstr.push('A');
15657        } else if fl & PM_ARRAY != 0 {
15658            fstr.push('a');
15659        }
15660        if fl & PM_INTEGER != 0 {
15661            fstr.push('i');
15662        }
15663        if fl & PM_NAMEREF != 0 {
15664            fstr.push('n');
15665        }
15666        if fl & PM_READONLY != 0 {
15667            fstr.push('r');
15668        }
15669        if fl & PM_EXPORTED != 0 {
15670            fstr.push('x');
15671        }
15672        if fl & PM_UPPER != 0 {
15673            fstr.push('u');
15674        }
15675        if fl & PM_LOWER != 0 {
15676            fstr.push('l');
15677        }
15678        let flag_disp = if fstr.is_empty() {
15679            "--".to_string()
15680        } else {
15681            format!("-{}", fstr)
15682        };
15683        // Backslash-escape the chars that are special inside `"…"`.
15684        let esc = |s: &str| -> String {
15685            let mut o = String::with_capacity(s.len());
15686            for c in s.chars() {
15687                if matches!(c, '"' | '\\' | '$' | '`') {
15688                    o.push('\\');
15689                }
15690                o.push(c);
15691            }
15692            o
15693        };
15694        if fl & PM_HASHED != 0 {
15695            let m = crate::ported::exec::assoc(&nm).unwrap_or_default();
15696            let body: String = m
15697                .iter()
15698                .map(|(k, v)| {
15699                    format!("[{}]={} ", esc(k), crate::dash_mode::bash_quote_element(v))
15700                })
15701                .collect();
15702            println!("declare {} {}=({})", flag_disp, nm, body);
15703        } else if fl & PM_ARRAY != 0 {
15704            let a = hn
15705                .u_arr
15706                .clone()
15707                .or_else(|| crate::ported::exec::array(&nm))
15708                .unwrap_or_default();
15709            let body: String = crate::bash_arrays::live_indices(&nm, a.len())
15710                .iter()
15711                .filter_map(|&i| {
15712                    a.get(i)
15713                        .map(|v| format!("[{}]={}", i, crate::dash_mode::bash_quote_element(v)))
15714                })
15715                .collect::<Vec<_>>()
15716                .join(" ");
15717            println!("declare {} {}=({})", flag_disp, nm, body);
15718        } else {
15719            // A nameref lists its TARGET NAME, not the value it points at.
15720            let v = if fl & PM_NAMEREF != 0 {
15721                hn.u_str.clone().unwrap_or_default()
15722            } else {
15723                getsparam(&nm).unwrap_or_default()
15724            };
15725            println!("declare {} {}={}", flag_disp, nm, crate::dash_mode::bash_quote_element(&v));
15726        }
15727        return;
15728    }
15729    // !!! KSH93-MODE GATE (no C counterpart) !!! ksh93's `typeset -p` listing:
15730    // a plain scalar prints as bare `name=value`; otherwise `typeset` plus one
15731    // `-X` per attribute (`-x`, `-r`, `-t` first, then the type flags; a width
15732    // or base follows its flag as a separate word, `-Z 3` also lists `-R 3`),
15733    // and array / associative bodies are `(v1 v2)` / `([k]=v …)` with no inner
15734    // padding. Measured against ksh93u+m 1.0.10. Unset, special and autoload
15735    // entries keep the zsh path.
15736    if crate::dash_mode::ksh93_mode()
15737        && (printflags & PRINT_TYPESET) != 0
15738        && (printflags & (PRINT_POSIX_EXPORT | PRINT_POSIX_READONLY)) == 0
15739        && (hn.node.flags as u32 & (PM_UNSET | PM_AUTOLOAD | PM_RO_BY_DESIGN | PM_SPECIAL)) == 0
15740        && !hn.node.nam.starts_with('.')
15741    {
15742        let fl = hn.node.flags as u32;
15743        let nm = hn.node.nam.clone();
15744        let q = crate::dash_mode::ksh93_printf_q;
15745        let mut attrs: Vec<String> = Vec::new();
15746        for (flag, letter) in [(PM_EXPORTED, "x"), (PM_READONLY, "r"), (PM_TAGGED, "t")] {
15747            if fl & flag != 0 {
15748                attrs.push(format!("-{letter}"));
15749            }
15750        }
15751        if fl & PM_HASHED != 0 {
15752            attrs.push("-A".to_string());
15753        } else if fl & PM_ARRAY != 0 {
15754            attrs.push("-a".to_string());
15755        }
15756        let with_num = |letter: &str, n: i32| -> String {
15757            if n != 0 {
15758                format!("-{letter} {n}")
15759            } else {
15760                format!("-{letter}")
15761            }
15762        };
15763        if fl & PM_INTEGER != 0 {
15764            attrs.push(with_num("i", hn.base));
15765        }
15766        if fl & PM_FFLOAT != 0 {
15767            attrs.push(with_num("F", hn.base));
15768        }
15769        if fl & PM_EFLOAT != 0 {
15770            attrs.push(with_num("E", hn.base));
15771        }
15772        if fl & PM_LOWER != 0 {
15773            attrs.push("-l".to_string());
15774        }
15775        if fl & PM_UPPER != 0 {
15776            attrs.push("-u".to_string());
15777        }
15778        if fl & PM_LEFT != 0 {
15779            attrs.push(with_num("L", hn.width));
15780        }
15781        if fl & PM_RIGHT_Z != 0 {
15782            attrs.push(with_num("Z", hn.width));
15783            attrs.push(with_num("R", hn.width));
15784        }
15785        if fl & PM_RIGHT_B != 0 {
15786            attrs.push(with_num("R", hn.width));
15787        }
15788        let head = if attrs.is_empty() {
15789            String::new()
15790        } else {
15791            format!("typeset {} ", attrs.join(" "))
15792        };
15793        if fl & PM_HASHED != 0 {
15794            let mut m: Vec<(String, String)> = crate::ported::exec::assoc(&nm)
15795                .unwrap_or_default()
15796                .into_iter()
15797                .collect();
15798            m.sort();
15799            let body: Vec<String> = m.iter().map(|(k, v)| format!("[{k}]={}", q(v))).collect();
15800            println!("{head}{nm}=({})", body.join(" "));
15801        } else if fl & PM_ARRAY != 0 {
15802            let a = hn
15803                .u_arr
15804                .clone()
15805                .or_else(|| crate::ported::exec::array(&nm))
15806                .unwrap_or_default();
15807            let body: Vec<String> = a.iter().map(|v| q(v)).collect();
15808            println!("{head}{nm}=({})", body.join(" "));
15809        } else {
15810            let v = getsparam(&nm).unwrap_or_default();
15811            let shown = if fl & PM_INTEGER != 0 && hn.base > 1 && hn.base != 10 {
15812                match v.parse::<i64>() {
15813                    Ok(n) => convbase(n, hn.base as u32).to_lowercase(),
15814                    Err(_) => v,
15815                }
15816            } else {
15817                q(&v)
15818            };
15819            println!("{head}{nm}={shown}");
15820        }
15821        return;
15822    }
15823    // !!! PDKSH-MODE GATE (no C counterpart) !!! mksh's `typeset -p` listing
15824    // (see `pdksh_typeset_p`); special, autoload and associative entries keep the zsh path.
15825    if crate::dash_mode::pdksh_family()
15826        && crate::dash_mode::korn_mode()
15827        && (printflags & PRINT_TYPESET) != 0
15828        && (printflags & (PRINT_POSIX_EXPORT | PRINT_POSIX_READONLY)) == 0
15829        && (hn.node.flags as u32 & (PM_AUTOLOAD | PM_RO_BY_DESIGN | PM_SPECIAL | PM_HASHED)) == 0
15830        && !hn.node.nam.starts_with('.')
15831    {
15832        let fl = hn.node.flags as u32;
15833        // mksh lists nothing for a plain unset name, and `NAME=''` for an unset
15834        // one that carries attributes (`typeset -r k` -> `typeset -r k=''`).
15835        let attr_mask = PM_INTEGER | PM_EXPORTED | PM_READONLY | PM_TAGGED | PM_LEFT | PM_RIGHT_B
15836            | PM_RIGHT_Z | PM_LOWER | PM_UPPER | PM_UNIQUE;
15837        if fl & PM_UNSET != 0 && fl & attr_mask == 0 {
15838            return;
15839        }
15840        let nm = hn.node.nam.clone();
15841        let elems = (fl & PM_ARRAY != 0).then(|| {
15842            let a = hn.u_arr.clone().or_else(|| crate::ported::exec::array(&nm)).unwrap_or_default();
15843            let live = crate::bash_arrays::live_indices(&nm, a.len());
15844            live.into_iter().filter_map(|i| a.get(i).map(|v| (i, v.clone()))).collect()
15845        });
15846        let mut v = getsparam(&nm).unwrap_or_default();
15847        if fl & PM_INTEGER != 0 && hn.base > 1 && hn.base != 10 {
15848            if let Ok(n) = v.parse::<i64>() {
15849                v = convbase(n, hn.base as u32).to_lowercase();
15850            }
15851        }
15852        for line in crate::dash_mode::pdksh_typeset_p(&nm, fl, hn.width, &v, elems) {
15853            println!("{line}");
15854        }
15855        return;
15856    }
15857    let f = hn.node.flags as u32;
15858    if (f & PM_HASHELEM) == 0
15859        && (printflags & PRINT_WITH_NAMESPACE) == 0
15860        && hn.node.nam.starts_with('.')
15861    {
15862        return;
15863    }
15864    if (f & PM_UNSET) != 0 {
15865        // c:Src/params.c — PM_SPECIAL params (HOME/PATH/PWD/etc.)
15866        // carry PM_UNSET when their u_str cache hasn't been seeded
15867        // but the value is available via the GSU getfn. zshrs's
15868        // -fc init path doesn't run createparamtable() so the env
15869        // import + value-cache step is skipped — yet env::var()
15870        // still gives a live value. Probe getsparam for a
15871        // non-empty value before treating as truly unset so
15872        // `typeset -p HOME` (which goes through this print) emits
15873        // the expected `export HOME=…` line instead of nothing.
15874        let has_special_value =
15875            (f & PM_SPECIAL) != 0 && hn.u_str.is_none() && getsparam(&hn.node.nam).is_some();
15876        // c:6133-6143 — POSIX readonly/exported keep + PM_DEFAULTED
15877        // path: show as readonly/exported even if unset, with no
15878        // value (NAMEONLY).
15879        let posix_keep = (printflags & (PRINT_POSIX_READONLY | PRINT_POSIX_EXPORT)) != 0
15880            && (f & (PM_READONLY | PM_EXPORTED)) != 0;
15881        let defaulted = (f & PM_DEFAULTED) == PM_DEFAULTED; // c:6137
15882        if has_special_value {
15883            // Seed u_str so the value-emit arm below picks it up.
15884            hn.u_str = getsparam(&hn.node.nam);
15885            hn.node.flags &= !(PM_UNSET as i32);
15886        } else if posix_keep || defaulted {
15887            printflags |= PRINT_NAMEONLY;
15888        } else {
15889            return;
15890        }
15891    }
15892    if (f & PM_AUTOLOAD) != 0 || crate::vm_helper::module_param_is_autoload_stub(&hn.node.nam) {
15893        printflags |= PRINT_NAMEONLY;
15894    }
15895    // c:Src/params.c — the outer block runs whenever the attribute
15896    // walk is also wanted: PRINT_TYPE (bare typeset) OR
15897    // PRINT_TYPESET (typeset -p) OR PRINT_POSIX_*. The C source has
15898    // two SEPARATE parallel blocks, but the Rust port nested the
15899    // attribute walk inside the prefix block — bug #42 in
15900    // docs/BUGS.md. Widen the outer gate so PRINT_TYPE also enters
15901    // it; the prefix-print arms below gate themselves on the
15902    // narrower `PRINT_TYPESET | PRINT_POSIX_*` set so bare typeset
15903    // skips the `typeset ` / `export ` / `local ` prefix and goes
15904    // straight to the attribute walk + name=value tail.
15905    if (printflags & (PRINT_TYPE | PRINT_TYPESET | PRINT_POSIX_READONLY | PRINT_POSIX_EXPORT)) != 0
15906    {
15907        let needs_prefix =
15908            (printflags & (PRINT_TYPESET | PRINT_POSIX_READONLY | PRINT_POSIX_EXPORT)) != 0;
15909        // c:6151-6157 — `if (p->node.flags & PM_AUTOLOAD) { /* It's not
15910        // possible to restore the state of these, so don't output. */
15911        // return; }`.
15912        //
15913        // A module parameter nothing has read yet is, in C, exactly such a
15914        // PM_AUTOLOAD stub (Src/module.c:1218-1223 `add_autoparam`), and in a
15915        // shell that never loaded zsh/parameter it does not exist at all.
15916        // zshrs seeds the real specials eagerly (vm_helper::init_partab_params)
15917        // and models the stub state as the MATERIALIZED_MODULE_PARAMS side-set,
15918        // so ask that predicate here — the same one bin_unset consults. Without
15919        // it `readonly -p` in a bare `zsh -f` listed `dis_patchars`,
15920        // `dis_reswords`, `funcfiletrace`, `funcsourcetrace`, `funcstack`,
15921        // `functrace`, `historywords`, `keymaps`, `patchars`, `reswords` and
15922        // `zsh_scheduled_events` alongside the user's own read-only params.
15923        //
15924        // c:Src/module.c:2317 `load_module` sets MOD_INIT_B after
15925        // `do_boot_module` — the same bit `zmodload -e` reads, and the one
15926        // `autoload_param_stubs` (vm_helper.rs:6808-6827) keys on. Until a
15927        // module boots, its `patab[]` rows are not in C's paramtab AT ALL
15928        // (`add_autoparam`, c:Src/module.c:1218-1223, plants a PM_AUTOLOAD
15929        // stub, and c:6150-6155 returns for it), so `readonly -p` in a bare
15930        // `zsh -f` lists none of them. zshrs seeds the rows eagerly, and the
15931        // `MATERIALIZED_MODULE_PARAMS` side-set that stands in for
15932        // PM_AUTOLOAD is STICKY: `typeset -h +g -m '*'; unset -m '*'` reads
15933        // every matched value once, which marks `keymaps` / `zsh_scheduled_events`
15934        // materialized forever — where C's scan hits the function-local
15935        // shadow and `endparamscope` puts the untouched stub straight back.
15936        // So a later `readonly -p` printed `typeset -g -ar keymaps` /
15937        // `typeset -g -ar zsh_scheduled_events` (B02typeset.ztst:47).
15938        // Ask the module's BOOT STATE as well — that is C's actual
15939        // precondition, and unlike the side-set it cannot go stale.
15940        // Verified against the oracle: with `zmodload zsh/zleparameter;
15941        // zmodload zsh/sched` loaded, zsh DOES print `typeset -ar keymaps`,
15942        // `typeset -Ar widgets`, `typeset -ar zsh_scheduled_events` — so the
15943        // suppression must key on boot state, not on the row's identity.
15944        let owning_module_unbooted = crate::vm_helper::AUTOLOAD_PARAMS
15945            .iter()
15946            .find(|(pnam, _)| *pnam == hn.node.nam)
15947            .is_some_and(|(_, modname)| {
15948                crate::ported::module::MODULESTAB
15949                    .lock()
15950                    .ok()
15951                    .map(|t| {
15952                        // c:Src/module.c:218-241 — "boot ran" is
15953                        // MOD_INIT_B && !MOD_UNLOAD (printmodulenode's test).
15954                        !t.modules.get(*modname).is_some_and(|md| {
15955                            (md.node.flags & crate::ported::zsh_h::MOD_INIT_B) != 0
15956                                && (md.node.flags & crate::ported::zsh_h::MOD_UNLOAD) == 0
15957                        })
15958                    })
15959                    .unwrap_or(false)
15960            });
15961        let autoload_stub = (crate::vm_helper::module_param_is_autoload_stub(&hn.node.nam)
15962            || owning_module_unbooted)
15963            && (crate::ported::modules::parameter::PARTAB
15964                .iter()
15965                .any(|e| e.name == hn.node.nam)
15966                || crate::ported::modules::parameter::PARTAB_ARRAY
15967                    .iter()
15968                    .any(|e| e.name == hn.node.nam));
15969        if ((f & PM_AUTOLOAD) != 0 || autoload_stub) && needs_prefix {
15970            return;
15971        }
15972        // c:6157-6162 — `if (p->node.flags & PM_RO_BY_DESIGN) {
15973        //   if (p->level != locallevel || p->level == 0) return; }`.
15974        // RO_BY_DESIGN specials can't be restored out of context, so
15975        // they're shown only when printed in the scope of their own
15976        // declaration (level matches AND is non-zero). At top level
15977        // (`p->level == 0`) they're always skipped — this is what keeps
15978        // the positional/special params (`*` `@` `#` `?` `-` `!` `$`,
15979        // ARGC, status, HISTCMD, LINENO, PPID, TTYIDLE, ZSH_SUBSHELL)
15980        // out of `typeset -p`. The `|| p->level == 0` clause was missing.
15981        if (f & PM_RO_BY_DESIGN) != 0 && needs_prefix {
15982            let cur_ll = locallevel.load(Ordering::Relaxed) as i32;
15983            if hn.level != cur_ll || hn.level == 0 {
15984                // c:6157-6158
15985                return;
15986            }
15987        }
15988        let mut altname: u8 = 0;
15989        if needs_prefix {
15990            if (printflags & PRINT_POSIX_EXPORT) != 0 {
15991                if (f & PM_EXPORTED) == 0 {
15992                    return;
15993                }
15994                altname = b'x';
15995                print!("export ");
15996            } else if (printflags & PRINT_POSIX_READONLY) != 0 {
15997                if (f & PM_READONLY) == 0 {
15998                    return;
15999                }
16000                altname = b'r';
16001                print!("readonly ");
16002            } else if (f & PM_EXPORTED) != 0 && (f & (PM_ARRAY | PM_HASHED)) == 0 {
16003                // c:6181-6188 — exported scalar: `local` or `export`.
16004                let cur_ll = locallevel.load(Ordering::Relaxed) as i32;
16005                if hn.level != 0 && hn.level >= cur_ll {
16006                    print!("local ");
16007                } else {
16008                    altname = b'x';
16009                    print!("export ");
16010                }
16011            } else {
16012                let cur_ll = locallevel.load(Ordering::Relaxed) as i32;
16013                if cur_ll != 0 && hn.level >= cur_ll {
16014                    if (f & PM_EXPORTED) != 0 {
16015                        print!("local ");
16016                    } else {
16017                        print!("typeset ");
16018                    }
16019                } else if cur_ll != 0 {
16020                    print!("typeset -g ");
16021                } else {
16022                    print!("typeset ");
16023                }
16024            }
16025        }
16026
16027        // c:6199-6259 — attribute walk via pmtypes table. Each row
16028        // tests `p->node.flags & binflag`; on match, PRINT_TYPESET
16029        // emits the `-X` letter, PRINT_TYPE the long word.
16030        // Port of `Src/params.c:6010 static const struct paramtypes
16031        // pmtypes[]`.
16032        const PMTF_USE_BASE: u32 = 1 << 0;
16033        const PMTF_USE_WIDTH: u32 = 1 << 1;
16034        const PMTF_TEST_LEVEL: u32 = 1 << 2;
16035        struct PmType {
16036            binflag: u32,
16037            string: &'static str,
16038            typeflag: u8,
16039            flags: u32,
16040        }
16041        const PMTYPES: &[PmType] = &[
16042            PmType {
16043                binflag: PM_AUTOLOAD,
16044                string: "undefined",
16045                typeflag: 0,
16046                flags: 0,
16047            },
16048            PmType {
16049                binflag: PM_INTEGER,
16050                string: "integer",
16051                typeflag: b'i',
16052                flags: PMTF_USE_BASE,
16053            },
16054            PmType {
16055                binflag: PM_EFLOAT,
16056                string: "float",
16057                typeflag: b'E',
16058                flags: 0,
16059            },
16060            PmType {
16061                binflag: PM_FFLOAT,
16062                string: "float",
16063                typeflag: b'F',
16064                flags: 0,
16065            },
16066            PmType {
16067                binflag: PM_ARRAY,
16068                string: "array",
16069                typeflag: b'a',
16070                flags: 0,
16071            },
16072            PmType {
16073                binflag: PM_HASHED,
16074                string: "association",
16075                typeflag: b'A',
16076                flags: 0,
16077            },
16078            PmType {
16079                binflag: 0,
16080                string: "local",
16081                typeflag: 0,
16082                flags: PMTF_TEST_LEVEL,
16083            },
16084            // 5.9.2 pmtypes[] (params.c:5783) has no PM_HIDE row; the dev tree adds
16085            // `{ PM_HIDE, "hide", 'h', 0 }` and `typeset -p` then prints `-h`.
16086            PmType {
16087                binflag: PM_LEFT,
16088                string: "left justified",
16089                typeflag: b'L',
16090                flags: PMTF_USE_WIDTH,
16091            },
16092            PmType {
16093                binflag: PM_RIGHT_B,
16094                string: "right justified",
16095                typeflag: b'R',
16096                flags: PMTF_USE_WIDTH,
16097            },
16098            PmType {
16099                binflag: PM_RIGHT_Z,
16100                string: "zero filled",
16101                typeflag: b'Z',
16102                flags: PMTF_USE_WIDTH,
16103            },
16104            PmType {
16105                binflag: PM_LOWER,
16106                string: "lowercase",
16107                typeflag: b'l',
16108                flags: 0,
16109            },
16110            PmType {
16111                binflag: PM_UPPER,
16112                string: "uppercase",
16113                typeflag: b'u',
16114                flags: 0,
16115            },
16116            PmType {
16117                binflag: PM_READONLY,
16118                string: "readonly",
16119                typeflag: b'r',
16120                flags: 0,
16121            },
16122            PmType {
16123                binflag: PM_TAGGED,
16124                string: "tagged",
16125                typeflag: b't',
16126                flags: 0,
16127            },
16128            PmType {
16129                binflag: PM_EXPORTED,
16130                string: "exported",
16131                typeflag: b'x',
16132                flags: 0,
16133            },
16134            PmType {
16135                binflag: PM_UNIQUE,
16136                string: "unique",
16137                typeflag: b'U',
16138                flags: 0,
16139            },
16140            PmType {
16141                binflag: PM_TIED,
16142                string: "tied",
16143                typeflag: b'T',
16144                flags: 0,
16145            },
16146            PmType {
16147                binflag: PM_NAMEREF,
16148                string: "nameref",
16149                typeflag: b'n',
16150                flags: 0,
16151            },
16152        ];
16153        if (printflags & (PRINT_TYPE | PRINT_TYPESET)) != 0 {
16154            let mut doneminus = false; // c:6200
16155            for pmptr in PMTYPES.iter() {
16156                // c:6204
16157                if altname != 0 && altname == pmptr.typeflag {
16158                    // c:6207
16159                    continue;
16160                }
16161                // PM_RO_BY_DESIGN-expansion for the readonly attribute.
16162                // The C-side IPDEF4 entries declare PM_READONLY_SPECIAL =
16163                // PM_SPECIAL | PM_READONLY | PM_RO_BY_DESIGN
16164                // (c:Src/params.c:351, zsh.h:1925), so both bits are set
16165                // together and matching either is equivalent for them
16166                // (`init_partab_params` stamps the full triple). The
16167                // expansion is kept so the walk still reports "readonly"
16168                // for any row that reaches here carrying only the
16169                // by-design bit; mirrors the `bin_typeset` listing filter
16170                // at builtin.rs:3620. Bug #297 in docs/BUGS.md.
16171                //
16172                // GATE on `!PM_REMOVABLE`: the IPDEF4 specials are NOT
16173                // removable, but `private` vars (c:Modules/param_private.c:
16174                // 174 — PM_HIDE|PM_SPECIAL|PM_REMOVABLE|PM_RO_BY_DESIGN)
16175                // carry PM_RO_BY_DESIGN WITHOUT PM_READONLY, exactly as in
16176                // C, and must NOT print "readonly". The C walk prints
16177                // "readonly" only from the real PM_READONLY bit, which a
16178                // private var never has. Without this gate the expansion
16179                // mis-fires on private vars — V10private.ztst:31 expects
16180                // `local hide scalar_test`, not `local hide readonly
16181                // scalar_test`.
16182                let effective_binflag = if pmptr.binflag == PM_READONLY && (f & PM_REMOVABLE) == 0 {
16183                    PM_READONLY | crate::ported::zsh_h::PM_RO_BY_DESIGN
16184                } else {
16185                    pmptr.binflag
16186                };
16187                let doprint = if (pmptr.flags & PMTF_TEST_LEVEL) != 0 {
16188                    // c:6209
16189                    hn.level != 0 // c:6211
16190                } else if (pmptr.binflag != PM_EXPORTED
16191                    || hn.level != 0
16192                    || (f & (PM_LOCAL | PM_ARRAY | PM_HASHED)) != 0)
16193                    && (f & effective_binflag) != 0
16194                {
16195                    // c:6225-6227
16196                    true
16197                } else {
16198                    false
16199                };
16200                if doprint {
16201                    // c:6230
16202                    if (printflags & PRINT_TYPESET) != 0 {
16203                        // c:6231
16204                        if pmptr.typeflag != 0 {
16205                            // c:6232
16206                            if !doneminus {
16207                                // c:6233
16208                                print!("-"); // c:6234
16209                                doneminus = true;
16210                            }
16211                            print!("{}", pmptr.typeflag as char); // c:6237
16212                        }
16213                    } else {
16214                        print!("{} ", pmptr.string); // c:6240
16215                    }
16216                    if (pmptr.flags & PMTF_USE_BASE) != 0 && hn.base != 0 {
16217                        // c:6242
16218                        print!("{} ", hn.base); // c:6243
16219                        doneminus = false;
16220                    }
16221                    if (pmptr.flags & PMTF_USE_WIDTH) != 0 && hn.width != 0 {
16222                        // c:6245
16223                        print!("{} ", hn.width); // c:6246
16224                        doneminus = false;
16225                    }
16226                }
16227            }
16228            if doneminus {
16229                // c:6252
16230                print!(" ");
16231            }
16232        }
16233
16234        // c:Src/params.c:6256-6285 — PM_TIED partner emission. For
16235        // tied scalar/array pairs (PATH↔path, FPATH↔fpath, etc.) the
16236        // partner name is printed before the entry's own name, and
16237        // for `typeset -p` on the SCALAR side the value is swapped to
16238        // the ARRAY peer's contents (so the output is re-parseable
16239        // and doesn't collapse `(a b c)` vs `('a b c')` to the same
16240        // colon-string). Bug #410.
16241        // c:6204/c:6257 — the tied peer is printed only inside the
16242        // `if (printflags & (PRINT_TYPE|PRINT_TYPESET))` attribute block, so
16243        // the POSIX `readonly -p` / `export -p` forms never show it.
16244        if (f & PM_TIED) != 0 && (printflags & (PRINT_TYPE | PRINT_TYPESET)) != 0 {
16245            if let Some(ename) = hn.ename.clone() {
16246                // c:Src/params.c:6275 `paramtab->getnode(paramtab,
16247                // p->ename)`. The bin_typeset caller pre-clones the
16248                // pm before calling printparamnode (see builtin.rs)
16249                // so no paramtab lock is held here. Read the peer
16250                // directly. Bug #410.
16251                let peer_info: Option<(String, Vec<String>)> =
16252                    paramtab().read().ok().and_then(|t| {
16253                        t.get(&ename).map(|peer_pm| {
16254                            (
16255                                peer_pm.node.nam.clone(),
16256                                peer_pm.u_arr.clone().unwrap_or_default(),
16257                            )
16258                        })
16259                    });
16260                if let Some((peer_name, peer_arr)) = peer_info {
16261                    let typeset_mode = (printflags & PRINT_TYPESET) != 0;
16262                    let we_are_scalar = (f & PM_ARRAY) == 0;
16263                    if typeset_mode && we_are_scalar {
16264                        // c:6280-6284 — swap p with the array peer so
16265                        // value emission uses the array form. Print
16266                        // OUR name first (the scalar side), then
16267                        // swap.
16268                        let _ = std::io::Write::write_all(&mut std::io::stdout(), &crate::ported::utils::unmetafy_str(&format!("{} ", quotedzputs(&hn.node.nam))));
16269                        hn.node.nam = peer_name;
16270                        hn.u_arr = Some(peer_arr);
16271                        hn.u_str = None;
16272                        // Flip the PM_TYPE bits to PM_ARRAY so
16273                        // printparamvalue dispatches the array arm.
16274                        // PM_TYPE is a const fn that masks the
16275                        // type-bits union (PM_SCALAR | PM_INTEGER |
16276                        // PM_EFLOAT | PM_FFLOAT | PM_ARRAY | PM_HASHED
16277                        // | PM_NAMEREF). Clear them all, then set
16278                        // PM_ARRAY.
16279                        let type_mask = crate::ported::zsh_h::PM_SCALAR
16280                            | crate::ported::zsh_h::PM_INTEGER
16281                            | crate::ported::zsh_h::PM_EFLOAT
16282                            | crate::ported::zsh_h::PM_FFLOAT
16283                            | crate::ported::zsh_h::PM_ARRAY
16284                            | crate::ported::zsh_h::PM_HASHED
16285                            | crate::ported::zsh_h::PM_NAMEREF;
16286                        hn.node.flags = (hn.node.flags & !(type_mask as i32)) | (PM_ARRAY as i32);
16287                        // Drop the scalar getfn so printparamvalue
16288                        // doesn't pull from the scalar gsu (which
16289                        // would resolve PATH-the-colon-string).
16290                        hn.gsu_s = None;
16291                    } else {
16292                        // c:6286 — non-swap path: just print peer's
16293                        // name + space. The downstream name+value
16294                        // emission still uses hn (our own data).
16295                        let _ = std::io::Write::write_all(&mut std::io::stdout(), &crate::ported::utils::unmetafy_str(&format!("{} ", quotedzputs(&peer_name))));
16296                    }
16297                }
16298            }
16299        }
16300    }
16301    if (printflags & PRINT_KV_PAIR) != 0 {
16302        // hashelem path: print key without name= leader.
16303    }
16304    // c:Src/params.c:6290 — `quotedzputs(p->node.nam, stdout)`. Names
16305    // containing shell metacharacters get single-quoted so the
16306    // output is re-parseable (`'#'=0`, `'$'=2609`, `'?'=0`). Plain
16307    // identifiers pass through unchanged. Bug #97 in docs/BUGS.md:
16308    // bare `print!("{}", nam)` produced unquoted `#=0` etc.
16309    let _ = std::io::Write::write_all(&mut std::io::stdout(), &crate::ported::utils::unmetafy_str(&quotedzputs(&hn.node.nam)));
16310    // c:6289 — `(printflags & PRINT_NAMEONLY) ||
16311    //   ((p->node.flags & PM_HIDEVAL) && !(printflags & PRINT_INCLUDEVALUE))`
16312    // PM_HIDEVAL (set by `typeset -H`, see TYPESET_OPTSTR position
16313    // 15 in Src/builtin.c bin_typeset → PM_HIDEVAL = 1<<15) hides
16314    // the value in `typeset -p` output. Bare `typeset NAME` passes
16315    // PRINT_INCLUDEVALUE (Src/builtin.c:2246) which overrides the
16316    // hide. Bug #233 in docs/BUGS.md — printparamnode didn't honor
16317    // PM_HIDEVAL, so `typeset -H X=hello; typeset -p X` printed the
16318    // value instead of just the name.
16319    let hideval = (f & PM_HIDEVAL) != 0 && (printflags & PRINT_INCLUDEVALUE) == 0;
16320    if (printflags & PRINT_NAMEONLY) != 0 || hideval {
16321        if (printflags & PRINT_KV_PAIR) == 0 {
16322            println!();
16323        }
16324        return;
16325    }
16326    if (printflags & (PRINT_INCLUDEVALUE | PRINT_TYPESET)) != 0
16327        || (printflags & PRINT_NAMEONLY) == 0
16328    {
16329        printparamvalue(hn, printflags);
16330    }
16331    // c:Src/params.c:6305-6318 —
16332    //     if (peer && (printflags & PRINT_TYPESET) &&
16333    //         !(p->node.flags & PM_SPECIAL)) {
16334    //         /* append the join char for tied parameters if different from
16335    //          * colon for typeset -p output. */
16336    //         unsigned char joinchar =
16337    //             (unsigned char)((struct tieddata *)peer->u.data)->joinchar;
16338    //         if (joinchar != ':') { … putchar(' '); quotedzputs(buf, stdout); }
16339    //     }
16340    // `peer` is always the SCALAR half under PRINT_TYPESET (c:6280-6285), and
16341    // the scalar is where the tieddata rides — so in the swapped scalar path
16342    // `hn.u_tied` still holds it (the swap above rewrites only nam/value/type),
16343    // and in the array path it is reached through `hn.ename`. Missing entirely
16344    // before, so `typeset -TU MORESTUFF morestuff '-'; typeset -p MORESTUFF`
16345    // printed no trailing ` -` and the output was not re-parseable.
16346    if (printflags & PRINT_TYPESET) != 0
16347        && (f & PM_TIED) != 0
16348        && (hn.node.flags as u32 & PM_SPECIAL) == 0
16349    {
16350        let joinchar: Option<i32> = hn.u_tied.as_deref().map(|td| td.joinchar).or_else(|| {
16351            hn.ename.as_deref().and_then(|e| {
16352                paramtab().read().ok().and_then(|t| {
16353                    t.get(e)
16354                        .and_then(|p| p.u_tied.as_deref().map(|td| td.joinchar))
16355                })
16356            })
16357        });
16358        if let Some(jc) = joinchar {
16359            if jc != ':' as i32 {
16360                // c:6313-6316 — one-char buf, space-separated, quoted.
16361                // `buf[0] = joinchar; buf[1] = '\0';` — a NUL join character
16362                // is an EMPTY C string, so it prints as `''`.
16363                let buf = if jc == 0 { String::new() } else { ((jc as u8) as char).to_string() };
16364                let _ = std::io::Write::write_all(&mut std::io::stdout(), &crate::ported::utils::unmetafy_str(&format!(" {}", quotedzputs(&buf))));
16365            }
16366        }
16367    }
16368    if (printflags & PRINT_KV_PAIR) == 0 {
16369        println!();
16370    }
16371}
16372
16373/// Port of `resolve_nameref()` from `Src/params.c:6325`. C body:
16374/// ```c
16375/// mod_export Param
16376/// resolve_nameref(Param pm)
16377/// {
16378///     return resolve_nameref_rec(pm, NULL, 0);
16379/// }
16380/// ```
16381/// Public entry point that walks the nameref alias chain to the
16382/// final non-nameref `param`. Stop-pm and keep_lastref are
16383/// internal; this wrapper hardcodes both per the C body.
16384/// WARNING: param names don't match C — Rust=() vs C=(pm)
16385pub fn resolve_nameref(
16386    // c:6325
16387    pm: Option<Param>,
16388) -> Option<Param> {
16389    resolve_nameref_rec(pm, None, 0) // c:6327
16390}
16391
16392/// Port of `resolve_nameref_rec()` from `Src/params.c:6332`. C
16393/// recursive helper for `resolve_nameref()`. Walks the chain of
16394/// nameref indirections via `gethashnode2(realparamtab, refname)`
16395/// + `loadparamnode(paramtab, upscope(pm, ref), refname)`,
16396/// checking PM_TAGGED for cycle detection, and returns the
16397/// final non-nameref Param. Returns the input `pm` unchanged
16398/// for the early-exit path (no NAMEREF / UNSET / has subscript /
16399/// empty refname). The chain walk delegates to
16400/// `resolve_nameref_name` which operates on the live paramtab by
16401/// name (the Rust table hands out clones, so the by-name walk is
16402/// the canonical chain follower).
16403#[allow(unused_variables)]
16404pub fn resolve_nameref_rec(
16405    pm: Option<Param>,
16406    stop: Option<&param>,
16407    keep_lastref: i32,
16408) -> Option<Param> {
16409    let pm_ref = pm.as_deref()?;
16410    let f = pm_ref.node.flags as u32;
16411    // c:6336-6339 — early exits return pm unchanged.
16412    if (f & PM_NAMEREF) == 0 || (f & PM_UNSET) != 0 || pm_ref.width != 0 {
16413        return pm;
16414    }
16415    let refname = pm_ref.u_str.as_deref().unwrap_or("");
16416    if refname.is_empty() {
16417        if keep_lastref != 0 {
16418            return pm; // c:6353-6354
16419        }
16420        return pm; // empty refname is also an early-exit shape (c:6339)
16421    }
16422    if (f & PM_TAGGED) != 0 {
16423        // c:6340-6343 — `zerr("%s: invalid self reference", pm->node.nam)`.
16424        let nam = pm_ref.node.nam.clone();
16425        zerr(&format!("{}: invalid self reference", nam));
16426        return None;
16427    }
16428    let stop_key = stop.map(|s| (s.node.nam.clone(), s.level));
16429    match crate::ported::params::resolve_nameref_name(
16430        &pm_ref.node.nam,
16431        stop_key.as_ref().map(|(n, l)| (n.as_str(), *l)),
16432    ) {
16433        crate::ported::params::nameref_resolution::Target {
16434            pm: Some(target), ..
16435        } => Some(target),
16436        crate::ported::params::nameref_resolution::Target { pm: None, .. } => {
16437            // c:6347 miss + keep_lastref (c:6353-6354).
16438            if keep_lastref != 0 {
16439                pm
16440            } else {
16441                None
16442            }
16443        }
16444        crate::ported::params::nameref_resolution::Placeholder(last, _) => {
16445            // Chain ended on an empty-refname ref: C returns that ref
16446            // (the early-exit at c:6336-6339 of the recursive call).
16447            let tab = paramtab().read().ok()?;
16448            tab.get(&last).cloned().or(pm)
16449        }
16450        crate::ported::params::nameref_resolution::SelfRef => None, // c:6343
16451        crate::ported::params::nameref_resolution::OutOfScope => None, // c:6347-6349
16452        crate::ported::params::nameref_resolution::NotRef => pm,
16453    }
16454}
16455
16456/// ```c
16457/// Param pm = (Param) gethashnode2(realparamtab, name);
16458/// if (pm && (pm->node.flags & PM_NAMEREF)) {
16459///     if (pm->node.flags & PM_READONLY) {
16460///         zerr("read-only reference: %s", pm->node.nam); return;
16461///     }
16462///     pm->base = pm->width = 0;
16463///     SETREFNAME(pm, ztrdup(value));
16464///     pm->node.flags &= ~PM_UNSET;
16465///     setscope(pm);
16466/// } else
16467///     setsparam(name, ztrdup(value));
16468/// ```
16469/// `gethashnode2` is the no-autoload paramtab lookup. The
16470/// nameref branch updates the alias target in-place; the normal
16471/// branch falls through to `setsparam`.
16472/// Port of `setloopvar()` from `Src/params.c:6394` (the valid_refname
16473/// check is c:6404-6407, added by 54718).
16474pub fn setloopvar(name: &str, value: &str) {
16475    // c:6367 — `Param pm = (Param) gethashnode2(realparamtab, name);`
16476    // realparamtab and paramtab are the same backing store in zshrs
16477    // (the alias-flip during assoc iteration isn't modelled); the
16478    // operative table is `paramtab()`.
16479    // Scope the write lock so we drop it before calling setsparam below.
16480    let nameref_branch = {
16481        let mut tab = paramtab().write().unwrap();
16482        if let Some(pm) = tab.get_mut(name) {
16483            // c:6369 — `if (pm && (pm->node.flags & PM_NAMEREF))`
16484            if (pm.node.flags as u32 & PM_NAMEREF) != 0 {
16485                // c:6370 — `if (pm->node.flags & PM_READONLY)`
16486                if (pm.node.flags as u32 & PM_READONLY) != 0 {
16487                    // c:6372 — `zerr("read-only reference: %s", pm->node.nam);`
16488                    let nam = pm.node.nam.clone();
16489                    drop(tab); // zerr redraws ZLE, which reads paramtab
16490                    zerr(&format!("read-only reference: {}", nam));
16491                    // c:6373 — `return;`
16492                    return;
16493                }
16494                // c:6404-6407 (54718) — `if (!valid_refname(value,
16495                // pm->node.flags)) { zerr("invalid variable name: %s",
16496                // value); return; }`
16497                if !valid_refname(value, pm.node.flags) {
16498                    drop(tab); // zerr redraws ZLE, which reads paramtab
16499                    zerr(&format!("invalid variable name: {}", value));
16500                    return;
16501                }
16502                // c:6408 — `pm->base = 0;`
16503                pm.base = 0;
16504                pm.width = 0;
16505                // c:6377 — `SETREFNAME(pm, ztrdup(value));`
16506                // SETREFNAME (params.c:482) macro: for PM_SPECIAL,
16507                // call gsu_s.setfn(pm, S); else free pm->u.str and
16508                // assign new. The PM_SPECIAL gsu vtable isn't fully
16509                // wired in zshrs; both branches collapse to the
16510                // direct `u_str` assignment which matches the
16511                // non-special path verbatim.
16512                pm.u_str = Some(value.to_string());
16513                // c:6378 — `pm->node.flags &= ~PM_UNSET;`
16514                pm.node.flags &= !(PM_UNSET as i32);
16515                true
16516            } else {
16517                false
16518            }
16519        } else {
16520            false
16521        }
16522    };
16523    if nameref_branch {
16524        // c:6379 — `setscope(pm);` — run the full-table variant with
16525        // no lock held (it manages its own short-lived locks).
16526        crate::ported::params::setscope_by_name(name, None);
16527    } else {
16528        // c:6381 — `setsparam(name, ztrdup(value));`
16529        setsparam(name, value);
16530    }
16531}
16532
16533/// PM_NAMEREF: extract `refname = GETREFNAME(pm)`, locate first
16534/// `[` to split name vs subscript (sets pm->width), look up the
16535/// base param via `gethashnode2(realparamtab, refname)` →
16536/// `loadparamnode` (skipping self) → `setscope_base(pm,
16537/// basepm->level)`; if pm->base > pm->level emits the KSH global
16538/// reference error or WARNNESTEDVAR diagnostic; finally walks the
16539/// `resolve_nameref_rec` chain to detect self-references.
16540/// In-place variant: operates only on the passed `&mut param`
16541/// (width computation + literal self-name check). Callers that
16542/// hold no paramtab lock should use `setscope_by_name` which runs
16543/// the full base-scope + chain self-ref detection against the
16544/// live table.
16545/// Port of `setscope()` from `Src/params.c:6382`.
16546pub fn setscope(pm: &mut param) {
16547    queue_signals();
16548    if (pm.node.flags as u32 & PM_NAMEREF) != 0 {
16549        // Refname is stored in pm.u_str for nameref-typed params.
16550        let refname = pm.u_str.clone();
16551        if let Some(rn) = refname {
16552            // c:6391-6400 — compute pm->width by finding the first `[`.
16553            if let Some(i) = rn.find('[') {
16554                pm.width = i as i32;
16555            }
16556        }
16557    }
16558    unqueue_signals();
16559}
16560
16561/// ```c
16562/// if ((pm->base = base) > pm->level) {
16563///     LinkList refs;
16564///     /* grow scoperefs[] to base+1 entries */
16565///     refs = scoperefs[base];
16566///     if (!refs) refs = scoperefs[base] = znewlinklist();
16567///     zpushnode(refs, pm);
16568/// }
16569/// ```
16570/// Records `pm` on the per-scope reference list so a future
16571/// scope-pop can resolve nameref/upper bindings. Rust port
16572/// records the param's name on `SCOPEREFS[base]` so a future
16573/// scope-pop can resolve upper/nameref references.
16574/// Port of `setscope_base()` from `Src/params.c:6436`.
16575pub fn setscope_base(pm: &mut param, base: i32) {
16576    // c:6438
16577    // c:6440 — `if ((pm->base = base) > pm->level) {`
16578    pm.base = base;
16579    if base > pm.level {
16580        SCOPEREFS.with(|sr| {
16581            let mut sr = sr.borrow_mut();
16582            // c:6442-6447 — `if (base >= scoperefs_num) { ... grow ... }`
16583            //               Rust Vec grows on demand via resize; mirrors
16584            //               the C double-and-zero-init pattern via the
16585            //               max(8, 2*base) growth heuristic.
16586            if (base as usize) >= sr.len() {
16587                let new_num = (2 * base as usize).max(8);
16588                sr.resize(new_num, Vec::new());
16589            }
16590            // c:6448-6451 — `refs = scoperefs[base]; if (!refs)
16591            //                  refs = scoperefs[base] = znewlinklist();
16592            //                zpushnode(refs, pm);`
16593            //               Rust pushes the param NAME (Vec<String>),
16594            //               not a raw pointer — borrow-safe and the
16595            //               name is the canonical key for upscope walks.
16596            sr[base as usize].insert(0, pm.node.nam.clone()); // c:6451
16597        });
16598    }
16599}
16600
16601/// `scoperefs` — port of `static LinkList *scoperefs` from `Src/params.c:503`.
16602/// One Vec<String> (param names) per scope index. Per-evaluator (bucket 1)
16603/// because each worker thread has its own nameref-resolution context.
16604thread_local! {
16605    /// `SCOPEREFS` static.
16606    pub static SCOPEREFS: std::cell::RefCell<Vec<Vec<String>>>
16607        = const { std::cell::RefCell::new(Vec::new()) };
16608}
16609
16610/// Port of `upscope()` from `Src/params.c:6455`. C body:
16611/// ```c
16612/// if (ref->node.flags & PM_UPPER)
16613///     while (pm->level > ref->level - 1 && (pm = pm->old));
16614/// else
16615///     for (; pm->old && pm->old->level >= ref->base; pm = pm->old);
16616/// return pm;
16617/// ```
16618/// Walks `pm->old` chain to the param at the right scope depth
16619/// for a nameref. Rust signature mirrors C `Param upscope(Param,
16620/// const Param ref)`.
16621/// WARNING: param names don't match C — Rust=(pm, reference) vs C=(pm, ref)
16622pub fn upscope(mut pm: Param, reference: &param) -> Param {
16623    if (reference.node.flags as u32 & PM_UPPER) != 0 {
16624        while pm.level > reference.level - 1 {
16625            match pm.old.take() {
16626                Some(o) => pm = o,
16627                None => break,
16628            }
16629        }
16630    } else {
16631        loop {
16632            let next_level = pm.old.as_ref().map(|o| o.level);
16633            match next_level {
16634                Some(l) if l >= reference.base => {
16635                    pm = pm.old.take().unwrap();
16636                }
16637                _ => break,
16638            }
16639        }
16640    }
16641    pm
16642}
16643
16644/// Port of `valid_refname()` from `Src/params.c:6489-6501` (as rewritten
16645/// by 54718, which dropped references to subscripted variables).
16646///
16647/// The referent is an identifier (namespaced or not) or one of the
16648/// one-char specials `! ? $ -`. A `-u` (PM_UPPER) reference may not name
16649/// a positional, `argv` or `ARGC`.
16650pub fn valid_refname(val: &str, flags: i32) -> bool {
16651    if (flags as u32 & PM_UPPER) != 0 {
16652        // c:6491-6495 — upward reference to positionals is doomed to fail
16653        if val.as_bytes().first().is_some_and(u8::is_ascii_digit) || val == "argv" || val == "ARGC" {
16654            return false; // c:6494
16655        }
16656    }
16657    // c:6497-6498
16658    if matches!(val.as_bytes().first(), Some(b'!' | b'?' | b'$' | b'-')) {
16659        return val.len() == 1;
16660    }
16661    // c:6500 — `return !*itype_end(val, INAMESPC, 0) && isident(val);`
16662    crate::ported::utils::itype_end(val, crate::ported::ztype_h::INAMESPC, false) == val.len()
16663        && isident(val)
16664}
16665
16666/// !!! WARNING: RUST-ONLY HELPER !!!
16667/// No C counterpart: reader for the C global `static Param foundparam`
16668/// (`Src/params.c:640`). C dereferences the global directly; Rust needs an
16669/// accessor because the store is a `OnceLock<Mutex<Option<String>>>`.
16670/// Read `foundparam`. Returns the last param name observed by
16671/// `scanparamvals`; cleared by callers after consumption.
16672pub fn foundparam() -> Option<String> {
16673    foundparam_lock().lock().unwrap().clone()
16674}
16675
16676/// !!! WARNING: RUST-ONLY HELPER !!!
16677/// No C counterpart: writer for the C global `static Param foundparam`
16678/// (`Src/params.c:640`) — C assigns the global in place (`foundparam = pm;`).
16679/// Set `foundparam`. Called from `scanparamvals`.
16680pub fn set_foundparam(nam: Option<String>) {
16681    *foundparam_lock().lock().unwrap() = nam;
16682}
16683
16684/// Port of `fetchvalue(Value v, char **pptr, int bracks, int scanflags)` from `Src/params.c:2180` — see real
16685/// implementation below; this slot kept for the C-source linenum
16686/// citation and is now an alias.
16687// (real fetchvalue is defined later)
16688
16689/// Port of `static int delunset;` from `Src/params.c:610`. Flag
16690/// `deleteparamtable` flips to 1 around the inner `deletehashtable`
16691/// call so each freed node runs its `unsetfn`. `freeparamnode`
16692/// consults this before invoking the unset hook (c:5986).
16693pub static DELUNSET: std::sync::atomic::AtomicI32 = // c:610
16694    std::sync::atomic::AtomicI32::new(0);
16695
16696/// !!! WARNING: RUST-ONLY HELPER !!!
16697/// No C counterpart: C keeps an associative array's elements in a nested
16698/// `HashTable` hanging off `pm->u.hash`, walked through the Param's GSU vtable.
16699/// zshrs stores them in this side table keyed by the owning param's name, because
16700/// the Rust `param` struct has no owned nested-table field.
16701pub(crate) fn paramtab_hashed_storage(
16702) -> &'static Mutex<crate::cow_map::CowHashMap<String, IndexMap<String, String>>> {
16703    PARAMTAB_HASHED_STORAGE_INNER
16704        .get_or_init(|| Mutex::new(crate::cow_map::CowHashMap::new()))
16705}
16706
16707/// Shadow stack for `paramtab_hashed_storage` entries displaced by
16708/// `local -A NAME` / `typeset -A NAME` shadows inside a function. The
16709/// canonical assoc data lives in `paramtab_hashed_storage` (a flat
16710/// HashMap keyed by name with NO scope dimension — Rust-only parallel
16711/// store; the C side keeps assoc data in pm.u_hash so it rides the
16712/// pm.old chain automatically, and the local's own bag is freed with
16713/// the local by `stdunsetfn`'s PM_HASHED arm, Src/params.c:3922-3925).
16714/// createparam pushes the displaced value whenever a PM_LOCAL shadow
16715/// installs and either side is PM_HASHED; endparamscope pops the frame
16716/// whose recorded level matches the local it is unwinding, so the outer
16717/// scope's bag comes back and the local's bag goes away. Mirrors C's
16718/// `copyparam` (Src/builtin.c:2382-2424) via parallel storage. Bug #415.
16719///
16720/// Each frame is `(level, row)`: `level` is the `locallevel` of the local
16721/// that displaced the row, `row` is `Some(map)` when the name had a row
16722/// before the shadow and `None` when it had none. The level tag is what
16723/// keeps push and pop matched — the pop side used to re-derive the push
16724/// condition from the param flags and the two spellings drifted.
16725pub(crate) static PARAMTAB_HASHED_SHADOW_STACK: OnceLock<
16726    Mutex<HashMap<String, Vec<(i32, Option<IndexMap<String, String>>)>>>,
16727> = OnceLock::new();
16728
16729/// !!! WARNING: RUST-ONLY HELPER !!!
16730/// No C counterpart: port-transition shim. C's `prefork()` (`Src/subst.c`) reads
16731/// parameters straight out of `paramtab`; zshrs's `subst.rs` still carries
16732/// `state.variables` / `state.arrays` / `state.assoc_arrays` as transient backing,
16733/// so this mirrors the table into them. It goes away when `subst.rs` reads
16734/// `paramtab()` directly.
16735/// Mirror the global `paramtab` (and the parallel hashed-storage
16736/// table) into the three HashMaps that `SubstState` uses as its
16737/// transient backing during `prefork()` (Src/subst.c:100). This
16738/// is a port-transition shim: once `subst.rs` reads parameters
16739/// directly through `paramtab().read()` / `.write()` instead of carrying
16740/// `state.variables`/`state.arrays`/`state.assoc_arrays`, this
16741/// helper goes away.
16742pub fn sync_state_from_paramtab(
16743    variables: &mut HashMap<String, String>,
16744    arrays: &mut HashMap<String, Vec<String>>,
16745    assoc_arrays: &mut HashMap<String, IndexMap<String, String>>,
16746) {
16747    let tab = paramtab().read().unwrap();
16748    for (name, pm) in tab.iter() {
16749        let f = pm.node.flags as u32;
16750        if (f & PM_ARRAY) != 0 {
16751            if let Some(arr) = pm.u_arr.as_ref() {
16752                arrays.insert(name.clone(), arr.clone());
16753            }
16754            variables.remove(name);
16755            assoc_arrays.remove(name);
16756        } else if (f & PM_HASHED) != 0 {
16757            if let Some(map) = paramtab_hashed_storage().lock().unwrap().get(name) {
16758                assoc_arrays.insert(name.clone(), map.clone());
16759            }
16760            variables.remove(name);
16761            arrays.remove(name);
16762        } else if let Some(s) = pm.u_str.as_ref() {
16763            // PM_SCALAR / PM_NAMEREF / numeric — fold to the string view.
16764            variables.insert(name.clone(), s.clone());
16765            arrays.remove(name);
16766            assoc_arrays.remove(name);
16767        }
16768    }
16769}
16770
16771/// !!! WARNING: RUST-ONLY HELPER !!!
16772/// No C counterpart: C has `convbase_underscore()` (`Src/params.c:5646`) for
16773/// integers but no underscore-grouping variant of `convfloat()`; the float path in
16774/// C never groups digits. This helper applies the same grouping to a formatted
16775/// float for zshrs's `typeset -F`/`-E` underscore support.
16776/// Format float with underscores
16777pub fn convfloat_underscore(dval: f64, underscore: i32) -> String {
16778    let s = convfloat(dval, 0, 0);
16779    if underscore <= 0 {
16780        return s;
16781    }
16782
16783    let u = underscore as usize;
16784    let (sign, rest) = if let Some(after) = s.strip_prefix('-') {
16785        ("-", after)
16786    } else {
16787        ("", s.as_str())
16788    };
16789
16790    let (int_part, frac_exp) = if let Some(dot_pos) = rest.find('.') {
16791        (&rest[..dot_pos], &rest[dot_pos..])
16792    } else {
16793        (rest, "")
16794    };
16795
16796    // Add underscores to integer part
16797    let int_chars: Vec<char> = int_part.chars().collect();
16798    let mut result = sign.to_string();
16799    let first_group = int_chars.len() % u;
16800    if first_group > 0 {
16801        result.extend(&int_chars[..first_group]);
16802        if first_group < int_chars.len() {
16803            result.push('_');
16804        }
16805    }
16806    for (i, chunk) in int_chars[first_group..].chunks(u).enumerate() {
16807        if i > 0 {
16808            result.push('_');
16809        }
16810        result.extend(chunk);
16811    }
16812
16813    // Add underscores to fractional part
16814    if let Some(frac) = frac_exp.strip_prefix('.') {
16815        result.push('.');
16816        let (frac_digits, exp) = if let Some(e_pos) = frac.find('e') {
16817            (&frac[..e_pos], &frac[e_pos..])
16818        } else {
16819            (frac, "")
16820        };
16821
16822        let frac_chars: Vec<char> = frac_digits.chars().collect();
16823        for (i, chunk) in frac_chars.chunks(u).enumerate() {
16824            if i > 0 {
16825                result.push('_');
16826            }
16827            result.extend(chunk);
16828        }
16829        result.push_str(exp);
16830    } else {
16831        result.push_str(frac_exp);
16832    }
16833
16834    result
16835}
16836
16837/// !!! WARNING: RUST-ONLY HELPER !!!
16838/// No C counterpart: `OnceLock` accessor for the C global `char *ifs`
16839/// (`Src/params.c:73`).
16840pub(crate) fn ifs_lock() -> &'static Mutex<Option<String>> {
16841    // `None` is C's `ifs == NULL` (an unset IFS, c:Src/params.c:4748).
16842    static IFS_VAR: OnceLock<Mutex<Option<String>>> = OnceLock::new();
16843    IFS_VAR.get_or_init(|| Mutex::new(Some(" \t\n\0".to_string())))
16844}
16845
16846/// !!! WARNING: RUST-ONLY HELPER !!!
16847/// No C counterpart: `OnceLock` accessor for the C global `char *home`
16848/// (`Src/params.c:91`).
16849fn home_lock() -> &'static Mutex<String> {
16850    static HOME_VAR: OnceLock<Mutex<String>> = OnceLock::new();
16851    HOME_VAR.get_or_init(|| Mutex::new(env::var("HOME").unwrap_or_default()))
16852}
16853
16854/// !!! WARNING: RUST-ONLY HELPER !!!
16855/// No C counterpart: `OnceLock` accessor for the C global `char *term`
16856/// (`Src/params.c:93`).
16857fn term_lock() -> &'static Mutex<String> {
16858    static TERM_VAR: OnceLock<Mutex<String>> = OnceLock::new();
16859    TERM_VAR.get_or_init(|| Mutex::new(env::var("TERM").unwrap_or_default()))
16860}
16861
16862/// !!! WARNING: RUST-ONLY HELPER !!!
16863/// No C counterpart: `OnceLock` accessor for the C global `char *wordchars`
16864/// (`Src/params.c:85`).
16865pub(crate) fn wordchars_lock() -> &'static Mutex<String> {
16866    static WORDCHARS_VAR: OnceLock<Mutex<String>> = OnceLock::new();
16867    WORDCHARS_VAR.get_or_init(|| Mutex::new("*?_-.[]~=/&;!#$%^(){}<>".to_string()))
16868}
16869
16870/// !!! WARNING: RUST-ONLY HELPER !!!
16871/// No C counterpart: `OnceLock` accessor for the three C globals behind
16872/// `$histchars` — `mod_export unsigned char bangchar;` (`Src/params.c:130`) and
16873/// `unsigned char hatchar, hashchar;` (`Src/params.c:132`) — held here as one
16874/// `[u8; 3]` so the getfn/setfn pair reads and writes them atomically.
16875fn histchars_lock() -> &'static Mutex<[u8; 3]> {
16876    static HISTCHARS_VAR: OnceLock<Mutex<[u8; 3]>> = OnceLock::new();
16877    HISTCHARS_VAR.get_or_init(|| Mutex::new([b'!', b'^', b'#']))
16878}
16879
16880/// !!! WARNING: RUST-ONLY HELPER !!!
16881/// No C counterpart: `OnceLock` accessor for the C global
16882/// `unsigned char keyboardhackchar = '\0';` (`Src/params.c:135`).
16883///
16884/// `pub(crate)` because C's global is file-scope-external and
16885/// `Src/options.c:876` assigns it directly (`keyboardhackchar = (value ?
16886/// '`' : '\0')`). That call site used to reach the byte by dispatching
16887/// `keyboardhacksetfn` under the paramtab write guard, which is neither
16888/// what C does nor safe to do; it now writes the global, like C.
16889pub(crate) fn keyboardhack_lock() -> &'static Mutex<u8> {
16890    static KEYBOARDHACK_VAR: OnceLock<Mutex<u8>> = OnceLock::new();
16891    KEYBOARDHACK_VAR.get_or_init(|| Mutex::new(0))
16892}
16893
16894/// !!! WARNING: RUST-ONLY HELPER !!!
16895/// No C counterpart: `OnceLock` accessor for the C global `zlong histsiz`
16896/// (`Src/hist.c:108`), seeded the way `Src/init.c:1094` seeds it
16897/// (`histsiz = DEFAULT_HISTSIZE;`).
16898/// Port of `zlong histsiz` (`Src/hist.c:108`), seeded by
16899/// `Src/init.c:1094` — `histsiz = DEFAULT_HISTSIZE;`.
16900/// `DEFAULT_HISTSIZE` is 30 (`configure.ac:2978`).
16901fn histsiz_lock() -> &'static Mutex<i64> {
16902    static HISTSIZ_VAR: OnceLock<Mutex<i64>> = OnceLock::new();
16903    HISTSIZ_VAR.get_or_init(|| Mutex::new(crate::ported::config_h::DEFAULT_HISTSIZE as i64))
16904    // c:Src/init.c:1094
16905}
16906
16907/// !!! WARNING: RUST-ONLY HELPER !!!
16908/// No C counterpart: `OnceLock` accessor for the C global `zlong savehistsiz`
16909/// (`Src/hist.c:113`). Nothing seeds it — C leaves the BSS global at 0.
16910/// Port of `zlong savehistsiz` (`Src/hist.c:113`). Nothing seeds it —
16911/// C zeroes the BSS global and `SAVEHIST` (`IPDEF1` at
16912/// `Src/params.c:303`) reads it through `savehistsizegetfn`
16913/// (`c:4985 return savehistsiz;`), so a shell with no rc file reports 0.
16914fn savehistsiz_lock() -> &'static Mutex<i64> {
16915    static SAVEHISTSIZ_VAR: OnceLock<Mutex<i64>> = OnceLock::new();
16916    SAVEHISTSIZ_VAR.get_or_init(|| Mutex::new(0)) // c:Src/hist.c:113
16917}
16918
16919/// !!! WARNING: RUST-ONLY HELPER !!!
16920/// No C counterpart: `OnceLock` accessor for the C global `char *zsh_terminfo`
16921/// (`Src/params.c:87`).
16922fn zsh_terminfo_lock() -> &'static Mutex<String> {
16923    static TERMINFO_VAR: OnceLock<Mutex<String>> = OnceLock::new();
16924    TERMINFO_VAR.get_or_init(|| Mutex::new(env::var("TERMINFO").unwrap_or_default()))
16925}
16926
16927/// !!! WARNING: RUST-ONLY HELPER !!!
16928/// No C counterpart: `OnceLock` accessor for the C global `char *zsh_terminfodirs`
16929/// (`Src/params.c:88`).
16930fn zsh_terminfodirs_lock() -> &'static Mutex<String> {
16931    static TERMINFODIRS_VAR: OnceLock<Mutex<String>> = OnceLock::new();
16932    TERMINFODIRS_VAR.get_or_init(|| Mutex::new(env::var("TERMINFO_DIRS").unwrap_or_default()))
16933}
16934
16935/// !!! WARNING: RUST-ONLY HELPER !!!
16936/// No C counterpart: `OnceLock` accessor for the C global `char *cached_username`
16937/// (`Src/utils.c:1071`).
16938fn cached_username_lock() -> &'static Mutex<String> {
16939    static USERNAME_VAR: OnceLock<Mutex<String>> = OnceLock::new();
16940    USERNAME_VAR.get_or_init(|| Mutex::new(initial_username()))
16941}
16942
16943// Port of `static unsigned numparamvals;` (params.c:626) and the
16944// related per-scan statics at params.c:637-640. Per PORT.md Rule D
16945// these are file-scope statics, NOT aggregated into a state struct.
16946//
16947//   c:626  static unsigned numparamvals;
16948//   c:637  static Patprog scanprog;
16949//   c:638  static char *scanstr;
16950//   c:639  static char **paramvals;
16951//   c:640  static Param foundparam;   <-- exposed earlier as FOUNDPARAM
16952/// `NUMPARAMVALS` static.
16953pub static NUMPARAMVALS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); // c:626
16954/// `SCANPROG` static.
16955pub static SCANPROG: OnceLock<Mutex<Option<String>>> = OnceLock::new(); // c:637
16956/// `SCANSTR` static.
16957pub static SCANSTR: OnceLock<Mutex<Option<String>>> = OnceLock::new(); // c:638
16958/// `PARAMVALS` static.
16959pub static PARAMVALS: OnceLock<Mutex<Vec<String>>> = OnceLock::new(); // c:639
16960
16961/// !!! WARNING: RUST-ONLY HELPER !!!
16962/// No C counterpart under this name. It performs the `getpwuid(getuid())->pw_name`
16963/// lookup that C does inside `get_username()` (`Src/utils.c:1075`) to seed the
16964/// `cached_username` global, plus an env fallback C does not have. The name-parity
16965/// port of `get_username()` itself lives in `utils.rs`; this is only the seed for
16966/// the `cached_username_lock` mirror.
16967///
16968/// Resolves the current user's name from `getpwuid(getuid())->pw_name`
16969/// rather than `$USER`. Falls back to env vars only if the
16970/// passwd lookup fails (rare on real systems).
16971fn initial_username() -> String {
16972    #[cfg(unix)]
16973    {
16974        let uid = unsafe { libc::getuid() };
16975        let mut pwd: libc::passwd = unsafe { std::mem::zeroed() };
16976        // libc::c_char is i8 on x86_64/aarch64-darwin and x86_64-linux but u8 on
16977        // aarch64-linux. Use c_char so getpwuid_r's pointer type matches per-target.
16978        let mut buf: Vec<libc::c_char> = vec![0; 1024];
16979        let mut result: *mut libc::passwd = std::ptr::null_mut();
16980        let rc =
16981            unsafe { libc::getpwuid_r(uid, &mut pwd, buf.as_mut_ptr(), buf.len(), &mut result) };
16982        if rc == 0 && !result.is_null() && !pwd.pw_name.is_null() {
16983            let cstr = unsafe { std::ffi::CStr::from_ptr(pwd.pw_name) };
16984            return cstr.to_string_lossy().into_owned();
16985        }
16986    }
16987    env::var("USER")
16988        .or_else(|_| env::var("LOGNAME"))
16989        .unwrap_or_default()
16990}
16991
16992/// !!! WARNING: RUST-ONLY HELPER !!!
16993/// No C counterpart: `OnceLock` accessor for the C global
16994/// `int numpipestats, pipestats[MAX_PIPESTATS];` (`Src/jobs.c:131`).
16995fn pipestats_lock() -> &'static Mutex<Vec<i32>> {
16996    static PIPESTATS_VAR: OnceLock<Mutex<Vec<i32>>> = OnceLock::new();
16997    PIPESTATS_VAR.get_or_init(|| Mutex::new(Vec::new()))
16998}
16999/// !!! WARNING: RUST-ONLY HELPER !!!
17000/// No C counterpart: `OnceLock` accessor for the C global `struct timespec shtimer;`
17001/// (`Src/params.c:142`).
17002/// `shtimer_lock` — see implementation.
17003pub fn shtimer_lock() -> &'static Mutex<Duration> {
17004    static SHTIMER_VAR: OnceLock<Mutex<Duration>> = OnceLock::new();
17005    SHTIMER_VAR.get_or_init(|| {
17006        Mutex::new(
17007            SystemTime::now()
17008                .duration_since(UNIX_EPOCH)
17009                .unwrap_or_default(),
17010        )
17011    })
17012}
17013
17014/// !!! WARNING: RUST-ONLY HELPER !!!
17015/// No C counterpart: `OnceLock` accessor for the C global `char **pparams`
17016/// (`Src/params.c:59`). Points at the single canonical store in `builtin::PPARAMS`
17017/// so `$#` reads the live positional-parameter vector.
17018fn pparams_lock() -> &'static Mutex<Vec<String>> {
17019    // Mirror of zsh's `pparams` (positional params $1, $2, ...).
17020    // Used by `poundgetfn` for `$#`. The canonical store is
17021    // `builtin::PPARAMS` (Src/init.c `pparams`); set/shift builtins
17022    // write there. Point at that single store so `$#` reads the
17023    // live value instead of an isolated empty mirror.
17024    &PPARAMS
17025}
17026
17027/// !!! WARNING: RUST-ONLY HELPER !!!
17028/// No C counterpart: accessor for the C global `char *zunderscore;`
17029/// (`Src/init.c:49`). Hands back the canonical port in `init::zunderscore` so there
17030/// is one store, as in C.
17031///
17032/// C has a single `char *zunderscore` global (`Src/init.c:49`) that
17033/// `setunderscore` (`Src/exec.c:2652`) writes and `underscoregetfn`
17034/// (`Src/params.c:5152`) reads. zshrs had TWO stores: the canonical
17035/// port `init::zunderscore` (written by the ported `setunderscore`,
17036/// src/ported/exec.rs:1612) and a private mutex here that
17037/// `set_zunderscore` wrote and `underscoregetfn` read — so a
17038/// `setunderscore("")` from the assignment-only-command path
17039/// (c:Src/exec.c:1370) never reached the reader, and
17040/// `fusevm_bridge.rs:5979-5989` had to dual-write to both. Point this
17041/// accessor at the canonical global so there is one store, as in C.
17042fn zunderscore_lock() -> &'static Mutex<String> {
17043    &crate::ported::init::zunderscore // c:Src/init.c:49
17044}
17045
17046/// !!! WARNING: RUST-ONLY HELPER !!!
17047/// No C counterpart: C assigns the `zunderscore` global directly at each site
17048/// (`setunderscore()`, `Src/exec.c:2652`, and the inline writes in `execcmd_exec`).
17049/// This helper packages the last-argv-slot write for the Rust dispatch hooks.
17050/// Update `$_` with the last argument of the just-completed
17051/// command. Mirrors C zsh's writeback in `execcmd_exec` (Src/exec.c)
17052/// where `zunderscore` is set to the last argv slot before
17053/// returning. Callers: every command-dispatch hook in
17054/// fusevm_bridge / vm_helper.
17055pub fn set_zunderscore(argv: &[String]) {
17056    let new = if let Some(last) = argv.last() {
17057        last.clone()
17058    } else {
17059        String::new()
17060    };
17061    *zunderscore_lock().lock().expect("zunderscore poisoned") = new;
17062}
17063
17064/// Port of `dontimport()` from `Src/params.c:796`.
17065/// ```c
17066/// /* If explicitly marked as don't import */
17067/// if (flags & PM_DONTIMPORT)
17068///     return 1;
17069/// /* If value already exported */
17070/// if (flags & PM_EXPORTED)
17071///     return 1;
17072/// /* If security issue when importing and running with some privilege */
17073/// if ((flags & PM_DONTIMPORT_SUID) && isset(PRIVILEGED))
17074///     return 1;
17075/// /* OK to import */
17076/// return 0;
17077/// ```
17078/// Port of `dontimport(int flags)` from `Src/params.c:796`.
17079fn dontimport(flags: i32) -> i32 {
17080    // c:796
17081    let flags = flags as u32;
17082    // c:799-800 — `if (flags & PM_DONTIMPORT) return 1`.
17083    if flags & PM_DONTIMPORT != 0 {
17084        // c:799
17085        return 1; // c:800
17086    }
17087    // c:802-803 — `if (flags & PM_EXPORTED) return 1`.
17088    if flags & PM_EXPORTED != 0 {
17089        // c:802
17090        return 1; // c:803
17091    }
17092    // c:805-806 — `if ((flags & PM_DONTIMPORT_SUID) && isset(PRIVILEGED)) return 1`.
17093    if flags & PM_DONTIMPORT_SUID != 0                 // c:805
17094        && isset(PRIVILEGED)
17095    {
17096        return 1; // c:806
17097    }
17098    0 // c:809
17099}
17100
17101// ===========================================================
17102// GSU dispatch table — maps special-parameter NAMES to their
17103// getfn callback. C zsh dispatches reads of `$RANDOM` /
17104// `$USERNAME` / `$UID` / etc. through `Param.gsu->getfn`, where
17105// each special parameter has a `Param` entry in `paramtab`
17106// pointing at its specific getfn (Src/params.c:225 SPECIAL_PARAM
17107// table seeds these mappings).
17108//
17109// zshrs has the GSU callbacks ported (uidgetfn, randomgetfn,
17110// usernamegetfn, etc. above) but the shell's parameter-read path
17111// (fusevm_bridge::expand_param) reads from ShellExecutor.variables
17112// directly — never dispatching through the callbacks. Result:
17113// `echo $RANDOM` returned the cached HashMap value (or empty),
17114// not a fresh `rand() & 0x7fff` from `randomgetfn`.
17115//
17116// `lookup_special_var(name)` is the bridge: given a variable
17117// name, returns the GSU getfn's output if `name` is a recognized
17118// special, else None. Callers (expand_param, subst.rs reads)
17119// check this before falling back to `variables.get(name)`.
17120// ===========================================================
17121
17122/// !!! WARNING: RUST-ONLY HELPER !!!
17123/// No C counterpart: side table. C marks an unset special by setting `PM_UNSET` on
17124/// the pm node (`unsetparam_pm`, `Src/params.c:3841`), which every getfn checks.
17125/// zshrs's `lookup_special_var` dispatches to the getfn without a pm node, so the
17126/// PM_UNSET bit is tracked in this set instead.
17127/// Registry of special-parameter names that have been `unset` and
17128/// should bypass the getfn regenerator.
17129///
17130/// c:Src/params.c:3853 — `unsetparam_pm` sets `PM_UNSET` on the pm
17131/// node which getfn callbacks check. zshrs's `lookup_special_var`
17132/// dispatches to libc/getfn directly without a paramtab pm node, so
17133/// PM_UNSET tracking happens in this side-set instead. Bug #417/#418.
17134fn unset_specials() -> &'static std::sync::Mutex<std::collections::HashSet<String>> {
17135    static SET: std::sync::OnceLock<std::sync::Mutex<std::collections::HashSet<String>>> =
17136        std::sync::OnceLock::new();
17137    SET.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
17138}
17139
17140/// !!! WARNING: RUST-ONLY HELPER !!!
17141/// No C counterpart: sets the side-table bit that stands in for `PM_UNSET` on a
17142/// special's pm node (see `unset_specials`).
17143/// Mark a special-parameter NAME as unset. Future
17144/// `lookup_special_var(name)` calls return `None` instead of dispatching
17145/// to the getfn regenerator. Re-assigning the name (e.g. `RANDOM=42`)
17146/// should clear this flag via `clear_unset_special`.
17147pub fn mark_unset_special(name: &str) {
17148    if let Ok(mut s) = unset_specials().lock() {
17149        s.insert(name.to_string());
17150    }
17151}
17152
17153/// !!! WARNING: RUST-ONLY HELPER !!!
17154/// No C counterpart: clears the side-table bit that stands in for `PM_UNSET` on a
17155/// special's pm node (see `unset_specials`).
17156/// Clear the unset flag for a special-parameter NAME — called when the
17157/// name is re-assigned so the getfn regenerator becomes active again.
17158pub fn clear_unset_special(name: &str) {
17159    if let Ok(mut s) = unset_specials().lock() {
17160        s.remove(name);
17161    }
17162}
17163
17164/// !!! WARNING: RUST-ONLY HELPER !!!
17165/// No C counterpart: reads the side-table bit that stands in for `PM_UNSET` on a
17166/// special's pm node (see `unset_specials`).
17167fn is_unset_special(name: &str) -> bool {
17168    unset_specials()
17169        .lock()
17170        .map(|s| s.contains(name))
17171        .unwrap_or(false)
17172}
17173
17174/// !!! WARNING: RUST-ONLY HELPER !!!
17175/// No C counterpart: C reaches a special's getter through the Param's GSU vtable
17176/// (`pm->gsu.s->getfn(pm)`, dispatched from `getstrvalue`, `Src/params.c:2335`).
17177/// Rust has no vtable on the `param` struct, so this function is the name-keyed
17178/// dispatch table that routes to the ported getfn callbacks above.
17179/// Look up a special-parameter NAME and dispatch to its GSU getfn.
17180///
17181/// Returns `Some(value_string)` if `name` is one of zshrs's
17182/// recognized specials with a real GSU getfn; `None` otherwise
17183/// (caller should fall back to `variables.get`).
17184///
17185/// This is the bridge between the named getfn callbacks above
17186/// (uidgetfn / randomgetfn / etc.) and the shell's parameter-read
17187/// path. Mirrors the `Param.gsu->getfn` dispatch C zsh does
17188/// inside `getsparam` / `getstrvalue` (Src/params.c:3076 / 2335).
17189pub fn lookup_special_var(name: &str) -> Option<String> {
17190    // ksh93 / mksh `$KSH_VERSION` under --ksh / --mksh / --pdksh (the strings the
17191    // reference shells report; scripts probe it with `${KSH_VERSION+set}`).
17192    if name == "KSH_VERSION" && crate::dash_mode::korn_mode() {
17193        return Some(crate::dash_mode::ksh_version_string().to_string());
17194    }
17195    // bash special SCALARS under --bash: `$BASH_VERSION` (version string) and
17196    // the scalar/bare reads of bash's special arrays (bash: `$PIPESTATUS` ==
17197    // `${PIPESTATUS[0]}`, `$FUNCNAME` == current function, `$BASH_VERSINFO` ==
17198    // major). No-op in --zsh (gate) — those names are ordinary there.
17199    if crate::dash_mode::bash_mode() {
17200        match name {
17201            "BASH_VERSION" => return Some(crate::dash_mode::bash_version()),
17202            // bash(1): HOSTNAME is the host name — zsh's HOST.
17203            "HOSTNAME" => return crate::ported::params::getsparam("HOST"),
17204            // bash(1): HOSTTYPE is the machine type, OSTYPE the OS, and
17205            // MACHTYPE the GNU `cpu-vendor-os` triple.
17206            "HOSTTYPE" => return Some(crate::ported::config_h::MACHTYPE.to_string()),
17207            "MACHTYPE" => {
17208                return Some(format!(
17209                    "{}-{}-{}",
17210                    crate::ported::config_h::MACHTYPE,
17211                    crate::ported::config_h::VENDOR,
17212                    crate::ported::config_h::OSTYPE
17213                ))
17214            }
17215            // bash(1) "Shell Variables": SHELLOPTS is the colon-separated
17216            // list of the `set -o` options currently enabled. Built from the
17217            // same table `set -o` lists and `set -o NAME` writes.
17218            "SHELLOPTS" => return Some(crate::dash_mode::bash_shellopts()),
17219            // bash(1) "Shell Variables": BASHOPTS is "a colon-separated list
17220            // of enabled shell options. Each word in the list is a valid
17221            // argument for the -s option to the shopt builtin." Twin of
17222            // SHELLOPTS one line up, over the OTHER option namespace — built
17223            // from the same table `shopt` lists and `shopt -s|-u` writes, so
17224            // the parameter and the builtin can never disagree.
17225            //
17226            // Missing until now: `shopt -s dotglob` recorded the flag and
17227            // `shopt -p dotglob` reported it, but `$BASHOPTS` expanded empty,
17228            // so every `case ":$BASHOPTS:" in *:NAME:*)` probe — the shape
17229            // scripts use to test for an option — answered "not set" for all
17230            // 59 names.
17231            "BASHOPTS" => return Some(crate::dash_mode::bash_shoptsopts()),
17232            // bash BASH_SUBSHELL ≈ zsh ZSH_SUBSHELL — the subshell nesting
17233            // depth, 0 in the top-level shell. bash(1): "Incremented by one
17234            // within each subshell or subshell environment when the shell
17235            // begins executing in that environment."
17236            "BASH_SUBSHELL" => {
17237                return Some(
17238                    crate::ported::params::getsparam("ZSH_SUBSHELL")
17239                        .unwrap_or_else(|| "0".to_string()),
17240                )
17241            }
17242            "PIPESTATUS" | "FUNCNAME" | "BASH_VERSINFO" => {
17243                // bare read = element 0 of the aliased array (empty if none).
17244                return Some(
17245                    crate::dash_mode::bash_special_array(name)
17246                        .and_then(|a| a.into_iter().next())
17247                        .unwrap_or_default(),
17248                );
17249            }
17250            _ => {}
17251        }
17252    }
17253    // c:Src/builtin.c:2083-2090 — `local -h NAME` / `private -h NAME`
17254    // (newspecial stays NS_NONE under PM_HIDE) creates an ORDINARY
17255    // parameter that hides the special, so C's read goes through that
17256    // node's plain gsu, never the special's getfn. A hidden node that is
17257    // not PM_SPECIAL — or is PM_SPECIAL only because makeprivate tagged it
17258    // (Src/Modules/param_private.c:174) — is therefore not this special:
17259    // fall back to the stored value (`() { local -h SECONDS; SECONDS=5;
17260    // echo $SECONDS }` prints 5). This holds for every special, not only the
17261    // regenerators: `() { local -h HOME; typeset -p HOME }` is `HOME=''`.
17262    // A non-special node that shadows another (`pm->old`) is such a local
17263    // even before its PM_HIDE attribute is stamped.
17264    if paramtab()
17265        .read()
17266        .ok()
17267        .and_then(|t| {
17268            t.get(name).map(|pm| {
17269                let f = pm.node.flags as u32;
17270                ((f & PM_SPECIAL) == 0 && pm.old.is_some())
17271                    || ((f & PM_HIDE) != 0
17272                        && ((f & PM_SPECIAL) == 0
17273                            || crate::ported::modules::param_private::is_private(
17274                                &**pm as *const param,
17275                            ) != 0))
17276            })
17277        })
17278        .unwrap_or(false)
17279    {
17280        return None;
17281    }
17282    // c:Src/params.c:3853 — PM_UNSET-flagged specials skip getfn.
17283    // Only applies to regenerator-style specials (RANDOM, SECONDS,
17284    // EPOCHSECONDS, TTYIDLE, ERRNO) — identity specials like UID, GID,
17285    // PPID stay live since they're not user-clearable in zsh either.
17286    //
17287    // Two sources of "is unset" — the side-set populated by
17288    // `mark_unset_special` from explicit `unset NAME`, and the
17289    // initial PM_UNSET flag set by `Src/params.c:298 IPDEF1(...,
17290    // PM_UNSET)`. ERRNO is the only IPDEF1-special with the initial
17291    // PM_UNSET flag (params.c:298) — zsh -fc reports `$ERRNO` as
17292    // empty because nothing has set errno since startup. Mirror by
17293    // also consulting the paramtab pm flags. Without this, `$ERRNO`
17294    // returned the live errno value instead of empty.
17295    //
17296    // `_` (c:Src/params.c:326 `IPDEF2("_", underscore_gsu,
17297    // PM_DONTIMPORT)`) belongs to the same family: its getfn
17298    // (`underscoregetfn`, c:5147) reads the `zunderscore` global, which
17299    // `unset _` does NOT clear — C's `stdunsetfn` (c:3908-3937) only
17300    // flips PM_UNSET on the node, and `unsetparam_pm` keeps the node in
17301    // the table because it is PM_SPECIAL without PM_REMOVABLE (c:3877).
17302    // So the flag is the ONLY record that `_` was unset; without
17303    // consulting it `${+_}` stayed 1 and `$_` kept regenerating from
17304    // zunderscore after `unset _`.
17305    if matches!(
17306        name,
17307        "RANDOM" | "SECONDS" | "EPOCHSECONDS" | "EPOCHREALTIME" | "TTYIDLE" | "ERRNO" | "_"
17308    ) && (is_unset_special(name)
17309        // !!! DASH-FAITHFUL GATE (no C counterpart) !!! dash has no $RANDOM or
17310        // $SECONDS: they are ordinary (unset) variables there.
17311        || (crate::dash_mode::dash_faithful() && matches!(name, "RANDOM" | "SECONDS")) || {
17312        // c:Src/params.c — paramtab PM_UNSET check. ERRNO carries
17313        // this flag from IPDEF1 initialization (params.c:298); reads
17314        // route through getsparam → paramtab pm.flags check at
17315        // line 4356-4358 normally, but ERRNO/RANDOM/etc. short-
17316        // circuit through lookup_special_var BEFORE that check.
17317        paramtab()
17318            .read()
17319            .ok()
17320            .and_then(|t| t.get(name).map(|pm| (pm.node.flags as u32 & PM_UNSET) != 0))
17321            .unwrap_or(false)
17322    }) {
17323        return None;
17324    }
17325    // All-digit positional: $1..$N from canonical PPARAMS.
17326    // C zsh dispatches positional params through pparams (Src/init.c).
17327    if !name.is_empty() && name.chars().all(|c| c.is_ascii_digit()) {
17328        let n: usize = name.parse().ok()?;
17329        if n == 0 {
17330            // c:4906-4911 argzerogetfn — `if (isset(POSIXARGZERO)) return
17331            // posixzero; return argzero;`
17332            if isset(POSIXARGZERO) {
17333                return crate::ported::utils::posixzero(); // c:4909
17334            }
17335            return argzero(); // c:4910
17336        }
17337        let pp = pparams_lock().lock().ok()?;
17338        return pp.get(n - 1).cloned();
17339    }
17340    match name {
17341        // libc identity callbacks.
17342        "UID" => Some(uidgetfn().to_string()),
17343        "GID" => Some(gidgetfn().to_string()),
17344        "EUID" => Some(euidgetfn().to_string()),
17345        "EGID" => Some(egidgetfn().to_string()),
17346        // c:Src/params.c:350 `IPDEF4("PPID", &ppid)` — ppid is the
17347        // file-static set at shell startup from getppid(). zshrs's
17348        // ported special_paramdef list registers PPID but nothing
17349        // populates the paramtab slot from getppid(2), so $PPID
17350        // always read 0. Read the `ppid` global setupvals fills
17351        // (c:Src/init.c:1226) — NOT a live getppid(), which a forked
17352        // pipeline stage would answer with the shell's own pid.
17353        "PPID" => Some(ppid.load(std::sync::atomic::Ordering::Relaxed).to_string()), // c:350 IPDEF4("PPID", &ppid)
17354        // c:Src/params.c:348 + c:4202 — `IPDEF4("HISTCMD", &curhist)`
17355        // binds `intvargetfn` (`return *pm->u.valptr;`) to `&curhist`
17356        // (`Src/hist.c:88`). Same valptr-bound shape as PPID above: the
17357        // value is a live global, never stored on the pm. `curhist` is
17358        // bumped by `linkcurline()` (`Src/hist.c:1093`) from `hbegin()`
17359        // (c:1166) BEFORE the line is parsed, so during event N the
17360        // parameter reads N and at the next prompt N+1.
17361        "HISTCMD" => Some(
17362            crate::ported::hist::curhist
17363                .load(Ordering::SeqCst)
17364                .to_string(),
17365        ), // c:348 + c:4202
17366        // c:Src/params.c:362-363 — `IPDEF5("COLUMNS", &zterm_columns,
17367        // zlevar_gsu)` / `IPDEF5("LINES", &zterm_lines, zlevar_gsu)`, whose
17368        // getfn is `intvargetfn` (c:4156 `return *pm->u.valptr;`). See
17369        // `intvargetfn` for why the node's own `u_val` is not the answer.
17370        // `unset COLUMNS` keeps the node (c:3877) and reads empty, so the
17371        // PM_UNSET flag decides whether the global is the answer at all.
17372        "COLUMNS" | "LINES" => {
17373            if is_unset_special(name) {
17374                return None; // c:3877
17375            }
17376            let tab = paramtab().read().ok()?;
17377            let pm = tab.get(name)?;
17378            if (pm.node.flags as u32 & PM_UNSET) != 0 {
17379                return None; // c:3877
17380            }
17381            Some(intvargetfn(pm).to_string()) // c:362-363 + c:4202
17382        }
17383        // libc syscall callbacks.
17384        "RANDOM" => Some(randomgetfn().to_string()),
17385        "TTYIDLE" => Some(ttyidlegetfn().to_string()),
17386        "ERRNO" => Some(errnogetfn().to_string()),
17387        // Time callbacks.
17388        "SECONDS" => {
17389            // c:Src/params.c:4561/4591 — PM_TYPE dispatches between
17390            // intsecondsgetfn (PM_INTEGER, default) and floatsecondsgetfn
17391            // (PM_EFLOAT/PM_FFLOAT after `typeset -F`/`-E SECONDS`). The
17392            // pm's gsu_i vs gsu_f vtable swap happens in setfn during
17393            // typeset; we read pm.flags from paramtab here.
17394            let pm_type = paramtab()
17395                .read()
17396                .ok()
17397                .and_then(|t| t.get("SECONDS").map(|pm| PM_TYPE(pm.node.flags as u32)))
17398                .unwrap_or(PM_INTEGER);
17399            if pm_type == PM_EFLOAT || pm_type == PM_FFLOAT {
17400                let v = floatsecondsgetfn();
17401                let base = paramtab()
17402                    .read()
17403                    .ok()
17404                    .and_then(|t| t.get("SECONDS").map(|pm| pm.base))
17405                    .unwrap_or(0);
17406                Some(convfloat(v, base, pm_type))
17407            } else {
17408                Some(intsecondsgetfn().to_string())
17409            }
17410        }
17411        // zsh/datetime module params. In recent zsh these are
17412        // auto-loaded via `zmodload zsh/datetime` and the param
17413        // appears in paramtab via the module's `p:NAME` feature
17414        // descriptor (Src/Modules/datetime.c:25). In zshrs the
17415        // datetime module's per-param wireup hasn't been ported
17416        // through paramtab yet — the getters exist
17417        // (modules::datetime::getcurrentsecs / getcurrentrealtime)
17418        // but no Param entry is created on `zmodload`. Mirror the C
17419        // behavior by routing the names through their canonical
17420        // getters here so `$EPOCHSECONDS` / `$EPOCHREALTIME` read
17421        // live time values regardless of explicit zmodload. p10k
17422        // and many other prompts rely on this without an explicit
17423        // zmodload (zsh ships datetime preloaded in most configs).
17424        // c:Src/params.c:1130-1153 createparam / c:2264 getparamnode — a
17425        // `local NAME=…` over a module special SPLICES A NEW Param into
17426        // paramtab ahead of the special (`pm->old = oldpm`), and every read
17427        // from then on goes through the NEW node's `stdgetfn`, not the
17428        // module's getfn. zshrs has no `old` chain, but `local` does replace
17429        // the paramtab node, so the shadow is observable as "the node for
17430        // this name is no longer PM_SPECIAL". Decline the module getter in
17431        // that state so the ordinary paramtab read wins.
17432        // Without this, `fn() { local EPOCHSECONDS=scruts; print $EPOCHSECONDS }`
17433        // printed the live epoch (V04features.ztst "Module special parameter
17434        // is hidden by a local parameter") even though `${(t)EPOCHSECONDS}`
17435        // already reported `scalar-local`.
17436        "EPOCHSECONDS" | "EPOCHREALTIME"
17437            if paramtab()
17438                .read()
17439                .ok()
17440                .and_then(|t| {
17441                    t.get(name)
17442                        .map(|pm| (pm.node.flags as u32 & PM_SPECIAL) == 0)
17443                })
17444                .unwrap_or(false) =>
17445        {
17446            None
17447        }
17448        "EPOCHSECONDS" => {
17449            // c:Src/Modules/datetime.c:206 `getcurrentsecs`. zsh requires
17450            // explicit `zmodload zsh/datetime` before EPOCHSECONDS is
17451            // bound (Src/Modules/datetime.c:25 `p:EPOCHSECONDS` feature
17452            // descriptor). Without the load, the name is unset and
17453            // ${EPOCHSECONDS:-x} falls through to "x". Match by gating
17454            // the getter on the module's loaded state. Bug #31 in
17455            // docs/BUGS.md.
17456            // bash(1) "Shell Variables": EPOCHSECONDS / EPOCHREALTIME are
17457            // always-on specials, no module load.
17458            if !crate::dash_mode::bash_mode()
17459                && !crate::ported::module::MODULESTAB
17460                    .lock()
17461                    .unwrap()
17462                    .is_loaded("zsh/datetime")
17463            {
17464                return None;
17465            }
17466            Some(crate::ported::modules::datetime::getcurrentsecs().to_string())
17467        }
17468        "EPOCHREALTIME" => {
17469            // c:Src/Modules/datetime.c:212 `getcurrentrealtime`. Same
17470            // zsh/datetime gate as EPOCHSECONDS above. Bug #31.
17471            // bash(1) "Shell Variables": EPOCHSECONDS / EPOCHREALTIME are
17472            // always-on specials, no module load.
17473            if !crate::dash_mode::bash_mode()
17474                && !crate::ported::module::MODULESTAB
17475                    .lock()
17476                    .unwrap()
17477                    .is_loaded("zsh/datetime")
17478            {
17479                return None;
17480            }
17481            let v = crate::ported::modules::datetime::getcurrentrealtime();
17482            if crate::dash_mode::bash_mode() {
17483                // bash(1): "seconds since the Unix epoch as a floating
17484                // point value with micro-second granularity" — `%d.%06d`.
17485                return Some(format!("{:.6}", v));
17486            }
17487            Some(format!("{:.10}", v))
17488        }
17489        "epochtime" => {
17490            // c:Src/Modules/datetime.c:220 `getcurrenttime` returns
17491            // [tv_sec, tv_nsec]. Bare `$epochtime` joins the two with
17492            // ` ` (the default IFS separator), matching the C path
17493            // `getstrvalue` → sepjoin on PM_ARRAY. Bug #317 in
17494            // docs/BUGS.md. Gate on zsh/datetime load per #31.
17495            if !crate::ported::module::MODULESTAB
17496                .lock()
17497                .unwrap()
17498                .is_loaded("zsh/datetime")
17499            {
17500                return None;
17501            }
17502            let arr = crate::ported::modules::datetime::getcurrenttime();
17503            Some(arr.join(" "))
17504        }
17505        // Cached-state callbacks. C dispatches `pm->gsu.s->getfn(pm)`
17506        // where pm is `paramtab->getnode(name)`. Mirror: look up pm,
17507        // pass it through. Each getfn here ignores pm (matches C's
17508        // UNUSED(Param pm)), so a fallback default-constructed param
17509        // is acceptable when the table isn't populated yet.
17510        //
17511        // c:Src/params.c paramsubst c:3193 — the `vunset = (!v || ...)`
17512        // check inspects `pm->node.flags & PM_UNSET`, not the value
17513        // returned by getfn. For specials whose getfn reads global
17514        // cached state (`ifs`, `wordchars`, `keyboardhackchar`), the
17515        // global keeps its last value across `unset NAME` (because
17516        // stdunsetfn only flips PM_UNSET; it doesn't clear the global).
17517        // Without consulting PM_UNSET here, `${IFS+set}` after
17518        // `unset IFS` still returned the default-IFS getter result,
17519        // diverging from zsh.
17520        "USERNAME" | "HOME" | "TERM" | "WORDCHARS" | "IFS" | "TERMINFO" | "TERMINFO_DIRS"
17521        | "KEYBOARD_HACK" | "histchars" | "HISTCHARS" => {
17522            let tab = paramtab().read().ok()?;
17523            let pm = tab.get(name)?;
17524            if (pm.node.flags as u32 & crate::ported::zsh_h::PM_UNSET) != 0 {
17525                return None;
17526            }
17527            Some(match name {
17528                "USERNAME" => usernamegetfn(pm),
17529                "HOME" => homegetfn(pm),
17530                "TERM" => termgetfn(pm),
17531                "WORDCHARS" => wordcharsgetfn(pm),
17532                "IFS" => ifsgetfn(pm),
17533                "TERMINFO" => terminfogetfn(pm),
17534                "TERMINFO_DIRS" => terminfodirsgetfn(pm),
17535                "KEYBOARD_HACK" => keyboardhackgetfn(pm),
17536                "histchars" | "HISTCHARS" => histcharsgetfn(pm),
17537                _ => unreachable!(),
17538            })
17539        }
17540        "_" => Some(underscoregetfn()),
17541        // Counters with int return.
17542        "HISTSIZE" => Some(histsizegetfn().to_string()),
17543        "SAVEHIST" => Some(savehistsizegetfn().to_string()),
17544        "#" | "ARGC" => Some(poundgetfn().to_string()),
17545        // c:Src/params.c:871 — `setsparam("TIMEFMT", DEFAULT_TIMEFMT)`.
17546        // createparamtable seeds this; the zshrs main binary skips
17547        // that init path so paramtab is empty for TIMEFMT. Route
17548        // here from the canonical default. paramtab check first so
17549        // an explicit user-set value sticks.
17550        "TIMEFMT" => {
17551            let tab_val = paramtab()
17552                .read()
17553                .ok()
17554                .and_then(|t| t.get("TIMEFMT").and_then(|pm| pm.u_str.clone()));
17555            if let Some(v) = tab_val {
17556                if !v.is_empty() {
17557                    return Some(v);
17558                }
17559            }
17560            Some(crate::ported::zsh_system_h::DEFAULT_TIMEFMT.to_string())
17561        }
17562        // c:Src/init.c:1214-1215 — `nullcmd = ztrdup("cat");
17563        // readnullcmd = ztrdup(DEFAULT_READNULLCMD);`. C seeds these
17564        // into the REAL paramtab at startup; `unset NULLCMD` then
17565        // removes the entry and getsparam returns NULL — which is
17566        // load-bearing: A04redirect "null redir with NULLCMD unset"
17567        // requires `unset NULLCMD; >file` to error "redirection with
17568        // no command". The previous read-time fallback here faked the
17569        // seed on EVERY lookup, making unset impossible. The seed now
17570        // lives in ShellExecutor::new (vm_helper.rs, next to TIMEFMT)
17571        // and absent means absent.
17572        // $0 routes through utils::argzero.
17573        "0" => argzero(),
17574        // POSIX shell-special scalars. C dispatches these through
17575        // dedicated gsu getfn callbacks (Src/params.c special_assigns).
17576        // c:Src/params.c lastvalgetfn — `?` and `status` are aliases
17577        // for the last-command exit code (lastval). C wires them via
17578        // separate IPDEF entries that share the same getfn.
17579        "?" | "status" => Some(LASTVAL.load(Ordering::Relaxed).to_string()),
17580        // c:Src/loop.c:719 — `try_errflag = -1` reset before
17581        // each `{ try } always { catch }` block; reads `-1` when
17582        // outside a try block. zsh exposes TRY_BLOCK_ERROR as an
17583        // integer special-param: inside an always-arm after a
17584        // normal-exit try, reads 0; -1 only when no try has yet
17585        // fired in this scope. BUILTIN_SET_TRY_BLOCK_ERROR writes
17586        // via set_scalar (u_str), so accept either storage form
17587        // and treat a present-but-empty u_str as 0 too.
17588        "TRY_BLOCK_ERROR" => {
17589            // c:Src/loop.c:719 — `zlong try_errflag = -1;` global,
17590            // exported via IPDEF6 (c:Src/params.c:364) so `$TRY_BLOCK_ERROR`
17591            // reads it directly. Initialized to -1 (sentinel: "no try
17592            // block has fired yet"); the `exectry` always-arm sets it
17593            // to `errflag & ERRFLAG_ERROR` (c:765) before running the
17594            // body, then restores at c:787. Read straight from the
17595            // canonical atomic — paramtab's u_str / u_val are NOT the
17596            // source of truth (matches C's IPDEF6 getfn signature).
17597            Some(
17598                crate::ported::r#loop::try_errflag
17599                    .load(std::sync::atomic::Ordering::Relaxed)
17600                    .to_string(),
17601            )
17602        }
17603        "TRY_BLOCK_INTERRUPT" => {
17604            // c:Src/loop.c:727 — `zlong try_interrupt = -1;` global.
17605            // Same shape as TRY_BLOCK_ERROR.
17606            Some(
17607                crate::ported::r#loop::try_interrupt
17608                    .load(std::sync::atomic::Ordering::Relaxed)
17609                    .to_string(),
17610            )
17611        }
17612        "$" => Some(mypid.load(std::sync::atomic::Ordering::Relaxed).to_string()), // c:353 IPDEF4("$", &mypid)
17613        "!" => {
17614            // c:Src/params.c:345 IPDEF4("!", &lastpid) — `$!` reads
17615            // directly from the `lastpid` atomic (Src/jobs.c:73).
17616            // The previous Rust port read from paramtab["!"].u_str,
17617            // which only had a value if something wrote to it via
17618            // setsparam/assignsparam — and `"!"` is not a valid
17619            // identifier (isident("!") == 0 per params.c:1288), so
17620            // those calls failed loudly. Read directly from the
17621            // canonical store like the C getter does.
17622            let pid =
17623                crate::ported::modules::clone::lastpid.load(std::sync::atomic::Ordering::Relaxed);
17624            // bash(1): `$!` is unset until a background job has been started
17625            // (expands to ""); zsh's `lastpid` reads 0 there.
17626            if pid == 0 && crate::dash_mode::bash_mode() {
17627                return Some(String::new());
17628            }
17629            Some(pid.to_string())
17630        }
17631        // $* / $@ join positional params via sepjoin's IFS default —
17632        // c:Src/utils.c:3936-3945: set-but-empty IFS joins with ""
17633        // (`IFS=""; echo "$*"` concatenates); unset IFS joins with
17634        // " ". The previous `.unwrap_or(' ')` collapsed empty-IFS to
17635        // a space.
17636        //
17637        // c:Src/params.c:392,393,430 — `IPDEF9("*", &pparams, …)`,
17638        // `IPDEF9("@", &pparams, …)` and `IPDEF9("argv", &pparams, …)` are
17639        // THREE names over ONE storage, all reading through `vararray_gsu`
17640        // (c:189-190). This arm IS that gsu's `arrvargetfn`, so the third
17641        // spelling belongs here too: c:Src/params.c:2350-2358 `getstrvalue`
17642        // takes the PM_ARRAY case for all three and answers
17643        // `sepjoin(getvaluearr(v), NULL, 1)`.
17644        //
17645        // Without the `argv` spelling, `getsparam("argv")` fell through to
17646        // the paramtab read below, found the seeded PM_ARRAY node whose own
17647        // `u_arr` is empty (the positionals live in `PPARAMS`, not on the
17648        // node — see `printparamnode`'s refresh at params.rs:13874), and
17649        // answered `Some("")`. Every operator that decides on the VALUE
17650        // rather than on set-ness then took the null branch:
17651        // c:Src/subst.c:3188-3189 `if (colf && !vunset) vunset = (isarr) ?
17652        // !*aval : !*val`, so `${argv:-n}` / `${argv:+Y}` / `${argv:?}`
17653        // diverged while the non-colon `${argv-n}` / `${argv+Y}` / `${argv?}`
17654        // — which test set-ness alone — were already right.
17655        "*" | "@" | "argv" => pparams_lock()
17656            .lock()
17657            .ok()
17658            .map(|p| crate::ported::utils::sepjoin(&p, None)),
17659        // $- : current option-letter set.
17660        // c:Src/params.c:3262 (IPDEF) → dashparamgetfn in options.c:890.
17661        // Canonical C body walks `zshletters[FIRST_OPT..=LAST_OPT]`
17662        // (c:292-368) emitting each letter whose mapped option is
17663        // active (XOR-ing with the c:295 negation prefix `-OPT`).
17664        // The previous Rust port hand-rolled a hardcoded subset
17665        // ("569X" + 8 ad-hoc letters) that diverged from C's table:
17666        // letter 'h' wrongly mapped to `hashall` (c:271 ALIAS for
17667        // HASHCMDS) instead of HISTIGNOREDUPS (c:349). Parity bug
17668        // #32 — `$-` last char differed (zsh `f`, zshrs `fh`).
17669        // Route through `dashgetfn` (options.rs:835) which is the
17670        // direct port of `Src/options.c:890`.
17671        "-" => Some(crate::ported::options::dashgetfn()),
17672        // Arrays — joined with space for scalar context.
17673        "pipestatus" => {
17674            let arr = pipestatgetfn();
17675            if arr.is_empty() {
17676                None
17677            } else {
17678                Some(arr.join(" "))
17679            }
17680        }
17681        _ => None,
17682    }
17683}
17684
17685/// Shared test mutex for histsiz mutations (gsu_tests +
17686/// tests submodules both write the same global; this lock
17687/// serialises them under parallel test execution).
17688#[cfg(test)]
17689pub(crate) static HISTSIZ_TEST_LOCK: Mutex<()> = Mutex::new(());
17690
17691/// Shared test mutex for histchars mutations (gsu_tests +
17692/// tests submodules both write bangchar/hatchar/hashchar atomics;
17693/// this lock serialises them under parallel test execution).
17694#[cfg(test)]
17695pub(crate) static HISTCHARS_TEST_LOCK_SHARED: Mutex<()> = Mutex::new(());
17696
17697#[cfg(test)]
17698mod gsu_tests {
17699    use super::*;
17700
17701    #[test]
17702    fn test_libc_id_callbacks_match_libc() {
17703        let _g = crate::test_util::global_state_lock();
17704        assert_eq!(uidgetfn(), unsafe { libc::getuid() } as i64);
17705        assert_eq!(gidgetfn(), unsafe { libc::getgid() } as i64);
17706        assert_eq!(euidgetfn(), unsafe { libc::geteuid() } as i64);
17707        assert_eq!(egidgetfn(), unsafe { libc::getegid() } as i64);
17708    }
17709
17710    /// Pin: `usernamegetfn` routes through `get_username()` per
17711    /// `Src/params.c:4658` (which refreshes cache on uid change
17712    /// per `Src/utils.c:1082`). The previous Rust port read a
17713    /// stale cached value directly. Verify the getter returns
17714    /// the same name as a direct libc `getpwuid(getuid())` —
17715    /// confirming the path WENT through the refresh helper, not
17716    /// the stale paramtab Mutex.
17717    #[test]
17718    fn usernamegetfn_matches_libc_getpwuid_for_current_uid() {
17719        let _g = crate::test_util::global_state_lock();
17720        let __pm = crate::ported::zsh_h::param::default();
17721        let uname = usernamegetfn(&__pm);
17722        // The current process is running as some uid; the getter
17723        // must return either a populated name OR an empty string
17724        // (when getpwuid fails, e.g. sandboxed builds). It must
17725        // NOT panic and must NOT return a stale cached value
17726        // from a different uid.
17727        let direct = unsafe {
17728            let pw = libc::getpwuid(libc::getuid());
17729            if pw.is_null() {
17730                String::new()
17731            } else {
17732                std::ffi::CStr::from_ptr((*pw).pw_name)
17733                    .to_string_lossy()
17734                    .into_owned()
17735            }
17736        };
17737        assert_eq!(
17738            uname, direct,
17739            "c:4658 — usernamegetfn must match getpwuid(getuid())->pw_name"
17740        );
17741    }
17742
17743    #[test]
17744    fn test_random_returns_15_bit_value() {
17745        let _g = crate::test_util::global_state_lock();
17746        for _ in 0..100 {
17747            let v = randomgetfn();
17748            assert!(v >= 0 && v < 0x8000);
17749        }
17750    }
17751
17752    #[test]
17753    fn test_random_set_seeds_deterministically() {
17754        let _g = crate::test_util::global_state_lock();
17755        randomsetfn(42);
17756        let a = randomgetfn();
17757        randomsetfn(42);
17758        let b = randomgetfn();
17759        assert_eq!(a, b);
17760    }
17761
17762    #[test]
17763    fn test_ifs_round_trip() {
17764        let _g = crate::test_util::global_state_lock();
17765        let mut __pm = crate::ported::zsh_h::param::default();
17766        let original = ifsgetfn(&__pm);
17767        ifssetfn(&mut __pm, ":,;".to_string());
17768        assert_eq!(ifsgetfn(&__pm), ":,;");
17769        ifssetfn(&mut __pm, original);
17770    }
17771
17772    #[test]
17773    fn test_histsiz_clamps_to_1() {
17774        let _g = crate::test_util::global_state_lock();
17775        let _g = HISTSIZ_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
17776        let original = histsizegetfn();
17777        histsizesetfn(0);
17778        assert_eq!(histsizegetfn(), 1);
17779        histsizesetfn(-5);
17780        assert_eq!(histsizegetfn(), 1);
17781        histsizesetfn(500);
17782        assert_eq!(histsizegetfn(), 500);
17783        histsizesetfn(original);
17784    }
17785
17786    #[test]
17787    fn test_savehistsiz_clamps_to_0() {
17788        let _g = crate::test_util::global_state_lock();
17789        let original = savehistsizegetfn();
17790        savehistsizesetfn(-5);
17791        assert_eq!(savehistsizegetfn(), 0);
17792        savehistsizesetfn(100);
17793        assert_eq!(savehistsizegetfn(), 100);
17794        savehistsizesetfn(original);
17795    }
17796
17797    /// Pin: `savehistsizesetfn` syncs BOTH storage mirrors so the
17798    /// twin-storage Rust adaptation behaves like the single global
17799    /// in C. The params.rs Mutex<i64> drives `$SAVEHIST` reads;
17800    /// the hist.rs AtomicI64 drives the history-file writer cap.
17801    /// Previously only the params.rs side was written, so
17802    /// `SAVEHIST=10000` left hist.rs at 0 and the writer would
17803    /// cap at zero lines.
17804    #[test]
17805    fn savehistsizesetfn_syncs_to_hist_module() {
17806        let _g = crate::test_util::global_state_lock();
17807        let original_params = savehistsizegetfn();
17808        let original_hist = savehistsiz.load(Ordering::SeqCst);
17809        // Set via the setfn — both storages must reflect the value.
17810        savehistsizesetfn(12345);
17811        assert_eq!(
17812            savehistsizegetfn(),
17813            12345,
17814            "c:4994 — params.rs Mutex<i64> reflects new value"
17815        );
17816        assert_eq!(
17817            savehistsiz.load(Ordering::SeqCst),
17818            12345,
17819            "c:4994 — hist.rs AtomicI64 synced (was the previous gap)"
17820        );
17821        // Negative clamps to 0 in BOTH stores.
17822        savehistsizesetfn(-99);
17823        assert_eq!(savehistsizegetfn(), 0, "c:4998 — params.rs clamps to 0");
17824        assert_eq!(
17825            savehistsiz.load(Ordering::SeqCst),
17826            0,
17827            "c:4998 — hist.rs clamps to 0 too"
17828        );
17829        // Restore.
17830        savehistsizesetfn(original_params);
17831        savehistsiz.store(original_hist, Ordering::SeqCst);
17832    }
17833
17834    #[test]
17835    fn test_pipestat_round_trip() {
17836        let _g = crate::test_util::global_state_lock();
17837        pipestatsetfn(Some(vec![
17838            "1".to_string(),
17839            "0".to_string(),
17840            "127".to_string(),
17841        ]));
17842        let v = pipestatgetfn();
17843        assert_eq!(v, vec!["1", "0", "127"]);
17844        pipestatsetfn(None);
17845        assert_eq!(pipestatgetfn(), Vec::<String>::new());
17846    }
17847
17848    /// Pin: `setnumvalue` actually STORES the scalar string per
17849    /// `Src/params.c:2862-2872`. The previous Rust port computed
17850    /// the string then dropped it via `let _ = s;` — meaning a
17851    /// numeric assignment to a SCALAR param stored NOTHING.
17852    ///
17853    /// C body for PM_SCALAR: `setstrvalue(v, convbase_underscore(
17854    /// val.u.l, pm->base, pm->width));`. We pin the round-trip
17855    /// for an integer assigned to a scalar param.
17856    #[test]
17857    fn setnumvalue_stores_int_value_into_scalar_pm() {
17858        let _g = crate::test_util::global_state_lock();
17859        // c:2860 — setnumvalue bails when unset(EXECOPT). The unit-test
17860        // env doesn't run through createoptiontable so we set "exec"
17861        // explicitly to simulate normal runtime.
17862        let saved_exec = opt_state_get("exec").unwrap_or(false);
17863        opt_state_set("exec", true);
17864        // Build a scalar Param with no special base/width.
17865        let mut pm = Box::new(param {
17866            node: hashnode {
17867                next: None,
17868                nam: "x".to_string(),
17869                flags: PM_SCALAR as i32,
17870            },
17871            u_data: 0,
17872            u_tied: None,
17873            u_arr: None,
17874            u_str: Some(String::new()),
17875            u_val: 0,
17876            u_dval: 0.0,
17877            u_hash: None,
17878            gsu_s: None,
17879            gsu_i: None,
17880            gsu_f: None,
17881            gsu_a: None,
17882            gsu_h: None,
17883            base: 0,
17884            width: 0,
17885            env: None,
17886            ename: None,
17887            old: None,
17888            level: 0,
17889        });
17890        let mut v = value {
17891            pm: Some(pm.clone()),
17892            arr: Vec::new(),
17893            scanflags: 0,
17894            valflags: 0,
17895            start: 0,
17896            end: -1,
17897        };
17898        let val = mnumber {
17899            l: 42,
17900            d: 0.0,
17901            type_: MN_INTEGER,
17902        };
17903        setnumvalue(Some(&mut v), val);
17904        // c:2871 — the scalar storage now holds "42".
17905        let stored = v.pm.as_ref().unwrap().u_str.clone().unwrap_or_default();
17906        assert_eq!(
17907            stored, "42",
17908            "c:2871 — setnumvalue must store the rendered integer; \
17909             was previously dropped via `let _ = s;`"
17910        );
17911        let _ = pm;
17912        opt_state_set("exec", saved_exec);
17913    }
17914
17915    #[test]
17916    fn test_simple_arrayuniq_first_wins() {
17917        let _g = crate::test_util::global_state_lock();
17918        let v = vec![
17919            "a".to_string(),
17920            "b".to_string(),
17921            "a".to_string(),
17922            "c".to_string(),
17923        ];
17924        assert_eq!(simple_arrayuniq(v), vec!["a", "b", "c"]);
17925    }
17926
17927    #[test]
17928    fn test_split_env_string() {
17929        let _g = crate::test_util::global_state_lock();
17930        assert_eq!(
17931            split_env_string("PATH=/usr/bin:/bin"),
17932            Some(("PATH".to_string(), "/usr/bin:/bin".to_string()))
17933        );
17934        assert_eq!(
17935            split_env_string("EMPTY="),
17936            Some(("EMPTY".to_string(), "".to_string()))
17937        );
17938        assert_eq!(split_env_string("NOEQUALS"), None);
17939    }
17940
17941    #[test]
17942    fn test_mkenvstr() {
17943        let _g = crate::test_util::global_state_lock();
17944        assert_eq!(mkenvstr("PATH", "/usr/bin", 0), "PATH=/usr/bin");
17945        assert_eq!(mkenvstr("EMPTY", "", 0), "EMPTY=");
17946    }
17947
17948    #[test]
17949    fn test_seconds_round_trip() {
17950        let _g = crate::test_util::global_state_lock();
17951        intsecondssetfn(0);
17952        let s1 = intsecondsgetfn();
17953        std::thread::sleep(Duration::from_millis(5));
17954        let s2 = intsecondsgetfn();
17955        assert!(s2 >= s1);
17956        // Reset to a known offset and read back.
17957        setrawseconds(100.0);
17958        assert_eq!(getrawseconds(), 100.0);
17959    }
17960
17961    #[test]
17962    fn test_argzero_round_trip() {
17963        let _g = crate::test_util::global_state_lock();
17964        // pm is UNUSED in argzerosetfn / argzerogetfn (C signature
17965        // matches Rust). Use Param::default() as the dummy carrier.
17966        let mut pm = param::default();
17967        argzerosetfn(&mut pm, "/bin/zsh".to_string());
17968        assert_eq!(argzerogetfn(&pm), "/bin/zsh");
17969        argzerosetfn(&mut pm, String::new());
17970    }
17971
17972    #[test]
17973    fn test_env_get_set() {
17974        let _g = crate::test_util::global_state_lock();
17975        let result = zputenv("ZSHRS_TEST_VAR=hello");
17976        assert_eq!(result, 0);
17977        assert_eq!(zgetenv("ZSHRS_TEST_VAR"), Some("hello".to_string()));
17978        delenv("ZSHRS_TEST_VAR");
17979        assert_eq!(zgetenv("ZSHRS_TEST_VAR"), None);
17980    }
17981
17982    #[test]
17983    fn test_keyboardhack_one_char() {
17984        let _g = crate::test_util::global_state_lock();
17985        let mut __pm = crate::ported::zsh_h::param::default();
17986        keyboardhacksetfn(&mut __pm, "\\".to_string());
17987        assert_eq!(keyboardhackgetfn(&__pm), "\\");
17988        keyboardhacksetfn(&mut __pm, String::new());
17989        assert_eq!(keyboardhackgetfn(&__pm), "");
17990    }
17991
17992    /// Pin: `keyboardhacksetfn` accepts ASCII chars cleanly per
17993    /// `Src/params.c:5040-5060`. Tests the canonical happy path
17994    /// — single ASCII char, empty input, and the ASCII guard.
17995    ///
17996    /// The previous Rust port skipped `unmetafy(x, &len)` (c:5044)
17997    /// before the length and ASCII checks. This test exercises
17998    /// the surface API; the unmetafy fix is doc-pinned in the
17999    /// fn body since constructing Meta-encoded String values for
18000    /// the test fixture would require unsafe (Rust strings must
18001    /// be valid UTF-8 and the Meta byte 0x83 is not a valid
18002    /// UTF-8 lead).
18003    #[test]
18004    fn keyboardhacksetfn_handles_ascii_and_empty() {
18005        let _g = crate::test_util::global_state_lock();
18006        let mut __pm = crate::ported::zsh_h::param::default();
18007        // c:5056 — single ASCII char stored.
18008        keyboardhacksetfn(&mut __pm, ";".to_string());
18009        assert_eq!(
18010            keyboardhackgetfn(&__pm),
18011            ";",
18012            "c:5056 — single ASCII char stored verbatim"
18013        );
18014        // c:5056 — different ASCII char stored.
18015        keyboardhacksetfn(&mut __pm, ",".to_string());
18016        assert_eq!(keyboardhackgetfn(&__pm), ",");
18017        // c:5058 — empty input clears to '\0'.
18018        keyboardhacksetfn(&mut __pm, String::new());
18019        assert_eq!(keyboardhackgetfn(&__pm), "");
18020    }
18021
18022    #[test]
18023    fn test_histchars_default() {
18024        let _g = crate::test_util::global_state_lock();
18025        let _g = HISTCHARS_TEST_LOCK_SHARED
18026            .lock()
18027            .unwrap_or_else(|e| e.into_inner());
18028        histcharssetfn(&mut param::default(), "!^#".to_string());
18029        assert_eq!(histcharsgetfn(&param::default()), "!^#");
18030        histcharssetfn(&mut param::default(), "@$&".to_string());
18031        assert_eq!(histcharsgetfn(&param::default()), "@$&");
18032        // c:5095-5097 — an empty string is len 0, not NULL: all three
18033        // characters become '\0' and the value reads back empty.
18034        histcharssetfn(&mut param::default(), String::new());
18035        assert_eq!(bangchar.load(Ordering::SeqCst), 0);
18036        assert_eq!(histcharsgetfn(&param::default()), "");
18037        histcharssetfn(&mut param::default(), "!^#".to_string());
18038    }
18039
18040    /// Pin: `histcharssetfn` runs `unmetafy` per Src/params.c:5086
18041    /// BEFORE the length truncation and ASCII guard. Previously
18042    /// the Rust port skipped unmetafy, so a Meta-pair would
18043    /// inflate the byte count past 3 and the truncation would
18044    /// drop valid characters.
18045    ///
18046    /// Test the happy path: 1-char, 2-char, 3-char ASCII inputs
18047    /// all parse correctly and each char-position fills the
18048    /// matching atomic.
18049    #[test]
18050    fn histcharssetfn_handles_1_2_3_char_inputs() {
18051        let _g = crate::test_util::global_state_lock();
18052        let _g = HISTCHARS_TEST_LOCK_SHARED
18053            .lock()
18054            .unwrap_or_else(|e| e.into_inner());
18055        // 1-char: bangchar=='Q', hatchar=='\0', hashchar=='\0'.
18056        histcharssetfn(&mut param::default(), "Q".to_string());
18057        assert_eq!(bangchar.load(Ordering::SeqCst), b'Q' as i32);
18058        assert_eq!(hatchar.load(Ordering::SeqCst), 0);
18059        assert_eq!(hashchar.load(Ordering::SeqCst), 0);
18060        // 2-char: bangchar=='X', hatchar=='Y', hashchar=='\0'.
18061        histcharssetfn(&mut param::default(), "XY".to_string());
18062        assert_eq!(bangchar.load(Ordering::SeqCst), b'X' as i32);
18063        assert_eq!(hatchar.load(Ordering::SeqCst), b'Y' as i32);
18064        assert_eq!(hashchar.load(Ordering::SeqCst), 0);
18065        // 3-char: bangchar=='A', hatchar=='B', hashchar=='C'.
18066        histcharssetfn(&mut param::default(), "ABC".to_string());
18067        assert_eq!(bangchar.load(Ordering::SeqCst), b'A' as i32);
18068        assert_eq!(hatchar.load(Ordering::SeqCst), b'B' as i32);
18069        assert_eq!(hashchar.load(Ordering::SeqCst), b'C' as i32);
18070        // 4+ char: c:5087-5088 truncates to 3.
18071        histcharssetfn(&mut param::default(), "WXYZ".to_string());
18072        assert_eq!(bangchar.load(Ordering::SeqCst), b'W' as i32);
18073        assert_eq!(hatchar.load(Ordering::SeqCst), b'X' as i32);
18074        assert_eq!(hashchar.load(Ordering::SeqCst), b'Y' as i32);
18075        // Back to the default characters (an explicit value: "" is not NULL).
18076        histcharssetfn(&mut param::default(), "!^#".to_string());
18077        assert_eq!(bangchar.load(Ordering::SeqCst), b'!' as i32);
18078        assert_eq!(hatchar.load(Ordering::SeqCst), b'^' as i32);
18079        assert_eq!(hashchar.load(Ordering::SeqCst), b'#' as i32);
18080    }
18081}
18082
18083// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
18084// ─── RUST-ONLY ACCESSORS ───
18085//
18086// Singleton accessor ported for `OnceLock<Mutex<T>>` / `OnceLock<
18087// RwLock<T>>` globals declared above. C zsh uses direct global
18088// access; Rust needs these wrappers because `OnceLock::get_or_init`
18089// is the only way to lazily construct shared state. These ported sit
18090// here so the body of this file reads in C source order without
18091// the accessor wrappers interleaved between real port ported.
18092// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
18093
18094// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
18095// ─── RUST-ONLY ACCESSORS ───
18096//
18097// Singleton accessor ported for `OnceLock<Mutex<T>>` / `OnceLock<
18098// RwLock<T>>` globals declared above. C zsh uses direct global
18099// access; Rust needs these wrappers because `OnceLock::get_or_init`
18100// is the only way to lazily construct shared state. These ported sit
18101// here so the body of this file reads in C source order without
18102// the accessor wrappers interleaved between real port ported.
18103// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
18104
18105// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
18106// ─── RUST-ONLY ACCESSORS ───
18107//
18108// Singleton accessor ported for `OnceLock<Mutex<T>>` / `OnceLock<
18109// RwLock<T>>` globals declared above. C zsh uses direct global
18110// access; Rust needs these wrappers because `OnceLock::get_or_init`
18111// is the only way to lazily construct shared state. These ported sit
18112// here so the body of this file reads in C source order without
18113// the accessor wrappers interleaved between real port ported.
18114// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
18115
18116/// !!! WARNING: RUST-ONLY HELPER !!!
18117/// No C counterpart: `OnceLock` accessor for the C global `static Param foundparam`
18118/// (`Src/params.c:640`).
18119fn foundparam_lock() -> &'static Mutex<Option<String>> {
18120    FOUNDPARAM.get_or_init(|| Mutex::new(None))
18121}
18122
18123/// !!! WARNING: RUST-ONLY HELPER !!!
18124/// No C counterpart: `OnceLock` accessor for the C global
18125/// `mod_export HashTable paramtab, realparamtab;` (`Src/params.c:515`). C names the
18126/// global directly; Rust needs a function to lazily construct the `RwLock`.
18127/// Accessor for the global `paramtab` (Src/params.c:515).
18128/// Mirrors C's `paramtab->...` dereference by handing back the
18129/// inner RwLock; callers `.read()` for lookups and `.write()` for
18130/// mutation, operating on the `HashMap<String, Param>` directly.
18131pub fn paramtab() -> &'static RwLock<hashtable_nodes<Param>> {
18132    // c:854 — `paramtab = realparamtab = newparamtable(151, "paramtab");`
18133    PARAMTAB_INNER.get_or_init(|| RwLock::new(hashtable_nodes::newhashtable(151)))
18134}
18135
18136/// !!! WARNING: RUST-ONLY HELPER !!!
18137/// No C counterpart: `OnceLock` accessor for the C global `realparamtab`, declared
18138/// alongside `paramtab` at `Src/params.c:515`.
18139/// Accessor for the global `realparamtab` (Src/params.c:515).
18140/// Same role as `paramtab` for the not-currently-redirected case;
18141/// the alias-flip during assoc-array iteration isn't modelled yet.
18142pub fn realparamtab() -> &'static RwLock<hashtable_nodes<Param>> {
18143    // c:854 — same 151-bucket table C aliases `paramtab` onto.
18144    REALPARAMTAB_INNER.get_or_init(|| RwLock::new(hashtable_nodes::newhashtable(151)))
18145}
18146
18147/// !!! WARNING: RUST-ONLY HELPER !!!
18148/// No C counterpart: `OnceLock` accessor for the C global `static Patprog scanprog;`
18149/// (`Src/params.c:637`).
18150fn scanprog_lock() -> &'static Mutex<Option<String>> {
18151    SCANPROG.get_or_init(|| Mutex::new(None))
18152}
18153
18154/// !!! WARNING: RUST-ONLY HELPER !!!
18155/// No C counterpart: `OnceLock` accessor for the C global `static char *scanstr;`
18156/// (`Src/params.c:638`).
18157fn scanstr_lock() -> &'static Mutex<Option<String>> {
18158    SCANSTR.get_or_init(|| Mutex::new(None))
18159}
18160
18161/// !!! WARNING: RUST-ONLY HELPER !!!
18162/// No C counterpart: `OnceLock` accessor for the C global `static char **paramvals;`
18163/// (`Src/params.c:639`).
18164fn paramvals_lock() -> &'static Mutex<Vec<String>> {
18165    PARAMVALS.get_or_init(|| Mutex::new(Vec::new()))
18166}
18167
18168#[cfg(test)]
18169mod tests {
18170    use super::*;
18171    use crate::ported::zsh_h::Pound;
18172    use crate::zsh_h::hashnode;
18173
18174    /// `environ_image` hands back exactly the environment it took: an
18175    /// added variable goes, a removed one returns AT ITS OLD POSITION
18176    /// (`env` prints the array in order), and a changed value is
18177    /// rewritten. The fork this stands in for leaves all three untouched.
18178    #[test]
18179    fn environ_image_restores_values_membership_and_order() {
18180        let _g = crate::test_util::global_state_lock();
18181        let names = ["ZQ_ENVIMG_A", "ZQ_ENVIMG_B", "ZQ_ENVIMG_C"];
18182        for n in names {
18183            env::set_var(n, "outer");
18184        }
18185        let position = |name: &str| env::vars_os().position(|(k, _)| k == name);
18186        let before: Vec<_> = names.iter().map(|n| position(n)).collect();
18187
18188        let img = environ_image::save();
18189        env::set_var("ZQ_ENVIMG_B", "inner-and-longer");
18190        env::remove_var("ZQ_ENVIMG_A");
18191        env::set_var("ZQ_ENVIMG_NEW", "inner");
18192        img.restore();
18193
18194        for n in names {
18195            assert_eq!(env::var(n).as_deref(), Ok("outer"), "{n}");
18196        }
18197        assert!(env::var_os("ZQ_ENVIMG_NEW").is_none());
18198        let after: Vec<_> = names.iter().map(|n| position(n)).collect();
18199        assert_eq!(before, after, "environ order changed");
18200
18201        // A body that changed nothing leaves the array alone.
18202        let img = environ_image::save();
18203        img.restore();
18204        let again: Vec<_> = names.iter().map(|n| position(n)).collect();
18205        assert_eq!(before, again);
18206        for n in names {
18207            env::remove_var(n);
18208        }
18209    }
18210
18211    /// `setscope_base` pushes the param name onto `SCOPEREFS[base]`
18212    /// when `base > pm.level` (c:6440). Grows the SCOPEREFS Vec as
18213    /// needed (c:6442-6447).
18214    #[test]
18215    fn setscope_base_pushes_name_when_base_above_level() {
18216        let _g = crate::test_util::global_state_lock();
18217        SCOPEREFS.with(|s| s.borrow_mut().clear());
18218        let mut pm = param {
18219            node: hashnode {
18220                next: None,
18221                nam: "foo".to_string(),
18222                flags: 0,
18223            },
18224            u_data: 0,
18225            u_tied: None,
18226            u_arr: None,
18227            u_str: None,
18228            u_val: 0,
18229            u_dval: 0.0,
18230            u_hash: None,
18231            gsu_s: None,
18232            gsu_i: None,
18233            gsu_f: None,
18234            gsu_a: None,
18235            gsu_h: None,
18236            base: 0,
18237            width: 0,
18238            env: None,
18239            ename: None,
18240            old: None,
18241            level: 2,
18242        };
18243        setscope_base(&mut pm, 5);
18244        assert_eq!(pm.base, 5);
18245        SCOPEREFS.with(|s| {
18246            let s = s.borrow();
18247            assert!(s.len() >= 6, "SCOPEREFS grew to fit index 5");
18248            assert_eq!(s[5], vec!["foo".to_string()]);
18249        });
18250    }
18251
18252    /// `assignaparam` rejects slice into PM_HASHED with the canonical
18253    /// "attempt to set slice of associative array" zerr (c:3386-3390).
18254    #[test]
18255    fn assignaparam_rejects_slice_into_hashed() {
18256        let _g = crate::test_util::global_state_lock();
18257        opt_state_set("exec", true);
18258        unsetparam("aa_h");
18259        // Create a hashed param.
18260        sethparam("aa_h", vec!["k".to_string(), "v".to_string()]);
18261        let before = paramtab().read().unwrap().contains_key("aa_h");
18262        assert!(before);
18263        // Slice write should be rejected.
18264        let result = assignaparam("aa_h[idx]", vec!["x".to_string()], 0);
18265        assert!(result.is_none(), "slice into hashed must return None");
18266        unsetparam("aa_h");
18267        opt_state_set("exec", false);
18268    }
18269
18270    /// `assignaparam` on a PM_NAMEREF param resolves the chain first
18271    /// (fetchvalue at c:3392): a ref bound to a not-yet-defined name
18272    /// REDIRECTS the array assignment, creating the TARGET as an
18273    /// array (K01nameref.ztst "assign new array via nameref":
18274    /// `typeset -n ptr=var; ptr=(val1 val2)` → `typeset -g -a var`).
18275    /// The "can't change type of a named reference" rejection
18276    /// (c:3395-3398) only fires for PLACEHOLDER refs (empty refname),
18277    /// covered by the K01 test-setting-ref matrix.
18278    #[test]
18279    fn assignaparam_rejects_nameref_type_change() {
18280        let _g = crate::test_util::global_state_lock();
18281        opt_state_set("exec", true);
18282        unsetparam("aa_nr");
18283        // Insert a PM_NAMEREF param directly.
18284        let pm = param {
18285            node: hashnode {
18286                next: None,
18287                nam: "aa_nr".to_string(),
18288                flags: (PM_NAMEREF | PM_SCALAR) as i32,
18289            },
18290            u_data: 0,
18291            u_tied: None,
18292            u_arr: None,
18293            u_str: Some("target".to_string()),
18294            u_val: 0,
18295            u_dval: 0.0,
18296            u_hash: None,
18297            gsu_s: None,
18298            gsu_i: None,
18299            gsu_f: None,
18300            gsu_a: None,
18301            gsu_h: None,
18302            base: 0,
18303            width: 0,
18304            env: None,
18305            ename: None,
18306            old: None,
18307            level: 0,
18308        };
18309        paramtab()
18310            .write()
18311            .unwrap()
18312            .insert("aa_nr".to_string(), Box::new(pm));
18313
18314        unsetparam("target");
18315        let result = assignaparam("aa_nr", vec!["a".to_string(), "b".to_string()], 0);
18316        assert!(
18317            result.is_some(),
18318            "assignment redirects to the ref's target (c:3392 fetchvalue resolve)"
18319        );
18320
18321        // PM_NAMEREF flag still present on the REF (not stripped).
18322        let pm = paramtab().read().unwrap().get("aa_nr").cloned().unwrap();
18323        assert_ne!(pm.node.flags as u32 & PM_NAMEREF, 0, "PM_NAMEREF preserved");
18324        assert_eq!(
18325            pm.u_str.as_deref(),
18326            Some("target"),
18327            "refname unchanged by assignment-through"
18328        );
18329        // The TARGET was created as an array with the value.
18330        let t = paramtab().read().unwrap().get("target").cloned().unwrap();
18331        assert_eq!(
18332            t.u_arr.as_deref(),
18333            Some(&["a".to_string(), "b".to_string()][..]),
18334            "target holds the assigned array"
18335        );
18336
18337        unsetparam("target");
18338        unsetparam("aa_nr");
18339        opt_state_set("exec", false);
18340    }
18341
18342    /// `assignaparam` with ASSPM_AUGMENT against a scalar param must
18343    /// prepend the previous scalar value as `val[0]` of the new array
18344    /// (c:3404-3412). Implements `a=x; a+=(y z)` → `a=(x y z)`. A
18345    /// regression that drops the prepend would yield `a=(y z)` and
18346    /// silently lose the original scalar — invisible at write time,
18347    /// surfaces only when the caller reads `${a[1]}`.
18348    #[test]
18349    fn assignaparam_augment_prepends_old_scalar() {
18350        let _g = crate::test_util::global_state_lock();
18351        opt_state_set("exec", true);
18352        unsetparam("aa_aug");
18353        // Seed a scalar.
18354        setsparam("aa_aug", "old");
18355        // Augment with two new values.
18356        let pm = assignaparam(
18357            "aa_aug",
18358            vec!["new1".to_string(), "new2".to_string()],
18359            ASSPM_AUGMENT,
18360        )
18361        .expect("augment should succeed");
18362        let arr = pm.u_arr.expect("ASSPM_AUGMENT must produce u_arr");
18363        assert_eq!(
18364            arr,
18365            vec!["old".to_string(), "new1".to_string(), "new2".to_string()],
18366            "c:3408-3411 — scalar prepended at index 0, then new values follow"
18367        );
18368        unsetparam("aa_aug");
18369        opt_state_set("exec", false);
18370    }
18371
18372    /// `assignaparam` against a PM_UNIQUE-flagged target dedupes
18373    /// the value array (c:3401 + arrsetfn's uniqarray). Implements
18374    /// `typeset -U arr; arr=(a b a c b)` → `arr=(a b c)`. A
18375    /// regression that drops the uniqarray call would let duplicates
18376    /// linger — invisible until a downstream `[[ -n ${arr[(r)b]} ]]`
18377    /// check counts more matches than expected, or `$path` grows
18378    /// unbounded with repeated directory entries.
18379    #[test]
18380    fn assignaparam_unique_flag_dedupes_values() {
18381        let _g = crate::test_util::global_state_lock();
18382        opt_state_set("exec", true);
18383        unsetparam("aa_uniq");
18384        // Seed an empty PM_ARRAY|PM_UNIQUE.
18385        setaparam("aa_uniq", vec![]);
18386        {
18387            let mut tab = paramtab().write().unwrap();
18388            let pm = tab.get_mut("aa_uniq").expect("aa_uniq must exist");
18389            pm.node.flags |= PM_UNIQUE as i32;
18390        }
18391        // Now write duplicates; PM_UNIQUE must collapse them.
18392        let pm = assignaparam(
18393            "aa_uniq",
18394            vec!["a".into(), "b".into(), "a".into(), "c".into(), "b".into()],
18395            0,
18396        )
18397        .expect("assignment succeeds");
18398        let arr = pm.u_arr.expect("u_arr populated");
18399        assert_eq!(
18400            arr,
18401            vec!["a".to_string(), "b".to_string(), "c".to_string()],
18402            "c:3401 — PM_UNIQUE collapses duplicates, keeping first occurrence"
18403        );
18404        // The PM_UNIQUE flag must persist through the assignment.
18405        let pm_check = paramtab().read().unwrap().get("aa_uniq").cloned().unwrap();
18406        assert_ne!(
18407            pm_check.node.flags as u32 & PM_UNIQUE,
18408            0,
18409            "PM_UNIQUE flag preserved across assignment"
18410        );
18411        unsetparam("aa_uniq");
18412        opt_state_set("exec", false);
18413    }
18414
18415    /// `getsparam` reads PM_INTEGER params via convbase, not via
18416    /// `u_str` (which is None for typed integers). Pins the latent
18417    /// bug fix that made `read REPLY` after `(( REPLY=42 ))` return
18418    /// nothing — every numeric param read used to fall through to
18419    /// the OS env-var fallback.
18420    #[test]
18421    fn getsparam_returns_integer_via_convbase() {
18422        let _g = crate::test_util::global_state_lock();
18423        opt_state_set("exec", true);
18424        unsetparam("gs_int");
18425        setiparam("gs_int", 999);
18426        assert_eq!(getsparam("gs_int").as_deref(), Some("999"));
18427        unsetparam("gs_int");
18428        opt_state_set("exec", false);
18429    }
18430
18431    /// `getsparam` reads PM_FFLOAT params via convfloat. Same fix
18432    /// shape as the PM_INTEGER path.
18433    #[test]
18434    fn getsparam_returns_float_via_convfloat() {
18435        let _g = crate::test_util::global_state_lock();
18436        opt_state_set("exec", true);
18437        unsetparam("gs_f");
18438        // Stash a float via the setnumvalue / setnparam path.
18439        let v = mnumber {
18440            l: 0,
18441            d: 2.5,
18442            type_: MN_FLOAT,
18443        };
18444        setnparam("gs_f", v);
18445        let s = getsparam("gs_f").expect("PM_FFLOAT should serialize");
18446        // convfloat formats with default precision; just check it's
18447        // not empty and parses back.
18448        assert!(
18449            s.parse::<f64>()
18450                .map(|f| (f - 2.5).abs() < 1e-6)
18451                .unwrap_or(false),
18452            "expected ~2.5 round-trip, got {:?}",
18453            s
18454        );
18455        unsetparam("gs_f");
18456        opt_state_set("exec", false);
18457    }
18458
18459    /// `getsparam` against a non-default `pm.base` integer param must
18460    /// render using that base via `convbase`. The c:2364 dispatch
18461    /// passes `pm.base` (or 10) to convbase; a regression that
18462    /// hardcodes base=10 would silently break `typeset -i 16 hex=255`
18463    /// readers (would see "255" instead of the C-faithful "16#FF").
18464    #[test]
18465    fn getsparam_integer_honors_pm_base() {
18466        let _g = crate::test_util::global_state_lock();
18467        opt_state_set("exec", true);
18468        unsetparam("hex_param");
18469        // Manually insert a PM_INTEGER param with base=16.
18470        let pm = param {
18471            node: hashnode {
18472                next: None,
18473                nam: "hex_param".to_string(),
18474                flags: PM_INTEGER as i32,
18475            },
18476            u_data: 0,
18477            u_tied: None,
18478            u_arr: None,
18479            u_str: None,
18480            u_val: 255,
18481            u_dval: 0.0,
18482            u_hash: None,
18483            gsu_s: None,
18484            gsu_i: None,
18485            gsu_f: None,
18486            gsu_a: None,
18487            gsu_h: None,
18488            base: 16,
18489            width: 0,
18490            env: None,
18491            ename: None,
18492            old: None,
18493            level: 0,
18494        };
18495        paramtab()
18496            .write()
18497            .unwrap()
18498            .insert("hex_param".to_string(), Box::new(pm));
18499
18500        let s = getsparam("hex_param").expect("PM_INTEGER must serialize");
18501        // convbase emits "16#FF" form for base 16.
18502        assert!(
18503            s.contains("FF") || s.contains("ff"),
18504            "c:2364 — base-16 must render hex digits; got {:?}",
18505            s
18506        );
18507
18508        unsetparam("hex_param");
18509        opt_state_set("exec", false);
18510    }
18511
18512    /// `endparamscope` clears `SCOPEREFS[old_locallevel]` and resets
18513    /// nameref params' base when their base exceeds the new locallevel.
18514    /// End-to-end of the setscope_base writer + endparamscope reader.
18515    #[test]
18516    fn endparamscope_resets_scoperefs_and_nameref_base() {
18517        let _g = crate::test_util::global_state_lock();
18518        SCOPEREFS.with(|s| s.borrow_mut().clear());
18519
18520        // Set up: locallevel = 3, push a PM_NAMEREF param with base=5 onto SCOPEREFS[3].
18521        set_locallevel(3);
18522        let pm = param {
18523            node: hashnode {
18524                next: None,
18525                nam: "ref1".to_string(),
18526                flags: (PM_NAMEREF | PM_SCALAR) as i32,
18527            },
18528            u_data: 0,
18529            u_tied: None,
18530            u_arr: None,
18531            u_str: Some(String::new()),
18532            u_val: 0,
18533            u_dval: 0.0,
18534            u_hash: None,
18535            gsu_s: None,
18536            gsu_i: None,
18537            gsu_f: None,
18538            gsu_a: None,
18539            gsu_h: None,
18540            base: 5,
18541            width: 0,
18542            env: None,
18543            ename: None,
18544            old: None,
18545            level: 0,
18546        };
18547        paramtab()
18548            .write()
18549            .unwrap()
18550            .insert("ref1".to_string(), Box::new(pm));
18551        // Populate SCOPEREFS[3] manually with "ref1".
18552        SCOPEREFS.with(|sr| {
18553            let mut sr = sr.borrow_mut();
18554            sr.resize(8, Vec::new());
18555            sr[3].push("ref1".to_string());
18556        });
18557
18558        endparamscope();
18559
18560        // After endparamscope: locallevel decremented to 2; "ref1"'s
18561        // base reset to 0 (was 5 > new ll=2). SCOPEREFS[3] cleared.
18562        let pm_after = paramtab().read().unwrap().get("ref1").cloned();
18563        assert!(pm_after.is_some(), "ref1 should still exist (level=0)");
18564        assert_eq!(pm_after.unwrap().base, 0, "PM_NAMEREF.base reset to 0");
18565        SCOPEREFS.with(|sr| {
18566            assert!(sr.borrow()[3].is_empty(), "SCOPEREFS[3] cleared");
18567        });
18568
18569        // Cleanup
18570        paramtab().write().unwrap().remove("ref1");
18571        set_locallevel(0);
18572    }
18573
18574    /// `endparamscope` MUST NOT reset `base` on PM_UPPER namerefs
18575    /// (c:5891 — the `!(pm->node.flags & PM_UPPER)` clause guards
18576    /// the reset). PM_UPPER namerefs point UPWARD in the scope chain
18577    /// (e.g. `typeset -n -u up=outer` from inside a function) and
18578    /// their base must persist across scope pops so the upward
18579    /// resolution keeps working. A regression that drops the PM_UPPER
18580    /// guard would silently degrade upward namerefs into local ones
18581    /// after the first function return.
18582    #[test]
18583    fn endparamscope_preserves_pm_upper_nameref_base() {
18584        let _g = crate::test_util::global_state_lock();
18585        SCOPEREFS.with(|s| s.borrow_mut().clear());
18586
18587        set_locallevel(3);
18588        let pm = param {
18589            node: hashnode {
18590                next: None,
18591                nam: "up_ref".to_string(),
18592                flags: (PM_NAMEREF | PM_SCALAR | PM_UPPER) as i32,
18593            },
18594            u_data: 0,
18595            u_tied: None,
18596            u_arr: None,
18597            u_str: Some(String::new()),
18598            u_val: 0,
18599            u_dval: 0.0,
18600            u_hash: None,
18601            gsu_s: None,
18602            gsu_i: None,
18603            gsu_f: None,
18604            gsu_a: None,
18605            gsu_h: None,
18606            base: 5,
18607            width: 0,
18608            env: None,
18609            ename: None,
18610            old: None,
18611            level: 0,
18612        };
18613        paramtab()
18614            .write()
18615            .unwrap()
18616            .insert("up_ref".to_string(), Box::new(pm));
18617        SCOPEREFS.with(|sr| {
18618            let mut sr = sr.borrow_mut();
18619            sr.resize(8, Vec::new());
18620            sr[3].push("up_ref".to_string());
18621        });
18622
18623        endparamscope();
18624
18625        let pm_after = paramtab()
18626            .read()
18627            .unwrap()
18628            .get("up_ref")
18629            .cloned()
18630            .expect("up_ref must survive scope pop");
18631        assert_eq!(
18632            pm_after.base, 5,
18633            "c:5891 — PM_UPPER nameref base MUST be preserved (was 5, must stay 5)"
18634        );
18635        assert_ne!(
18636            pm_after.node.flags as u32 & PM_UPPER,
18637            0,
18638            "PM_UPPER flag itself must persist"
18639        );
18640
18641        paramtab().write().unwrap().remove("up_ref");
18642        set_locallevel(0);
18643    }
18644
18645    /// `setscope_base` does NOT push when `base <= pm.level` (the C
18646    /// `if ((pm->base = base) > pm->level)` guard at c:6440 fails).
18647    #[test]
18648    fn setscope_base_no_push_when_base_below_level() {
18649        let _g = crate::test_util::global_state_lock();
18650        SCOPEREFS.with(|s| s.borrow_mut().clear());
18651        let mut pm = param {
18652            node: hashnode {
18653                next: None,
18654                nam: "bar".to_string(),
18655                flags: 0,
18656            },
18657            u_data: 0,
18658            u_tied: None,
18659            u_arr: None,
18660            u_str: None,
18661            u_val: 0,
18662            u_dval: 0.0,
18663            u_hash: None,
18664            gsu_s: None,
18665            gsu_i: None,
18666            gsu_f: None,
18667            gsu_a: None,
18668            gsu_h: None,
18669            base: 0,
18670            width: 0,
18671            env: None,
18672            ename: None,
18673            old: None,
18674            level: 10,
18675        };
18676        setscope_base(&mut pm, 3); // 3 <= 10 → no push
18677        assert_eq!(pm.base, 3);
18678        SCOPEREFS.with(|s| {
18679            // No push happened; SCOPEREFS stays empty.
18680            assert!(s.borrow().is_empty() || s.borrow().iter().all(|v| v.is_empty()));
18681        });
18682    }
18683
18684    /// `setscope_base` boundary: `base == pm.level` must NOT push.
18685    /// The C guard c:6440 is strictly `>` (`> pm->level`, not `>=`).
18686    /// A regression that uses `>=` would push every assignment at
18687    /// the current scope into SCOPEREFS, causing endparamscope to
18688    /// re-process every same-scope param on every function return —
18689    /// O(n) extra work per call PLUS spurious base resets.
18690    #[test]
18691    fn setscope_base_equal_level_does_not_push() {
18692        let _g = crate::test_util::global_state_lock();
18693        SCOPEREFS.with(|s| s.borrow_mut().clear());
18694        let mut pm = param {
18695            node: hashnode {
18696                next: None,
18697                nam: "edge".to_string(),
18698                flags: 0,
18699            },
18700            u_data: 0,
18701            u_tied: None,
18702            u_arr: None,
18703            u_str: None,
18704            u_val: 0,
18705            u_dval: 0.0,
18706            u_hash: None,
18707            gsu_s: None,
18708            gsu_i: None,
18709            gsu_f: None,
18710            gsu_a: None,
18711            gsu_h: None,
18712            base: 0,
18713            width: 0,
18714            env: None,
18715            ename: None,
18716            old: None,
18717            level: 5,
18718        };
18719        setscope_base(&mut pm, 5); // base == level → strict `>` fails
18720        assert_eq!(pm.base, 5, "base assignment always happens");
18721        SCOPEREFS.with(|sr| {
18722            let any_push = sr.borrow().iter().any(|v| !v.is_empty());
18723            assert!(
18724                !any_push,
18725                "c:6440 — `base > pm->level` is STRICT; equal must not push"
18726            );
18727        });
18728    }
18729
18730    #[test]
18731    fn test_colonarr_conversion() {
18732        let _g = crate::test_util::global_state_lock();
18733        let arr = colonsplit("/bin:/usr/bin:/usr/local/bin", false);
18734        assert_eq!(arr, vec!["/bin", "/usr/bin", "/usr/local/bin"]);
18735        let path = colonarrgetfn(&arr);
18736        assert_eq!(path, "/bin:/usr/bin:/usr/local/bin");
18737    }
18738    #[test]
18739    fn test_isident() {
18740        let _g = crate::test_util::global_state_lock();
18741        assert!(isident("foo"));
18742        assert!(isident("_bar"));
18743        assert!(isident("FOO_BAR"));
18744        assert!(isident("x123"));
18745        assert!(isident("123")); // positional params
18746        assert!(!isident(""));
18747        assert!(!isident("foo bar"));
18748    }
18749
18750    /// Pin: `isident` requires balanced `[...]` per `Src/params.c:1329-1330`:
18751    ///   if (*ss != '[') return 0;
18752    ///   if (!(ss = parse_subscript(++ss, 1, ']'))) return 0;
18753    ///
18754    /// The previous Rust port accepted ANY `[` as a valid
18755    /// terminator (`if c == '[' { return true; }`) without
18756    /// checking for a matching `]`. So `foo[` (no close) was
18757    /// accepted as a valid identifier — diverging from C which
18758    /// rejects.
18759    #[test]
18760    fn isident_requires_balanced_subscript_brackets() {
18761        let _g = crate::test_util::global_state_lock();
18762        // Balanced `[...]` is valid.
18763        assert!(
18764            isident("foo[0]"),
18765            "c:1330 — balanced [0] passes parse_subscript"
18766        );
18767        assert!(
18768            isident("foo[bar]"),
18769            "c:1330 — balanced [bar] passes parse_subscript"
18770        );
18771        // UNBALANCED — open without close — must be rejected.
18772        assert!(
18773            !isident("foo["),
18774            "c:1330 — `foo[` missing `]` MUST be rejected"
18775        );
18776        // Trailing chars after `]` — C parse_subscript returns
18777        // a position INSIDE the string, the surrounding isident
18778        // body checks that nothing follows; our port currently
18779        // returns true at the first `[` either way, but pin the
18780        // balanced case as a working invariant.
18781        assert!(isident("a[1]"), "c:1330 — short balanced subscript valid");
18782    }
18783
18784    #[test]
18785    fn test_unique_array() {
18786        let _g = crate::test_util::global_state_lock();
18787        let arr = vec!["a".into(), "b".into(), "a".into(), "c".into(), "b".into()];
18788        let result = uniqarray(arr);
18789        assert_eq!(result, vec!["a", "b", "c"]);
18790    }
18791
18792    #[test]
18793    fn test_convbase() {
18794        let _g = crate::test_util::global_state_lock();
18795        // CBASES off (default): `16#FF` / `8#7` form. The `0x.../
18796        // 0...` short-prefix output is gated on `setopt CBASES` —
18797        // see Src/params.c:5599-5605.
18798        assert_eq!(convbase(255, 16), "16#FF");
18799        assert_eq!(convbase(10, 10), "10");
18800        assert_eq!(convbase(-5, 10), "-5");
18801        assert_eq!(convbase(7, 8), "8#7");
18802        assert_eq!(convbase(5, 2), "2#101");
18803    }
18804
18805    #[test]
18806    fn test_convfloat() {
18807        let _g = crate::test_util::global_state_lock();
18808        // Use 2.5 instead of 3.14 — clippy errors on the latter as
18809        // an approx PI constant. The test checks 2-decimal formatting
18810        // round-trips, which the exact value doesn't influence.
18811        let s = convfloat(2.5, 2, PM_FFLOAT);
18812        assert!(s.starts_with("2.50"));
18813
18814        assert_eq!(convfloat(f64::INFINITY, 0, 0), "Inf");
18815        assert_eq!(convfloat(f64::NEG_INFINITY, 0, 0), "-Inf");
18816        assert_eq!(convfloat(f64::NAN, 0, 0), "NaN");
18817    }
18818
18819    #[test]
18820    fn test_getarrvalue() {
18821        let _g = crate::test_util::global_state_lock();
18822        let arr = vec!["a".into(), "b".into(), "c".into(), "d".into()];
18823        assert_eq!(getarrvalue(&arr, 2, 3), vec!["b", "c"]);
18824        assert_eq!(getarrvalue(&arr, -2, -1), vec!["c", "d"]);
18825        assert_eq!(getarrvalue(&arr, 1, 4), vec!["a", "b", "c", "d"]);
18826    }
18827
18828    #[test]
18829    fn test_setarrvalue() {
18830        let _g = crate::test_util::global_state_lock();
18831        // c:2897 — setarrvalue bails when unset(EXECOPT). Set "exec"
18832        // for the unit-test env (real zsh defaults exec=true).
18833        let saved_exec = opt_state_get("exec").unwrap_or(false);
18834        opt_state_set("exec", true);
18835        // C-faithful: setarrvalue takes a Value pointing at a Param
18836        // with u_arr set. Construct one inline.
18837        let pm = Box::new(param {
18838            node: hashnode {
18839                next: None,
18840                nam: "test".to_string(),
18841                flags: PM_ARRAY as i32,
18842            },
18843            u_data: 0,
18844            u_tied: None,
18845            u_arr: Some(vec!["a".into(), "b".into(), "c".into(), "d".into()]),
18846            u_str: None,
18847            u_val: 0,
18848            u_dval: 0.0,
18849            u_hash: None,
18850            gsu_s: None,
18851            gsu_i: None,
18852            gsu_f: None,
18853            gsu_a: None,
18854            gsu_h: None,
18855            base: 0,
18856            width: 0,
18857            env: None,
18858            ename: None,
18859            old: None,
18860            level: 0,
18861        });
18862        let mut v = value {
18863            pm: Some(pm),
18864            arr: Vec::new(),
18865            scanflags: 0,
18866            valflags: 0,
18867            start: 2,
18868            end: 3,
18869        };
18870        setarrvalue(&mut v, vec!["X".into(), "Y".into()]);
18871        let arr = v.pm.unwrap().u_arr.unwrap();
18872        assert_eq!(arr, vec!["a", "X", "Y", "d"]);
18873        opt_state_set("exec", saved_exec);
18874    }
18875
18876    #[test]
18877    fn test_valid_refname() {
18878        let _g = crate::test_util::global_state_lock();
18879        assert!(valid_refname("foo", 0));
18880        assert!(valid_refname("_bar", 0));
18881        assert!(valid_refname("1", 0));
18882        assert!(valid_refname("!", 0));
18883        // c:6497-6500 (54718) — no subscripted referents.
18884        assert!(!valid_refname("arr[1]", 0));
18885        assert!(!valid_refname("hash[y][1,2]", 0));
18886        assert!(!valid_refname("1[1]", 0));
18887        assert!(!valid_refname("![1]", 0));
18888        assert!(valid_refname(".a.b", 0));
18889        assert!(!valid_refname("not[2]good", 0));
18890        assert!(!valid_refname("arr[1", 0));
18891        assert!(!valid_refname("12x", 0));
18892        assert!(!valid_refname("@", 0));
18893        assert!(!valid_refname("", 0));
18894        // C semantics: a leader that is neither an identifier nor `! ? $ -`.
18895        assert!(!valid_refname(" ", 0));
18896        // PM_UPPER rejects digit-leader and argv/ARGC.
18897        assert!(!valid_refname("1", PM_UPPER as i32));
18898        assert!(!valid_refname("argv", PM_UPPER as i32));
18899        assert!(!valid_refname("ARGC", PM_UPPER as i32));
18900    }
18901
18902    #[test]
18903    fn test_uniq_array_empty() {
18904        let _g = crate::test_util::global_state_lock();
18905        let empty: Vec<String> = Vec::new();
18906        assert!(uniqarray(empty).is_empty());
18907    }
18908
18909    #[test]
18910    fn test_convbase_underscore() {
18911        let _g = crate::test_util::global_state_lock();
18912        let s = convbase_underscore(1234567, 10, 3);
18913        assert_eq!(s, "1_234_567");
18914    }
18915
18916    fn val_str(v: getarg_out<'_>) -> String {
18917        match v {
18918            getarg_out::Value(v) => v.to_str(),
18919            getarg_out::Flags { .. } => panic!("expected Value, got Flags"),
18920        }
18921    }
18922
18923    #[test]
18924    fn getarg_n_flag_picks_second_exact_match() {
18925        let _g = crate::test_util::global_state_lock();
18926        // C params.c:1431-1442 + 1758 — `(en.2.)pat` picks 2nd exact match.
18927        let arr: Vec<String> = vec!["foo".into(), "bar".into(), "foo".into(), "baz".into()];
18928        let out = getarg("(en.2.r)foo", Some(&arr), None, None).expect("Some");
18929        assert_eq!(val_str(out), "foo");
18930    }
18931
18932    #[test]
18933    fn getarg_n_flag_third_exact_match() {
18934        let _g = crate::test_util::global_state_lock();
18935        let arr: Vec<String> = vec!["a".into(), "a".into(), "a".into(), "b".into()];
18936        let out = getarg("(en.3.r)a", Some(&arr), None, None).expect("Some");
18937        assert_eq!(val_str(out), "a");
18938    }
18939
18940    #[test]
18941    fn getarg_n_flag_returns_index_with_i() {
18942        let _g = crate::test_util::global_state_lock();
18943        // (en.2.i) — return INDEX of 2nd exact match.
18944        let arr: Vec<String> = vec!["x".into(), "y".into(), "x".into(), "y".into()];
18945        let out = getarg("(en.2.i)x", Some(&arr), None, None).expect("Some");
18946        assert_eq!(val_str(out), "3");
18947    }
18948
18949    #[test]
18950    fn getarg_negative_n_flips_search_direction() {
18951        let _g = crate::test_util::global_state_lock();
18952        // C params.c:1488-1491 — negative `num` flips down (reverse).
18953        // (en.-1.) on forward-default search matches from the end.
18954        let arr: Vec<String> = vec!["a".into(), "a".into(), "a".into()];
18955        let out = getarg("(en.-1.i)a", Some(&arr), None, None).expect("Some");
18956        assert_eq!(val_str(out), "3");
18957    }
18958
18959    #[test]
18960    fn getarg_n_flag_zero_treated_as_one() {
18961        let _g = crate::test_util::global_state_lock();
18962        // C params.c:1438-1439 — `if (!num) num = 1`.
18963        let arr: Vec<String> = vec!["x".into(), "y".into()];
18964        let out = getarg("(en.0.r)x", Some(&arr), None, None).expect("Some");
18965        assert_eq!(val_str(out), "x");
18966    }
18967
18968    #[test]
18969    fn getarg_unknown_flag_char_returns_none() {
18970        let _g = crate::test_util::global_state_lock();
18971        // C params.c:1477-1483 flagerr — invalid flag char reports error.
18972        let arr: Vec<String> = vec!["x".into()];
18973        assert!(getarg("(z)x", Some(&arr), None, None).is_none());
18974    }
18975
18976    #[test]
18977    fn getarg_n_flag_unterminated_arg_returns_none() {
18978        let _g = crate::test_util::global_state_lock();
18979        // (n.5 missing closing delimiter — flagerr.
18980        let arr: Vec<String> = vec!["x".into()];
18981        assert!(getarg("(n.5", Some(&arr), None, None).is_none());
18982    }
18983
18984    #[test]
18985    fn getarg_b_flag_starts_search_at_index() {
18986        let _g = crate::test_util::global_state_lock();
18987        // C params.c:1748-1760 — `(b.N.e)pat` skips first N-1 elements
18988        // forward (parsed value `N`, normalized to `beg = N-1`).
18989        let arr: Vec<String> = vec!["x".into(), "y".into(), "x".into(), "y".into()];
18990        // Forward, beg=2 (skip first 2) → starts at idx 2 → 'x' at 3.
18991        let out = getarg("(b.3.ei)x", Some(&arr), None, None).expect("Some");
18992        assert_eq!(val_str(out), "3");
18993    }
18994
18995    #[test]
18996    fn getarg_b_flag_with_R_reverse_from_offset() {
18997        let _g = crate::test_util::global_state_lock();
18998        // C params.c:1750-1755 — reverse search starting at parsed-1 idx.
18999        // arr=(x y x y), beg=2 (parsed 3-1), reverse → walks 2,1,0; first
19000        // exact 'x' is at idx 2 → 1-based index 3.
19001        let arr: Vec<String> = vec!["x".into(), "y".into(), "x".into(), "y".into()];
19002        // `I` reports that index...
19003        let out = getarg("(b.3.eI)x", Some(&arr), None, None).expect("Some");
19004        assert_eq!(val_str(out), "3");
19005        // ...while `R` reports the matched VALUE, and wins when both are
19006        // given (`${arr[(b:3:eIR)x]}` → `x` in zsh 5.9).
19007        let out = getarg("(b.3.eIR)x", Some(&arr), None, None).expect("Some");
19008        assert_eq!(val_str(out), "x");
19009    }
19010
19011    #[test]
19012    fn getarg_b_flag_out_of_bounds_forward_returns_empty() {
19013        let _g = crate::test_util::global_state_lock();
19014        // c:1746 — beg >= len returns len+1 (empty for value-mode).
19015        let arr: Vec<String> = vec!["x".into()];
19016        let out = getarg("(b.5.er)x", Some(&arr), None, None).expect("Some");
19017        assert_eq!(val_str(out), "");
19018    }
19019
19020    #[test]
19021    fn getarg_b_flag_out_of_bounds_index_mode_returns_len_plus_one() {
19022        let _g = crate::test_util::global_state_lock();
19023        let arr: Vec<String> = vec!["x".into(), "y".into()];
19024        let out = getarg("(b.5.ei)x", Some(&arr), None, None).expect("Some");
19025        assert_eq!(val_str(out), "3");
19026    }
19027
19028    #[test]
19029    fn getarg_hash_neg_num_on_lowercase_r_returns_all() {
19030        let _g = crate::test_util::global_state_lock();
19031        // C params.c:1488-1491 — neg `num` flips down on `r`,
19032        // converting hash search to return-all-matches semantics.
19033        let mut h: IndexMap<String, String> = IndexMap::new();
19034        h.insert("a".into(), "1".into());
19035        h.insert("b".into(), "1".into());
19036        h.insert("c".into(), "2".into());
19037        let out = getarg("(en.-1.r)1", None, Some(&h), None).expect("Some");
19038        // r + neg = R semantics → all values where pat matches value.
19039        assert_eq!(val_str(out), "1 1");
19040    }
19041
19042    #[test]
19043    fn getarg_hash_neg_num_on_uppercase_R_returns_single() {
19044        let _g = crate::test_util::global_state_lock();
19045        // R + neg `num` un-flips back to single-match (r semantics).
19046        let mut h: IndexMap<String, String> = IndexMap::new();
19047        h.insert("a".into(), "1".into());
19048        h.insert("b".into(), "1".into());
19049        h.insert("c".into(), "2".into());
19050        let out = getarg("(en.-1.R)1", None, Some(&h), None).expect("Some");
19051        // R + neg → r → single first match.
19052        assert_eq!(val_str(out), "1");
19053    }
19054
19055    #[test]
19056    fn getarg_hash_i_flag_matches_keys_not_values() {
19057        let _g = crate::test_util::global_state_lock();
19058        // On an associative array the `i` flag matches the pattern against
19059        // KEYS (not values) and returns the matching key. A value-search
19060        // therefore finds nothing, and the `b<NUM>` begin-offset does not
19061        // skip entries for an associative key search. Verified against
19062        // zsh 5.9: `h=(a 1 b 1 c 1); ${h[(i)b]}` → "b"; `${h[(b:3:ei)1]}` → "".
19063        // (The prior expectation "c" — a b-offset skip returning a value —
19064        // does not match real zsh; the code already matches zsh.)
19065        let mut h: IndexMap<String, String> = IndexMap::new();
19066        h.insert("a".into(), "1".into());
19067        h.insert("b".into(), "1".into());
19068        h.insert("c".into(), "1".into());
19069        // `i` globs the KEY "b" and returns that key.
19070        let by_key = getarg("(i)b", None, Some(&h), None).expect("Some");
19071        assert_eq!(val_str(by_key), "b");
19072        // Searching a VALUE ("1") matches no key named "1" → empty.
19073        let by_val = getarg("(b.3.ei)1", None, Some(&h), None).expect("Some");
19074        assert_eq!(val_str(by_val), "");
19075    }
19076
19077    #[test]
19078    fn getarg_hash_I_flag_matches_all_keys() {
19079        let _g = crate::test_util::global_state_lock();
19080        // The `I` flag returns ALL matching keys. `(I)*` globs every key;
19081        // a value-search matches no key. Verified against zsh 5.9:
19082        // `h=(a 1 b 1 c 1); ${h[(I)*]}` → "a b c"; `${h[(b:2:eI)1]}` → "".
19083        // (The prior expectation "b c" for a value-search does not match
19084        // real zsh; the code already matches zsh.)
19085        let mut h: IndexMap<String, String> = IndexMap::new();
19086        h.insert("a".into(), "1".into());
19087        h.insert("b".into(), "1".into());
19088        h.insert("c".into(), "1".into());
19089        // `(I)*` matches all keys via glob, in insertion order.
19090        let all_keys = getarg("(I)*", None, Some(&h), None).expect("Some");
19091        assert_eq!(val_str(all_keys), "a b c");
19092        // Value-search "1" matches no key → empty.
19093        let out = getarg("(b.2.eI)1", None, Some(&h), None).expect("Some");
19094        assert_eq!(val_str(out), "");
19095    }
19096
19097    #[test]
19098    fn getarg_hash_b_flag_out_of_bounds_returns_empty() {
19099        let _g = crate::test_util::global_state_lock();
19100        // c:1746 — beg >= len with single-match → empty.
19101        let mut h: IndexMap<String, String> = IndexMap::new();
19102        h.insert("a".into(), "1".into());
19103        let out = getarg("(b.5.e)1", None, Some(&h), None).expect("Some");
19104        assert_eq!(val_str(out), "");
19105    }
19106
19107    #[test]
19108    fn getarg_w_flag_splits_multi_word_array_elements() {
19109        let _g = crate::test_util::global_state_lock();
19110        // C params.c:1761-1797 — `(w)N` joins array then re-splits by
19111        // IFS-default whitespace. arr=("a b" "c d"); (w)2 → "b" not "c d".
19112        let arr: Vec<String> = vec!["a b".into(), "c d".into()];
19113        let out = getarg("(w)2", Some(&arr), None, None).expect("Some");
19114        assert_eq!(val_str(out), "b");
19115    }
19116
19117    #[test]
19118    fn getarg_w_flag_simple_array_indexing_still_works() {
19119        let _g = crate::test_util::global_state_lock();
19120        let arr: Vec<String> = vec!["one".into(), "two".into(), "three".into()];
19121        let out = getarg("(w)2", Some(&arr), None, None).expect("Some");
19122        assert_eq!(val_str(out), "two");
19123    }
19124
19125    #[test]
19126    fn getarg_f_flag_splits_by_newline() {
19127        let _g = crate::test_util::global_state_lock();
19128        // C params.c:1424-1427 — `f` flag aliases `w` with sep="\n".
19129        // arr=("a b\nc d"); (f)2 → "c d" (split by \n only, not space).
19130        let arr: Vec<String> = vec!["a b\nc d".into()];
19131        let out = getarg("(f)2", Some(&arr), None, None).expect("Some");
19132        assert_eq!(val_str(out), "c d");
19133    }
19134
19135    #[test]
19136    fn getarg_scalar_w_flag_picks_nth_word() {
19137        let _g = crate::test_util::global_state_lock();
19138        // C params.c:1761-1797 — scalar word-mode arm. `(w)2` on
19139        // scalar "hello world foo" returns the 2nd whitespace word.
19140        let out = getarg("(w)2", None, None, Some("hello world foo")).expect("Some");
19141        assert_eq!(val_str(out), "world");
19142    }
19143
19144    #[test]
19145    fn getarg_scalar_w_flag_negative_index_counts_from_end() {
19146        let _g = crate::test_util::global_state_lock();
19147        let out = getarg("(w)-1", None, None, Some("alpha beta gamma")).expect("Some");
19148        assert_eq!(val_str(out), "gamma");
19149    }
19150
19151    #[test]
19152    fn getarg_scalar_re_returns_char_at_match_position() {
19153        let _g = crate::test_util::global_state_lock();
19154        // C params.c:1798-1980 — char-search returns CHAR at match
19155        // position, not full substring. Verified empirically:
19156        //   /bin/zsh -c 's="barfooxyz"; print "${s[(r)foo]}"'  → "f"
19157        let out = getarg("(re)bc", None, None, Some("abcdef")).expect("Some");
19158        assert_eq!(val_str(out), "b");
19159    }
19160
19161    #[test]
19162    fn getarg_scalar_ie_returns_position_of_first_match() {
19163        let _g = crate::test_util::global_state_lock();
19164        let out = getarg("(ie)cd", None, None, Some("abcdef")).expect("Some");
19165        // 'cd' starts at 1-based position 3.
19166        assert_eq!(val_str(out), "3");
19167    }
19168
19169    #[test]
19170    fn getarg_scalar_Ie_returns_position_of_last_match() {
19171        let _g = crate::test_util::global_state_lock();
19172        let out = getarg("(Ie)b", None, None, Some("abcabc")).expect("Some");
19173        // Last 'b' is at 1-based position 5.
19174        assert_eq!(val_str(out), "5");
19175    }
19176
19177    #[test]
19178    fn getarg_scalar_ie_no_match_returns_len_plus_one() {
19179        let _g = crate::test_util::global_state_lock();
19180        let out = getarg("(ie)z", None, None, Some("abc")).expect("Some");
19181        assert_eq!(val_str(out), "4");
19182    }
19183
19184    #[test]
19185    fn getarg_scalar_Ie_no_match_returns_zero() {
19186        let _g = crate::test_util::global_state_lock();
19187        let out = getarg("(Ie)z", None, None, Some("abc")).expect("Some");
19188        assert_eq!(val_str(out), "0");
19189    }
19190
19191    #[test]
19192    fn getarg_scalar_n_flag_picks_second_match() {
19193        let _g = crate::test_util::global_state_lock();
19194        // C params.c:1929/1964 — `!--num` Nth-match counter on
19195        // scalar char-search. abcabc: 'a' at idx 0 and 3 → 2nd match
19196        // at byte position 4 (1-based).
19197        let out = getarg("(en.2.i)a", None, None, Some("abcabc")).expect("Some");
19198        assert_eq!(val_str(out), "4");
19199    }
19200
19201    #[test]
19202    fn getarg_scalar_b_flag_starts_from_offset() {
19203        let _g = crate::test_util::global_state_lock();
19204        // C params.c:1740-1742 — `(b.N.)` starts search from idx N-1.
19205        // abc bc abc: with b=4, skip first 3 chars; first 'b' at byte 5.
19206        let out = getarg("(b.4.ei)b", None, None, Some("abcbc")).expect("Some");
19207        assert_eq!(val_str(out), "4");
19208    }
19209
19210    #[test]
19211    fn getarg_scalar_re_n2_picks_second_substring() {
19212        let _g = crate::test_util::global_state_lock();
19213        let out = getarg("(en.2.r)b", None, None, Some("abab")).expect("Some");
19214        assert_eq!(val_str(out), "b");
19215    }
19216
19217    /// c:3076/3193 — assignsparam writes into paramtab; getsparam
19218    /// reads it back. The round-trip is the spine of every
19219    /// `foo=bar; print $foo` flow. Regression here would silently
19220    /// drop assignments.
19221    #[test]
19222    fn assignsparam_then_getsparam_round_trips() {
19223        let _g = crate::test_util::global_state_lock(); // c:3193
19224                                                        // c:2697 — assignsparam → assignstrvalue bails when
19225                                                        // unset(EXECOPT). The unit-test env doesn't run through
19226                                                        // createoptiontable so we set "exec" explicitly to simulate
19227                                                        // normal runtime. Mirrors the same setup used by
19228                                                        // `setnumvalue_stores_int_value_into_scalar_pm` above.
19229        let saved_exec = opt_state_get("exec") // c:2697
19230            .unwrap_or(false); // c:2697
19231        opt_state_set("exec", true); // c:2697
19232        let name = "ZSHRS_TEST_ASSIGN_GET"; // c:3193
19233        assignsparam(name, "test_value_42", 0); // c:3193
19234        assert_eq!(
19235            // c:3076
19236            getsparam(name).as_deref(), // c:3076
19237            Some("test_value_42")       // c:3076
19238        ); // c:3076
19239           // Cleanup so other tests don't see leaked param.
19240        let _ = paramtab().write().unwrap().remove(name); // c:3819
19241        opt_state_set("exec", saved_exec); // c:2697
19242    }
19243
19244    /// Regression: a subscript WRITE to a hash-storage-backed special assoc
19245    /// (`$compstate`, populated during completion via set_compstate_str) with a
19246    /// STRING key must succeed, not error "assignment to invalid subscript
19247    /// range". After the undeclared-subscript auto-vivify was removed, the
19248    /// completion system's `compstate[insert]=menu` writes started arithmetic-
19249    /// evaluating the `insert` key to 0 and failing — flooding real shells with
19250    /// errors on every completion. The fix recognises any name present in
19251    /// paramtab_hashed_storage() as associative. A name NOT in that store still
19252    /// errors (matches zsh: `compstate` only exists during completion).
19253    #[test]
19254    fn subscript_write_to_hash_backed_special_assoc_succeeds() {
19255        let _g = crate::test_util::global_state_lock();
19256        let saved_exec = opt_state_get("exec").unwrap_or(false);
19257        opt_state_set("exec", true);
19258        // Start from a clean `compstate`: completion tests elsewhere in
19259        // the binary leave one behind in the paramtab, and a stale
19260        // non-assoc entry sends the write down the scalar-subscript path
19261        // instead of the hash-backed one this test pins.
19262        unsetparam("compstate");
19263        // Simulate the completion setup that populates $compstate before the
19264        // user completer widget runs (compcore::callcompfunc → set_compstate_str).
19265        paramtab_hashed_storage()
19266            .lock()
19267            .unwrap()
19268            .entry("compstate".to_string())
19269            .or_default();
19270        // User completer write: `compstate[insert]=menu`.
19271        let ok = assignsparam("compstate[insert]", "menu", 0);
19272        assert!(
19273            ok.is_some(),
19274            "compstate[insert]=menu must succeed, got error"
19275        );
19276        assert_eq!(
19277            paramtab_hashed_storage()
19278                .lock()
19279                .unwrap()
19280                .get("compstate")
19281                .and_then(|m| m.get("insert"))
19282                .map(String::as_str),
19283            Some("menu"),
19284        );
19285        // Negative: a name with NO hash-storage backing and a non-numeric key
19286        // still errors (returns None) — the undeclared-subscript guard holds.
19287        let bad = assignsparam("zshrs_no_such_assoc[k1]", "bb", 0);
19288        assert!(
19289            bad.is_none(),
19290            "undeclared non-numeric subscript must still error"
19291        );
19292        // Cleanup.
19293        let _ = paramtab_hashed_storage()
19294            .lock()
19295            .unwrap()
19296            .remove("compstate");
19297        let _ = paramtab().write().unwrap().remove("compstate");
19298        let _ = paramtab().write().unwrap().remove("zshrs_no_such_assoc");
19299        opt_state_set("exec", saved_exec);
19300    }
19301
19302    /// c:3076 — getsparam on a non-existent param returns None.
19303    /// A regression returning Some("") would mask unset-param errors.
19304    #[test]
19305    fn getsparam_unknown_param_returns_none() {
19306        let _g = crate::test_util::global_state_lock();
19307        assert!(getsparam("ZSHRS_TEST_DEFINITELY_UNSET").is_none());
19308    }
19309
19310    /// c:3819 — direct paramtab.remove drops the entry; subsequent
19311    /// getsparam returns None. The set→remove→lookup gap verifies
19312    /// the canonical paramtab is actually backing both reads + writes.
19313    #[test]
19314    fn paramtab_remove_makes_getsparam_return_none() {
19315        let _g = crate::test_util::global_state_lock();
19316        let name = "ZSHRS_TEST_UNSET_FLOW";
19317        assignsparam(name, "to_be_removed", 0);
19318        assert!(
19319            getsparam(name).is_some(),
19320            "param must be set before remove path"
19321        );
19322        let _ = paramtab().write().unwrap().remove(name);
19323        assert!(
19324            getsparam(name).is_none(),
19325            "after remove, getsparam must return None"
19326        );
19327    }
19328
19329    /// c:3357 — assignaparam stores an array. getsparam on an array
19330    /// param returns the first element OR a join (depends on IFS).
19331    /// Verify the slot was populated AT ALL by querying paramtab.
19332    #[test]
19333    fn assignaparam_populates_paramtab_with_array() {
19334        let _g = crate::test_util::global_state_lock();
19335        let name = "ZSHRS_TEST_ARR_X";
19336        assignaparam(name, vec!["a".into(), "b".into(), "c".into()], 0);
19337        let tab = paramtab().read().expect("paramtab poisoned");
19338        let pm = tab.get(name).expect("param installed");
19339        assert_eq!(
19340            pm.u_arr.as_deref(),
19341            Some(&["a".to_string(), "b".to_string(), "c".to_string()][..]),
19342            "assignaparam stores all three elements"
19343        );
19344        drop(tab);
19345        let _ = paramtab().write().unwrap().remove(name);
19346    }
19347
19348    // Use the module-scope HISTCHARS_TEST_LOCK_SHARED (declared
19349    // outside the test modules) so gsu_tests + tests serialise
19350    // against the same Mutex rather than two independent ones.
19351
19352    /// `Src/params.c:5095-5097` — `histcharssetfn` stores bangchar /
19353    /// hatchar / hashchar in the per-char globals. Pin the round-trip
19354    /// for ALL THREE: change HISTCHARS to a custom 3-char string,
19355    /// verify each atomic global reflects the new value, and verify
19356    /// setting the default `"!^#"` back restores all three.
19357    #[test]
19358    fn histcharssetfn_syncs_all_three_histchar_globals() {
19359        let _g = crate::test_util::global_state_lock();
19360        let _g = HISTCHARS_TEST_LOCK_SHARED
19361            .lock()
19362            .unwrap_or_else(|e| e.into_inner());
19363        // Default state.
19364        histcharssetfn(&mut param::default(), "!^#".to_string());
19365        assert_eq!(bangchar.load(Ordering::SeqCst), b'!' as i32);
19366        assert_eq!(hatchar.load(Ordering::SeqCst), b'^' as i32);
19367        assert_eq!(hashchar.load(Ordering::SeqCst), b'#' as i32);
19368        // Set HISTCHARS to "@:%".
19369        histcharssetfn(&mut param::default(), "@:%".to_string());
19370        assert_eq!(
19371            bangchar.load(Ordering::SeqCst),
19372            b'@' as i32,
19373            "c:5095 — bangchar = first byte of HISTCHARS"
19374        );
19375        assert_eq!(
19376            hatchar.load(Ordering::SeqCst),
19377            b':' as i32,
19378            "c:5096 — hatchar = second byte of HISTCHARS"
19379        );
19380        assert_eq!(
19381            hashchar.load(Ordering::SeqCst),
19382            b'%' as i32,
19383            "c:5097 — hashchar = third byte of HISTCHARS"
19384        );
19385        // Restore.
19386        histcharssetfn(&mut param::default(), "!^#".to_string());
19387        assert_eq!(bangchar.load(Ordering::SeqCst), b'!' as i32);
19388        assert_eq!(hashchar.load(Ordering::SeqCst), b'#' as i32);
19389    }
19390
19391    /// `Src/params.c:5064-5074` — `histcharsgetfn` reads from the
19392    /// three atomic globals and returns a string of non-NUL bytes.
19393    /// Pin set→get symmetry: after `histcharssetfn(Some("@&%"))`,
19394    /// `histcharsgetfn(&param::default())` returns `"@&%"`.
19395    #[test]
19396    fn histcharsgetfn_round_trips_with_histcharssetfn() {
19397        let _g = crate::test_util::global_state_lock();
19398        let _g = HISTCHARS_TEST_LOCK_SHARED
19399            .lock()
19400            .unwrap_or_else(|e| e.into_inner());
19401        histcharssetfn(&mut param::default(), "@&%".to_string());
19402        assert_eq!(
19403            histcharsgetfn(&param::default()),
19404            "@&%",
19405            "c:5068-5073 — getfn reads atomic globals setfn wrote"
19406        );
19407        // Restore default and verify round-trip (an explicit value: "" is
19408        // len 0, not NULL, c:5095-5097).
19409        histcharssetfn(&mut param::default(), "!^#".to_string());
19410        assert_eq!(
19411            histcharsgetfn(&param::default()),
19412            "!^#",
19413            "default `!^#` round-trips through atomics"
19414        );
19415    }
19416
19417    /// `Src/params.c:5118-5128` — `homesetfn(x)` round-trip:
19418    /// `homesetfn(s); homegetfn() == s` for non-symlink paths and
19419    /// CHASELINKS-off. Pins the basic store-then-read contract.
19420    #[test]
19421    fn homesetfn_stores_value_for_getfn() {
19422        let _g = crate::test_util::global_state_lock();
19423        let mut __pm = crate::ported::zsh_h::param::default();
19424        let saved = homegetfn(&__pm);
19425        homesetfn(&mut __pm, "/tmp/zshrs_test_home".to_string());
19426        assert_eq!(
19427            homegetfn(&__pm),
19428            "/tmp/zshrs_test_home",
19429            "c:5121-5126 — homesetfn → homegetfn round-trip"
19430        );
19431        // Restore.
19432        homesetfn(&mut __pm, saved);
19433    }
19434
19435    /// `Src/params.c:5125-5126` — empty input becomes `ztrdup("")`.
19436    /// Pin empty-string handling.
19437    #[test]
19438    fn homesetfn_empty_input_stores_empty() {
19439        let _g = crate::test_util::global_state_lock();
19440        let mut __pm = crate::ported::zsh_h::param::default();
19441        let saved = homegetfn(&__pm);
19442        homesetfn(&mut __pm, String::new());
19443        assert_eq!(
19444            homegetfn(&__pm),
19445            "",
19446            "c:5126 — empty x stores empty (no panic)"
19447        );
19448        homesetfn(&mut __pm, saved);
19449    }
19450
19451    /// `Src/params.c:5004-5011` — `errnosetfn(x)` writes errno
19452    /// unconditionally, then warns (NOT errors) on truncation. The
19453    /// store happens regardless of warning. Pin set→get round-trip.
19454    #[test]
19455    #[cfg(any(target_os = "macos", target_os = "linux"))]
19456    fn errnosetfn_writes_through_to_libc_errno_getfn() {
19457        let _g = crate::test_util::global_state_lock();
19458        // Set errno to a small int.
19459        errnosetfn(42);
19460        assert_eq!(
19461            errnogetfn(),
19462            42,
19463            "c:5006 — errno = (int)x; subsequent getfn must read it back"
19464        );
19465        errnosetfn(0);
19466        assert_eq!(errnogetfn(), 0);
19467    }
19468
19469    /// `Src/params.c:5008-5010` — truncation check fires when
19470    /// `(zlong)errno != x`. C also resets errno indirectly inside
19471    /// `zwarn` (libc calls touch errno) — so after the warning,
19472    /// the user's observed `$ERRNO` is the post-warning value, NOT
19473    /// the truncated cast. Faithful Rust port has the same behavior.
19474    /// Pin only that the function returns normally and doesn't crash;
19475    /// any specific post-call errno value is implementation-defined.
19476    #[test]
19477    #[cfg(any(target_os = "macos", target_os = "linux"))]
19478    fn errnosetfn_does_not_panic_on_truncation() {
19479        let _g = crate::test_util::global_state_lock();
19480        // i64::MAX → truncates to i32 = -1 → warning fires inside.
19481        // The store at c:5008 happens; whether the warning's libc
19482        // calls then overwrite errno is implementation-defined.
19483        errnosetfn(i64::MAX);
19484        // Just verify the call returned (no panic) and getfn works.
19485        let _ = errnogetfn();
19486        // Reset.
19487        errnosetfn(0);
19488    }
19489
19490    /// `Src/params.c:5090-5093` — non-ASCII chars in HISTCHARS
19491    /// produce a warning and the function returns WITHOUT updating
19492    /// any globals. Pin the rejection: state before == state after
19493    /// when a non-ASCII byte is in position 0/1/2.
19494    #[test]
19495    fn histcharssetfn_rejects_non_ascii_chars() {
19496        let _g = crate::test_util::global_state_lock();
19497        let _g = HISTCHARS_TEST_LOCK_SHARED
19498            .lock()
19499            .unwrap_or_else(|e| e.into_inner());
19500        // Reset to defaults.
19501        histcharssetfn(&mut param::default(), "!^#".to_string());
19502        let bang_before = bangchar.load(Ordering::SeqCst);
19503        let hat_before = hatchar.load(Ordering::SeqCst);
19504        // Try to set HISTCHARS with non-ASCII char.
19505        histcharssetfn(&mut param::default(), "é".to_string());
19506        // c:5092 — rejection returns BEFORE any state changes.
19507        assert_eq!(
19508            bangchar.load(Ordering::SeqCst),
19509            bang_before,
19510            "c:5092 — bangchar unchanged after non-ASCII rejection"
19511        );
19512        assert_eq!(hatchar.load(Ordering::SeqCst), hat_before);
19513    }
19514
19515    /// Shared mutex for tests that mutate argzero/posixzero — both
19516    /// share global state and race when run in parallel.
19517    static ARGZERO_TEST_LOCK: Mutex<()> = Mutex::new(());
19518
19519    // HISTSIZ_TEST_LOCK is defined at module scope to share between
19520    // gsu_tests and tests submodules — both mutate histsiz.
19521
19522    /// `Src/params.c:4974-4977` — `histsizesetfn` floors at 1 then
19523    /// calls `resizehistents()` to prune the in-memory ring to the
19524    /// new cap. The previous Rust port skipped the resize call (and
19525    /// also failed to mirror the value into `hist::histsiz`), so
19526    /// HISTSIZE shrinks didn't take effect until the next implicit
19527    /// prune. Pin: setting HISTSIZE to N caps both the param store
19528    /// AND the hist::histsiz atomic used by resizehistents.
19529    #[test]
19530    fn histsizesetfn_floors_at_one_and_mirrors_to_hist_module() {
19531        let _g = crate::test_util::global_state_lock();
19532        let _g = HISTSIZ_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
19533        let saved_param = histsizegetfn();
19534        let saved_hist = histsiz.load(Ordering::SeqCst);
19535
19536        // c:4976 — value < 1 floors at 1.
19537        histsizesetfn(0);
19538        assert_eq!(histsizegetfn(), 1, "c:4976 — HISTSIZE 0 must floor at 1");
19539        assert_eq!(
19540            histsiz.load(Ordering::SeqCst),
19541            1,
19542            "c:4977 — mirror into hist::histsiz so resizehistents sees it"
19543        );
19544
19545        // Negative floors too.
19546        histsizesetfn(-5);
19547        assert_eq!(histsizegetfn(), 1, "c:4976 — negative floors at 1");
19548
19549        // Positive passes through.
19550        histsizesetfn(500);
19551        assert_eq!(histsizegetfn(), 500);
19552        assert_eq!(histsiz.load(Ordering::SeqCst), 500);
19553
19554        // Restore.
19555        *histsiz_lock().lock().unwrap() = saved_param;
19556        histsiz.store(saved_hist, Ordering::SeqCst);
19557    }
19558
19559    /// `Src/params.c:5152-5158` — `underscoregetfn` returns
19560    /// `dupstring(zunderscore)` then runs `untokenize(u)` on it.
19561    /// The Rust port previously skipped untokenize, exposing raw
19562    /// lexer-injected token bytes (Stringg, Equals, ...) in `$_`
19563    /// reads.
19564    #[test]
19565    fn underscoregetfn_runs_untokenize_on_zunderscore() {
19566        let _g = crate::test_util::global_state_lock();
19567        // Inject zunderscore containing a Pound token byte (\u{e184})
19568        // and verify it gets stripped by untokenize in the return.
19569        let saved = zunderscore_lock().lock().unwrap().clone();
19570
19571        // Set zunderscore to a string containing a Pound token byte
19572        // surrounded by literals.
19573        let pound = Pound;
19574        let mut s = String::new();
19575        s.push('a');
19576        s.push(pound);
19577        s.push('b');
19578        *zunderscore_lock().lock().unwrap() = s;
19579
19580        let result = underscoregetfn();
19581        // c:5156 — untokenize replaces Pound (ITOK) with '#'
19582        // (its ztokens entry). The raw \u{e184} byte must NOT survive.
19583        assert!(
19584            !result.contains(pound),
19585            "c:5156 — untokenize must strip Pound token byte from $_"
19586        );
19587        assert!(
19588            result.contains('#') || result.contains("a"),
19589            "c:5156 — Pound (ITOK) maps to '#' via ztokens[0]"
19590        );
19591
19592        // Restore.
19593        *zunderscore_lock().lock().unwrap() = saved;
19594    }
19595
19596    /// `Src/params.c:4954-4961` — `argzerogetfn` returns `posixzero`
19597    /// when `isset(POSIXARGZERO)`, else `argzero`. After mutating
19598    /// argzero (e.g. `exec -a foo`), `$0` under POSIXARGZERO must
19599    /// report the ORIGINAL startup argv[0], not the rewritten name.
19600    #[test]
19601    fn argzerogetfn_respects_posixargzero_option() {
19602        let _g = crate::test_util::global_state_lock();
19603        let _g = ARGZERO_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
19604
19605        // Save state.
19606        let saved_argzero = argzero();
19607        let saved_posixzero = posixzero();
19608        let saved_pos_option = opt_state_get("posixargzero").unwrap_or(false);
19609
19610        // Set up: posixzero (original) ≠ argzero (rewritten).
19611        set_posixzero(Some("/bin/zsh".to_string()));
19612        set_argzero(Some("rewritten-name".to_string()));
19613        // The set_argzero call mirrors to posixzero only if unset,
19614        // and we set posixzero first → mirror skipped. Confirm separation.
19615
19616        // pm is UNUSED in argzerogetfn (C signature matches Rust).
19617        // Use param::default() as the dummy carrier.
19618        let pm = param::default();
19619
19620        // POSIXARGZERO off → returns argzero.
19621        opt_state_set("posixargzero", false);
19622        assert_eq!(
19623            argzerogetfn(&pm),
19624            "rewritten-name",
19625            "c:4960 — !POSIXARGZERO returns argzero (current display name)"
19626        );
19627
19628        // POSIXARGZERO on → returns posixzero (the preserved startup argv[0]).
19629        opt_state_set("posixargzero", true);
19630        assert_eq!(
19631            argzerogetfn(&pm),
19632            "/bin/zsh",
19633            "c:4959 — POSIXARGZERO on returns posixzero (original startup argv[0])"
19634        );
19635
19636        // Restore.
19637        set_argzero(saved_argzero);
19638        set_posixzero(saved_posixzero);
19639        opt_state_set("posixargzero", saved_pos_option);
19640    }
19641
19642    /// `Src/init.c:271` — `argv0 = argzero = posixzero = *argv++`.
19643    /// At shell init both share the same source. The Rust port
19644    /// preserves this contract by having `set_argzero` mirror to
19645    /// `posixzero` ONLY on first call (when posixzero is None).
19646    /// Subsequent argzero changes (function frames, exec -a) must
19647    /// NOT clobber posixzero.
19648    #[test]
19649    fn set_argzero_mirrors_to_posixzero_only_on_first_call() {
19650        let _g = crate::test_util::global_state_lock();
19651        let _g = ARGZERO_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
19652
19653        let saved_argzero = argzero();
19654        let saved_posixzero = posixzero();
19655
19656        // Reset both to None.
19657        set_argzero(None);
19658        set_posixzero(None);
19659        // First call: posixzero is None, so it should mirror.
19660        set_argzero(Some("/usr/local/bin/zsh".to_string()));
19661        assert_eq!(
19662            posixzero().as_deref(),
19663            Some("/usr/local/bin/zsh"),
19664            "c:271 — first set_argzero mirrors to posixzero (was None)"
19665        );
19666        // Second call: posixzero now Some, so mirror is skipped.
19667        set_argzero(Some("function-name".to_string()));
19668        assert_eq!(
19669            posixzero().as_deref(),
19670            Some("/usr/local/bin/zsh"),
19671            "c:271 — second set_argzero does NOT clobber posixzero"
19672        );
19673        assert_eq!(
19674            argzero().as_deref(),
19675            Some("function-name"),
19676            "argzero updated as normal"
19677        );
19678
19679        // Restore.
19680        set_posixzero(saved_posixzero);
19681        set_argzero(saved_argzero);
19682    }
19683
19684    /// Locale-touching tests share process-wide env + libc state.
19685    /// Cargo runs tests in parallel by default, so without
19686    /// serialization a concurrent `env::set_var("LC_ALL")` can race
19687    /// a `env::remove_var("LC_ALL")` and corrupt assertions. Pin
19688    /// every locale test through this Mutex.
19689    fn locale_test_lock() -> &'static Mutex<()> {
19690        static L: OnceLock<Mutex<()>> = OnceLock::new();
19691        L.get_or_init(|| Mutex::new(()))
19692    }
19693
19694    /// Pin `LC_NAMES` to the canonical zsh `lc_names[]` table at
19695    /// `Src/params.c:4805-4825`. The five categories in entry order
19696    /// (LC_COLLATE, LC_CTYPE, LC_MESSAGES, LC_NUMERIC, LC_TIME) MUST
19697    /// match — `lcsetfn` walks this table by `strcmp(ln->name, pm->node.nam)`
19698    /// per c:4926 and dispatches to `setlocale(ln->category, ...)`.
19699    #[test]
19700    fn lc_names_match_zsh_canonical_table() {
19701        let _g = crate::test_util::global_state_lock();
19702        let names: Vec<&str> = LC_NAMES.iter().map(|(n, _)| *n).collect();
19703        assert_eq!(
19704            names,
19705            vec![
19706                "LC_COLLATE",
19707                "LC_CTYPE",
19708                "LC_MESSAGES",
19709                "LC_NUMERIC",
19710                "LC_TIME"
19711            ],
19712            "Src/params.c:4805-4825 — lc_names entry order must be preserved"
19713        );
19714        // Verify each name maps to a distinct libc category — proves
19715        // we aren't aliasing LC_NUMERIC to LC_TIME etc.
19716        let cats: Vec<libc::c_int> = LC_NAMES.iter().map(|(_, c)| *c).collect();
19717        let mut sorted = cats.clone();
19718        sorted.sort();
19719        sorted.dedup();
19720        assert_eq!(sorted.len(), 5, "all five LC_* categories must be distinct");
19721        assert!(cats.contains(&libc::LC_COLLATE));
19722        assert!(cats.contains(&libc::LC_CTYPE));
19723        assert!(cats.contains(&libc::LC_MESSAGES));
19724        assert!(cats.contains(&libc::LC_NUMERIC));
19725        assert!(cats.contains(&libc::LC_TIME));
19726    }
19727
19728    /// Pin `lcsetfn` to the canonical `setlocale` invocation at
19729    /// `Src/params.c:4925-4927`. When LC_ALL is empty and pm matches
19730    /// an entry in `lc_names`, libc setlocale MUST be called with
19731    /// the corresponding category. Verified by reading libc state
19732    /// back via `setlocale(cat, NULL)` after the assignment.
19733    #[test]
19734    fn lcsetfn_invokes_libc_setlocale_for_matching_category() {
19735        let _g = crate::test_util::global_state_lock();
19736        let _g = locale_test_lock().lock().unwrap_or_else(|e| e.into_inner());
19737        // Save LC_ALL/LC_CTYPE state.
19738        let saved_lc_all = env::var("LC_ALL").ok();
19739        let saved_lc_ctype = env::var("LC_CTYPE").ok();
19740        env::remove_var("LC_ALL"); // c:4912 LC_ALL must be empty for body to run
19741                                   // Drop LC_ALL from paramtab too — the sibling
19742                                   // `lcsetfn_short_circuits_when_lc_all_set` test sets it via
19743                                   // setsparam (params.rs:12986) and getsparam reads paramtab
19744                                   // BEFORE env::var (params.rs:4346). Without this clear, lcsetfn
19745                                   // sees the leftover paramtab value and short-circuits.
19746        unsetparam("LC_ALL");
19747
19748        // Read libc's current LC_CTYPE setting.
19749        let before = unsafe {
19750            let p = libc::setlocale(libc::LC_CTYPE, std::ptr::null());
19751            if p.is_null() {
19752                String::new()
19753            } else {
19754                std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned()
19755            }
19756        };
19757
19758        // Call lcsetfn with LC_CTYPE → "C" (universally available POSIX locale).
19759        // c:4876 matches the category on `pm->node.nam`, so the parameter
19760        // being assigned IS the category selector — the name has to live on
19761        // the `param` handed in, not in a separate argument.
19762        let mut pm = param::default();
19763        pm.node.nam = "LC_CTYPE".to_string();
19764        lcsetfn(&mut pm, "C".to_string());
19765
19766        // Read it back — must report "C" since C invokes setlocale(LC_CTYPE, "C").
19767        let after = unsafe {
19768            let p = libc::setlocale(libc::LC_CTYPE, std::ptr::null());
19769            if p.is_null() {
19770                String::new()
19771            } else {
19772                std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned()
19773            }
19774        };
19775        assert_eq!(
19776            after, "C",
19777            "Src/params.c:4927 — lcsetfn must call setlocale(LC_CTYPE, \"C\")"
19778        );
19779
19780        // Env mirror also set.
19781        assert_eq!(env::var("LC_CTYPE").unwrap_or_default(), "C");
19782
19783        // Restore libc + env state.
19784        let _ = unsafe {
19785            let c = std::ffi::CString::new(before.as_bytes()).unwrap_or_default();
19786            libc::setlocale(libc::LC_CTYPE, c.as_ptr())
19787        };
19788        match saved_lc_all {
19789            Some(v) => env::set_var("LC_ALL", v),
19790            None => env::remove_var("LC_ALL"),
19791        }
19792        match saved_lc_ctype {
19793            Some(v) => env::set_var("LC_CTYPE", v),
19794            None => env::remove_var("LC_CTYPE"),
19795        }
19796    }
19797
19798    /// Pin `lcsetfn`'s LC_ALL early-return per c:4912-4913: when
19799    /// LC_ALL is non-empty, lcsetfn must short-circuit BEFORE
19800    /// touching libc setlocale for the per-category override.
19801    #[test]
19802    fn lcsetfn_short_circuits_when_lc_all_set() {
19803        let _g = crate::test_util::global_state_lock();
19804        let _g = locale_test_lock().lock().unwrap_or_else(|e| e.into_inner());
19805        let saved_lc_all = env::var("LC_ALL").ok();
19806        let saved_lc_ctype = env::var("LC_CTYPE").ok();
19807        env::set_var("LC_ALL", "C"); // c:4912 non-empty LC_ALL
19808                                     // Also stamp paramtab — `getsparam` reads paramtab FIRST
19809                                     // (params.rs:4346) and only falls through to `env::var` when
19810                                     // the key is absent. A prior test that left LC_ALL in paramtab
19811                                     // with an empty value (PM_UNSET tombstone or similar) makes
19812                                     // getsparam return Some("") and the env::set_var above never
19813                                     // reaches lcsetfn. Setting paramtab here pins the contract.
19814        setsparam("LC_ALL", "C");
19815
19816        // Capture libc state before.
19817        let before = unsafe {
19818            let p = libc::setlocale(libc::LC_CTYPE, std::ptr::null());
19819            if p.is_null() {
19820                String::new()
19821            } else {
19822                std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned()
19823            }
19824        };
19825
19826        // Try to set LC_CTYPE; should NOT touch libc state.
19827        let mut pm = param::default();
19828        pm.node.nam = "LC_CTYPE".to_string();
19829        lcsetfn(&mut pm, "POSIX".to_string());
19830
19831        // libc state must be unchanged.
19832        let after = unsafe {
19833            let p = libc::setlocale(libc::LC_CTYPE, std::ptr::null());
19834            if p.is_null() {
19835                String::new()
19836            } else {
19837                std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned()
19838            }
19839        };
19840        assert_eq!(
19841            before, after,
19842            "c:4912-4913 — lcsetfn must early-return when LC_ALL is non-empty"
19843        );
19844
19845        // Restore.
19846        match saved_lc_all {
19847            Some(v) => env::set_var("LC_ALL", v),
19848            None => env::remove_var("LC_ALL"),
19849        }
19850        match saved_lc_ctype {
19851            Some(v) => env::set_var("LC_CTYPE", v),
19852            None => env::remove_var("LC_CTYPE"),
19853        }
19854    }
19855
19856    /// Pin `getsparam_u` to its canonical C body at
19857    /// `Src/params.c:3089-3094`: returns `unmeta(getsparam(s))`,
19858    /// NOT a PM_SCALAR-checked `getstrvalue` wrapper.
19859    ///
19860    /// Before this fix, the Rust port took `Option<&mut value>`
19861    /// and gated on `PM_TYPE == PM_SCALAR` — a complete fabrication
19862    /// with no caller because no caller's type fit the bogus sig.
19863    #[test]
19864    fn getsparam_u_unmetas_getsparam_result() {
19865        let _g = crate::test_util::global_state_lock();
19866        let _g = locale_test_lock().lock().unwrap_or_else(|e| e.into_inner());
19867
19868        // Plain ASCII: getsparam_u returns the same content as
19869        // getsparam (no Meta bytes to strip).
19870        let saved = env::var("ZSHRS_TEST_LOCALE_GSU").ok();
19871        env::set_var("ZSHRS_TEST_LOCALE_GSU", "en_US.UTF-8");
19872        assert_eq!(
19873            getsparam_u("ZSHRS_TEST_LOCALE_GSU"),
19874            Some("en_US.UTF-8".to_string()),
19875            "Src/params.c:3092 — getsparam_u returns unmeta(getsparam(s)) for ASCII"
19876        );
19877
19878        // Missing param: returns None (matches C `if ((s = getsparam(s)))` false branch).
19879        env::remove_var("ZSHRS_TEST_LOCALE_GSU_MISSING");
19880        assert_eq!(
19881            getsparam_u("ZSHRS_TEST_LOCALE_GSU_MISSING"),
19882            None,
19883            "Src/params.c:3094 — getsparam_u returns NULL when getsparam returns NULL"
19884        );
19885
19886        // Restore.
19887        match saved {
19888            Some(v) => env::set_var("ZSHRS_TEST_LOCALE_GSU", v),
19889            None => env::remove_var("ZSHRS_TEST_LOCALE_GSU"),
19890        }
19891    }
19892
19893    /// Pin `setarrvalue` EXECOPT bail per `Src/params.c:2897-2898`.
19894    /// Same NO_EXEC semantic as setnumvalue: dry-run shell evaluation
19895    /// must not mutate array params.
19896    #[test]
19897    fn setarrvalue_bails_under_no_exec() {
19898        let _g = crate::test_util::global_state_lock();
19899
19900        let saved_exec = opt_state_get("exec").unwrap_or(false);
19901
19902        opt_state_set("exec", false);
19903        let pm = Box::new(param {
19904            node: hashnode {
19905                next: None,
19906                nam: "noexec_arr".to_string(),
19907                flags: PM_ARRAY as i32,
19908            },
19909            u_data: 0,
19910            u_tied: None,
19911            u_arr: Some(vec!["initial".to_string()]),
19912            u_str: None,
19913            u_val: 0,
19914            u_dval: 0.0,
19915            u_hash: None,
19916            gsu_s: None,
19917            gsu_i: None,
19918            gsu_f: None,
19919            gsu_a: None,
19920            gsu_h: None,
19921            base: 0,
19922            width: 0,
19923            env: None,
19924            ename: None,
19925            old: None,
19926            level: 0,
19927        });
19928        let mut v = value {
19929            pm: Some(pm),
19930            arr: Vec::new(),
19931            scanflags: 0,
19932            valflags: 0,
19933            start: 0,
19934            end: -1,
19935        };
19936        // Under NO_EXEC, the assign must be skipped.
19937        setarrvalue(&mut v, vec!["new1".to_string(), "new2".to_string()]);
19938        let arr = v.pm.as_ref().unwrap().u_arr.clone().unwrap_or_default();
19939        assert_eq!(
19940            arr,
19941            vec!["initial".to_string()],
19942            "c:2897 — NO_EXEC: setarrvalue must NOT replace u_arr"
19943        );
19944
19945        // With exec=true, the same call replaces.
19946        opt_state_set("exec", true);
19947        setarrvalue(&mut v, vec!["new1".to_string(), "new2".to_string()]);
19948        let arr = v.pm.as_ref().unwrap().u_arr.clone().unwrap_or_default();
19949        assert_eq!(
19950            arr,
19951            vec!["new1".to_string(), "new2".to_string()],
19952            "with EXEC set, setarrvalue replaces u_arr"
19953        );
19954
19955        opt_state_set("exec", saved_exec);
19956    }
19957
19958    /// Pin `setnumvalue` EXECOPT bail per `Src/params.c:2860`.
19959    /// When unset(EXECOPT) (i.e. NO_EXEC mode via `zsh -n` or
19960    /// `set -n`), param mutations MUST be skipped so dry-run shell
19961    /// evaluation doesn't leak state into the param table.
19962    #[test]
19963    fn setnumvalue_bails_under_no_exec() {
19964        let _g = crate::test_util::global_state_lock();
19965
19966        let saved_exec = opt_state_get("exec").unwrap_or(false);
19967
19968        // c:2860 — NO_EXEC: setnumvalue must not mutate the param.
19969        opt_state_set("exec", false);
19970        let mut pm = Box::new(param {
19971            node: hashnode {
19972                next: None,
19973                nam: "ne".to_string(),
19974                flags: PM_INTEGER as i32,
19975            },
19976            u_data: 0,
19977            u_tied: None,
19978            u_arr: None,
19979            u_str: None,
19980            u_val: 999,
19981            u_dval: 0.0,
19982            u_hash: None,
19983            gsu_s: None,
19984            gsu_i: None,
19985            gsu_f: None,
19986            gsu_a: None,
19987            gsu_h: None,
19988            base: 0,
19989            width: 0,
19990            env: None,
19991            ename: None,
19992            old: None,
19993            level: 0,
19994        });
19995        let mut v = value {
19996            pm: Some(pm.clone()),
19997            arr: Vec::new(),
19998            scanflags: 0,
19999            valflags: 0,
20000            start: 0,
20001            end: -1,
20002        };
20003        let val = mnumber {
20004            l: 42,
20005            d: 0.0,
20006            type_: MN_INTEGER,
20007        };
20008        setnumvalue(Some(&mut v), val);
20009        // pm.u_val MUST still be 999 (the initial), not 42.
20010        let stored = v.pm.as_ref().unwrap().u_val;
20011        assert_eq!(
20012            stored, 999,
20013            "c:2860 — NO_EXEC: setnumvalue must NOT mutate pm.u_val \
20014             (was {} but should stay 999)",
20015            stored
20016        );
20017
20018        // With exec=true, the same call mutates.
20019        opt_state_set("exec", true);
20020        setnumvalue(Some(&mut v), val);
20021        let stored = v.pm.as_ref().unwrap().u_val;
20022        assert_eq!(stored, 42, "with EXEC set, setnumvalue stores u_val = 42");
20023
20024        let _ = pm;
20025        opt_state_set("exec", saved_exec);
20026    }
20027
20028    /// Pin `$-` rendering to honor `set -n` (noexec). The previous
20029    /// Rust port called `opt("noexec")` which isn't a real option
20030    /// name in zsh — the lookup always returned false so `$-` never
20031    /// included 'n' even when `set -n` was active.
20032    #[test]
20033    fn dash_param_rendering_honors_noexec_via_exec_negation() {
20034        let _g = crate::test_util::global_state_lock();
20035        let saved = opt_state_get("exec").unwrap_or(false);
20036
20037        // With exec=true (default), $- should NOT include 'n'.
20038        opt_state_set("exec", true);
20039        let s = lookup_special_var("-").unwrap_or_default();
20040        assert!(
20041            !s.contains('n'),
20042            "exec=true → $-=`{}` must NOT include 'n'",
20043            s
20044        );
20045
20046        // With exec=false (`set -n`), $- SHOULD include 'n'.
20047        opt_state_set("exec", false);
20048        let s = lookup_special_var("-").unwrap_or_default();
20049        assert!(
20050            s.contains('n'),
20051            "exec=false → $-=`{}` MUST include 'n' (was silently dropped \
20052             when reading non-existent option name `noexec`)",
20053            s
20054        );
20055
20056        opt_state_set("exec", saved);
20057    }
20058
20059    /// Pin `TERM_UNKNOWN` bit value to the canonical C value at
20060    /// `Src/zsh.h:1986`. The previous params.rs duplicate had
20061    /// `1 << 0 = 0x01` which is actually C's TERM_BAD (Src/zsh.h:1985);
20062    /// the correct TERM_UNKNOWN value is 0x02. This single-byte
20063    /// drift caused the params.rs term-init code to silently set the
20064    /// TERM_BAD bit instead of TERM_UNKNOWN, while prompt.rs guards
20065    /// imported the correct 0x02 value from zsh_h.rs — the two
20066    /// paths disagreed about which bit means \"unknown terminal\".
20067    #[test]
20068    fn term_unknown_bit_value_matches_c() {
20069        let _g = crate::test_util::global_state_lock();
20070        assert_eq!(
20071            TERM_UNKNOWN, 0x02,
20072            "Src/zsh.h:1986 — TERM_UNKNOWN must be 0x02, got {:#x}",
20073            TERM_UNKNOWN
20074        );
20075        assert_eq!(
20076            TERM_BAD, 0x01,
20077            "Src/zsh.h:1985 — TERM_BAD must be 0x01 (and != TERM_UNKNOWN)"
20078        );
20079        // Crucially: TERM_BAD and TERM_UNKNOWN must be DISTINCT bits.
20080        assert_ne!(
20081            TERM_BAD, TERM_UNKNOWN,
20082            "TERM_BAD and TERM_UNKNOWN must be distinct (caught the 1<<0 drift bug)"
20083        );
20084    }
20085
20086    /// Pin `getstrvalue` PM_INTEGER branch to canonical C convbase
20087    /// dispatch at `Src/params.c:2373`. The previous Rust port used
20088    /// naked `.to_string()` (base-10) regardless of `pm.base`; C
20089    /// honors the param's stored base so `typeset -i 16 x=255` renders
20090    /// as `0xff` not `255`.
20091    #[test]
20092    fn getstrvalue_pm_integer_honors_pm_base() {
20093        let _g = crate::test_util::global_state_lock();
20094
20095        let saved_cbases_top = opt_state_get("cbases").unwrap_or(false);
20096        opt_state_set("cbases", true);
20097
20098        // Build a PM_INTEGER param with u_val=255 and base=16.
20099        let mut pm = Box::new(param {
20100            node: hashnode {
20101                next: None,
20102                nam: "test_hex_var".to_string(),
20103                flags: PM_INTEGER as i32,
20104            },
20105            u_data: 0,
20106            u_tied: None,
20107            u_arr: None,
20108            u_str: None,
20109            u_val: 255,
20110            u_dval: 0.0,
20111            u_hash: None,
20112            gsu_s: None,
20113            gsu_i: None,
20114            gsu_f: None,
20115            gsu_a: None,
20116            gsu_h: None,
20117            base: 16,
20118            width: 0,
20119            env: None,
20120            ename: None,
20121            old: None,
20122            level: 0,
20123        });
20124        let mut v = value {
20125            pm: Some(pm.clone()),
20126            arr: Vec::new(),
20127            scanflags: 0,
20128            valflags: 0,
20129            start: 0,
20130            end: -1,
20131        };
20132        let rendered = getstrvalue(Some(&mut v));
20133        assert_eq!(
20134            rendered, "0xFF",
20135            "c:2373 / c:5621 — PM_INTEGER base=16 + u_val=255 with CBASES \
20136             renders as `0xFF` (uppercase per C `dig - 10 + 'A'`), got {:?}",
20137            rendered
20138        );
20139
20140        // Base-8 (octal) with OCTALZEROES.
20141        let saved_oct = opt_state_get("octalzeroes").unwrap_or(false);
20142        let saved_cbases = opt_state_get("cbases").unwrap_or(false);
20143        opt_state_set("cbases", true);
20144        opt_state_set("octalzeroes", true);
20145        pm.base = 8;
20146        pm.u_val = 8;
20147        v.pm = Some(pm.clone());
20148        let rendered = getstrvalue(Some(&mut v));
20149        assert_eq!(
20150            rendered, "010",
20151            "c:2373 — PM_INTEGER base=8 with OCTALZEROES renders as `010`, got {:?}",
20152            rendered
20153        );
20154        opt_state_set("cbases", saved_cbases);
20155        opt_state_set("octalzeroes", saved_oct);
20156
20157        // Base=0 (default) → base-10.
20158        pm.base = 0;
20159        pm.u_val = 42;
20160        v.pm = Some(pm.clone());
20161        let rendered = getstrvalue(Some(&mut v));
20162        assert_eq!(
20163            rendered, "42",
20164            "c:2373 — PM_INTEGER base=0 defaults to base-10"
20165        );
20166
20167        opt_state_set("cbases", saved_cbases_top);
20168    }
20169
20170    /// Pin `unsetparam` to its canonical C body at `Src/params.c:3819-3833`.
20171    /// Two guards the previous Rust port skipped:
20172    ///   1. PM_NAMEREF params are NOT removed by unsetparam (c:3830).
20173    ///   2. PM_READONLY rejection per unsetparam_pm c:3850 — readonly
20174    ///      params survive the unset call.
20175    #[test]
20176    fn unsetparam_skips_nameref_and_readonly() {
20177        let _g = crate::test_util::global_state_lock();
20178
20179        let saved_exec = opt_state_get("exec").unwrap_or(false);
20180        opt_state_set("exec", true);
20181
20182        // Helper: install a scalar param with the given flag-set.
20183        fn install(name: &str, value: &str, flags: u32) {
20184            let mut tab = paramtab().write().unwrap();
20185            tab.insert(
20186                name.to_string(),
20187                Box::new(param {
20188                    node: hashnode {
20189                        next: None,
20190                        nam: name.to_string(),
20191                        flags: (PM_SCALAR | flags) as i32,
20192                    },
20193                    u_data: 0,
20194                    u_tied: None,
20195                    u_arr: None,
20196                    u_str: Some(value.to_string()),
20197                    u_val: 0,
20198                    u_dval: 0.0,
20199                    u_hash: None,
20200                    gsu_s: None,
20201                    gsu_i: None,
20202                    gsu_f: None,
20203                    gsu_a: None,
20204                    gsu_h: None,
20205                    base: 0,
20206                    width: 0,
20207                    env: None,
20208                    ename: None,
20209                    old: None,
20210                    level: 0,
20211                }),
20212            );
20213        }
20214
20215        // c:3830 — nameref params skip the unset.
20216        let nameref_name = "zshrs_test_unsetparam_nameref";
20217        install(nameref_name, "target_var_name", PM_NAMEREF);
20218        unsetparam(nameref_name);
20219        {
20220            let tab = paramtab().read().unwrap();
20221            assert!(
20222                tab.contains_key(nameref_name),
20223                "c:3830 — PM_NAMEREF param survives unsetparam"
20224            );
20225        }
20226
20227        // c:3850 (via unsetparam_pm) — readonly rejection.
20228        let ro_name = "zshrs_test_unsetparam_readonly";
20229        install(ro_name, "locked", PM_READONLY);
20230        unsetparam(ro_name);
20231        {
20232            let tab = paramtab().read().unwrap();
20233            assert!(
20234                tab.contains_key(ro_name),
20235                "c:3850 — PM_READONLY param survives unsetparam"
20236            );
20237        }
20238
20239        // Plain scalar removed normally.
20240        let plain_name = "zshrs_test_unsetparam_plain";
20241        install(plain_name, "removable", 0);
20242        unsetparam(plain_name);
20243        {
20244            let tab = paramtab().read().unwrap();
20245            assert!(
20246                !tab.contains_key(plain_name),
20247                "plain scalar successfully removed"
20248            );
20249        }
20250
20251        // Clean up.
20252        {
20253            let mut tab = paramtab().write().unwrap();
20254            tab.remove(nameref_name);
20255            tab.remove(ro_name);
20256            tab.remove(plain_name);
20257        }
20258        opt_state_set("exec", saved_exec);
20259    }
20260
20261    /// Pin `assigniparam` to its canonical C body at `Src/params.c:3754-3761`.
20262    /// Three-arg signature: `(s, val, flags)`. Previous Rust port
20263    /// dropped the flags arg AND returned void; this restores both.
20264    #[test]
20265    fn assigniparam_takes_flags_arg_and_returns_param() {
20266        let _g = crate::test_util::global_state_lock();
20267
20268        let saved_exec = opt_state_get("exec").unwrap_or(false);
20269        opt_state_set("exec", true);
20270
20271        let name = "zshrs_test_assigniparam_x";
20272        {
20273            let mut tab = paramtab().write().unwrap();
20274            tab.remove(name);
20275        }
20276
20277        // c:3755-3760 — assigniparam returns Param and threads flags through.
20278        let r = assigniparam(name, 77, ASSPM_WARN as i32);
20279        assert!(
20280            r.is_some(),
20281            "c:3760 — returns Some(Param) for new int param"
20282        );
20283        {
20284            let tab = paramtab().read().unwrap();
20285            let pm = tab.get(name).expect("integer param created");
20286            assert_ne!(
20287                (pm.node.flags as u32) & PM_INTEGER,
20288                0,
20289                "c:3757-3760 — PM_INTEGER flag set"
20290            );
20291            assert_eq!(pm.u_val, 77, "c:3759 — value stored in u_val");
20292        }
20293
20294        // Reassign with a different flag value (0 — no warnings).
20295        let r = assigniparam(name, 88, 0);
20296        assert!(r.is_some(), "reassign returns Some");
20297        {
20298            let tab = paramtab().read().unwrap();
20299            let pm = tab.get(name).expect("param still present");
20300            assert_eq!(pm.u_val, 88, "reassign updates u_val");
20301        }
20302
20303        // Clean up.
20304        {
20305            let mut tab = paramtab().write().unwrap();
20306            tab.remove(name);
20307        }
20308        opt_state_set("exec", saved_exec);
20309    }
20310
20311    /// Pin `setnparam` to its canonical C body at `Src/params.c:3745-3749`.
20312    /// MUST accept `mnumber` (integer or float) and return Param.
20313    /// Previous Rust port took `f64` only and returned void — losing
20314    /// the integer side and the Param return entirely.
20315    #[test]
20316    fn setnparam_accepts_both_integer_and_float() {
20317        let _g = crate::test_util::global_state_lock();
20318
20319        let saved_exec = opt_state_get("exec").unwrap_or(false);
20320        opt_state_set("exec", true);
20321
20322        // Clean up any leftover.
20323        let int_name = "zshrs_test_setnparam_i";
20324        let flt_name = "zshrs_test_setnparam_f";
20325        {
20326            let mut tab = paramtab().write().unwrap();
20327            tab.remove(int_name);
20328            tab.remove(flt_name);
20329        }
20330
20331        // c:3748 — integer branch: setnparam returns Some(Param) with
20332        // PM_INTEGER flag and u_val set.
20333        let r = setnparam(
20334            int_name,
20335            mnumber {
20336                l: 999,
20337                d: 0.0,
20338                type_: MN_INTEGER,
20339            },
20340        );
20341        assert!(r.is_some(), "setnparam returns Some for new param");
20342        {
20343            let tab = paramtab().read().unwrap();
20344            let pm = tab.get(int_name).expect("integer param created");
20345            assert_ne!(
20346                (pm.node.flags as u32) & PM_INTEGER,
20347                0,
20348                "c:3748 — PM_INTEGER flag set for integer mnumber"
20349            );
20350            assert_eq!(pm.u_val, 999, "c:3748 — integer value stored in u_val");
20351        }
20352
20353        // c:3748 — float branch: setnparam with MN_FLOAT creates PM_FFLOAT.
20354        let r = setnparam(
20355            flt_name,
20356            mnumber {
20357                l: 0,
20358                d: 3.14,
20359                type_: MN_FLOAT,
20360            },
20361        );
20362        assert!(r.is_some(), "setnparam returns Some for new float param");
20363        {
20364            let tab = paramtab().read().unwrap();
20365            let pm = tab.get(flt_name).expect("float param created");
20366            assert_ne!(
20367                (pm.node.flags as u32) & PM_FFLOAT,
20368                0,
20369                "c:3748 — PM_FFLOAT flag set for float mnumber"
20370            );
20371            assert!(
20372                (pm.u_dval - 3.14).abs() < 1e-10,
20373                "c:3748 — float value stored in u_dval"
20374            );
20375        }
20376
20377        // Clean up.
20378        {
20379            let mut tab = paramtab().write().unwrap();
20380            tab.remove(int_name);
20381            tab.remove(flt_name);
20382        }
20383        opt_state_set("exec", saved_exec);
20384    }
20385
20386    /// Pin `setiparam` to its canonical C body at `Src/params.c:3767-3773`.
20387    /// MUST create the param as PM_INTEGER via `assignnparam`, not as
20388    /// PM_SCALAR via `assignsparam` with a stringified value.
20389    #[test]
20390    fn setiparam_creates_pm_integer_param() {
20391        let _g = crate::test_util::global_state_lock();
20392        let name = "zshrs_test_setiparam_x";
20393
20394        // C: `assignnparam` bails when `unset(EXECOPT)` (Src/params.c:3679).
20395        // Real zsh startup sets exec=true; the unit-test env doesn't run
20396        // through `createoptiontable` so we set "exec" explicitly to
20397        // simulate normal runtime.
20398        let saved_exec = opt_state_get("exec").unwrap_or(false);
20399        opt_state_set("exec", true);
20400
20401        // Clean up any leftover.
20402        {
20403            let mut tab = paramtab().write().unwrap();
20404            tab.remove(name);
20405        }
20406
20407        // Set integer value.
20408        setiparam(name, 42);
20409
20410        // Param should exist with PM_INTEGER flag set + u_val == 42.
20411        {
20412            let tab = paramtab().read().unwrap();
20413            let pm = tab.get(name).expect("setiparam must create the param");
20414            assert_ne!(
20415                (pm.node.flags as u32) & PM_INTEGER,
20416                0,
20417                "c:3770-3772 — created param must have PM_INTEGER flag set, \
20418                 got flags = {:#x}",
20419                pm.node.flags
20420            );
20421            assert_eq!(pm.u_val, 42, "c:3771 — integer value stored in pm.u_val");
20422        }
20423
20424        // Reassign to verify update path also keeps PM_INTEGER.
20425        setiparam(name, 100);
20426        {
20427            let tab = paramtab().read().unwrap();
20428            let pm = tab.get(name).expect("setiparam reassign must keep param");
20429            assert_eq!(pm.u_val, 100, "reassign updates the integer value");
20430            assert_ne!(
20431                (pm.node.flags as u32) & PM_INTEGER,
20432                0,
20433                "reassign keeps PM_INTEGER flag"
20434            );
20435        }
20436
20437        // Clean up.
20438        {
20439            let mut tab = paramtab().write().unwrap();
20440            tab.remove(name);
20441        }
20442        // Restore EXECOPT.
20443        opt_state_set("exec", saved_exec);
20444    }
20445
20446    /// Pin `gethparam` / `gethkparam` to their canonical C bodies at
20447    /// `Src/params.c:3117-3140`. Same signature-fix family as `getaparam`:
20448    /// the `name: &str` path with digit-first reject + PM_HASHED check.
20449    #[test]
20450    fn gethparam_and_gethkparam_signature_matches_c() {
20451        let _g = crate::test_util::global_state_lock();
20452        // c:3122 / c:3136 — digit-first name reject.
20453        assert_eq!(
20454            gethparam("123abc"),
20455            None,
20456            "c:3122 — digit-first name rejected"
20457        );
20458        assert_eq!(
20459            gethkparam("123abc"),
20460            None,
20461            "c:3136 — digit-first name rejected"
20462        );
20463
20464        // Missing param → None.
20465        assert_eq!(
20466            gethparam("zshrs_test_hashparam_xyz"),
20467            None,
20468            "missing param returns None"
20469        );
20470        assert_eq!(
20471            gethkparam("zshrs_test_hashparam_xyz"),
20472            None,
20473            "missing param returns None"
20474        );
20475
20476        // PM_SCALAR param (not hashed) → None.
20477        {
20478            let mut tab = paramtab().write().unwrap();
20479            tab.insert(
20480                "zshrs_test_gethp_scalar".to_string(),
20481                Box::new(param {
20482                    node: hashnode {
20483                        next: None,
20484                        nam: "zshrs_test_gethp_scalar".to_string(),
20485                        flags: PM_SCALAR as i32,
20486                    },
20487                    u_data: 0,
20488                    u_tied: None,
20489                    u_arr: None,
20490                    u_str: Some("scalar value".to_string()),
20491                    u_val: 0,
20492                    u_dval: 0.0,
20493                    u_hash: None,
20494                    gsu_s: None,
20495                    gsu_i: None,
20496                    gsu_f: None,
20497                    gsu_a: None,
20498                    gsu_h: None,
20499                    base: 0,
20500                    width: 0,
20501                    env: None,
20502                    ename: None,
20503                    old: None,
20504                    level: 0,
20505                }),
20506            );
20507        }
20508        assert_eq!(
20509            gethparam("zshrs_test_gethp_scalar"),
20510            None,
20511            "c:3123 — non-PM_HASHED returns None"
20512        );
20513        assert_eq!(
20514            gethkparam("zshrs_test_gethp_scalar"),
20515            None,
20516            "c:3137 — non-PM_HASHED returns None"
20517        );
20518
20519        // PM_HASHED param → Some(Vec::new()) for an empty association.
20520        {
20521            let mut tab = paramtab().write().unwrap();
20522            tab.insert(
20523                "zshrs_test_gethp_hash".to_string(),
20524                Box::new(param {
20525                    node: hashnode {
20526                        next: None,
20527                        nam: "zshrs_test_gethp_hash".to_string(),
20528                        flags: PM_HASHED as i32,
20529                    },
20530                    u_data: 0,
20531                    u_tied: None,
20532                    u_arr: None,
20533                    u_str: None,
20534                    u_val: 0,
20535                    u_dval: 0.0,
20536                    u_hash: None,
20537                    gsu_s: None,
20538                    gsu_i: None,
20539                    gsu_f: None,
20540                    gsu_a: None,
20541                    gsu_h: None,
20542                    base: 0,
20543                    width: 0,
20544                    env: None,
20545                    ename: None,
20546                    old: None,
20547                    level: 0,
20548                }),
20549            );
20550        }
20551        assert_eq!(
20552            gethparam("zshrs_test_gethp_hash"),
20553            Some(Vec::new()),
20554            "c:3123-3124 — PM_HASHED empty-storage returns Some(empty vec)"
20555        );
20556        assert_eq!(
20557            gethkparam("zshrs_test_gethp_hash"),
20558            Some(Vec::new()),
20559            "c:3137-3138 — PM_HASHED empty-storage returns Some(empty vec)"
20560        );
20561
20562        // Populate paramtab_hashed_storage and verify gethparam/gethkparam
20563        // return the actual values/keys per c:3124 (SCANPM_WANTVALS) and
20564        // c:3138 (SCANPM_WANTKEYS). IndexMap preserves insertion order.
20565        {
20566            let mut store = paramtab_hashed_storage().lock().unwrap();
20567            let mut map: IndexMap<String, String> = IndexMap::new();
20568            map.insert("k1".to_string(), "v1".to_string());
20569            map.insert("k2".to_string(), "v2".to_string());
20570            store.insert("zshrs_test_gethp_hash".to_string(), map);
20571        }
20572        assert_eq!(
20573            gethparam("zshrs_test_gethp_hash"),
20574            Some(vec!["v1".to_string(), "v2".to_string()]),
20575            "c:3124 — paramvalarr(SCANPM_WANTVALS) returns values from hashed-storage"
20576        );
20577        assert_eq!(
20578            gethkparam("zshrs_test_gethp_hash"),
20579            Some(vec!["k1".to_string(), "k2".to_string()]),
20580            "c:3138 — paramvalarr(SCANPM_WANTKEYS) returns keys from hashed-storage"
20581        );
20582
20583        // Clean up.
20584        {
20585            let mut tab = paramtab().write().unwrap();
20586            tab.remove("zshrs_test_gethp_scalar");
20587            tab.remove("zshrs_test_gethp_hash");
20588        }
20589        paramtab_hashed_storage()
20590            .lock()
20591            .unwrap()
20592            .remove("zshrs_test_gethp_hash");
20593    }
20594
20595    /// c:Src/params.c:717 — `paramvalarr(v->pm->gsu.h->getfn(v->pm),
20596    /// v->scanflags)`. For a SPECIALPMDEF magic hash that `getfn` hands
20597    /// back the fake `HashTable` whose `scantab` IS the module's
20598    /// `scanpm*` function (`Src/module.c` createspecialhash), so the
20599    /// SCAN is the backing — there is no stored map to read.
20600    ///
20601    /// zshrs keeps ordinary assoc contents in the name-keyed
20602    /// `paramtab_hashed_storage` map, and a seeded row for a magic name
20603    /// left an EMPTY map there which `gethparam`/`gethkparam` consulted
20604    /// FIRST, shadowing the scanfn. Symptom: `compadd -k commands`
20605    /// (`Completion/Unix/Type/_path_commands` sh:103) reaches
20606    /// `get_data_arr` → `gethkparam` and returned ZERO matches in a
20607    /// fresh shell, so command-name completion offered builtins only —
20608    /// and only started working once some other `$commands` read had run
20609    /// the scan, which is what calls `fillcmdnamtable` under
20610    /// HASH_LIST_ALL (`Src/Modules/parameter.c:253`).
20611    ///
20612    /// `builtins` is the probe rather than `commands` because
20613    /// `scanpmbuiltins` enumerates the builtin table, which is populated
20614    /// in every process and does not depend on `$PATH` state left behind
20615    /// by another test.
20616    #[test]
20617    fn magic_hash_scanfn_outranks_an_empty_hashed_storage_row() {
20618        let _g = crate::test_util::global_state_lock();
20619        crate::vm_helper::seed_partab_param("builtins");
20620        // Exactly the row that used to answer instead of the scan.
20621        paramtab_hashed_storage()
20622            .lock()
20623            .unwrap()
20624            .insert("builtins".to_string(), IndexMap::new());
20625
20626        let keys = gethkparam("builtins").expect("c:3137 — `builtins` is PM_HASHED");
20627        assert!(
20628            keys.iter().any(|k| k == "print"),
20629            "c:3138 — an empty hashed-storage row must not shadow scanpmbuiltins \
20630             (got {} key(s))",
20631            keys.len()
20632        );
20633        let vals = gethparam("builtins").expect("c:3123 — `builtins` is PM_HASHED");
20634        assert_eq!(
20635            vals.len(),
20636            keys.len(),
20637            "c:3124 — the value scan must answer from the same source as the keys"
20638        );
20639
20640        paramtab_hashed_storage().lock().unwrap().remove("builtins");
20641    }
20642
20643    /// Pin `getaparam` to its canonical C body at `Src/params.c:3101-3110`.
20644    /// Three branches: digit-first reject (c:3107), PM_ARRAY return
20645    /// (c:3108-3109), non-array / missing-param return None (c:3110).
20646    #[test]
20647    fn getaparam_returns_array_for_pm_array_only() {
20648        let _g = crate::test_util::global_state_lock();
20649        // c:3107 — digit-first name → None (positional params reject).
20650        assert_eq!(
20651            getaparam("123abc"),
20652            None,
20653            "c:3107 — digit-first name rejected"
20654        );
20655
20656        // c:3110 — missing param → None.
20657        assert_eq!(
20658            getaparam("zshrs_test_arr_nonexistent_xyz"),
20659            None,
20660            "c:3110 — missing param returns None"
20661        );
20662
20663        // Helper that builds a Param via the canonical createparam
20664        // path so we don't reach into struct internals (param has
20665        // many fields and no Default).
20666        fn build_arr(name: &str, arr: Vec<String>) {
20667            let mut tab = paramtab().write().unwrap();
20668            tab.insert(
20669                name.to_string(),
20670                Box::new(param {
20671                    node: hashnode {
20672                        next: None,
20673                        nam: name.to_string(),
20674                        flags: PM_ARRAY as i32,
20675                    },
20676                    u_data: 0,
20677                    u_tied: None,
20678                    u_arr: Some(arr),
20679                    u_str: None,
20680                    u_val: 0,
20681                    u_dval: 0.0,
20682                    u_hash: None,
20683                    gsu_s: None,
20684                    gsu_i: None,
20685                    gsu_f: None,
20686                    gsu_a: None,
20687                    gsu_h: None,
20688                    base: 0,
20689                    width: 0,
20690                    env: None,
20691                    ename: None,
20692                    old: None,
20693                    level: 0,
20694                }),
20695            );
20696        }
20697        fn build_scalar(name: &str, s: &str) {
20698            let mut tab = paramtab().write().unwrap();
20699            tab.insert(
20700                name.to_string(),
20701                Box::new(param {
20702                    node: hashnode {
20703                        next: None,
20704                        nam: name.to_string(),
20705                        flags: PM_SCALAR as i32,
20706                    },
20707                    u_data: 0,
20708                    u_tied: None,
20709                    u_arr: None,
20710                    u_str: Some(s.to_string()),
20711                    u_val: 0,
20712                    u_dval: 0.0,
20713                    u_hash: None,
20714                    gsu_s: None,
20715                    gsu_i: None,
20716                    gsu_f: None,
20717                    gsu_a: None,
20718                    gsu_h: None,
20719                    base: 0,
20720                    width: 0,
20721                    env: None,
20722                    ename: None,
20723                    old: None,
20724                    level: 0,
20725                }),
20726            );
20727        }
20728
20729        // c:3108 — PM_ARRAY param returns the array contents.
20730        build_arr(
20731            "zshrs_test_getaparam_arr",
20732            vec!["one".to_string(), "two".to_string(), "three".to_string()],
20733        );
20734        assert_eq!(
20735            getaparam("zshrs_test_getaparam_arr"),
20736            Some(vec![
20737                "one".to_string(),
20738                "two".to_string(),
20739                "three".to_string()
20740            ]),
20741            "c:3108-3109 — PM_ARRAY param returns its array"
20742        );
20743
20744        // c:3108 — PM_SCALAR (non-array) param → None.
20745        build_scalar("zshrs_test_getaparam_scalar", "not an array");
20746        assert_eq!(
20747            getaparam("zshrs_test_getaparam_scalar"),
20748            None,
20749            "c:3108 — non-PM_ARRAY param returns None"
20750        );
20751
20752        // Clean up.
20753        {
20754            let mut tab = paramtab().write().unwrap();
20755            tab.remove("zshrs_test_getaparam_arr");
20756            tab.remove("zshrs_test_getaparam_scalar");
20757        }
20758    }
20759
20760    // ═══════════════════════════════════════════════════════════════════
20761    // setX/getX round-trips and cross-type coercion.
20762    // Anchored to zsh behavior:
20763    //   - `typeset -i x=42; print -- $x` → "42"
20764    //   - `x=42; print -- ${(t)x}` → "scalar" (set without typeset stays scalar)
20765    //   - integer ↔ scalar coercion when reading back.
20766    // Each test sets up via setX, reads back via getX, and asserts the
20767    // observable shell-level value.
20768    // ═══════════════════════════════════════════════════════════════════
20769
20770    fn with_exec<F: FnOnce()>(body: F) {
20771        let _g = crate::test_util::global_state_lock();
20772        let saved = opt_state_get("exec").unwrap_or(false);
20773        opt_state_set("exec", true);
20774        body();
20775        opt_state_set("exec", saved);
20776    }
20777
20778    // ── Scalar round-trip ──────────────────────────────────────────
20779    /// `setsparam("X", "hello"); getsparam("X")` → Some("hello").
20780    /// Anchor: `X=hello; print -r -- "$X"` in zsh → `hello`.
20781    #[test]
20782    fn setsparam_then_getsparam_roundtrip_basic() {
20783        with_exec(|| {
20784            unsetparam("zshrs_rt_s1");
20785            setsparam("zshrs_rt_s1", "hello");
20786            assert_eq!(getsparam("zshrs_rt_s1").as_deref(), Some("hello"));
20787            unsetparam("zshrs_rt_s1");
20788        });
20789    }
20790
20791    /// Empty string round-trip — scalar can hold "".
20792    #[test]
20793    fn setsparam_empty_string_roundtrips_as_empty() {
20794        with_exec(|| {
20795            unsetparam("zshrs_rt_s2");
20796            setsparam("zshrs_rt_s2", "");
20797            assert_eq!(getsparam("zshrs_rt_s2").as_deref(), Some(""));
20798            unsetparam("zshrs_rt_s2");
20799        });
20800    }
20801
20802    /// Multi-byte UTF-8 scalar round-trip.
20803    /// Anchor: `X="日本語"; print -r -- "$X"` → "日本語"
20804    #[test]
20805    fn setsparam_multibyte_utf8_roundtrips() {
20806        with_exec(|| {
20807            unsetparam("zshrs_rt_s3");
20808            setsparam("zshrs_rt_s3", "日本語");
20809            assert_eq!(getsparam("zshrs_rt_s3").as_deref(), Some("日本語"));
20810            unsetparam("zshrs_rt_s3");
20811        });
20812    }
20813
20814    /// Embedded newline survives round-trip.
20815    #[test]
20816    fn setsparam_embedded_newline_roundtrips() {
20817        with_exec(|| {
20818            unsetparam("zshrs_rt_s4");
20819            setsparam("zshrs_rt_s4", "a\nb\nc");
20820            assert_eq!(getsparam("zshrs_rt_s4").as_deref(), Some("a\nb\nc"));
20821            unsetparam("zshrs_rt_s4");
20822        });
20823    }
20824
20825    /// Overwrite: setsparam twice returns the latter value.
20826    #[test]
20827    fn setsparam_overwrite_replaces_previous_value() {
20828        with_exec(|| {
20829            unsetparam("zshrs_rt_s5");
20830            setsparam("zshrs_rt_s5", "first");
20831            setsparam("zshrs_rt_s5", "second");
20832            assert_eq!(getsparam("zshrs_rt_s5").as_deref(), Some("second"));
20833            unsetparam("zshrs_rt_s5");
20834        });
20835    }
20836
20837    // ── Integer round-trip ─────────────────────────────────────────
20838    /// `setiparam("X", 42); getiparam("X")` → 42.
20839    /// Anchor: `typeset -i X=42; print -- $X` → "42".
20840    #[test]
20841    fn setiparam_then_getiparam_roundtrip_basic() {
20842        with_exec(|| {
20843            unsetparam("zshrs_rt_i1");
20844            setiparam("zshrs_rt_i1", 42);
20845            assert_eq!(getiparam("zshrs_rt_i1"), 42);
20846            unsetparam("zshrs_rt_i1");
20847        });
20848    }
20849
20850    /// Negative integer round-trip.
20851    #[test]
20852    fn setiparam_negative_value_roundtrips() {
20853        with_exec(|| {
20854            unsetparam("zshrs_rt_i2");
20855            setiparam("zshrs_rt_i2", -12345);
20856            assert_eq!(getiparam("zshrs_rt_i2"), -12345);
20857            unsetparam("zshrs_rt_i2");
20858        });
20859    }
20860
20861    /// `$COLUMNS` and `$LINES` read the live `zterm_*` global, both ways.
20862    ///
20863    /// c:Src/params.c:362-363 registers them as
20864    /// `IPDEF5("COLUMNS", &zterm_columns, zlevar_gsu)` /
20865    /// `IPDEF5("LINES", &zterm_lines, zlevar_gsu)`, so the getter
20866    /// (`intvargetfn`, c:4156 `return *pm->u.valptr;`) and the setter
20867    /// (`zlevarsetfn`, c:4176 `*p = x`) are two views of ONE cell. zshrs had
20868    /// only the setter half wired, and even that had no caller, so the two
20869    /// drifted: `adjustwinsize` published the new width to `ZTERM_COLUMNS`
20870    /// and to the parameter separately, and anything that rolled the
20871    /// parameter table back — an in-process `$(...)`'s saved state, a
20872    /// function scope — took the parameter back to the old width while the
20873    /// global kept the new one.
20874    ///
20875    /// What that cost: a completer forks a helper (`_call_program`), the
20876    /// foreground wait is where SIGWINCH is delivered, and `_git_commands`
20877    /// then pads every display string to `${(r.COLUMNS-4.)…}`. Strings built
20878    /// against the OLD width, counted against the NEW one, and `git <TAB>`
20879    /// across a 24x80 -> 24x60 resize asked to see "all 152 possibilities
20880    /// (236 lines)" for 152 one-line matches.
20881    #[test]
20882    fn columns_and_lines_are_two_views_of_the_zterm_globals() {
20883        with_exec(|| {
20884            let saved_cols = crate::ported::utils::ZTERM_COLUMNS.load(Ordering::SeqCst);
20885            let saved_lines = crate::ported::utils::ZTERM_LINES.load(Ordering::SeqCst);
20886            // `createparamtable` does not run under the unit harness, so make
20887            // the two nodes exist the way a real shell's startup does. The
20888            // read path deliberately requires a node: `unset COLUMNS` keeps
20889            // a PM_UNSET node (c:3877) and must read EMPTY, not the global.
20890            setiparam("COLUMNS", 80);
20891            setiparam("LINES", 24);
20892
20893            // READ half — c:4156. A resize publishes to the global; the
20894            // parameter must report it without anyone re-assigning it.
20895            crate::ported::utils::ZTERM_COLUMNS.store(60, Ordering::SeqCst);
20896            crate::ported::utils::ZTERM_LINES.store(12, Ordering::SeqCst);
20897            assert_eq!(getiparam("COLUMNS"), 60, "$COLUMNS must read zterm_columns");
20898            assert_eq!(getiparam("LINES"), 12, "$LINES must read zterm_lines");
20899            assert_eq!(getsparam("COLUMNS").as_deref(), Some("60"));
20900            assert_eq!(getsparam("LINES").as_deref(), Some("12"));
20901
20902            // WRITE half — c:4176. An assignment must move the global the
20903            // rest of the shell measures against, not just the node.
20904            setiparam("COLUMNS", 100);
20905            assert_eq!(
20906                crate::ported::utils::ZTERM_COLUMNS.load(Ordering::SeqCst),
20907                100,
20908                "COLUMNS=100 must write zterm_columns"
20909            );
20910            assert_eq!(getiparam("COLUMNS"), 100);
20911
20912            crate::ported::utils::ZTERM_COLUMNS.store(saved_cols, Ordering::SeqCst);
20913            crate::ported::utils::ZTERM_LINES.store(saved_lines, Ordering::SeqCst);
20914            setiparam("COLUMNS", saved_cols as i64);
20915            setiparam("LINES", saved_lines as i64);
20916        });
20917    }
20918
20919    /// Zero round-trip — distinct from "unset".
20920    #[test]
20921    fn setiparam_zero_roundtrips() {
20922        with_exec(|| {
20923            unsetparam("zshrs_rt_i3");
20924            setiparam("zshrs_rt_i3", 0);
20925            assert_eq!(getiparam("zshrs_rt_i3"), 0);
20926            unsetparam("zshrs_rt_i3");
20927        });
20928    }
20929
20930    /// `i64::MAX` and `i64::MIN` survive — zsh uses zlong (= long long
20931    /// on 64-bit hosts, which is i64 in Rust).
20932    #[test]
20933    fn setiparam_i64_extremes_roundtrip() {
20934        with_exec(|| {
20935            unsetparam("zshrs_rt_i4");
20936            setiparam("zshrs_rt_i4", i64::MAX);
20937            assert_eq!(getiparam("zshrs_rt_i4"), i64::MAX);
20938            setiparam("zshrs_rt_i4", i64::MIN);
20939            assert_eq!(getiparam("zshrs_rt_i4"), i64::MIN);
20940            unsetparam("zshrs_rt_i4");
20941        });
20942    }
20943
20944    // ── Cross-type coercion: setiparam → getsparam (int → string) ──
20945    /// `setiparam("X", 42); getsparam("X")` → Some("42").
20946    /// Anchor: `typeset -i X=42; print -r -- "$X"` → "42".
20947    #[test]
20948    fn setiparam_then_getsparam_coerces_to_decimal_string() {
20949        with_exec(|| {
20950            unsetparam("zshrs_rt_x1");
20951            setiparam("zshrs_rt_x1", 42);
20952            assert_eq!(getsparam("zshrs_rt_x1").as_deref(), Some("42"));
20953            unsetparam("zshrs_rt_x1");
20954        });
20955    }
20956
20957    /// Negative int → "-N" string form via getsparam.
20958    #[test]
20959    fn setiparam_negative_int_to_string_carries_minus_sign() {
20960        with_exec(|| {
20961            unsetparam("zshrs_rt_x2");
20962            setiparam("zshrs_rt_x2", -7);
20963            assert_eq!(getsparam("zshrs_rt_x2").as_deref(), Some("-7"));
20964            unsetparam("zshrs_rt_x2");
20965        });
20966    }
20967
20968    // ── Cross-type: setsparam("42") → getiparam → 42 ───────────────
20969    /// `setsparam("X", "42"); getiparam("X")` → 42 (numeric parse).
20970    /// Anchor: `X=42; print -- $((X+0))` → "42" (arith coerces).
20971    #[test]
20972    fn setsparam_numeric_string_then_getiparam_coerces_to_int() {
20973        with_exec(|| {
20974            unsetparam("zshrs_rt_x3");
20975            setsparam("zshrs_rt_x3", "42");
20976            assert_eq!(getiparam("zshrs_rt_x3"), 42);
20977            unsetparam("zshrs_rt_x3");
20978        });
20979    }
20980
20981    /// Non-numeric scalar → getiparam returns 0 (C zsh behavior).
20982    /// Anchor: `X=hello; print -- $((X+0))` → "0" (no numeric parse).
20983    #[test]
20984    fn setsparam_non_numeric_then_getiparam_returns_zero() {
20985        with_exec(|| {
20986            unsetparam("zshrs_rt_x4");
20987            setsparam("zshrs_rt_x4", "not a number");
20988            assert_eq!(getiparam("zshrs_rt_x4"), 0);
20989            unsetparam("zshrs_rt_x4");
20990        });
20991    }
20992
20993    // ── Array round-trip ───────────────────────────────────────────
20994    /// `setaparam("X", vec![...]); getaparam("X")` round-trips elements.
20995    /// Anchor: `X=(a b c); print -r -- "${X[@]}"` → "a b c".
20996    #[test]
20997    fn setaparam_then_getaparam_roundtrip_basic() {
20998        with_exec(|| {
20999            unsetparam("zshrs_rt_a1");
21000            setaparam("zshrs_rt_a1", vec!["a".into(), "b".into(), "c".into()]);
21001            assert_eq!(
21002                getaparam("zshrs_rt_a1"),
21003                Some(vec!["a".into(), "b".into(), "c".into()])
21004            );
21005            unsetparam("zshrs_rt_a1");
21006        });
21007    }
21008
21009    /// Empty array round-trip — distinct from unset.
21010    #[test]
21011    fn setaparam_empty_array_roundtrips_as_empty_vec() {
21012        with_exec(|| {
21013            unsetparam("zshrs_rt_a2");
21014            setaparam("zshrs_rt_a2", vec![]);
21015            assert_eq!(getaparam("zshrs_rt_a2"), Some(vec![]));
21016            unsetparam("zshrs_rt_a2");
21017        });
21018    }
21019
21020    /// Array element with embedded space stays one element (not split).
21021    /// Anchor: `X=("hi there" world); print ${#X}` → 2.
21022    #[test]
21023    fn setaparam_element_with_space_stays_one_element() {
21024        with_exec(|| {
21025            unsetparam("zshrs_rt_a3");
21026            setaparam("zshrs_rt_a3", vec!["hi there".into(), "world".into()]);
21027            assert_eq!(
21028                getaparam("zshrs_rt_a3"),
21029                Some(vec!["hi there".into(), "world".into()])
21030            );
21031            unsetparam("zshrs_rt_a3");
21032        });
21033    }
21034
21035    // ── unsetparam: idempotent + clears value ──────────────────────
21036    /// Unsetting a never-set param does nothing (no panic, no error).
21037    #[test]
21038    fn unsetparam_on_never_set_param_is_noop() {
21039        let _g = crate::test_util::global_state_lock();
21040        unsetparam("zshrs_rt_never_existed");
21041        // No assertion — just must not panic.
21042    }
21043
21044    /// Unset then get → None.
21045    #[test]
21046    fn unsetparam_then_getsparam_returns_none() {
21047        with_exec(|| {
21048            setsparam("zshrs_rt_u1", "value");
21049            unsetparam("zshrs_rt_u1");
21050            assert_eq!(getsparam("zshrs_rt_u1"), None);
21051        });
21052    }
21053
21054    /// Unsetting twice is idempotent.
21055    #[test]
21056    fn unsetparam_twice_is_idempotent() {
21057        with_exec(|| {
21058            setsparam("zshrs_rt_u2", "value");
21059            unsetparam("zshrs_rt_u2");
21060            unsetparam("zshrs_rt_u2"); // second call must not panic
21061            assert_eq!(getsparam("zshrs_rt_u2"), None);
21062        });
21063    }
21064
21065    // ── Cross-type: setiparam writes through scalar table, getaparam None ─
21066    /// PM_INTEGER is NOT PM_ARRAY — getaparam on integer returns None.
21067    /// Anchor: `typeset -i X=42; print -- ${X[1]}` errors (not an array).
21068    #[test]
21069    fn setiparam_then_getaparam_returns_none_not_an_array() {
21070        with_exec(|| {
21071            unsetparam("zshrs_rt_x5");
21072            setiparam("zshrs_rt_x5", 42);
21073            assert_eq!(
21074                getaparam("zshrs_rt_x5"),
21075                None,
21076                "PM_INTEGER is not PM_ARRAY — getaparam must return None"
21077            );
21078            unsetparam("zshrs_rt_x5");
21079        });
21080    }
21081
21082    // ── Read missing param: returns None / 0 ───────────────────────
21083    /// `getsparam("doesnt_exist")` → None.
21084    #[test]
21085    fn getsparam_on_unset_returns_none() {
21086        let _g = crate::test_util::global_state_lock();
21087        unsetparam("zshrs_rt_missing");
21088        assert_eq!(getsparam("zshrs_rt_missing"), None);
21089    }
21090
21091    /// `getiparam("doesnt_exist")` → 0 (C zsh semantics).
21092    /// Anchor: `unset X; print -- $((X+1))` → "1" (X read as 0).
21093    #[test]
21094    fn getiparam_on_unset_returns_zero() {
21095        let _g = crate::test_util::global_state_lock();
21096        unsetparam("zshrs_rt_missing2");
21097        assert_eq!(getiparam("zshrs_rt_missing2"), 0);
21098    }
21099
21100    /// `getaparam("doesnt_exist")` → None.
21101    #[test]
21102    fn getaparam_on_unset_returns_none() {
21103        let _g = crate::test_util::global_state_lock();
21104        unsetparam("zshrs_rt_missing3");
21105        assert_eq!(getaparam("zshrs_rt_missing3"), None);
21106    }
21107
21108    // ─── zsh corpus pins: params set/get round-trips ─────────────────
21109
21110    /// `setsparam("foo", "bar")` followed by `getsparam("foo")` returns
21111    /// `"bar"`. Round-trip scalar.
21112    #[test]
21113    fn params_corpus_scalar_round_trip_simple() {
21114        let _g = crate::test_util::global_state_lock();
21115        unsetparam("ZP_RT1");
21116        setsparam("ZP_RT1", "bar");
21117        assert_eq!(getsparam("ZP_RT1").as_deref(), Some("bar"));
21118        unsetparam("ZP_RT1");
21119    }
21120
21121    /// `setsparam("x", "")` then `getsparam("x")` returns `Some("")`,
21122    /// NOT `None` — empty-set is distinct from unset (per zsh semantics).
21123    #[test]
21124    fn params_corpus_empty_scalar_is_set_not_unset() {
21125        let _g = crate::test_util::global_state_lock();
21126        unsetparam("ZP_EMP");
21127        setsparam("ZP_EMP", "");
21128        assert_eq!(getsparam("ZP_EMP").as_deref(), Some(""), "empty-set is set");
21129        unsetparam("ZP_EMP");
21130    }
21131
21132    /// `unsetparam` after `setsparam` removes the param entirely:
21133    /// `getsparam` returns `None`.
21134    #[test]
21135    fn params_corpus_unset_after_set_returns_none() {
21136        let _g = crate::test_util::global_state_lock();
21137        setsparam("ZP_UR", "v");
21138        unsetparam("ZP_UR");
21139        assert_eq!(getsparam("ZP_UR"), None, "unsetparam removes param");
21140    }
21141
21142    /// `setiparam("i", 42)` and `getiparam` round-trip integer.
21143    #[test]
21144    fn params_corpus_integer_round_trip() {
21145        let _g = crate::test_util::global_state_lock();
21146        unsetparam("ZP_I");
21147        setiparam("ZP_I", 42);
21148        assert_eq!(getiparam("ZP_I"), 42);
21149        unsetparam("ZP_I");
21150    }
21151
21152    /// `setiparam` then `getsparam` reads integer as string.
21153    #[test]
21154    fn params_corpus_integer_read_as_string() {
21155        let _g = crate::test_util::global_state_lock();
21156        unsetparam("ZP_IS");
21157        setiparam("ZP_IS", 42);
21158        assert_eq!(
21159            getsparam("ZP_IS").as_deref(),
21160            Some("42"),
21161            "integer param reads as string repr"
21162        );
21163        unsetparam("ZP_IS");
21164    }
21165
21166    /// `setaparam("a", vec!["one","two","three"])` round-trips via
21167    /// `getaparam`.
21168    #[test]
21169    fn params_corpus_array_round_trip() {
21170        let _g = crate::test_util::global_state_lock();
21171        unsetparam("ZP_A");
21172        setaparam("ZP_A", vec!["one".into(), "two".into(), "three".into()]);
21173        assert_eq!(
21174            getaparam("ZP_A").as_deref(),
21175            Some(&["one".into(), "two".into(), "three".into()][..]),
21176            "array round-trip",
21177        );
21178        unsetparam("ZP_A");
21179    }
21180
21181    /// Empty array round-trip — `setaparam("a", vec![])` then
21182    /// `getaparam` returns `Some(empty vec)`, not `None`.
21183    #[test]
21184    fn params_corpus_empty_array_is_set_not_unset() {
21185        let _g = crate::test_util::global_state_lock();
21186        unsetparam("ZP_EA");
21187        setaparam("ZP_EA", vec![]);
21188        let got = getaparam("ZP_EA");
21189        assert!(got.is_some(), "empty array is set, not None");
21190        assert_eq!(got.unwrap().len(), 0, "len = 0");
21191        unsetparam("ZP_EA");
21192    }
21193
21194    /// `setsparam` overwrites previous value (scalar reassignment).
21195    #[test]
21196    fn params_corpus_scalar_overwrite() {
21197        let _g = crate::test_util::global_state_lock();
21198        unsetparam("ZP_O");
21199        setsparam("ZP_O", "first");
21200        setsparam("ZP_O", "second");
21201        assert_eq!(
21202            getsparam("ZP_O").as_deref(),
21203            Some("second"),
21204            "scalar overwrites cleanly"
21205        );
21206        unsetparam("ZP_O");
21207    }
21208
21209    /// Re-assigning a scalar to an array (changing type via setaparam).
21210    /// Default zsh allows this: array replaces scalar.
21211    #[test]
21212    fn params_corpus_scalar_to_array_replace_type() {
21213        let _g = crate::test_util::global_state_lock();
21214        unsetparam("ZP_ST");
21215        setsparam("ZP_ST", "scalar_val");
21216        setaparam("ZP_ST", vec!["a".into(), "b".into()]);
21217        assert_eq!(
21218            getaparam("ZP_ST").as_deref(),
21219            Some(&["a".into(), "b".into()][..]),
21220            "setaparam replaces scalar type",
21221        );
21222        unsetparam("ZP_ST");
21223    }
21224
21225    /// `unsetparam` on a name that doesn't exist is a no-op
21226    /// (zsh's idempotent unset).
21227    #[test]
21228    fn params_corpus_unset_on_missing_is_noop() {
21229        let _g = crate::test_util::global_state_lock();
21230        unsetparam("ZP_NEVER_EXISTED_42_xyz");
21231        // No panic: just returns None on subsequent get.
21232        assert_eq!(getsparam("ZP_NEVER_EXISTED_42_xyz"), None);
21233    }
21234
21235    /// Empty string passed to integer slot: `setiparam("z", 0)`
21236    /// returns `Some(0)` via getiparam (zero is a legitimate int).
21237    #[test]
21238    fn params_corpus_integer_zero_is_set() {
21239        let _g = crate::test_util::global_state_lock();
21240        unsetparam("ZP_IZ");
21241        setiparam("ZP_IZ", 0);
21242        assert_eq!(getiparam("ZP_IZ"), 0);
21243        assert_eq!(getsparam("ZP_IZ").as_deref(), Some("0"));
21244        unsetparam("ZP_IZ");
21245    }
21246
21247    /// Negative integers round-trip with sign preserved.
21248    #[test]
21249    fn params_corpus_integer_negative_preserves_sign() {
21250        let _g = crate::test_util::global_state_lock();
21251        unsetparam("ZP_IN");
21252        setiparam("ZP_IN", -42);
21253        assert_eq!(getiparam("ZP_IN"), -42);
21254        assert_eq!(getsparam("ZP_IN").as_deref(), Some("-42"));
21255        unsetparam("ZP_IN");
21256    }
21257
21258    // ─── associative array (hash) pins ───────────────────────────────
21259
21260    /// `sethparam` + `gethparam` round-trip. C `gethparam` returns
21261    /// `paramvalarr(..., SCANPM_WANTVALS)` (Src/params.c:3122) — i.e.
21262    /// the VALUES side of the hash, not flat k/v pairs. For
21263    /// `(key1 val1 key2 val2)` that's `[val1, val2]`. Keys come from
21264    /// `gethkparam` (covered by `..._hash_keys_only_returns_keys`).
21265    #[test]
21266    fn params_corpus_hash_round_trip_basic() {
21267        let _g = crate::test_util::global_state_lock();
21268        unsetparam("ZP_H");
21269        sethparam(
21270            "ZP_H",
21271            vec!["key1".into(), "val1".into(), "key2".into(), "val2".into()],
21272        );
21273        let got = gethparam("ZP_H");
21274        assert!(got.is_some(), "hash param set");
21275        let g = got.unwrap();
21276        assert_eq!(g.len(), 2, "2 values (SCANPM_WANTVALS) preserved");
21277        let mut sorted = g.clone();
21278        sorted.sort();
21279        assert_eq!(
21280            sorted,
21281            vec!["val1".to_string(), "val2".to_string()],
21282            "values come back regardless of hash-iter order"
21283        );
21284        unsetparam("ZP_H");
21285    }
21286
21287    /// `gethparam` on missing returns None.
21288    #[test]
21289    fn params_corpus_hash_missing_returns_none() {
21290        let _g = crate::test_util::global_state_lock();
21291        unsetparam("ZP_HM_xyz");
21292        assert!(gethparam("ZP_HM_xyz").is_none());
21293    }
21294
21295    /// `gethkparam` returns the keys-only view of a hash. With
21296    /// `{a:1,b:2}`, keys are `["a","b"]` (order may not be stable).
21297    #[test]
21298    fn params_corpus_hash_keys_only_returns_keys() {
21299        let _g = crate::test_util::global_state_lock();
21300        unsetparam("ZP_HK");
21301        sethparam(
21302            "ZP_HK",
21303            vec!["a".into(), "1".into(), "b".into(), "2".into()],
21304        );
21305        let keys = gethkparam("ZP_HK");
21306        assert!(keys.is_some(), "keys-only view exists");
21307        let k = keys.unwrap();
21308        assert_eq!(k.len(), 2, "2 keys");
21309        let mut sorted = k.clone();
21310        sorted.sort();
21311        assert_eq!(sorted, vec!["a".to_string(), "b".to_string()]);
21312        unsetparam("ZP_HK");
21313    }
21314
21315    /// Setting a hash with empty `Vec` removes the previous key/value
21316    /// pairs. After empty `sethparam`, lookups find no entries. Note
21317    /// `gethparam` returns SCANPM_WANTVALS (values only); `(a 1)` has
21318    /// 1 value, not 2 — see `..._hash_round_trip_basic`.
21319    #[test]
21320    fn params_corpus_hash_set_empty_clears_entries() {
21321        let _g = crate::test_util::global_state_lock();
21322        unsetparam("ZP_HC");
21323        sethparam("ZP_HC", vec!["a".into(), "1".into()]);
21324        assert_eq!(gethparam("ZP_HC").map(|v| v.len()), Some(1));
21325        sethparam("ZP_HC", vec![]);
21326        // empty hash still exists (some implementations distinguish
21327        // empty hash from unset)
21328        let after = gethparam("ZP_HC");
21329        if let Some(v) = after {
21330            assert!(v.is_empty(), "empty hash has 0 elements");
21331        }
21332        unsetparam("ZP_HC");
21333    }
21334
21335    /// Large integer at i64 boundary: i64::MAX round-trips.
21336    #[test]
21337    fn params_corpus_integer_max_round_trips() {
21338        let _g = crate::test_util::global_state_lock();
21339        unsetparam("ZP_IMX");
21340        setiparam("ZP_IMX", i64::MAX);
21341        assert_eq!(getiparam("ZP_IMX"), i64::MAX);
21342        unsetparam("ZP_IMX");
21343    }
21344
21345    /// i64::MIN round-trips with sign.
21346    #[test]
21347    fn params_corpus_integer_min_round_trips() {
21348        let _g = crate::test_util::global_state_lock();
21349        unsetparam("ZP_IMN");
21350        setiparam("ZP_IMN", i64::MIN);
21351        assert_eq!(getiparam("ZP_IMN"), i64::MIN);
21352        unsetparam("ZP_IMN");
21353    }
21354
21355    // ═══════════════════════════════════════════════════════════════════
21356    // C-parity tests pinning Src/params.c:1288 (isident). Foundational
21357    // identifier validator used by every param-name validation site.
21358    // ═══════════════════════════════════════════════════════════════════
21359
21360    /// `isident("")` returns false. C c:1292:
21361    ///   `if (!*s) return 0;`
21362    #[test]
21363    fn isident_empty_string_returns_false() {
21364        let _g = crate::test_util::global_state_lock();
21365        assert!(!isident(""));
21366    }
21367
21368    /// `isident("foo")` returns true — basic identifier.
21369    #[test]
21370    fn isident_alpha_identifier_returns_true() {
21371        let _g = crate::test_util::global_state_lock();
21372        assert!(isident("foo"));
21373    }
21374
21375    /// `isident("_foo")` — underscore prefix allowed.
21376    #[test]
21377    fn isident_underscore_prefix_returns_true() {
21378        let _g = crate::test_util::global_state_lock();
21379        assert!(isident("_foo"));
21380    }
21381
21382    /// `isident("foo_bar123")` — alnum + underscore mid.
21383    #[test]
21384    fn isident_alnum_with_underscore_returns_true() {
21385        let _g = crate::test_util::global_state_lock();
21386        assert!(isident("foo_bar123"));
21387    }
21388
21389    /// `isident("123")` — all-digit positional param. C c:1300+:
21390    ///   "All-digit names are valid (positional params)"
21391    #[test]
21392    fn isident_all_digit_positional_returns_true() {
21393        let _g = crate::test_util::global_state_lock();
21394        assert!(isident("123"), "all-digit names are positional params");
21395    }
21396
21397    /// `isident("123abc")` — digit prefix with letters → false.
21398    /// Mixed digit-first not a valid positional param OR identifier.
21399    #[test]
21400    fn isident_digit_prefix_alpha_returns_false() {
21401        let _g = crate::test_util::global_state_lock();
21402        assert!(!isident("123abc"));
21403    }
21404
21405    /// `isident(".ns.foo")` — ksh93 namespace dotted name allowed.
21406    /// C c:1296-1311 — leading `.` accepted if not followed by digit.
21407    #[test]
21408    fn isident_ksh93_namespace_dot_prefix_returns_true() {
21409        let _g = crate::test_util::global_state_lock();
21410        assert!(isident(".ns.foo"));
21411    }
21412
21413    /// `isident(".0bad")` — namespace must NOT start with digit.
21414    /// C c:1300: `if (idigit(s[1])) return 0;`
21415    #[test]
21416    fn isident_namespace_starting_with_digit_returns_false() {
21417        let _g = crate::test_util::global_state_lock();
21418        assert!(!isident(".0bad"), "namespace can't start with digit");
21419    }
21420
21421    /// `isident("foo-bar")` — dash not allowed in identifier.
21422    #[test]
21423    fn isident_dash_in_name_returns_false() {
21424        let _g = crate::test_util::global_state_lock();
21425        assert!(!isident("foo-bar"));
21426    }
21427
21428    /// `isident("foo bar")` — space not allowed.
21429    #[test]
21430    fn isident_space_in_name_returns_false() {
21431        let _g = crate::test_util::global_state_lock();
21432        assert!(!isident("foo bar"));
21433    }
21434
21435    /// `isident("foo[0]")` — subscript at end is allowed (C handles
21436    /// it in `isident_requires_balanced_subscript_brackets`).
21437    /// Pin: a simple `[0]` subscript validates.
21438    #[test]
21439    fn isident_with_simple_subscript_returns_true() {
21440        let _g = crate::test_util::global_state_lock();
21441        assert!(isident("foo[0]"));
21442    }
21443
21444    // ═══════════════════════════════════════════════════════════════════
21445    // Additional C-parity tests for Src/params.c isident.
21446    // ═══════════════════════════════════════════════════════════════════
21447
21448    /// c:1288 — `isident("")` returns false (empty not an identifier).
21449    #[test]
21450    fn isident_empty_returns_false() {
21451        let _g = crate::test_util::global_state_lock();
21452        assert!(!isident(""));
21453    }
21454
21455    /// c:1288 — single underscore IS valid ident.
21456    #[test]
21457    fn isident_single_underscore_is_valid() {
21458        let _g = crate::test_util::global_state_lock();
21459        assert!(isident("_"));
21460    }
21461
21462    /// c:1288 — single letter IS valid ident.
21463    #[test]
21464    fn isident_single_letter_is_valid() {
21465        let _g = crate::test_util::global_state_lock();
21466        assert!(isident("a"));
21467        assert!(isident("Z"));
21468    }
21469
21470    /// c:1288 — all-digit name (positional param like '$1') IS valid.
21471    #[test]
21472    fn isident_all_digits_is_valid_positional() {
21473        let _g = crate::test_util::global_state_lock();
21474        assert!(isident("1"));
21475        assert!(isident("123"));
21476        assert!(isident("0"));
21477    }
21478
21479    /// c:1288 — digit-first mixed (e.g. '1abc') is INVALID.
21480    #[test]
21481    fn isident_digit_first_mixed_is_invalid() {
21482        let _g = crate::test_util::global_state_lock();
21483        assert!(!isident("1abc"));
21484        assert!(!isident("0x"));
21485    }
21486
21487    /// c:1288 — underscore + alnum is valid.
21488    #[test]
21489    fn isident_underscore_prefix_alnum_is_valid() {
21490        let _g = crate::test_util::global_state_lock();
21491        assert!(isident("_foo"));
21492        assert!(isident("_123"));
21493        assert!(isident("foo_bar"));
21494    }
21495
21496    /// c:1288 — alphabetic + digits is valid.
21497    #[test]
21498    fn isident_alpha_digits_mix_is_valid() {
21499        let _g = crate::test_util::global_state_lock();
21500        assert!(isident("foo123"));
21501        assert!(isident("var2"));
21502    }
21503
21504    /// c:1326 — unbalanced subscript `foo[` is invalid.
21505    #[test]
21506    fn isident_unbalanced_open_bracket_returns_false() {
21507        let _g = crate::test_util::global_state_lock();
21508        assert!(!isident("foo["));
21509        assert!(!isident("foo[0"));
21510    }
21511
21512    /// c:1326 — special chars like `-`, `.`, `@` not allowed in identifier.
21513    #[test]
21514    fn isident_special_chars_rejected() {
21515        let _g = crate::test_util::global_state_lock();
21516        assert!(!isident("foo-bar")); // hyphen
21517        assert!(!isident("foo@bar"));
21518        assert!(!isident("foo!"));
21519    }
21520
21521    /// c:1288 — namespace prefix `.ns.var` requires a non-digit after dot.
21522    #[test]
21523    fn isident_namespace_prefix_dot_rejects_digit_after() {
21524        let _g = crate::test_util::global_state_lock();
21525        assert!(!isident(".0"), "dot + digit invalid");
21526        assert!(!isident("."), "lone dot invalid");
21527    }
21528
21529    /// c:1288 — namespace prefix `.foo` is valid.
21530    #[test]
21531    fn isident_namespace_prefix_alpha_after_dot_valid() {
21532        let _g = crate::test_util::global_state_lock();
21533        assert!(isident(".foo"));
21534        assert!(isident(".ns.var"));
21535    }
21536
21537    /// c:1288 — `isident` is deterministic.
21538    #[test]
21539    fn isident_is_deterministic() {
21540        let _g = crate::test_util::global_state_lock();
21541        for s in ["foo", "_bar", "123", "1abc", "", ".foo", "foo[", "foo-bar"] {
21542            let first = isident(s);
21543            for _ in 0..5 {
21544                assert_eq!(isident(s), first, "{:?} must be pure", s);
21545            }
21546        }
21547    }
21548
21549    // ═══════════════════════════════════════════════════════════════════
21550    // Additional C-parity tests for Src/params.c
21551    // c:1403 issetvar / c:2152 setsparam / c:2317 isident / c:4099 getiparam
21552    // c:4191 getsparam / c:4326 getsparam_u / c:4353 getaparam /
21553    // c:5363 setaparam / c:5696 setiparam / c:5775 unsetparam
21554    // ═══════════════════════════════════════════════════════════════════
21555
21556    /// c:1403 — `issetvar` returns i32 (compile-time type pin).
21557    #[test]
21558    fn issetvar_returns_i32_type() {
21559        let _g = crate::test_util::global_state_lock();
21560        let _: i32 = issetvar("anything");
21561    }
21562
21563    /// c:1403 — `issetvar("__nonexistent")` returns 0 for unset.
21564    #[test]
21565    fn issetvar_unknown_returns_zero() {
21566        let _g = crate::test_util::global_state_lock();
21567        let r = issetvar("__never_a_real_var_xyz_zshrs__");
21568        assert_eq!(r, 0, "unset var → 0");
21569    }
21570
21571    /// c:4191 — `getsparam` returns Option<String>.
21572    #[test]
21573    fn getsparam_returns_option_string_type() {
21574        let _g = crate::test_util::global_state_lock();
21575        let _: Option<String> = getsparam("anything");
21576    }
21577
21578    /// c:4191 — `getsparam("__nonexistent")` returns None.
21579    #[test]
21580    fn getsparam_unknown_returns_none() {
21581        let _g = crate::test_util::global_state_lock();
21582        assert!(
21583            getsparam("__never_a_real_var_xyz_zshrs__").is_none(),
21584            "unknown var → None"
21585        );
21586    }
21587
21588    /// c:4099 — `getiparam` returns i64.
21589    #[test]
21590    fn getiparam_returns_i64_type() {
21591        let _g = crate::test_util::global_state_lock();
21592        let _: i64 = getiparam("anything");
21593    }
21594
21595    /// c:4353 — `getaparam` returns Option<Vec<String>>.
21596    #[test]
21597    fn getaparam_returns_option_vec_string_type() {
21598        let _g = crate::test_util::global_state_lock();
21599        let _: Option<Vec<String>> = getaparam("anything");
21600    }
21601
21602    /// c:4326 — `getsparam_u("")` empty returns None.
21603    #[test]
21604    fn getsparam_u_empty_returns_none() {
21605        let _g = crate::test_util::global_state_lock();
21606        assert!(getsparam_u("").is_none(), "empty → None");
21607    }
21608
21609    /// c:2152 — `setsparam`/`getsparam` round-trip.
21610    #[test]
21611    fn setsparam_getsparam_round_trip() {
21612        let _g = crate::test_util::global_state_lock();
21613        let name = "ZSHRS_TEST_SETSPARAM_ROUND_TRIP";
21614        let _ = unsetparam(name);
21615        setsparam(name, "hello");
21616        assert_eq!(
21617            getsparam(name).as_deref(),
21618            Some("hello"),
21619            "setsparam/getsparam round-trip"
21620        );
21621        unsetparam(name);
21622    }
21623
21624    /// c:5696 — `setiparam`/`getiparam` round-trip.
21625    #[test]
21626    fn setiparam_getiparam_round_trip() {
21627        let _g = crate::test_util::global_state_lock();
21628        let name = "ZSHRS_TEST_SETIPARAM_ROUND_TRIP";
21629        unsetparam(name);
21630        setiparam(name, 42);
21631        assert_eq!(getiparam(name), 42, "setiparam/getiparam round-trip");
21632        unsetparam(name);
21633    }
21634
21635    /// c:5363 — `setaparam`/`getaparam` round-trip preserves array.
21636    #[test]
21637    fn setaparam_getaparam_round_trip() {
21638        let _g = crate::test_util::global_state_lock();
21639        let name = "ZSHRS_TEST_SETAPARAM_ROUND_TRIP";
21640        unsetparam(name);
21641        let v = vec!["a".to_string(), "b".to_string(), "c".to_string()];
21642        setaparam(name, v.clone());
21643        assert_eq!(getaparam(name), Some(v), "setaparam/getaparam round-trip");
21644        unsetparam(name);
21645    }
21646
21647    /// c:5775 — `unsetparam` is idempotent on unset names.
21648    #[test]
21649    fn unsetparam_unknown_name_no_panic() {
21650        let _g = crate::test_util::global_state_lock();
21651        unsetparam("__never_real_var_xyz__");
21652        unsetparam("__never_real_var_xyz__");
21653    }
21654
21655    // ═══════════════════════════════════════════════════════════════════
21656    // Additional C-parity tests for Src/params.c GSU vtable callbacks.
21657    // c:3993 intgetfn   / c:4002 intsetfn   / c:4011 floatgetfn
21658    // c:4020 floatsetfn / c:4029 strgetfn   / c:4057 arrgetfn
21659    // c:4084 hashgetfn  / c:4093 hashsetfn  / c:4180 nullstrsetfn
21660    // c:4192 nullunsetfn
21661    // ═══════════════════════════════════════════════════════════════════
21662
21663    /// c:3993-3997 — `intgetfn` body is `return pm->u.val;`. Pin that
21664    /// the Rust port reads `pm.u_val` verbatim with no transform.
21665    #[test]
21666    fn intgetfn_returns_u_val_verbatim() {
21667        let _g = crate::test_util::global_state_lock();
21668        let mut pm = param::default();
21669        pm.u_val = 0;
21670        assert_eq!(intgetfn(&pm), 0, "u_val=0 → 0");
21671        pm.u_val = 42;
21672        assert_eq!(intgetfn(&pm), 42, "u_val=42 → 42");
21673        pm.u_val = i64::MIN;
21674        assert_eq!(intgetfn(&pm), i64::MIN, "u_val=i64::MIN preserved");
21675        pm.u_val = i64::MAX;
21676        assert_eq!(intgetfn(&pm), i64::MAX, "u_val=i64::MAX preserved");
21677    }
21678
21679    /// c:4002-4006 — `intsetfn` body is `pm->u.val = x;`. For a
21680    /// non-special name (not SECONDS/RANDOM), the Rust port must
21681    /// write `pm.u_val = x` and intgetfn must round-trip.
21682    #[test]
21683    fn intsetfn_intgetfn_round_trip_for_non_special_name() {
21684        let _g = crate::test_util::global_state_lock();
21685        let mut pm = param::default();
21686        pm.node.nam = "X".to_string(); // non-SECONDS, non-RANDOM
21687        intsetfn(&mut pm, 7);
21688        assert_eq!(intgetfn(&pm), 7, "non-special intsetfn writes u_val");
21689        intsetfn(&mut pm, -123);
21690        assert_eq!(intgetfn(&pm), -123, "negative i64 round-trips");
21691    }
21692
21693    /// c:4011-4015 — `floatgetfn` body is `return pm->u.dval;`.
21694    /// Read `pm.u_dval` verbatim with no transform.
21695    #[test]
21696    fn floatgetfn_returns_u_dval_verbatim() {
21697        let _g = crate::test_util::global_state_lock();
21698        let mut pm = param::default();
21699        pm.u_dval = 0.0;
21700        assert_eq!(floatgetfn(&pm), 0.0, "u_dval=0.0 → 0.0");
21701        pm.u_dval = 3.14;
21702        assert_eq!(floatgetfn(&pm), 3.14, "u_dval=3.14 preserved");
21703        pm.u_dval = -2.71;
21704        assert_eq!(floatgetfn(&pm), -2.71, "negative dval preserved");
21705    }
21706
21707    /// c:4020-4024 — `floatsetfn` body for non-SECONDS is
21708    /// `pm->u.dval = x;`. Round-trip via floatgetfn.
21709    #[test]
21710    fn floatsetfn_floatgetfn_round_trip_for_non_seconds() {
21711        let _g = crate::test_util::global_state_lock();
21712        let mut pm = param::default();
21713        pm.node.nam = "Y".to_string(); // non-SECONDS
21714        floatsetfn(&mut pm, 1.5);
21715        assert_eq!(floatgetfn(&pm), 1.5, "non-SECONDS floatsetfn writes u_dval");
21716        floatsetfn(&mut pm, -0.0);
21717        assert_eq!(floatgetfn(&pm), -0.0, "neg zero round-trips");
21718    }
21719
21720    /// c:4029-4033 — `strgetfn` C body returns `pm->u.str ? pm->u.str
21721    /// : (char *) hcalloc(1);` — when u_str is None the C path
21722    /// returns a freshly allocated empty C string. Rust port must
21723    /// return `String::new()` (empty owned String) for the None case.
21724    #[test]
21725    fn strgetfn_returns_empty_string_when_u_str_none() {
21726        let _g = crate::test_util::global_state_lock();
21727        let mut pm = param::default();
21728        pm.u_str = None;
21729        let s = strgetfn(&pm);
21730        assert!(
21731            s.is_empty(),
21732            "None u_str → empty String (port of hcalloc(1))"
21733        );
21734    }
21735
21736    /// c:4029-4033 — `strgetfn` returns u_str clone when present.
21737    #[test]
21738    fn strgetfn_returns_clone_when_u_str_some() {
21739        let _g = crate::test_util::global_state_lock();
21740        let mut pm = param::default();
21741        pm.u_str = Some("hello".to_string());
21742        assert_eq!(strgetfn(&pm), "hello", "Some(s) → s");
21743    }
21744
21745    /// c:4057-4061 — `arrgetfn` C body returns `pm->u.arr ? pm->u.arr
21746    /// : &nullarray;` — when u_arr is None, the C path returns a
21747    /// pointer to the static empty `nullarray`. Rust port returns
21748    /// `Vec::new()` for the None case (empty owned Vec).
21749    #[test]
21750    fn arrgetfn_returns_empty_vec_when_u_arr_none() {
21751        let _g = crate::test_util::global_state_lock();
21752        let mut pm = param::default();
21753        pm.u_arr = None;
21754        let v = arrgetfn(&pm);
21755        assert!(v.is_empty(), "None u_arr → empty Vec (port of &nullarray)");
21756    }
21757
21758    /// c:4057-4061 — `arrgetfn` returns u_arr clone when present.
21759    #[test]
21760    fn arrgetfn_returns_clone_when_u_arr_some() {
21761        let _g = crate::test_util::global_state_lock();
21762        let mut pm = param::default();
21763        let src = vec!["a".to_string(), "b".to_string(), "c".to_string()];
21764        pm.u_arr = Some(src.clone());
21765        assert_eq!(arrgetfn(&pm), src, "Some(v) → v clone");
21766    }
21767
21768    /// c:4084-4088 — `hashgetfn` C body is `return pm->u.hash;`. The
21769    /// Rust port returns `Option<&HashTable>` (borrowing form of the
21770    /// C nullable pointer). Pin both the None and Some cases.
21771    #[test]
21772    fn hashgetfn_returns_option_ref_hashtable() {
21773        let _g = crate::test_util::global_state_lock();
21774        let mut pm = param::default();
21775        pm.u_hash = None;
21776        assert!(hashgetfn(&pm).is_none(), "None u_hash → None");
21777        pm.u_hash = newparamtable(8, "h");
21778        assert!(hashgetfn(&pm).is_some(), "Some u_hash → Some");
21779    }
21780
21781    /// c:4093-4097 — `hashsetfn` C body installs `x` into `pm->u.hash`.
21782    /// Rust port writes `pm.u_hash = Some(x)`; round-trip via
21783    /// hashgetfn must observe the same table.
21784    #[test]
21785    fn hashsetfn_hashgetfn_round_trip() {
21786        let _g = crate::test_util::global_state_lock();
21787        let mut pm = param::default();
21788        let ht = newparamtable(8, "h").expect("newparamtable returns Some");
21789        let want_size = ht.hsize;
21790        hashsetfn(&mut pm, ht);
21791        let got = hashgetfn(&pm).expect("hashsetfn must store table");
21792        assert_eq!(got.hsize, want_size, "stored table size preserved");
21793    }
21794
21795    /// c:4180-4183 — `nullstrsetfn(UNUSED pm, char *x)` is the
21796    /// PM_SPECIAL "cannot be set" hook: `zsfree(x);` discards x and
21797    /// makes no change to pm. Pin that the Rust port leaves every
21798    /// field of pm untouched.
21799    #[test]
21800    fn nullstrsetfn_leaves_pm_unchanged() {
21801        let _g = crate::test_util::global_state_lock();
21802        let mut pm = param::default();
21803        pm.node.nam = "PINNED".to_string();
21804        pm.u_str = Some("orig".to_string());
21805        pm.u_val = 99;
21806        nullstrsetfn(&mut pm, "discarded".to_string());
21807        assert_eq!(pm.node.nam, "PINNED", "nam untouched");
21808        assert_eq!(pm.u_str.as_deref(), Some("orig"), "u_str untouched");
21809        assert_eq!(pm.u_val, 99, "u_val untouched");
21810    }
21811
21812    /// c:4192-4195 — `nullunsetfn(UNUSED pm, UNUSED exp)` is the
21813    /// PM_SPECIAL "cannot be unset" hook: empty body. Pin that the
21814    /// Rust port leaves every field of pm untouched.
21815    #[test]
21816    fn nullunsetfn_leaves_pm_unchanged() {
21817        let _g = crate::test_util::global_state_lock();
21818        let mut pm = param::default();
21819        pm.node.nam = "PINNED".to_string();
21820        pm.node.flags = PM_SCALAR as i32;
21821        pm.u_str = Some("keep".to_string());
21822        nullunsetfn(&mut pm, 0);
21823        nullunsetfn(&mut pm, 1);
21824        assert_eq!(pm.node.nam, "PINNED", "nam untouched");
21825        assert_eq!(pm.node.flags, PM_SCALAR as i32, "flags untouched");
21826        assert_eq!(pm.u_str.as_deref(), Some("keep"), "u_str untouched");
21827    }
21828}
21829
21830// ===========================================================
21831// !!! WARNING: RUST-ONLY NAMEREF ADAPTERS — NO DIRECT C
21832// COUNTERPARTS AS FREE FUNCTIONS !!!
21833//
21834// C's nameref machinery (Src/params.c:6325-6500 resolve_nameref /
21835// resolve_nameref_rec / upscope / setscope / valid_refname, plus the
21836// PM_NAMEREF arms of fetchvalue c:2247-2270 and assignstrvalue
21837// c:2690-2720) walks live Param POINTERS chained through
21838// realparamtab. zshrs's paramtab hands out CLONES behind an RwLock,
21839// so the chain walk must re-look-up each hop BY NAME — these
21840// adapters are that by-name walk plus its carriers. Every entry
21841// here exists to adapt a cited C pattern to the lock/clone storage,
21842// in the same class as fdtable_get / sess_get. The canonical
21843// C-named ports (resolve_nameref at params.rs, resolve_nameref_rec,
21844// upscope, setscope, valid_refname) delegate INTO this walk.
21845//
21846// Relocated from vm_helper.rs (2026-06-12): ported code was calling
21847// bridge-resident helpers, inverting the layering rule. Bridge
21848// callers reach these via the vm_helper re-export.
21849// ===========================================================
21850
21851/// Result of walking a nameref chain by name. Rust-side carrier for
21852/// what C's `resolve_nameref_rec` (Src/params.c:6332) communicates
21853/// via the returned `Param` pointer: the chain may end at the final
21854/// non-ref target, at a placeholder ref (empty refname), at a
21855/// subscripted ref (`pm->width` ends the chain per c:6339), at a
21856/// dangling name (target never defined), or in a loop.
21857#[allow(non_camel_case_types)]
21858pub enum nameref_resolution {
21859    /// `name` is not a nameref (or doesn't exist / is an unset ref).
21860    NotRef,
21861    /// Chain ends at a nameref whose refname is empty/None —
21862    /// the C `keep_lastref` shape (c:6354). Fields = that ref's name and
21863    /// scope level (the ref may be a hidden `old`-chain node).
21864    Placeholder(String, i32),
21865    /// Final non-ref target. `pm` is a clone of the resolved binding
21866    /// (None when the target name was never defined — C returns NULL
21867    /// at c:6347 when gethashnode2 misses). `level` is the resolved
21868    /// binding's scope level (for old-chain writes).
21869    Target {
21870        /// final target name
21871        name: String,
21872        /// subscript from a `name[sub]` refname (ends the chain, c:6339)
21873        subscript: Option<String>,
21874        /// clone of the resolved binding (possibly a hidden old-chain node)
21875        pm: Option<Param>,
21876        /// scope level of the resolved binding (only when pm is Some)
21877        level: i32,
21878    },
21879    /// Loop detected — `zerr("...: invalid self reference")` already
21880    /// emitted (c:6341-6343).
21881    SelfRef,
21882    /// The refname EXISTS in the table but the upscope walk excluded
21883    /// every binding (c:6347-6349: `loadparamnode(upscope(...))`
21884    /// returned NULL with gethashnode2 non-NULL, so the `keep_lastref`
21885    /// else-arm is SKIPPED). Reads see unset; assignment FAILS
21886    /// (createparam c:1108-1118 refuses the existing ref).
21887    OutOfScope,
21888}
21889
21890/// !!! WARNING: RUST-ONLY HELPER !!!
21891/// No C counterpart under this name: name-keyed adaptation of `resolve_nameref_rec`
21892/// (`Src/params.c:6332`), which walks `Param` pointers. zshrs's paramtab lives
21893/// behind an `RwLock`, so each hop must re-look-up by name and the C `PM_TAGGED`
21894/// pointer tag becomes a visited `(name, level)` set. The name-parity port of
21895/// `resolve_nameref_rec` itself is above.
21896/// Walk the nameref chain starting at `name` against the live
21897/// paramtab. Faithful adaptation of `resolve_nameref_rec`
21898/// (Src/params.c:6332-6357): per-hop `upscope` via the old-chain,
21899/// `pm->width` subscript chain-end (c:6338-6339), PM_TAGGED-style
21900/// loop detection (visited set of (name, level) stands in for the
21901/// C pointer tag — same revisit condition, borrow-safe), and the
21902/// `keep_lastref` placeholder shape. `stop_at` mirrors the C `stop`
21903/// param (compared by (name, level) identity).
21904pub fn resolve_nameref_name(name: &str, stop_at: Option<(&str, i32)>) -> nameref_resolution {
21905    // `${(!)…}` SCANPM_NONAMEREF scope (c:2247 `!(scanflags &
21906    // SCANPM_NONAMEREF)`) — treat nothing as a ref. Centralized here so
21907    // every caller inherits the gate (previously each caller guarded
21908    // via is_nameref first).
21909    if NAMEREF_SUPPRESS.with(|c| c.get()) > 0 {
21910        return nameref_resolution::NotRef;
21911    }
21912    // upscope (Src/params.c:6440-6463) over CLONED chain nodes —
21913    // nested in the engine (sole consumer) instead of a free fn.
21914    let upscope_clone =
21915        |visible: &param, ref_flags: u32, ref_level: i32, ref_base: i32| -> Option<Param> {
21916            let mut cur: &param = visible;
21917            if (ref_flags & PM_UPPER) != 0 {
21918                // c:6457-6458 — `while (pm->level > ref->level - 1 &&
21919                // (pm = pm->old));` — pm becomes NULL when the chain runs
21920                // out above the ref's scope (an upscope ref to a name that
21921                // only exists at/below its own level is DANGLING).
21922                while cur.level > ref_level - 1 {
21923                    match cur.old.as_deref() {
21924                        Some(o) => cur = o,
21925                        None => return None,
21926                    }
21927                }
21928            } else {
21929                // c:6460
21930                while let Some(o) = cur.old.as_deref() {
21931                    if o.level >= ref_base {
21932                        cur = o;
21933                    } else {
21934                        break;
21935                    }
21936                }
21937            }
21938            Some(Box::new(cur.clone()))
21939        };
21940    let mut tab = match paramtab().read() {
21941        Ok(t) => t,
21942        Err(_) => return nameref_resolution::NotRef,
21943    };
21944    let first = match tab.get(name) {
21945        Some(p) => p,
21946        None => return nameref_resolution::NotRef,
21947    };
21948    let f = first.node.flags as u32;
21949    if (f & PM_NAMEREF) == 0 {
21950        return nameref_resolution::NotRef;
21951    }
21952    // c:6336-6339 — an UNSET nameref ends the chain at itself: reads
21953    // see "unset" (fetchvalue NULL), assignment writes its refname
21954    // (setstrvalue c:2712 clears PM_UNSET then the PM_NAMEREF arm
21955    // stores the new refname).
21956    if (f & PM_UNSET) != 0 {
21957        return nameref_resolution::Placeholder(first.node.nam.clone(), first.level);
21958    }
21959    let mut cur: Param = first.clone();
21960    let mut visited: Vec<(String, i32)> = Vec::new();
21961    let mut hops = 0usize;
21962    loop {
21963        hops += 1;
21964        if hops > 256 {
21965            // Hard backstop (C relies on PM_TAGGED; this can't fire
21966            // unless the tag logic above missed a cycle).
21967            zerr(&format!("{}: invalid self reference", cur.node.nam));
21968            return nameref_resolution::SelfRef;
21969        }
21970        let cf = cur.node.flags as u32;
21971        if (cf & PM_NAMEREF) == 0 || (cf & PM_UNSET) != 0 {
21972            // Final non-ref (or unset-ref) target reached.
21973            let lvl = cur.level;
21974            let nm = cur.node.nam.clone();
21975            return nameref_resolution::Target {
21976                name: nm,
21977                subscript: None,
21978                pm: Some(cur),
21979                level: lvl,
21980            };
21981        }
21982        // c:6338-6339 — refname; subscripted refname ends the chain.
21983        let refname_full = match cur.u_str.as_deref() {
21984            Some(r) if !r.is_empty() => r.to_string(),
21985            _ => {
21986                // c:6354 keep_lastref — placeholder ref.
21987                return nameref_resolution::Placeholder(cur.node.nam.clone(), cur.level);
21988            }
21989        };
21990        // c:6339 — `pm->width` is the subscript offset; a subscripted
21991        // ref is the end of any chain (see fetchvalue c:2250-2256).
21992        if cur.width > 0 && (cur.width as usize) < refname_full.len() {
21993            let (base_name, sub) = {
21994                let i = cur.width as usize;
21995                let tail = &refname_full[i + 1..];
21996                let key = tail.strip_suffix(']').unwrap_or(tail);
21997                (refname_full[..i].to_string(), Some(key.to_string()))
21998            };
21999            let resolved = tab
22000                .get(&base_name)
22001                .and_then(|vis| upscope_clone(vis, cur.node.flags as u32, cur.level, cur.base));
22002            // c:2258-2259 (fetchvalue) — `pm = loadparamnode(paramtab,
22003            // upscope(p1, pm), ref)`: a PM_AUTOLOAD stub loads its module;
22004            // a load that fails to define it leaves the element unset.
22005            let resolved = if resolved
22006                .as_ref()
22007                .is_some_and(|p| (p.node.flags as u32 & PM_AUTOLOAD) != 0)
22008            {
22009                drop(tab);
22010                newparamtable(0, "paramtab").and_then(|ht| loadparamnode(&ht, resolved, &base_name))
22011            } else {
22012                resolved
22013            };
22014            let lvl = resolved.as_ref().map(|p| p.level).unwrap_or(0);
22015            return nameref_resolution::Target {
22016                name: base_name,
22017                subscript: sub,
22018                pm: resolved,
22019                level: lvl,
22020            };
22021        }
22022        // c:6341-6343 — PM_TAGGED loop detection.
22023        let key = (cur.node.nam.clone(), cur.level);
22024        if visited.contains(&key) {
22025            zerr(&format!("{}: invalid self reference", cur.node.nam));
22026            return nameref_resolution::SelfRef;
22027        }
22028        visited.push(key);
22029        // c:6346-6347 — next hop via gethashnode2 + upscope.
22030        let next = match tab.get(&refname_full) {
22031            Some(vis) => {
22032                match upscope_clone(vis, cur.node.flags as u32, cur.level, cur.base) {
22033                    // c:6347 `loadparamnode(paramtab, upscope(pm, ref),
22034                    // refname)` — a PM_AUTOLOAD stub loads its module; if
22035                    // that fails to define the parameter, resolution is NULL.
22036                    Some(n) if (n.node.flags as u32 & PM_AUTOLOAD) != 0 => {
22037                        drop(tab);
22038                        match newparamtable(0, "paramtab")
22039                            .and_then(|ht| loadparamnode(&ht, Some(n), &refname_full))
22040                        {
22041                            Some(loaded) => {
22042                                tab = match paramtab().read() {
22043                                    Ok(t) => t,
22044                                    Err(_) => return nameref_resolution::NotRef,
22045                                };
22046                                loaded
22047                            }
22048                            None => return nameref_resolution::OutOfScope,
22049                        }
22050                    }
22051                    Some(n) => n,
22052                    None => {
22053                        // c:6347-6349 — name exists but upscope ran
22054                        // out: the keep_lastref else-arm is skipped,
22055                        // resolve returns NULL.
22056                        return nameref_resolution::OutOfScope;
22057                    }
22058                }
22059            }
22060            None => {
22061                // c:6347 gethashnode2 miss — dangling target.
22062                return nameref_resolution::Target {
22063                    name: refname_full,
22064                    subscript: None,
22065                    pm: None,
22066                    level: 0,
22067                };
22068            }
22069        };
22070        // c:6348 — `pm != stop` guard: when the hop lands on the stop
22071        // param, do NOT recurse (used by setscope's self-ref check
22072        // via resolve_nameref_rec(pm, pm, 0) at c:6423).
22073        if let Some((snam, slvl)) = stop_at {
22074            if next.node.nam == snam && next.level == slvl {
22075                let lvl = next.level;
22076                let nm = next.node.nam.clone();
22077                return nameref_resolution::Target {
22078                    name: nm,
22079                    subscript: None,
22080                    pm: Some(next),
22081                    level: lvl,
22082                };
22083            }
22084        }
22085        // c:6348 — `!(pm->node.flags & PM_UNSET)` guard: an unset
22086        // target stops the recursion; the unset param is returned.
22087        if (next.node.flags as u32 & PM_UNSET) != 0 {
22088            // An unset NAMEREF hop behaves like a placeholder: the
22089            // assignment revives it by writing the refname (the
22090            // PM_NAMEREF assignstrvalue arm at c:2715).
22091            if (next.node.flags as u32 & PM_NAMEREF) != 0 {
22092                return nameref_resolution::Placeholder(next.node.nam.clone(), next.level);
22093            }
22094            let lvl = next.level;
22095            let nm = next.node.nam.clone();
22096            return nameref_resolution::Target {
22097                name: nm,
22098                subscript: None,
22099                pm: Some(next),
22100                level: lvl,
22101            };
22102        }
22103        cur = next;
22104    }
22105}
22106
22107thread_local! {
22108/// `${(!)name}` — SCANPM_NONAMEREF depth (Src/params.c:2232-2235:
22109/// the `!` flag routes the lookup through `getnode2`, skipping
22110/// `resolve_nameref`). Non-zero ⇒ `is_nameref` reports false so
22111/// every deref point reads the ref itself.
22112pub static NAMEREF_SUPPRESS: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
22113}
22114
22115/// RAII guard for `${(!)...}` — suppresses nameref resolution on this
22116/// thread for its lifetime (SCANPM_NONAMEREF scope).
22117pub struct NamerefSuppressGuard;
22118
22119impl NamerefSuppressGuard {
22120    /// Enter a SCANPM_NONAMEREF scope.
22121    #[allow(clippy::new_without_default)]
22122    pub fn new() -> Self {
22123        NAMEREF_SUPPRESS.with(|c| c.set(c.get() + 1));
22124        NamerefSuppressGuard
22125    }
22126}
22127
22128impl Drop for NamerefSuppressGuard {
22129    fn drop(&mut self) {
22130        NAMEREF_SUPPRESS.with(|c| c.set(c.get().saturating_sub(1)));
22131    }
22132}
22133
22134/// !!! WARNING: RUST-ONLY HELPER !!!
22135/// No C counterpart: C tests `pm->node.flags & PM_NAMEREF` inline at each site
22136/// (e.g. `fetchvalue`, `Src/params.c:2247`). This predicate does the paramtab
22137/// lookup that Rust needs before the flag can be read.
22138/// Is the visible binding of `name` a PM_NAMEREF? (Cheap pre-check
22139/// so the resolver isn't invoked on every plain variable read.)
22140///
22141/// A node still carrying `PM_NAMEREF` does NOT necessarily behave as a
22142/// reference. C gates every nameref-chase on the c:1088-1090 predicate
22143/// (`Src/params.c:1086-1090`, `createparam`):
22144///
22145/// ```c
22146/// if (oldpm && !(flags & PM_NAMEREF) &&
22147///     (oldpm->level == locallevel ?
22148///      !(oldpm->node.flags & PM_RO_BY_DESIGN) : !(flags & PM_LOCAL)) &&
22149///     (oldpm->node.flags & PM_NAMEREF) &&
22150///     (!(oldpm->node.flags & PM_UNSET) ||
22151///      (oldpm->node.flags & PM_DECLARED))) {
22152/// ```
22153///
22154/// `unset -n ref` on a LOCAL reference runs `unsetparam_pm`, which does
22155/// `pm->node.flags &= ~PM_DECLARED;` (c:3798 — "like ksh, not like bash")
22156/// and then stops at c:3851 `if ((pm->level && locallevel >= pm->level)
22157/// ... return 0;`, i.e. it KEEPS the node in `paramtab` with
22158/// `PM_NAMEREF | PM_UNSET` and PM_DECLARED cleared. C's c:1088-1090
22159/// predicate is then false, so `createparam` skips the chase entirely
22160/// and falls through to c:1135 (`pm = oldpm`), reusing the node with the
22161/// caller's fresh flags — the reference becomes an ordinary parameter of
22162/// whatever type the assignment/typeset asked for.
22163///
22164/// The same predicate also guards `fetchvalue` (c:2241-2243
22165/// `if (pm->node.flags & PM_UNSET && !(pm->node.flags & PM_DECLARED))
22166/// return NULL;`), so reads of such a node yield empty either way.
22167pub fn is_nameref(name: &str) -> bool {
22168    // `${(!)…}` SCANPM_NONAMEREF scope active → treat nothing as a ref.
22169    if NAMEREF_SUPPRESS.with(|c| c.get()) > 0 {
22170        return false;
22171    }
22172    paramtab()
22173        .read()
22174        .ok()
22175        .and_then(|t| {
22176            t.get(name).map(|p| {
22177                let f = p.node.flags as u32;
22178                // c:1089 — `(oldpm->node.flags & PM_NAMEREF)`.
22179                (f & PM_NAMEREF) != 0
22180                    // c:1090 — `(!(oldpm->node.flags & PM_UNSET) ||
22181                    //            (oldpm->node.flags & PM_DECLARED))`.
22182                    && ((f & PM_UNSET) == 0 || (f & PM_DECLARED) != 0)
22183            })
22184        })
22185        .unwrap_or(false)
22186}
22187
22188/// !!! WARNING: RUST-ONLY HELPER !!!
22189/// No C counterpart: the PM_TYPE dispatch inside `getstrvalue`
22190/// (`Src/params.c:2335`) lifted out so it can run against a borrowed `param`
22191/// without the VALFLAG_SUBST padding. C does this inline in the one function.
22192/// Raw scalar value of a (possibly hidden) param clone — the
22193/// type-dispatch heart of `getstrvalue` (Src/params.c:2350-2382)
22194/// without the VALFLAG_SUBST padding (refs to hidden bindings).
22195fn param_scalar_value(pm: &param) -> Option<String> {
22196    let t = PM_TYPE(pm.node.flags as u32);
22197    if t == PM_INTEGER {
22198        let base = if pm.base > 0 { pm.base } else { 10 };
22199        Some(convbase(pm.u_val, base as u32))
22200    } else if t == PM_EFLOAT || t == PM_FFLOAT {
22201        Some(convfloat(pm.u_dval, pm.base, pm.node.flags as u32))
22202    } else if t == PM_SCALAR && pm.gsu_s.is_some() {
22203        pm.gsu_s.as_ref().map(|gsu| (gsu.getfn)(pm))
22204    } else if let Some(s) = pm.u_str.as_ref() {
22205        Some(s.clone())
22206    } else if let Some(arr) = pm.u_arr.as_ref() {
22207        Some(arr.join(" "))
22208    } else if t == PM_HASHED {
22209        // assoc joined values (getstrvalue KSH `[0]` path is rare;
22210        // bare $assoc is empty in zsh, return empty)
22211        Some(String::new())
22212    } else {
22213        None
22214    }
22215}
22216
22217/// !!! WARNING: RUST-ONLY HELPER !!!
22218/// No C counterpart: C performs this read inside `fetchvalue`'s nameref branch
22219/// (`Src/params.c:2247`) followed by `getindex()` (`Src/params.c:2290`), operating
22220/// on `Param` pointers. Rust resolves the target by name, so the element read is
22221/// split out here.
22222/// Element read for a nameref bound to `target[sub]` — the
22223/// fetchvalue REFSLICE / getindex hand-off (Src/params.c:2247-2270
22224/// + c:2289). Arrays take a math index (1-based, negative from
22225/// end), assocs a literal key, scalars a char index.
22226fn nameref_element_read(pm: &param, target: &str, key: &str) -> Option<String> {
22227    let t = PM_TYPE(pm.node.flags as u32);
22228    // The resolved binding is the visible one unless the upscope walk
22229    // (c:6455) landed on a hidden `old`-chain node.
22230    let visible = paramtab()
22231        .read()
22232        .ok()
22233        .and_then(|tab| tab.get(target).map(|p| p.level))
22234        == Some(pm.level);
22235    // c:2265 `scanflags |= SCANPM_NOEXEC` for a reference slice, so
22236    // getarg's c:1563-1570 parses the subscript and singsubs it with
22237    // EXECOPT off: a `$(...)` in the referent expands to nothing.
22238    let key: String = {
22239        let exe = opt_state_get("exec").unwrap_or(true);
22240        let tok = match crate::ported::lex::parsestr(key) {
22241            Ok(t) => t,
22242            Err(_) => return None, // c:1566-1567
22243        };
22244        opt_state_set("exec", false); // c:1568-1569
22245        let out = crate::ported::subst::singsub(&tok);
22246        opt_state_set("exec", exe); // c:1571
22247        out
22248    };
22249    let key = key.as_str();
22250    if t == PM_HASHED {
22251        // c:2289 getindex reads the element through the hash's own
22252        // `gsu.h->getfn`: a zsh/parameter special answers per key.
22253        if (pm.node.flags as u32 & PM_SPECIAL) != 0 {
22254            if let Some(e) = crate::ported::modules::parameter::PARTAB.iter().find(|e| e.name == target) {
22255                return (e.getfn)(std::ptr::null_mut(), key).and_then(|p| p.u_str);
22256            }
22257        }
22258        // The assoc backing is name-keyed (paramtab_hashed_storage);
22259        // when the resolved binding is a HIDDEN old-chain node (a
22260        // `local` shadow covers it), the outer's data lives on the
22261        // shadow STACK (pushed by createparam's PM_HASHED save).
22262        if !visible {
22263            if let Some(stk) = crate::ported::params::PARAMTAB_HASHED_SHADOW_STACK.get() {
22264                if let Ok(stk) = stk.lock() {
22265                    if let Some(frames) = stk.get(target) {
22266                        // Frames are `(level, row)`; the hidden outer's bag is
22267                        // the row saved by the innermost shadow.
22268                        if let Some((_, Some(saved))) = frames.last() {
22269                            return saved.get(key).cloned();
22270                        }
22271                    }
22272                }
22273            }
22274        }
22275        return paramtab_hashed_storage()
22276            .lock()
22277            .ok()
22278            .and_then(|m| m.get(target).and_then(|h| h.get(key).cloned()));
22279    }
22280    // c:1597 `r = mathevalarg(s, &s)` — reports a bad (or empty) index.
22281    let idx = crate::ported::math::mathevalarg(key);
22282    if errflag.load(Ordering::Relaxed) != 0 {
22283        return None;
22284    }
22285    // A visible binding reads through the canonical getters, so the
22286    // specials' getfns (`argv`, `ARGC`, `_`, ...) answer, not the
22287    // node's stored slot.
22288    if t == PM_ARRAY {
22289        let arr = if visible {
22290            getaparam(target)?
22291        } else {
22292            pm.u_arr.clone()?
22293        };
22294        let len = arr.len() as i64;
22295        let pos = if idx < 0 { len + idx + 1 } else { idx };
22296        if pos >= 1 && pos <= len {
22297            return Some(arr[(pos - 1) as usize].clone());
22298        }
22299        return Some(String::new());
22300    }
22301    // scalar char index (1-based)
22302    let s = if visible {
22303        getsparam(target)?
22304    } else {
22305        param_scalar_value(pm)?
22306    };
22307    let chars: Vec<char> = s.chars().collect();
22308    let len = chars.len() as i64;
22309    let pos = if idx < 0 { len + idx + 1 } else { idx };
22310    if pos >= 1 && pos <= len {
22311        return Some(chars[(pos - 1) as usize].to_string());
22312    }
22313    Some(String::new())
22314}
22315
22316/// !!! WARNING: RUST-ONLY HELPER !!!
22317/// No C counterpart under this name: full-table variant of `setscope(Param pm)`
22318/// (`Src/params.c:6382`). The name-parity `setscope` port above works on a borrowed
22319/// `param`; the base-scope computation, the WARNNESTEDVAR diagnostic and the
22320/// chain-walk self-reference unset all need the live paramtab, which cannot be
22321/// locked while that borrow is held.
22322/// Full-table variant of `setscope(Param pm)` (Src/params.c:6382).
22323/// Performs the parts the in-place `setscope` above cannot: the
22324/// base-scope computation against the live paramtab (c:6402-6410),
22325/// the WARNNESTEDVAR diagnostic (c:6411-6419), and the chain-walk
22326/// self-reference detection + unset (c:6421-6431). Returns 1 when
22327/// the self-reference error fired (the ref has been removed), else 0.
22328/// MUST be called without any paramtab lock held.
22329///
22330/// `level` picks the struct: `None` is the visible binding of `name`;
22331/// `Some(l)` is the node at scope `l` on its `old` chain, which is how a
22332/// ref hidden by a same-name local is addressed (C passes that struct's
22333/// pointer). A hidden ref gets the base computation only: the chain-walk
22334/// self-reference check starts from the visible binding of `name`.
22335pub fn setscope_by_name(name: &str, level: Option<i32>) -> i32 {
22336    queue_signals();
22337    // The struct setscope works on: the visible node, or the node at
22338    // `level` on its `old` chain.
22339    let with_node = |f: &mut dyn FnMut(&mut param)| {
22340        if let Ok(mut tab) = paramtab().write() {
22341            let mut cur = tab.get_mut(name).map(|b| b.as_mut());
22342            while let Some(pm) = cur {
22343                if level.is_none_or(|l| pm.level == l) {
22344                    f(pm);
22345                    return;
22346                }
22347                cur = pm.old.as_deref_mut();
22348            }
22349        }
22350    };
22351    // Snapshot the ref's state.
22352    let mut snap = None;
22353    with_node(&mut |pm: &mut param| {
22354        if (pm.node.flags as u32 & PM_NAMEREF) != 0 {
22355            snap = Some((pm.u_str.clone(), pm.level, pm.node.flags as u32));
22356        }
22357    });
22358    let visible = level.is_none()
22359        || paramtab().read().ok().and_then(|t| t.get(name).map(|p| p.level)) == level;
22360    let (refname_opt, ref_level, ref_flags) = match snap {
22361        Some(s) => s,
22362        None => {
22363            unqueue_signals();
22364            return 0;
22365        }
22366    };
22367    let refname_full = refname_opt.unwrap_or_default();
22368    // c:6388-6400 — `t = itype_end(refname, INAMESPC, 0)`; only a `[`
22369    // right after the NAME is a subscript, so `![1]` keeps width 0 and
22370    // names the (nonexistent) parameter `![1]`.
22371    let (head, width) = {
22372        let t = crate::ported::utils::itype_end(&refname_full, crate::ported::ztype_h::INAMESPC, false);
22373        if t > 0 && refname_full[t..].starts_with('[') {
22374            (refname_full[..t].to_string(), t as i32)
22375        } else {
22376            (refname_full.clone(), 0)
22377        }
22378    };
22379    if width != 0 {
22380        with_node(&mut |pm: &mut param| pm.width = width); // c:6398 pm->width = t - refname
22381    }
22382    // c:6402-6410 — compute pm->base from the target's visible level.
22383    // `basepm != pm || !basepm->old || (basepm = basepm->old)`: a ref
22384    // whose refname is its own name binds to the ENCLOSING binding.
22385    let mut basepm_is_self = false;
22386    if (ref_flags & PM_UPPER) == 0 && !head.is_empty() {
22387        // c:6405-6406 — `basepm = loadparamnode(realparamtab, basepm,
22388        // refname)`: binding a reference to an autoloadable parameter
22389        // loads its module.
22390        let stub = paramtab()
22391            .read()
22392            .ok()
22393            .and_then(|t| t.get(&head).filter(|p| (p.node.flags as u32 & PM_AUTOLOAD) != 0).cloned());
22394        if let Some(stub) = stub {
22395            let _ = newparamtable(0, "paramtab").and_then(|ht| loadparamnode(&ht, Some(stub), &head));
22396        } else if crate::vm_helper::module_param_is_autoload_stub(&head) {
22397            // The same load for a zsh/parameter row, whose PM_AUTOLOAD state
22398            // zshrs keeps in vm_helper's MATERIALIZED_MODULE_PARAMS side set.
22399            crate::vm_helper::mark_module_param_used(&head);
22400        }
22401        let base_level: Option<i32> = {
22402            let tab = paramtab().read().unwrap();
22403            match tab.get(&head) {
22404                Some(vis) => {
22405                    if head == name && vis.level == ref_level {
22406                        // basepm == pm — prefer pm->old (c:6407).
22407                        match vis.old.as_deref() {
22408                            Some(o) => Some(o.level),
22409                            None => {
22410                                basepm_is_self = true;
22411                                None
22412                            }
22413                        }
22414                    } else {
22415                        Some(vis.level)
22416                    }
22417                }
22418                None => None,
22419            }
22420        };
22421        if let Some(bl) = base_level {
22422            with_node(&mut |pm: &mut param| setscope_base(pm, bl)); // c:6409 setscope_base(pm, basepm->level)
22423        }
22424        // c:6411-6419 — base > level diagnostics.
22425        let mut base_now = 0;
22426        with_node(&mut |pm: &mut param| base_now = pm.base);
22427        if base_now > ref_level && isset(crate::ported::zsh_h::WARNNESTEDVAR) {
22428            // c:6417-6418
22429            zwarn(&format!(
22430                "reference {} in enclosing scope set to local variable {}",
22431                name, head
22432            ));
22433        }
22434    }
22435    // c:6421-6431 — self-reference detection via the chain walk with
22436    // stop == pm.
22437    let mut selfref = basepm_is_self;
22438    if !visible {
22439        unqueue_signals();
22440        return 0;
22441    }
22442    if !head.is_empty() && width == 0 && !basepm_is_self {
22443        match resolve_nameref_name(name, Some((name, ref_level))) {
22444            nameref_resolution::Target {
22445                name: tname, level, ..
22446            } if tname == name && level == ref_level => {
22447                selfref = true; // chain looped back to pm
22448            }
22449            nameref_resolution::SelfRef => {
22450                // zerr already emitted inside the walk (with the
22451                // looping ref's name, matching the recursive C zerr).
22452                unqueue_signals();
22453                return 1;
22454            }
22455            _ => {}
22456        }
22457    }
22458    if selfref {
22459        // c:6426-6429 — `zerr("%s: invalid self reference", refname);
22460        //               unsetparam_pm(pm, 0, 1);`
22461        zerr(&format!("{}: invalid self reference", head));
22462        if let Some(mut pm) = paramtab().write().ok().and_then(|mut t| t.remove(name)) {
22463            if let Some(prev) = pm.old.take() {
22464                // uncover any outer binding (unsetparam_pm restore shape)
22465                if let Ok(mut tab) = paramtab().write() {
22466                    tab.insert(name.to_string(), prev);
22467                }
22468            }
22469        }
22470        unqueue_signals();
22471        return 1;
22472    }
22473    unqueue_signals();
22474    0
22475}