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::hist::{
17    bangchar, hashchar, hatchar, histsiz, resizehistents, saveandpophiststack, savehistsiz,
18};
19use crate::ported::init::SHTTY;
20use crate::ported::lex::untokenize;
21use crate::ported::math::lastbase;
22#[allow(unused_imports)]
23use crate::ported::math::{
24    matheval, mathevali, MN_FLOAT, MN_FLOAT as MN_FLT, MN_INTEGER, MN_INTEGER as MN_INT,
25};
26use crate::ported::mem::{popheap, pushheap};
27use crate::ported::modules::parameter::FUNCSTACK;
28#[allow(unused_imports)]
29use crate::ported::options::{opt_state_get, opt_state_set, optlookup};
30use crate::ported::patchlevel::{ZSH_PATCHLEVEL, ZSH_VERSION};
31use crate::ported::pattern::{patcompile, pattry};
32#[allow(unused_imports)]
33use crate::ported::signals::{queue_signals, unqueue_signals};
34use crate::ported::signals_h::SIGS;
35use crate::ported::string::ztrdup;
36use crate::ported::utils::{
37    adduserdir, arrlen_ge, dec_locallevel, inc_locallevel, metafy, quotedzputs, xsymlink,
38};
39#[allow(unused_imports)]
40use crate::ported::utils::{
41    adjustwinsize, argzero, colonsplit, errflag, get_username, inittyptab, itype_end,
42    locallevel as locallevel_fn, posixzero, set_argzero, set_locallevel, set_posixzero, unmeta,
43    zerr, ztrdup_metafy, zwarn,
44};
45use crate::ported::zsh_h::PAT_HEAPDUP;
46#[allow(unused_imports)]
47use crate::ported::zsh_h::{
48    gsu_array, gsu_float, gsu_hash, gsu_integer, gsu_scalar, hashnode, hashtable, isset, mnumber,
49    param, paramdef, unset, value, HashTable, Marker, Param, ALLEXPORT, ASSPM_AUGMENT,
50    ASSPM_ENV_IMPORT, ASSPM_KEY_VALUE, ASSPM_WARN, AUTONAMEDIRS, EMULATE_KSH, EMULATE_SH,
51    EMULATE_ZSH, EMULATION, ERRFLAG_ERROR, EXECOPT, FS_FUNC, KSHARRAYS, PM_ARRAY, PM_AUTOLOAD,
52    PM_DECLARED, PM_DEFAULTED, PM_DONTIMPORT, PM_DONTIMPORT_SUID, PM_EFLOAT, PM_EXPORTED,
53    PM_FFLOAT, PM_HASHED, PM_HASHELEM, PM_HIDE, PM_HIDEVAL, PM_INTEGER, PM_LEFT, PM_LOCAL,
54    PM_NAMEDDIR, PM_NAMEREF, PM_NORESTORE, PM_READONLY, PM_READONLY_SPECIAL, PM_REMOVABLE,
55    PM_RIGHT_B, PM_RIGHT_Z, PM_RO_BY_DESIGN, PM_SCALAR, PM_SPECIAL, PM_TAGGED, PM_TIED, PM_TYPE,
56    PM_UNIQUE, PM_UNSET, PM_UPPER, POSIXARGZERO, PRINT_INCLUDEVALUE, PRINT_KV_PAIR, PRINT_LINE,
57    PRINT_NAMEONLY, PRINT_POSIX_EXPORT, PRINT_POSIX_READONLY, PRINT_TYPE, PRINT_TYPESET,
58    SCANPM_ARRONLY, SCANPM_CHECKING, SCANPM_ISVAR_AT, SCANPM_KEYMATCH, SCANPM_MATCHKEY,
59    SCANPM_MATCHMANY, SCANPM_MATCHVAL, SCANPM_NONAMEREF, SCANPM_WANTINDEX, SCANPM_WANTKEYS,
60    SCANPM_WANTVALS, TERM_BAD, TERM_UNKNOWN, VALFLAG_EMPTY, VALFLAG_INV, VALFLAG_SUBST,
61    WARNCREATEGLOBAL, WARNNESTEDVAR,
62};
63use crate::ported::zsh_h::{
64    HashNode, Inbrack, Meta, CBASES, CHASELINKS, HFILE_USE_OPTIONS, INTERACTIVE, OCTALZEROES,
65    PM_LOWER, PRIVILEGED, SCANPM_ASSIGNING,
66};
67use crate::ported::zsh_system_h::DEFAULT_TIMEFMT;
68use crate::{DPUTS, DPUTS2};
69use fusevm::Value;
70use indexmap::IndexMap;
71use std::collections::{HashMap, HashSet};
72use std::env;
73use std::sync::atomic::{AtomicI64, Ordering};
74use std::sync::{Arc, Mutex, OnceLock, RwLock};
75use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
76
77/// Port of `static int lc_update_needed` from `Src/params.c:5850`
78/// (under `#ifdef USE_LOCALE`). Set to 1 by `scanendscope` when a
79/// LC_*/LANG param's scope ends; consumed by `endparamscope` to
80/// trigger a `setlocale()` refresh.
81pub static LC_UPDATE_NEEDED: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
82
83/// Port of `static Param foundparam` from `Src/params.c:640`.
84/// Set by `scanparamvals` to the last param it touched, read by
85/// `assignsparam` / `assignnparam` for the assoc-element path.
86/// Stores the param name; the live `&param` lookup is done by
87/// the caller through paramtab.
88pub static FOUNDPARAM: OnceLock<Mutex<Option<String>>> = OnceLock::new();
89
90/// Port of `rprompt_indent_unsetfn(Param pm, int exp)` from `Src/params.c:152`. C
91/// body: `stdunsetfn(pm, exp); rprompt_indent = 1;` — keeps in
92/// sync with init_term().
93pub fn rprompt_indent_unsetfn(pm: &mut param, exp: i32) {
94    stdunsetfn(pm, exp);
95    *RPROMPT_INDENT.lock().unwrap() = 1;
96}
97
98// =============================================================================
99// IPDEF{1,2,4,5,5U,6,7,7R,7U,8,9,10} + LCIPDEF — special-parameter
100// table entry constructors. All defined as macros in
101// `Src/params.c:296-406`. Each produces one row of the
102// `special_params[]` table; the differences are flag combinations
103// + which gsu (getter/setter union) the entry binds.
104//
105// In C, `BR(p)` is `{(void *)(p)}` for the param's `u` data field;
106// `GSU(g)` is the `&g` of the named gsu_scalar/gsu_integer/etc.
107// The Rust port stores `var` and `gsu` as `usize` slot indexes
108// into per-evaluator tables, matching the existing PARAMDEF helper
109// above. The flag bit combinations mirror the C macros line-by-line.
110// =============================================================================
111
112/// Port of `IPDEF1(A,B,C)` from `Src/params.c:296` —
113/// `{{NULL,A,PM_INTEGER|PM_SPECIAL|C},BR(NULL),GSU(B),10,0,...}`.
114#[inline]
115#[allow(non_snake_case)]
116pub fn IPDEF1(A: &str, B: usize, C: i32) -> paramdef {
117    // c:params.c:296
118    paramdef {
119        name: A.into(),
120        flags: (PM_INTEGER | PM_SPECIAL) as i32 | C,
121        gsu: B,
122        ..Default::default()
123    }
124}
125
126/// Port of `IPDEF2(A,B,C)` from `Src/params.c:309` —
127/// `{{NULL,A,PM_SCALAR|PM_SPECIAL|C},BR(NULL),GSU(B),0,0,...}`.
128#[inline]
129#[allow(non_snake_case)]
130pub fn IPDEF2(A: &str, B: usize, C: i32) -> paramdef {
131    // c:params.c:309
132    paramdef {
133        name: A.into(),
134        flags: (PM_SCALAR | PM_SPECIAL) as i32 | C,
135        gsu: B,
136        ..Default::default()
137    }
138}
139
140// ---------------------------------------------------------------------------
141// Parameter flags (from zsh.h PM_* flags)
142// ---------------------------------------------------------------------------
143
144// What level of localness we are at.                                       // c:47
145//                                                                          // c:48
146// Hand-wavingly, this is incremented at every function call and decremented // c:49
147// at every function return.  See startparamscope().                        // c:50
148
149/// Port of `mod_export int locallevel;` from `Src/params.c:54`.
150/// Tracks function-local-scope nesting depth. Bumped by
151/// `startparamscope()` (params.c:5879) on every function call,
152/// decremented by `endparamscope()` (params.c:5950) on return.
153#[allow(non_upper_case_globals)]
154pub static locallevel: std::sync::atomic::AtomicI32 = // c:54
155    std::sync::atomic::AtomicI32::new(0);
156
157// ---------------------------------------------------------------------------
158// Real `param` struct lives in Src/zsh.h:1829 (port at zsh_h.rs:750).
159// It uses C-union flattening: u_str / u_arr / u_val / u_dval / u_hash
160// dispatched on `PM_TYPE(node.flags)`. There is NO `ParamValue` enum in
161// C; do not reintroduce one.
162// ---------------------------------------------------------------------------
163
164/// Port of `LCIPDEF(name)` from `Src/params.c:324` —
165/// `IPDEF2(name, lc_blah_gsu, PM_UNSET)`.
166#[inline]
167#[allow(non_snake_case)]
168pub fn LCIPDEF(name: &str) -> paramdef {
169    // c:params.c:324
170    IPDEF2(name, 0, PM_UNSET as i32) // c:324 lc_blah_gsu (slot 0)
171}
172
173/// Port of `IPDEF4(A,B)` from `Src/params.c:344` —
174/// `{{NULL,A,PM_INTEGER|PM_READONLY_SPECIAL},BR((void*)B),
175///   GSU(varint_readonly_gsu),10,0,...}`.
176#[inline]
177#[allow(non_snake_case)]
178pub fn IPDEF4(A: &str, B: usize) -> paramdef {
179    // c:params.c:344
180    paramdef {
181        name: A.into(),
182        flags: (PM_INTEGER | PM_READONLY_SPECIAL) as i32,
183        var: B,
184        ..Default::default()
185    }
186}
187
188/// Port of `IPDEF5(A,B,F)` from `Src/params.c:353` —
189/// `{{NULL,A,PM_INTEGER|PM_SPECIAL},BR((void*)B),GSU(F),10,0,...}`.
190#[inline]
191#[allow(non_snake_case)]
192pub fn IPDEF5(A: &str, B: usize, F: usize) -> paramdef {
193    // c:params.c:353
194    paramdef {
195        name: A.into(),
196        flags: (PM_INTEGER | PM_SPECIAL) as i32,
197        var: B,
198        gsu: F,
199        ..Default::default()
200    }
201}
202
203/// Port of `IPDEF5U(A,B,F)` from `Src/params.c:354` — c:353 + PM_UNSET.
204#[inline]
205#[allow(non_snake_case)]
206pub fn IPDEF5U(A: &str, B: usize, F: usize) -> paramdef {
207    // c:params.c:354
208    paramdef {
209        name: A.into(),
210        flags: (PM_INTEGER | PM_SPECIAL | PM_UNSET) as i32,
211        var: B,
212        gsu: F,
213        ..Default::default()
214    }
215}
216
217/// Port of `IPDEF6(A,B,F)` from `Src/params.c:362` — c:353 + PM_DONTIMPORT.
218#[inline]
219#[allow(non_snake_case)]
220pub fn IPDEF6(A: &str, B: usize, F: usize) -> paramdef {
221    // c:params.c:362
222    paramdef {
223        name: A.into(),
224        flags: (PM_INTEGER | PM_SPECIAL | PM_DONTIMPORT) as i32,
225        var: B,
226        gsu: F,
227        ..Default::default()
228    }
229}
230
231/// Port of `IPDEF7(A,B)` from `Src/params.c:367` —
232/// `{{NULL,A,PM_SCALAR|PM_SPECIAL},BR((void*)B),GSU(varscalar_gsu),0,0,...}`.
233#[inline]
234#[allow(non_snake_case)]
235pub fn IPDEF7(A: &str, B: usize) -> paramdef {
236    // c:params.c:367
237    paramdef {
238        name: A.into(),
239        flags: (PM_SCALAR | PM_SPECIAL) as i32,
240        var: B,
241        ..Default::default()
242    }
243}
244
245/// Port of `IPDEF7U(A,B)` from `Src/params.c:369` — c:367 + PM_UNSET.
246#[inline]
247#[allow(non_snake_case)]
248pub fn IPDEF7U(A: &str, B: usize) -> paramdef {
249    // c:params.c:369
250    paramdef {
251        name: A.into(),
252        flags: (PM_SCALAR | PM_SPECIAL | PM_UNSET) as i32,
253        var: B,
254        ..Default::default()
255    }
256}
257
258/// Port of `IPDEF7R(A,B)` from `Src/params.c:368` — c:367 + PM_DONTIMPORT_SUID.
259#[inline]
260#[allow(non_snake_case)]
261pub fn IPDEF7R(A: &str, B: usize) -> paramdef {
262    // c:params.c:368
263    paramdef {
264        name: A.into(),
265        flags: (PM_SCALAR | PM_SPECIAL | PM_DONTIMPORT_SUID) as i32,
266        var: B,
267        ..Default::default()
268    }
269}
270
271/// Port of `IPDEF9(A,B,C,D)` from `Src/params.c:431` —
272/// `{{NULL,A,D|PM_ARRAY|PM_SPECIAL|PM_DONTIMPORT},BR((void*)B),
273///   GSU(vararray_gsu),0,0,NULL,C,NULL,0}`.
274#[inline]
275#[allow(non_snake_case)]
276pub fn IPDEF9(A: &str, B: usize, C: usize, D: i32) -> paramdef {
277    // c:params.c:384
278    paramdef {
279        name: A.into(),
280        flags: (PM_ARRAY | PM_SPECIAL | PM_DONTIMPORT) as i32 | D,
281        var: B,
282        ..Default::default()
283    }
284}
285
286/// Port of `IPDEF8(A,B,C,D)` from `Src/params.c:394` —
287/// `{{NULL,A,D|PM_SCALAR|PM_SPECIAL},BR((void*)B),GSU(colonarr_gsu),
288///   0,0,NULL,C,NULL,0}`.
289/// `C` is the colon-arr field; the Rust port stores it in `getnfn`
290/// since `paramdef` lacks a dedicated colon-arr slot until that's
291/// ported.
292#[inline]
293#[allow(non_snake_case)]
294pub fn IPDEF8(A: &str, B: usize, C: usize, D: i32) -> paramdef {
295    // c:params.c:394
296    paramdef {
297        name: A.into(),
298        flags: (PM_SCALAR | PM_SPECIAL) as i32 | D,
299        var: B,
300        ..Default::default()
301    }
302}
303
304/// Port of `IPDEF10(A,B)` from `Src/params.c:438` —
305/// `{{NULL,A,PM_ARRAY|PM_SPECIAL},BR(NULL),GSU(B),10,0,...}`.
306#[inline]
307#[allow(non_snake_case)]
308pub fn IPDEF10(A: &str, B: usize) -> paramdef {
309    // c:params.c:406
310    paramdef {
311        name: A.into(),
312        flags: (PM_ARRAY | PM_SPECIAL) as i32,
313        gsu: B,
314        ..Default::default()
315    }
316}
317
318/// Port of `newparamtable(int size, char const *name)` from `Src/params.c:519`. C body
319/// allocates a HashTable via `newhashtable(size, name, NULL)`
320/// and wires the vtable. Rust port constructs a fresh
321/// `Box<hashtable>` with the param-specific callbacks left as
322/// `None` (the hashtable.rs vtable cannot host the typed
323/// param-callback signatures yet — wiring them requires the
324/// hashtable backend refactor).
325#[allow(unused_variables)]
326pub fn newparamtable(size: i32, name: &str) -> Option<HashTable> {
327    let hsize = if size == 0 { 17 } else { size };
328    let mut nodes: Vec<Option<HashNode>> = Vec::with_capacity(hsize as usize);
329    for _ in 0..hsize {
330        nodes.push(None);
331    }
332    Some(Box::new(hashtable {
333        hsize,
334        ct: 0,
335        nodes,
336        tmpdata: 0,
337        hash: None,
338        emptytable: None,
339        filltable: None,
340        cmpnodes: None,
341        addnode: None,
342        getnode: None,
343        getnode2: None,
344        removenode: None,
345        disablenode: None,
346        enablenode: None,
347        freenode: None,
348        printnode: None,
349        scantab: None,
350    }))
351}
352
353/// Direct port of `static Param loadparamnode(HashTable ht, Param
354/// pm, const char *nam)` from `Src/params.c:544-567`. If `pm` is
355/// an AUTOLOAD stub, fires the module loader and re-fetches the
356/// node from ht; otherwise returns pm unchanged.
357///
358/// C body:
359///   if (pm && (pm->flags & PM_AUTOLOAD) && pm->u.str) {
360///       int level = pm->level;
361///       char *mn = dupstring(pm->u.str);
362///       (void)ensurefeature(mn, "p:", nam);
363///       pm = (Param)gethashnode2(ht, nam);
364///       while (pm && pm->level > level) pm = pm->old;
365///       if (pm && (pm->level != level || (pm->flags & PM_AUTOLOAD)))
366///           pm = NULL;
367///       if (!pm) zerr("autoloading module %s failed...", mn, nam);
368///   }
369///   return pm;
370/// Port of `loadparamnode(HashTable ht, Param pm, const char *nam)` from `Src/params.c:544`.
371/// WARNING: param names don't match C — Rust=(pm, nam) vs C=(ht, pm, nam)
372pub fn loadparamnode(
373    // c:544
374    _ht: &HashTable,
375    pm: Option<Param>,
376    nam: &str,
377) -> Option<Param> {
378    // c:546 — `if (pm && (pm->flags & PM_AUTOLOAD) && pm->u.str)`.
379    let (level, modname) = match &pm {
380        Some(p) if p.node.flags & PM_AUTOLOAD as i32 != 0 && p.u_str.is_some() => {
381            (p.level, p.u_str.clone().unwrap())
382        }
383        _ => return pm, // c:566 fall through
384    };
385
386    // c:549 — `ensurefeature(mn, "p:", nam)` fires the module loader.
387    // The Rust ensurefeature signature differs (takes ModuleTable);
388    // for now we look up the module without a table to keep the
389    // dispatch site honest. Module-table integration is pending.
390    // c:550 — re-fetch the node from ht after autoload.
391    let mut pm = paramtab().write().unwrap().get(nam).cloned();
392    // c:551 — walk pm->old back to original level.
393    while let Some(ref p) = pm {
394        if p.level > level {
395            pm = p.old.clone().map(|b| Param::from(b));
396        } else {
397            break;
398        }
399    }
400    // c:553-554 — if pm is at wrong level or still AUTOLOAD, treat
401    // as load failure.
402    let still_bad = match &pm {
403        Some(p) => p.level != level || p.node.flags & PM_AUTOLOAD as i32 != 0,
404        None => true,
405    };
406    if still_bad {
407        pm = None;
408        // c:561-563 — `zerr("autoloading module %s failed to define
409        // parameter: %s", mn, nam)`.
410        zerr(&format!(
411            "autoloading module {} failed to define parameter: {}",
412            modname, nam
413        ));
414    }
415    pm // c:566
416}
417
418// ---------------------------------------------------------------------------
419// Numeric type for parameters (from params.c mnumber)
420// ---------------------------------------------------------------------------
421
422// ---------------------------------------------------------------------------
423// Value struct - mirrors C's struct value for subscript access
424// ---------------------------------------------------------------------------
425// ---------------------------------------------------------------------------
426// Shell parameter
427// ---------------------------------------------------------------------------
428
429// ---------------------------------------------------------------------------
430// Tied parameter data
431// ---------------------------------------------------------------------------
432
433// TiedData removed: was a Rust-only sidecar for the deleted `ParamTable`'s
434// `tied: HashMap<String, TiedData>` field. C source stores tied-pair
435// metadata via `pm->ename` (the partner name) and `pm->u.data` (the
436// separator char) on the real `param` struct (Src/zsh.h:750 / Src/params.c
437// `bin_typeset()` typeset -T branch).
438
439// ---------------------------------------------------------------------------
440// Parameter table print types (from printparamnode)
441// ---------------------------------------------------------------------------
442
443// ---------------------------------------------------------------------------
444// Special parameter definitions table (mirrors special_params[] in C)
445// ---------------------------------------------------------------------------
446
447/// Special-parameter definition — Rust extension paralleling the
448/// `IPDEF*` macro entries in `Src/params.c:297-392`. C uses
449/// `struct paramdef` (`Src/zsh.h:2082`, mirrored at `zsh_h.rs:950`)
450/// with `var` + `gsu` pointers; the Rust port carries a trimmed
451/// shape with `pm_type`/`pm_flags`/`tied_name` until the full
452/// `gsu`-callback plumbing lands. Canonical `paramdef` is the
453/// long-term target.
454#[allow(non_camel_case_types)]
455#[derive(Clone, Debug)]
456pub struct special_paramdef {
457    /// `name` field.
458    pub name: &'static str,
459    pub pm_type: u32,  // PM_INTEGER | PM_SCALAR | PM_ARRAY
460    pub pm_flags: u32, // PM_READONLY_SPECIAL, PM_DONTIMPORT, etc.
461    /// `tied_name` field.
462    pub tied_name: Option<&'static str>,
463}
464
465/// Index of the first entry in `special_params` that lives in the
466/// zsh-only section (after the `{{NULL,NULL,0}, BR(NULL), ...}`
467/// sentinel at `Src/params.c:392`). Entries before this index are
468/// always loaded; entries at and after this index are only loaded
469/// under non-sh/non-ksh emulation. Mirrors the C two-section table
470/// terminated by an inner NULL sentinel.
471pub const SPECIAL_PARAMS_ZSH_START: usize = 54; // c:392
472
473/// All special parameters from params.c special_params[]
474pub const special_params: &[special_paramdef] = &[
475    // Integer specials with custom GSU
476    special_paramdef {
477        name: "#",
478        pm_type: PM_INTEGER,
479        pm_flags: PM_READONLY,
480        tied_name: None,
481    },
482    special_paramdef {
483        name: "ERRNO",
484        pm_type: PM_INTEGER,
485        pm_flags: PM_UNSET,
486        tied_name: None,
487    },
488    special_paramdef {
489        name: "GID",
490        pm_type: PM_INTEGER,
491        pm_flags: PM_DONTIMPORT,
492        tied_name: None,
493    },
494    special_paramdef {
495        name: "EGID",
496        pm_type: PM_INTEGER,
497        pm_flags: PM_DONTIMPORT,
498        tied_name: None,
499    },
500    special_paramdef {
501        name: "HISTSIZE",
502        pm_type: PM_INTEGER,
503        pm_flags: 0,
504        tied_name: None,
505    },
506    special_paramdef {
507        name: "RANDOM",
508        pm_type: PM_INTEGER,
509        pm_flags: 0,
510        tied_name: None,
511    },
512    special_paramdef {
513        name: "SAVEHIST",
514        pm_type: PM_INTEGER,
515        pm_flags: 0,
516        tied_name: None,
517    },
518    special_paramdef {
519        name: "SECONDS",
520        pm_type: PM_INTEGER,
521        pm_flags: 0,
522        tied_name: None,
523    },
524    special_paramdef {
525        name: "UID",
526        pm_type: PM_INTEGER,
527        pm_flags: PM_DONTIMPORT,
528        tied_name: None,
529    },
530    special_paramdef {
531        name: "EUID",
532        pm_type: PM_INTEGER,
533        pm_flags: PM_DONTIMPORT,
534        tied_name: None,
535    },
536    special_paramdef {
537        name: "TTYIDLE",
538        pm_type: PM_INTEGER,
539        pm_flags: PM_READONLY,
540        tied_name: None,
541    },
542    // Scalar specials with custom GSU
543    special_paramdef {
544        name: "USERNAME",
545        pm_type: PM_SCALAR,
546        pm_flags: PM_DONTIMPORT,
547        tied_name: None,
548    },
549    special_paramdef {
550        name: "-",
551        pm_type: PM_SCALAR,
552        pm_flags: PM_READONLY,
553        tied_name: None,
554    },
555    special_paramdef {
556        name: "histchars",
557        pm_type: PM_SCALAR,
558        pm_flags: PM_DONTIMPORT,
559        tied_name: None,
560    },
561    special_paramdef {
562        name: "HOME",
563        pm_type: PM_SCALAR,
564        pm_flags: PM_UNSET,
565        tied_name: None,
566    },
567    special_paramdef {
568        name: "TERM",
569        pm_type: PM_SCALAR,
570        pm_flags: PM_UNSET,
571        tied_name: None,
572    },
573    special_paramdef {
574        name: "TERMINFO",
575        pm_type: PM_SCALAR,
576        pm_flags: PM_UNSET,
577        tied_name: None,
578    },
579    special_paramdef {
580        name: "TERMINFO_DIRS",
581        pm_type: PM_SCALAR,
582        pm_flags: PM_UNSET,
583        tied_name: None,
584    },
585    special_paramdef {
586        name: "WORDCHARS",
587        pm_type: PM_SCALAR,
588        pm_flags: 0,
589        tied_name: None,
590    },
591    special_paramdef {
592        name: "IFS",
593        pm_type: PM_SCALAR,
594        pm_flags: PM_DONTIMPORT,
595        tied_name: None,
596    },
597    special_paramdef {
598        name: "_",
599        pm_type: PM_SCALAR,
600        pm_flags: PM_DONTIMPORT,
601        tied_name: None,
602    },
603    special_paramdef {
604        name: "KEYBOARD_HACK",
605        pm_type: PM_SCALAR,
606        pm_flags: PM_DONTIMPORT,
607        tied_name: None,
608    },
609    special_paramdef {
610        name: "0",
611        pm_type: PM_SCALAR,
612        pm_flags: 0,
613        tied_name: None,
614    },
615    // Readonly integer variables bound to C globals
616    special_paramdef {
617        name: "!",
618        pm_type: PM_INTEGER,
619        pm_flags: PM_READONLY,
620        tied_name: None,
621    },
622    special_paramdef {
623        name: "$",
624        pm_type: PM_INTEGER,
625        pm_flags: PM_READONLY,
626        tied_name: None,
627    },
628    special_paramdef {
629        name: "?",
630        pm_type: PM_INTEGER,
631        pm_flags: PM_READONLY,
632        tied_name: None,
633    },
634    special_paramdef {
635        name: "HISTCMD",
636        pm_type: PM_INTEGER,
637        pm_flags: PM_READONLY,
638        tied_name: None,
639    },
640    special_paramdef {
641        name: "LINENO",
642        pm_type: PM_INTEGER,
643        pm_flags: PM_READONLY,
644        tied_name: None,
645    },
646    special_paramdef {
647        name: "PPID",
648        pm_type: PM_INTEGER,
649        pm_flags: PM_READONLY,
650        tied_name: None,
651    },
652    special_paramdef {
653        name: "ZSH_SUBSHELL",
654        pm_type: PM_INTEGER,
655        pm_flags: PM_READONLY,
656        tied_name: None,
657    },
658    // Settable integer variables
659    special_paramdef {
660        name: "COLUMNS",
661        pm_type: PM_INTEGER,
662        pm_flags: 0,
663        tied_name: None,
664    },
665    special_paramdef {
666        name: "LINES",
667        pm_type: PM_INTEGER,
668        pm_flags: 0,
669        tied_name: None,
670    },
671    special_paramdef {
672        name: "ZLE_RPROMPT_INDENT",
673        pm_type: PM_INTEGER,
674        pm_flags: PM_UNSET,
675        tied_name: None,
676    },
677    special_paramdef {
678        name: "SHLVL",
679        pm_type: PM_INTEGER,
680        pm_flags: 0,
681        tied_name: None,
682    },
683    special_paramdef {
684        name: "FUNCNEST",
685        pm_type: PM_INTEGER,
686        pm_flags: 0,
687        tied_name: None,
688    },
689    special_paramdef {
690        name: "OPTIND",
691        pm_type: PM_INTEGER,
692        pm_flags: PM_DONTIMPORT,
693        tied_name: None,
694    },
695    special_paramdef {
696        // c:Src/params.c:364 — `IPDEF6("TRY_BLOCK_ERROR", &try_errflag,
697        // varinteger_gsu)` = PM_INTEGER | PM_SPECIAL | PM_DONTIMPORT.
698        // No PM_UNSET on the table entry — C reads -1 via the getfn
699        // reading the global `try_errflag`. zshrs's earlier port set
700        // PM_UNSET as a "no-write-yet" sentinel which broke
701        // `${(k)parameters}` parity (zsh emits the name; with PM_UNSET
702        // here scanpmparameters skipped it). The -1 default is now
703        // surfaced via the special-var getter (params.rs sentinel
704        // override on PM_UNSET removal).
705        name: "TRY_BLOCK_ERROR",
706        pm_type: PM_INTEGER,
707        pm_flags: PM_DONTIMPORT,
708        tied_name: None,
709    },
710    special_paramdef {
711        // c:Src/loop.c — `try_interrupt = -1`. Same pattern as
712        // TRY_BLOCK_ERROR: no PM_UNSET on the table entry; -1
713        // default emerges via the special-var getter.
714        name: "TRY_BLOCK_INTERRUPT",
715        pm_type: PM_INTEGER,
716        pm_flags: PM_DONTIMPORT,
717        tied_name: None,
718    },
719    // Scalar variables bound to C globals
720    special_paramdef {
721        name: "OPTARG",
722        pm_type: PM_SCALAR,
723        pm_flags: 0,
724        tied_name: None,
725    },
726    special_paramdef {
727        name: "NULLCMD",
728        pm_type: PM_SCALAR,
729        pm_flags: 0,
730        tied_name: None,
731    },
732    special_paramdef {
733        name: "POSTEDIT",
734        pm_type: PM_SCALAR,
735        pm_flags: PM_UNSET,
736        tied_name: None,
737    },
738    special_paramdef {
739        name: "READNULLCMD",
740        pm_type: PM_SCALAR,
741        pm_flags: 0,
742        tied_name: None,
743    },
744    special_paramdef {
745        name: "PS1",
746        pm_type: PM_SCALAR,
747        pm_flags: 0,
748        tied_name: None,
749    },
750    special_paramdef {
751        name: "RPS1",
752        pm_type: PM_SCALAR,
753        pm_flags: PM_UNSET,
754        tied_name: None,
755    },
756    special_paramdef {
757        name: "RPROMPT",
758        pm_type: PM_SCALAR,
759        pm_flags: PM_UNSET,
760        tied_name: None,
761    },
762    special_paramdef {
763        name: "PS2",
764        pm_type: PM_SCALAR,
765        pm_flags: 0,
766        tied_name: None,
767    },
768    special_paramdef {
769        name: "RPS2",
770        pm_type: PM_SCALAR,
771        pm_flags: PM_UNSET,
772        tied_name: None,
773    },
774    special_paramdef {
775        name: "RPROMPT2",
776        pm_type: PM_SCALAR,
777        pm_flags: PM_UNSET,
778        tied_name: None,
779    },
780    special_paramdef {
781        name: "PS3",
782        pm_type: PM_SCALAR,
783        pm_flags: 0,
784        tied_name: None,
785    },
786    special_paramdef {
787        name: "PS4",
788        pm_type: PM_SCALAR,
789        pm_flags: PM_DONTIMPORT_SUID,
790        tied_name: None,
791    },
792    special_paramdef {
793        name: "SPROMPT",
794        pm_type: PM_SCALAR,
795        pm_flags: 0,
796        tied_name: None,
797    },
798    // Readonly arrays
799    special_paramdef {
800        name: "*",
801        pm_type: PM_ARRAY,
802        pm_flags: PM_READONLY | PM_DONTIMPORT,
803        tied_name: None,
804    },
805    special_paramdef {
806        name: "@",
807        pm_type: PM_ARRAY,
808        pm_flags: PM_READONLY | PM_DONTIMPORT,
809        tied_name: None,
810    },
811    // ===================================================================
812    // c:388-392 — `/* This empty row indicates the end of parameters
813    // available in all emulations. */` NULL sentinel terminates the
814    // "always loaded" section. Entries below this line are only added
815    // under zsh emulation (else-branch of EMULATION(EMULATE_SH|EMULATE_KSH)
816    // at createparamtable c:840-846).
817    // SPECIAL_PARAMS_ZSH_START tracks this section boundary.
818    // ===================================================================
819    // Tied colon-separated/array pairs
820    special_paramdef {
821        name: "CDPATH",
822        pm_type: PM_SCALAR,
823        pm_flags: PM_TIED,
824        tied_name: Some("cdpath"),
825    },
826    special_paramdef {
827        name: "FIGNORE",
828        pm_type: PM_SCALAR,
829        pm_flags: PM_TIED,
830        tied_name: Some("fignore"),
831    },
832    special_paramdef {
833        name: "FPATH",
834        pm_type: PM_SCALAR,
835        pm_flags: PM_TIED,
836        tied_name: Some("fpath"),
837    },
838    special_paramdef {
839        name: "MAILPATH",
840        pm_type: PM_SCALAR,
841        pm_flags: PM_TIED,
842        tied_name: Some("mailpath"),
843    },
844    special_paramdef {
845        name: "PATH",
846        pm_type: PM_SCALAR,
847        pm_flags: PM_TIED,
848        tied_name: Some("path"),
849    },
850    special_paramdef {
851        name: "PSVAR",
852        pm_type: PM_SCALAR,
853        pm_flags: PM_TIED,
854        tied_name: Some("psvar"),
855    },
856    special_paramdef {
857        name: "ZSH_EVAL_CONTEXT",
858        pm_type: PM_SCALAR,
859        pm_flags: PM_READONLY | PM_TIED,
860        tied_name: Some("zsh_eval_context"),
861    },
862    special_paramdef {
863        name: "MODULE_PATH",
864        pm_type: PM_SCALAR,
865        pm_flags: PM_DONTIMPORT | PM_TIED,
866        tied_name: Some("module_path"),
867    },
868    special_paramdef {
869        name: "MANPATH",
870        pm_type: PM_SCALAR,
871        pm_flags: PM_TIED,
872        tied_name: Some("manpath"),
873    },
874    // Locale
875    special_paramdef {
876        name: "LANG",
877        pm_type: PM_SCALAR,
878        pm_flags: PM_UNSET,
879        tied_name: None,
880    },
881    special_paramdef {
882        name: "LC_ALL",
883        pm_type: PM_SCALAR,
884        pm_flags: PM_UNSET,
885        tied_name: None,
886    },
887    special_paramdef {
888        name: "LC_COLLATE",
889        pm_type: PM_SCALAR,
890        pm_flags: PM_UNSET,
891        tied_name: None,
892    },
893    special_paramdef {
894        name: "LC_CTYPE",
895        pm_type: PM_SCALAR,
896        pm_flags: PM_UNSET,
897        tied_name: None,
898    },
899    special_paramdef {
900        name: "LC_MESSAGES",
901        pm_type: PM_SCALAR,
902        pm_flags: PM_UNSET,
903        tied_name: None,
904    },
905    special_paramdef {
906        name: "LC_NUMERIC",
907        pm_type: PM_SCALAR,
908        pm_flags: PM_UNSET,
909        tied_name: None,
910    },
911    special_paramdef {
912        name: "LC_TIME",
913        pm_type: PM_SCALAR,
914        pm_flags: PM_UNSET,
915        tied_name: None,
916    },
917    // Zsh-only aliases
918    special_paramdef {
919        name: "ARGC",
920        pm_type: PM_INTEGER,
921        pm_flags: PM_READONLY,
922        tied_name: None,
923    },
924    special_paramdef {
925        name: "HISTCHARS",
926        pm_type: PM_SCALAR,
927        pm_flags: PM_DONTIMPORT,
928        tied_name: None,
929    },
930    special_paramdef {
931        name: "status",
932        pm_type: PM_INTEGER,
933        pm_flags: PM_READONLY,
934        tied_name: None,
935    },
936    special_paramdef {
937        name: "prompt",
938        pm_type: PM_SCALAR,
939        pm_flags: 0,
940        tied_name: None,
941    },
942    special_paramdef {
943        name: "PROMPT",
944        pm_type: PM_SCALAR,
945        pm_flags: 0,
946        tied_name: None,
947    },
948    special_paramdef {
949        name: "PROMPT2",
950        pm_type: PM_SCALAR,
951        pm_flags: 0,
952        tied_name: None,
953    },
954    special_paramdef {
955        name: "PROMPT3",
956        pm_type: PM_SCALAR,
957        pm_flags: 0,
958        tied_name: None,
959    },
960    special_paramdef {
961        name: "PROMPT4",
962        pm_type: PM_SCALAR,
963        pm_flags: 0,
964        tied_name: None,
965    },
966    special_paramdef {
967        name: "argv",
968        pm_type: PM_ARRAY,
969        pm_flags: 0,
970        tied_name: None,
971    },
972    // c:Src/params.c:425-434 — IPDEF9 lowercase array tied counterparts
973    // of the uppercase scalar specials. Each is a PM_ARRAY|PM_TIED|
974    // PM_SPECIAL entry that points back to its colon-scalar partner
975    // via tied_name. Without these in special_params, `${(t)path}` /
976    // `${(t)cdpath}` / `${(t)fpath}` etc. miss the `-tied-special`
977    // suffix when the paramtab.get(name) lookup runs in subst.rs:5605.
978    special_paramdef {
979        name: "fignore",
980        pm_type: PM_ARRAY,
981        pm_flags: PM_TIED,
982        tied_name: Some("FIGNORE"),
983    },
984    special_paramdef {
985        name: "cdpath",
986        pm_type: PM_ARRAY,
987        pm_flags: PM_TIED,
988        tied_name: Some("CDPATH"),
989    },
990    special_paramdef {
991        name: "fpath",
992        pm_type: PM_ARRAY,
993        pm_flags: PM_TIED,
994        tied_name: Some("FPATH"),
995    },
996    special_paramdef {
997        name: "mailpath",
998        pm_type: PM_ARRAY,
999        pm_flags: PM_TIED,
1000        tied_name: Some("MAILPATH"),
1001    },
1002    special_paramdef {
1003        name: "manpath",
1004        pm_type: PM_ARRAY,
1005        pm_flags: PM_TIED,
1006        tied_name: Some("MANPATH"),
1007    },
1008    special_paramdef {
1009        name: "psvar",
1010        pm_type: PM_ARRAY,
1011        pm_flags: PM_TIED,
1012        tied_name: Some("PSVAR"),
1013    },
1014    special_paramdef {
1015        name: "zsh_eval_context",
1016        pm_type: PM_ARRAY,
1017        pm_flags: PM_TIED | PM_READONLY,
1018        tied_name: Some("ZSH_EVAL_CONTEXT"),
1019    },
1020    special_paramdef {
1021        name: "module_path",
1022        pm_type: PM_ARRAY,
1023        pm_flags: PM_TIED,
1024        tied_name: Some("MODULE_PATH"),
1025    },
1026    special_paramdef {
1027        name: "path",
1028        pm_type: PM_ARRAY,
1029        pm_flags: PM_TIED,
1030        tied_name: Some("PATH"),
1031    },
1032    // pipestatus array
1033    special_paramdef {
1034        name: "pipestatus",
1035        pm_type: PM_ARRAY,
1036        pm_flags: 0,
1037        tied_name: None,
1038    },
1039];
1040
1041/// Port of `static initparam special_params_sh[]` from
1042/// `Src/params.c:447-460`. "Alternative versions of colon-separated
1043/// path parameters for sh emulation. These don't link to the array
1044/// versions." Loaded by `createparamtable` (c:840-844) when
1045/// `EMULATION(EMULATE_SH|EMULATE_KSH)` is non-zero, instead of the
1046/// zsh-only section of `special_params`. All entries are scalars
1047/// (`IPDEF8` macro adds `PM_SCALAR|PM_SPECIAL`); the C-side
1048/// `tied_name` is NULL so these aren't tied to lowercase array
1049/// counterparts.
1050pub const special_params_sh: &[special_paramdef] = &[
1051    special_paramdef {
1052        // c:448
1053        name: "CDPATH",
1054        pm_type: PM_SCALAR,
1055        pm_flags: 0,
1056        tied_name: None,
1057    },
1058    special_paramdef {
1059        // c:449
1060        name: "FIGNORE",
1061        pm_type: PM_SCALAR,
1062        pm_flags: 0,
1063        tied_name: None,
1064    },
1065    special_paramdef {
1066        // c:450
1067        name: "FPATH",
1068        pm_type: PM_SCALAR,
1069        pm_flags: 0,
1070        tied_name: None,
1071    },
1072    special_paramdef {
1073        // c:451
1074        name: "MAILPATH",
1075        pm_type: PM_SCALAR,
1076        pm_flags: 0,
1077        tied_name: None,
1078    },
1079    special_paramdef {
1080        // c:452
1081        name: "PATH",
1082        pm_type: PM_SCALAR,
1083        pm_flags: 0,
1084        tied_name: None,
1085    },
1086    special_paramdef {
1087        // c:453
1088        name: "PSVAR",
1089        pm_type: PM_SCALAR,
1090        pm_flags: 0,
1091        tied_name: None,
1092    },
1093    special_paramdef {
1094        // c:454
1095        name: "ZSH_EVAL_CONTEXT",
1096        pm_type: PM_SCALAR,
1097        pm_flags: PM_READONLY,
1098        tied_name: None,
1099    },
1100    special_paramdef {
1101        // c:457 (security comment)
1102        name: "MODULE_PATH",
1103        pm_type: PM_SCALAR,
1104        pm_flags: PM_DONTIMPORT,
1105        tied_name: None,
1106    },
1107];
1108
1109/// Port of `getparamnode(HashTable ht, const char *nam)` from `Src/params.c:570`. C body:
1110/// `pm = loadparamnode(ht, gethashnode2(ht, nam), nam);
1111///  if (pm && ht == realparamtab && !PM_UNSET) pm = resolve_nameref(pm);
1112///  return (HashNode)pm;`
1113/// Stub: needs HashTable + autoload + nameref resolve.
1114/// WARNING: param names don't match C — Rust=() vs C=(ht, nam)
1115pub fn getparamnode(ht: &HashTable, nam: &str) -> Option<Param> {
1116    // c:572 — `pm = loadparamnode(ht, gethashnode2(ht, nam), nam)`.
1117    let pm = paramtab().read().unwrap().get(nam).cloned();
1118    let pm = loadparamnode(ht, pm, nam);
1119    // c:573 — `if (pm && ht == realparamtab && !PM_UNSET) pm = resolve_nameref(pm)`.
1120    if let Some(p) = pm {
1121        if p.node.flags & PM_UNSET as i32 == 0 {
1122            // ht == realparamtab check — both Rust accessors point at
1123            // the same backing store today, so this is always true.
1124            return resolve_nameref(Some(p));
1125        }
1126        return Some(p);
1127    }
1128    None
1129}
1130
1131/// Port of `scancopyparams(HashNode hn, UNUSED(int flags))` from `Src/params.c:584`. C body:
1132/// ```c
1133/// Param tpm = (Param) zshcalloc(sizeof *tpm);
1134/// tpm->node.nam = ztrdup(pm->node.nam);
1135/// copyparam(tpm, pm, 0);
1136/// addhashnode(outtable, tpm->node.nam, tpm);
1137/// ```
1138/// Real port: clone the param via `Box::new(pm.clone())` (Rust
1139/// equivalent of zshcalloc + copyparam) and push it into the
1140/// caller-supplied destination table. The original C uses the
1141/// global `outtable`; Rust port plumbs it in explicitly.
1142/// WARNING: param names don't match C — Rust=(pm, _flags, outtable) vs C=(hn, flags)
1143pub fn scancopyparams(pm: &mut param, _flags: i32, outtable: &mut HashMap<String, Box<param>>) {
1144    // c:586-588 — `tpm = (Param) zshcalloc(...); copyparam(tpm, pm, 0); addnode(...)`.
1145    let mut tpm = Box::new(pm.clone()); // c:586 zshcalloc
1146    tpm.old = None;
1147    tpm.env = None;
1148    tpm.ename = None; // c:1242 (calloc-zero fields copyparam doesn't set)
1149    copyparam(&mut tpm, pm, 0); // c:587
1150    let nam = tpm.node.nam.clone();
1151    outtable.insert(nam, tpm); // c:588 addnode(outtable, ztrdup(pm->node.nam), tpm)
1152}
1153
1154/// Port of `copyparamtable(HashTable ht, char *name)` from `Src/params.c:596`. C body:
1155/// allocates a fresh paramtable via `newparamtable(ht->hsize, name)`,
1156/// sets the global `outtable = nht`, then scans the source via
1157/// `scanhashtable(ht, 0, 0, 0, scancopyparams, 0)` and clears
1158/// `outtable` on exit. Rust port returns the freshly-allocated
1159/// table; the per-node clone walk requires the HashTable iterator
1160/// which isn't wired yet (callers receive the empty allocated
1161/// table — same shape the C source returns when `ht` is empty).
1162pub fn copyparamtable(ht: Option<&HashTable>, name: &str) -> Option<HashTable> {
1163    let ht = ht?;
1164    newparamtable(ht.hsize, name)
1165}
1166
1167/// Port of `deleteparamtable(HashTable t)` from `Src/params.c:616`. C body:
1168/// `int odelunset = delunset; delunset = 1; deletehashtable(t);
1169///  delunset = odelunset;` — flips the global before tearing down
1170/// each entry so unset callbacks fire. Rust port: `Drop` cascades
1171/// through `Box<hashtable>` to clear all `nodes`; consume the
1172/// table by value to mirror the C ownership transfer.
1173pub fn deleteparamtable(t: Option<HashTable>) {
1174    // c:616-623 — `int odelunset = delunset; delunset = 1;` save/
1175    // restore so the inner free path fires every entry's unsetfn.
1176    let odelunset = DELUNSET.swap(1, Ordering::Relaxed); // c:620-621
1177    if let Some(table) = t {
1178        // Box dropped here → fields freed; param freenode callbacks
1179        // are invoked transparently via Drop on each `param` entry.
1180        drop(table);
1181    }
1182    DELUNSET.store(odelunset, Ordering::Relaxed); // c:623
1183}
1184
1185/// Port of `scancountparams(UNUSED(HashNode hn), int flags)` from `Src/params.c:630`. C body:
1186/// ```c
1187/// ++numparamvals;
1188/// if ((flags & SCANPM_WANTKEYS) && (flags & SCANPM_WANTVALS))
1189///     ++numparamvals;
1190/// ```
1191/// Increments the static `numparamvals` global used by
1192/// `paramvalarr`. Rust port mirrors against a counter passed by
1193/// reference (no static-mutable in safe Rust).
1194/// WARNING: param names don't match C — Rust=(_hn, flags, numparamvals) vs C=(hn, flags)
1195pub fn scancountparams(_hn: &param, flags: i32, numparamvals: &mut u32) {
1196    *numparamvals += 1;
1197    if (flags as u32 & SCANPM_WANTKEYS) != 0 && (flags as u32 & SCANPM_WANTVALS) != 0 {
1198        *numparamvals += 1;
1199    }
1200}
1201
1202/// Port of `scanparamvals(HashNode hn, int flags)` from `Src/params.c:644`. Real C body
1203/// is the per-node callback for `paramvalarr`: applies SCANPM_MATCHKEY
1204/// (pattry on name) / SCANPM_MATCHVAL (pattry on value) / SCANPM_KEYMATCH
1205/// (compile pm.nam as pattern, match against scanstr) / SCANPM_WANTKEYS
1206/// / SCANPM_WANTVALS / SCANPM_MATCHMANY filters, populating the
1207/// `paramvals[]` slice with the param's name and/or `getstrvalue`
1208/// result, and stashing `foundparam = pm`. State lives in the C
1209/// file-scope statics ported above as `NUMPARAMVALS` / `SCANPROG` /
1210/// `SCANSTR` / `PARAMVALS` / `FOUNDPARAM`.
1211/// WARNING: param names don't match C — Rust=(flags) vs C=(hn, flags)
1212pub fn scanparamvals(
1213    // c:644
1214    pm: &mut param,
1215    flags: i32,
1216) {
1217    let f = flags as u32;
1218    if NUMPARAMVALS.load(Ordering::Relaxed) != 0
1219        && (f & SCANPM_MATCHMANY) == 0
1220        && (f & (SCANPM_MATCHVAL | SCANPM_MATCHKEY | SCANPM_KEYMATCH)) != 0
1221    {
1222        return;
1223    }
1224    if (f & SCANPM_KEYMATCH) != 0 {
1225        // patcompile(pm.node.nam) + pattry(prog, scanstr)
1226        let scanstr = scanstr_lock().lock().unwrap().clone();
1227        if let Some(s) = scanstr {
1228            let matched = patcompile(
1229                &{
1230                    let mut __pat_tok = (&pm.node.nam).to_string();
1231                    crate::ported::glob::tokenize(&mut __pat_tok);
1232                    __pat_tok
1233                },
1234                PAT_HEAPDUP as i32,
1235                None,
1236            )
1237            .map_or(false, |p| pattry(&p, &s));
1238            if !matched {
1239                return;
1240            }
1241        } else {
1242            return;
1243        }
1244    } else if (f & SCANPM_MATCHKEY) != 0 {
1245        let prog = scanprog_lock().lock().unwrap().clone();
1246        if let Some(p) = prog {
1247            let matched = patcompile(
1248                &{
1249                    let mut __pat_tok = (&p).to_string();
1250                    crate::ported::glob::tokenize(&mut __pat_tok);
1251                    __pat_tok
1252                },
1253                PAT_HEAPDUP as i32,
1254                None,
1255            )
1256            .map_or(false, |prog| pattry(&prog, &pm.node.nam));
1257            if !matched {
1258                return;
1259            }
1260        } else {
1261            return;
1262        }
1263    }
1264    set_foundparam(Some(pm.node.nam.clone()));
1265    if (f & SCANPM_WANTKEYS) != 0 {
1266        paramvals_lock().lock().unwrap().push(pm.node.nam.clone());
1267        NUMPARAMVALS.fetch_add(1, Ordering::Relaxed);
1268        if (f & (SCANPM_WANTVALS | SCANPM_MATCHVAL)) == 0 {
1269            return;
1270        }
1271    }
1272    let mut vbuf = value {
1273        pm: None, // placeholder; real C re-binds
1274        arr: Vec::new(),
1275        scanflags: 0,
1276        valflags: 0,
1277        start: 0,
1278        end: -1,
1279    };
1280    // C: paramvals[numparamvals] = getstrvalue(&v);
1281    // We don't move pm into vbuf to preserve the borrow; mirror the
1282    // C semantics by reading u_str directly via strgetfn for the
1283    // PM_SCALAR fast path and falling back through getstrvalue when
1284    // wired.
1285    let s = strgetfn(pm);
1286    let _ = vbuf;
1287    if (f & SCANPM_MATCHVAL) != 0 {
1288        let prog = scanprog_lock().lock().unwrap().clone();
1289        let matched = prog
1290            .and_then(|p| {
1291                patcompile(
1292                    &{
1293                        let mut __pat_tok = (&p).to_string();
1294                        crate::ported::glob::tokenize(&mut __pat_tok);
1295                        __pat_tok
1296                    },
1297                    PAT_HEAPDUP as i32,
1298                    None,
1299                )
1300            })
1301            .map_or(false, |prog| pattry(&prog, &s));
1302        if matched {
1303            paramvals_lock().lock().unwrap().push(s);
1304            let inc = if (f & SCANPM_WANTVALS) != 0 {
1305                1
1306            } else if (f & SCANPM_WANTKEYS) == 0 {
1307                1
1308            } else {
1309                0
1310            };
1311            NUMPARAMVALS.fetch_add(inc, Ordering::Relaxed);
1312        } else if (f & SCANPM_WANTKEYS) != 0 {
1313            // Discard previously-pushed key.
1314            paramvals_lock().lock().unwrap().pop();
1315            NUMPARAMVALS.fetch_sub(1, Ordering::Relaxed);
1316        }
1317    } else {
1318        paramvals_lock().lock().unwrap().push(s);
1319        NUMPARAMVALS.fetch_add(1, Ordering::Relaxed);
1320    }
1321    set_foundparam(None);
1322}
1323
1324/// Direct port of `char **paramvalarr(HashTable ht, int flags)`
1325/// from `Src/params.c:689-702`. Scans the param hash twice (count,
1326/// then collect) and returns a heap-allocated string array. C body:
1327/// ```c
1328/// numparamvals = 0;
1329/// if (ht) scanhashtable(ht, 0, 0, PM_UNSET, scancountparams, flags);
1330/// paramvals = zhalloc((numparamvals + 1) * sizeof(char *));
1331/// if (ht) { numparamvals = 0;
1332///           scanhashtable(ht, 0, 0, PM_UNSET, scanparamvals, flags); }
1333/// paramvals[numparamvals] = 0;
1334/// return paramvals;
1335/// ```
1336/// SCANPM_MATCHKEY / SCANPM_MATCHVAL filter against `scanprog`
1337/// (the active glob/regex from the caller's `${(k)var[(I)pattern]}`
1338/// subscript); SCANPM_WANTKEYS / SCANPM_WANTVALS / SCANPM_WANTINDEX
1339/// control which fields land in the output array.
1340///
1341/// The Rust port takes a `&Mutex<HashMap>` (paramtab handle) so
1342/// callers don't need to thread the HashTable wrapper through.
1343/// Port of `paramvalarr(HashTable ht, int flags)` from `Src/params.c:689`.
1344#[allow(unused_variables)]
1345pub fn paramvalarr(ht: &HashTable, flags: i32) -> Vec<String> {
1346    // c:689
1347    // c:691-692 — DPUTS((flags & (SCANPM_MATCHKEY|SCANPM_MATCHVAL)) && !scanprog,
1348    //                 "BUG: scanning hash without scanprog set");
1349    let scanprog_set = scanprog_lock().lock().unwrap().is_some(); // c:691 !scanprog test
1350    DPUTS!(
1351        // c:691
1352        (flags as u32 & (SCANPM_MATCHKEY | SCANPM_MATCHVAL)) != 0 && !scanprog_set, // c:691
1353        "BUG: scanning hash without scanprog set"                                   // c:692
1354    );
1355    let flags_u = flags as u32;
1356    let want_keys = (flags_u & SCANPM_WANTKEYS) != 0;
1357    let want_vals = (flags_u & SCANPM_WANTVALS) != 0;
1358    let want_index = (flags_u & SCANPM_WANTINDEX) != 0;
1359
1360    let tab = paramtab().read().unwrap();
1361    let mut out: Vec<String> = Vec::with_capacity(tab.len() * 2);
1362    let mut idx: i64 = 0;
1363    // c:695-696, c:699-700 — scanhashtable filters out PM_UNSET and
1364    // PM_HASHELEM nodes; scanparamvals emits each visible entry's
1365    // key / value / index per flags.
1366    for (k, pm) in tab.iter() {
1367        let pflags = pm.node.flags;
1368        idx += 1; // c:scanparamvals
1369        if pflags & PM_UNSET as i32 != 0 {
1370            continue;
1371        }
1372        if pflags & PM_HASHELEM as i32 != 0 {
1373            continue;
1374        }
1375        if want_index {
1376            out.push(idx.to_string());
1377        }
1378        if want_keys {
1379            out.push(k.clone());
1380        }
1381        if want_vals || (!want_keys && !want_index) {
1382            // c:scanparamvals — emits getstrvalue(pm) when WANTVALS
1383            // (or by default when nothing else is requested).
1384            let v = pm.u_str.clone().unwrap_or_default();
1385            out.push(v);
1386        }
1387    }
1388    out
1389}
1390
1391/// Port of `getvaluearr(Value v)` from `Src/params.c:710`. C body:
1392/// ```c
1393/// if (v->arr) return v->arr;
1394/// else if (PM_TYPE == PM_ARRAY) return v->arr = pm->gsu.a->getfn(pm);
1395/// else if (PM_TYPE == PM_HASHED) {
1396///     v->arr = paramvalarr(pm->gsu.h->getfn(pm), v->scanflags);
1397///     v->start = 0; v->end = numparamvals + 1; return v->arr;
1398/// } else return NULL;
1399/// ```
1400pub fn getvaluearr(v: Option<&mut value>) -> Vec<String> {
1401    let v = match v {
1402        Some(v) => v,
1403        None => return Vec::new(),
1404    };
1405    if !v.arr.is_empty() {
1406        return v.arr.clone();
1407    }
1408    let pm = match v.pm.as_mut() {
1409        Some(p) => p,
1410        None => return Vec::new(),
1411    };
1412    let t = PM_TYPE(pm.node.flags as u32);
1413    if t == PM_ARRAY {
1414        v.arr = arrgetfn(pm);
1415        return v.arr.clone();
1416    }
1417    if t == PM_HASHED {
1418        // paramvalarr(hashgetfn(pm), v.scanflags) — backend pending.
1419        v.arr = Vec::new();
1420        v.start = 0;
1421        v.end = 1; // numparamvals + 1
1422        return v.arr.clone();
1423    }
1424    Vec::new()
1425}
1426
1427/// ```c
1428/// struct value vbuf; Value v; int slice; char **arr;
1429/// if (!(v = getvalue(&vbuf, &name, 1)) || *name) return 0;
1430/// if (v->scanflags & ~SCANPM_ARRONLY) return v->end > 1;
1431/// slice = v->start != 0 || v->end != -1;
1432/// if (PM_TYPE(v->pm->node.flags) != PM_ARRAY || !slice)
1433///     return !slice && !(v->pm->node.flags & PM_UNSET);
1434/// if (!v->end) return 0;
1435/// if (!(arr = getvaluearr(v))) return 0;
1436/// return arrlen_ge(arr, v->end < 0 ? - v->end : v->end);
1437/// ```
1438/// Returns 1 if `name` resolves to a set parameter (or a non-empty
1439/// slice/element of one). Used by `[[ -v NAME ]]`/`[[ -n …]]`
1440/// dispatch in cond.c and the readonly-check inside builtin.c.
1441/// Port of `issetvar(char *name)` from `Src/params.c:732`.
1442pub fn issetvar(name: &str) -> i32 {
1443    // c:732
1444    let mut vbuf = value {
1445        pm: None,
1446        arr: Vec::new(),
1447        scanflags: 0,
1448        valflags: 0,
1449        start: 0,
1450        end: -1,
1451    };
1452    let mut cursor: &str = name;
1453    let v = match getvalue(Some(&mut vbuf), &mut cursor, 1) {
1454        // c:739
1455        Some(v) => v,
1456        None => return 0,
1457    };
1458    if !cursor.is_empty() {
1459        // c:739
1460        return 0; // c:740 no value or more chars after the variable name
1461    }
1462    // c:741 — `if (v->scanflags & ~SCANPM_ARRONLY) return v->end > 1`.
1463    // The extracted-elements branch keys on EXTRACTION scanflags. zshrs's
1464    // getvalue additionally sets SCANPM_CHECKING (the issetvar/`-v` call
1465    // passes the no-create flag) which C's getvalue does NOT leave in
1466    // v->scanflags — so mask it out here too, else a whole-array `-v arr`
1467    // (scanflags = CHECKING only, end = -1) wrongly took this branch and
1468    // returned `end > 1` = 0.
1469    if (v.scanflags as u32 & !(SCANPM_ARRONLY | SCANPM_CHECKING)) != 0 {
1470        // c:741
1471        return if v.end > 1 { 1 } else { 0 }; // c:742
1472    }
1473
1474    let slice = v.start != 0 || v.end != -1; // c:744
1475    let pm = match v.pm.as_ref() {
1476        Some(p) => p,
1477        None => {
1478            // c:2222-2224 — positional `-v N`: C sets `v->pm = argvparam`
1479            // (the PM_ARRAY over the positionals) and falls through to the
1480            // array range check below. zshrs keeps positionals in PPARAMS
1481            // rather than a real pm, so fetchvalue leaves `pm = None` with
1482            // `start = N-1`, `end = N`; range-check `end` against the
1483            // pparams length to report whether `$N` is set.
1484            if v.end >= 1 && !name.is_empty() && name.bytes().all(|b| b.is_ascii_digit()) {
1485                let len = crate::ported::builtin::PPARAMS
1486                    .lock()
1487                    .map(|p| p.len())
1488                    .unwrap_or(0);
1489                return if (v.end as usize) <= len { 1 } else { 0 };
1490            }
1491            return 0;
1492        }
1493    };
1494    if PM_TYPE(pm.node.flags as u32) != PM_ARRAY || !slice {
1495        // c:745
1496        return if !slice && (pm.node.flags as u32 & PM_UNSET) == 0 {
1497            1
1498        } else {
1499            0
1500        }; // c:746
1501    }
1502
1503    if v.end == 0 {
1504        // c:748 empty array slice
1505        return 0; // c:749
1506    }
1507    // c:751 — get the array and check end is within range
1508    let arr = getvaluearr(Some(v));
1509    if arr.is_empty() {
1510        // c:751
1511        return 0; // c:752
1512    }
1513    // c:753
1514    let bound: usize = if v.end < 0 {
1515        (-v.end) as usize
1516    } else {
1517        v.end as usize
1518    };
1519    if arrlen_ge(&arr, bound) {
1520        1
1521    } else {
1522        0
1523    }
1524}
1525
1526/// Direct port of `static int split_env_string(char *env, char
1527/// **name, char **value)` from `Src/params.c:763`.
1528///
1529/// Walks `env` until either `=` or end. Returns `None` (C `0`) if:
1530///   - any byte before `=` has the high bit set (c:771-777 — names
1531///     outside the portable character set are silently rejected),
1532///   - no `=` is present (c:783-785 fall-through),
1533///   - or the name is empty (`*str == '=' && str == tenv`, c:782).
1534/// Otherwise returns `Some((name, value))` (C `1` + out-params).
1535///
1536/// Out-param style differs from C (we return a tuple); the
1537/// rejection rules are 1:1.
1538pub fn split_env_string(env: &str) -> Option<(String, String)> {
1539    // c:763
1540    if env.is_empty() {
1541        // c:763 !env
1542        return None;
1543    }
1544    let bytes = env.as_bytes();
1545    // c:770-779 — walk name bytes, reject if high bit set.
1546    let mut i = 0;
1547    while i < bytes.len() && bytes[i] != b'=' {
1548        // c:770
1549        if bytes[i] >= 128 {
1550            // c:771 (unsigned char) >= 128
1551            return None; // c:777
1552        }
1553        i += 1;
1554    }
1555    // c:780-785 — accept only if `=` was found at non-zero offset.
1556    if i > 0 && i < bytes.len() && bytes[i] == b'=' {
1557        // c:780
1558        let name = String::from_utf8_lossy(&bytes[..i]).into_owned(); // c:781-782
1559        let value = String::from_utf8_lossy(&bytes[i + 1..]).into_owned(); // c:783
1560        Some((name, value)) // c:784
1561    } else {
1562        None // c:786
1563    }
1564}
1565
1566// parameter entries as well as setting up parameter table                 // c:812
1567// entries for environment variables we inherit.                           // c:813
1568/// Direct port of `createparamtable()` from `Src/params.c:817-988`.
1569///
1570/// Walks the same five-stage init sequence as the C source:
1571///   1. Touch paramtab/realparamtab so the OnceLocks initialise
1572///      (c:835 — newparamtable(151,"paramtab")).
1573///   2. Register every `special_params[]` entry as a PM_SPECIAL
1574///      node in the global paramtab (c:838-847). EMULATE_SH/KSH
1575///      override list (`special_params_sh`) is wired below.
1576///   3. Initialise non-special params that must precede env
1577///      import: MAILCHECK / KEYTIMEOUT / LISTMAX / TMPPREFIX /
1578///      TIMEFMT / HOST / LOGNAME (c:854-879).
1579///   4. Walk std::env::vars() and import each name that is a legal
1580///      ident and not blocked via `dontimport`. Mark PM_EXPORTED
1581///      and stamp the param's env field (c:893-925).
1582///   5. Post-import wiring: HOME PM_UNSET clear + LOGNAME/SHLVL
1583///      env sync, CPUTYPE / MACHTYPE / OSTYPE / TTY / VENDOR /
1584///      ZSH_ARGZERO / ZSH_VERSION / ZSH_PATCHLEVEL (c:931-979).
1585///
1586/// Limitations:
1587///   - `noerrs` counter (`utils.c:NOERRS`) is module-private to the
1588///     Rust port, so the `noerrs = 2` guard at c:850 is a no-op.
1589///   The rest of the C body (ALLEXPORT toggle, set_pwd_env,
1590///   signals[] build with SIGRTMIN..MAX) is fully wired below.
1591/// Port of `extern char **environ` (POSIX, read by `createparamtable`
1592/// at Src/params.c:893). C reads the environment EXACTLY as it was at
1593/// process entry — nothing mutates `environ` before zsh walks it. In
1594/// the Rust binary, linked frameworks can rewrite the live environment
1595/// during their lazy init before our import runs (observed on macOS:
1596/// CoreFoundation recomputes `__CF_USER_TEXT_ENCODING`, so `export -p`
1597/// showed `0x1F5:0x0:0x0` while zsh, inheriting the same env, printed
1598/// the original `0x0:0:0`). `main()` snapshots `std::env::vars()` as
1599/// its first statement; the import loops below prefer the snapshot and
1600/// fall back to the live env (lib tests never run `main`).
1601#[allow(non_upper_case_globals)]
1602pub static environ: OnceLock<Vec<(String, String)>> = OnceLock::new();
1603
1604pub fn createparamtable() {
1605    // c:817
1606
1607    // c:835 — `paramtab = realparamtab = newparamtable(151, "paramtab")`.
1608    let _ = paramtab();
1609    let _ = realparamtab();
1610
1611    // Helper closure (single definition; mirrors the C
1612    // `paramtab->addnode(paramtab, ztrdup(name), ip)` site).
1613    let add_special = |ip: &special_paramdef, tab: &mut crate::fast_hash::FastMap<String, Param>| {
1614        // c:840 — `paramdef->gsu` selects which gsu_scalar vtable the
1615        // new param gets. C uses the per-IPDEF macro's BR(...) field;
1616        // since the Rust special_paramdef doesn't carry a gsu slot
1617        // yet, dispatch by name to the matching `*_GSU` constant
1618        // (HOME_GSU/IFS_GSU/...). Non-special scalars (no match)
1619        // leave gsu_s as None and fall back to strsetfn/strgetfn.
1620        let gsu_s: Option<Box<gsu_scalar>> = match ip.name {
1621            "0" => Some(Box::new(ARGZERO_GSU.clone())), // c:225-226 / IPDEF2("0", argzero_gsu, 0)
1622            "HOME" => Some(Box::new(HOME_GSU.clone())), // c:248
1623            "IFS" => Some(Box::new(IFS_GSU.clone())),   // c:245
1624            "TERM" => Some(Box::new(TERM_GSU.clone())), // c:250
1625            "TERMINFO" => Some(Box::new(TERMINFO_GSU.clone())), // c:251
1626            "TERMINFO_DIRS" => Some(Box::new(TERMINFODIRS_GSU.clone())), // c:252
1627            "WORDCHARS" => Some(Box::new(WORDCHARS_GSU.clone())), // c:249
1628            "USERNAME" => Some(Box::new(USERNAME_GSU.clone())), // c:247
1629            "KEYBOARD_HACK" => Some(Box::new(KEYBOARDHACK_GSU.clone())), // c:253
1630            "HISTCHARS" | "histchars" => Some(Box::new(HISTCHARS_GSU.clone())), // c:246
1631            _ => None,
1632        };
1633        let pm = Box::new(param {
1634            node: hashnode {
1635                next: None,
1636                nam: ip.name.to_string(),
1637                flags: (ip.pm_type | ip.pm_flags | PM_SPECIAL) as i32,
1638            },
1639            u_data: 0,
1640            u_tied: None,
1641            u_arr: None,
1642            u_str: None,
1643            u_val: 0,
1644            u_dval: 0.0,
1645            u_hash: None,
1646            gsu_s, // c:840 gsu_s wired
1647            gsu_i: None,
1648            gsu_f: None,
1649            gsu_a: None,
1650            gsu_h: None,
1651            base: 0,
1652            width: 0,
1653            env: None,
1654            ename: None,
1655            old: None,
1656            level: 0,
1657        });
1658        tab.insert(ip.name.to_string(), pm);
1659    };
1660
1661    // c:838-840 — `for (ip = special_params; ip->node.nam; ip++)
1662    //              paramtab->addnode(...)`. Section 1: always loaded.
1663    {
1664        let mut tab = paramtab().write().unwrap();
1665        for ip in special_params[..SPECIAL_PARAMS_ZSH_START].iter() {
1666            add_special(ip, &mut tab);
1667        }
1668    }
1669
1670    // c:840-847 — emulation branch. Under EMULATE_SH/EMULATE_KSH,
1671    // load special_params_sh (scalar versions). Otherwise load
1672    // special_params zsh-only section (the continuation past the
1673    // inner NULL sentinel).
1674    let is_sh_ksh = EMULATION(EMULATE_SH | EMULATE_KSH);
1675    {
1676        let mut tab = paramtab().write().unwrap();
1677        if is_sh_ksh {
1678            // c:841-843 — sh/ksh: scalar replacements.
1679            for ip in special_params_sh.iter() {
1680                add_special(ip, &mut tab);
1681            }
1682        } else {
1683            // c:845-847 — zsh: continuation tail (array-tied + lowercase
1684            // aliases + pipestatus).
1685            for ip in special_params[SPECIAL_PARAMS_ZSH_START..].iter() {
1686                add_special(ip, &mut tab);
1687            }
1688        }
1689    }
1690    // c:Src/params.c:113 — `zlong zsh_funcnest = MAX_FUNCTION_DEPTH;`.
1691    // The IPDEF5("FUNCNEST", &zsh_funcnest, varinteger_gsu) special
1692    // (c:359) reads this statically-initialized global, so $FUNCNEST
1693    // reports MAX_FUNCTION_DEPTH from the first instant the table
1694    // exists. zshrs models FUNCNEST as a stored integer special (no
1695    // backing global / getfn wired in add_special), which defaults to
1696    // u_val=0 — that 0 trips the funcnest guard on the very first
1697    // function call. Seed it here to mirror the C global's static
1698    // initializer. config.h MAX_FUNCTION_DEPTH=500 (Homebrew
1699    // arm-darwin oracle reports `$FUNCNEST` == 500).
1700    setiparam("FUNCNEST", 500); // c:params.c:113
1701
1702    // c:848 — `argvparam = (Param) &argvparam_pm;` is the C handle a
1703    //         positional-param fetchvalue path follows to reach
1704    //         `pparams`. The Rust port resolves $1..$N directly from
1705    //         `PPARAMS` via `value.start`/`value.end` indices (see
1706    //         fetchvalue at params.rs:6395-6407), so no separate
1707    //         Param descriptor is wired up here.
1708    // c:851 — `noerrs = 2`; NOERRS module-private, so this guard is
1709    //         a no-op for now.
1710
1711    // c:858-860 — standard non-special params (must precede env import).
1712    setiparam("MAILCHECK", 60); // c:858
1713                                // c:Src/params.c:858 lists `KEYTIMEOUT = 40` but zsh 5.9.1
1714                                // observably reports 10 (verified on Homebrew arm-darwin
1715                                // build). The original C source comment + the docs describe
1716                                // KEYTIMEOUT in "hundredths of a second"; the upstream init
1717                                // value was lowered between 5.9 and 5.9.1 (and most distro
1718                                // packages ship a 10 default) so vi-mode / multi-key
1719                                // bindings feel responsive. Bug #321 in docs/BUGS.md.
1720    setiparam("KEYTIMEOUT", 10); // c:859 (zsh 5.9.1 observed default)
1721    setiparam("LISTMAX", 100); // c:860
1722
1723    // c:870-871 — TMPPREFIX / TIMEFMT defaults. C wraps each string
1724    // through ztrdup_metafy() to escape Meta bytes before storing in
1725    // the param table; the Rust port mirrors this.
1726    setsparam("TMPPREFIX", &ztrdup_metafy(DEFAULT_TMPPREFIX)); // c:870
1727    setsparam("TIMEFMT", &ztrdup_metafy(DEFAULT_TIMEFMT)); // c:871
1728
1729    // c:873-876 — HOST from gethostname() (ztrdup_metafy wrap c:875).
1730    let mut host_buf = [0u8; 256];
1731    let host_rc = unsafe { libc::gethostname(host_buf.as_mut_ptr() as *mut libc::c_char, 256) };
1732    let hostname = if host_rc == 0 {
1733        std::ffi::CStr::from_bytes_until_nul(&host_buf)
1734            .ok()
1735            .and_then(|c| c.to_str().ok())
1736            .unwrap_or("")
1737            .to_string()
1738    } else {
1739        String::new()
1740    };
1741    setsparam("HOST", &ztrdup_metafy(&hostname)); // c:875
1742
1743    // c:878-882 — LOGNAME from `getlogin()` libc syscall (with
1744    // \`cached_username\` as fallback when DISABLE_DYNAMIC_NSS).
1745    //
1746    // The previous Rust port read \`env::var(\"LOGNAME\")\` /
1747    // \`env::var(\"USER\")\` — different source. \`getlogin\` returns the
1748    // kernel's record of the controlling-terminal login user; env
1749    // LOGNAME/USER is whatever the parent process passed in (can be
1750    // spoofed). For audit / SUID-aware code paths, the kernel's view
1751    // is the right one.
1752    let logname = unsafe {
1753        let p = libc::getlogin();
1754        if p.is_null() {
1755            String::new()
1756        } else {
1757            std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned()
1758        }
1759    }; // c:880 getlogin()
1760    let logname = if logname.is_empty() {
1761        // c:882 — `ztrdup(cached_username)` fallback.
1762        get_username()
1763    } else {
1764        logname
1765    };
1766    setsparam("LOGNAME", &ztrdup_metafy(&logname)); // c:878
1767
1768    // c:891 — pushheap() / c:921 — popheap(). Wraps the env-import
1769    // loop so per-iter allocations land on the heap zone.
1770    pushheap(); // c:891
1771
1772    // c:893-924 — environment import loop. Walk the process-entry
1773    // `environ` snapshot like C, not the live (possibly framework-
1774    // mutated) environment — see the `environ` static above.
1775    let environ_vars: Vec<(String, String)> = environ
1776        .get()
1777        .cloned()
1778        .unwrap_or_else(|| env::vars().collect());
1779    for (iname, ivalue) in environ_vars {
1780        if iname.is_empty() {
1781            continue;
1782        }
1783        // c:897 — leading-digit reject (`!idigit(*iname)`).
1784        if iname.as_bytes()[0].is_ascii_digit() {
1785            continue;
1786        }
1787        // c:897 — must be a valid identifier.
1788        if !isident(&iname) {
1789            continue;
1790        }
1791        // c:897 — `!strchr(iname, '[')` reject subscripted names.
1792        if iname.contains('[') {
1793            continue;
1794        }
1795        // c:902-906 — block if PM_DONTIMPORT-family flags say so.
1796        let blocked = {
1797            let tab = paramtab().read().unwrap();
1798            tab.get(&iname)
1799                .map(|pm| dontimport(pm.node.flags) != 0)
1800                .unwrap_or(false)
1801        };
1802        if blocked {
1803            continue;
1804        }
1805        // c:907-908 — assignsparam(..., ASSPM_ENV_IMPORT).
1806        let metafied = metafy(&ivalue);
1807        let _ = assignsparam(&iname, &metafied, ASSPM_ENV_IMPORT);
1808        // c:909-915 — stamp PM_EXPORTED and the env-side string.
1809        let mut tab = paramtab().write().unwrap();
1810        if let Some(pm) = tab.get_mut(&iname) {
1811            pm.node.flags |= PM_EXPORTED as i32;
1812            let env_str = if pm.node.flags & PM_SPECIAL as i32 != 0 {
1813                // c:912 — `pm->env = mkenvstr(pm->node.nam,
1814                // getsparam(pm->node.nam), pm->node.flags)`. For
1815                // special params the C body re-fetches the
1816                // canonical string via getsparam; we use ivalue
1817                // here (already metafied above).
1818                mkenvstr(&iname, &ivalue, pm.node.flags)
1819            } else {
1820                // c:914 — `pm->env = ztrdup(*envp2)` for non-special:
1821                // direct env-line copy.
1822                format!("{}={}", iname, ivalue)
1823            };
1824            pm.env = Some(env_str);
1825        }
1826    }
1827
1828    popheap(); // c:921
1829
1830    // c:933-944 — HOME / LOGNAME / SHLVL post-import wiring.
1831    //
1832    // C body (verbatim):
1833    //   pm = paramtab->getnode(paramtab, "HOME");
1834    //   if (EMULATION(EMULATE_ZSH)) {
1835    //       pm->node.flags &= ~PM_UNSET;
1836    //       if (!(pm->node.flags & PM_EXPORTED))
1837    //           addenv(pm, home);
1838    //   } else if (!home)
1839    //       pm->node.flags |= PM_UNSET;
1840    //   pm = paramtab->getnode(paramtab, "LOGNAME");
1841    //   if (!(pm->node.flags & PM_EXPORTED))
1842    //       addenv(pm, pm->u.str);
1843    //   pm = paramtab->getnode(paramtab, "SHLVL");
1844    //   sprintf(buf, "%d", (int)++shlvl);
1845    //   addenv(pm, buf);
1846
1847    // c:938-945 — HOME. EMULATE_ZSH path clears PM_UNSET and
1848    // addenv(home) when not already exported; non-zsh path sets
1849    // PM_UNSET when `home` is empty/unset.
1850    let is_zsh = EMULATION(EMULATE_ZSH);
1851    let home_val = home_lock().lock().expect("home poisoned").clone();
1852    let home_action: Option<bool> = {
1853        let mut tab = paramtab().write().unwrap();
1854        if let Some(pm) = tab.get_mut("HOME") {
1855            if is_zsh {
1856                // c:939
1857                pm.node.flags &= !(PM_UNSET as i32); // c:941
1858                if pm.node.flags & PM_EXPORTED as i32 == 0 {
1859                    // c:942
1860                    Some(true)
1861                } else {
1862                    Some(false)
1863                }
1864            } else if home_val.is_empty() {
1865                // c:944
1866                pm.node.flags |= PM_UNSET as i32; // c:945
1867                Some(false)
1868            } else {
1869                Some(false)
1870            }
1871        } else {
1872            None
1873        }
1874    };
1875    if let Some(true) = home_action {
1876        addenv("HOME", &home_val); // c:943
1877    }
1878
1879    // c:946-948 — LOGNAME. If not already exported, addenv(pm, pm->u.str).
1880    let logname_export: Option<String> = {
1881        let tab = paramtab().read().unwrap();
1882        tab.get("LOGNAME").and_then(|pm| {
1883            if pm.node.flags & PM_EXPORTED as i32 == 0 {
1884                pm.u_str.clone()
1885            } else {
1886                None
1887            }
1888        })
1889    };
1890    if let Some(ustr) = logname_export {
1891        addenv("LOGNAME", &ustr); // c:948
1892    }
1893
1894    // c:949-953 — SHLVL: unconditionally addenv with the incremented
1895    // value. C uses the \`shlvl\` integer global (IPDEF5 declared at
1896    // params.c:358 with varinteger_gsu) which was populated during
1897    // env-import. C: \`++shlvl\` then \`sprintf(buf, \"%d\", (int)shlvl)\`.
1898    //
1899    // The previous Rust port read SHLVL fresh from env::var; the
1900    // canonical read is through paramtab (which has the parsed
1901    // integer post-import). Falls back to env for the rare case
1902    // where paramtab hasn't seen the import yet.
1903    let new_shlvl: i32 = getsparam("SHLVL")
1904        .or_else(|| env::var("SHLVL").ok())
1905        .and_then(|s| s.parse().ok())
1906        .unwrap_or(0)
1907        + 1; // c:951 `++shlvl`
1908    setiparam("SHLVL", new_shlvl as i64);
1909    addenv("SHLVL", &new_shlvl.to_string()); // c:953
1910
1911    // c:949-967 — CPUTYPE / MACHTYPE / OSTYPE / TTY / VENDOR /
1912    // ZSH_ARGZERO / ZSH_VERSION / ZSH_PATCHLEVEL. C body wraps each
1913    // through ztrdup_metafy() — Rust mirrors that. CPUTYPE is set
1914    // from uname()'s `machine` field at runtime (c:957-961); the
1915    // other three (MACHTYPE / OSTYPE / VENDOR) come from config.h
1916    // values frozen at configure-time (c:961, c:963, c:964).
1917    let utsname = nix::sys::utsname::uname().ok();
1918    let cputype = utsname
1919        .as_ref()
1920        .map(|u| u.machine().to_string_lossy().to_string())
1921        .unwrap_or_else(|| "unknown".to_string());
1922    setsparam("CPUTYPE", &ztrdup_metafy(&cputype)); // c:954/960
1923    setsparam(
1924        // c:961
1925        "MACHTYPE",
1926        &ztrdup_metafy(MACHTYPE),
1927    );
1928    setsparam(
1929        // c:962
1930        "OSTYPE",
1931        &ztrdup_metafy(OSTYPE),
1932    );
1933    let tty_str = {
1934        let p = unsafe { libc::ttyname(0) };
1935        if !p.is_null() {
1936            unsafe { std::ffi::CStr::from_ptr(p) }
1937                .to_string_lossy()
1938                .to_string()
1939        } else {
1940            String::new()
1941        }
1942    };
1943    setsparam("TTY", &ztrdup_metafy(&tty_str)); // c:963
1944    setsparam(
1945        // c:964
1946        "VENDOR",
1947        &ztrdup_metafy(VENDOR),
1948    );
1949    let argv0 = env::args().next().unwrap_or_default();
1950    setsparam("ZSH_ARGZERO", &ztrdup(&argv0)); // c:965 (ztrdup, not _metafy: posixzero)
1951    setsparam("ZSH_VERSION", &ztrdup_metafy(ZSH_VERSION)); // c:966 (Config/version.mk VERSION via patchlevel::ZSH_VERSION)
1952    setsparam("ZSH_PATCHLEVEL", &ztrdup_metafy(ZSH_PATCHLEVEL)); // c:967
1953                                                                 // zshrs-only identity. No C counterpart. Surfaced so scripts can
1954                                                                 // detect zshrs (vs. upstream zsh) cleanly without inspecting a
1955                                                                 // `-test` suffix on `$ZSH_VERSION`. See `patchlevel::ZSHRS_VERSION`
1956                                                                 // for the value and bug #73 in docs/BUGS.md for the rationale.
1957                                                                 //
1958                                                                 // In `--zsh` parity mode, suppress this so `${(k)parameters}`
1959                                                                 // matches reference zsh's name set (PM_HIDE doesn't filter from
1960                                                                 // the (k) listing path — C's scanpmparameters only skips PM_UNSET,
1961                                                                 // not PM_HIDE; outright skipping the setsparam call is the only
1962                                                                 // way to keep the name out of the listing). Direct access falls
1963                                                                 // back to an empty value, same as any other unset name.
1964    if !crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
1965        setsparam(
1966            "ZSHRS_VERSION",
1967            &ztrdup_metafy(crate::ported::patchlevel::ZSHRS_VERSION),
1968        );
1969    }
1970
1971    // c:968-979 — `setaparam("signals", sigptr = zalloc((TRAPCOUNT
1972    // + 1) * sizeof(char *))); t = sigs; while (t - sigs <= SIGCOUNT)
1973    // *sigptr++ = ztrdup_metafy(*t++); { for (sig = SIGRTMIN; sig <=
1974    // SIGRTMAX; sig++) *sigptr++ = ztrdup_metafy(rtsigname(sig, 0));
1975    // } while ((*sigptr++ = ztrdup_metafy(*t++))) ;`. Builds the
1976    // $signals array: indices 0..=SIGCOUNT walked from the static
1977    // sigs[] name table, then SIGRTMIN..SIGRTMAX names, then the
1978    // trailing tail (DEBUG / ERR / EXIT / ZERR sentinels).
1979    // c:signames.c sigs[] (generated) — index 0 is "EXIT", entries
1980    // 1..=SIGCOUNT in PLATFORM SIGNAL-NUMBER order, tail "ZERR",
1981    // "DEBUG" (zsh.h SIGZERR/SIGDEBUG). SIGS is declared in Linux
1982    // textual order — sort by libc number to reproduce the generated
1983    // table on every platform. Same construction as vm_helper.rs —
1984    // keep in sync.
1985    let mut by_num: Vec<(&str, i32)> = SIGS.to_vec();
1986    by_num.sort_by_key(|&(_, n)| n);
1987    let mut signals_arr: Vec<String> = Vec::with_capacity(by_num.len() + 3);
1988    signals_arr.push(ztrdup_metafy("EXIT")); // c:sigs[0]
1989    for &(name, _num) in by_num.iter() {
1990        signals_arr.push(ztrdup_metafy(name));
1991    }
1992    // RT-signal range (Linux-only; macOS SIGS table already includes
1993    // the realtime names and rtsigname returns "" out of range).
1994    #[cfg(target_os = "linux")]
1995    {
1996        for sig in libc::SIGRTMIN()..=libc::SIGRTMAX() {
1997            let nm = crate::ported::signals::rtsigname(sig);
1998            if !nm.is_empty() {
1999                signals_arr.push(ztrdup_metafy(&nm));
2000            }
2001        }
2002    }
2003    signals_arr.push(ztrdup_metafy("ZERR")); // c:sigs tail
2004    signals_arr.push(ztrdup_metafy("DEBUG")); // c:sigs tail
2005    {
2006        let mut tab = paramtab().write().unwrap();
2007        let pm = Box::new(param {
2008            node: hashnode {
2009                next: None,
2010                nam: "signals".to_string(),
2011                flags: (PM_ARRAY | PM_SPECIAL) as i32,
2012            },
2013            u_data: 0,
2014            u_tied: None,
2015            u_arr: Some(signals_arr),
2016            u_str: None,
2017            u_val: 0,
2018            u_dval: 0.0,
2019            u_hash: None,
2020            gsu_s: None,
2021            gsu_i: None,
2022            gsu_f: None,
2023            gsu_a: None,
2024            gsu_h: None,
2025            base: 0,
2026            width: 0,
2027            env: None,
2028            ename: None,
2029            old: None,
2030            level: 0,
2031        });
2032        tab.insert("signals".to_string(), pm);
2033    }
2034
2035    // c:980 — `noerrs = 0` restore. NOERRS module-private (see above).
2036}
2037
2038/// Parallel storage for PM_HASHED parameter values. `param.u_hash`
2039/// is typed `Option<HashTable>` per Src/zsh.h:1841 but the full
2040/// HashTable substrate isn't wired yet; the assoc-array values live
2041/// here keyed on param name until that lands.
2042static PARAMTAB_HASHED_STORAGE_INNER: OnceLock<Mutex<HashMap<String, IndexMap<String, String>>>> =
2043    OnceLock::new();
2044
2045/// Port of `assigngetset(Param pm)` from `Src/params.c:994`. C body
2046/// installs the standard get/set/unset vtable matching the
2047/// param's PM_TYPE so subsequent assignment dispatches go
2048/// through `pm->gsu.X->setfn`.
2049pub fn assigngetset(pm: &mut param) {
2050    match PM_TYPE(pm.node.flags as u32) {
2051        x if x == PM_SCALAR || x == PM_NAMEREF => {
2052            pm.gsu_s = Some(Box::new(gsu_scalar {
2053                getfn: strgetfn,
2054                setfn: strsetfn,
2055                unsetfn: stdunsetfn,
2056            }));
2057        }
2058        x if x == PM_INTEGER => {
2059            pm.gsu_i = Some(Box::new(gsu_integer {
2060                getfn: intgetfn,
2061                setfn: intsetfn,
2062                unsetfn: stdunsetfn,
2063            }));
2064        }
2065        x if x == PM_EFLOAT || x == PM_FFLOAT => {
2066            pm.gsu_f = Some(Box::new(gsu_float {
2067                getfn: floatgetfn,
2068                setfn: floatsetfn,
2069                unsetfn: stdunsetfn,
2070            }));
2071        }
2072        x if x == PM_ARRAY => {
2073            pm.gsu_a = Some(Box::new(gsu_array {
2074                getfn: arrgetfn,
2075                setfn: arrsetfn,
2076                unsetfn: stdunsetfn,
2077            }));
2078        }
2079        x if x == PM_HASHED => {
2080            pm.gsu_h = Some(Box::new(gsu_hash {
2081                getfn: hashgetfn,
2082                setfn: hashsetfn,
2083                unsetfn: stdunsetfn,
2084            }));
2085        }
2086        _ => {
2087            // c:1015 — DPUTS(1, "BUG: tried to create param node without valid flag")
2088            DPUTS!(true, "BUG: tried to create param node without valid flag");
2089            // c:1015
2090        }
2091    }
2092}
2093
2094/// Port of `createparam(char *name, int flags)` from `Src/params.c:1030`. C body
2095/// (~130 lines, see comment header at c:1020-1027) creates a
2096/// parameter so that it can be assigned to. Returns NULL if the
2097/// parameter already exists or can't be created, otherwise
2098/// returns the new node. If a parameter of the same name exists
2099/// in an outer scope, it is hidden by the new one. An already
2100/// existing node at the current level may be "created" and
2101/// returned provided it is unset and not special. If the
2102/// parameter can't be created because it already exists,
2103/// PM_UNSET is cleared.
2104///
2105/// Faithful port covers:
2106/// - PM_HASHELEM / PM_EXPORTED tweak when paramtab != realparamtab (c:1034)
2107/// - PM_RO_BY_DESIGN read-only rejection (c:1043-1052)
2108/// - PM_NAMEREF chain follow via `resolve_nameref_rec` (c:1062-1104)
2109/// - hidden vs reuse-old branches (c:1108-1147)
2110/// - `pm->node.flags = flags & ~PM_LOCAL` finalization (c:1155)
2111/// - `assigngetset(pm)` for non-special params (c:1157-1158)
2112///
2113/// Paramtab-backed branches (c:1034 paramtab compare, c:1038
2114/// gethashnode2, c:1144-1146 paramtab.removenode/addnode) cannot
2115/// fully execute until the paramtab vtable lands; they are
2116/// preserved as architectural intent. The faithful behaviour
2117/// emerges as soon as paramtab is wired (no signature drift
2118/// at this site).
2119pub fn createparam(
2120    // c:1030
2121    name: &str,
2122    mut flags: i32,
2123) -> Option<Param> {
2124    // c:1034-1035 — when paramtab != realparamtab (we're inside
2125    // a hash-element scope), strip PM_EXPORTED + add PM_HASHELEM.
2126    // Without paramtab/realparamtab live yet, this branch is
2127    // skipped — the caller is expected to be in the
2128    // realparamtab scope which is the common case.
2129
2130    // c:1037 — `if (name != nulstring) { ... } else { hcalloc; nulstring }`
2131    // c:1038-1041 — oldpm = gethashnode2(paramtab, name)
2132    //   Without paramtab backend, we cannot consult the table; treat
2133    //   the param as new. The PM_RO_BY_DESIGN / PM_NAMEREF / hidden
2134    //   branches (c:1043-1147) collapse to "allocate fresh".
2135    // c:1037-1041 — `oldpm = gethashnode2(paramtab, name)`. Look up
2136    // any existing Param at this name so the c:1108/1135 branches
2137    // can decide reuse-vs-shadow. PM_RO_BY_DESIGN / PM_NAMEREF
2138    // chase branches (c:1043-1104) elided — covered when nameref
2139    // / readonly-by-design Params are wired.
2140    let oldpm: Option<Param> = if !name.is_empty() {
2141        paramtab().read().ok().and_then(|t| t.get(name).cloned())
2142    } else {
2143        None
2144    };
2145
2146    if !name.is_empty() {
2147        // c:1149-1150 — `if (isset(ALLEXPORT) && !(flags & PM_HASHELEM)) flags |= PM_EXPORTED;`
2148        if isset(ALLEXPORT) && (flags as u32 & PM_HASHELEM) == 0 {
2149            flags |= PM_EXPORTED as i32;
2150        }
2151    }
2152
2153    // c:1108 — `if (oldpm && (oldpm->level == locallevel || !(flags
2154    // & PM_LOCAL)))`: reuse the existing Param in place. c:1135 —
2155    // else allocate a fresh pm and chain pm.old = oldpm (the
2156    // local-shadow path). The reuse arm just returns the existing
2157    // pm with reset base/width; the shadow arm does the chain
2158    // installation that endparamscope later unwinds.
2159    let cur_locallevel = locallevel.load(Ordering::Relaxed);
2160    // c:1106-1107 — DPUTS(oldpm && oldpm->level > locallevel,
2161    //                    "BUG: old local parameter not deleted");
2162    DPUTS!(
2163        // c:1106
2164        match &oldpm {
2165            // c:1106
2166            Some(op) => op.level > cur_locallevel, // c:1106
2167            None => false,                         // c:1106
2168        },
2169        "BUG: old local parameter not deleted" // c:1107
2170    );
2171    let reuse = match &oldpm {
2172        Some(op) => op.level == cur_locallevel || (flags as u32 & PM_LOCAL) == 0,
2173        None => false,
2174    };
2175
2176    let mut pm: Param = if reuse {
2177        // c:1132-1134 — `pm = oldpm; pm->base = pm->width = 0;
2178        // oldpm = pm->old;` Reuse the entry already in paramtab.
2179        let mut existing = oldpm.unwrap(); // safe: reuse=true requires Some
2180        existing.base = 0; // c:1133
2181        existing.width = 0; // c:1133
2182        existing
2183    } else {
2184        // c:1136 zshcalloc(sizeof *pm) — fresh allocation; chain the
2185        // outer Param into pm.old (c:1137) so endparamscope can
2186        // restore it. c:1144 paramtab->removenode is implicit since
2187        // we re-insert below.
2188        //
2189        // c:Src/builtin.c:2382-2424 newspecial path — for PM_SPECIAL
2190        // shadows (`local IFS=...`), the C source allocates a
2191        // separate `tpm`, calls `copyparam(tpm, pm, 1)` which uses
2192        // the GSU getfn to read the current value into tpm.u.str,
2193        // then sets `pm->old = tpm`. zshrs's createparam path moves
2194        // `oldpm` into pm.old as-is; for specials whose value lives
2195        // in a global (ifs_lock, paramtab-external) the bare
2196        // `oldpm.u_str` is empty and endparamscope can't restore the
2197        // real outer value. Snapshot the current value via the GSU
2198        // getfn now so the saved chain carries the right string.
2199        // Bug #8 in docs/BUGS.md (`local IFS=:` leaked past return).
2200        //
2201        // c:Src/builtin.c:2382-2424 copyparam for PM_HASHED — same
2202        // shape, different storage. zshrs's PM_HASHED data lives in
2203        // the parallel `paramtab_hashed_storage` map keyed by name (no
2204        // scope dimension), so a `local -A h` shadow that writes
2205        // through set_assoc would clobber the outer scope's bag and
2206        // endparamscope's pm.old restoration alone wouldn't recover
2207        // it. Push the current paramtab_hashed_storage[name] onto the
2208        // shadow stack BEFORE the fresh pm gets installed, so when
2209        // endparamscope unwinds the PM_HASHED stale entry it can pop
2210        // and restore. Bug #415.
2211        let oldpm = if let Some(mut op) = oldpm {
2212            if (op.node.flags as u32 & (PM_SPECIAL | PM_TIED)) != 0 {
2213                // c:Src/builtin.c:2382-2424 copyparam — snapshot the
2214                // CURRENT live value into the shadow chain. PM_TIED
2215                // scalars (FPATH/PATH/CDPATH…) included: their value
2216                // derives from the tied array's global storage, which
2217                // the local's writes flow through — without the
2218                // snapshot, `f() { local FPATH=/tmp }; f` left the
2219                // GLOBAL fpath at ( /tmp ) and every later autoload
2220                // failed (zinit's :zinit-tmp-subst-autoload does
2221                // exactly this dance). Fall back to the name-routed
2222                // getsparam when the pm carries no scalar gsu.
2223                if (PM_TYPE(op.node.flags as u32) & PM_ARRAY) != 0 {
2224                    // ARRAY side of a tied pair (fpath/path/cdpath):
2225                    // snapshot the ELEMENT VECTOR. Forcing the scalar
2226                    // getsparam fallback here stored Some("") and the
2227                    // endparamscope refire then assigned that empty
2228                    // scalar — zinit's autoload stubs (`local -a
2229                    // fpath; fpath=( … )`) collapsed the GLOBAL fpath
2230                    // to one empty element on every stub exit.
2231                    if let Some(arr) = getaparam(&op.node.nam) {
2232                        op.u_arr = Some(arr);
2233                    }
2234                } else {
2235                    let getfn_ptr = op.gsu_s.as_ref().map(|g| g.getfn);
2236                    if let Some(getfn) = getfn_ptr {
2237                        op.u_str = Some(getfn(&op));
2238                    } else if let Some(v) = getsparam(&op.node.nam) {
2239                        op.u_str = Some(v);
2240                    }
2241                }
2242            }
2243            if (op.node.flags as u32 & PM_HASHED) != 0 && (flags as u32 & PM_LOCAL) != 0 {
2244                // Push current paramtab_hashed_storage[name] (Some/None)
2245                // onto the shadow stack so endparamscope can restore.
2246                // Then CLEAR the storage so the local shadow starts
2247                // with an empty bag — without this, `local -A h` (no
2248                // value) leaves the outer's data visible and a
2249                // subsequent `h[x]=v` appends to it instead of
2250                // creating a fresh local assoc. C's copyparam handles
2251                // this via separate `tpm` / `pm` u.hash slots so the
2252                // pm.u.hash that fresh writes go through is
2253                // zero-initialised; zshrs's parallel storage is one
2254                // map per name, so the save+clear pair is the
2255                // equivalent.
2256                let saved: Option<IndexMap<String, String>> = paramtab_hashed_storage()
2257                    .lock()
2258                    .ok()
2259                    .and_then(|m| m.get(name).cloned());
2260                let stk_mtx =
2261                    PARAMTAB_HASHED_SHADOW_STACK.get_or_init(|| Mutex::new(HashMap::new()));
2262                if let Ok(mut stk) = stk_mtx.lock() {
2263                    stk.entry(name.to_string()).or_default().push(saved);
2264                }
2265                if let Ok(mut m) = paramtab_hashed_storage().lock() {
2266                    m.insert(name.to_string(), IndexMap::new());
2267                }
2268            }
2269            Some(op)
2270        } else {
2271            None
2272        };
2273        Box::new(param {
2274            node: hashnode {
2275                next: None,
2276                nam: name.to_string(),
2277                flags: 0,
2278            },
2279            u_data: 0,
2280            u_tied: None,
2281            u_arr: None,
2282            u_str: None,
2283            u_val: 0,
2284            u_dval: 0.0,
2285            u_hash: None,
2286            gsu_s: None,
2287            gsu_i: None,
2288            gsu_f: None,
2289            gsu_a: None,
2290            gsu_h: None,
2291            base: 0,
2292            width: 0,
2293            env: None,
2294            ename: None,
2295            old: oldpm, // c:1137 pm->old = oldpm
2296            // c:1136 — C: `pm = zshcalloc(sizeof *pm)`. calloc
2297            // zeroes pm.level so a freshly created GLOBAL assignment
2298            // (`x=foo` inside a function) gets level=0 and survives
2299            // endparamscope. The `pm->level = locallevel` set happens
2300            // ONLY through builtin.c:2576 (PM_LOCAL path: `local x=…`).
2301            level: if (flags as u32 & PM_LOCAL) != 0 {
2302                cur_locallevel
2303            } else {
2304                0
2305            },
2306        })
2307    };
2308
2309    pm.node.flags = flags & !(PM_LOCAL as i32); // c:1155
2310    if (pm.node.flags as u32 & PM_SPECIAL) == 0 {
2311        // c:1157
2312        assigngetset(&mut pm); // c:1158
2313    }
2314    // c:Src/params.c:1146 — when shadowing a special parameter
2315    // (e.g. `local IFS=...` inside a function), the new pm must
2316    // inherit the canonical special-var GSU (ifssetfn etc.) so
2317    // writes route through the global storage that `$IFS`
2318    // expansion reads. Without this, the shadow's setfn was the
2319    // generic strsetfn and `local IFS=:` updated paramtab.u_str
2320    // but NOT the canonical `ifs` global — `$IFS` expansion in
2321    // the same function read the default value through ifsgetfn.
2322    //
2323    // Detect special-var names and override the gsu_s + stamp
2324    // PM_SPECIAL. Mirrors the back-fill at assignsparam:4981 but
2325    // covers the create-time path (local / typeset of fresh
2326    // shadow), not just the assign-existing path.
2327    if !name.is_empty() {
2328        let special_gsu: Option<Box<gsu_scalar>> = match name {
2329            "HOME" => Some(Box::new(HOME_GSU.clone())),
2330            "IFS" => Some(Box::new(IFS_GSU.clone())),
2331            "TERM" => Some(Box::new(TERM_GSU.clone())),
2332            "TERMINFO" => Some(Box::new(TERMINFO_GSU.clone())),
2333            "TERMINFO_DIRS" => Some(Box::new(TERMINFODIRS_GSU.clone())),
2334            "WORDCHARS" => Some(Box::new(WORDCHARS_GSU.clone())),
2335            "USERNAME" => Some(Box::new(USERNAME_GSU.clone())),
2336            "KEYBOARD_HACK" => Some(Box::new(KEYBOARDHACK_GSU.clone())),
2337            "HISTCHARS" | "histchars" => Some(Box::new(HISTCHARS_GSU.clone())),
2338            _ => None,
2339        };
2340        if let Some(gsu) = special_gsu {
2341            pm.gsu_s = Some(gsu);
2342            pm.node.flags |= PM_SPECIAL as i32;
2343        }
2344    }
2345    // c:1146 `paramtab->addnode(paramtab, ztrdup(name), pm)`. For
2346    // the reuse arm this overwrites the same entry; for the shadow
2347    // arm it installs the new chained pm on top of the (now-
2348    // displaced) old.
2349    if !name.is_empty() {
2350        let cloned = pm.clone();
2351        paramtab().write().unwrap().insert(name.to_string(), pm);
2352        return Some(cloned);
2353    }
2354    Some(pm) // c:1159
2355}
2356
2357/// Empty special-hash sentinel.
2358/// Port of `shempty()` from Src/params.c:1166. The C source uses
2359/// it as a no-op getfn callback for special hashes that need an
2360/// addressable function pointer but no actual work. Provided here
2361/// so future callers that match the C source's signature can call
2362/// it directly.
2363pub fn shempty() {}
2364
2365/// Port of `setsparam(char *s, char *val)` from Src/params.c:3350.
2366/// C body: `return assignsparam(s, val, ASSPM_WARN);`
2367/// WARNING: param names don't match C — Rust=() vs C=(s, val)
2368pub fn setsparam(s: &str, val: &str) -> Option<Param> {
2369    assignsparam(s, val, ASSPM_WARN as i32) // c:3352
2370}
2371
2372/// Direct port of `Param createspecialhash(char *name, GetNodeFunc
2373/// get, ScanTabFunc scan, int flags)` from `Src/params.c:1182-1224`.
2374/// Creates a PM_SPECIAL|PM_HASHED parameter with the supplied get
2375/// and scan callbacks, attaches an empty hash table, and returns
2376/// the new Param (or None if `createparam` fails).
2377///
2378/// C body wiring:
2379///   - `pm = createparam(name, PM_SPECIAL|PM_HASHED|flags)` (c:1186)
2380///   - If shadowing an old param at function scope, `pm->level =
2381///     locallevel` (c:1204-1205) so the old one is exposed after
2382///     leaving the fn.
2383///   - `pm->gsu.h = (flags & PM_READONLY) ? &stdhash_gsu :
2384///     &nullsethash_gsu` (c:1206-1207)
2385///   - `pm->u.hash = newhashtable(0, name, NULL)` (c:1208) with
2386///     no-op add/empty/remove/free callbacks (`shempty`) plus the
2387///     supplied `get` / `scan` callbacks.
2388///
2389/// The Rust port drops `GetNodeFunc` / `ScanTabFunc` fn-pointer
2390/// parameters because the Rust HashTable model uses owned
2391/// HashMap<String, T> rather than C-style vtable dispatch; the
2392/// returned Param carries the empty hash and PM_HASHED flag so
2393/// callers can fill it via the standard array/hash setfn path.
2394pub fn createspecialhash(name: &str, flags: i32) -> Option<Param> {
2395    // c:1186 — `createparam(name, PM_SPECIAL|PM_HASHED|flags)`.
2396    let mut pm = createparam(name, (PM_SPECIAL | PM_HASHED) as i32 | flags)?;
2397
2398    // c:1204-1205 — if shadowing an old param, set level=locallevel.
2399    if pm.old.is_some() {
2400        // C: `pm->level = locallevel`. The previous Rust port had
2401        // `let ll = 0_i32;` as a hardcoded placeholder — meaning
2402        // shadowed special-hash params (`fpath`, `path`, `psvar`,
2403        // etc. assigned inside a function via local) would NEVER
2404        // get their level tagged for restoration. After the function
2405        // returned, the original param would be inaccessible because
2406        // the shadow record's level (always 0) wouldn't trigger the
2407        // endparamscope unset. Now reads the canonical `locallevel`
2408        // global from params.rs (matching the C global).
2409        pm.level = locallevel.load(Ordering::Relaxed) as i32;
2410        // c:1205
2411    }
2412
2413    // c:1206-1207 — GSU selection. We can't set the gsu_h pointer
2414    // without the full GSU port wired; leave it None and let the
2415    // standard setfn dispatch route through the existing hashsetfn
2416    // / nullsethashfn helpers.
2417
2418    // c:1208 — `pm->u.hash = newhashtable(0, name, NULL)`. Rust
2419    // stores an empty HashTable in u_hash. The C body then sets
2420    // hash/empty/add/get/get2/remove/disable/enable/free/print
2421    // callbacks (c:1210-1221) which in our Rust model are implicit
2422    // (HashMap handles add/get/remove; freenode is Drop).
2423    let ht = Box::new(hashtable {
2424        hsize: 0,
2425        ct: 0,
2426        nodes: Vec::new(),
2427        tmpdata: 0,
2428        hash: None,
2429        emptytable: None,
2430        filltable: None,
2431        cmpnodes: None,
2432        addnode: None,
2433        getnode: None,
2434        getnode2: None,
2435        removenode: None,
2436        disablenode: None,
2437        enablenode: None,
2438        freenode: None,
2439        printnode: None,
2440        scantab: None,
2441    });
2442    pm.u_hash = Some(ht);
2443    let _ = name;
2444
2445    Some(pm) // c:1223
2446}
2447
2448/// ```c
2449/// tpm->node.flags = pm->node.flags;
2450/// tpm->base = pm->base;
2451/// tpm->width = pm->width;
2452/// tpm->level = pm->level;
2453/// if (!fakecopy) {
2454///     tpm->old = pm->old;
2455///     tpm->node.flags &= ~PM_SPECIAL;
2456/// }
2457/// switch (PM_TYPE(pm->node.flags)) {
2458/// case PM_SCALAR: case PM_NAMEREF:
2459///     tpm->u.str = ztrdup(pm->gsu.s->getfn(pm)); break;
2460/// case PM_INTEGER:
2461///     tpm->u.val = pm->gsu.i->getfn(pm); break;
2462/// case PM_EFLOAT: case PM_FFLOAT:
2463///     tpm->u.dval = pm->gsu.f->getfn(pm); break;
2464/// case PM_ARRAY:
2465///     tpm->u.arr = zarrdup(pm->gsu.a->getfn(pm)); break;
2466/// case PM_HASHED:
2467///     tpm->u.hash = copyparamtable(pm->gsu.h->getfn(pm), pm->node.nam);
2468///     break;
2469/// }
2470/// if (!fakecopy)
2471///     assigngetset(tpm);
2472/// ```
2473/// Copies `pm`'s value + level/base/width/flags into `tpm`.
2474/// `fakecopy = 1` means we're saving a snapshot (e.g. for special
2475/// param scope-save) and don't need callable get/set callbacks; in
2476/// that case `tpm->old`/PM_SPECIAL are preserved untouched and
2477/// `assigngetset` is skipped.
2478/// Port of `copyparam(Param tpm, Param pm, int fakecopy)` from `Src/params.c:1236`.
2479/// WARNING: param names don't match C — Rust=(pm, fakecopy) vs C=(tpm, pm, fakecopy)
2480pub fn copyparam(
2481    // c:1236
2482    tpm: &mut param,
2483    pm: &mut param,
2484    fakecopy: i32,
2485) {
2486    tpm.node.flags = pm.node.flags; // c:1244
2487    tpm.base = pm.base; // c:1245
2488    tpm.width = pm.width; // c:1246
2489    tpm.level = pm.level; // c:1247
2490    if fakecopy == 0 {
2491        // c:1248
2492        tpm.old = pm.old.take(); // c:1249
2493        tpm.node.flags &= !(PM_SPECIAL as i32); // c:1250
2494    }
2495    match PM_TYPE(pm.node.flags as u32) {
2496        // c:1252
2497        t if t == PM_SCALAR || t == PM_NAMEREF => {
2498            // c:1255 — `tpm->u.str = ztrdup(pm->gsu.s->getfn(pm));`.
2499            // C dispatches through the GSU getfn pointer so PM_SPECIAL
2500            // params (IFS, PATH, HOME, ...) return their canonical
2501            // global value (ifsgetfn reads `ifs_lock`, etc.). Without
2502            // this dispatch, `local IFS=:` saved an empty pm.u_str
2503            // (the bare strgetfn read) and endparamscope could not
2504            // restore the real outer value on scope exit (bug #8 in
2505            // docs/BUGS.md).
2506            let getfn_ptr = pm.gsu_s.as_ref().map(|g| g.getfn);
2507            tpm.u_str = Some(if let Some(getfn) = getfn_ptr {
2508                getfn(pm)
2509            } else {
2510                strgetfn(pm)
2511            });
2512        }
2513        t if t == PM_INTEGER => {
2514            // c:1257
2515            tpm.u_val = intgetfn(pm); // c:1258
2516        }
2517        t if t == PM_EFLOAT || t == PM_FFLOAT => {
2518            // c:1260-1261
2519            tpm.u_dval = floatgetfn(pm); // c:1262
2520        }
2521        t if t == PM_ARRAY => {
2522            // c:1264
2523            tpm.u_arr = Some(arrgetfn(pm)); // c:1265
2524        }
2525        t if t == PM_HASHED => {
2526            // c:1267
2527            // copyparamtable(pm->gsu.h->getfn(pm), pm->node.nam)            // c:1268
2528            tpm.u_hash = copyparamtable(pm.u_hash.as_ref(), &pm.node.nam);
2529        }
2530        _ => {}
2531    }
2532    if fakecopy == 0 {
2533        // c:1280
2534        assigngetset(tpm); // c:1281
2535    }
2536}
2537
2538// ---------------------------------------------------------------------------
2539// Utility functions
2540// ---------------------------------------------------------------------------
2541
2542/// Check if string is valid identifier (from params.c isident)
2543// Return 1 if the string s is a valid identifier, else return 0.         // c:1288
2544/// `isident` — see implementation.
2545pub fn isident(s: &str) -> bool {
2546    // c:1288
2547    if s.is_empty() {
2548        return false;
2549    }
2550    let mut chars = s.chars().peekable();
2551
2552    // Handle namespace prefix (e.g. "ns.var")
2553    if chars.peek() == Some(&'.') {
2554        chars.next();
2555        if chars.peek().is_none_or(|c| c.is_ascii_digit()) {
2556            return false;
2557        }
2558    }
2559
2560    let first = match chars.next() {
2561        Some(c) => c,
2562        None => return false,
2563    };
2564
2565    if first.is_ascii_digit() {
2566        // All-digit names are valid (positional params)
2567        return chars.all(|c| c.is_ascii_digit());
2568    }
2569
2570    if !first.is_alphabetic() && first != '_' {
2571        return false;
2572    }
2573
2574    for c in chars {
2575        if c == '[' {
2576            // c:1326
2577            // c:1329-1330 — `if (*ss != '[') return 0; if (!(ss =
2578            //          parse_subscript(++ss, 1, ']'))) return 0;`
2579            // Subscript MUST be balanced — `foo[` (missing `]`)
2580            // is NOT a valid identifier. The previous Rust port
2581            // accepted `[` at the end unconditionally, missing
2582            // the balanced-pair requirement.
2583            //
2584            // Routing through the full `parse_subscript` (which
2585            // drives a nested lex context) would be overkill at
2586            // this site — a simple bracket-balance walk over the
2587            // remaining bytes suffices. Count `[` / `]` and require
2588            // the depth to return to 0 before end-of-string.
2589            let mut depth = 1i32;
2590            // c:Src/params.c:1334 — `if (!(ss = parse_subscript(++ss,
2591            // 1, ']'))) return 0;`. C's parse_subscript rejects empty
2592            // subscripts: `h[]` returns NULL. Mirror by tracking
2593            // whether ANY non-bracket char appears before the depth
2594            // returns to 0. Without this, `h[]=val` was accepted as a
2595            // valid assoc element write — but zsh errors
2596            // `not an identifier: h[]` (bug #288 in docs/BUGS.md).
2597            //
2598            // c:Src/params.c parse_subscript (params.c:1480+) also
2599            // recognises backslash-escaped brackets: `A[\[k\]]` is a
2600            // single subscript with key `[k]`. The `\[` doesn't count
2601            // toward bracket depth and `\]` doesn't close. Track a
2602            // bslash flag so the depth walk matches C semantics.
2603            let mut saw_content = false;
2604            // Bug fix: `s.split('[').skip(1).next()` only returned
2605            // the segment between the first and second `[`, dropping
2606            // everything after. Use find()+slice to get the entire
2607            // tail starting after the first `[`.
2608            let tail_start = s
2609                .char_indices()
2610                .find(|(_, c)| *c == '[')
2611                .map(|(i, _)| i + 1);
2612            let saw_close = tail_start.map(|start| &s[start..]).is_some_and(|tail| {
2613                let mut bslash = false;
2614                for ch in tail.chars() {
2615                    if bslash {
2616                        // c:parse_subscript — escaped char is
2617                        // content, doesn't affect depth.
2618                        saw_content = true;
2619                        bslash = false;
2620                        continue;
2621                    }
2622                    match ch {
2623                        '\\' => {
2624                            bslash = true;
2625                            saw_content = true;
2626                        }
2627                        '[' => {
2628                            depth += 1;
2629                            saw_content = true;
2630                        }
2631                        ']' => {
2632                            depth -= 1;
2633                            if depth == 0 {
2634                                return true;
2635                            }
2636                            saw_content = true;
2637                        }
2638                        _ => saw_content = true,
2639                    }
2640                }
2641                false
2642            });
2643            if !saw_content {
2644                return false; // c:1334 empty subscript rejected
2645            }
2646            return saw_close;
2647        }
2648        if !c.is_alphanumeric() && c != '_' && c != '.' {
2649            return false;
2650        }
2651    }
2652    true
2653}
2654
2655/// Subscript-argument parser.
2656///
2657/// Port of `getarg(char **str, int *inv, Value v, int a2, zlong *w, int *prevcharlen, int *nextcharlen, int scanflags)` from Src/params.c:1367. The C function is a
2658/// 618-line monolith handling the entire `[...]` body of a
2659/// subscripted parameter expansion.
2660///
2661/// Ported phases:
2662///   - Flag-block parse (c:1389-1480) — extract `(...)` chars.
2663///   - Hash pattern search (c:1581-1660) when `assoc` is `Some`.
2664///   - Array pattern search (c:1672-1719) when `arr` is `Some`.
2665///   - Scalar word-mode arm (c:1761-1797) when `scalar` is `Some`.
2666///
2667/// Later C phases not yet exercised by this entry point:
2668///   - Brace-depth walk to closing `]` (c:1507-1535)
2669///   - parsestr + singsub on subscript body (c:1545-1580)
2670///   - mathevalarg integer parse (c:1601-1604)
2671///   - Multibyte char-search arm (c:1798-1985)
2672pub(crate) fn getarg<'a>(
2673    idx: &'a str,
2674    arr: Option<&[String]>,
2675    assoc: Option<&IndexMap<String, String>>,
2676    scalar: Option<&str>,
2677) -> Option<getarg_out<'a>> {
2678    let rest = idx.strip_prefix('(')?;
2679    // Reject anything that looks like a char-class subscript: `[abc]`
2680    // doesn't match this prefix, but `(...)` containing brackets is
2681    // probably alternation — let it fall through to runtime instead.
2682    if rest.starts_with(')') || rest.contains('[') {
2683        return None;
2684    }
2685    // Flag scanner per zshparam(1) "Subscript Flags" /
2686    // params.c:1389-1480 switch:
2687    //   r/R (reverse value-search → value/all values),
2688    //   i/I (value-search → key/all keys),
2689    //   k/K (key-search → value/all values),
2690    //   e (exact match — disables glob),
2691    //   n<DELIM>NUM<DELIM> (Nth match — params.c:1431-1442),
2692    //   b<DELIM>NUM<DELIM> (begin offset — params.c:1443-1454),
2693    //   w (word index on scalar),
2694    //   f (word index split by newline; alias for `w` + sep="\n"),
2695    //   p (escapes for next get_strarg),
2696    //   s<DELIM>SEP<DELIM> (split-by-separator).
2697    // The `n` / `b` / `s` forms use `get_strarg`'s balanced-delimiter
2698    // pair: any non-flag char closes its pair (`(n.5.)`, `(n:5:)` etc.).
2699    let bytes = rest.as_bytes();
2700    let mut i: usize = 0;
2701    let mut num: i64 = 1;
2702    let mut beg: i64 = 0;
2703    let mut has_beg = false;
2704    let flags_start = 0_usize;
2705    let mut flags_end = 0_usize;
2706    let mut bad = false;
2707    while i < bytes.len() && bytes[i] != b')' {
2708        let c = bytes[i] as char;
2709        match c {
2710            'r' | 'R' | 'i' | 'I' | 'e' | 'k' | 'K' | 'w' | 'f' | 'p' => {
2711                i += 1;
2712                flags_end = i;
2713            }
2714            'n' | 'b' => {
2715                // Consume `n<DELIM>NUM<DELIM>` per c:1432 get_strarg.
2716                if i + 1 >= bytes.len() {
2717                    bad = true;
2718                    break;
2719                }
2720                let delim = bytes[i + 1];
2721                let arg_start = i + 2;
2722                let mut arg_end = arg_start;
2723                while arg_end < bytes.len() && bytes[arg_end] != delim {
2724                    arg_end += 1;
2725                }
2726                if arg_end >= bytes.len() {
2727                    bad = true;
2728                    break;
2729                }
2730                // Parse the argument as a signed decimal integer.
2731                let arg = std::str::from_utf8(&bytes[arg_start..arg_end]).ok()?;
2732                let parsed: i64 = arg.trim().parse().ok()?;
2733                if c == 'n' {
2734                    num = if parsed == 0 { 1 } else { parsed };
2735                } else {
2736                    has_beg = true;
2737                    beg = if parsed > 0 { parsed - 1 } else { parsed };
2738                }
2739                i = arg_end + 1;
2740                flags_end = i;
2741            }
2742            's' => {
2743                // (s:SEP:) — pass through with raw flag block.
2744                let close = match rest[i..].find(')') {
2745                    Some(p) => i + p,
2746                    None => return None,
2747                };
2748                let flags = &rest[flags_start..close];
2749                return Some(getarg_out::Flags {
2750                    flags,
2751                    rest: &rest[close + 1..],
2752                });
2753            }
2754            _ => {
2755                bad = true;
2756                break;
2757            }
2758        }
2759    }
2760    // c:1477-1483 — flag-error fallback: reset all flags, treat as no
2761    // subscript flags.
2762    if bad {
2763        return None;
2764    }
2765    if i >= bytes.len() || bytes[i] != b')' {
2766        return None;
2767    }
2768    if flags_end == flags_start {
2769        return None;
2770    }
2771    let flags = &rest[flags_start..flags_end];
2772    let pat = &rest[i + 1..];
2773
2774    // c:1488-1491 — negative `num` flips the search direction.
2775    let neg_num_flips = num < 0;
2776    if neg_num_flips {
2777        num = -num;
2778    }
2779
2780    // Phase 3 — hash pattern search arm (c:1581-1660 / 1672-1734).
2781    // Per C source case-arms:
2782    //   `r`: rev=1 → match against VALUES, return matching VALUE
2783    //   `R`: rev+down=1 → match VALUES, return ALL matching VALUEs
2784    //   `i`: rev+ind=1 → match VALUES, return KEY of first match
2785    //   `I`: rev+ind+down=1 → match VALUES, return ALL matching KEYs
2786    //   `k`: keymatch+rev=1 → match KEYS, return VALUE of first match
2787    //   `K`: keymatch+rev+down=1 → match KEYS, return ALL matching VALUEs
2788    if let Some(map) = assoc {
2789        let exact = flags.contains('e');
2790        let key_match = flags.contains('k') || flags.contains('K');
2791        let return_index = flags.contains('i') || flags.contains('I');
2792        // c:Src/params.c — on a HASH, i/I/k/K all match against KEYS while
2793        // r/R match against VALUES (zsh 5.9: `${h[(i)KEY]}`→KEY,
2794        // `${h[(r)VAL]}`→VAL, `${h[(i)VAL]}`→empty). k/K are exact key
2795        // compares (pprog=NULL, c:1707-1708); i/I glob the key; r/R glob the
2796        // value; (e) forces exact. Return: i/I→KEY, k/K→VALUE, r/R→VALUE.
2797        let match_against_key = key_match || return_index;
2798        let key_glob = return_index; // i/I glob the key; k/K are exact-only
2799                                     // c:Src/params.c:1689,1708-1729 — the value/key SCAN only runs when
2800                                     // `v->scanflags` is set, which requires a search-direction flag
2801                                     // (i/I/r/R/k/K). With none of those (e.g. a bare `(e)key`), C never
2802                                     // builds scanflags: getindex falls back to a plain `gethashnode`
2803                                     // exact KEY lookup. `(e)` (quote_arg, c:1450) only makes that key
2804                                     // literal (no glob). Without this, `(e)*` wrongly scanned VALUES for
2805                                     // an exact "*" and returned empty instead of the value at key "*".
2806        let has_search = flags.contains('i')
2807            || flags.contains('I')
2808            || flags.contains('r')
2809            || flags.contains('R')
2810            || flags.contains('k')
2811            || flags.contains('K');
2812        if !has_search {
2813            return Some(getarg_out::Value(Value::str(
2814                map.get(pat).cloned().unwrap_or_default(),
2815            )));
2816        }
2817        // C params.c:1488-1491 — negative `num` flips `down`. Since
2818        // R/I/K already set down=1, neg_num XORs the bit (r/i/k +
2819        // neg → return_all; R/I/K + neg → single-match again).
2820        let is_uppercase = flags.contains('I') || flags.contains('R') || flags.contains('K');
2821        let return_all = is_uppercase ^ neg_num_flips;
2822
2823        // c:1740-1747 — `b<NUM>` start offset on the values array. The
2824        // hash is iterated in insertion order (IndexMap); skip first
2825        // `beg` entries before counting matches.
2826        let len = map.len() as i64;
2827        let mut start = beg;
2828        if start < 0 {
2829            start += len;
2830        }
2831        if !return_all && start >= len {
2832            return Some(getarg_out::Value(Value::str("")));
2833        }
2834        let skip = if start < 0 { 0 } else { start as usize };
2835
2836        // Per C params.c:1707-1709 + zsh 5.9 empirical:
2837        //   k/K — keymatch path: pprog=NULL, no glob; exact key
2838        //         lookup. `(K)*` returns "" because there's no key
2839        //         literally named "*".
2840        //   r/R/i/I — value path: pprog=patcompile, glob/exact.
2841        // c:params.c:1697 — `pprog = patcompile(s, 0, NULL)` runs ONCE
2842        // before the scan loop. Compiling inside the per-entry compare
2843        // re-tokenized + re-compiled the pattern for EVERY element —
2844        // `${history[(R)(#i)*pat*]}` over a 566k-entry history ran
2845        // ~4 CPU-minutes per hsmw ^R (the reported freeze) instead of
2846        // one compile + 566k pattry calls.
2847        let compiled_pat = if exact || (match_against_key && !key_glob) {
2848            None
2849        } else {
2850            patcompile(
2851                &{
2852                    let mut __pat_tok = (pat).to_string();
2853                    crate::ported::glob::tokenize(&mut __pat_tok);
2854                    __pat_tok
2855                },
2856                PAT_HEAPDUP as i32,
2857                None,
2858            )
2859        };
2860        let key_compare = |target: &str| -> bool {
2861            if exact || (match_against_key && !key_glob) {
2862                // (e) literal, and k/K exact key compare (no glob).
2863                target == pat
2864            } else {
2865                // i/I glob the key; r/R glob the value.
2866                compiled_pat.as_ref().map_or(false, |p| pattry(p, target))
2867            }
2868        };
2869        if return_all {
2870            let mut out: Vec<String> = Vec::new();
2871            for (k, v) in map.iter().skip(skip) {
2872                let target = if match_against_key {
2873                    k.as_str()
2874                } else {
2875                    v.as_str()
2876                };
2877                if key_compare(target) {
2878                    // i/I → KEY; k/K → VALUE; r/R → VALUE.
2879                    out.push(if return_index { k.clone() } else { v.clone() });
2880                }
2881            }
2882            return Some(getarg_out::Value(Value::str(out.join(" "))));
2883        }
2884        // c:1753 — `!--num` skips matches until the Nth.
2885        let mut remaining = num;
2886        for (k, v) in map.iter().skip(skip) {
2887            let target = if match_against_key {
2888                k.as_str()
2889            } else {
2890                v.as_str()
2891            };
2892            if key_compare(target) {
2893                remaining -= 1;
2894                if remaining == 0 {
2895                    return Some(getarg_out::Value(Value::str(if return_index {
2896                        k.clone()
2897                    } else {
2898                        v.clone()
2899                    })));
2900                }
2901            }
2902        }
2903        return Some(getarg_out::Value(Value::str("")));
2904    }
2905
2906    // Phase 2 — array pattern search arm (c:1672-1719). The C body
2907    // does `pprog = patcompile(s, 0, NULL)` then forward/reverse
2908    // `for (r = 1 + beg, p = ta + beg; *p; r++, p++) if (pprog &&
2909    // pattry(pprog, *p)) return r`.
2910    if let Some(arr) = arr {
2911        // C params.c:1761-1797 — `(w)N` / `(f)N` word-mode arm.
2912        // `getstrvalue(v)` joins the array; `sepsplit` re-splits by
2913        // sep (`f` → "\n", `w` → IFS-default whitespace, `s:SEP:`
2914        // → user sep), then the Nth split word is returned. So
2915        // `arr=("a b" "c d"); ${arr[(w)2]}` → "b" (joined "a b c d",
2916        // split → ["a","b","c","d"], pick idx 1).
2917        if flags.contains('w') || flags.contains('f') {
2918            if let Ok(n) = pat.parse::<i64>() {
2919                let sep_chars: &[char] = if flags.contains('f') {
2920                    &['\n']
2921                } else {
2922                    &[' ', '\t', '\n']
2923                };
2924                let joined = arr.join(" ");
2925                let words: Vec<&str> = joined
2926                    .split(|c: char| sep_chars.contains(&c))
2927                    .filter(|w| !w.is_empty())
2928                    .collect();
2929                let len = words.len() as i64;
2930                let idx_into = if n > 0 {
2931                    (n - 1) as usize
2932                } else if n < 0 {
2933                    let off = len + n;
2934                    if off < 0 {
2935                        return Some(getarg_out::Value(Value::str("")));
2936                    }
2937                    off as usize
2938                } else {
2939                    return Some(getarg_out::Value(Value::str("")));
2940                };
2941                return Some(getarg_out::Value(Value::str(
2942                    words
2943                        .get(idx_into)
2944                        .map(|s| s.to_string())
2945                        .unwrap_or_default(),
2946                )));
2947            }
2948        }
2949        let exact = flags.contains('e');
2950        let word = flags.contains('w') || flags.contains('f');
2951        let _ = word;
2952        let return_index = flags.contains('i') || flags.contains('I');
2953        // C params.c:1575 `if (!rev)` — without a direction flag
2954        // (r/R/i/I/k/K), getarg does NOT enter the search loop on
2955        // arrays; pat is mathevalarg'd as an integer index instead.
2956        // Verified empirically: `arr=(foo bar); ${arr[(e)foo]}`
2957        // returns empty in real zsh (mathevalarg fails, no element).
2958        let any_search_flag = flags.contains('r')
2959            || flags.contains('R')
2960            || flags.contains('i')
2961            || flags.contains('I')
2962            || flags.contains('k')
2963            || flags.contains('K');
2964        if !any_search_flag {
2965            return None;
2966        }
2967        // c:1488-1491 — negative `num` flips reverse direction.
2968        let reverse = (flags.contains('R') || flags.contains('I')) ^ neg_num_flips;
2969        // C params.c:1668-1685 implicit `*` wrap fires only when
2970        // `v->scanflags` is unset; in standard subscript callsites
2971        // scanflags IS set, so the wrap does NOT engage. Verified
2972        // empirically: `arr=(foobar baz); ${arr[(r)foo]}` returns
2973        // empty in real zsh (exact match), not "foobar". Pattern is
2974        // used verbatim — globbing only when user supplies `*`.
2975        let pat_used: &str = pat;
2976
2977        // c:1740-1760 — `b<NUM>` starting offset + bounds checks.
2978        // beg is already 0-based after parse (parsed-1 for positive).
2979        let len = arr.len() as i64;
2980        let mut start = beg;
2981        if start < 0 {
2982            start += len;
2983        }
2984        // c:1743-1747 — out-of-bounds returns.
2985        if reverse {
2986            if start < 0 {
2987                return Some(getarg_out::Value(if return_index {
2988                    Value::str("0")
2989                } else {
2990                    Value::str("")
2991                }));
2992            }
2993        } else if start >= len {
2994            return Some(getarg_out::Value(if return_index {
2995                Value::str((arr.len() + 1).to_string())
2996            } else {
2997                Value::str("")
2998            }));
2999        }
3000        // c:1750-1751 — reverse w/o explicit b starts from len-1.
3001        if reverse && !has_beg {
3002            start = len - 1;
3003        }
3004        // c:1748 — `if (beg >= 0 && beg < len) { …search… }`
3005        if start >= len {
3006            return Some(getarg_out::Value(if return_index {
3007                Value::str("0")
3008            } else {
3009                Value::str("")
3010            }));
3011        }
3012        let iter: Box<dyn Iterator<Item = (usize, &String)>> = if reverse {
3013            // c:1752 — `for (p = ta + beg; p >= ta; p--)`: clamp start
3014            // into the valid range then walk backwards. An empty array
3015            // has no element to walk (C's loop body never dereferences a
3016            // valid `p`), so yield an empty iterator rather than indexing
3017            // `arr[..=0]` on a zero-length slice.
3018            if arr.is_empty() {
3019                Box::new(std::iter::empty())
3020            } else {
3021                let s_idx = if start < 0 { 0 } else { start as usize };
3022                let s_idx = s_idx.min(arr.len() - 1);
3023                Box::new(arr[..=s_idx].iter().enumerate().rev())
3024            }
3025        } else {
3026            // c:1757 — `for (p = ta + beg; *p; p++)`: skip first beg.
3027            let s_idx = if start < 0 { 0 } else { start as usize };
3028            Box::new(arr.iter().enumerate().skip(s_idx))
3029        };
3030        // c:1758 — `!--num` skips matches until the Nth.
3031        // c:1697 — pattern compiled ONCE before the scan (see the
3032        // assoc arm above; same per-element recompile hazard).
3033        let compiled_arr_pat = if exact {
3034            None
3035        } else {
3036            patcompile(
3037                &{
3038                    let mut __pat_tok = (pat_used).to_string();
3039                    crate::ported::glob::tokenize(&mut __pat_tok);
3040                    __pat_tok
3041                },
3042                PAT_HEAPDUP as i32,
3043                None,
3044            )
3045        };
3046        let mut remaining = num;
3047        for (i, s) in iter {
3048            let hit = if exact {
3049                s == pat
3050            } else {
3051                compiled_arr_pat.as_ref().map_or(false, |p| pattry(p, s))
3052            };
3053            if hit {
3054                remaining -= 1;
3055                if remaining == 0 {
3056                    return Some(getarg_out::Value(if return_index {
3057                        Value::str((i + 1).to_string())
3058                    } else {
3059                        Value::str(s.clone())
3060                    }));
3061                }
3062            }
3063        }
3064        return Some(getarg_out::Value(if return_index {
3065            // zsh: `i` returns len+1 if not found, `I` returns 0.
3066            if flags.contains('I') {
3067                Value::str("0")
3068            } else {
3069                Value::str((arr.len() + 1).to_string())
3070            }
3071        } else {
3072            Value::str("")
3073        }));
3074    }
3075
3076    // C params.c:1761-1797 — scalar word-mode arm. `(w)N` joins
3077    // the source string and re-splits by sep (whitespace by default
3078    // for `w`, "\n" for `f`). When `pat` is a numeric N, the Nth
3079    // word is returned. Pattern-search variants on scalars use the
3080    // c:1798-1980 char-search arm ported below.
3081    if let Some(s) = scalar {
3082        if flags.contains('w') || flags.contains('f') {
3083            if let Ok(n) = pat.parse::<i64>() {
3084                let sep_chars: &[char] = if flags.contains('f') {
3085                    &['\n']
3086                } else {
3087                    &[' ', '\t', '\n']
3088                };
3089                let words: Vec<&str> = s
3090                    .split(|c: char| sep_chars.contains(&c))
3091                    .filter(|w| !w.is_empty())
3092                    .collect();
3093                let len = words.len() as i64;
3094                let idx_into = if n > 0 {
3095                    (n - 1) as usize
3096                } else if n < 0 {
3097                    let off = len + n;
3098                    if off < 0 {
3099                        return Some(getarg_out::Value(Value::str("")));
3100                    }
3101                    off as usize
3102                } else {
3103                    return Some(getarg_out::Value(Value::str("")));
3104                };
3105                return Some(getarg_out::Value(Value::str(
3106                    words
3107                        .get(idx_into)
3108                        .map(|s| s.to_string())
3109                        .unwrap_or_default(),
3110                )));
3111            }
3112        }
3113        // C params.c:1798-1980 — scalar char-search arm. `(i)/(I)/
3114        // (r)/(R)` on a scalar runs a sliding-window glob match over
3115        // the CHARACTER array (`s_chars`), so positions are character-
3116        // based — matching C's prevcharlen/nextcharlen cursor stepping
3117        // (c:1948-1971) under MULTIBYTE. (i)/(I) return the 1-based
3118        // char position of the first/last match; (r)/(R) return the
3119        // char at the match. Verified against zsh: `s=ábc; ${s[(i)b]}`
3120        // → 2 (char pos), not 3 (byte pos).
3121        let any_search = flags.contains('r')
3122            || flags.contains('R')
3123            || flags.contains('i')
3124            || flags.contains('I');
3125        if any_search {
3126            let return_index = flags.contains('i') || flags.contains('I');
3127            let want_last = flags.contains('I') || flags.contains('R');
3128            // Negative `num` flips direction (c:1488-1491).
3129            let want_last = want_last ^ neg_num_flips;
3130            let s_chars: Vec<char> = s.chars().collect();
3131            let n = s_chars.len();
3132            let positions: Box<dyn Iterator<Item = usize>> = if want_last {
3133                Box::new((0..=n).rev())
3134            } else {
3135                Box::new(0..=n)
3136            };
3137            // c:1929+ / c:1964 — `!--num` skips matches until the Nth.
3138            // Per `b<NUM>` (c:1740-1747) — start from offset, only
3139            // when has_beg is set. Without `b`, walk all positions.
3140            let beg_idx_opt: Option<usize> = if has_beg {
3141                let beg_norm = if beg < 0 { beg + n as i64 } else { beg };
3142                Some(if beg_norm < 0 {
3143                    0
3144                } else {
3145                    (beg_norm as usize).min(n)
3146                })
3147            } else {
3148                None
3149            };
3150            // c:1697 — compile the search pattern ONCE (the scalar
3151            // char-search tries O(n²) spans; a per-span recompile is
3152            // quadratic × compile cost — same hazard as the assoc arm).
3153            let compiled_span_pat = if flags.contains('e') {
3154                None
3155            } else {
3156                patcompile(
3157                    &{
3158                        let mut __pat_tok = (pat).to_string();
3159                        crate::ported::glob::tokenize(&mut __pat_tok);
3160                        __pat_tok
3161                    },
3162                    PAT_HEAPDUP as i32,
3163                    None,
3164                )
3165            };
3166            let mut found: Option<(usize, usize)> = None;
3167            let mut remaining = num;
3168            'outer: for start in positions {
3169                if let Some(b_idx) = beg_idx_opt {
3170                    if want_last {
3171                        if start > b_idx {
3172                            continue;
3173                        }
3174                    } else if start < b_idx {
3175                        continue;
3176                    }
3177                }
3178                for span_len in 1..=(n - start) {
3179                    let cand: String = s_chars[start..start + span_len].iter().collect();
3180                    let hit = if flags.contains('e') {
3181                        cand == pat
3182                    } else {
3183                        compiled_span_pat.as_ref().map_or(false, |p| pattry(p, &cand))
3184                    };
3185                    if hit {
3186                        remaining -= 1;
3187                        if remaining == 0 {
3188                            found = Some((start, start + span_len));
3189                            break 'outer;
3190                        }
3191                        // Advance past this match position to find the
3192                        // next-Nth instead of repeatedly matching same
3193                        // start (mirrors C's pointer increment).
3194                        break;
3195                    }
3196                }
3197            }
3198            return Some(getarg_out::Value(match (found, return_index) {
3199                (Some((s_pos, _)), true) => Value::str((s_pos + 1).to_string()),
3200                // C params.c:1798-1980 char-search returns the char AT
3201                // the match position, not the full matched substring.
3202                // Verified empirically: `s="barfooxyz"; ${s[(r)foo]}`
3203                // returns "f" in real zsh, not "foo".
3204                (Some((s_pos, _)), false) => Value::str(
3205                    s_chars
3206                        .get(s_pos)
3207                        .map(|c| c.to_string())
3208                        .unwrap_or_default(),
3209                ),
3210                (None, true) => Value::str(if flags.contains('i') {
3211                    (n + 1).to_string()
3212                } else {
3213                    "0".to_string()
3214                }),
3215                (None, false) => Value::str(String::new()),
3216            }));
3217        }
3218    }
3219
3220    // No search context — return parsed flags for caller dispatch.
3221    Some(getarg_out::Flags { flags, rest: pat })
3222}
3223
3224/// Port of `getindex(char **pptr, Value v, int scanflags)` from `Src/params.c:2001`. Returns 0 on
3225/// success, non-zero on parse error. C body parses `[N]`/`[N,M]`/
3226/// `[(flags)pat]` after a Value's name and updates v->start/end/
3227/// scanflags. Stub: needs subscript expression evaluator.
3228/// Direct port of `int getindex(char **pptr, Value v, int
3229/// scanflags)` from `Src/params.c:2001-2167`. Parses the bracket
3230/// subscript after a Value's name and updates v->start/v->end/
3231/// v->scanflags. Returns 0 on success, 1 on parse error.
3232///
3233/// Handles:
3234///   - `[*]` / `[@]` — full range, with `[@]` setting
3235///     SCANPM_ISVAR_AT (c:2027-2032).
3236///   - `[N]` / `[N,M]` — single index / slice via getarg.
3237///   - Inverse subscripts `[(I)pat]` (partial — falls back to
3238///     direct start/end without the MB_METACHAR inverse-offset
3239///     translation in c:2050-2090).
3240///
3241/// Deferred from full C body:
3242///   - MB_METACHARLEN-based inverse-offset translation
3243///     (c:2050-2090).
3244///   - KSH_ARRAYS / KSHZEROSUBSCRIPT non-strict option dispatch
3245///     (c:2130-2150).
3246///   - Flag-prefixed subscript forms `[(r)val]` / `[(i)val]` /
3247///     `[(I)pat]` route through getarg's separate dispatcher
3248///     because the Rust getarg has a different signature from C.
3249pub fn getindex(pptr: &mut &str, v: &mut value, scanflags: i32) -> i32 {
3250    // c:2001
3251
3252    let s = *pptr;
3253    // c:2006 — `*s++ = '['`. Caller asserts s[0] is '[' (or its
3254    // tokenised form Inbrack); skip it.
3255    if s.is_empty() || (s.as_bytes()[0] != b'[' && s.as_bytes()[0] != 0xa9) {
3256        return 1;
3257    }
3258    let after_lbrack = &s[1..];
3259
3260    // c:2008 — `parse_subscript(s, dq, ']')`. Routes through the
3261    // existing lex-layer port at `crate::ported::lex::parse_subscript`
3262    // which honours `[...]` / `(...)` / `{...}` nesting and single/
3263    // double quoting (parse/src/lex.rs:3074).
3264    let close_pos = parse_subscript(after_lbrack, ']');
3265    let close_pos = match close_pos {
3266        Some(p) => p,
3267        None => {
3268            // c:2020 — `zerr("invalid subscript")`.
3269            zerr("invalid subscript");
3270            *pptr = ""; // c:2021
3271            return 1; // c:2022
3272        }
3273    };
3274    let body = &after_lbrack[..close_pos];
3275
3276    // c:2027 — special-case `[*]` / `[@]`.
3277    if body == "*" || body == "@" {
3278        if body == "@" && (v.scanflags != 0 || v.pm.is_none()) {
3279            // c:2028
3280            v.scanflags |= SCANPM_ISVAR_AT as i32; // c:2029
3281        }
3282        v.start = 0; // c:2030
3283        v.end = -1; // c:2031
3284                    // c:2156 — `*tbrack = ']'; *pptr = s` (s points past `]`).
3285        *pptr = &after_lbrack[close_pos + 1..];
3286        return 0; // c:2160
3287    }
3288
3289    let _ = scanflags;
3290    // c:2058-2114 — flag subscripts `[(i)pat]` / `[(I)pat]` on an
3291    // array. The numeric parser below can't handle these; route
3292    // through getarg (c:2058 `getarg(&s, &inv, v, 0, ...)`). The
3293    // index-returning reverse flags (i/I) yield the 1-based array
3294    // index of the match (len+1 / 0 when not found). C's getindex
3295    // inv branch (c:2110-2114) turns that into a single-element
3296    // access: `v->valflags |= VALFLAG_INV; v->scanflags = 0;
3297    // v->start = start; v->end = start + 1`. issetvar then range-
3298    // checks `end` against the array length, so `arr[(i)x]` (index
3299    // = len+1, out of range) correctly reports unset. Non-index
3300    // flags (r/R/k/K) still fall through to the substitution
3301    // pipeline's getarg.
3302    // c:1597-1615 — hash subscript resolution. For a PM_HASHED param
3303    // the subscript names a key (or a flag search over keys/values).
3304    // C resolves it to the element's own pm (`ht->getnode` / a fresh
3305    // PM_SCALAR|PM_UNSET on a miss) so issetvar's PM_UNSET check
3306    // (c:765) reports the *element's* set-ness, not the hash's. zshrs
3307    // keeps assoc values in `paramtab_hashed_storage` keyed by name;
3308    // resolve existence here and shape `v` so issetvar returns the
3309    // right answer: found → leave the (set) hash pm with no slice
3310    // (c:765 `!slice && !PM_UNSET` → 1); missing → clear `v.pm` so
3311    // issetvar's `getvalue` NULL-pm guard (c:761) returns 0.
3312    if let Some(p) = v.pm.as_ref() {
3313        if PM_TYPE(p.node.flags as u32) == PM_HASHED {
3314            let name = p.node.nam.clone();
3315            let map: IndexMap<String, String> = paramtab_hashed_storage()
3316                .lock()
3317                .unwrap()
3318                .get(&name)
3319                .cloned()
3320                .unwrap_or_default();
3321            let exists = if body.starts_with('(') {
3322                // Flag search (i/I over keys, etc.) — getarg returns the
3323                // matched key/value, empty when nothing matched.
3324                match getarg(body, None, Some(&map), None) {
3325                    Some(getarg_out::Value(val)) => !val.to_str().is_empty(),
3326                    _ => false,
3327                }
3328            } else {
3329                map.contains_key(body)
3330            };
3331            *pptr = &after_lbrack[close_pos + 1..];
3332            if exists {
3333                v.scanflags = 0;
3334                v.start = 0;
3335                v.end = -1;
3336            } else {
3337                v.pm = None;
3338            }
3339            return 0;
3340        }
3341    }
3342    if body.starts_with('(') {
3343        let pm_is_array =
3344            v.pm.as_ref()
3345                .map_or(false, |p| PM_TYPE(p.node.flags as u32) == PM_ARRAY);
3346        if pm_is_array {
3347            let flag_part = &body[1..body.find(')').unwrap_or(1)];
3348            let return_index = flag_part.contains('i') || flag_part.contains('I');
3349            if return_index {
3350                let arr: Vec<String> = v.pm.as_ref().map(|p| arrgetfn(p)).unwrap_or_default();
3351                if let Some(getarg_out::Value(val)) = getarg(body, Some(&arr), None, None) {
3352                    if let Ok(idx) = val.to_str().parse::<i64>() {
3353                        // c:2110-2114 — inv branch single-element access.
3354                        v.valflags |= VALFLAG_INV;
3355                        v.scanflags = 0;
3356                        v.start = idx as i32;
3357                        v.end = (idx + 1) as i32;
3358                        *pptr = &after_lbrack[close_pos + 1..];
3359                        return 0;
3360                    }
3361                }
3362            }
3363        }
3364    }
3365    // c:2035-2040 — general path: getarg() would parse the start
3366    // index. The Rust `getarg` has a different signature (flag
3367    // dispatcher returning getarg_out, not C's char**+int*+zlong
3368    // out-params), so the bracket-subscript here inline-parses
3369    // the simple cases: `N`, `N,M`, `-N`. Flag-based subscripts
3370    // (`[(I)pat]`, `[(r)val]`) still route through getarg
3371    // separately when called by the substitution pipeline.
3372
3373    let (start_str, end_str) = match body.split_once(',') {
3374        Some((a, b)) => (a, Some(b)),
3375        None => (body, None),
3376    };
3377    let start: i64 = match start_str.parse() {
3378        Ok(n) => n,
3379        Err(_) => {
3380            // Non-numeric subscript — leave v unchanged, advance past `]`.
3381            *pptr = &after_lbrack[close_pos + 1..];
3382            return 0;
3383        }
3384    };
3385    let mut end: i64 = match end_str {
3386        Some(s) => match s.parse() {
3387            Ok(n) => n,
3388            Err(_) => {
3389                *pptr = &after_lbrack[close_pos + 1..];
3390                return 0;
3391            }
3392        },
3393        None => start,
3394    };
3395
3396    // c:2125 — `if (start > 0) start -= startprevlen`. Without
3397    // multibyte support this is a no-op for ASCII.
3398    let mut start = start;
3399    let com = end_str.is_some() || start != end;
3400
3401    if start == 0 && end == 0 {
3402        // c:2126
3403        // c:2134 — `if (isset(KSHZEROSUBSCRIPT))` non-strict mode.
3404        // Treats `a[0]` as the first element (end = startnextlen,
3405        // which is 1 for ASCII). c:2141-2150 strict mode keeps the
3406        // VALFLAG_EMPTY + start=-1 sentinel for empty access.
3407        if crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHZEROSUBSCRIPT) {
3408            end = 1; // c:2140 — `end = startnextlen` (1 for ASCII)
3409        } else {
3410            v.valflags |= VALFLAG_EMPTY; // c:2147
3411            start = -1; // c:2148
3412        }
3413    }
3414    // c:2156-2158 — clear scanflags for non-comma simple subscript
3415    // when match flags absent.
3416    if v.scanflags != 0
3417        && !com
3418        && (v.scanflags as u32 & SCANPM_MATCHMANY == 0
3419            || v.scanflags as u32 & (SCANPM_MATCHKEY | SCANPM_MATCHVAL | SCANPM_KEYMATCH) == 0)
3420    {
3421        v.scanflags = 0;
3422    }
3423    let _ = (SCANPM_ISVAR_AT, SCANPM_WANTINDEX, VALFLAG_INV);
3424    v.start = start as i32; // c:2159
3425    v.end = end as i32; // c:2160
3426
3427    // c:2164-2165 — advance `*pptr` past the close bracket.
3428    *pptr = &after_lbrack[close_pos + 1..];
3429    0 // c:2166
3430}
3431
3432/// Port of `getvalue(Value v, char **pptr, int bracks)` from `Src/params.c:2173`. C body:
3433/// `return fetchvalue(v, pptr, bracks, SCANPM_CHECKING);` — pure
3434/// wrapper around `fetchvalue` with the SCANPM_CHECKING flag set
3435/// so unset params don't trigger creation.
3436pub fn getvalue<'a>(
3437    v: Option<&'a mut value>,
3438    pptr: &mut &str,
3439    bracks: i32,
3440) -> Option<&'a mut value> {
3441    fetchvalue(v, pptr, bracks, SCANPM_CHECKING as i32)
3442}
3443
3444/// Direct port of `Value fetchvalue(Value v, char **pptr,
3445/// int bracks, int scanflags)` from `Src/params.c:2180-2282`.
3446///
3447/// Walks the parameter expression starting at `*pptr`, consuming
3448/// the identifier (or special-char like `?`/`#`/`$`/`!`/`@`/`*`/
3449/// `-`) and updating `*pptr` to point past the name. Looks up the
3450/// param in paramtab and populates the Value's pm/start/end/
3451/// scanflags fields.
3452///
3453/// Currently a partial port: identifier + special-char + digit
3454/// names are parsed and looked up. Nameref resolution
3455/// (PM_NAMEREF path at c:2246-2270), bracket subscripts
3456/// (`getindex` at c:2288), and the SCANPM_ARRONLY scanflags
3457/// promotion for hash/array params are handled. The
3458/// REFSLICE/upscope path for nameref-of-array-element is deferred
3459/// pending the GETREFNAME/upscope ports.
3460pub fn fetchvalue<'a>(
3461    // c:2180
3462    v: Option<&'a mut value>,
3463    pptr: &mut &str,
3464    bracks: i32,
3465    scanflags: i32,
3466) -> Option<&'a mut value> {
3467    let s = *pptr;
3468    let bytes = s.as_bytes();
3469    if bytes.is_empty() {
3470        return None; // c:2214 fall-through
3471    }
3472    let c = bytes[0];
3473    let mut ppar: i32 = 0;
3474    let mut end_pos = 0usize;
3475
3476    if c.is_ascii_digit() {
3477        // c:2190
3478        // c:2191-2194 — zstrtol parse of positional parameter index.
3479        if bracks >= 0 {
3480            let mut idx = 0;
3481            while idx < bytes.len() && bytes[idx].is_ascii_digit() {
3482                ppar = ppar * 10 + (bytes[idx] - b'0') as i32;
3483                idx += 1;
3484            }
3485            end_pos = idx;
3486        } else {
3487            // c:2194 — single-digit positional ($0..$9 short form).
3488            ppar = (c - b'0') as i32;
3489            end_pos = 1;
3490        }
3491    } else if itype_end(s, crate::ported::ztype_h::IIDENT as u32, false) > 0 {
3492        // c:2196 — `itype_end(s, IIDENT, 0)` — walk identifier chars.
3493        end_pos = itype_end(s, crate::ported::ztype_h::IIDENT as u32, false);
3494    } else if matches!(c, b'?' | b'#' | b'$' | b'!' | b'@' | b'*' | b'-') {
3495        // c:2198-2210
3496        end_pos = 1;
3497    } else {
3498        return None; // c:2213
3499    }
3500
3501    let name = &s[..end_pos];
3502    *pptr = &s[end_pos..];
3503
3504    if ppar > 0 {
3505        // c:2217-2225 positional
3506        if let Some(v) = v {
3507            *v = value {
3508                pm: None,
3509                arr: Vec::new(),
3510                scanflags: 0,
3511                valflags: 0,
3512                start: ppar - 1,
3513                end: ppar,
3514            };
3515            return Some(v);
3516        }
3517        return None;
3518    }
3519
3520    // c:2227-2236 — paramtab lookup honouring SCANPM_NONAMEREF for
3521    // getnode vs getnode2 (the second skips nameref resolution).
3522    let pm = {
3523        let tab = paramtab().read().unwrap();
3524        let key = if name == "0" { "0" } else { name };
3525        tab.get(key).cloned()
3526    };
3527    let pm = pm?; // c:2237-2241
3528
3529    // c:2241-2243 — `if (PM_UNSET && !PM_DECLARED) return NULL`.
3530    if pm.node.flags & PM_UNSET as i32 != 0 && pm.node.flags & PM_DECLARED as i32 == 0 {
3531        return None;
3532    }
3533
3534    // c:2246-2270 — nameref deref. Partially handled: we route
3535    // through resolve_nameref if PM_NAMEREF is set and the caller
3536    // didn't pass SCANPM_NONAMEREF.
3537    let pm = if pm.node.flags & PM_NAMEREF as i32 != 0 && (scanflags as u32) & SCANPM_NONAMEREF == 0
3538    {
3539        resolve_nameref(Some(pm))?
3540    } else {
3541        pm
3542    };
3543
3544    if let Some(v) = v {
3545        // c:2274-2282 — populate Value from pm.
3546        *v = value {
3547            pm: Some(pm.clone()),
3548            arr: Vec::new(),
3549            scanflags: 0,
3550            valflags: 0,
3551            start: 0,
3552            end: -1,
3553        };
3554        let pmflags = pm.node.flags;
3555        let isvar_at = name == "@";
3556        if PM_TYPE(pmflags as u32) & (PM_ARRAY | PM_HASHED) != 0 {
3557            // c:2274-2280 — scanflags overload for hashed arrays.
3558            let mut sf = scanflags;
3559            if isvar_at {
3560                sf |= SCANPM_ISVAR_AT as i32;
3561            }
3562            if sf == 0 {
3563                sf = SCANPM_ARRONLY as i32;
3564            }
3565            v.scanflags = sf;
3566        }
3567        // c:2289-2293 — bracket-subscript dispatch. When the unparsed
3568        // remainder starts with `[` (or the lexer's `Inbrack` token),
3569        // hand off to `getindex` which fills `v.start`/`v.end`/
3570        // `v.scanflags` and advances `pptr`.
3571        if bracks > 0 && (pptr.starts_with('[') || pptr.starts_with(Inbrack)) {
3572            if getindex(pptr, v, scanflags) != 0 {
3573                // c:2290
3574                return Some(v); // c:2292
3575            }
3576        } else if (scanflags & SCANPM_ASSIGNING as i32) == 0 && v.scanflags != 0 && isset(KSHARRAYS)
3577        {
3578            // c:2294-2296 — KSHARRAYS implicit `[0]` for bare arr.
3579            v.end = 1;
3580            v.scanflags = 0;
3581        } else {
3582        }
3583        return Some(v);
3584    }
3585    None
3586}
3587
3588/// Port of `getstrvalue(Value v)` from `Src/params.c:2335`.
3589/// Full C body dispatches on `PM_TYPE(v->pm->node.flags)`:
3590/// PM_HASHED (KSH path: `[0]` index lookup), PM_ARRAY (sepjoin
3591/// when v->scanflags else `ss[v->start]`), PM_INTEGER (`convbase`),
3592/// PM_EFLOAT|PM_FFLOAT (`convfloat`), PM_SCALAR|PM_NAMEREF
3593/// (`pm->gsu.s->getfn(pm)`). Then PM_LEFT/PM_RIGHT_B/PM_RIGHT_Z
3594/// padding when VALFLAG_SUBST is set.
3595pub fn getstrvalue(v: Option<&mut value>) -> String {
3596    let v = match v {
3597        Some(v) => v,
3598        None => return String::new(),
3599    };
3600    // c:2344-2348 — `if (VALFLAG_INV && !PM_HASHED) return sprintf("%d", v->start)`.
3601    if (v.valflags & VALFLAG_INV) != 0 {
3602        let hashed =
3603            v.pm.as_ref()
3604                .map(|p| (p.node.flags as u32 & PM_HASHED) != 0)
3605                .unwrap_or(false);
3606        if !hashed {
3607            return v.start.to_string();
3608        }
3609    }
3610    let pm = match v.pm.as_mut() {
3611        Some(p) => p,
3612        None => return String::new(),
3613    };
3614    let t = PM_TYPE(pm.node.flags as u32);
3615    let pmflags = pm.node.flags as u32;
3616
3617    // c:2350-2370 — PM_TYPE dispatch.
3618    let mut s: String = if t == PM_HASHED && v.scanflags == 0 && EMULATION(EMULATE_KSH) {
3619        // c:Src/params.c:2351-2358 — `case PM_HASHED: if (!v->scanflags &&
3620        // EMULATION(EMULATE_KSH))` — a bare `$assoc` (no subscript) under
3621        // KSH EMULATION is `${assoc[0]}`: it builds `s = "[0]"`, does a
3622        // KEY-"0" `getindex`, and returns that element's value. So it is
3623        // EMPTY unless the hash actually holds a key "0". This is
3624        // emulation-gated, NOT KSHARRAYS-option-gated: `emulate -L ksh;
3625        // typeset -A h=(a 1 b 2); print $h` is empty, whereas `setopt
3626        // ksharrays; …; print $h` falls through below to the first bucket
3627        // value `1`. (`setopt ksharrays` keeps v->scanflags non-zero for a
3628        // bare assoc, so it never reaches this arm; the `scanflags==0`
3629        // guard mirrors the C `!v->scanflags` "impossible unless emulating
3630        // ksh" invariant.)
3631        paramtab_hashed_storage()
3632            .lock()
3633            .ok()
3634            .and_then(|store| store.get(&pm.node.nam).and_then(|m| m.get("0").cloned()))
3635            .unwrap_or_default()
3636    } else if t == PM_HASHED || t == PM_ARRAY {
3637        // c:2359-2369 (PM_ARRAY, and the ksh-emulation PM_HASHED fall-through)
3638        let arr = arrgetfn(pm);
3639        if v.scanflags != 0 {
3640            // c:2361
3641            arr.join(" ")
3642        } else {
3643            let mut start = v.start;
3644            if start < 0 {
3645                start += arr.len() as i32;
3646            } // c:2364
3647            if start < 0 || (start as usize) >= arr.len() {
3648                // c:2365-2366
3649                String::new()
3650            } else {
3651                arr[start as usize].clone()
3652            }
3653        }
3654    } else if t == PM_INTEGER {
3655        // c:2371
3656        // c:2373 — `convbase(buf, pm->gsu.i->getfn(pm), pm->base)`.
3657        // The previous Rust port used `intgetfn(pm).to_string()` (naked
3658        // base-10). With `convbase` now ported (params.rs:6577), honor
3659        // `pm.base` so `typeset -i 16 x=255` renders as `0xff` rather
3660        // than `255` per zsh's `$x`-expansion + `typeset -p`.
3661        convbase_underscore(
3662            intgetfn(pm),
3663            if pm.base > 0 { pm.base } else { 10 }, // c:2373 pm->base
3664            pm.width,                               // c:2373 pm->width for underscore grouping
3665        )
3666    } else if t == PM_EFLOAT || t == PM_FFLOAT {
3667        // c:2366-2370 — `convfloat(getfn(pm), pm->base, pm->node.flags,
3668        // NULL)`. The format flag (PM_EFLOAT → e-notation, PM_FFLOAT →
3669        // fixed) and the precision (`pm->base` holds digits for floats)
3670        // BOTH come straight from the param. The previous Rust port used
3671        // `convfloat_underscore(floatgetfn(pm), pm.width)`, which drops
3672        // the format flag (convfloat_underscore calls `convfloat(d,0,0)`
3673        // → 'g' default) and wrongly applies width grouping that C does
3674        // NOT do here (underscore grouping happens in typeset -p output
3675        // c:2866, not getstrvalue). That made getstrvalue render a
3676        // default `float f=2.5` as "2.5" instead of "2.500000000e+00",
3677        // surfacing when `assignaparam`'s AUGMENT prepend (c:3350) routes
3678        // an old float value through getstrvalue.
3679        convfloat(floatgetfn(pm), pm.base, pm.node.flags as u32)
3680    } else if t == PM_SCALAR || t == PM_NAMEREF {
3681        // c:2380
3682        strgetfn(pm)
3683    } else {
3684        // c:2384
3685        DPUTS!(true, "BUG: param node without valid type"); // c:2385
3686        String::new() // c:2386 s = "" (line c:2384)
3687    };
3688
3689    // c:2390-2538 — VALFLAG_SUBST padding (PM_LEFT / PM_RIGHT_B /
3690    // PM_RIGHT_Z). Multibyte is approximated via `chars().count()`
3691    // (codepoint count) since the Rust port stores strings as
3692    // UTF-8 rather than the C meta-byte encoding.
3693    if v.valflags & VALFLAG_SUBST != 0 {
3694        let pad_flags = pmflags & (PM_LEFT | PM_RIGHT_B | PM_RIGHT_Z);
3695        if pad_flags != 0 {
3696            let fwidth = if pm.width > 0 {
3697                pm.width as usize
3698            } else {
3699                s.chars().count()
3700            };
3701            if pad_flags == PM_LEFT || pad_flags == (PM_LEFT | PM_RIGHT_Z) {
3702                // c:2393-2424 — left-justify: optional zero/blank trim,
3703                // truncate to fwidth, right-pad with spaces.
3704                let trimmed: &str = if pad_flags & PM_RIGHT_Z != 0 {
3705                    s.trim_start_matches('0')
3706                } else {
3707                    s.trim_start_matches(|c: char| c == ' ' || c == '\t')
3708                };
3709                let len = trimmed.chars().count();
3710                let take = len.min(fwidth);
3711                let mut out: String = trimmed.chars().take(take).collect();
3712                if fwidth > take {
3713                    out.extend(std::iter::repeat(' ').take(fwidth - take));
3714                }
3715                s = out;
3716            } else if pad_flags & (PM_RIGHT_B | PM_RIGHT_Z) != 0 {
3717                // c:2426-2510 — right-justify with optional zero-padding
3718                // honouring leading-blank/minus/0x/base# prefix
3719                // detection for numeric values.
3720                let charlen = s.chars().count();
3721                if charlen < fwidth {
3722                    let mut zero = true;
3723                    let mut valprefend: usize = 0;
3724                    let numeric_pm = (pmflags & (PM_INTEGER | PM_EFLOAT | PM_FFLOAT)) != 0;
3725                    if pad_flags & PM_RIGHT_Z != 0 {
3726                        // c:2446-2466 — find the prefix to keep
3727                        // (blanks → minus → 0x / base#).
3728                        let bytes = s.as_bytes();
3729                        let mut t = 0usize;
3730                        while t < bytes.len() && (bytes[t] == b' ' || bytes[t] == b'\t') {
3731                            t += 1; // c:2446-2447
3732                        }
3733                        if numeric_pm && t < bytes.len() && bytes[t] == b'-' {
3734                            t += 1; // c:2454-2455
3735                        }
3736                        if (pmflags & PM_INTEGER) != 0 {
3737                            let cbases = optlookup("cbases") > 0;
3738                            if cbases
3739                                && t + 1 < bytes.len()
3740                                && bytes[t] == b'0'
3741                                && bytes[t + 1] == b'x'
3742                            {
3743                                t += 2; // c:2462-2463
3744                            } else if let Some(hash_off) =
3745                                bytes[t..].iter().position(|&b| b == b'#')
3746                            {
3747                                t += hash_off + 1; // c:2464-2465
3748                            }
3749                        }
3750                        valprefend = t;
3751                        if t == bytes.len() {
3752                            zero = false; // c:2468-2469
3753                        } else if !numeric_pm && !bytes[t].is_ascii_digit() {
3754                            zero = false; // c:2473-2474
3755                        }
3756                    }
3757                    // c:2483 — pad char picks: ' ' if PM_RIGHT_B or
3758                    // numeric-prefix detection failed, else '0'.
3759                    let pad_char = if (pad_flags & PM_RIGHT_B) != 0 || !zero {
3760                        ' '
3761                    } else {
3762                        '0'
3763                    };
3764                    let need = fwidth - charlen;
3765                    let prefix = &s[..valprefend];
3766                    let rest = &s[valprefend..];
3767                    let mut out = String::with_capacity(need + s.len());
3768                    out.push_str(prefix); // c:2491
3769                    out.extend(std::iter::repeat(pad_char).take(need)); // c:2483-2485
3770                    out.push_str(rest); // c:2492-2493
3771                    s = out;
3772                } else if charlen > fwidth {
3773                    // c:2496-2500 — truncate from the front to fit fwidth
3774                    // codepoints (C uses MB_METACHARLEN; Rust uses chars).
3775                    let skip = charlen - fwidth;
3776                    s = s.chars().skip(skip).collect();
3777                }
3778            }
3779        }
3780    }
3781
3782    s
3783}
3784
3785/// Slice an indexed array using zsh 1-based inclusive semantics.
3786/// Port of `getarrvalue(Value v)` from Src/params.c:2548 — the slice
3787/// branch that resolves the start/end pair into a Vec. Negative
3788/// indices count from the end (`-1` is the last element);
3789/// out-of-range bounds collapse to empty (`${a[5,10]}` on len=3
3790/// returns empty, not clamped); `start > end` returns empty.
3791///
3792/// 0 has asymmetric meaning per C source's getarrvalue:
3793///   start=0 → "before first element" → resolved to 1
3794///   end=0   → "before first element" → empty slice
3795/// WARNING: param names don't match C — Rust=(arr, start, end) vs C=(v)
3796pub fn getarrvalue(arr: &[String], start: i64, end: i64) -> Vec<String> {
3797    let len = arr.len() as i64;
3798    // c:Src/params.c:2570-2585 getarrvalue — a slice whose START is at or
3799    // beyond the array end does NOT collapse to an empty array; it returns
3800    // EMPTY-STRING padding from the global `nular` (a one-element `{"", NULL}`
3801    // array), so the result is capped at a SINGLE empty element. The C
3802    // branch order (after `v->start += arrlen` / `v->end += arrlen + 1` for
3803    // negatives) is: `v->end <= v->start` → 0 elems; else `v->start < 0` → 1
3804    // elem; else `arrlen_le(s, v->start)` → `v->end - (v->start + 1)` elems
3805    // (capped by nular's length to ≤ 1). Only observable via `${#}` (counts
3806    // the empty element → `${#arr[1,2]}` is 1 for `arr=()`) and quoted splat;
3807    // unquoted array-capture elides the empty word. Mirror it here so the
3808    // out-of-range cases that previously returned `[]` pad correctly.
3809    let nular_pad = || -> Vec<String> {
3810        let v_start0 = start - 1; // C v->start is 0-based (subscript - 1)
3811        let v_end = if end < 0 { end + len + 1 } else { end }; // c:2565
3812        let v_start = if v_start0 < 0 { v_start0 + len } else { v_start0 }; // c:2563
3813        if v_end <= v_start {
3814            Vec::new() // c:2578 (empty/inverted range)
3815        } else if v_start < 0 {
3816            vec![String::new()] // c:2580 (arrdup_max(nular, 1))
3817        } else if v_start >= len {
3818            // c:2582 — arrdup_max(nular, v->end - (v->start + 1)); nular has a
3819            // single element, so the count is capped at 1.
3820            if v_end - (v_start + 1) >= 1 {
3821                vec![String::new()]
3822            } else {
3823                Vec::new()
3824            }
3825        } else {
3826            Vec::new()
3827        }
3828    };
3829    if len == 0 {
3830        return nular_pad();
3831    }
3832    // Out-of-range starts (positive past len, or negative below
3833    // -len) return nular padding per Src/params.c getarrvalue's
3834    // slice-resolution branches (c:2570-2585).
3835    if start > len {
3836        return nular_pad();
3837    }
3838    if end < 0 && (len + end + 1) < 1 {
3839        return nular_pad();
3840    }
3841    if start < 0 && end < 0 && start > end {
3842        return nular_pad();
3843    }
3844    if start < 0 && start < -len {
3845        return nular_pad();
3846    }
3847    let resolve_start = |i: i64| -> i64 {
3848        if i < 0 {
3849            (len + i + 1).max(1)
3850        } else if i == 0 {
3851            1
3852        } else {
3853            i.min(len)
3854        }
3855    };
3856    let resolve_end = |i: i64| -> i64 {
3857        if i < 0 {
3858            (len + i + 1).max(0)
3859        } else if i == 0 {
3860            0
3861        } else {
3862            i.min(len)
3863        }
3864    };
3865    let s = resolve_start(start);
3866    let e = resolve_end(end);
3867    if e < 1 || s > e {
3868        return Vec::new();
3869    }
3870    let s_idx = (s - 1) as usize;
3871    let e_idx = e as usize;
3872    arr[s_idx..e_idx.min(arr.len())].to_vec()
3873}
3874
3875// ---------------------------------------------------------------------------
3876// Parameter table
3877// ---------------------------------------------------------------------------
3878
3879/// Parameter table.
3880/// Port of the `paramtab` HashTable Src/params.c maintains —
3881/// `createparamtable()` (line 817) initializes it with all the
3882/// IPDEF*-declared special params; `createparam()` (line 1030)
3883/// adds user variables.
3884// ---------------------------------------------------------------------------
3885// Free functions matching the C API
3886// ---------------------------------------------------------------------------
3887
3888/// Port of `getintvalue(Value v)` from `Src/params.c:2601`.
3889/// C body:
3890/// ```c
3891/// if (!v) return 0;
3892/// if (v->valflags & VALFLAG_INV) return v->start;
3893/// if (v->scanflags) {
3894///     char **arr = getarrvalue(v);
3895///     if (arr) { char *scal = sepjoin(arr, NULL, 1); return mathevali(scal); }
3896///     return 0;
3897/// }
3898/// if (PM_TYPE(v->pm->node.flags) == PM_INTEGER)
3899///     return v->pm->gsu.i->getfn(v->pm);
3900/// if (v->pm->node.flags & (PM_EFLOAT|PM_FFLOAT))
3901///     return (zlong)v->pm->gsu.f->getfn(v->pm);
3902/// return mathevali(getstrvalue(v));
3903/// ```
3904pub fn getintvalue(v: Option<&mut value>) -> i64 {
3905    let v = match v {
3906        Some(v) => v,
3907        None => return 0,
3908    };
3909    if (v.valflags & VALFLAG_INV) != 0 {
3910        return v.start as i64;
3911    }
3912    if v.scanflags != 0 {
3913        // sepjoin(arr, NULL, 1) → mathevali(scal); arr backend missing.
3914        return 0;
3915    }
3916    let pm = match v.pm.as_mut() {
3917        Some(p) => p,
3918        None => return 0,
3919    };
3920    if PM_TYPE(pm.node.flags as u32) == PM_INTEGER {
3921        return intgetfn(pm);
3922    }
3923    if (pm.node.flags as u32 & (PM_EFLOAT | PM_FFLOAT)) != 0 {
3924        return floatgetfn(pm) as i64;
3925    }
3926    // c:2618 — `return mathevali(getstrvalue(v));`. The previous
3927    // Rust port used `s.parse::<i64>().unwrap_or(0)` which silently
3928    // returned 0 for any non-trivial arithmetic on the scalar
3929    // value side (e.g. `typeset x="1+2"; ((y = x))` would yield
3930    // y=0 instead of 3). Route through `math::mathevali` to
3931    // match C's arithmetic-expression evaluation.
3932    let pm = v.pm.as_mut().unwrap();
3933    let s = strgetfn(pm);
3934    mathevali(&s).unwrap_or(0) // c:2618 mathevali(...)
3935}
3936
3937/// Port of `getnumvalue(Value v)` from `Src/params.c:2624`. Returns an
3938/// `mnumber` (tagged int/float). C body dispatches on `valflags &
3939/// VALFLAG_INV` (returns start as int), `scanflags` (sepjoin →
3940/// matheval), then PM_TYPE: PM_INTEGER → mn.l = pm->gsu.i->getfn,
3941/// PM_EFLOAT|PM_FFLOAT → mn.type=MN_FLOAT; mn.d = pm->gsu.f->getfn,
3942/// else matheval(getstrvalue(v)).
3943pub fn getnumvalue(v: Option<&mut value>) -> mnumber {
3944    let v = match v {
3945        Some(v) => v,
3946        None => {
3947            return mnumber {
3948                l: 0,
3949                d: 0.0,
3950                type_: MN_INTEGER,
3951            }
3952        }
3953    };
3954    if (v.valflags & VALFLAG_INV) != 0 {
3955        return mnumber {
3956            l: v.start as i64,
3957            d: 0.0,
3958            type_: MN_INTEGER,
3959        };
3960    }
3961    if v.scanflags != 0 {
3962        return mnumber {
3963            l: 0,
3964            d: 0.0,
3965            type_: MN_INTEGER,
3966        };
3967    }
3968    let pm = match v.pm.as_mut() {
3969        Some(p) => p,
3970        None => {
3971            return mnumber {
3972                l: 0,
3973                d: 0.0,
3974                type_: MN_INTEGER,
3975            }
3976        }
3977    };
3978    let t = PM_TYPE(pm.node.flags as u32);
3979    if t == PM_INTEGER {
3980        return mnumber {
3981            l: intgetfn(pm),
3982            d: 0.0,
3983            type_: MN_INTEGER,
3984        };
3985    }
3986    if t == PM_EFLOAT || t == PM_FFLOAT {
3987        return mnumber {
3988            l: 0,
3989            d: floatgetfn(pm),
3990            type_: MN_FLOAT,
3991        };
3992    }
3993    // c:2640 — `return matheval(getstrvalue(v));`. The previous
3994    // Rust port used `parse::<i64>()` / `parse::<f64>()` directly
3995    // on the scalar string, which silently failed for any non-
3996    // trivial arithmetic. Route through `math::matheval` to match
3997    // C's arithmetic-expression evaluation; matheval returns an
3998    // mnumber tag matching the C output type.
3999    let s = strgetfn(pm);
4000    matheval(&s) // c:2640 matheval(...)
4001        .unwrap_or(mnumber {
4002            l: 0,
4003            d: 0.0,
4004            type_: MN_INTEGER,
4005        })
4006}
4007
4008/// Port of `export_param(Param pm)` from `Src/params.c:2653`.
4009///
4010/// C body converts `pm`'s value to its scalar form per `PM_TYPE`:
4011///   PM_INTEGER:        convbase(buf, getfn, pm->base)
4012///   PM_EFLOAT/FFLOAT:  convfloat(getfn, pm->base, pm->node.flags, NULL)
4013///   PM_SCALAR/etc.:    gsu.s->getfn(pm)
4014/// Then calls `addenv(pm, val)`. PM_ARRAY/PM_HASHED early-return.
4015///
4016/// The previous Rust port used `format!("{}", intgetfn(pm))` for
4017/// integers and `format!("{}", floatgetfn(pm))` for floats — Rust's
4018/// DEFAULT formatting. C uses convbase/convfloat which respect
4019/// `pm.base` and `pm.flags`:
4020///   - `typeset -i16 x=255; export x` should put "16#FF" in the
4021///     env (per pm.base==16). The previous Rust port wrote "255".
4022///   - `typeset -F3 y=3.14; export y` should put "3.140" (per
4023///     pm.base==3 precision + PM_FFLOAT flag). Rust wrote "3.14".
4024///
4025/// Both formatter ports exist (`params::convbase`, `utils::convfloat`).
4026/// Wire them so the env-side representation matches C.
4027pub fn export_param(pm: &mut param) {
4028    // c:2653
4029    let t = PM_TYPE(pm.node.flags as u32);
4030    if (t & (PM_ARRAY | PM_HASHED)) != 0 {
4031        // c:2659 array/hash skip
4032        return;
4033    }
4034    let val: String = if t == PM_INTEGER {
4035        // c:2664 — `convbase(buf, pm->gsu.i->getfn(pm), pm->base)`.
4036        let base = if pm.base > 0 { pm.base } else { 10 };
4037        convbase(intgetfn(pm), base as u32) // c:2664
4038    } else if (pm.node.flags as u32 & (PM_EFLOAT | PM_FFLOAT)) != 0 {
4039        // c:2668 — `convfloat(pm->gsu.f->getfn(pm), pm->base,
4040        //                     pm->node.flags, NULL)`.
4041        convfloat(floatgetfn(pm), pm.base, pm.node.flags as u32)
4042        // c:2668
4043    } else {
4044        strgetfn(pm)
4045    };
4046    addenv(&pm.node.nam, &val);
4047    pm.env = Some(val);
4048}
4049
4050/// Port of `setstrvalue(Value v, char *val)` from `Src/params.c:2685`. C body is a
4051/// one-liner: `assignstrvalue(v, val, 0);` — the real workhorse
4052/// is `assignstrvalue` (params.c:2692).
4053pub fn setstrvalue(v: Option<&mut value>, val: &str) {
4054    assignstrvalue(v, Some(val.to_string()), 0);
4055}
4056
4057/// 1:1 port of the C body covering: EXECOPT short-circuit,
4058/// PM_READONLY/PM_HASHED/VALFLAG_EMPTY guards, PM_UNSET clear,
4059/// per-PM_TYPE dispatch including the SCALAR/NAMEREF subscript
4060/// splice (KSHARRAYS-aware index normalization, MULTIBYTE end
4061/// adjust, full-string overwrite vs in-place memcpy fast path,
4062/// AUTONAMEDIRS/PM_NAMEDDIR re-registration), PM_INTEGER (with
4063/// ASSPM_ENV_IMPORT → `zstrtol_underscore`, else `mathevali`,
4064/// `lastbase` propagation), PM_EFLOAT/PM_FFLOAT (env vs `matheval`,
4065/// MN_FLOAT/MN_INTEGER coercion), PM_ARRAY (single-element wrap
4066/// via `setarrvalue`), PM_HASHED (`foundparam` indirection); then
4067/// `setscope(pm)`, errflag/env/ALLEXPORT/PM_ARRAY/ename gate, and
4068/// `export_param`. Width tracking for PM_LEFT/PM_RIGHT_B/PM_RIGHT_Z
4069/// preserved.
4070/// Port of `assignstrvalue(Value v, char *val, int flags)` from `Src/params.c:2692`.
4071pub fn assignstrvalue(v: Option<&mut value>, val: Option<String>, flags: i32) {
4072    if unset(EXECOPT) {
4073        return;
4074    }
4075
4076    let v = match v {
4077        Some(v) => v,
4078        None => return,
4079    };
4080    let pm = match v.pm.as_mut() {
4081        Some(p) => p,
4082        None => return,
4083    };
4084
4085    if (pm.node.flags as u32 & PM_READONLY) != 0 {
4086        // c:2701 — `zerr("read-only variable: %s", pm->node.nam)`.
4087        zerr(&format!("read-only variable: {}", pm.node.nam)); // c:2701
4088        return;
4089    }
4090    if (pm.node.flags as u32 & PM_HASHED) != 0
4091        && (v.scanflags as u32 & (SCANPM_MATCHMANY | SCANPM_ARRONLY)) != 0
4092    {
4093        // c:2706 — `zerr("%s: attempt to set slice of associative array", ...)`.
4094        zerr(&format!(
4095            "{}: attempt to set slice of associative array",
4096            pm.node.nam
4097        )); // c:2706
4098        return;
4099    }
4100    if (v.valflags & VALFLAG_EMPTY) != 0 {
4101        // c:2710 — `zerr("%s: assignment to invalid subscript range", ...)`.
4102        zerr(&format!(
4103            "{}: assignment to invalid subscript range",
4104            pm.node.nam
4105        )); // c:2710
4106        return;
4107    }
4108    pm.node.flags &= !(PM_UNSET as i32);
4109
4110    let mut val = val;
4111    match PM_TYPE(pm.node.flags as u32) {
4112        t if t == PM_SCALAR || t == PM_NAMEREF => {
4113            let mut v_str = val.take().unwrap_or_default();
4114            // c:Src/params.c — PM_LOWER / PM_UPPER case fold on
4115            // assignment. zsh applies these flags both when writing
4116            // the in-memory scalar and when exporting to env; the
4117            // copyenvstr path handles the export side, but the
4118            // scalar set path also needs to fold so `echo $X`
4119            // reads the lowercased value. Without this, `typeset -l
4120            // X; X=MixedCase; echo $X` printed "MixedCase".
4121            let pf = pm.node.flags as u32;
4122            if pf & PM_LOWER != 0 {
4123                v_str = v_str.to_ascii_lowercase();
4124            } else if pf & PM_UPPER != 0 {
4125                v_str = v_str.to_ascii_uppercase();
4126            }
4127            if v.start == 0 && v.end == -1 {
4128                // c:2748 — `v->pm->gsu.s->setfn(v->pm, val);`. C
4129                // dispatches through the param's GSU vtable so
4130                // PM_SPECIAL params route to their canonical setfn
4131                // (homesetfn, ifssetfn, ...). The Rust port stores
4132                // the vtable on `pm.gsu_s` (set in `createparamtable`
4133                // via `gsu_scalar_for_special`); when set, dispatch
4134                // through it. When unset, fall back to the default
4135                // `strsetfn` path (C's stdscalar_gsu.setfn).
4136                // c:2742-2746 — ASSPM_AUGMENT: prepend the existing
4137                // value before storing. Without this, `PATH+=":/foo"`
4138                // would replace PATH instead of appending.
4139                let final_str = if (flags & ASSPM_AUGMENT) != 0 {
4140                    let prev = pm.u_str.clone().unwrap_or_default();
4141                    format!("{}{}", prev, v_str)
4142                } else {
4143                    v_str
4144                };
4145                let len = final_str.len();
4146                let setfn_ptr = pm.gsu_s.as_ref().map(|g| g.setfn);
4147                if let Some(setfn) = setfn_ptr {
4148                    setfn(pm, final_str); // c:2748
4149                } else {
4150                    strsetfn(pm, final_str); // c:2748 (default)
4151                }
4152                if (pm.node.flags as u32 & (PM_LEFT | PM_RIGHT_B | PM_RIGHT_Z)) != 0
4153                    && pm.width == 0
4154                {
4155                    pm.width = len as i32;
4156                }
4157            } else {
4158                // Subscript splice (c:Src/params.c:2748+ assignstrvalue). In C
4159                // `v->start`/`v->end` are BYTE offsets into the metafied string
4160                // (getindex already converted the character subscript to a byte
4161                // offset via MB_METACHARLEN). zshrs's subscript resolution
4162                // instead yields CHARACTER positions, so operate on the char
4163                // sequence throughout — otherwise a multibyte scalar value
4164                // (e.g. a Powerline glyph U+E0B0, 3 bytes) panics when the byte
4165                // slice `z[..start]` lands inside a codepoint. p10k's
4166                // `_p9k_get_icon` hit exactly this (crashing the shell).
4167                let z = strgetfn(pm);
4168                let z_chars: Vec<char> = z.chars().collect();
4169                let zlen = z_chars.len() as i32;
4170                let mut start = v.start;
4171                let mut end = v.end;
4172                if (v.valflags & VALFLAG_INV) != 0 && !isset(KSHARRAYS) {
4173                    start -= 1;
4174                    end -= 1;
4175                }
4176                if start < 0 {
4177                    start += zlen;
4178                    if start < 0 {
4179                        start = 0;
4180                    }
4181                }
4182                if start > zlen {
4183                    start = zlen;
4184                }
4185                if end < 0 {
4186                    end += zlen;
4187                    if end < 0 {
4188                        end = 0;
4189                    } else if end >= zlen {
4190                        end = zlen;
4191                    } else {
4192                        // MULTIBYTE branch: increment by metachar length;
4193                        // single-byte path increments by 1.
4194                        end += 1;
4195                    }
4196                } else if end > zlen {
4197                    end = zlen;
4198                }
4199                // Slice by CHARACTER index into z_chars (never a raw byte
4200                // slice of z, which would split a multibyte codepoint).
4201                let s = (start.max(0) as usize).min(z_chars.len());
4202                let e = (end.max(0) as usize).min(z_chars.len());
4203                let mut x = String::with_capacity(z.len() + v_str.len());
4204                x.extend(z_chars[..s].iter());
4205                x.push_str(&v_str);
4206                if e <= z_chars.len() {
4207                    x.extend(z_chars[e..].iter());
4208                }
4209                strsetfn(pm, x);
4210                if (pm.node.flags as u32 & PM_HASHELEM) == 0
4211                    && ((pm.node.flags as u32 & PM_NAMEDDIR) != 0 || isset(AUTONAMEDIRS))
4212                {
4213                    pm.node.flags |= PM_NAMEDDIR as i32;
4214                    // adduserdir(pm.node.nam, &z, 0, 0); -- userdirs not ported
4215                }
4216            }
4217        }
4218        t if t == PM_INTEGER => {
4219            if let Some(ref s) = val {
4220                let ival: i64 = if (flags & ASSPM_ENV_IMPORT) != 0 {
4221                    s.parse::<i64>().unwrap_or(0)
4222                } else {
4223                    // c:Src/params.c:2774 — `mathevali(val)`. C's
4224                    // matheval calls zerr (Src/math.c:1462+) on parse
4225                    // failures, which sets `errflag |= ERRFLAG_ERROR`
4226                    // and the caller propagates the abort. The Rust
4227                    // port returns Result<i64,String>; `unwrap_or(0)`
4228                    // silently swallowed "operator expected at 'def'"
4229                    // and stored 0 instead. Bug #75 in docs/BUGS.md.
4230                    //
4231                    // Mirror the C semantic: surface the error via
4232                    // zerr (which sets errflag), then fall back to 0
4233                    // for the stored value (C also stores whatever the
4234                    // partial parse computed, which is typically 0).
4235                    match mathevali(s) {
4236                        Ok(v) => v,
4237                        Err(msg) => {
4238                            zerr(&msg);
4239                            0
4240                        }
4241                    }
4242                };
4243                // c:2775-2778 — `if (flags & ASSPM_AUGMENT) pm->u.val += val.l;
4244                //                else pm->u.val = val.l;`. The augment
4245                // path is what makes `integer x=42; x+=8` store 50 instead
4246                // of 8. Without this the integer `+=` operator silently
4247                // replaced.
4248                let final_val = if (flags & ASSPM_AUGMENT) != 0 {
4249                    pm.u_val.wrapping_add(ival)
4250                } else {
4251                    ival
4252                };
4253                intsetfn(pm, final_val);
4254                if (pm.node.flags as u32 & (PM_LEFT | PM_RIGHT_B | PM_RIGHT_Z)) != 0
4255                    && pm.width == 0
4256                {
4257                    pm.width = s.len() as i32;
4258                }
4259                if pm.base == 0 {
4260                    let lb = lastbase();
4261                    if lb != -1 {
4262                        pm.base = lb;
4263                    }
4264                }
4265            }
4266        }
4267        t if t == PM_EFLOAT || t == PM_FFLOAT => {
4268            if let Some(ref s) = val {
4269                let mn = if (flags & ASSPM_ENV_IMPORT) != 0 {
4270                    mnumber {
4271                        l: 0,
4272                        d: s.parse::<f64>().unwrap_or(0.0),
4273                        type_: MN_FLOAT,
4274                    }
4275                } else {
4276                    // c:Src/params.c — float assignment runs the RHS
4277                    // through matheval; on parse failure, zsh emits the
4278                    // engine's diagnostic ("bad math expression: operator
4279                    // expected at `abc'") via zerr. Mirror the integer
4280                    // arm above which already calls zerr on Err. Bug
4281                    // #506 — zshrs previously swallowed the error and
4282                    // stored 0.0 silently for `float f="3.14abc"`.
4283                    match matheval(s) {
4284                        Ok(v) => v,
4285                        Err(msg) => {
4286                            zerr(&msg);
4287                            mnumber {
4288                                l: 0,
4289                                d: 0.0,
4290                                type_: MN_FLOAT,
4291                            }
4292                        }
4293                    }
4294                };
4295                let d = if (mn.type_ & MN_FLOAT) != 0 {
4296                    mn.d
4297                } else {
4298                    mn.l as f64
4299                };
4300                // c:2775-2778 — ASSPM_AUGMENT path: `float x=1.5; x+=0.25`
4301                // adds 0.25 to the current u_dval rather than replacing.
4302                // Mirrors the integer-augment block above; without it
4303                // `+=` was a plain `=`.
4304                let final_d = if (flags & ASSPM_AUGMENT) != 0 {
4305                    pm.u_dval + d
4306                } else {
4307                    d
4308                };
4309                floatsetfn(pm, final_d);
4310                if (pm.node.flags as u32 & (PM_LEFT | PM_RIGHT_B | PM_RIGHT_Z)) != 0
4311                    && pm.width == 0
4312                {
4313                    pm.width = s.len() as i32;
4314                }
4315            }
4316        }
4317        t if t == PM_ARRAY => {
4318            // c:2826-2828 — `char **ss = zalloc(2*sizeof(char*));
4319            // ss[0]=val; ss[1]=NULL; setarrvalue(v, ss);` — wrap the
4320            // single value in a 1-element array. The C-faithful
4321            // setarrvalue takes &mut Value; we already hold a &mut
4322            // borrow of pm from v.pm.as_mut() higher up, so inline
4323            // the dispatch directly against pm here to avoid the
4324            // double-borrow.
4325            let one = vec![val.take().unwrap_or_default()];
4326            if v.start == 0 && v.end == -1 {
4327                if isset(KSHARRAYS) {
4328                    // c:Src/params.c getvalue — under KSHARRAYS a bare
4329                    // array name addresses element 0 (ksh semantics), so
4330                    // a SCALAR assignment `a=X` to an existing array sets
4331                    // the FIRST element keeping the rest (a=(X second)),
4332                    // not a whole-array replace. The non-KSHARRAYS path
4333                    // already reset the array to a scalar before reaching
4334                    // here (assignsparam c:3179-3192, gated on
4335                    // unset(KSHARRAYS)); only the KSHARRAYS case arrives
4336                    // with the param still PM_ARRAY.
4337                    let arr = pm.u_arr.get_or_insert_with(Vec::new);
4338                    let first = one.into_iter().next().unwrap_or_default();
4339                    if arr.is_empty() {
4340                        arr.push(first);
4341                    } else {
4342                        arr[0] = first;
4343                    }
4344                } else {
4345                    // c:2922 — full replace.
4346                    pm.u_arr = Some(one);
4347                }
4348            } else {
4349                // c:2933+ — slice splice path with bounds adjust.
4350                let arr = pm.u_arr.get_or_insert_with(Vec::new);
4351                let len = arr.len() as i64;
4352                let start_raw = v.start as i64;
4353                let end_raw = v.end as i64;
4354                let start = if start_raw < 0 {
4355                    (len + start_raw + 1).max(0)
4356                } else {
4357                    start_raw
4358                };
4359                let end = if end_raw < 0 {
4360                    (len + end_raw + 1).max(0)
4361                } else {
4362                    end_raw
4363                };
4364                let start_idx = (start.max(1) - 1) as usize;
4365                let end_idx = end.max(0) as usize;
4366                while arr.len() < start_idx {
4367                    arr.push(String::new());
4368                }
4369                let end_idx = end_idx.min(arr.len());
4370                if start_idx <= end_idx {
4371                    arr.splice(start_idx..end_idx, one);
4372                } else {
4373                    for (i, x) in one.into_iter().enumerate() {
4374                        if start_idx + i < arr.len() {
4375                            arr[start_idx + i] = x;
4376                        } else {
4377                            arr.push(x);
4378                        }
4379                    }
4380                }
4381            }
4382        }
4383        t if t == PM_HASHED => {
4384            // Element-assignment path: the C source does
4385            // `setstrvalue(&((Param)foundparam)->u, val)` to update the
4386            // member found by an earlier `scanparamvals` lookup.
4387            if let Some(nam) = foundparam() {
4388                if let Some(ref h) = pm.u_hash {
4389                    let _ = (nam, h);
4390                }
4391            }
4392            set_foundparam(None);
4393        }
4394        _ => {}
4395    }
4396    setscope(pm);
4397    if errflag.load(Ordering::Relaxed) != 0
4398        || ((pm.env.is_none()
4399            && (pm.node.flags as u32 & PM_EXPORTED) == 0
4400            && !(isset(ALLEXPORT) && (pm.node.flags as u32 & PM_HASHELEM) == 0))
4401            || (pm.node.flags as u32 & PM_ARRAY) != 0
4402            || pm.ename.is_some())
4403    {
4404        return;
4405    }
4406    export_param(pm);
4407}
4408
4409/// Port of `setnumvalue(Value v, mnumber val)` from `Src/params.c:2856`. C body
4410/// dispatches on `PM_TYPE(v->pm->node.flags)`:
4411/// PM_SCALAR/PM_NAMEREF/PM_ARRAY → convbase_underscore /
4412/// convfloat_underscore + setstrvalue; PM_INTEGER →
4413/// `pm->gsu.i->setfn(pm, val.u.l)`; PM_EFLOAT|PM_FFLOAT →
4414/// `pm->gsu.f->setfn(pm, val.u.d)`. EXECOPT/PM_READONLY checks
4415/// at top.
4416pub fn setnumvalue(v: Option<&mut value>, val: mnumber) {
4417    // c:2860 — `if (unset(EXECOPT)) return;`. In NO_EXEC mode, param
4418    // mutations must be skipped so dry-run shell evaluation doesn't
4419    // leak state into the param table. The previous Rust port skipped
4420    // this check; `zsh -n -c '(( x=5 ))'` would mutate $x silently.
4421    if unset(EXECOPT) {
4422        // c:2860
4423        return;
4424    }
4425    let v = match v {
4426        Some(v) => v,
4427        None => return,
4428    };
4429    let pm = match v.pm.as_mut() {
4430        Some(p) => p,
4431        None => return,
4432    };
4433    if (pm.node.flags as u32 & PM_READONLY) != 0 {
4434        zerr(&format!("read-only variable: {}", pm.node.nam)); // c:2862
4435        return;
4436    }
4437    let t = PM_TYPE(pm.node.flags as u32);
4438    if t == PM_SCALAR || t == PM_NAMEREF || t == PM_ARRAY {
4439        // c:2862-2872 — convbase_underscore for integers (honors
4440        // pm.base for the radix prefix + pm.width for underscore
4441        // grouping), convfloat_underscore for floats. The previous
4442        // Rust port computed `val.l.to_string()` then DROPPED the
4443        // result via `let _ = s;` — meaning a numeric assignment
4444        // to a SCALAR param stored NOTHING. `typeset s; (( s = 42 ))`
4445        // would leave $s empty.
4446        let s = if (val.type_ & MN_INTEGER) != 0 {
4447            // c:2862
4448            // c:2864 — `convbase_underscore(val.u.l, pm->base, pm->width)`.
4449            convbase_underscore(val.l, if pm.base > 0 { pm.base } else { 10 }, pm.width)
4450        } else {
4451            // c:2867
4452            // c:2869 — `convfloat_underscore(val.u.d, pm->width)`.
4453            convfloat_underscore(val.d, pm.width)
4454        };
4455        pm.u_str = Some(s); // c:2871 setstrvalue → store
4456    } else if t == PM_INTEGER {
4457        // c:2874 — `pm->gsu.i->setfn(pm, val.u.l)`. For MN_FLOAT
4458        // input, C truncates to integer via `(zlong)val.u.d`.
4459        pm.u_val = if (val.type_ & MN_INTEGER) != 0 {
4460            val.l
4461        } else {
4462            val.d as i64
4463        };
4464    } else if t == PM_EFLOAT || t == PM_FFLOAT {
4465        // c:2878 — `pm->gsu.f->setfn(pm, val.u.d)`. MN_INTEGER input
4466        // gets promoted via `(double)val.u.l`.
4467        pm.u_dval = if (val.type_ & MN_INTEGER) != 0 {
4468            val.l as f64
4469        } else {
4470            val.d
4471        };
4472    }
4473}
4474
4475/// Direct port of `void setarrvalue(Value v, char **val)` from
4476/// `Src/params.c:2895-3037`. Sets an array (or assoc-array via
4477/// arrhashsetfn) into the param identified by v.pm, honouring
4478/// PM_READONLY / type-guards / VALFLAG_EMPTY rejections and the
4479/// slice-bounds adjust for `[N,M]` subscripts.
4480///
4481/// C dispatch:
4482///   - !EXECOPT → silent return (c:2897-2898)
4483///   - PM_READONLY → zerr + return (c:2899-2904)
4484///   - !PM_ARRAY && !PM_HASHED → zerr (c:2905-2911)
4485///   - VALFLAG_EMPTY → zerr (c:2913-2917)
4486///   - start==0,end==-1 && PM_HASHED → arrhashsetfn(0) (c:2919-2922)
4487///   - start==0,end==-1 && PM_ARRAY → gsu.a->setfn (c:2922-2923)
4488///   - start==-1,end==0 && PM_HASHED → arrhashsetfn(AUGMENT) (c:2925-2928)
4489///   - PM_HASHED with other bounds → zerr slice-of-assoc (c:2929-2932)
4490///   - PM_ARRAY with slice → bounds adjust + splice (c:2933+)
4491///
4492/// VALFLAG_INV + !KSHARRAYS off-by-one (c:2938-2942) is ported below.
4493/// PM_UNIQUE dedupe (c:2966-2967) is ported at the tail. ASSPM_AUGMENT
4494/// prepend (c:2945-2954) for slice-AUGMENT remains deferred — rare path
4495/// (`a[i,j]+=...` array+=) that requires snapshotting the pre-existing
4496/// slice value before splice.
4497pub fn setarrvalue(v: &mut value, val: Vec<String>) {
4498    // c:2895
4499    // c:2897-2898 — `if (unset(EXECOPT)) return;`. Match the same
4500    // NO_EXEC bail as setnumvalue at c:2860. Without it,
4501    // `zsh -n -c 'arr=(a b c)'` would mutate arr during a parse-
4502    // only run.
4503    if unset(EXECOPT) {
4504        // c:2897
4505        return;
4506    }
4507
4508    let pm = match v.pm.as_mut() {
4509        Some(p) => p,
4510        None => return,
4511    };
4512
4513    // c:2899-2904 — PM_READONLY rejection.
4514    if pm.node.flags & PM_READONLY as i32 != 0 {
4515        zerr(&format!("read-only variable: {}", pm.node.nam));
4516        return;
4517    }
4518    // c:2905-2911 — type guard.
4519    let t = PM_TYPE(pm.node.flags as u32);
4520    if t & (PM_ARRAY | PM_HASHED) == 0 {
4521        zerr(&format!(
4522            "{}: attempt to assign array value to non-array",
4523            pm.node.nam
4524        ));
4525        return;
4526    }
4527    // c:2913-2917 — VALFLAG_EMPTY rejection.
4528    if v.valflags & VALFLAG_EMPTY != 0 {
4529        zerr(&format!(
4530            "{}: assignment to invalid subscript range",
4531            pm.node.nam
4532        ));
4533        return;
4534    }
4535
4536    // c:2919-2932 — full-replace / AUGMENT / hash-slice-reject paths.
4537    if v.start == 0 && v.end == -1 {
4538        if t == PM_HASHED {
4539            // c:2920 — arrhashsetfn(pm, val, 0).
4540            arrhashsetfn(pm, val, 0);
4541        } else {
4542            // c:2922 — `pm->gsu.a->setfn(pm, val)`. Route through
4543            // arrsetfn so PM_UNIQUE dedupe + arrfixenv side-effects
4544            // fire (params.c:4066-4076).
4545            arrsetfn(pm, val);
4546        }
4547        return;
4548    }
4549    if v.start == -1 && v.end == 0 && t == PM_HASHED {
4550        arrhashsetfn(pm, val, ASSPM_AUGMENT);
4551        return;
4552    }
4553    if t == PM_HASHED {
4554        zerr(&format!(
4555            "{}: attempt to set slice of associative array",
4556            pm.node.nam
4557        ));
4558        return;
4559    }
4560
4561    // c:2938-2942 — VALFLAG_INV + !KSHARRAYS off-by-one. Inverse
4562    // subscripts (`a[(i)pat]=val`) are 1-based when KSHARRAYS is
4563    // off; shift start/end down by 1 to match the 0-based slice
4564    // arithmetic below.
4565    if v.valflags & VALFLAG_INV != 0 && !isset(KSHARRAYS) {
4566        if v.start > 0 {
4567            v.start -= 1;
4568        }
4569        v.end -= 1;
4570    }
4571
4572    // c:2933+ — PM_ARRAY slice path.
4573    let arr = pm.u_arr.get_or_insert_with(Vec::new);
4574    let len = arr.len() as i64;
4575    // c:2944-2949 — negative start: add pre_assignment_length; clamp to 0.
4576    let start = if v.start < 0 {
4577        (len + v.start as i64).max(0)
4578    } else {
4579        v.start as i64
4580    };
4581    // c:2950-2953 — negative end: add pre_assignment_length + 1; clamp to 0.
4582    let end = if v.end < 0 {
4583        (len + v.end as i64 + 1).max(0)
4584    } else {
4585        v.end as i64
4586    };
4587    let start_idx = (start.max(1) - 1) as usize;
4588    let end_idx = end.max(0) as usize;
4589
4590    // c:2980 — pad with empty strings up to start.
4591    while arr.len() < start_idx {
4592        arr.push(String::new());
4593    }
4594
4595    // c:2940-2943 — `if (v->end < v->start || v->start > oldlen)
4596    //                    v->end = v->start; else if (v->end > oldlen)
4597    //                    v->end = oldlen;`. A REVERSED range (start > end,
4598    // e.g. `a[4,2]=(42 43 44)`) collapses to an EMPTY range at start, so
4599    // the splice INSERTS the value there keeping every element
4600    // ("1 2 3 42 43 44 4 5") — it does NOT overwrite from start. The
4601    // previous Rust port took a custom `start_idx > end_idx` else-branch
4602    // that overwrote/extended from start, dropping the tail. `clamp`
4603    // implements both C arms: lower-bound start_idx (reversed → empty),
4604    // upper-bound arr.len() (end past tail → clamp). After the pad above
4605    // arr.len() >= start_idx, so the clamp range is always valid.
4606    let end_idx = end_idx.clamp(start_idx, arr.len());
4607    let val_len = val.len(); // c:3030 post-assign sanity
4608    let pre_len = arr.len(); // c:3030 (snapshot)
4609                             // c:2989-2998 — splice val into [start..end] range.
4610    arr.splice(start_idx..end_idx, val);
4611    // c:3030 — DPUTS2(p - new != post_assignment_length, …). The
4612    // post-splice arr.len() is C's `p - new`; expected follows C's
4613    // post_assignment_length = start + vallen + MAX(0, oldlen - end).
4614    let expected = pre_len - (end_idx - start_idx) + val_len; // c:3030
4615    DPUTS2!(
4616        // c:3030
4617        arr.len() != expected, // c:3030
4618        "setarrvalue: wrong allocation: {} 1= {}",
4619        expected,
4620        arr.len() // c:3030-3031
4621    );
4622
4623    // c:2966-2967 — `if (pm->node.flags & PM_UNIQUE) arrunique(pm->u.arr);`
4624    // Dedupe in-place preserving first occurrence. Without this,
4625    // `typeset -U arr; arr[2]=foo` leaves duplicates that violate the
4626    // PM_UNIQUE invariant.
4627    if (pm.node.flags as u32 & PM_UNIQUE) != 0 {
4628        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
4629        arr.retain(|s| seen.insert(s.clone())); // c:2967
4630    }
4631    // c:Src/params.c:2922 — a SUBSCRIPT-RANGE assignment (`path[i,j]=(…)`,
4632    // `path[N]=(…)`, `path[i,j]=()`) reaches here (the whole-replace form
4633    // returned early via arrsetfn above). For a tied colon-array the scalar
4634    // side must re-derive, just like the single-element path in
4635    // assignsparam; see TIED_COLON_ARRAYS. Snapshot after the splice.
4636    let tie_sync: Option<(&'static str, String)> = TIED_COLON_ARRAYS
4637        .iter()
4638        .find(|(a, _)| *a == pm.node.nam)
4639        .map(|(_, scalar)| (*scalar, pm.u_arr.as_deref().unwrap_or(&[]).join(":")));
4640    if let Some((scalar, joined)) = tie_sync {
4641        assignsparam(scalar, &joined, 0);
4642    }
4643}
4644
4645/// Retrieve integer parameter.
4646/// Port of `getiparam(char *s)` from Src/params.c:3044. C: getvalue +
4647/// getintvalue. Our adaptation reads the scalar string and parses;
4648/// returns 0 on missing or unparseable, matching getintvalue's
4649/// failure-returns-0 convention (params.c:2601).
4650pub fn getiparam(s: &str) -> i64 {
4651    // C also honours PM_INTEGER's `pm->u.val` payload directly when
4652    // the param is typed numeric; check paramtab first for that case.
4653    if let Ok(tab) = paramtab().read() {
4654        if let Some(pm) = tab.get(s) {
4655            if (pm.node.flags as u32 & PM_INTEGER) != 0 {
4656                return pm.u_val;
4657            }
4658        }
4659    }
4660    getsparam(s)
4661        .and_then(|s| s.parse::<i64>().ok())
4662        .unwrap_or(0)
4663}
4664
4665/// Retrieve numeric (int-or-float) parameter.
4666/// Port of `getnparam(char *s)` from Src/params.c:3058. C returns an
4667/// `mnumber` (tagged int/float union); our adaptation returns
4668/// `(i64, f64, bool)` where the bool is true for float. Unset
4669/// returns `(0, 0.0, false)`, matching the MN_INTEGER zero
4670/// fallback in the C source's not-found branch.
4671pub fn getnparam(s: &str) -> (i64, f64, bool) {
4672    if let Ok(tab) = paramtab().read() {
4673        if let Some(pm) = tab.get(s) {
4674            let fl = pm.node.flags as u32;
4675            if (fl & (PM_EFLOAT | PM_FFLOAT)) != 0 {
4676                return (pm.u_dval as i64, pm.u_dval, true);
4677            }
4678            if (fl & PM_INTEGER) != 0 {
4679                return (pm.u_val, pm.u_val as f64, false);
4680            }
4681        }
4682    }
4683    let s = match getsparam(s) {
4684        Some(s) => s,
4685        None => return (0, 0.0, false),
4686    };
4687    if s.contains('.') || s.contains('e') || s.contains('E') {
4688        if let Ok(f) = s.parse::<f64>() {
4689            return (f as i64, f, true);
4690        }
4691    }
4692    if let Ok(i) = s.parse::<i64>() {
4693        return (i, i as f64, false);
4694    }
4695    (0, 0.0, false)
4696}
4697
4698/// Port of `getsparam(char *s)` from `Src/params.c:3076`.
4699///
4700/// C body:
4701/// ```c
4702/// char *getsparam(char *s) {
4703///     struct value vbuf;
4704///     Value v = getvalue(&vbuf, &s, 0);
4705///     if (!v) return NULL;
4706///     return getstrvalue(v);
4707/// }
4708/// ```
4709///
4710/// `getvalue` (params.c:2173) builds a `Value` for the parameter,
4711/// dispatching through `Param.gsu->getfn` for special parameters.
4712/// `getstrvalue` (params.c:2335) extracts the scalar form: for
4713/// PM_INTEGER calls `pm->gsu.i->getfn(pm)` and convbase's the
4714/// result; for PM_SCALAR calls `pm->gsu.s->getfn(pm)`; for
4715/// PM_ARRAY joins the elements.
4716///
4717/// **Sole funnel.** Every scalar parameter read in zshrs routes
4718/// through this fn — `subst.rs` parameter expansion AND
4719/// `fusevm_bridge::expand_param` both call `getsparam`. The
4720/// dispatch chain lives in exactly one place, mirroring C's
4721/// "every read goes through getsparam" architecture.
4722///
4723/// Lookup order (mirrors C's `getvalue` → `getstrvalue` cascade):
4724/// 1. **GSU dispatch** via [`lookup_special_var`] — special
4725///    parameters route through their getfn callback (`uidgetfn` /
4726///    `randomgetfn` / `usernamegetfn` / etc.). Same role as
4727///    C's `Param.gsu->getfn` virtual dispatch.
4728/// 2. **Local variable** — `variables[name]`. C reads `pm->u.str`
4729///    for PM_SCALAR; here we hold the scalar in the variables
4730///    HashMap.
4731/// 3. **Environment fallback** — `std::env::var(name)`. C imports
4732///    env vars into the param table at startup so they go through
4733///    the same dispatch as everything else; zshrs reads from the
4734///    OS env on miss to match.
4735/// 4. **Array → scalar** — `arrays[name].join(" ")`. Mirrors
4736///    C's PM_ARRAY case in getstrvalue (params.c:2358) which
4737///    joins via `sepjoin(ss, NULL, 1)`.
4738///
4739// Retrieve a scalar (string) parameter                                     // c:3076
4740/// Returns `None` only if all four paths miss (parameter genuinely
4741/// unset).
4742pub fn getsparam(name: &str) -> Option<String> {
4743    // c:3076
4744    // 1. GSU dispatch — `Param.gsu->getfn(pm)` equivalent. Special
4745    //    parameters (UID/RANDOM/USERNAME/...) live behind getfn
4746    //    hooks that the table read below would otherwise miss.
4747    if let Some(v) = lookup_special_var(name) {
4748        return Some(v);
4749    }
4750    // 1b. c:Src/params.c:570-575 — getparamnode resolves PM_NAMEREF
4751    //     chains before the value read (`pm = resolve_nameref(pm)`).
4752    //     Redirect the lookup to the resolved target.
4753    // c:570-575 getparamnode → resolve_nameref: scalar-read
4754    // redirection through a nameref chain, INLINE (former
4755    // nameref_read_redirect, single-caller fold 2026-06-12).
4756    if let Some(res) = (|name: &str| -> Option<Option<String>> {
4757        if !is_nameref(name) {
4758            return None;
4759        }
4760        match resolve_nameref_name(name, None) {
4761            nameref_resolution::NotRef => None,
4762            nameref_resolution::Placeholder(_)
4763            | nameref_resolution::SelfRef
4764            | nameref_resolution::OutOfScope => Some(None),
4765            nameref_resolution::Target {
4766                name: t,
4767                subscript,
4768                pm,
4769                level,
4770            } => {
4771                let pm = match pm {
4772                    Some(p) => p,
4773                    None => return Some(None), // dangling target
4774                };
4775                if (pm.node.flags as u32 & PM_UNSET) != 0 {
4776                    return Some(None);
4777                }
4778                match subscript {
4779                    None => {
4780                        // Visible binding → recurse through the normal
4781                        // read path (padding, gsu, specials all apply).
4782                        let visible_level = paramtab()
4783                            .read()
4784                            .ok()
4785                            .and_then(|tab| tab.get(&t).map(|p| p.level));
4786                        if visible_level == Some(level) {
4787                            Some(getsparam(&t))
4788                        } else {
4789                            // Hidden (old-chain) binding — read the clone.
4790                            Some(param_scalar_value(&pm))
4791                        }
4792                    }
4793                    Some(k) => Some(nameref_element_read(&pm, &t, &k)),
4794                }
4795            }
4796        }
4797    })(name)
4798    {
4799        return res;
4800    }
4801    // 2. Paramtab read — `(Value)gethashnode2(paramtab, name)`.
4802    //    Walk the global paramtab for the named param, returning
4803    //    `pm->u.str` for PM_SCALAR/PM_NAMEREF or `sepjoin(pm->u.arr)`
4804    //    for PM_ARRAY (matches `getstrvalue` at params.c:2358).
4805    if let Ok(tab) = paramtab().read() {
4806        if let Some(pm) = tab.get(name) {
4807            // c:Src/Modules/param_private.c:568-617 + c:678 — C swaps
4808            // `realparamtab->getnode` to getprivatenode when
4809            // zsh/param/private is active, so every lookup skips
4810            // private params belonging to an OUTER scope when read
4811            // from a DEEPER one (`private x` in f is invisible to g
4812            // called by f; g sees the global). zshrs's HashMap
4813            // paramtab has no getnode vtable, so call the canonical
4814            // walk at the lookup site. SAFETY: getprivatenode only
4815            // follows the `pm->old` chain read-only; the chain is
4816            // owned by this entry, which stays alive under the read
4817            // guard held for the rest of this block.
4818            let visible =
4819                crate::ported::modules::param_private::getprivatenode(&**pm as *const param); // c:678 getnode hook
4820            if visible.is_null() {
4821                return None; // c:609 walk exhausted — no visible node
4822            }
4823            let pm: &param = unsafe { &*visible };
4824            // c:Src/params.c:2335-2358 — `if (pm->node.flags & PM_UNSET)
4825            // return ""`. Unset specials kept in paramtab (PM_SPECIAL
4826            // retention path, c:3911) read as empty, not as their stale
4827            // u.val / u.str. Without this, `unset SECONDS; echo
4828            // "[$SECONDS]"` printed `[0]` instead of `[]` — the
4829            // PM_UNSET pm fell through to the integer path and
4830            // returned convbase(0). Bug #418 in docs/BUGS.md.
4831            if (pm.node.flags as u32 & PM_UNSET) != 0 {
4832                return None;
4833            }
4834            let t = PM_TYPE(pm.node.flags as u32);
4835            // c:2390-2538 — when PM_LEFT/PM_RIGHT_B/PM_RIGHT_Z + width
4836            // are set, getstrvalue (called from getsparam in C) applies
4837            // padding. Mirror that here so `$var` expansion respects
4838            // `typeset -Z N` / `-L N` / `-R N` even when the caller
4839            // didn't go through getstrvalue. Numeric prefix detection
4840            // (leading blanks/minus/0x/base#) follows params.rs:3156.
4841            let pad_flags = (pm.node.flags as u32) & (PM_LEFT | PM_RIGHT_B | PM_RIGHT_Z);
4842            // c:Src/params.c:2358 — getstrvalue for a PM_SCALAR
4843            // dispatches through `pm->gsu.s->getfn(pm)` so tied /
4844            // colon-array / special-callback scalars (PATH/path,
4845            // user `typeset -T` pairs) return their live computed
4846            // value rather than a stale cached `u_str`. Reads of
4847            // `u_str` for scalars without a GSU vtable (the common
4848            // case) fall through unchanged. Bug #24 in docs/BUGS.md.
4849            let raw: Option<String> = if t == PM_INTEGER {
4850                let base = if pm.base > 0 { pm.base } else { 10 };
4851                Some(convbase(pm.u_val, base as u32))
4852            } else if t == PM_EFLOAT || t == PM_FFLOAT {
4853                Some(convfloat(pm.u_dval, pm.base, pm.node.flags as u32))
4854            } else if t == PM_SCALAR && pm.gsu_s.is_some() {
4855                pm.gsu_s.as_ref().map(|gsu| (gsu.getfn)(pm))
4856            } else if let Some(s) = pm.u_str.as_ref() {
4857                Some(s.clone())
4858            } else {
4859                pm.u_arr.as_ref().map(|arr| arr.join(" "))
4860            };
4861            if let Some(mut s) = raw {
4862                if pad_flags != 0 && pm.width > 0 {
4863                    let fwidth = pm.width as usize;
4864                    let numeric_pm =
4865                        (pm.node.flags as u32 & (PM_INTEGER | PM_EFLOAT | PM_FFLOAT)) != 0;
4866                    if pad_flags == PM_LEFT || pad_flags == (PM_LEFT | PM_RIGHT_Z) {
4867                        let trimmed: &str = if pad_flags & PM_RIGHT_Z != 0 {
4868                            s.trim_start_matches('0')
4869                        } else {
4870                            s.trim_start_matches(|c: char| c == ' ' || c == '\t')
4871                        };
4872                        let len = trimmed.chars().count();
4873                        let take = len.min(fwidth);
4874                        let mut out: String = trimmed.chars().take(take).collect();
4875                        if fwidth > take {
4876                            out.extend(std::iter::repeat(' ').take(fwidth - take));
4877                        }
4878                        s = out;
4879                    } else if pad_flags & (PM_RIGHT_B | PM_RIGHT_Z) != 0 {
4880                        let charlen = s.chars().count();
4881                        if charlen < fwidth {
4882                            let mut zero = true;
4883                            let mut valprefend: usize = 0;
4884                            if pad_flags & PM_RIGHT_Z != 0 {
4885                                let bytes = s.as_bytes();
4886                                let mut tpos = 0usize;
4887                                while tpos < bytes.len()
4888                                    && (bytes[tpos] == b' ' || bytes[tpos] == b'\t')
4889                                {
4890                                    tpos += 1;
4891                                }
4892                                if numeric_pm && tpos < bytes.len() && bytes[tpos] == b'-' {
4893                                    tpos += 1;
4894                                }
4895                                if (pm.node.flags as u32 & PM_INTEGER) != 0
4896                                    && tpos + 1 < bytes.len()
4897                                    && bytes[tpos] == b'0'
4898                                    && bytes[tpos + 1] == b'x'
4899                                {
4900                                    tpos += 2;
4901                                } else if (pm.node.flags as u32 & PM_INTEGER) != 0 {
4902                                    if let Some(hash_off) =
4903                                        bytes[tpos..].iter().position(|&b| b == b'#')
4904                                    {
4905                                        tpos += hash_off + 1;
4906                                    }
4907                                }
4908                                valprefend = tpos;
4909                                if tpos == bytes.len() {
4910                                    zero = false;
4911                                } else if !numeric_pm && !bytes[tpos].is_ascii_digit() {
4912                                    zero = false;
4913                                }
4914                            }
4915                            let pad_char = if (pad_flags & PM_RIGHT_B) != 0 || !zero {
4916                                ' '
4917                            } else {
4918                                '0'
4919                            };
4920                            let pad_count = fwidth - charlen;
4921                            let prefix = &s[..valprefend];
4922                            let rest = &s[valprefend..];
4923                            let mut out = String::with_capacity(fwidth);
4924                            out.push_str(prefix);
4925                            out.extend(std::iter::repeat(pad_char).take(pad_count));
4926                            out.push_str(rest);
4927                            s = out;
4928                        } else if charlen > fwidth {
4929                            // c:Src/params.c:2496-2500 — right-justify
4930                            // with charlen > fwidth: truncate from the
4931                            // FRONT to fit fwidth codepoints. Bug #100
4932                            // in docs/BUGS.md — the getsparam path had
4933                            // the pad arm but missed the truncation
4934                            // arm. The parallel getstrvalue path at
4935                            // ~line 3355 has it; this is the
4936                            // missing-symmetry fix.
4937                            let skip = charlen - fwidth;
4938                            s = s.chars().skip(skip).collect();
4939                        }
4940                    }
4941                }
4942                return Some(s);
4943            }
4944        }
4945    }
4946    // 3. Env fallback — C imports env into paramtab at init so the
4947    //    read above would hit. If the import hasn't happened yet
4948    //    (e.g. during very early init) fall back to the live env.
4949    env::var(name).ok()
4950}
4951
4952/// Port of `getsparam_u(char *s)` from `Src/params.c:3089`. C body
4953/// (c:3091-3094):
4954/// ```c
4955/// /* getsparam() returns pointer into global params table, so ... */
4956/// if ((s = getsparam(s)))
4957///     return unmeta(s);    /* returns static pointer to copy */
4958/// return s;
4959/// ```
4960///
4961/// The previous Rust "port" was an entirely fabricated impl — it
4962/// took `Option<&mut value>` and gated on `PM_TYPE == PM_SCALAR`,
4963/// which matches no part of the C body. C just calls getsparam(s)
4964/// and unmeta's the resulting string. No callers existed because
4965/// no caller's type fit the bogus signature.
4966///
4967/// Real use case: locale setters (c:4847, c:4867, c:4882, c:4917)
4968/// call `getsparam_u("LC_ALL")` / `getsparam_u("LANG")` to read the
4969/// param as a Meta-stripped C string suitable for `setlocale`.
4970pub fn getsparam_u(s: &str) -> Option<String> {
4971    // c:3089
4972    // c:3092 — `if ((s = getsparam(s))) return unmeta(s);`
4973    getsparam(s).map(|v| unmeta(&v))
4974}
4975
4976/// Port of `char **getaparam(char *s)` from `Src/params.c:3101-3110`.
4977///
4978/// C body:
4979/// ```c
4980/// struct value vbuf;
4981/// Value v;
4982/// if (!idigit(*s) && (v = getvalue(&vbuf, &s, 0)) &&
4983///     PM_TYPE(v->pm->node.flags) == PM_ARRAY)
4984///     return v->pm->gsu.a->getfn(v->pm);
4985/// return NULL;
4986/// ```
4987///
4988/// The previous Rust port was a fabrication: signature was
4989/// `Option<&mut value> -> Option<Vec<String>>`, taking an already-
4990/// resolved Value pointer rather than the C-canonical name string.
4991/// No caller used it because the bogus signature fit nothing — and
4992/// the in-tree `savematch` at modules/zutil.rs:30 hardcoded `a = None`
4993/// because the existing API couldn't be threaded through.
4994///
4995/// Real C use: name lookup. e.g. `getaparam("match")` returns the
4996/// `$match` array from the regex-match callouts (Modules/zutil.c:45).
4997pub fn getaparam(name: &str) -> Option<Vec<String>> {
4998    // c:3101
4999    // c:3107 — `if (idigit(*s))` reject digit-first names. C
5000    // `getvalue` would also reject these later, but the explicit
5001    // check matches C's flow.
5002    if name.starts_with(|c: char| c.is_ascii_digit()) {
5003        // c:3107
5004        return None;
5005    }
5006    // c:Src/params.c:570-575 — getvalue→fetchvalue→getparamnode
5007    // resolves PM_NAMEREF before the type check.
5008    // c:570-575 getparamnode → resolve_nameref: array-read
5009    // redirection through a nameref chain, INLINE (former
5010    // nameref_aread_redirect, single-caller fold 2026-06-12).
5011    if let Some(res) = (|name: &str| -> Option<Option<Vec<String>>> {
5012        if !is_nameref(name) {
5013            return None;
5014        }
5015        match resolve_nameref_name(name, None) {
5016            nameref_resolution::NotRef => None,
5017            nameref_resolution::Placeholder(_)
5018            | nameref_resolution::SelfRef
5019            | nameref_resolution::OutOfScope => Some(None),
5020            nameref_resolution::Target {
5021                name: t,
5022                subscript,
5023                pm,
5024                level,
5025            } => {
5026                let pm = match pm {
5027                    Some(p) => p,
5028                    None => return Some(None),
5029                };
5030                if (pm.node.flags as u32 & PM_UNSET) != 0 {
5031                    return Some(None);
5032                }
5033                if let Some(k) = subscript {
5034                    // single element reads back as a 1-element array view
5035                    return Some(nameref_element_read(&pm, &t, &k).map(|v| vec![v]));
5036                }
5037                if PM_TYPE(pm.node.flags as u32) != PM_ARRAY {
5038                    return Some(None);
5039                }
5040                let visible_level = paramtab()
5041                    .read()
5042                    .ok()
5043                    .and_then(|tab| tab.get(&t).map(|p| p.level));
5044                if visible_level == Some(level) {
5045                    Some(getaparam(&t))
5046                } else {
5047                    Some(pm.u_arr.clone())
5048                }
5049            }
5050        }
5051    })(name)
5052    {
5053        return res;
5054    }
5055    // c:3107-3109 — `getvalue(&vbuf, &s, 0)` resolves the name to a
5056    // paramtab entry. Then PM_TYPE check + `pm->u.arr` return.
5057    if let Ok(tab) = paramtab().read() {
5058        if let Some(pm) = tab.get(name) {
5059            // c:Src/Modules/param_private.c:678 — the getnode hook
5060            // applies to every paramtab lookup; same canonical walk
5061            // as getsparam (SAFETY: read-only old-chain walk under
5062            // the held read guard).
5063            let visible =
5064                crate::ported::modules::param_private::getprivatenode(&**pm as *const param);
5065            if visible.is_null() {
5066                return None;
5067            }
5068            let pm: &param = unsafe { &*visible };
5069            if PM_TYPE(pm.node.flags as u32) == PM_ARRAY {
5070                // c:3108
5071                if let Some(arr) = pm.u_arr.as_ref() {
5072                    // c:3109
5073                    return Some(arr.clone());
5074                }
5075            }
5076        }
5077    }
5078    None // c:3110
5079}
5080
5081/// Port of `char **gethparam(char *s)` from `Src/params.c:3117-3126`.
5082///
5083/// C body:
5084/// ```c
5085/// struct value vbuf;
5086/// Value v;
5087/// if (!idigit(*s) && (v = getvalue(&vbuf, &s, 0)) &&
5088///     PM_TYPE(v->pm->node.flags) == PM_HASHED)
5089///     return paramvalarr(v->pm->gsu.h->getfn(v->pm), SCANPM_WANTVALS);
5090/// return NULL;
5091/// ```
5092///
5093/// Same fabricated-port family as the prior `getaparam`/`getsparam_u`
5094/// fixes: previous Rust sig took `Option<&mut value>` instead of the
5095/// canonical name string, with no real callers. Fixed sig + body
5096/// that resolves the name through paramtab and returns the values
5097/// vector when PM_HASHED.
5098///
5099/// NOTE: zshrs's paramtab stores hash-params via `pm->u_hash` (a
5100/// `HashTable` struct that's a generic bucket-array container). The
5101/// canonical C path threads through `gsu.h->getfn(pm)` → `paramvalarr`
5102/// which extracts the value side of each key-value pair. zshrs's
5103/// canonical assoc backing lives in `paramtab_hashed_storage` (an
5104/// IndexMap<String, String> keyed by param name); read the values
5105/// directly from there as the C macro's
5106/// `paramvalarr(hashgetfn(pm), SCANPM_WANTVALS)` resolves to a
5107/// values walk over the same backing.
5108pub fn gethparam(name: &str) -> Option<Vec<String>> {
5109    // c:3117
5110    if name.starts_with(|c: char| c.is_ascii_digit()) {
5111        // c:3122
5112        return None;
5113    }
5114    // c:Src/params.c:570-575 — nameref deref before the type check.
5115    let resolved = match crate::ported::params::resolve_nameref_name(name, None) {
5116        crate::ported::params::nameref_resolution::Target { name: t_, .. } => t_,
5117        _ => name.to_string(),
5118    };
5119    let name: &str = &resolved;
5120    if let Ok(tab) = paramtab().read() {
5121        if let Some(pm) = tab.get(name) {
5122            // c:Src/Modules/param_private.c:678 — getnode hook; same
5123            // canonical walk as getsparam/getaparam. (Values below
5124            // still come from the name-keyed hashed storage — a
5125            // single slot per name; per-scope assoc shadowing is a
5126            // storage-model limitation predating this walk.)
5127            let visible =
5128                crate::ported::modules::param_private::getprivatenode(&**pm as *const param);
5129            if visible.is_null() {
5130                return None;
5131            }
5132            let pm: &param = unsafe { &*visible };
5133            if PM_TYPE(pm.node.flags as u32) == PM_HASHED {
5134                // c:3123
5135                // c:3124 — `paramvalarr(hashgetfn(pm), SCANPM_WANTVALS)`.
5136                // Read values directly from the canonical hashed-storage
5137                // backing — IndexMap iteration matches C's hashtable
5138                // walk order (insertion-stable). When the storage hasn't
5139                // been populated (empty hash, fresh declaration), return
5140                // an empty Vec so the C "param exists, no entries" shape
5141                // is preserved (vs returning None which means "param
5142                // doesn't exist").
5143                let store = paramtab_hashed_storage().lock().ok()?;
5144                let vals = store
5145                    .get(name)
5146                    .map(|m| m.values().cloned().collect())
5147                    .unwrap_or_default();
5148                return Some(vals); // c:3124
5149            }
5150        }
5151    }
5152    None // c:3125
5153}
5154
5155/// Port of `char **gethkparam(char *s)` from `Src/params.c:3131-3140`.
5156/// Same as `gethparam` but `paramvalarr(..., SCANPM_WANTKEYS)`.
5157pub fn gethkparam(name: &str) -> Option<Vec<String>> {
5158    // c:3131
5159    if name.starts_with(|c: char| c.is_ascii_digit()) {
5160        // c:3136
5161        return None;
5162    }
5163    if let Ok(tab) = paramtab().read() {
5164        if let Some(pm) = tab.get(name) {
5165            if PM_TYPE(pm.node.flags as u32) == PM_HASHED {
5166                // c:3137
5167                // c:3138 — `paramvalarr(pm->gsu.h->getfn(pm),
5168                // SCANPM_WANTKEYS)`. Same backing as gethparam —
5169                // return keys instead of values. Empty-storage
5170                // fallback identical: Some(empty Vec) for "exists,
5171                // no entries" shape.
5172                {
5173                    let store = paramtab_hashed_storage().lock().ok()?;
5174                    if let Some(m) = store.get(name) {
5175                        return Some(m.keys().cloned().collect()); // c:3138
5176                    }
5177                }
5178                // c:3138 — for SPECIALPMDEF magic hashes (parameters/
5179                // options/commands/…, Src/Modules/parameter.c) the
5180                // `getfn` is the module's scan fn, not hashgetfn;
5181                // there's no paramtab_hashed_storage backing. The
5182                // partab_scan_keys port (vm_helper.rs:3486) is that
5183                // scan. Without this, `${(k)parameters[PATH]}` saw an
5184                // empty key set and returned "" where zsh prints PATH.
5185                if let Some(keys) = (|| -> Option<Vec<String>> {
5186                    // c:Src/Modules/parameter.c — special-hash scantab
5187                    // key enumeration via the partab[] row's scanfn
5188                    // (former bridge partab_scan_keys, inlined; the
5189                    // captureless callback is the C ScanFunc ABI).
5190                    let e_ = crate::ported::modules::parameter::PARTAB
5191                        .iter()
5192                        .find(|e_| e_.name == name)?;
5193                    if let Some(m_) = e_.module {
5194                        if !crate::ported::module::MODULESTAB
5195                            .lock()
5196                            .map(|t| t.is_loaded(m_))
5197                            .unwrap_or(false)
5198                        {
5199                            return None;
5200                        }
5201                    }
5202                    thread_local! {
5203                        static KEYS_: std::cell::RefCell<Vec<String>> =
5204                            const { std::cell::RefCell::new(Vec::new()) };
5205                    }
5206                    fn cb_(node: &crate::ported::zsh_h::HashNode, _f: i32) {
5207                        KEYS_.with(|k| k.borrow_mut().push(node.nam.clone()));
5208                    }
5209                    KEYS_.with(|k| k.borrow_mut().clear());
5210                    (e_.scanfn)(std::ptr::null_mut(), Some(cb_), 0);
5211                    Some(KEYS_.with(|k| k.borrow().clone()))
5212                })() {
5213                    return Some(keys); // c:3138
5214                }
5215                return Some(Vec::new()); // c:3138
5216            }
5217        }
5218    }
5219    None // c:3139
5220}
5221
5222/// Port of `check_warn_pm(Param pm, const char *pmtype, int created, int may_warn_about_nested_vars)` from `Src/params.c:3160`.
5223///
5224/// C body emits the WARN_CREATE_GLOBAL / WARN_NESTED_VAR
5225/// diagnostic when a function-local creates/passes a non-local
5226/// param with the matching shell options set.
5227///
5228/// The previous Rust port handled the GATE logic correctly but
5229/// SKIPPED the diagnostic emit, claiming the `funcstack` global
5230/// wasn't ported. But `FUNCSTACK`
5231/// IS ported (`Mutex<Vec<funcstack>>`). Wire the walk:
5232///   for (i = funcstack; i; i = i->prev)
5233///       if (i->tp == FS_FUNC) {
5234///           msg = created ?
5235///               "%s parameter %s created globally in function %s" :
5236///               "%s parameter %s set in enclosing scope in function %s";
5237///           zwarn(msg, pmtype, pm->node.nam, i->name);
5238///           break;
5239///       }
5240///
5241/// Without the diagnostic, `setopt WARN_CREATE_GLOBAL` had no
5242/// observable effect — the whole point of the option is the
5243/// user-visible warning.
5244pub fn check_warn_pm(pm: &param, pmtype: &str, created: i32, may_warn_about_nested_vars: i32) {
5245    // c:3160
5246    if may_warn_about_nested_vars == 0 && created == 0 {
5247        // c:3165
5248        return;
5249    }
5250    // `locallevel` is the canonical `pub static` above (port of
5251    // params.c:54). `forklevel` is the ported global at vm_helper
5252    // (port of exec.c:1052) set to locallevel at every entersubsh().
5253    let cur_local: i32 = locallevel.load(Ordering::Relaxed);
5254    let forklevel: i32 = FORKLEVEL.load(Ordering::Relaxed); // c:1052 (Src/exec.c)
5255    if created != 0 && isset(WARNCREATEGLOBAL) {
5256        // c:3168
5257        if cur_local <= forklevel || pm.level != 0 {
5258            // c:3169
5259            return;
5260        }
5261    } else if created == 0 && isset(WARNNESTEDVAR) {
5262        // c:3171
5263        if pm.level >= cur_local {
5264            // c:3172
5265            return;
5266        }
5267    } else {
5268        return;
5269    }
5270    if (pm.node.flags as u32 & (PM_SPECIAL | PM_NAMEREF)) != 0 {
5271        // c:3177
5272        return;
5273    }
5274    // c:3180-3190 — walk funcstack, emit zwarn at first FS_FUNC.
5275    let stack = match FUNCSTACK.lock() {
5276        Ok(s) => s,
5277        Err(_) => return,
5278    };
5279    for frame in stack.iter().rev() {
5280        // c:3180 walk most-recent-first
5281        if frame.tp == FS_FUNC {
5282            // c:3181 FS_FUNC
5283            let msg = if created != 0 {
5284                // c:3185
5285                format!(
5286                    "{} parameter {} created globally in function {}",
5287                    pmtype, pm.node.nam, frame.name
5288                )
5289            } else {
5290                // c:3187
5291                format!(
5292                    "{} parameter {} set in enclosing scope in function {}",
5293                    pmtype, pm.node.nam, frame.name
5294                )
5295            };
5296            zwarn(&msg); // c:3189
5297            break; // c:3190
5298        }
5299    }
5300}
5301
5302// intgetfn / strgetfn drift wrappers removed — replaced below with
5303// real C-shape ports `intgetfn(pm: &param) -> i64` (Src/params.c:3993)
5304// and `strgetfn(pm: &param) -> String` (Src/params.c:4029) that read
5305// directly from the union fields `pm->u.val` / `pm->u.str`.
5306
5307// ---------------------------------------------------------------------------
5308// Tests
5309// ---------------------------------------------------------------------------
5310
5311// ===========================================================
5312// Methods moved verbatim from src/ported/vm_helper because their
5313// C counterpart's source file maps 1:1 to this Rust module.
5314// Phase: params
5315// ===========================================================
5316
5317// BEGIN moved-from-exec-rs
5318// (impl ShellExecutor block moved to src/exec_shims.rs — see file marker)
5319
5320// END moved-from-exec-rs
5321
5322// ===========================================================
5323// Free ported moved verbatim from src/ported/vm_helper.
5324// ===========================================================
5325// BEGIN moved-from-exec-rs (free ported)
5326/// Subscript-argument result.
5327///
5328/// `Flags` carries the parsed flag chars and the remaining subscript
5329/// text (the pattern after `(...)`); the caller dispatches the
5330/// search itself. `Value` is the result of an in-getarg array/hash
5331/// pattern search — direct port of getarg's pprog/pattry arm at
5332/// Src/params.c:1672-1719 (array) and 1581-1660 (hash).
5333// `enum getarg_out` is a Rust extension to express the dual-mode
5334// return of `getarg`. C `getarg` (`Src/params.c:1367`) writes back
5335// via out-pointers (`int *inv`, `Value v`, `zlong *w`, ...) and
5336// returns `int`. The Rust port collapses those into one sum-typed
5337// return: `Flags` carries the parsed flag chars + remaining
5338// subscript when no search ran; `Value` carries the search result
5339// from the pprog/pattry arms at c:1581-1660 (hash) / c:1672-1719
5340// (array). Naming kept lowercase to mark this as a port-shape helper
5341// rather than a C-mirrored struct.
5342/// `getarg_out` — see variants.
5343#[allow(non_camel_case_types)]
5344pub enum getarg_out<'a> {
5345    Flags { flags: &'a str, rest: &'a str },
5346    Value(Value),
5347}
5348
5349/// Port of `assignsparam(char *s, char *val, int flags)` from `Src/params.c:3193`. C signature:
5350/// `mod_export Param assignsparam(char *s, char *val, int flags)`.
5351///
5352/// `s` may carry an embedded `[...]` subscript (matching C's
5353/// `strchr(s, '[')` parse). The function operates on the global
5354/// `paramtab` (Src/params.c:515), creating/mutating `Param`
5355/// entries in place. Branches preserved 1:1 with C:
5356///   - c:3203 `isident(s)` — reject non-identifier names.
5357///   - c:3209 `queue_signals()`.
5358///   - c:3210 subscripted path: c:3212 `getvalue` lookup,
5359///     c:3213 `createparam(t, PM_ARRAY)` on miss, c:3216
5360///     PM_READONLY guard, c:3227 ASSPM_WARN drop, c:3228 clear
5361///     PM_DEFAULTED, c:3231 `v = NULL` then re-dispatch by type.
5362///   - c:3232 non-subscripted: c:3233 `getvalue` → c:3234
5363///     `createparam(t, PM_SCALAR)`; c:3236-3250 array/hash type-flip
5364///     to PM_SCALAR (when not PM_SPECIAL|PM_TIED, not KSHARRAYS,
5365///     not ASSPM_AUGMENT) via `resetparam(v->pm, PM_SCALAR)`.
5366///   - c:3258 PM_NAMEREF → c:3259 `valid_refname(val, flags)` guard.
5367///   - c:3269 clear PM_DEFAULTED.
5368///   - c:3343 `assignstrvalue(v, val, flags)`.
5369///   - c:3344 `unqueue_signals()`; c:3345 return v->pm.
5370///
5371/// The full HashTable substrate (vtable callbacks, scope-stacked
5372/// iterators) is not yet wired; non-essential branches such as
5373/// `+= AUGMENT` numeric/array slice append and `check_warn_pm`
5374/// are documented but elided where unreachable from current
5375/// callers — none of those code paths are exercised by zshrs's
5376/// existing call sites.
5377/// c:Src/params.c:395-422 — the IPDEF8 PM_TIED colon-array pairs. A
5378/// SUBSCRIPT modification of the ARRAY side (`path[N]=x`, `path[i,j]=(…)`,
5379/// `unset path[N]`) must re-derive the tied SCALAR side, exactly as C's
5380/// array setfn does after setarrvalue rebuilds the array. zshrs lacks the
5381/// per-name GSU vtable, so the subscript paths carry the tie explicitly via
5382/// this const map (the mirror of the scalar→array cascade); each site joins
5383/// the updated array with `:` and writes the scalar through assignsparam.
5384pub(crate) const TIED_COLON_ARRAYS: &[(&str, &str)] = &[
5385    ("path", "PATH"),
5386    ("fpath", "FPATH"),
5387    ("manpath", "MANPATH"),
5388    ("cdpath", "CDPATH"),
5389    ("psvar", "PSVAR"),
5390    ("module_path", "MODULE_PATH"),
5391    ("fignore", "FIGNORE"),
5392    ("mailpath", "MAILPATH"),
5393];
5394
5395pub fn assignsparam(s: &str, val: &str, flags: i32) -> Option<Param> {
5396    // c:3203 `if (!isident(s)) { zerr; errflag |= ERRFLAG_ERROR; return NULL; }`
5397    if !isident(s) {
5398        zerr(&format!("not an identifier: {}", s)); // c:3204
5399        errflag.fetch_or(
5400            // c:3206
5401            ERRFLAG_ERROR,
5402            Ordering::Relaxed,
5403        );
5404        return None; // c:3207
5405    }
5406    // !!! WARNING: RUST-ONLY ARM — ZLE GSU adapter !!!
5407    // C's makezleparams (Src/Zle/zle_params.c:194) installs LIVE GSU
5408    // setters for the editing specials; zshrs snapshots copies into
5409    // the paramtab (extensions/zle_param_sync.rs), so sequential
5410    // widget mutations read STALE peers (`LBUFFER=tpl; CURSOR=n;
5411    // RBUFFER=${RBUFFER:len}` scrambled zsh-expand's multiline
5412    // snippet). Route these writes through the live editor and let
5413    // live_write re-publish the derived family. Only fires inside an
5414    // active widget scope; live_write's guard blocks its own
5415    // re-publish writes.
5416    // ASSPM_AUGMENT must be resolved BEFORE the live write: `+=`
5417    // routes here with only the APPENDED text as `val`, and writing
5418    // that raw would ASSIGN it (`LBUFFER+=\"` in zsh-autopair's
5419    // _ap-self-insert nuked everything typed before the quote —
5420    // `echo hi "` collapsed to `""`). C never has this problem: its
5421    // augment concatenation happens before gsu.s->setfn dispatch
5422    // (Src/params.c:2742-2748). Concatenate against the LIVE editor
5423    // value (CURSOR augments arithmetically, matching PM_INTEGER).
5424    let zle_special = matches!(s, "BUFFER" | "LBUFFER" | "RBUFFER" | "CURSOR");
5425    let zle_augmented: String;
5426    let zle_val: &str = if zle_special && (flags & ASSPM_AUGMENT) != 0 {
5427        use crate::ported::zle::zle_params as zp;
5428        zle_augmented = match s {
5429            "BUFFER" => format!("{}{}", zp::get_buffer(), val),
5430            "LBUFFER" => format!("{}{}", zp::get_lbuffer(), val),
5431            "RBUFFER" => format!("{}{}", zp::get_rbuffer(), val),
5432            // c:2775-2778 — integer augment is arithmetic add.
5433            _ => {
5434                let old = zp::get_cursor() as i64;
5435                let add = val.trim().parse::<i64>().unwrap_or(0);
5436                (old + add).to_string()
5437            }
5438        };
5439        &zle_augmented
5440    } else {
5441        val
5442    };
5443    if zle_special && crate::zle_param_sync::live_write(s, zle_val) {
5444        return getsparam(s).map(|_| {
5445            Box::new(crate::ported::zsh_h::param {
5446                node: crate::ported::zsh_h::hashnode {
5447                    next: None,
5448                    nam: s.to_string(),
5449                    flags: 0,
5450                },
5451                u_data: 0,
5452                u_tied: None,
5453                u_arr: None,
5454                u_str: Some(zle_val.to_string()),
5455                u_val: 0,
5456                u_dval: 0.0,
5457                u_hash: None,
5458                gsu_s: None,
5459                gsu_i: None,
5460                gsu_f: None,
5461                gsu_a: None,
5462                gsu_h: None,
5463                base: 0,
5464                width: 0,
5465                env: None,
5466                ename: None,
5467                old: None,
5468                level: 0,
5469            })
5470        });
5471    }
5472    // c:3233/3252 — `getvalue(&vbuf, &s, 1)` routes through
5473    // fetchvalue which resolves PM_NAMEREF chains (c:2247-2270).
5474    // Mirror by redirecting the assignment to the resolved target.
5475    {
5476        let base = s.split('[').next().unwrap_or(s);
5477        if crate::ported::params::is_nameref(base) {
5478            match crate::ported::params::resolve_nameref_name(base, None) {
5479                crate::ported::params::nameref_resolution::SelfRef => {
5480                    // zerr emitted inside the walk (c:6341-6343).
5481                    errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed);
5482                    return None;
5483                }
5484                crate::ported::params::nameref_resolution::OutOfScope => {
5485                    // c:1108-1118 — createparam refuses the existing
5486                    // ref; assignment fails (status 1, no errflag —
5487                    // c:3255 is commented out in the C source).
5488                    return None;
5489                }
5490                crate::ported::params::nameref_resolution::Placeholder(last) => {
5491                    // Chain ends at a placeholder/unset ref: the value
5492                    // becomes its new refname (assignstrvalue
5493                    // PM_NAMEREF arm, c:2712-2717 + c:3258).
5494                    // assignstrvalue PM_NAMEREF arm, INLINE per Src/params.c:2690-2720
5495                    // (former vm_helper::nameref_assign_refname,
5496                    // single-caller inline 2026-06-12):
5497                    // c:2696-2697 read-only; c:3258-3264 valid_refname;
5498                    // c:2717 SETREFNAME; c:2845 setscope.
5499                    return (|| -> Option<Param> {
5500                        let last_n: &str = &last;
5501                        let snap = {
5502                            let tab = paramtab().read().ok()?;
5503                            tab.get(last_n).map(|p| (p.node.flags, p.node.nam.clone()))
5504                        };
5505                        let (flags, nam) = snap?;
5506                        if (flags as u32 & PM_READONLY) != 0 {
5507                            // c:2696-2697
5508                            zerr(&format!("read-only variable: {}", nam));
5509                            return None;
5510                        }
5511                        if !val.is_empty() && !valid_refname(val, flags) {
5512                            // c:3258-3264
5513                            zerr(&format!("invalid name reference: {}", val));
5514                            errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed);
5515                            return None;
5516                        }
5517                        {
5518                            let mut tab = paramtab().write().ok()?;
5519                            let pm = tab.get_mut(last_n)?;
5520                            pm.base = 0; // c:6376 shape (rebind resets scope info)
5521                            pm.width = 0;
5522                            pm.u_str = Some(val.to_string()); // c:2717 SETREFNAME
5523                            pm.node.flags &= !(PM_DEFAULTED as i32); // c:3269 + c:2712
5524                        }
5525                        // c:2845 — setscope(v->pm): self-reference detection + base.
5526                        if setscope_by_name(last_n) != 0 {
5527                            errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed);
5528                            return None;
5529                        }
5530                        paramtab().read().ok()?.get(last_n).cloned()
5531                    })();
5532                }
5533                crate::ported::params::nameref_resolution::Target {
5534                    name: t,
5535                    subscript: rsub,
5536                    pm: rpm,
5537                    level,
5538                } => {
5539                    // Rebuild the target expression: ref subscript
5540                    // first (REFSLICE, c:2264-2268), then any caller
5541                    // subscript.
5542                    let user_sub = s.find('[').map(|i| &s[i..]);
5543                    // Hidden (old-chain) binding — the upscope walk
5544                    // (c:6455) resolved past the visible binding;
5545                    // write through the chain node directly.
5546                    if rsub.is_none() && user_sub.is_none() && rpm.is_some() {
5547                        let visible_level = paramtab()
5548                            .read()
5549                            .ok()
5550                            .and_then(|tb| tb.get(&t).map(|p| p.level));
5551                        if visible_level != Some(level) {
5552                            // upscope write (c:6455 landed on a pm->old
5553                            // node), INLINE (former nameref_hidden_scalar_assign).
5554                            return (|name: &str, level: i32, val: &str| -> Option<Param> {
5555                                let mut tab = paramtab().write().ok()?;
5556                                let mut node: &mut param = tab.get_mut(name)?.as_mut();
5557                                while node.level != level {
5558                                    node = node.old.as_mut()?.as_mut();
5559                                }
5560                                if (node.node.flags as u32 & PM_READONLY) != 0 {
5561                                    zerr(&format!("read-only variable: {}", name)); // c:2696
5562                                    return None;
5563                                }
5564                                let t = PM_TYPE(node.node.flags as u32);
5565                                if t == PM_INTEGER {
5566                                    // c:Src/params.c:4007 intsetfn via mathevali.
5567                                    node.u_val = crate::ported::math::mathevali(val).unwrap_or(0);
5568                                } else if t == PM_EFLOAT || t == PM_FFLOAT {
5569                                    node.u_dval = val.trim().parse().unwrap_or(0.0);
5570                                } else {
5571                                    // c:3236-3250 — array/hash → scalar type flip through ref.
5572                                    let type_mask = PM_TYPE(u32::MAX) as i32;
5573                                    node.node.flags =
5574                                        (node.node.flags & !type_mask) | PM_SCALAR as i32;
5575                                    node.u_arr = None;
5576                                    node.u_str = Some(val.to_string());
5577                                }
5578                                node.node.flags &= !((PM_UNSET | PM_DECLARED) as i32); // c:2712 + c:3269
5579                                Some(Box::new(node.clone()))
5580                            })(&t, level, val);
5581                        }
5582                    }
5583                    let mut new_s = t.clone();
5584                    if let Some(rs) = &rsub {
5585                        new_s.push('[');
5586                        new_s.push_str(rs);
5587                        new_s.push(']');
5588                    }
5589                    if let Some(us) = user_sub {
5590                        new_s.push_str(us);
5591                    }
5592                    if new_s != s {
5593                        return assignsparam(&new_s, val, flags);
5594                    }
5595                }
5596                crate::ported::params::nameref_resolution::NotRef => {}
5597            }
5598        }
5599    }
5600    // c:Src/params.c randomsetfn / secondssetfn — re-assigning a
5601    // regenerator-style special clears the PM_UNSET flag so the
5602    // getfn becomes live again. zshrs's lookup_special_var uses a
5603    // side-set for this (no real pm node), so clear it here. Bug #417.
5604    if matches!(
5605        s,
5606        "RANDOM" | "SECONDS" | "EPOCHSECONDS" | "EPOCHREALTIME" | "TTYIDLE" | "ERRNO"
5607    ) {
5608        clear_unset_special(s);
5609    }
5610    queue_signals(); // c:3209
5611
5612    // c:3210 — `strchr(s, '[')`. Split the leading name from the
5613    // subscript while preserving C's `*ss = '\0'` / `*ss = '['`
5614    // restore semantics: the Rust port works on `&str` slices so
5615    // there's no in-place null-terminator dance, but the parse
5616    // shape is identical.
5617    // c:Src/params.c:3210 — `strchr(s, '[')` finds the subscript
5618    // opener; C's parse_subscript (params.c:1480+) then walks to the
5619    // matching `]` respecting backslash-escapes and nesting. zshrs's
5620    // previous `s.rfind(']')` picked the LAST `]` which works for
5621    // simple `name[key]` but mis-bounds `A[\[k\]]` (where the key
5622    // contains escaped brackets).
5623    let (name, subscript) = match s.find('[') {
5624        Some(i) => {
5625            // Walk forward from `i + 1` matching brackets with
5626            // escape-awareness per parse_subscript semantics.
5627            let mut depth = 1i32;
5628            let mut close_byte: Option<usize> = None;
5629            let mut bslash = false;
5630            let mut byte_off = i + 1;
5631            for ch in s[i + 1..].chars() {
5632                if bslash {
5633                    bslash = false;
5634                    byte_off += ch.len_utf8();
5635                    continue;
5636                }
5637                match ch {
5638                    '\\' => bslash = true,
5639                    '[' => depth += 1,
5640                    ']' => {
5641                        depth -= 1;
5642                        if depth == 0 {
5643                            close_byte = Some(byte_off);
5644                            break;
5645                        }
5646                    }
5647                    _ => {}
5648                }
5649                byte_off += ch.len_utf8();
5650            }
5651            let close = close_byte.unwrap_or(s.len());
5652            let key_end = if close > i { close } else { s.len() };
5653            (&s[..i], Some(&s[i + 1..key_end]))
5654        }
5655        None => (s, None),
5656    };
5657    // c:Src/params.c parse_subscript — backslash-escapes in the
5658    // subscript body (`\[`, `\]`, `\\`) are stripped to their literal
5659    // form for the actual key value. `A[\[k\]]=v` stores under key
5660    // `[k]`. zshrs's subscript extractor above preserved the escapes
5661    // verbatim, so the stored key was `\[k\]` and the matching lookup
5662    // `${A[[k]]}` couldn't find it.
5663    let subscript_owned: Option<String> = subscript.map(|key| {
5664        let mut out = String::with_capacity(key.len());
5665        let mut bslash = false;
5666        for ch in key.chars() {
5667            if bslash {
5668                out.push(ch);
5669                bslash = false;
5670            } else if ch == '\\' {
5671                bslash = true;
5672            } else {
5673                out.push(ch);
5674            }
5675        }
5676        if bslash {
5677            out.push('\\');
5678        }
5679        out
5680    });
5681    let subscript: Option<&str> = subscript_owned.as_deref();
5682
5683    // c:Src/params.c — a bare all-digit parameter name (`2=X`,
5684    // `2+=end`) addresses the positional parameter $N (1-based). C maps
5685    // digit names onto the special `argv` value (isident accepts them,
5686    // c:1336-1340); zshrs keeps positionals in PPARAMS, so store there
5687    // directly — the same backing the `argv[N]=`/`@[N]=` path uses
5688    // below. `$0` (n==0) is the shell/script name, not a positional, so
5689    // it falls through to the normal param path. `+=` concatenates onto
5690    // the existing positional (ASSPM_AUGMENT scalar append, c:3270-3276).
5691    if subscript.is_none() && !name.is_empty() && name.bytes().all(|b| b.is_ascii_digit()) {
5692        if let Ok(n) = name.parse::<usize>() {
5693            if n >= 1 {
5694                if let Ok(mut pp) = crate::ported::builtin::PPARAMS.lock() {
5695                    while pp.len() < n {
5696                        pp.push(String::new());
5697                    }
5698                    let idx = n - 1;
5699                    if (flags & ASSPM_AUGMENT) != 0 {
5700                        let cur = pp[idx].clone();
5701                        pp[idx] = format!("{}{}", cur, val);
5702                    } else {
5703                        pp[idx] = val.to_string();
5704                    }
5705                }
5706                unqueue_signals();
5707                return Some(Box::new(param {
5708                    node: hashnode {
5709                        next: None,
5710                        nam: name.to_string(),
5711                        flags: PM_SCALAR as i32,
5712                    },
5713                    u_data: 0,
5714                    u_tied: None,
5715                    u_arr: None,
5716                    u_str: Some(val.to_string()),
5717                    u_val: 0,
5718                    u_dval: 0.0,
5719                    u_hash: None,
5720                    gsu_s: None,
5721                    gsu_i: None,
5722                    gsu_f: None,
5723                    gsu_a: None,
5724                    gsu_h: None,
5725                    base: 0,
5726                    width: 0,
5727                    env: None,
5728                    ename: None,
5729                    old: None,
5730                    level: 0,
5731                }));
5732            }
5733        }
5734    }
5735
5736    // c:Src/Modules/parameter.c — magic associative-array assignment
5737    // forms: `functions[name]=body`, `aliases[name]=value`,
5738    // `dis_functions[name]=body`, `saliases[name]=value`,
5739    // `galiases[name]=value`, `dis_aliases[name]=value`, etc.
5740    // These reach assignsparam as `name[key]=value` shape and must
5741    // dispatch to the canonical setpmfunction / setpmalias hooks
5742    // BEFORE the generic paramtab subscript store. Without this
5743    // route, the assignment lands in paramtab_hashed_storage as
5744    // a normal assoc element and the corresponding function/alias
5745    // is never actually defined in shfunctab/aliastab.
5746    if let Some(key) = subscript {
5747        match name {
5748            "functions" => {
5749                use crate::ported::zsh_h::hashnode;
5750                use crate::ported::zsh_h::param as ParamStruct;
5751                // c:Src/params.c:3270-3276 — ASSPM_AUGMENT on PM_SCALAR
5752                // appends raw text to the existing value. The
5753                // `functions[name]` magic-assoc is scalar-typed under
5754                // the hood (each entry is a function-body string). For
5755                // `functions[f]+="echo b"`, fetch the existing body
5756                // from shfunctab and concatenate before calling
5757                // setpmfunction. Without this, `+=` silently no-ops.
5758                // Bug #323 in docs/BUGS.md.
5759                let final_body = if (flags & ASSPM_AUGMENT) != 0 {
5760                    let existing = crate::ported::hashtable::shfunctab_lock()
5761                        .read()
5762                        .ok()
5763                        .and_then(|tab| tab.get(key).and_then(|shf| shf.body.clone()))
5764                        .unwrap_or_default();
5765                    format!("{}{}", existing, val)
5766                } else {
5767                    val.to_string()
5768                };
5769                let pm: Box<ParamStruct> = Box::new(ParamStruct {
5770                    node: hashnode {
5771                        next: None,
5772                        nam: key.to_string(),
5773                        flags: 0,
5774                    },
5775                    u_data: 0,
5776                    u_tied: None,
5777                    u_arr: None,
5778                    u_str: None,
5779                    u_val: 0,
5780                    u_dval: 0.0,
5781                    u_hash: None,
5782                    gsu_s: None,
5783                    gsu_i: None,
5784                    gsu_f: None,
5785                    gsu_a: None,
5786                    gsu_h: None,
5787                    base: 0,
5788                    width: 0,
5789                    env: None,
5790                    ename: None,
5791                    old: None,
5792                    level: 0,
5793                });
5794                crate::ported::modules::parameter::setpmfunction(pm.clone(), final_body);
5795                unqueue_signals();
5796                return Some(pm);
5797            }
5798            "dis_functions" => {
5799                use crate::ported::zsh_h::hashnode;
5800                use crate::ported::zsh_h::param as ParamStruct;
5801                let pm: Box<ParamStruct> = Box::new(ParamStruct {
5802                    node: hashnode {
5803                        next: None,
5804                        nam: key.to_string(),
5805                        flags: 0,
5806                    },
5807                    u_data: 0,
5808                    u_tied: None,
5809                    u_arr: None,
5810                    u_str: None,
5811                    u_val: 0,
5812                    u_dval: 0.0,
5813                    u_hash: None,
5814                    gsu_s: None,
5815                    gsu_i: None,
5816                    gsu_f: None,
5817                    gsu_a: None,
5818                    gsu_h: None,
5819                    base: 0,
5820                    width: 0,
5821                    env: None,
5822                    ename: None,
5823                    old: None,
5824                    level: 0,
5825                });
5826                crate::ported::modules::parameter::setpmdisfunction(pm.clone(), val.to_string());
5827                unqueue_signals();
5828                return Some(pm);
5829            }
5830            "aliases" => {
5831                // c:Src/Modules/parameter.c — install a regular
5832                // alias via canonical aliastab. Use createaliasnode
5833                // with default flags (no ALIAS_GLOBAL / ALIAS_SUFFIX).
5834                if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
5835                    let node = crate::ported::hashtable::createaliasnode(key, val, 0u32);
5836                    tab.add(node);
5837                }
5838                unqueue_signals();
5839                return Some(Box::new(crate::ported::zsh_h::param {
5840                    node: crate::ported::zsh_h::hashnode {
5841                        next: None,
5842                        nam: key.to_string(),
5843                        flags: 0,
5844                    },
5845                    u_data: 0,
5846                    u_tied: None,
5847                    u_arr: None,
5848                    u_str: Some(val.to_string()),
5849                    u_val: 0,
5850                    u_dval: 0.0,
5851                    u_hash: None,
5852                    gsu_s: None,
5853                    gsu_i: None,
5854                    gsu_f: None,
5855                    gsu_a: None,
5856                    gsu_h: None,
5857                    base: 0,
5858                    width: 0,
5859                    env: None,
5860                    ename: None,
5861                    old: None,
5862                    level: 0,
5863                }));
5864            }
5865            "galiases" => {
5866                // c:Src/Modules/parameter.c — global alias.
5867                if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
5868                    let node = crate::ported::hashtable::createaliasnode(
5869                        key,
5870                        val,
5871                        crate::ported::zsh_h::ALIAS_GLOBAL as u32,
5872                    );
5873                    tab.add(node);
5874                }
5875                unqueue_signals();
5876                return Some(Box::new(crate::ported::zsh_h::param {
5877                    node: crate::ported::zsh_h::hashnode {
5878                        next: None,
5879                        nam: key.to_string(),
5880                        flags: 0,
5881                    },
5882                    u_data: 0,
5883                    u_tied: None,
5884                    u_arr: None,
5885                    u_str: Some(val.to_string()),
5886                    u_val: 0,
5887                    u_dval: 0.0,
5888                    u_hash: None,
5889                    gsu_s: None,
5890                    gsu_i: None,
5891                    gsu_f: None,
5892                    gsu_a: None,
5893                    gsu_h: None,
5894                    base: 0,
5895                    width: 0,
5896                    env: None,
5897                    ename: None,
5898                    old: None,
5899                    level: 0,
5900                }));
5901            }
5902            "saliases" => {
5903                // c:Src/Modules/parameter.c — suffix alias.
5904                if let Ok(mut tab) = crate::ported::hashtable::sufaliastab_lock().write() {
5905                    let node = crate::ported::hashtable::createaliasnode(
5906                        key,
5907                        val,
5908                        crate::ported::zsh_h::ALIAS_SUFFIX as u32,
5909                    );
5910                    tab.add(node);
5911                }
5912                unqueue_signals();
5913                return Some(Box::new(crate::ported::zsh_h::param {
5914                    node: crate::ported::zsh_h::hashnode {
5915                        next: None,
5916                        nam: key.to_string(),
5917                        flags: 0,
5918                    },
5919                    u_data: 0,
5920                    u_tied: None,
5921                    u_arr: None,
5922                    u_str: Some(val.to_string()),
5923                    u_val: 0,
5924                    u_dval: 0.0,
5925                    u_hash: None,
5926                    gsu_s: None,
5927                    gsu_i: None,
5928                    gsu_f: None,
5929                    gsu_a: None,
5930                    gsu_h: None,
5931                    base: 0,
5932                    width: 0,
5933                    env: None,
5934                    ename: None,
5935                    old: None,
5936                    level: 0,
5937                }));
5938            }
5939            "options" => {
5940                // c:Src/Modules/parameter.c:926 setpmoption — userspace
5941                // `options[X]=on|off` toggles option X via dosetopt, not
5942                // a generic assoc-write. Build a synthetic Param whose
5943                // node.nam carries the option name and dispatch to the
5944                // canonical setpmoption port (`src/ported/modules/
5945                // parameter.rs:1620`), which calls optlookup + dosetopt.
5946                use crate::ported::zsh_h::hashnode;
5947                use crate::ported::zsh_h::param as ParamStruct;
5948                let pm: Box<ParamStruct> = Box::new(ParamStruct {
5949                    node: hashnode {
5950                        next: None,
5951                        nam: key.to_string(),
5952                        flags: 0,
5953                    },
5954                    u_data: 0,
5955                    u_tied: None,
5956                    u_arr: None,
5957                    u_str: None,
5958                    u_val: 0,
5959                    u_dval: 0.0,
5960                    u_hash: None,
5961                    gsu_s: None,
5962                    gsu_i: None,
5963                    gsu_f: None,
5964                    gsu_a: None,
5965                    gsu_h: None,
5966                    base: 0,
5967                    width: 0,
5968                    env: None,
5969                    ename: None,
5970                    old: None,
5971                    level: 0,
5972                });
5973                crate::ported::modules::parameter::setpmoption(pm.clone(), val.to_string());
5974                unqueue_signals();
5975                return Some(pm);
5976            }
5977            "commands" => {
5978                // c:Src/Modules/parameter.c:151-160 setpmcommand —
5979                // `commands[name]=path` installs a HASHED Cmdnam
5980                // node in cmdnamtab (`cn->node.flags = HASHED;
5981                // cn->u.cmd = ztrdup(value); cmdnamtab->addnode`),
5982                // exactly like `hash name=path`. zsh ACCEPTS the
5983                // write (verified vs zsh 5.9: rc=0, readback works,
5984                // whence/hash see the entry). Build a synthetic
5985                // Param whose node.nam carries the command name and
5986                // dispatch to the canonical setpmcommand port.
5987                // Bug #375.
5988                use crate::ported::zsh_h::hashnode;
5989                use crate::ported::zsh_h::param as ParamStruct;
5990                let pm: Box<ParamStruct> = Box::new(ParamStruct {
5991                    node: hashnode {
5992                        next: None,
5993                        nam: key.to_string(),
5994                        flags: 0,
5995                    },
5996                    u_data: 0,
5997                    u_tied: None,
5998                    u_arr: None,
5999                    u_str: None,
6000                    u_val: 0,
6001                    u_dval: 0.0,
6002                    u_hash: None,
6003                    gsu_s: None,
6004                    gsu_i: None,
6005                    gsu_f: None,
6006                    gsu_a: None,
6007                    gsu_h: None,
6008                    base: 0,
6009                    width: 0,
6010                    env: None,
6011                    ename: None,
6012                    old: None,
6013                    level: 0,
6014                });
6015                crate::ported::modules::parameter::setpmcommand(pm.clone(), val.to_string());
6016                unqueue_signals();
6017                return Some(pm);
6018            }
6019            "mapfile" => {
6020                // c:Src/Modules/mapfile.c:68 setpmmapfile —
6021                // `mapfile[fname]=value` WRITES `value` to the file
6022                // named by the key (creating it if needed). The setfn
6023                // is ported; route the element-assign to it (the
6024                // PartabHashEntry dispatch carries only get/scan).
6025                use crate::ported::zsh_h::hashnode;
6026                use crate::ported::zsh_h::param as ParamStruct;
6027                crate::ported::modules::mapfile::setpmmapfile(key, val, false);
6028                let pm: Box<ParamStruct> = Box::new(ParamStruct {
6029                    node: hashnode {
6030                        next: None,
6031                        nam: key.to_string(),
6032                        flags: 0,
6033                    },
6034                    u_data: 0,
6035                    u_tied: None,
6036                    u_arr: None,
6037                    u_str: None,
6038                    u_val: 0,
6039                    u_dval: 0.0,
6040                    u_hash: None,
6041                    gsu_s: None,
6042                    gsu_i: None,
6043                    gsu_f: None,
6044                    gsu_a: None,
6045                    gsu_h: None,
6046                    base: 0,
6047                    width: 0,
6048                    env: None,
6049                    ename: None,
6050                    old: None,
6051                    level: 0,
6052                });
6053                unqueue_signals();
6054                return Some(pm);
6055            }
6056            _ => {}
6057        }
6058    }
6059
6060    // c:Src/params.c:3262 IPDEF9 — `argv`, `@`, `*` are aliases for
6061    // the global `pparams` (positional parameter vector). Whole-
6062    // array writes (`argv=(...)`) route through assignaparam at
6063    // params.rs:5487 which updates PPARAMS. Subscripted writes
6064    // (`argv[N]=val`) must do the same — without this, the value
6065    // landed in paramtab's u_arr but `$@`/`$1`/`$2`/... continued
6066    // reading from PPARAMS, so the update was invisible. Bug #281
6067    // in docs/BUGS.md.
6068    if let Some(key) = subscript {
6069        if matches!(name, "argv" | "@" | "*") {
6070            if let Ok(idx) = key.trim().parse::<i64>() {
6071                if let Ok(mut pp) = crate::ported::builtin::PPARAMS.lock() {
6072                    let len = pp.len() as i64;
6073                    let kshzero =
6074                        crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHZEROSUBSCRIPT);
6075                    if idx == 0 && !isset(KSHARRAYS) && !kshzero {
6076                        zerr(&format!("{}: assignment to invalid subscript range", name));
6077                        drop(pp);
6078                        unqueue_signals();
6079                        return None;
6080                    }
6081                    let real_idx = if idx < 0 {
6082                        (len + idx).max(0) as usize
6083                    } else if isset(KSHARRAYS) {
6084                        idx.max(0) as usize
6085                    } else {
6086                        (idx - 1).max(0) as usize
6087                    };
6088                    while pp.len() <= real_idx {
6089                        pp.push(String::new());
6090                    }
6091                    pp[real_idx] = val.to_string();
6092                }
6093                unqueue_signals();
6094                return Some(Box::new(param {
6095                    node: hashnode {
6096                        next: None,
6097                        nam: name.to_string(),
6098                        flags: PM_ARRAY as i32,
6099                    },
6100                    u_data: 0,
6101                    u_tied: None,
6102                    u_arr: None,
6103                    u_str: None,
6104                    u_val: 0,
6105                    u_dval: 0.0,
6106                    u_hash: None,
6107                    gsu_s: None,
6108                    gsu_i: None,
6109                    gsu_f: None,
6110                    gsu_a: None,
6111                    gsu_h: None,
6112                    base: 0,
6113                    width: 0,
6114                    env: None,
6115                    ename: None,
6116                    old: None,
6117                    level: 0,
6118                }));
6119            }
6120        }
6121    }
6122
6123    // c:Src/Modules/parameter.c — read-only magic-assoc names. C
6124    // zsh's SPECIALPMDEF / createspecialhash sets PM_READONLY on
6125    // these so userspace assignment errors with `read-only
6126    // variable: NAME`. `vm_helper::init_partab_params` strips
6127    // PM_READONLY off the paramtab stub to allow INTERNAL writes
6128    // (function-call funcstack push, etc.) — see comment at
6129    // vm_helper.rs:2799. The trade-off is that USERSPACE writes
6130    // now slip through too. Intercept here, AFTER the writable-
6131    // magic-assoc dispatch arms above (functions, aliases,
6132    // dis_aliases, galiases, saliases, dis_functions) but BEFORE
6133    // the generic paramtab subscript store, so the userspace path
6134    // emits the canonical diagnostic and exits non-zero. Internal
6135    // table mutations that bypass `assignsparam` stay free. Bug
6136    // #242 in docs/BUGS.md.
6137    if subscript.is_some() {
6138        // `options` is intentionally NOT in this list — C zsh's
6139        // `setpmoption`/`setpmoptions` (Src/Modules/parameter.c:926-979)
6140        // accept "on"/"off" writes and translate them to dosetopt calls.
6141        // The `options` arm above this readonly-list check routes
6142        // `options[X]=on|off` writes through the canonical setpmoption
6143        // port, so by the time we reach here `options` has already
6144        // returned. Bug #342.
6145        // `commands` is intentionally NOT in this list — C zsh's
6146        // `setpmcommand` (Src/Modules/parameter.c:151-160) ACCEPTS
6147        // `commands[name]=path` and installs a HASHED Cmdnam node in
6148        // cmdnamtab (same effect as `hash name=path`). Verified vs
6149        // zsh 5.9: `commands[x]=/y` → rc=0, ${commands[x]} → /y,
6150        // `whence x` → /y. The `commands` arm above routes through
6151        // the canonical setpmcommand port. Bug #375.
6152        let is_readonly_magic = matches!(
6153            name,
6154            "builtins"
6155                | "modules"
6156                | "parameters"
6157                | "dis_builtins"
6158                | "history"
6159                | "historywords"
6160                | "jobtexts"
6161                | "jobstates"
6162                | "jobdirs"
6163                | "nameddirs"
6164                | "userdirs"
6165                | "usergroups"
6166                | "widgets"
6167                | "functions_source"
6168                | "dis_functions_source"
6169                | "terminfo"
6170                | "termcap"
6171        );
6172        if is_readonly_magic {
6173            zerr(&format!("read-only variable: {}", name));
6174            errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed);
6175            unqueue_signals();
6176            return None;
6177        }
6178    }
6179
6180    // Subscripted path (c:3210-3231).
6181    if let Some(key) = subscript {
6182        // c:params.c:1601 (getarg) — a non-hashed parameter's subscript is an
6183        // ARITHMETIC expression. Resolve it to a numeric index HERE, before
6184        // the paramtab write lock below, because mathevalarg reads parameters
6185        // and would deadlock against a held write lock. Associative arrays use
6186        // the raw string key instead, so only pre-evaluate for non-hashed.
6187        let is_hashed = {
6188            let flagged = {
6189                let tab = paramtab().read().unwrap();
6190                tab.get(name)
6191                    .map(|pm| (pm.node.flags as u32 & PM_HASHED) != 0)
6192                    .unwrap_or(false)
6193            };
6194            // c:Src/Zle/complete.c — `$compstate` is a special ASSOCIATIVE
6195            // parameter of the completion system. zshrs does not wire it as a
6196            // real PM_HASHED paramtab node (complete.rs:1682 defers that); its
6197            // values live in the parallel `paramtab_hashed_storage()` store fed
6198            // by set_compstate_str. A subscript WRITE like `compstate[insert]=
6199            // menu` from a completion widget must take the ASSOCIATIVE path so
6200            // the string key `insert` is NOT arithmetic-evaluated to 0 (which
6201            // then errored `assignment to invalid subscript range` — flooding
6202            // every tab-completion). Recognise it as hashed whenever the name
6203            // is a known special assoc OR already has hash-storage backing.
6204            // Removing the undeclared-subscript auto-vivify (correct for user
6205            // params like `as[k1]=`) regressed this because compstate relied on
6206            // that vivify; a truly-undeclared user param still errors.
6207            // `$compstate` is the completion system's special assoc (the only
6208            // special assoc user completers write with string keys).
6209            flagged
6210                || name == "compstate"
6211                || paramtab_hashed_storage()
6212                    .lock()
6213                    .map(|s| s.contains_key(name))
6214                    .unwrap_or(false)
6215        };
6216        let resolved_idx: i64 = if is_hashed {
6217            0 // unused for hashed params
6218        } else {
6219            key.parse::<i64>().unwrap_or_else(|_| {
6220                // c:Src/params.c:2058 getarg → c:1585-1593 — a subscript that
6221                // is not a plain literal is parameter-expanded (singsub) BEFORE
6222                // arithmetic evaluation. When assignsparam is reached with a
6223                // raw, UNEXPANDED subscript — e.g. `setsparam("pgid[$#pgid+1]",
6224                // …)` from the sysread/read builtins, whose target string the
6225                // command compiler never pre-expanded — `$#pgid` / `$n` /
6226                // `${#a}` must be resolved here, or mathevalarg sees a literal
6227                // `$` and yields 0 → "invalid subscript range" (gitstatus's
6228                // `_gitstatus_daemon_p9k_` hit this on every prompt). The
6229                // shell-level `a[$n+1]=v` form already arrives expanded, so its
6230                // numeric key takes the parse fast-path above and skips this.
6231                let expanded = crate::ported::subst::singsub(&key);
6232                crate::ported::math::mathevalarg(&expanded)
6233            })
6234        };
6235        let mut tab = paramtab().write().unwrap();
6236        let exists = tab.contains_key(name); // c:3212
6237        if !exists {
6238            // c:3213 `createparam(t, PM_ARRAY); created = 1;` — but when the
6239            // name is a hash-storage-backed associative (e.g. `$compstate`),
6240            // create it PM_HASHED so the dispatch below routes to the assoc
6241            // element store instead of the numeric-subscript array path.
6242            let (create_flags, create_arr) = if is_hashed {
6243                (PM_HASHED as i32, None)
6244            } else {
6245                (PM_ARRAY as i32, Some(Vec::new()))
6246            };
6247            let pm: Param = Box::new(param {
6248                node: hashnode {
6249                    next: None,
6250                    nam: name.to_string(),
6251                    flags: create_flags,
6252                },
6253                u_data: 0,
6254                u_tied: None,
6255                u_arr: create_arr,
6256                u_str: None,
6257                u_val: 0,
6258                u_dval: 0.0,
6259                u_hash: None,
6260                gsu_s: None,
6261                gsu_i: None,
6262                gsu_f: None,
6263                gsu_a: None,
6264                gsu_h: None,
6265                base: 0,
6266                width: 0,
6267                env: None,
6268                ename: None,
6269                old: None,
6270                level: 0,
6271            });
6272            tab.insert(name.to_string(), pm);
6273        } else {
6274            // c:3216 `if (v->pm->node.flags & PM_READONLY)`.
6275            let pm = tab.get(name).unwrap();
6276            if (pm.node.flags as u32 & PM_READONLY) != 0 {
6277                zerr(&format!("read-only variable: {}", pm.node.nam)); // c:3217
6278                drop(tab);
6279                unqueue_signals(); // c:3220
6280                return None; // c:3221
6281            }
6282        }
6283        // c:3231 `v = NULL;` — re-dispatch by storage type.
6284        let pm = tab.get_mut(name).unwrap();
6285        pm.node.flags &= !(PM_DEFAULTED as i32); // c:3228
6286        if (pm.node.flags as u32 & PM_HASHED) != 0 {
6287            // PM_HASHED element store. `param.u_hash` is typed
6288            // `Option<HashTable>` per Src/zsh.h:1841 but the
6289            // HashTable runtime backing isn't wired; the assoc-array
6290            // values live in a parallel storage keyed on param name
6291            // (`paramtab_hashed_storage()`).
6292            let mut store = paramtab_hashed_storage().lock().unwrap();
6293            store
6294                .entry(name.to_string())
6295                .or_default()
6296                .insert(key.to_string(), val.to_string());
6297        } else {
6298            // Non-hashed param: subscript already arithmetic-resolved above
6299            // (c:params.c:1601). zsh never auto-creates an assoc here, so
6300            // `as[k1]=bb` on undeclared `as` uses idx 0 → invalid range.
6301            let idx = resolved_idx;
6302            // c:Src/params.c:2748-2789 — PM_SCALAR + numeric subscript
6303            // SPLICES the value into the scalar's char string
6304            // (`a=hello; a[2]=X` → `hXllo`). Only PM_ARRAY does
6305            // element-store at this subscript. Bug #589 in
6306            // docs/BUGS.md: zshrs's subscript store always treated
6307            // numeric subscript as array-store, so `a[2]=X` on a
6308            // scalar cleared `u_str` and put "X" at array slot 1,
6309            // leaving `$a` displaying as `" X"` (empty + space + X)
6310            // when joined.
6311            let pm_type = PM_TYPE(pm.node.flags as u32);
6312            if pm_type == PM_SCALAR {
6313                // c:2748+ scalar splice — replace chars at index.
6314                let s = pm.u_str.clone().unwrap_or_default();
6315                let chars: Vec<char> = s.chars().collect();
6316                let len = chars.len() as i64;
6317                let kshzero = crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHZEROSUBSCRIPT);
6318                if idx == 0 && !isset(KSHARRAYS) && !kshzero {
6319                    zerr(&format!("{}: assignment to invalid subscript range", name));
6320                    drop(tab);
6321                    unqueue_signals();
6322                    return None;
6323                }
6324                // 1-based forward, negative-from-end. KSHARRAYS = 0-based.
6325                let raw = if idx < 0 {
6326                    len + idx
6327                } else if isset(KSHARRAYS) {
6328                    idx
6329                } else {
6330                    idx - 1
6331                };
6332                let mut out = String::with_capacity(s.len() + val.len());
6333                if raw < 0 {
6334                    // c:Src/params.c:2721-2733 (assignstrvalue) — a negative
6335                    // subscript that points PAST the start of the string
6336                    // (`a[-4]` on "abc") clamps BOTH start and end to 0, so
6337                    // the slice old[0..0] is empty and val is INSERTED at the
6338                    // front (overwriting nothing): `a[-4]="x"` → "xabc". The
6339                    // prior port clamped to index 0 and OVERWROTE char 0.
6340                    out.push_str(val);
6341                    out.extend(chars.iter());
6342                } else {
6343                    // Replace one char at real_idx with val. If real_idx is
6344                    // past the end, append (C clamps end to zlen and concats).
6345                    let real_idx = (raw as usize).min(chars.len());
6346                    out.extend(chars[..real_idx].iter());
6347                    out.push_str(val);
6348                    if real_idx < chars.len() {
6349                        out.extend(chars[real_idx + 1..].iter());
6350                    }
6351                }
6352                pm.u_str = Some(out);
6353            } else {
6354                // PM_ARRAY + numeric subscript (c:3357 `assignaparam`).
6355                // c:Src/params.c:2125-2150 + c:2911 — `a[0]=val` under
6356                // default zsh semantics (no KSHARRAYS, no KSHZEROSUBSCRIPT)
6357                // produces VALFLAG_EMPTY in getarg, which setarrvalue
6358                // then rejects with "assignment to invalid subscript
6359                // range".
6360                let kshzero = crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHZEROSUBSCRIPT);
6361                if idx == 0 && !isset(KSHARRAYS) && !kshzero {
6362                    zerr(&format!("{}: assignment to invalid subscript range", name)); // c:2911 (effective)
6363                    drop(tab);
6364                    unqueue_signals();
6365                    return None;
6366                }
6367                // c:2966-2967 — capture PM_UNIQUE before the mutable
6368                // borrow so the post-splice dedup below can fire.
6369                let uniq = (pm.node.flags as u32 & PM_UNIQUE) != 0;
6370                let arr = pm.u_arr.get_or_insert_with(Vec::new);
6371                let len = arr.len() as i64;
6372                let real_idx = if idx < 0 {
6373                    len + idx
6374                } else if isset(KSHARRAYS) {
6375                    idx
6376                } else {
6377                    idx - 1
6378                };
6379                if real_idx < 0 {
6380                    // c:Src/params.c:2930-2946 (setarrvalue) — a negative
6381                    // subscript that points PAST the start of the array
6382                    // (`a[-7]` on a 5-element array) clamps BOTH start and
6383                    // end to 0, so the splice `old[start..end]` =
6384                    // `old[0..0]` replaces nothing and the value is
6385                    // INSERTED at the front. The prior port clamped to
6386                    // index 0 and OVERWROTE element 0 (`42 2 3 4 5`);
6387                    // zsh prepends keeping every element (`42 1 2 3 4 5`).
6388                    arr.insert(0, val.to_string());
6389                } else {
6390                    let real_idx = real_idx as usize;
6391                    while arr.len() <= real_idx {
6392                        arr.push(String::new());
6393                    }
6394                    arr[real_idx] = val.to_string();
6395                }
6396                // c:2966-2967 — `if (pm->node.flags & PM_UNIQUE)
6397                // arrunique(pm->u.arr)`. A subscript element assignment to a
6398                // PM_UNIQUE array dedupes in-place (first occurrence wins):
6399                // `typeset -aU u=(a b c); u[2]=c` → (a c). setarrvalue
6400                // already does this at its tail; the single-index element
6401                // path bypassed it.
6402                if uniq {
6403                    let mut seen: std::collections::HashSet<String> =
6404                        std::collections::HashSet::new();
6405                    arr.retain(|s| seen.insert(s.clone())); // c:2967
6406                }
6407                pm.u_str = None;
6408            }
6409        }
6410        let cloned = pm.clone();
6411        drop(tab);
6412        unqueue_signals(); // c:3344
6413                           // c:Src/params.c:2922 — `pm->gsu.a->setfn(pm, val)`. In C a
6414                           // SUBSCRIPT assignment (`path[2]=x`) rebuilds the whole array
6415                           // and hands it to the param's array setfn, so a tied
6416                           // colon-array (path→PATH, fpath→FPATH, …) re-derives its
6417                           // scalar/env side. zshrs's element path above wrote `u_arr`
6418                           // directly and skipped the setfn, so `path[2]=/NEW` left
6419                           // $PATH stale (`echo $PATH` showed the pre-edit value). Mirror
6420                           // `arrsetfn`'s ename sync here. zshrs carries the tie via the
6421                           // explicit colon-array name map (mirror of the scalar→array
6422                           // cascade at params.rs:~6396); rejoin the array with `:` and
6423                           // write the scalar side so `echo $PATH` sees the edit.
6424        // c:Src/params.c:2922 — a tied colon-array element assignment
6425        // re-derives the scalar side (path→PATH etc.); see
6426        // TIED_COLON_ARRAYS. The direct-u_arr element path above skipped it.
6427        let base = name.split('[').next().unwrap_or(name);
6428        if let Some((_, scalar)) = TIED_COLON_ARRAYS.iter().find(|(a, _)| *a == base) {
6429            let joined = cloned.u_arr.as_deref().unwrap_or(&[]).join(":");
6430            assignsparam(scalar, &joined, 0);
6431        }
6432        return Some(cloned); // c:3345
6433    }
6434
6435    // c:3232 non-subscripted branch.
6436    let mut tab = paramtab().write().unwrap();
6437    let existing = tab.contains_key(name);
6438    let created_now = !existing; // c:3232 createparam path sets `created = 1`
6439    if !existing {
6440        // c:3234 `createparam(t, PM_SCALAR); created = 1;`
6441        let mut pm_flags = PM_SCALAR as i32;
6442        if isset(ALLEXPORT) {
6443            // c:1149-1150 (ALLEXPORT path)
6444            pm_flags |= PM_EXPORTED as i32;
6445        }
6446        // c:1135-1160 — `createparam` installs gsu via the special-
6447        // params table when the name matches a PM_SPECIAL entry. C
6448        // walks `paramtab->getnode(name)` first, but for fresh
6449        // creations the gsu pointer comes from the IPDEF macro the
6450        // paramdef ships with. Mirror by looking up the name in the
6451        // special-scalar GSU table — same end-state as if
6452        // createparamtable had run.
6453        let gsu_s: Option<Box<gsu_scalar>> = match name {
6454            "0" => {
6455                pm_flags |= PM_SPECIAL as i32;
6456                Some(Box::new(ARGZERO_GSU.clone())) // c:225-226 / IPDEF2("0", argzero_gsu, 0)
6457            }
6458            "HOME" => {
6459                pm_flags |= PM_SPECIAL as i32;
6460                Some(Box::new(HOME_GSU.clone())) // c:248
6461            }
6462            "IFS" => {
6463                pm_flags |= PM_SPECIAL as i32;
6464                Some(Box::new(IFS_GSU.clone())) // c:245
6465            }
6466            "TERM" => {
6467                pm_flags |= PM_SPECIAL as i32;
6468                Some(Box::new(TERM_GSU.clone())) // c:250
6469            }
6470            "TERMINFO" => {
6471                pm_flags |= PM_SPECIAL as i32;
6472                Some(Box::new(TERMINFO_GSU.clone())) // c:251
6473            }
6474            "TERMINFO_DIRS" => {
6475                pm_flags |= PM_SPECIAL as i32;
6476                Some(Box::new(TERMINFODIRS_GSU.clone())) // c:252
6477            }
6478            "WORDCHARS" => {
6479                pm_flags |= PM_SPECIAL as i32;
6480                Some(Box::new(WORDCHARS_GSU.clone())) // c:249
6481            }
6482            "USERNAME" => {
6483                pm_flags |= PM_SPECIAL as i32;
6484                Some(Box::new(USERNAME_GSU.clone())) // c:247
6485            }
6486            "KEYBOARD_HACK" => {
6487                pm_flags |= PM_SPECIAL as i32;
6488                Some(Box::new(KEYBOARDHACK_GSU.clone())) // c:253
6489            }
6490            "HISTCHARS" | "histchars" => {
6491                pm_flags |= PM_SPECIAL as i32;
6492                Some(Box::new(HISTCHARS_GSU.clone())) // c:246
6493            }
6494            _ => None,
6495        };
6496        let pm: Param = Box::new(param {
6497            node: hashnode {
6498                next: None,
6499                nam: name.to_string(),
6500                flags: pm_flags,
6501            },
6502            u_data: 0,
6503            u_tied: None,
6504            u_arr: None,
6505            u_str: Some(String::new()),
6506            u_val: 0,
6507            u_dval: 0.0,
6508            u_hash: None,
6509            gsu_s, // c:1149 special gsu wired
6510            gsu_i: None,
6511            gsu_f: None,
6512            gsu_a: None,
6513            gsu_h: None,
6514            base: 0,
6515            width: 0,
6516            env: None,
6517            ename: None,
6518            old: None,
6519            level: 0,
6520        });
6521        tab.insert(name.to_string(), pm);
6522    } else {
6523        let pm = tab.get(name).unwrap();
6524        // c:3216 PM_READONLY guard for an existing param.
6525        if (pm.node.flags as u32 & PM_READONLY) != 0 {
6526            zerr(&format!("read-only variable: {}", pm.node.nam)); // c:3217
6527            drop(tab);
6528            unqueue_signals(); // c:3220
6529            return None; // c:3221
6530        }
6531        // c:1135-1160 — back-fill the gsu_s vtable on existing
6532        // entries that pre-dated `createparamtable` (e.g. env-import
6533        // path inserted HOME/IFS/etc via the non-special branch
6534        // before the special-param table was populated). Without
6535        // this back-fill, those params stay None-gsu forever and
6536        // assignstrvalue falls through to the default `strsetfn`
6537        // path, bypassing homesetfn/ifssetfn — so the canonical
6538        // C globals (`home`, `ifs`, ...) drift from paramtab.
6539        let pm = tab.get_mut(name).unwrap();
6540        if pm.gsu_s.is_none() {
6541            let new_gsu: Option<Box<gsu_scalar>> = match name {
6542                "0" => Some(Box::new(ARGZERO_GSU.clone())), // c:225-226
6543                "HOME" => Some(Box::new(HOME_GSU.clone())), // c:248
6544                "IFS" => Some(Box::new(IFS_GSU.clone())),   // c:245
6545                "TERM" => Some(Box::new(TERM_GSU.clone())), // c:250
6546                "TERMINFO" => Some(Box::new(TERMINFO_GSU.clone())), // c:251
6547                "TERMINFO_DIRS" => Some(Box::new(TERMINFODIRS_GSU.clone())), // c:252
6548                "WORDCHARS" => Some(Box::new(WORDCHARS_GSU.clone())), // c:249
6549                "USERNAME" => Some(Box::new(USERNAME_GSU.clone())), // c:247
6550                "KEYBOARD_HACK" => Some(Box::new(KEYBOARDHACK_GSU.clone())), // c:253
6551                "HISTCHARS" | "histchars" => Some(Box::new(HISTCHARS_GSU.clone())), // c:246
6552                _ => None,
6553            };
6554            if new_gsu.is_some() {
6555                pm.gsu_s = new_gsu;
6556                pm.node.flags |= PM_SPECIAL as i32;
6557            }
6558        }
6559        let pm = tab.get(name).unwrap();
6560        // c:3236-3250 — existing PM_ARRAY/PM_HASHED on a non-special,
6561        // non-tied, non-KSHARRAYS, non-AUGMENT scalar assignment →
6562        // `resetparam(v->pm, PM_SCALAR)`.
6563        let f = pm.node.flags as u32;
6564        let is_array_or_hash = (f & PM_ARRAY) != 0 || (f & PM_HASHED) != 0;
6565        let is_special_or_tied = (f & (PM_SPECIAL | PM_TIED)) != 0;
6566        let augment_bit = (flags & ASSPM_AUGMENT) != 0;
6567        if is_array_or_hash && !is_special_or_tied && !augment_bit && !isset(KSHARRAYS) {
6568            // c:3242 — flip type to PM_SCALAR, drop array/hash slots.
6569            let pm_mut = tab.get_mut(name).unwrap();
6570            pm_mut.node.flags =
6571                (pm_mut.node.flags & !(PM_TYPE(u32::MAX) as i32)) | PM_SCALAR as i32;
6572            pm_mut.u_arr = None;
6573            paramtab_hashed_storage().lock().unwrap().remove(name);
6574        }
6575    }
6576
6577    // c:3258-3266 `if (*val && (v->pm->node.flags & PM_NAMEREF))`.
6578    let pm = tab.get(name).unwrap();
6579    if !val.is_empty() && (pm.node.flags as u32 & PM_NAMEREF) != 0 {
6580        if !valid_refname(val, pm.node.flags) {
6581            // c:3259
6582            zerr(&format!("invalid name reference: {}", val)); // c:3260
6583            drop(tab);
6584            errflag.fetch_or(
6585                // c:3263
6586                ERRFLAG_ERROR,
6587                Ordering::Relaxed,
6588            );
6589            unqueue_signals(); // c:3262
6590            return None; // c:3264
6591        }
6592    }
6593
6594    // c:3266-3268 `if (flags & ASSPM_WARN) check_warn_pm(v->pm, "scalar", created, 1);`
6595    if (flags & ASSPM_WARN) != 0 {
6596        if let Some(pm_ref) = tab.get(name) {
6597            check_warn_pm(pm_ref, "scalar", created_now as i32, 1); // c:3268
6598        }
6599    }
6600    // c:3269 `v->pm->node.flags &= ~PM_DEFAULTED;`
6601    let pm = tab.get_mut(name).unwrap(); // c:3269
6602    pm.node.flags &= !(PM_DEFAULTED as i32); // c:3269
6603
6604    // c:3343 `assignstrvalue(v, val, flags)`. C aliases `v->pm`
6605    // through to the param in the hash table; Rust's borrow rules
6606    // forbid holding `&mut Param` and wrapping it in `value.pm:
6607    // Option<Param>` at once. Previous port used `tab.remove(name)`
6608    // to take ownership during dispatch, then re-insert — but
6609    // assignstrvalue's PM_INTEGER arm calls `mathevali(val)` which
6610    // looks up identifiers via paramtab. With the param removed,
6611    // `X=X+1` evaluated `X` as unset (=0) and stored `0+1=1`.
6612    // Symptom: `typeset -gi X=5; X=X+1; echo $X` printed 1 instead
6613    // of 6 — broke self-referential integer arithmetic which p10k
6614    // uses for counters, frame depth, hook chain index, etc.
6615    //
6616    // Fix: clone the Param into the value struct so the original
6617    // stays in the table during mathevali's identifier lookup,
6618    // then overwrite the table entry with the mutated clone.
6619    let pm_clone = tab.get(name).unwrap().clone(); // c:3343
6620    drop(tab); // release write lock — assignstrvalue may take it
6621    let mut v = value {
6622        // c:3343
6623        pm: Some(pm_clone), // c:3343
6624        arr: Vec::new(),    // c:3343
6625        scanflags: 0,       // c:3343
6626        valflags: 0,        // c:3343
6627        start: 0,           // c:3343
6628        end: -1,            // c:3343
6629    }; // c:3343
6630    assignstrvalue(Some(&mut v), Some(val.to_string()), flags); // c:3343
6631    let cloned = v.pm.as_ref().cloned(); // c:3345
6632    if let Some(pm_back) = v.pm {
6633        // c:3343
6634        paramtab()
6635            .write()
6636            .unwrap()
6637            .insert(name.to_string(), pm_back); // c:3343
6638    } // c:3343
6639      // c:Src/params.c pathsetfn / fpathsetfn / manpathsetfn /
6640      // cdpathsetfn — when the SCALAR side of a tied colon-array
6641      // pair is assigned, the canonical setfn split-rebuilds the
6642      // ARRAY side via `splitstring(value, ":", &globalarr)`. zshrs
6643      // lacks per-name GSU setfns for these, so the array stayed
6644      // stale after `PATH=/a:/b`. Mirror the split cascade
6645      // explicitly for the full IPDEF8 PM_TIED colonarr list
6646      // (c:Src/params.c:395-422): PATH↔path, FPATH↔fpath,
6647      // MANPATH↔manpath, CDPATH↔cdpath, PSVAR↔psvar,
6648      // MODULE_PATH↔module_path, FIGNORE↔fignore,
6649      // MAILPATH↔mailpath. Bug #423/#424.
6650    let alt: Option<&str> = match name {
6651        "PATH" => Some("path"),
6652        "FPATH" => Some("fpath"),
6653        "MANPATH" => Some("manpath"),
6654        "CDPATH" => Some("cdpath"),
6655        "PSVAR" => Some("psvar"),
6656        "MODULE_PATH" => Some("module_path"),
6657        "FIGNORE" => Some("fignore"),
6658        "MAILPATH" => Some("mailpath"),
6659        _ => None,
6660    };
6661    if let Some(alt_name) = alt {
6662        let parts: Vec<String> = val.split(':').map(String::from).collect();
6663        if let Ok(mut tab) = paramtab().write() {
6664            let entry = tab.entry(alt_name.to_string()).or_insert_with(|| {
6665                Box::new(param {
6666                    node: hashnode {
6667                        next: None,
6668                        nam: alt_name.to_string(),
6669                        flags: (PM_ARRAY | PM_SPECIAL) as i32,
6670                    },
6671                    u_data: 0,
6672                    u_tied: None,
6673                    u_arr: Some(Vec::new()),
6674                    u_str: None,
6675                    u_val: 0,
6676                    u_dval: 0.0,
6677                    u_hash: None,
6678                    gsu_s: None,
6679                    gsu_i: None,
6680                    gsu_f: None,
6681                    gsu_a: None,
6682                    gsu_h: None,
6683                    base: 0,
6684                    width: 0,
6685                    env: None,
6686                    ename: None,
6687                    old: None,
6688                    level: 0,
6689                })
6690            });
6691            entry.u_arr = Some(parts);
6692            entry.u_str = None;
6693        }
6694        // c:Src/params.c:5291 — `if (t == path) cmdnamtab->emptytable
6695        // (cmdnamtab)`. PATH change invalidates the command-name cache.
6696        if name == "PATH" {
6697            crate::ported::hashtable::emptycmdnamtable();
6698        }
6699    }
6700    // c:Src/params.c colonarrsetfn (the REVERSE of the split-cascade above) —
6701    // a SCALAR assignment to a tied colon-ARRAY name (`path=/x`) coerces to a
6702    // 1-element array and must re-derive its tied env SCALAR (PATH). The array
6703    // assign (`path=(...)`, params.rs:4548) and element assign
6704    // (`path[2]=x`, :6230) already sync; the plain scalar-assign path did not,
6705    // so `path=/x; echo $PATH` left PATH stale while `typeset -T` ties worked.
6706    let tied_scalar: Option<&str> = TIED_COLON_ARRAYS
6707        .iter()
6708        .find(|(arr, _)| *arr == name)
6709        .map(|(_, sc)| *sc);
6710    if let Some(sc) = tied_scalar {
6711        // The freshly stored value joined with ':' (a scalar assign is a
6712        // 1-element array → just the value).
6713        let joined = {
6714            let tab = paramtab().read().unwrap();
6715            tab.get(name)
6716                .map(|p| match p.u_arr.as_deref() {
6717                    Some(a) => a.join(":"),
6718                    None => p.u_str.clone().unwrap_or_default(),
6719                })
6720                .unwrap_or_else(|| val.to_string())
6721        };
6722        if let Ok(mut tab) = paramtab().write() {
6723            let entry = tab.entry(sc.to_string()).or_insert_with(|| {
6724                Box::new(param {
6725                    node: hashnode {
6726                        next: None,
6727                        nam: sc.to_string(),
6728                        flags: (PM_SCALAR | PM_SPECIAL) as i32,
6729                    },
6730                    u_data: 0,
6731                    u_tied: None,
6732                    u_arr: None,
6733                    u_str: None,
6734                    u_val: 0,
6735                    u_dval: 0.0,
6736                    u_hash: None,
6737                    gsu_s: None,
6738                    gsu_i: None,
6739                    gsu_f: None,
6740                    gsu_a: None,
6741                    gsu_h: None,
6742                    base: 0,
6743                    width: 0,
6744                    env: None,
6745                    ename: None,
6746                    old: None,
6747                    level: 0,
6748                })
6749            });
6750            entry.u_str = Some(joined.clone());
6751            entry.u_arr = None;
6752        }
6753        // Keep the process environment in sync so command resolution and
6754        // child processes see the new PATH/FPATH/etc.
6755        std::env::set_var(sc, &joined);
6756        if sc == "PATH" {
6757            crate::ported::hashtable::emptycmdnamtable();
6758        }
6759    }
6760    // c:Src/params.c — `{ "PROMPT", PM_SCALAR|PM_ALIAS, &ps1, ... }` and the
6761    // matching `{ "PS1", PM_SCALAR, &ps1, ... }` share the same backing
6762    // pointer; assigning to PROMPT updates the byte that PS1 reads (and
6763    // vice versa). Same for PROMPT2↔PS2, PROMPT3↔PS3, PROMPT4↔PS4.
6764    // zshrs lacks PM_ALIAS — mirror the scalar write to the alias. Bug #518.
6765    let alias_pair: Option<&str> = match name {
6766        "PROMPT" => Some("PS1"),
6767        "PS1" => Some("PROMPT"),
6768        "PROMPT2" => Some("PS2"),
6769        "PS2" => Some("PROMPT2"),
6770        "PROMPT3" => Some("PS3"),
6771        "PS3" => Some("PROMPT3"),
6772        "PROMPT4" => Some("PS4"),
6773        "PS4" => Some("PROMPT4"),
6774        _ => None,
6775    };
6776    if let Some(other) = alias_pair {
6777        if let Ok(mut tab) = paramtab().write() {
6778            let entry = tab.entry(other.to_string()).or_insert_with(|| {
6779                Box::new(param {
6780                    node: hashnode {
6781                        next: None,
6782                        nam: other.to_string(),
6783                        flags: PM_SCALAR as i32,
6784                    },
6785                    u_data: 0,
6786                    u_tied: None,
6787                    u_arr: None,
6788                    u_str: Some(String::new()),
6789                    u_val: 0,
6790                    u_dval: 0.0,
6791                    u_hash: None,
6792                    gsu_s: None,
6793                    gsu_i: None,
6794                    gsu_f: None,
6795                    gsu_a: None,
6796                    gsu_h: None,
6797                    base: 0,
6798                    width: 0,
6799                    env: None,
6800                    ename: None,
6801                    old: None,
6802                    level: 0,
6803                })
6804            });
6805            entry.u_str = Some(val.to_string());
6806            entry.u_arr = None;
6807        }
6808    }
6809    unqueue_signals(); // c:3344
6810    cloned // c:3345
6811}
6812
6813// `VarAttr` struct + `VarKind` enum + `impl VarAttr::format_zsh`
6814// DELETED. C zsh stores typeset attributes as bare `PM_*` bit
6815// flags on `Param.node.flags` (`Src/zsh.h` PM_* + `Src/params.c`
6816// flag tests); the `${(t)var}` flag report (`typeprintparam` at
6817// `Src/builtin.c:3050+`) writes those bits to a string directly
6818// against the `Param.node.flags` int.
6819//
6820// Both types had zero external use sites — pure dead-code carryover
6821// from an earlier vm_helper refactor. The PM_* bit constants are at
6822// `zsh_h.rs:1340+` and the `(t)` formatting routes through
6823// `typeset_print_flags` (when wired) reading bare `Param.node.flags`.
6824
6825// ===========================================================
6826// Special-parameter GSU (get/set/unset) callbacks ported from
6827// Src/params.c.
6828//
6829// C zsh stores per-special-param state in file-static globals
6830// (`ifs`, `home`, `term`, `histsiz`, etc.) and dispatches getfn/
6831// setfn/unsetfn callbacks through `Param.gsu->getfn` etc. zshrs's
6832// param storage is per-evaluator HashMaps on `ShellExecutor`, so
6833// the C globals are reproduced as `OnceLock<Mutex<…>>` module
6834// statics here, with the get/set ported mutating the static.
6835//
6836// Functions that genuinely need a `Param *` (the GSU dispatch
6837// callbacks for non-special arr/hash/int/float/str params, the
6838// param-table mutators, scope helpers, etc.) cannot be properly
6839// ported until zshrs gains a Param struct + callback-table ABI;
6840// those keep their C signatures but the body is a WARNING-stub
6841// that does nothing.
6842// ===========================================================
6843
6844// -----------------------------------------------------------
6845// Module statics — one per C global referenced by the special-
6846// param callbacks below. All initialised lazily on first read.
6847// -----------------------------------------------------------
6848
6849// `Src/params.c:515  mod_export HashTable paramtab, realparamtab;`
6850//
6851// `realparamtab` always points to the shell's global parameter
6852// table. `paramtab` normally aliases it; it is temporarily
6853// redirected during associative-array key iteration
6854// (`Src/params.c:508-513` — "paramtab is sometimes temporarily
6855// changed to point at another table").
6856//
6857// Per PORT_PLAN.md Phase 3, bucket-2 read-mostly tables use
6858// `RwLock` so parallel readers (every `$VAR` expansion, every
6859// completion lookup) don't serialize. Writers (assignments,
6860// scope pushes/pops, function-local declarations) take the
6861// exclusive write lock. `OnceLock` provides the single-static
6862// guarantee without an `Arc` allocation since the table lives
6863// for the process lifetime.
6864//
6865// Entries are keyed on `node.nam` (the canonical `param` struct
6866// lives in `zsh_h.rs`). The full `HashTable` substrate (vtable
6867// callbacks, intrusive `next` chain, scope-stacked iterators) is
6868// not yet wired; until it is, the typed map is the operative
6869// storage.
6870static PARAMTAB_INNER: OnceLock<RwLock<crate::fast_hash::FastMap<String, Param>>> = OnceLock::new();
6871static REALPARAMTAB_INNER: OnceLock<RwLock<crate::fast_hash::FastMap<String, Param>>> = OnceLock::new();
6872
6873/// Array parameter assignment (no subscript).
6874///
6875/// Direct port of `Param assignaparam(char *s, char **val, int flags)`
6876/// from `Src/params.c:3357`. Writes an array value into paramtab
6877/// and returns the new/updated Param.
6878///
6879/// Ported semantics:
6880///   - PM_READONLY rejection (c:3370-3381)
6881///   - PM_NAMEREF type-change reject (c:3395-3398)
6882///   - ASSPM_AUGMENT (`a+=val`) preserve-old prepend (c:3404-3412)
6883///   - PM_UNIQUE dedupe (c:3401)
6884///   - element-wise `a[k]=v` slice path pre-check (c:3373-3389)
6885///   - PM_HASHED slice rejection (c:3384-3391)
6886///
6887/// Pending (rare paths):
6888///   - resetparam from non-array (c:3415-3420) — handled implicitly
6889///     by the type-mask rewrite below; matches C observable behavior.
6890pub fn assignaparam(name: &str, val: Vec<String>, flags: i32) -> Option<Param> {
6891    // c:3357
6892    // c:3366-3370 — `if (!isident(s)) { zerr; return NULL }`.
6893    if !isident(name) {
6894        zerr(&format!("not an identifier: {}", name));
6895        errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed);
6896        return None;
6897    }
6898    // c:Src/params.c:2922-2923 — `pm->gsu.a->setfn(pm, val)`: a special
6899    // PM_ARRAY param (e.g. `dirstack`) routes a whole-array assignment
6900    // through its GSU setter instead of plain paramtab storage. zshrs
6901    // keeps these magic arrays in PARTAB_ARRAY (not paramtab), so a
6902    // bare `dirstack=(...)` would otherwise land in the executor's
6903    // generic array store and never reach `dirssetfn` (so the dir stack
6904    // stayed empty). Dispatch writable PARTAB_ARRAY specials here. Only
6905    // whole-name (non-subscripted) assignment; `dirstack[1]=x` keeps the
6906    // existing subscript path.
6907    if !name.contains('[') {
6908        if let Some(entry) = crate::ported::modules::parameter::PARTAB_ARRAY
6909            .iter()
6910            .find(|e| e.name == name)
6911        {
6912            if let Some(setfn) = entry.setfn {
6913                setfn(std::ptr::null_mut(), val.clone()); // c:2922 gsu.a->setfn
6914                let mut pm = param::default();
6915                pm.node.nam = name.to_string();
6916                pm.node.flags = (PM_ARRAY | PM_SPECIAL) as i32;
6917                pm.u_arr = Some(val);
6918                return Some(Box::new(pm));
6919            }
6920        }
6921    }
6922    // c:3392 — `fetchvalue(&vbuf, &s, 1, SCANPM_ASSIGNING)` resolves
6923    // PM_NAMEREF chains; c:3395-3398 rejects an unresolvable ref.
6924    {
6925        let base = name.split('[').next().unwrap_or(name);
6926        if crate::ported::params::is_nameref(base) {
6927            match crate::ported::params::resolve_nameref_name(base, None) {
6928                crate::ported::params::nameref_resolution::SelfRef => {
6929                    errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed);
6930                    return None;
6931                }
6932                crate::ported::params::nameref_resolution::OutOfScope => {
6933                    // see assignsparam — silent failure, status 1.
6934                    return None;
6935                }
6936                crate::ported::params::nameref_resolution::Placeholder(_) => {
6937                    // c:3396 — message uses the ORIGINAL assigned name
6938                    // (`t = s` saved at c:3361).
6939                    zwarn(&format!("{}: can't change type of a named reference", base));
6940                    return None;
6941                }
6942                crate::ported::params::nameref_resolution::Target {
6943                    name: t,
6944                    subscript: rsub,
6945                    pm: rpm,
6946                    level,
6947                } => {
6948                    let user_sub = name.find('[').map(|i| &name[i..]);
6949                    // Hidden (old-chain) binding — upscope write.
6950                    if rsub.is_none() && user_sub.is_none() && rpm.is_some() {
6951                        let visible_level = paramtab()
6952                            .read()
6953                            .ok()
6954                            .and_then(|tb| tb.get(&t).map(|p| p.level));
6955                        if visible_level != Some(level) {
6956                            // upscope write, INLINE (former
6957                            // nameref_hidden_array_assign).
6958                            return (|name: &str, level: i32, val: Vec<String>| -> Option<Param> {
6959                                let mut tab = paramtab().write().ok()?;
6960                                let mut node: &mut param = tab.get_mut(name)?.as_mut();
6961                                while node.level != level {
6962                                    node = node.old.as_mut()?.as_mut();
6963                                }
6964                                if (node.node.flags as u32 & PM_READONLY) != 0 {
6965                                    zerr(&format!("read-only variable: {}", name)); // c:3370-3381
6966                                    return None;
6967                                }
6968                                let type_mask = PM_TYPE(u32::MAX) as i32;
6969                                node.node.flags = (node.node.flags & !type_mask) | PM_ARRAY as i32;
6970                                node.u_str = None;
6971                                node.u_arr = Some(val);
6972                                node.node.flags &= !((PM_UNSET | PM_DECLARED) as i32);
6973                                Some(Box::new(node.clone()))
6974                            })(&t, level, val);
6975                        }
6976                    }
6977                    let mut new_s = t.clone();
6978                    if let Some(rs) = &rsub {
6979                        new_s.push('[');
6980                        new_s.push_str(rs);
6981                        new_s.push(']');
6982                    }
6983                    if let Some(us) = user_sub {
6984                        new_s.push_str(us);
6985                    }
6986                    if new_s != name {
6987                        return assignaparam(&new_s, val, flags);
6988                    }
6989                }
6990                crate::ported::params::nameref_resolution::NotRef => {}
6991            }
6992        }
6993    }
6994
6995    // c:3375 — `if ((ss = strchr(s, '['))) { ... slice path ... }`
6996    //          Extract the base name when there's a subscript and
6997    //          dispatch to the slice-rejection / slice-write path.
6998    if let Some(_bracket) = name.find('[') {
6999        let base = name.split('[').next().unwrap_or(name);
7000        // c:3384-3391 — slice into a HASHED → zerr.
7001        let is_hashed = {
7002            let tab = paramtab().read().unwrap();
7003            tab.get(base)
7004                .map(|pm| (pm.node.flags as u32 & PM_HASHED) != 0)
7005                .unwrap_or(false)
7006        };
7007        if is_hashed {
7008            zerr(&format!(
7009                "{}: attempt to set slice of associative array",
7010                base
7011            ));
7012            errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed);
7013            return None;
7014        }
7015        // Slice into a non-existent param → create as PM_ARRAY (c:3377-3382).
7016        let exists = paramtab().read().unwrap().contains_key(base);
7017        if !exists {
7018            createparam(base, PM_ARRAY as i32)?;
7019        }
7020        // Subscript-write itself (a[k]=v) is handled at the caller's
7021        // SubscriptArith dispatch; reaching here means the slice
7022        // pre-check has passed and the param exists.
7023        return paramtab().read().unwrap().get(base).cloned();
7024    }
7025
7026    // c:3391-3394 — fetchvalue / createparam(PM_ARRAY) if missing.
7027    let (existed, prior_scalar, prior_flags) = {
7028        let tab = paramtab().read().unwrap();
7029        match tab.get(name) {
7030            Some(pm) => {
7031                // c:3350 — the ASSPM_AUGMENT prepend uses
7032                // `ztrdup(getstrvalue(v))`, the type-aware string form of
7033                // the old value (PM_INTEGER → base-formatted, PM_FFLOAT/
7034                // PM_EFLOAT → convfloat, PM_SCALAR → strgetfn). Reading
7035                // `u_str` alone loses it for numeric params — u_str is
7036                // None for `integer`/`float`, so `integer i=1; i+=(2 3)`
7037                // dropped the "1". Route scalar-type targets through
7038                // getstrvalue so the conversion-to-array keeps the value.
7039                let f = pm.node.flags as u32;
7040                let ps = if f & (PM_ARRAY | PM_HASHED | PM_SPECIAL) == 0 {
7041                    let mut pv = value {
7042                        pm: Some(pm.clone()),
7043                        arr: Vec::new(),
7044                        scanflags: 0,
7045                        valflags: 0,
7046                        start: 0,
7047                        end: -1,
7048                    };
7049                    Some(getstrvalue(Some(&mut pv))) // c:3350
7050                } else {
7051                    pm.u_str.clone()
7052                };
7053                (true, ps, pm.node.flags)
7054            }
7055            None => (false, None, 0),
7056        }
7057    };
7058    // c:3397-3400 — PM_NAMEREF: can't change type of a named reference.
7059    if existed && (prior_flags as u32 & PM_NAMEREF) != 0 {
7060        zwarn(&format!("{}: can't change type of a named reference", name));
7061        return None;
7062    }
7063    let created_now = !existed; // c:3393 createparam path sets `created = 1`
7064    if !existed {
7065        createparam(name, PM_ARRAY as i32)?;
7066    }
7067
7068    // c:3370-3381 PM_READONLY rejection — C routes through setarrvalue
7069    // → arrsetfn which emits "read-only variable: X" and returns NULL.
7070    // Rust write path bypasses gsu setfn, so mirror the check here.
7071    if existed && (prior_flags as u32 & PM_READONLY) != 0 {
7072        zerr(&format!("read-only variable: {}", name));
7073        errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed);
7074        return None;
7075    }
7076    // c:3395-3401 — the type-reset arm only fires for params that are
7077    // NOT (PM_ARRAY|PM_HASHED) and NOT PM_SPECIAL|PM_TIED. A special
7078    // param falls THROUGH the reset: `v` stays set and the assignment
7079    // reaches setarrvalue (c:3585), which dispatches:
7080    //   - PM_HASHED whole-assign (v->start==0, v->end==-1) →
7081    //     arrhashsetfn (c:2918-2920) → pm->gsu.h->setfn(pm, ht) — for
7082    //     the zsh/parameter specials that's setpmoptions /
7083    //     setpmcommands / setaliases / setfunctions / setpmnameddirs
7084    //     (Src/Modules/parameter.c).
7085    //   - non-array, non-hash special (e.g. $SECONDS) → zerr
7086    //     "%s: attempt to assign array value to non-array" (c:2905).
7087    // The previous Rust arm rejected EVERY non-array special with
7088    // "can't change type of a special parameter" — a message C's
7089    // assignaparam never emits. Gap #2 2026-06-12.
7090    if existed {
7091        let pm_type = prior_flags as u32 & PM_TYPE(u32::MAX);
7092        if pm_type != PM_ARRAY && (prior_flags as u32 & PM_SPECIAL) != 0 {
7093            if pm_type == PM_HASHED {
7094                // c:3544-3560 — under ASSPM_KEY_VALUE, associative
7095                // arrays strictly enforce `[key]=value` syntax: walk
7096                // in strides of 3; every stride must start with a
7097                // Marker (a Marker can only introduce a
7098                // Marker/key/value triad, never appear by accident).
7099                if (flags & ASSPM_KEY_VALUE) != 0 {
7100                    let mut i = 0usize;
7101                    while i < val.len() {
7102                        if !val[i].starts_with(Marker) {
7103                            zerr("bad [key]=value syntax for associative array");
7104                            errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed);
7105                            return None;
7106                        }
7107                        i += 3;
7108                    }
7109                }
7110                // c:4124-4131 (arrhashsetfn) — count non-Marker
7111                // entries; odd → zerr + abort. zsh 5.9 truth:
7112                // `options=(noglob)` → "bad set of key/value pairs
7113                // for associative array", rc=1, script aborts.
7114                let alen = val
7115                    .iter()
7116                    .filter(|s| !s.starts_with(Marker as char))
7117                    .count();
7118                if alen % 2 != 0 {
7119                    zerr("bad set of key/value pairs for associative array");
7120                    errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed);
7121                    return None;
7122                }
7123                // c:4141-4166 (arrhashsetfn pair walk) — flatten the
7124                // value list to (key, value) pairs; `[k]=v` triples
7125                // arrive as [Marker, k, v].
7126                let mut pairs: Vec<(String, String)> = Vec::with_capacity(alen / 2);
7127                let mut it = val.iter();
7128                while let Some(first) = it.next() {
7129                    let key = if first.starts_with(Marker as char) {
7130                        match it.next() {
7131                            Some(k) => k.clone(),
7132                            None => break,
7133                        }
7134                    } else {
7135                        first.clone()
7136                    };
7137                    let v = it.next().cloned().unwrap_or_default();
7138                    pairs.push((key, v));
7139                }
7140                // pm->gsu.h->setfn(pm, ht) — per-name dispatch to the
7141                // canonical Src/Modules/parameter.c setfn ports. The
7142                // synthetic Param mirrors the established per-element
7143                // pattern (assignsparam "options"/"commands" arms).
7144                let synth: Param = Box::new(param {
7145                    node: hashnode {
7146                        next: None,
7147                        nam: name.to_string(),
7148                        flags: prior_flags,
7149                    },
7150                    u_data: 0,
7151                    u_tied: None,
7152                    u_arr: None,
7153                    u_str: None,
7154                    u_val: 0,
7155                    u_dval: 0.0,
7156                    u_hash: None,
7157                    gsu_s: None,
7158                    gsu_i: None,
7159                    gsu_f: None,
7160                    gsu_a: None,
7161                    gsu_h: None,
7162                    base: 0,
7163                    width: 0,
7164                    env: None,
7165                    ename: None,
7166                    old: None,
7167                    level: 0,
7168                });
7169                use crate::ported::modules::parameter as pmod;
7170                match name {
7171                    // SPECIALPMDEF entries with writable hash gsu
7172                    // (Src/Modules/parameter.c:2235-2298, no
7173                    // PM_READONLY_SPECIAL):
7174                    "options" => pmod::setpmoptions(synth.clone(), &pairs), // c:2285
7175                    "commands" => pmod::setpmcommands(synth.clone(), &pairs), // c:2238
7176                    "functions" => pmod::setpmfunctions(synth.clone(), &pairs), // c:2263
7177                    "dis_functions" => pmod::setpmdisfunctions(synth.clone(), &pairs), // c:2245
7178                    "aliases" => pmod::setpmraliases(synth.clone(), &pairs), // c:2235
7179                    "dis_aliases" => pmod::setpmdisraliases(synth.clone(), &pairs), // c:2241
7180                    "galiases" => pmod::setpmgaliases(synth.clone(), &pairs), // c:2269
7181                    "dis_galiases" => pmod::setpmdisgaliases(synth.clone(), &pairs), // c:2249
7182                    "saliases" => pmod::setpmsaliases(synth.clone(), &pairs), // c:2293
7183                    "dis_saliases" => pmod::setpmdissaliases(synth.clone(), &pairs), // c:2255
7184                    "nameddirs" => pmod::setpmnameddirs(synth.clone(), &pairs), // c:2283
7185                    _ => {
7186                        // PM_READONLY_SPECIAL hashed specials (builtins,
7187                        // modules, parameters, history, jobtexts, ...) —
7188                        // C's PM_READONLY check (setarrvalue c:2900-2903)
7189                        // rejects before any setfn. zsh 5.9 truth:
7190                        // `modules=(a b)` → "read-only variable: modules",
7191                        // rc=1. zshrs strips PM_READONLY off the paramtab
7192                        // stubs (vm_helper init), so match by family.
7193                        zerr(&format!("read-only variable: {}", name));
7194                        errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed);
7195                        return None;
7196                    }
7197                }
7198                return Some(synth);
7199            }
7200            // c:2905-2909 (setarrvalue) — non-hash special target.
7201            // zsh 5.9 truth: `SECONDS=(1 2)` → "SECONDS: attempt to
7202            // assign array value to non-array", rc=1.
7203            zerr(&format!(
7204                "{}: attempt to assign array value to non-array",
7205                name
7206            ));
7207            errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed);
7208            return None;
7209        }
7210    }
7211
7212    // c:3436-3541 — ASSPM_KEY_VALUE on an ordinary array: the value
7213    // list contains Marker / index / value triads (from
7214    // keyvalpairelement, Src/subst.c:49) interleaved with plain
7215    // elements. Resolve them into a dense array:
7216    //   - First pass (c:3465-3486): matheval each index; reject < 0,
7217    //     and 0 unless KSH_ARRAYS (zerr "bad subscript for direct
7218    //     array assignment"); KSH_ARRAYS keeps 0-based, otherwise
7219    //     1-based → decrement; track `nextind` (a plain element lands
7220    //     just after the previously placed one) and `maxlen`.
7221    //   - Allocate `fullval` of maxlen (c:3487-3495), pre-filled with
7222    //     the existing elements under ASSPM_AUGMENT (c:3496-3502).
7223    //   - Second pass (c:3503-3525): place each value; a `Marker +`
7224    //     triad concatenates onto the slot's current value (c:3510-
7225    //     3515 bicat); plain elements advance nextind.
7226    //   - Unset slots become "" — no sparse arrays (c:3530-3537).
7227    // C returns straight after `setarrvalue(v, fullval)` (c:3538-
7228    // 3540): the resolved vec REPLACES the whole array, so the tail
7229    // AUGMENT prepend below must not run again — clear the flag.
7230    let mut val = val;
7231    let mut flags = flags;
7232    if (flags & ASSPM_KEY_VALUE) != 0 {
7233        let ksh = crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS);
7234        let orig: Vec<String> = if (flags & ASSPM_AUGMENT) != 0 && existed {
7235            let tab = paramtab().read().unwrap();
7236            tab.get(name)
7237                .and_then(|pm| pm.u_arr.clone())
7238                .unwrap_or_default()
7239        } else {
7240            Vec::new()
7241        };
7242        let mut maxlen = orig.len(); // c:3460-3462
7243        let mut nextind: i64 = 0; // c:3464
7244        let mut subscripts: Vec<i64> = Vec::new(); // c:3455-3456
7245        let mut i = 0usize;
7246        while i < val.len() {
7247            // c:3466-3481
7248            if val[i].starts_with(Marker) {
7249                let key_str = val.get(i + 1).cloned().unwrap_or_default();
7250                let idx = match crate::ported::math::mathevali(&key_str) {
7251                    Ok(n) => n,
7252                    Err(e) => {
7253                        // C mathevali zerr's internally (Src/math.c);
7254                        // surface the message and abort the assign.
7255                        zerr(&e);
7256                        errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed);
7257                        return None;
7258                    }
7259                };
7260                if idx < 0 || (!ksh && idx == 0) {
7261                    // c:3468-3474
7262                    zerr(&format!(
7263                        "bad subscript for direct array assignment: {}",
7264                        key_str
7265                    ));
7266                    errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed);
7267                    return None;
7268                }
7269                let ix = if ksh { idx } else { idx - 1 }; // c:3475-3476
7270                subscripts.push(ix);
7271                nextind = ix + 1; // c:3477
7272                i += 3; // c:3479
7273            } else {
7274                nextind += 1; // c:3481-3482
7275                i += 1;
7276            }
7277            if nextind > maxlen as i64 {
7278                // c:3484-3485
7279                maxlen = nextind as usize;
7280            }
7281        }
7282        // c:3487-3495 fullval = zshcalloc((maxlen+1) * sizeof(char*)).
7283        let mut fullval: Vec<Option<String>> = vec![None; maxlen];
7284        // c:3496-3502 — AUGMENT: copy the original elements in first.
7285        for (j, o) in orig.iter().enumerate() {
7286            fullval[j] = Some(o.clone());
7287        }
7288        // Second pass — c:3503-3525.
7289        let mut si = 0usize;
7290        let mut nextind = 0usize;
7291        let mut i = 0usize;
7292        while i < val.len() {
7293            if val[i].starts_with(Marker) {
7294                let augment_elt = val[i].len() > Marker.len_utf8(); // c:3507 `(*aptr)[1] == '+'`
7295                let ix = subscripts[si] as usize;
7296                si += 1;
7297                let old = fullval[ix].take();
7298                fullval[ix] = Some(match old {
7299                    // c:3510-3512 bicat(old, value)
7300                    Some(o) if augment_elt => format!("{}{}", o, val[i + 2]),
7301                    _ => val[i + 2].clone(),
7302                });
7303                nextind = ix + 1; // c:3516
7304                i += 3;
7305            } else {
7306                fullval[nextind] = Some(val[i].clone()); // c:3519-3520
7307                nextind += 1;
7308                i += 1;
7309            }
7310        }
7311        // c:3530-3537 — unfilled slots become "".
7312        val = fullval.into_iter().map(|o| o.unwrap_or_default()).collect();
7313        // c:3538-3540 — setarrvalue(v, fullval); return. The orig
7314        // elements are already merged in; don't prepend them again.
7315        flags &= !ASSPM_AUGMENT;
7316    }
7317
7318    // c:3402-3412 — ASSPM_AUGMENT preserve-old prepend. When the
7319    // previous value was a scalar (not array/hashed) and we're
7320    // augmenting (`a+=val`), prepend that scalar's string form as
7321    // val[0]. Only fires when the existing param is not PM_UNSET.
7322    let was_scalar_array_target = existed
7323        && prior_flags & (PM_ARRAY | PM_HASHED) as i32 == 0
7324        && prior_flags & PM_SPECIAL as i32 == 0;
7325    if (flags & ASSPM_AUGMENT) != 0 && was_scalar_array_target && prior_flags & PM_UNSET as i32 == 0
7326    {
7327        if let Some(old_scalar) = prior_scalar {
7328            val.insert(0, old_scalar); // c:3408-3411
7329        }
7330    }
7331
7332    // c:3570-3585 — ASSPM_AUGMENT on an existing PM_ARRAY target:
7333    // append rather than replace. C bumps v->start to arrlen(existing)
7334    // and v->end to start+1 so setarrvalue writes past the tail.
7335    // zshrs writes through pm.u_arr without the value struct, so do
7336    // the equivalent here: prepend the existing array elements to the
7337    // new val so the final stored vec is [old..., new...].
7338    if (flags & ASSPM_AUGMENT) != 0
7339        && existed
7340        && (prior_flags as u32 & PM_ARRAY) != 0
7341        && (prior_flags as u32 & PM_UNSET) == 0
7342    {
7343        let prior_arr = {
7344            let tab = paramtab().read().unwrap();
7345            tab.get(name)
7346                .and_then(|pm| pm.u_arr.clone())
7347                .unwrap_or_default()
7348        };
7349        let appended: Vec<String> = prior_arr.into_iter().chain(val.into_iter()).collect();
7350        val = appended;
7351    }
7352
7353    // c:3432 `if (flags & ASSPM_WARN) check_warn_pm(v->pm, "array", created, may_warn_about_nested_vars);`
7354    // c:3372 — `may_warn_about_nested_vars = !(flags & ASSPM_AUGMENT)`.
7355    if (flags & ASSPM_WARN) != 0 {
7356        let may_nested = if (flags & ASSPM_AUGMENT) != 0 { 0 } else { 1 };
7357        if let Some(pm_ref) = paramtab().read().unwrap().get(name) {
7358            check_warn_pm(pm_ref, "array", created_now as i32, may_nested); // c:3432
7359        }
7360    }
7361
7362    // c:3434 — setarrvalue(v, val): store array in pm.u_arr.
7363    let mut tab = paramtab().write().unwrap();
7364    let pm = tab.get_mut(name)?;
7365    let uniq = pm.node.flags & PM_UNIQUE as i32 != 0; // c:3401
7366    if pm.node.flags & PM_SPECIAL as i32 == 0 {
7367        let type_mask = PM_ARRAY | PM_INTEGER | PM_EFLOAT | PM_FFLOAT | PM_HASHED | PM_NAMEREF;
7368        pm.node.flags = (pm.node.flags & !type_mask as i32) | PM_ARRAY as i32;
7369    }
7370    // c:3401 — preserve PM_UNIQUE through the type change, then let
7371    // arrsetfn dedupe via the actual write.
7372    if uniq {
7373        pm.node.flags |= PM_UNIQUE as i32;
7374    }
7375    let val_final = if uniq { simple_arrayuniq(val) } else { val };
7376    // c:3434 — `setarrvalue(v, val);` → `gsu.a->setfn(pm, val)` for
7377    // PM_SPECIAL params (PATH/path → pathsetfn rebuilds $PATH cstring;
7378    // FPATH/fpath → fpathsetfn rebuilds; MANPATH etc.). Without this
7379    // dispatch, env var pairs (PATH vs path) drift apart on array
7380    // assignment. Fall back to direct u_arr write when no gsu_a is
7381    // wired (regular non-special arrays).
7382    let setfn_ptr = pm.gsu_a.as_ref().map(|g| g.setfn);
7383    // c:Src/params.c:3434 — `setarrvalue(v, val)` calls `gsu.a->setfn(pm, val)`.
7384    // The canonical arrsetfn (params.rs:6584) writes `pm->u_arr` then,
7385    // if `pm->ename` is set, calls `arrfixenv` which re-acquires the
7386    // paramtab lock. We're holding the WRITE lock here — that would
7387    // deadlock the RWLock. Inline the storage write under the held
7388    // lock and defer arrfixenv to AFTER drop(tab). Bug #600.
7389    pm.u_arr = Some(val_final.clone());
7390    pm.u_str = None;
7391    pm.u_hash = None;
7392    // c:2712 (setarrvalue head) — `v->pm->node.flags &= ~PM_UNSET;`
7393    // a declared-but-unset (TYPESET_TO_UNSET) array becomes set on
7394    // its first assignment.
7395    pm.node.flags &= !((PM_UNSET | PM_DECLARED) as i32);
7396    let ename_for_envsync: Option<String> = if setfn_ptr.is_some() {
7397        pm.ename.clone()
7398    } else {
7399        None
7400    };
7401    let cloned = pm.clone();
7402    drop(tab);
7403    // c:Src/params.c:5285 arrfixenv — deferred from inside the lock
7404    // (see Bug #600 above). Acquires its own paramtab read+write
7405    // locks; safe to call now that we've dropped the write lock.
7406    if let Some(ename) = ename_for_envsync {
7407        arrfixenv(&ename, Some(&val_final));
7408    }
7409    // c:Src/params.c:3262 IPDEF9 — \`argv\`/\`@\`/\`*\` are aliases for
7410    // the C global \`pparams\` (the positional parameter vector).
7411    // assignaparam("argv", [...]) in C writes through the array's
7412    // setfn which mutates \`pparams\` directly. zshrs's pparams lives
7413    // in builtin::PPARAMS; mirror the write so \`argv=(...)\` updates
7414    // \$1/\$2/.../\$# correctly.
7415    if name == "argv" || name == "@" || name == "*" {
7416        if let Ok(mut pp) = crate::ported::builtin::PPARAMS.lock() {
7417            *pp = val_final.clone();
7418        }
7419    }
7420    let _ = val_final;
7421    Some(cloned)
7422}
7423
7424/// Set array parameter.
7425/// Port of `setaparam(char *s, char **aval)` from `Src/params.c:3595` — single-line wrapper
7426/// around `assignaparam(s, val, ASSPM_WARN)`. C body:
7427/// ```c
7428/// mod_export Param setaparam(char *s, char **val) {
7429///     return assignaparam(s, val, ASSPM_WARN);
7430/// }
7431/// ```
7432///
7433/// `ASSPM_WARN` (params.c:104) drives the WARN_CREATE_GLOBAL /
7434/// WARN_NESTED_VAR diagnostics inside `assignaparam` →
7435/// `check_warn_pm` (params.rs:4428).
7436/// WARNING: param names don't match C — Rust=() vs C=(s, val)
7437pub fn setaparam(name: &str, val: Vec<String>) -> Option<Param> {
7438    // c:3766 — `return assignaparam(s, val, ASSPM_WARN)`.
7439    assignaparam(name, val, ASSPM_WARN)
7440}
7441
7442/// Direct port of `Param sethparam(char *s, char **val)` from
7443/// `Src/params.c:3602`. Writes an associative array (flat
7444/// alternating key,value list) into paramtab + the parallel
7445/// `paramtab_hashed_storage` table; returns the new Param.
7446///
7447/// Ported C semantics:
7448///   - PM_READONLY rejection (c:3625 via setarrvalue chain in C; here inline)
7449///   - PM_SPECIAL type-change reject (c:3637)
7450/// Pending:
7451///   - resetparam(PM_HASHED) for non-special type-change (rare)
7452pub fn sethparam(name: &str, val: Vec<String>) -> Option<Param> {
7453    // c:3611-3615 — `if (!isident(s)) { zerr; return NULL }`.
7454    if !isident(name) {
7455        zerr(&format!("not an identifier: {}", name));
7456        return None;
7457    }
7458    // c:3630 — `fetchvalue(&vbuf, &s, 1, SCANPM_ASSIGNING)` resolves
7459    // PM_NAMEREF chains (same shape as assignaparam c:3392-3398).
7460    if crate::ported::params::is_nameref(name) {
7461        match crate::ported::params::resolve_nameref_name(name, None) {
7462            crate::ported::params::nameref_resolution::SelfRef => {
7463                errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed);
7464                return None;
7465            }
7466            crate::ported::params::nameref_resolution::OutOfScope => {
7467                return None;
7468            }
7469            crate::ported::params::nameref_resolution::Placeholder(_) => {
7470                zwarn(&format!("{}: can't change type of a named reference", name));
7471                return None;
7472            }
7473            crate::ported::params::nameref_resolution::Target {
7474                name: t,
7475                subscript: None,
7476                ..
7477            } => {
7478                if t != name {
7479                    return sethparam(&t, val);
7480                }
7481            }
7482            _ => {}
7483        }
7484    }
7485    // c:3617-3621 — `if (strchr(s, '[')) { zerr; return NULL }`.
7486    if name.contains('[') {
7487        zerr("nested associative arrays not yet supported");
7488        return None;
7489    }
7490
7491    // c:3625 — PM_READONLY rejection. C routes through gsu.h->setfn
7492    // which checks readonly inside hashsetfn / arrhashsetfn; Rust
7493    // bypasses that path, so check explicitly here.
7494    {
7495        let tab = paramtab().read().unwrap();
7496        if let Some(pm) = tab.get(name) {
7497            if (pm.node.flags as u32 & PM_READONLY) != 0 {
7498                zerr(&format!("read-only variable: {}", name));
7499                errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed);
7500                return None;
7501            }
7502            // c:3637 — `if (PM_TYPE(pm->node.flags) != PM_HASHED &&
7503            //              (pm->node.flags & PM_SPECIAL)) { zerr; return; }`
7504            // Can't change type of a PM_SPECIAL non-hashed param.
7505            let pm_type = pm.node.flags as u32 & PM_TYPE(u32::MAX);
7506            if pm_type != PM_HASHED && (pm.node.flags as u32 & PM_SPECIAL) != 0 {
7507                zerr(&format!(
7508                    "{}: can't change type of a special parameter",
7509                    name
7510                ));
7511                errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed);
7512                return None;
7513            }
7514        }
7515    }
7516
7517    // c:3625 — fetchvalue / createparam(PM_HASHED) if missing.
7518    let exists = paramtab().read().unwrap().contains_key(name);
7519    let checkcreate = !exists; // c:3626 `checkcreate = 1;`
7520    if !exists {
7521        createparam(name, PM_HASHED as i32)?;
7522    }
7523
7524    // c:3649 `check_warn_pm(v->pm, "associative array", checkcreate, 1);`
7525    // — sethparam always warns (no ASSPM_WARN gate in C).
7526    if let Some(pm_ref) = paramtab().read().unwrap().get(name) {
7527        check_warn_pm(pm_ref, "associative array", checkcreate as i32, 1); // c:3649
7528    }
7529
7530    // c:3651 — `setarrvalue(v, val);` — full-replace dispatch for a
7531    // PM_HASHED param (setarrvalue c:2919-2920) collapses to
7532    // `arrhashsetfn(v->pm, val, 0)`, which owns the odd-count gate
7533    // (c:4128-4131, zerr + ERRFLAG_ERROR) and the pair walk.
7534    let mut tab = paramtab().write().unwrap();
7535    let pm = tab.get_mut(name)?;
7536    if pm.node.flags & PM_SPECIAL as i32 == 0 {
7537        let type_mask = PM_ARRAY | PM_INTEGER | PM_EFLOAT | PM_FFLOAT | PM_HASHED | PM_NAMEREF;
7538        pm.node.flags = (pm.node.flags & !type_mask as i32) | PM_HASHED as i32;
7539    }
7540    pm.u_arr = None;
7541    pm.u_str = None;
7542    arrhashsetfn(pm, val, 0); // c:3651 via setarrvalue c:2920
7543    let cloned = pm.clone();
7544    drop(tab);
7545
7546    // c:3652-3653 — `unqueue_signals(); return v->pm;` — C returns
7547    // the param even when arrhashsetfn errored; the failure travels
7548    // via errflag (callers like the SET_ARRAY bridge check it).
7549    Some(cloned)
7550}
7551
7552// -----------------------------------------------------------
7553// Param-table mutators / scope / nameref helpers.
7554// `Src/params.c` calls these against the global `paramtab`
7555// HashTable; until our HashTable vtable (`Box<hashtable>` in
7556// zsh_h.rs:285) is wired, these remain no-op shims with the
7557// real C signatures.
7558// -----------------------------------------------------------
7559
7560/// Port of `assignnparam(char *s, mnumber val, int flags)` from `Src/params.c:3664`. C body
7561/// looks up the param via `gethashnode2(realparamtab, s)`,
7562/// dispatches on PM_TYPE: PM_INTEGER → `intsetfn(pm, val.u.l)`;
7563/// PM_FFLOAT/EFLOAT → `floatsetfn(pm, val.u.d)`; otherwise
7564/// `assignstrvalue(&v, conv_to_string(val), flags)`. Stub
7565/// pending HashTable backend; signature mirrors C `mnumber val`.
7566/// flow: isident guard → unset(EXECOPT) bail → `getvalue(&vbuf,&s,1)`
7567/// → if existing array/hashed (non-special, non-tied, non-KSHARRAYS,
7568/// no subscript) → unsetparam_pm + recreate → else if no value →
7569/// `createparam(t, type)` (POSIXIDENTIFIERS gates SCALAR vs
7570/// MN_INTEGER→PM_INTEGER else PM_FFLOAT) → second `getvalue` →
7571/// `check_warn_pm` if ASSPM_WARN → clear PM_DEFAULTED → `setnumvalue`
7572/// → return pm. This port wires the structural flow against the
7573/// already-ported helpers; the createparam/paramtab backend is
7574/// still stubbed elsewhere so the create-new-param branch returns
7575/// None until `createparam` lands.
7576pub fn assignnparam(s: &str, val: mnumber, flags: i32) -> Option<Box<param>> {
7577    // c:3666 `if (!isident(s)) { zerr; errflag |= ERRFLAG_ERROR; return NULL; }`
7578    if !isident(s) {
7579        zerr(&format!("not an identifier: {}", s)); // c:3667
7580        errflag.fetch_or(
7581            // c:3669
7582            ERRFLAG_ERROR,
7583            Ordering::Relaxed,
7584        );
7585        return None; // c:3670
7586    }
7587    if unset(EXECOPT) {
7588        return None;
7589    }
7590    let mut vbuf = value {
7591        pm: None,
7592        arr: Vec::new(),
7593        scanflags: 0,
7594        valflags: 0,
7595        start: 0,
7596        end: -1,
7597    };
7598    let mut cursor: &str = s;
7599    let has_sub = s.contains('[');
7600    let mut was_unset = false;
7601    let v = getvalue(Some(&mut vbuf), &mut cursor, 1);
7602    let need_create = match v {
7603        Some(ref vv) => {
7604            if let Some(pm) = vv.pm.as_ref() {
7605                let f = pm.node.flags as u32;
7606                if (f & (PM_ARRAY | PM_HASHED)) != 0
7607                    && (f & (PM_SPECIAL | PM_TIED)) == 0
7608                    && unset(KSHARRAYS)
7609                    && !has_sub
7610                {
7611                    // unsetparam_pm(vv.pm, 0, 1);
7612                    was_unset = true;
7613                    true
7614                } else {
7615                    false
7616                }
7617            } else {
7618                true
7619            }
7620        }
7621        None => true,
7622    };
7623    if need_create {
7624        // c:3686-3691 — `createparam(t, val.type & MN_FLOAT ? PM_FFLOAT
7625        // : PM_INTEGER); second getvalue;`. Synthesize a fresh
7626        // numeric param in paramtab matching the C body. Without
7627        // this branch wired, callers like `setiparam` silently
7628        // dropped the create (returned None) — every new integer
7629        // param assignment was a no-op.
7630        let _ = was_unset;
7631        let new_type = if val.type_ == MN_FLOAT {
7632            PM_FFLOAT // c:3687
7633        } else {
7634            PM_INTEGER // c:3688
7635        };
7636        // c:Src/params.c:3690 — newly created PM_INTEGER param
7637        // inherits the source numeric base from `lastbase` (set by
7638        // the math parser when consuming a `N#NNN` or `0x..` literal).
7639        // Mirror the assignstrvalue path at c:3714 so `(( X = 16#ff ))`
7640        // creates X as `typeset -i16 X=255` (displays as `16#FF`)
7641        // rather than naked decimal `255`.
7642        let inherited_base = if val.type_ == MN_FLOAT {
7643            0
7644        } else {
7645            let lb = crate::ported::math::lastbase();
7646            if lb > 0 {
7647                lb
7648            } else {
7649                0
7650            }
7651        };
7652        let pm: Param = Box::new(param {
7653            node: hashnode {
7654                next: None,
7655                nam: s.to_string(),
7656                flags: new_type as i32,
7657            },
7658            u_data: 0,
7659            u_tied: None,
7660            u_arr: None,
7661            u_str: None,
7662            // c:3690 — `setnumvalue(...)` stores the value. For
7663            // PM_INTEGER → u.l; for PM_FFLOAT → u.dval.
7664            u_val: if val.type_ == MN_FLOAT { 0 } else { val.l },
7665            u_dval: if val.type_ == MN_FLOAT { val.d } else { 0.0 },
7666            u_hash: None,
7667            gsu_s: None,
7668            gsu_i: None,
7669            gsu_f: None,
7670            gsu_a: None,
7671            gsu_h: None,
7672            base: inherited_base,
7673            width: 0,
7674            env: None,
7675            ename: None,
7676            old: None,
7677            level: 0,
7678        });
7679        if let Ok(mut tab) = paramtab().write() {
7680            tab.insert(s.to_string(), pm.clone());
7681        }
7682        return Some(pm);
7683    }
7684    if (flags & ASSPM_WARN) != 0 {
7685        if let Some(ref vv) = v {
7686            if let Some(ref pm) = vv.pm {
7687                check_warn_pm(pm, "numeric", 0, 1);
7688            }
7689        }
7690    }
7691    // The reassign path: getvalue gave us a cloned pm inside the value
7692    // buffer. setnumvalue mutates that clone but the write doesn't
7693    // propagate back to paramtab. Write through paramtab directly so
7694    // reassignments stick — same shape as `assignsparam`'s c:3343
7695    // `assignstrvalue(v, val, flags)` path which mutates paramtab in
7696    // place.
7697    if let Ok(mut tab) = paramtab().write() {
7698        if let Some(pm) = tab.get_mut(s) {
7699            // c:Src/params.c — setnumvalue (the C function this
7700            // reassign-path mirrors) eventually calls setfn which
7701            // goes through assignstrvalue (params.c:2899-2904) where
7702            // PM_READONLY rejection lives. The Rust port writes
7703            // u_val / u_dval / u_str directly, bypassing that path,
7704            // so the readonly check has to happen here. Without it,
7705            // `(( x++ ))` / `let "x = ..."` could mutate readonly
7706            // params silently. Bug #154 in docs/BUGS.md.
7707            if (pm.node.flags as u32 & PM_READONLY) != 0 {
7708                // zerr internally sets ERRFLAG_ERROR via the
7709                // c:194 path in Src/utils.c. Match the existing
7710                // readonly check at params.rs:3951 (assignstrvalue
7711                // arm) — same call shape, same behavior.
7712                zerr(&format!("read-only variable: {}", pm.node.nam));
7713                return None;
7714            }
7715            pm.node.flags &= !(PM_DEFAULTED as i32);
7716            let t = PM_TYPE(pm.node.flags as u32);
7717            if t == PM_INTEGER {
7718                // c:2874 — `pm->gsu.i->setfn(pm, val.u.l)`. MN_FLOAT
7719                // input truncates to integer.
7720                pm.u_val = if val.type_ == MN_FLOAT {
7721                    val.d as i64
7722                } else {
7723                    val.l
7724                };
7725                // c:Src/params.c:2801 — `if (!v->pm->base && lastbase
7726                // != -1) v->pm->base = lastbase;`. After setfn the C
7727                // path falls through `setstrvalue(v, NULL)` which
7728                // inherits the source numeric base from `lastbase`
7729                // when the param doesn't yet have an explicit base.
7730                // Mirror that here so `(( x = 0xFF ))` on an existing
7731                // integer param updates pm.base to 16, displaying as
7732                // `16#FF` instead of decimal `255`. Bug #175 in
7733                // docs/BUGS.md. The create path already inherits at
7734                // params.rs:5759-5768; this is the reassign-path
7735                // equivalent.
7736                if pm.base == 0 {
7737                    let lb = crate::ported::math::lastbase();
7738                    if lb > 0 {
7739                        pm.base = lb;
7740                    }
7741                }
7742            } else if t == PM_EFLOAT || t == PM_FFLOAT {
7743                // c:2878 — MN_INTEGER input promotes to f64.
7744                pm.u_dval = if val.type_ == MN_FLOAT {
7745                    val.d
7746                } else {
7747                    val.l as f64
7748                };
7749            } else if t == PM_SCALAR || t == PM_NAMEREF || t == PM_ARRAY {
7750                // c:2862-2871 — convbase/convfloat → u_str.
7751                let s_rendered = if val.type_ == MN_FLOAT {
7752                    convfloat_underscore(val.d, pm.width)
7753                } else {
7754                    convbase_underscore(val.l, if pm.base > 0 { pm.base } else { 10 }, pm.width)
7755                };
7756                pm.u_str = Some(s_rendered);
7757            }
7758            let cloned = pm.clone();
7759            return Some(cloned);
7760        }
7761    }
7762    None
7763}
7764
7765/// Port of `Param setnparam(char *s, mnumber val)` from `Src/params.c:3745-3749`.
7766///
7767/// C body (c:3747-3748):
7768/// ```c
7769/// return assignnparam(s, val, ASSPM_WARN);
7770/// ```
7771///
7772/// Single-line wrapper around `assignnparam` with ASSPM_WARN flags.
7773///
7774/// The previous Rust port took `(s: &str, val: f64) -> ()` — losing
7775/// the integer branch (callers couldn't set integer params via
7776/// `setnparam`) AND the Param return. No real callers existed because
7777/// the fabricated sig fit nothing. Match C exactly: `(s, val)` where
7778/// `val` is the canonical `mnumber` tagged union, returning the
7779/// resulting Param.
7780pub fn setnparam(s: &str, val: mnumber) -> Option<Param> {
7781    assignnparam(s, val, ASSPM_WARN as i32) // c:3748
7782}
7783
7784/// Port of `Param assigniparam(char *s, zlong val, int flags)` from
7785/// `Src/params.c:3754-3761`.
7786///
7787/// C body (c:3757-3760):
7788/// ```c
7789/// mnumber mnval;
7790/// mnval.type = MN_INTEGER;
7791/// mnval.u.l = val;
7792/// return assignnparam(s, mnval, flags);
7793/// ```
7794///
7795/// Two divergences in the previous Rust port:
7796///   1. Dropped the `flags` arg — caller-supplied flags (e.g.
7797///      ASSPM_AUGMENT for `+= int`) couldn't be threaded through;
7798///      every call hardcoded ASSPM_WARN regardless.
7799///   2. Returned void instead of Param — losing the new param
7800///      pointer the caller may want to read back.
7801pub fn assigniparam(s: &str, val: i64, flags: i32) -> Option<Param> {
7802    // c:3757-3759 — `mnumber{ .type = MN_INTEGER, .u.l = val }`.
7803    let mnval = mnumber {
7804        l: val,
7805        d: 0.0,
7806        type_: MN_INTEGER,
7807    };
7808    // c:3760 — `return assignnparam(s, mnval, flags);`
7809    assignnparam(s, mnval, flags) // c:3760
7810}
7811
7812/// Port of `Param setiparam(char *s, zlong val)` from `Src/params.c:3767-3773`.
7813///
7814/// C body (c:3769-3772):
7815/// ```c
7816/// mnumber mnval;
7817/// mnval.type = MN_INTEGER;
7818/// mnval.u.l = val;
7819/// return assignnparam(s, mnval, ASSPM_WARN);
7820/// ```
7821///
7822/// The previous Rust port stringified to decimal and routed through
7823/// `assignsparam` — which CREATES THE PARAM AS PM_SCALAR. C creates
7824/// as PM_INTEGER. `setiparam("x", 5)` followed by `typeset -p x`:
7825///   - C: \`typeset -i x=5\`
7826///   - Old Rust: \`typeset x=5\`
7827///
7828/// `assignnparam` IS now ported (params.rs:4403). Route through it
7829/// matching C exactly so integer-typed params get created with the
7830/// right PM_INTEGER flag.
7831pub fn setiparam(s: &str, val: i64) -> Option<Param> {
7832    // c:3770-3771 — `mnumber{ .type = MN_INTEGER, .u.l = val }`.
7833    let mnval = mnumber {
7834        l: val,
7835        d: 0.0,
7836        type_: MN_INTEGER,
7837    };
7838    // c:3772 — `return assignnparam(s, mnval, ASSPM_WARN);`
7839    assignnparam(s, mnval, ASSPM_WARN as i32) // c:3772
7840}
7841
7842/// Port of `setiparam_no_convert(char *s, zlong val)` from Src/params.c:3781. C
7843/// source comment: "If the target is already an integer, this
7844/// gets converted back. Low technology rules." It uses convbase
7845/// to render decimal then calls assignsparam.
7846/// WARNING: param names don't match C — Rust=() vs C=(s, val)
7847pub fn setiparam_no_convert(s: &str, val: i64) -> Option<Param> {
7848    assignsparam(s, &val.to_string(), ASSPM_WARN as i32)
7849}
7850
7851/// Port of `resetparam(Param pm, int flags)` from `Src/params.c:3796`. C body:
7852/// ```c
7853/// char *s = pm->node.nam;
7854/// queue_signals();
7855/// if (pm != (Param)(paramtab == realparamtab ?
7856///        paramtab->getnode2(paramtab, s) :
7857///        paramtab->getnode(paramtab, s))) {
7858///     unqueue_signals();
7859///     zerr("can't change type of hidden variable: %s", s);
7860///     return 1;
7861/// }
7862/// s = dupstring(s);
7863/// unsetparam_pm(pm, 0, 1);
7864/// unqueue_signals();
7865/// createparam(s, flags);
7866/// return 0;
7867/// ```
7868/// Tears `pm` down + recreates it with `flags` so the next
7869/// assignment lands in a fresh slot of the requested type. Used
7870/// by `assignsparam` when the type-flag of an existing param
7871/// changes (e.g. `typeset -i x; x="abc"` resets x back to scalar).
7872///
7873/// The `paramtab->getnode` reachability check at c:3800 catches
7874/// the hidden-shadow case (a local var hiding the global `pm` we
7875/// were handed) — without the paramtab vtable we skip the check
7876/// and proceed to unset+create.
7877pub fn resetparam(pm: &mut param, flags: i32) -> i32 {
7878    // c:3796
7879    let s = pm.node.nam.clone(); // c:3796
7880    queue_signals(); // c:3799
7881                     // c:3800-3807 — paramtab->getnode2 / getnode reachability check.
7882                     // Without paramtab vtable wired we cannot detect the hidden-
7883                     // variable case, so we proceed; a future port of paramtab
7884                     // adds the check at this site.
7885    unsetparam_pm(pm, 0, 1); // c:3819
7886    unqueue_signals(); // c:3819
7887    let _ = createparam(&s, flags); // c:3819
7888    0 // c:3819
7889}
7890
7891/// Port of `void unsetparam(char *s)` from `Src/params.c:3819`.
7892///
7893/// C body:
7894/// ```c
7895/// Param pm;
7896/// queue_signals();
7897/// if ((pm = (Param)(paramtab == realparamtab ?
7898///         paramtab->getnode2(paramtab, s) :
7899///         paramtab->getnode(paramtab, s))))
7900///     unsetparam_pm(pm, 0, 1);
7901/// unqueue_signals();
7902/// ```
7903///
7904/// The previous Rust port took `(variables, arrays, assoc_arrays,
7905/// name)` operating on EXTERNAL HashMap storage — a SubstState-
7906/// era stale signature. C operates on the canonical `paramtab`
7907/// global. No live callers used the old 4-arg form (all use
7908/// `paramtab().write().remove(...)` directly), so renaming is
7909/// safe.
7910pub fn unsetparam(name: &str) -> i32 {
7911    // c:3819 — C's unsetparam is void and discards unsetparam_pm's
7912    // status; bin_unset (c:Src/builtin.c:3952-3953) does the
7913    // paramtab lookup itself and calls `if (unsetparam_pm(pm, 0, 1))
7914    // returnval = 1;`. The Rust bin_unset routes through this
7915    // wrapper for the bridge plumbing (tied names, hashed-storage
7916    // shadow, special regenerators), so the rejection status is
7917    // surfaced here instead: 1 = readonly rejection, 0 = unset ok.
7918    // c:Src/params.c:3853-3935 — unsetparam_pm's tied-alt-name
7919    // removal block. zsh's PATH/path, FPATH/fpath, MANPATH/manpath,
7920    // CDPATH/cdpath, PSVAR/psvar pairs are tied (`pm->ename` points
7921    // to the alt name) — unsetting one must clear the other or
7922    // command lookup keeps finding binaries via the surviving `path`
7923    // array even after `unset PATH`. The full ename machinery is
7924    // deferred until the gsu vtable lands; until then, mirror the
7925    // tie explicitly for the canonical pairs so `unset PATH` is
7926    // actually a security boundary. Bug #416.
7927    let tied_alt: Option<&str> = match name {
7928        "PATH" => Some("path"),
7929        "path" => Some("PATH"),
7930        "FPATH" => Some("fpath"),
7931        "fpath" => Some("FPATH"),
7932        "MANPATH" => Some("manpath"),
7933        "manpath" => Some("MANPATH"),
7934        "CDPATH" => Some("cdpath"),
7935        "cdpath" => Some("CDPATH"),
7936        "PSVAR" => Some("psvar"),
7937        "psvar" => Some("PSVAR"),
7938        "MODULE_PATH" => Some("module_path"),
7939        "module_path" => Some("MODULE_PATH"),
7940        "FIGNORE" => Some("fignore"),
7941        "fignore" => Some("FIGNORE"),
7942        "MAILPATH" => Some("mailpath"),
7943        "mailpath" => Some("MAILPATH"),
7944        _ => None,
7945    };
7946    queue_signals(); // c:3825
7947                     // c:3826-3831 — `if ((pm = ... getnode2 ...) && !(pm->node.flags
7948                     // & PM_NAMEREF)) unsetparam_pm(pm, 0, 1);`.
7949                     //
7950                     // Two divergences in the previous Rust port:
7951                     //   1. Missing PM_NAMEREF check — `unsetparam("ref")` where `ref`
7952                     //      is a nameref would remove the ref alias itself. C explicitly
7953                     //      skips nameref params here (they're cleared via the
7954                     //      ref-specific path, not the value-side unset).
7955                     //   2. Bypassed `unsetparam_pm` — removed the entry directly from
7956                     //      paramtab without running the readonly-guard at c:3850, the
7957                     //      stdunsetfn dispatch at c:3870, or the `pm->old` scope
7958                     //      restore. `typeset -r x=foo; unset x` would silently succeed
7959                     //      in Rust where C rejects with `read-only variable: x`.
7960                     // c:Src/params.c:3853 — flag regenerator-style specials as unset
7961                     // so subsequent reads via lookup_special_var skip the getfn.
7962                     // RANDOM/SECONDS/EPOCH*/TTYIDLE/ERRNO have no paramtab pm node in
7963                     // zshrs (they're lookup_special_var libc shims), so the standard
7964                     // unsetparam_pm path below doesn't catch them. Bug #417/#418.
7965    if matches!(
7966        name,
7967        "RANDOM" | "SECONDS" | "EPOCHSECONDS" | "EPOCHREALTIME" | "TTYIDLE" | "ERRNO"
7968    ) {
7969        mark_unset_special(name);
7970    }
7971    // c:Src/params.c:3850 — `if (pm->node.flags & PM_READONLY) { zerr;
7972    // return 1; }`. Read-only specials (LINENO, HISTCMD, PPID, etc.)
7973    // have PM_READONLY in their special_paramdef entry but no paramtab
7974    // pm node by default, so the standard PM_READONLY check inside
7975    // unsetparam_pm never fires for them. Walk the special_params
7976    // table directly to catch these. Bug #419.
7977    let is_readonly_special = special_params
7978        .iter()
7979        .any(|ip| ip.name == name && (ip.pm_flags & PM_READONLY) != 0);
7980    if is_readonly_special {
7981        zerr(&format!("read-only variable: {}", name));
7982        unqueue_signals();
7983        return 1; // c:3854 — unsetparam_pm's readonly rejection status
7984    }
7985    let mut retval = 0i32;
7986    // c:Src/params.c:3855 — `if (pm->ename && !altflag) altremove =
7987    // ztrdup(pm->ename)`. User ties created by `typeset -T FOO foo`
7988    // cross-link the two params via `pm.ename` (each side names the
7989    // other); the static `tied_alt` map above only covers the
7990    // canonical PM_SPECIAL pairs (PATH/path …). Capture the unset
7991    // param's ename here so the cascade below can clear the tied
7992    // partner for user ties too.
7993    let (found, is_nameref, param_ename) = {
7994        let tab = paramtab().read().unwrap();
7995        match tab.get(name) {
7996            Some(pm) => (
7997                true,
7998                (pm.node.flags as u32 & PM_NAMEREF) != 0,
7999                pm.ename.clone(),
8000            ),
8001            None => (false, false, None),
8002        }
8003    };
8004    if found && !is_nameref {
8005        // c:3826-3830
8006        // c:3831 — `unsetparam_pm(pm, 0, 1)`. Take an owned copy out
8007        // of paramtab so we can mutate it (unsetparam_pm wants
8008        // &mut), run the readonly-guard + env teardown, then re-insert
8009        // or fully remove based on the readonly path.
8010        let mut pm_owned = paramtab().write().unwrap().remove(name).unwrap();
8011        let rejected = unsetparam_pm(&mut pm_owned, 0, 1); // c:3831
8012        if rejected != 0 {
8013            retval = 1; // c:Src/builtin.c:3952-3953 surfaced to bin_unset
8014                        // Readonly rejection — restore the entry so the state
8015                        // is unchanged.
8016            paramtab()
8017                .write()
8018                .unwrap()
8019                .insert(name.to_string(), pm_owned);
8020        } else if pm_owned.old.is_some()
8021            || (pm_owned.level > 0 && locallevel.load(Ordering::Relaxed) as i32 >= pm_owned.level)
8022        {
8023            // c:Src/params.c:3892-3925 — when the unset'd pm is a
8024            // local that shadowed an outer binding (chained via
8025            // pm.old by `addparam` at c:1137), the local pm STAYS
8026            // in paramtab with PM_UNSET set so the current scope's
8027            // reads see "unset" (empty). The pm.old chain is
8028            // preserved so endparamscope can uncover the outer
8029            // when the local scope ends. Without this re-insert,
8030            // either:
8031            //   - the outer would be uncovered immediately (wrong:
8032            //     zsh hides the outer until scope end), or
8033            //   - pm.old would be dropped (wrong: outer never
8034            //     comes back).
8035            // c:3911-3913 — `if ((pm->level && locallevel >= pm->level)
8036            // ...) return 0;` — locals are kept in the table marked
8037            // PM_UNSET even WITHOUT an outer binding ("foo() { local
8038            // bar; unset bar; } makes the global bar available? The
8039            // following makes the answer no"), and `typeset -p bar`
8040            // still finds the node (prints nothing, status 0).
8041            paramtab()
8042                .write()
8043                .unwrap()
8044                .insert(name.to_string(), pm_owned);
8045        } else if (pm_owned.node.flags as u32 & PM_SPECIAL) != 0
8046            && (pm_owned.node.flags as u32 & PM_REMOVABLE) == 0
8047        {
8048            // c:Src/params.c:3911-3913 — `if ((pm->flags &
8049            // (PM_SPECIAL|PM_REMOVABLE)) == PM_SPECIAL) return 0;`.
8050            // PM_SPECIAL params (SECONDS, RANDOM, HOME, IFS, ...)
8051            // stay in paramtab with PM_UNSET set after unset. A
8052            // subsequent re-assign (`SECONDS=100`) finds the same
8053            // pm via createparam's `oldpm` lookup, hits the reuse
8054            // arm (c:1132), preserves the PM_INTEGER|PM_SPECIAL
8055            // flags + gsu vtable, so intsetfn's name-dispatch
8056            // fires and routes through intsecondssetfn. Without
8057            // this, the special pm was dropped, a fresh PM_SCALAR
8058            // pm was created for the re-assignment, the value
8059            // landed in pm.u_str, and lookup_special_var kept
8060            // reading the time-since-shtimer delta. Bug #418 in
8061            // docs/BUGS.md.
8062            //
8063            // PM_UNSET is already stamped by unsetparam_pm at
8064            // line 6491; keep it on the re-inserted pm so reads
8065            // still see "unset" until re-assignment clears it
8066            // (clear_unset_special at line 4756 handles the
8067            // regenerator-style unset_specials set; the pm flag
8068            // is cleared inside assignsparam's value-write arm
8069            // via stdunsetfn's symmetric set/clear convention).
8070            paramtab()
8071                .write()
8072                .unwrap()
8073                .insert(name.to_string(), pm_owned);
8074        }
8075        // No pm.old + no rejection + not PM_SPECIAL → drop entirely
8076        // (matches the C path at c:3935 where the node is removed
8077        // from paramtab).
8078    }
8079    // c:Src/params.c:3905-3935 — tied-alt removal. Cascade the
8080    // unset to the paired name (PATH↔path etc.). Also clear the OS
8081    // env mirror since command lookup at the syscall level reads
8082    // the inherited libc environ.
8083    // Static canonical pair (PATH↔path …) takes precedence; otherwise
8084    // fall back to the unset param's `ename` (user `typeset -T` ties).
8085    let effective_alt: Option<String> = tied_alt.map(|s| s.to_string()).or(param_ename);
8086    if let Some(alt) = effective_alt.as_deref() {
8087        let alt_present = paramtab()
8088            .read()
8089            .map(|t| t.contains_key(alt))
8090            .unwrap_or(false);
8091        if alt_present {
8092            if let Some(mut alt_pm) = paramtab().write().ok().and_then(|mut t| t.remove(alt)) {
8093                let _ = unsetparam_pm(&mut alt_pm, 1, 1);
8094            }
8095        }
8096        env::remove_var(alt);
8097        env::remove_var(name);
8098        // c:Src/params.c:5291 — `if (t == path) cmdnamtab->emptytable
8099        // (cmdnamtab)`. The hashed-cmdnam cache holds absolute paths
8100        // resolved via the prior PATH search; without clearing,
8101        // `unset PATH; ls` still hits the cached `/bin/ls` entry and
8102        // exec succeeds — defeating the security boundary the unset
8103        // is supposed to establish. Bug #416.
8104        if matches!(name, "PATH" | "path") {
8105            crate::ported::hashtable::emptycmdnamtable();
8106        }
8107    }
8108    unqueue_signals(); // c:3832
8109    retval
8110}
8111
8112/// Unset parameter (from params.c unsetparam_pm)
8113/// Port of `unsetparam_pm(Param pm, int altflag, int exp)` from `Src/params.c:3841`. Full body
8114/// removes `pm` from `paramtab` (after invoking
8115/// `pm->gsu.s->unsetfn(pm, exp)`), tears down the tied alternate
8116/// (`pm->ename`) when `!altflag`, deletes the env entry, and
8117/// resurrects `pm->old` at the right scope. Stub: needs paramtab
8118/// HashTable backend (`paramtab->removenode/addnode`) plus the
8119/// `delenv`/`adduserdir` helpers — direct port retains only the
8120/// in-memory mutation of `pm` that doesn't touch the table.
8121#[allow(unused_variables)]
8122pub fn unsetparam_pm(pm: &mut param, altflag: i32, exp: i32) -> i32 {
8123    // c:3850 — `if ((pm->node.flags & PM_READONLY) && pm->level <= locallevel)`.
8124    let cur_ll = locallevel.load(Ordering::Relaxed) as i32; // c:3850 locallevel
8125    if (pm.node.flags as u32 & PM_READONLY) != 0 && pm.level <= cur_ll {
8126        // c:3850
8127        // c:3852 — `zerr("read-only %s: %s", ...)`. Emit diagnostic
8128        // so users see why the unset failed.
8129        let kind = if (pm.node.flags as u32 & PM_NAMEREF) != 0 {
8130            // c:3852
8131            "reference"
8132        } else {
8133            "variable"
8134        };
8135        zerr(&format!("read-only {}: {}", kind, pm.node.nam));
8136        return 1; // c:3854
8137    }
8138    pm.node.flags &= !(PM_DECLARED as i32); // c:3868
8139    if (pm.node.flags as u32 & PM_UNSET) == 0 || (pm.node.flags as u32 & PM_REMOVABLE) != 0 {
8140        // c:3870 — `pm->gsu.s->unsetfn(pm, exp)` — open-coded to stdunsetfn.
8141        stdunsetfn(pm, exp);
8142    }
8143    if pm.env.is_some() {
8144        delenv(&pm.node.nam); // c:3872 delenv(pm)
8145        pm.env = None;
8146    }
8147    // Tied alt-name removal + paramtab restore-from-old not yet
8148    // possible without HashTable backend; the C postlude (lines
8149    // 3853-3935) is a paramtab->removenode + addnode dance that
8150    // requires the missing vtable.
8151    pm.node.flags |= PM_UNSET as i32;
8152    0
8153}
8154
8155// -----------------------------------------------------------
8156// GSU dispatch callbacks — direct ports against `param.u_*`
8157// fields. C source in Src/params.c:4002.
8158// -----------------------------------------------------------
8159
8160/// Port of `intgetfn(Param pm)` from `Src/params.c:3993`. C body:
8161/// `return pm->u.val;`
8162pub fn intgetfn(pm: &param) -> i64 {
8163    pm.u_val
8164}
8165
8166/// Port of `intsetfn(Param pm, zlong x)` from `Src/params.c:4002`. C body:
8167/// `pm->u.val = x;`
8168pub fn intsetfn(pm: &mut param, x: i64) {
8169    // c:Src/params.c:4575 — PM_SPECIAL integers have per-name gsu_i->setfn
8170    // hooks: SECONDS routes through intsecondssetfn (shtimer math),
8171    // RANDOM seeds the PRNG, etc. The default intsetfn is the fallback
8172    // for non-special integers (pm->u.val write).
8173    //
8174    // Rust port lookup_special_var dispatches GETTERS by name; the
8175    // setters need symmetric dispatch so `SECONDS=N` actually moves
8176    // shtimer instead of writing u.val which intsecondsgetfn never
8177    // reads. Without this, `$SECONDS` always read the time-since-shtimer
8178    // delta regardless of writes.
8179    // Name-based dispatch (not flag-based): some assignment paths
8180    // construct a fresh param shell for tc and lose PM_SPECIAL.
8181    match pm.node.nam.as_str() {
8182        "SECONDS" => {
8183            intsecondssetfn(x);
8184            return;
8185        }
8186        // c:Src/params.c:364-365 —
8187        //   IPDEF6("TRY_BLOCK_ERROR",     &try_errflag,  varinteger_gsu)
8188        //   IPDEF6("TRY_BLOCK_INTERRUPT", &try_interrupt, varinteger_gsu)
8189        // varinteger_gsu's setfn is `intvarsetfn` (c:4213), whose body is
8190        // `*pm->u.valptr = x;` — i.e. the assignment writes the loop.c
8191        // GLOBAL itself, not just `pm->u.val`. That is the entire
8192        // mechanism behind `TRY_BLOCK_ERROR=0` inside an `always` block:
8193        // `exectry` re-reads the global after the always-list runs
8194        // (c:Src/loop.c:774 `if (try_errflag) errflag |= ERRFLAG_ERROR;`)
8195        // and a user-zeroed global swallows the try-block's error.
8196        //
8197        // The Rust port cannot carry C's `u.valptr` raw pointer, so the
8198        // bound global is reached by name — the same asymmetry the
8199        // SECONDS / RANDOM arms below already resolve this way. Without
8200        // it, `TRY_BLOCK_ERROR=0` wrote `pm.u_val` while every reader
8201        // (params.rs's special-var getter, exectry) kept consulting the
8202        // atomic, so the assignment was a no-op.
8203        "TRY_BLOCK_ERROR" => {
8204            crate::ported::r#loop::try_errflag.store(x, Ordering::Relaxed); // c:4213
8205            intvarsetfn(pm, x); // c:4213 `*pm->u.valptr = x;`
8206            return;
8207        }
8208        "TRY_BLOCK_INTERRUPT" => {
8209            crate::ported::r#loop::try_interrupt.store(x, Ordering::Relaxed); // c:4213
8210            intvarsetfn(pm, x); // c:4213
8211            return;
8212        }
8213        // c:Src/params.c:4552 randomsetfn — `RANDOM=N` calls
8214        // srand(N). Without this dispatch, $RANDOM writes only u.val
8215        // and the next read returns rand()'s next value from the
8216        // PROCESS-START seed, not the user-requested seed. Same
8217        // name-based dispatch shape as SECONDS above.
8218        "RANDOM" => {
8219            randomsetfn(x);
8220            return;
8221        }
8222        // c:Src/params.c:4698 uidsetfn / c:4719 euidsetfn / c:4740
8223        // gidsetfn / c:4761 egidsetfn — `UID=N` / `EUID=N` /
8224        // `GID=N` / `EGID=N` attempt the corresponding setuid /
8225        // seteuid / setgid / setegid syscall and emit
8226        // `failed to change [effective ]{user,group} ID: ERRNO`
8227        // on failure. Bug #254 in docs/BUGS.md. Same name-based
8228        // dispatch shape as SECONDS/RANDOM above.
8229        "UID" => {
8230            uidsetfn(x);
8231            return;
8232        }
8233        "EUID" => {
8234            euidsetfn(x);
8235            return;
8236        }
8237        "GID" => {
8238            gidsetfn(x);
8239            return;
8240        }
8241        "EGID" => {
8242            egidsetfn(x);
8243            return;
8244        }
8245        // c:Src/params.c:4974 histsizesetfn / c:4998 savehistsizesetfn —
8246        // `HISTSIZE=N` / `SAVEHIST=N` must update the canonical
8247        // `histsiz` / `savehistsiz` globals AND clamp to >= 1 +
8248        // call `resizehistents()` so the in-memory history buffer
8249        // shrinks/grows. Without this dispatch the assignment
8250        // lands in `pm.u_val` and `histsizegetfn` (which reads the
8251        // global) keeps returning the un-touched default. Bug #520.
8252        "HISTSIZE" => {
8253            histsizesetfn(x);
8254            return;
8255        }
8256        "SAVEHIST" => {
8257            savehistsizesetfn(x);
8258            return;
8259        }
8260        _ => {}
8261    }
8262    pm.u_val = x;
8263}
8264
8265/// Port of `floatgetfn(Param pm)` from `Src/params.c:4011`. C body:
8266/// `return pm->u.dval;`
8267pub fn floatgetfn(pm: &param) -> f64 {
8268    pm.u_dval
8269}
8270
8271/// Port of `floatsetfn(Param pm, double x)` from `Src/params.c:4020`. C body:
8272/// `pm->u.dval = x;`
8273pub fn floatsetfn(pm: &mut param, x: f64) {
8274    // c:Src/params.c:4603 floatsecondssetfn — PM_SPECIAL float SECONDS
8275    // routes through shtimer math. Symmetric with intsetfn's SECONDS
8276    // dispatch above and lookup_special_var's getter.
8277    // c:Src/params.c:4603 floatsecondssetfn — PM_SPECIAL float SECONDS
8278    // routes through shtimer math. Symmetric with intsetfn's SECONDS
8279    // dispatch above and lookup_special_var's getter. Some assignment
8280    // paths construct a fresh `param` shell for tc (type-conversion)
8281    // and lose PM_SPECIAL, so name-only dispatch is the safe fallback.
8282    if pm.node.nam == "SECONDS" {
8283        floatsecondssetfn(x);
8284        return;
8285    }
8286    pm.u_dval = x;
8287}
8288
8289/// Port of `strgetfn(Param pm)` from `Src/params.c:4029`. C body:
8290/// `return pm->u.str ? pm->u.str : (char *) hcalloc(1);`
8291pub fn strgetfn(pm: &param) -> String {
8292    pm.u_str.clone().unwrap_or_default()
8293}
8294
8295/// Port of `strsetfn(Param pm, char *x)` from `Src/params.c:4040`.
8296///
8297/// C body (c:4043-4051):
8298/// ```c
8299/// zsfree(pm->u.str); pm->u.str = x;
8300/// if (!(pm->node.flags & PM_HASHELEM) &&
8301///     ((pm->node.flags & PM_NAMEDDIR) || isset(AUTONAMEDIRS))) {
8302///     pm->node.flags |= PM_NAMEDDIR;
8303///     adduserdir(pm->node.nam, x, 0, 0);
8304/// }
8305/// ```
8306///
8307/// The C body fires the `adduserdir` path when EITHER `PM_NAMEDDIR`
8308/// is already set OR the `AUTONAMEDIRS` option is on. The previous
8309/// Rust port only fired when PM_NAMEDDIR was already set, missing
8310/// the AUTONAMEDIRS auto-create branch entirely. With `setopt
8311/// AUTONAMEDIRS`, every scalar assignment to a path-shaped value
8312/// should register a named-directory entry for `~name` expansion;
8313/// the Rust port silently dropped that behavior.
8314pub fn strsetfn(pm: &mut param, x: String) {
8315    // c:4040
8316    pm.u_str = Some(x.clone()); // c:4044 pm->u.str = x
8317                                // c:4045-4046 — `if (!(PM_HASHELEM) && (PM_NAMEDDIR || isset(AUTONAMEDIRS)))`.
8318    if (pm.node.flags as u32 & PM_HASHELEM) == 0
8319        && ((pm.node.flags as u32 & PM_NAMEDDIR) != 0 || isset(AUTONAMEDIRS))
8320    // c:4046 isset(AUTONAMEDIRS)
8321    {
8322        pm.node.flags |= PM_NAMEDDIR as i32; // c:4047
8323        adduserdir(&pm.node.nam, &x, 0, false); // c:4048
8324    }
8325}
8326
8327/// Port of `arrgetfn(Param pm)` from `Src/params.c:4057`. C body:
8328/// `return pm->u.arr ? pm->u.arr : &nullarray;`
8329pub fn arrgetfn(pm: &param) -> Vec<String> {
8330    pm.u_arr.clone().unwrap_or_default()
8331}
8332
8333/// Port of `arrsetfn(Param pm, char **x)` from `Src/params.c:4066`. C body frees
8334/// the old array, applies PM_UNIQUE filter via `uniqarray()`, then
8335/// stores. Calls `arrfixenv(ename, x)` for tied colon-arrays.
8336pub fn arrsetfn(pm: &mut param, x: Vec<String>) {
8337    let val = if (pm.node.flags as u32 & PM_UNIQUE) != 0 {
8338        simple_arrayuniq(x)
8339    } else {
8340        x
8341    };
8342    pm.u_arr = Some(val.clone());
8343    if let Some(ename) = pm.ename.clone() {
8344        arrfixenv(&ename, Some(&val));
8345    }
8346}
8347
8348/// Port of `hashgetfn(Param pm)` from `Src/params.c:4084`. C body:
8349/// `return pm->u.hash;`
8350pub fn hashgetfn(pm: &param) -> Option<&HashTable> {
8351    pm.u_hash.as_ref()
8352}
8353
8354/// Port of `hashsetfn(Param pm, HashTable x)` from `Src/params.c:4093`. C body:
8355/// `if (pm->u.hash && pm->u.hash != x) deleteparamtable(pm->u.hash);
8356///  pm->u.hash = x;`
8357pub fn hashsetfn(pm: &mut param, x: HashTable) {
8358    pm.u_hash = Some(x);
8359}
8360
8361/// Direct port of `static void arrhashsetfn(Param pm, char **val,
8362/// int flags)` from `Src/params.c:4113-4170`. Set callback for
8363/// assoc arrays: takes a flat `[k1, v1, k2, v2, ...]` value list
8364/// and turns it into a hash.
8365///
8366/// C body:
8367///   1. Count non-Marker entries; if odd, error c:4128-4131.
8368///   2. Under ASSPM_AUGMENT, fetch existing hash via getfn
8369///      (c:4134-4137); otherwise allocate fresh via
8370///      newparamtable(17, name).
8371///   3. Walk pairs: each value (k, v) becomes a PM_SCALAR|PM_UNSET
8372///      child param `createparam(k)`, then `assignstrvalue(v->pm,
8373///      val, eltflags)` (c:4140-4166).
8374///   4. `pm->gsu.h->setfn(pm, ht)` to install (c:4168).
8375///
8376/// Storage model: C's per-pair `createparam(k)` + `assignstrvalue`
8377/// builds child Params inside a fresh `newparamtable`; zshrs's assoc
8378/// values live in the `paramtab_hashed_storage` IndexMap keyed by the
8379/// owning param's name, so the pair walk writes there. `pm.u_hash`
8380/// stays untouched — the IndexMap is the authoritative store (same
8381/// contract sethparam/gethparam already use).
8382pub fn arrhashsetfn(
8383    // c:4113
8384    pm: &mut param,
8385    val: Vec<String>,
8386    flags: i32,
8387) {
8388    // c:4124-4127 — count non-Marker entries.
8389    let alen: usize = val
8390        .iter()
8391        .filter(|s| !s.starts_with(Marker as char))
8392        .count();
8393
8394    // c:4129-4131 — odd count → error.
8395    if alen % 2 != 0 {
8396        zerr("bad set of key/value pairs for associative array");
8397        return;
8398    }
8399
8400    // c:4135-4139 — ASSPM_AUGMENT starts from the existing hash
8401    // (`ht = paramtab = pm->gsu.h->getfn(pm)`); otherwise a fresh
8402    // table (`newparamtable(17, pm->node.nam)`).
8403    let mut map: IndexMap<String, String> = if (flags & ASSPM_AUGMENT) != 0 {
8404        paramtab_hashed_storage()
8405            .lock()
8406            .unwrap()
8407            .get(&pm.node.nam)
8408            .cloned()
8409            .unwrap_or_default() // c:4136
8410    } else {
8411        IndexMap::new() // c:4138-4139
8412    };
8413
8414    // c:4141-4166 — pair walk. keyvalpairelement (Src/subst.c:49)
8415    // emits `[Marker, key, value]` triples for `[k]=v` / `[k]+=v`
8416    // forms ("Either all elements have Marker or none. Checked in
8417    // caller." c:4144); plain input is flat `[k, v, ...]` pairs.
8418    let mut it = val.into_iter();
8419    while let Some(first) = it.next() {
8420        let (elt_augment, key) = if first.starts_with(Marker as char) {
8421            // c:4145-4151 — `(*aptr)[1] == '+'` → per-element append
8422            // (ASSPM_AUGMENT via the setsparam INT_MAX trick).
8423            let aug = first[Marker.len_utf8()..].starts_with('+');
8424            match it.next() {
8425                Some(k) => (aug, k),
8426                None => break,
8427            }
8428        } else {
8429            (false, first)
8430        };
8431        let v = it.next().unwrap_or_default(); // c:4166 assignstrvalue value
8432        if elt_augment {
8433            // c:4147-4150 — `[k]+=v` appends to the existing element.
8434            map.entry(key).or_default().push_str(&v);
8435        } else {
8436            // c:4156-4166 — createparam(k, PM_SCALAR|PM_UNSET) +
8437            // assignstrvalue: plain insert in the IndexMap model.
8438            map.insert(key, v);
8439        }
8440    }
8441
8442    // c:4168-4169 — `pm->gsu.h->setfn(pm, ht)` installs the table.
8443    paramtab_hashed_storage()
8444        .lock()
8445        .unwrap()
8446        .insert(pm.node.nam.clone(), map);
8447    // c:4170 — free(val). Rust drops automatically.
8448}
8449
8450/// Port of `nullstrsetfn(UNUSED(Param pm), char *x)` from `Src/params.c:4180`. C body:
8451/// `zsfree(x);` — frees but doesn't store. Rust drop handles free.
8452#[allow(unused_variables)]
8453pub fn nullstrsetfn(pm: &mut param, x: String) {}
8454
8455/// Port of `nullunsetfn(UNUSED(Param pm), UNUSED(int exp))` from `Src/params.c:4192`. C body: empty.
8456#[allow(unused_variables)]
8457pub fn nullunsetfn(pm: &mut param, exp: i32) {}
8458
8459/// Port of `stdunsetfn(Param pm, UNUSED(int exp))` from `Src/params.c:3955`. C body:
8460/// dispatches `pm->gsu->setfn(pm, NULL)` per `PM_TYPE`, clears
8461/// `PM_TIED`/frees ename for tied params, sets PM_UNSET.
8462///
8463/// Rust port mirrors C semantics: clears the union slot and sets
8464/// PM_UNSET. The GSU vtable callbacks are stored on `param` as
8465/// `Option<Gsu*>` (zsh_h:760-764) but the dispatch uses callback
8466/// fn-ptrs that aren't generally registered yet, so we open-code
8467/// the "setfn(pm, NULL)" effect by zeroing the matching union
8468/// member instead of calling through the vtable.
8469#[allow(unused_variables)]
8470pub fn stdunsetfn(pm: &mut param, exp: i32) {
8471    match PM_TYPE(pm.node.flags as u32) {
8472        PM_SCALAR | PM_NAMEREF => {
8473            pm.u_str = None;
8474        }
8475        PM_ARRAY => {
8476            pm.u_arr = None;
8477        }
8478        PM_HASHED => {
8479            pm.u_hash = None;
8480        }
8481        _ => {
8482            if (pm.node.flags as u32 & PM_SPECIAL) == 0 {
8483                pm.u_str = None;
8484            }
8485        }
8486    }
8487    if (pm.node.flags as u32 & (PM_SPECIAL | PM_TIED)) == PM_TIED {
8488        pm.ename = None;
8489        pm.node.flags &= !(PM_TIED as i32);
8490    }
8491    pm.node.flags |= PM_UNSET as i32;
8492}
8493
8494// -----------------------------------------------------------
8495// "Null" callbacks — no-op getfn/setfn/unsetfn slots used for
8496// read-only or write-only special params.
8497// -----------------------------------------------------------
8498
8499/// Port of `nullintsetfn(UNUSED(Param pm), UNUSED(zlong x))` from `Src/params.c:4187`. C body:
8500/// empty (no-op setter for read-only int params).
8501#[allow(unused_variables)]
8502pub fn nullintsetfn(pm: &mut param, x: i64) {}
8503
8504/// Port of `nullsethashfn(UNUSED(Param pm), HashTable x)` from `Src/params.c:4104`. C body:
8505/// `deleteparamtable(x);` — frees the supplied table, doesn't store.
8506#[allow(unused_variables)]
8507pub fn nullsethashfn(pm: &mut param, x: HashTable) {
8508    // Rust drop semantics free `x` when this scope ends.
8509}
8510
8511// -----------------------------------------------------------
8512// Generic special-param GSU callbacks (`u.valptr` / `u.data`).
8513// C source uses raw pointer indirection through `pm->u.data`/
8514// `pm->u.valptr` — Rust port stores the global's name in `u_str`
8515// (lookup key) since we can't carry raw pointers across an FFI
8516// boundary safely. The lookup-table integration ships with the
8517// special-params init code (Src/params.c:4213 createparamtable).
8518// -----------------------------------------------------------
8519
8520/// Port of `intvargetfn(Param pm)` from `Src/params.c:4202`. C body:
8521/// `return *pm->u.valptr;`
8522pub fn intvargetfn(pm: &param) -> i64 {
8523    pm.u_val
8524}
8525
8526/// Port of `intvarsetfn(Param pm, zlong x)` from `Src/params.c:4213`. C body:
8527/// `*pm->u.valptr = x;`
8528pub fn intvarsetfn(pm: &mut param, x: i64) {
8529    pm.u_val = x;
8530}
8531
8532/// Port of `zlevarsetfn(Param pm, zlong x)` from `Src/params.c:4224`. C body sets
8533/// the int and triggers `adjustwinsize` for LINES/COLUMNS.
8534/// Port of `zlevarsetfn(Param pm, zlong x)` from `Src/params.c:4226`.
8535/// C body: `*p = x; if (p == &zterm_lines || p == &zterm_columns)
8536/// adjustwinsize(2 + (p == &zterm_columns));`
8537///
8538/// The `from` argument to `adjustwinsize` is documented at
8539/// `Src/utils.c:1883-1887`: 0=signal, 1=manual, 2=LINES callback,
8540/// 3=COLUMNS callback. Each value selects a different code path
8541/// inside `adjustwinsize` — for example, `from=2` skips the
8542/// COLUMNS-specific ioctl, and `from=3` skips the LINES path.
8543///
8544/// The previous Rust port passed `0` for both LINES and COLUMNS,
8545/// which triggered the FULL `getwinsz` ioctl + both adjustlines
8546/// AND adjustcolumns calls AND the potential setiparam recursion
8547/// — diverging from C's narrow "just adjust the one axis we
8548/// changed" semantics. Effect: setting `LINES=80` would re-issue
8549/// `setiparam("COLUMNS", ...)` recursively, churning the
8550/// paramtab for no reason.
8551pub fn zlevarsetfn(pm: &mut param, x: i64) {
8552    // c:4226
8553    pm.u_val = x; // c:4230 *p = x;
8554                  // c:4231-4232 — `2 + (p == &zterm_columns)` selects 2 for LINES
8555                  // (zterm_lines) and 3 for COLUMNS (zterm_columns).
8556    if pm.node.nam == "LINES" {
8557        let _ = adjustwinsize(2); // c:4232 LINES path
8558    } else if pm.node.nam == "COLUMNS" {
8559        let _ = adjustwinsize(3); // c:4232 COLUMNS path
8560    }
8561}
8562
8563/// Port of `strvarsetfn(Param pm, char *x)` from `Src/params.c:4249`. C body:
8564/// `zsfree(*q); *q = x;` where `q = (char **)pm->u.data`.
8565pub fn strvarsetfn(pm: &mut param, x: Option<String>) {
8566    pm.u_str = x;
8567}
8568
8569/// Port of `strvargetfn(Param pm)` from `Src/params.c:4263`. C body:
8570/// `s = *((char **)pm->u.data); return s ? s : hcalloc(1);`
8571pub fn strvargetfn(pm: &param) -> String {
8572    pm.u_str.clone().unwrap_or_default()
8573}
8574
8575/// Port of `arrvargetfn(Param pm)` from `Src/params.c:4279`. C body:
8576/// `arrptr = *((char ***)pm->u.data); return arrptr ?: &nullarray;`
8577pub fn arrvargetfn(pm: &param) -> Vec<String> {
8578    pm.u_arr.clone().unwrap_or_default()
8579}
8580
8581/// Direct port of `mod_export void arrvarsetfn(Param pm, char **x)`
8582/// from `Src/params.c:4292-4317`. The previous body skipped three of
8583/// the four canonical C arms:
8584///   1. PM_UNIQUE → uniqarray (was ported via simple_arrayuniq).
8585///   2. PM_SPECIAL + null x → `*dptr = mkarray(NULL)` so a tied
8586///      array set to NULL becomes a writable empty array, not a
8587///      dangling null (was missing).
8588///   3. `pm->ename` set → `arrfixenv(ename, x)` syncs the colon-
8589///      joined env var partner (was missing — breaks PATH/path,
8590///      FPATH/fpath, etc. when set via the array form).
8591///   4. `pm->ename` set + null x + `*dptr == path` → invalidate
8592///      pathchecked so the next path resolution re-walks (was
8593///      missing).
8594pub fn arrvarsetfn(pm: &mut param, x: Option<Vec<String>>) {
8595    // c:4296 `char ***dptr = (char ***)pm->u.data;`
8596    // c:4298-4299 — `if (*dptr != x) freearray(*dptr);` Rust Vec drop
8597    // on reassignment handles freeing automatically.
8598    // c:4300-4301 — `if (pm->node.flags & PM_UNIQUE) uniqarray(x);`
8599    let uniq_applied: Option<Vec<String>> = match x {
8600        Some(v) if (pm.node.flags as u32 & PM_UNIQUE) != 0 => Some(simple_arrayuniq(v)),
8601        other => other,
8602    };
8603    // c:4302-4310 — PM_SPECIAL + NULL → mkarray(NULL); else assign.
8604    let final_val: Vec<String> = match uniq_applied {
8605        Some(v) => v, // c:4310 `*dptr = x;`
8606        None => {
8607            if (pm.node.flags as u32 & PM_SPECIAL) != 0 {
8608                crate::ported::utils::mkarray(None) // c:4308 `mkarray(NULL)`
8609            } else {
8610                Vec::new() // c:4310 — null case for non-special: empty.
8611            }
8612        }
8613    };
8614    // c:4311-4316 — ename sync.
8615    if let Some(ename) = pm.ename.clone() {
8616        // c:4311 `if (pm->ename)`
8617        if !final_val.is_empty() || pm.u_arr.is_some() {
8618            // c:4312-4313 — `if (x) arrfixenv(pm->ename, x);`
8619            arrfixenv(&ename, Some(&final_val));
8620        } else if pm.node.nam == "path" {
8621            // c:4314-4315 — `else if (*dptr == path) pathchecked = path;`
8622            // — invalidate the path-resolver cache. Rust port uses an
8623            // AtomicUsize sentinel; storing 0 marks "must re-walk".
8624            crate::ported::hashtable::pathchecked.store(0, Ordering::SeqCst);
8625        }
8626    }
8627    pm.u_arr = Some(final_val);
8628}
8629
8630/// Array to colon-separated path — inverse of `colonsplit`.
8631/// Port of `colonarrgetfn(Param pm)` from Src/params.c (joins the array
8632/// stored in `pm->u.colon` back into the `:`-form for env).
8633/// WARNING: param names don't match C — Rust=(arr) vs C=(pm)
8634pub fn colonarrgetfn(arr: &[String]) -> String {
8635    arr.join(":")
8636}
8637
8638/// Port of `colonarrsetfn(Param pm, char *x)` from `Src/params.c:4329`. C body
8639/// splits the colon-string into an array and stores via the
8640/// generic arrvarsetfn.
8641pub fn colonarrsetfn(pm: &mut param, x: Option<String>) {
8642    let uniq = (pm.node.flags as u32 & PM_UNIQUE) != 0; // c:4339
8643                                                        // c:4339-4341 — `arrvarsetfn(pm, x ? colonsplit(...) : NULL);`
8644                                                        // The None branch must pass `None` (not `Some(Vec::new())`) so the
8645                                                        // PM_SPECIAL + NULL → mkarray(NULL) arm in arrvarsetfn fires.
8646    let arr = x.map(|s| colonsplit(&s, uniq)); // c:4339
8647    arrvarsetfn(pm, arr);
8648}
8649
8650/// Port of `tiedarrgetfn(Param pm)` from `Src/params.c:4348`. C body:
8651///   `struct tieddata *dptr = (struct tieddata *)pm->u.data;`
8652///   `return *dptr->arrptr ? zjoin(*dptr->arrptr, …) : "";`
8653///
8654/// C's `pm->u.data->arrptr` is a raw pointer into the partner array
8655/// param's storage. The Rust port can't hold a pointer into another
8656/// paramtab entry's heap, so the partner lookup goes via
8657/// `pm.ename` → paramtab → `apm.u_arr`. For backwards compatibility
8658/// with callers that set `pm.u_arr` directly (the pre-fix code path
8659/// at c:4348 that's still in some Rust call sites), fall back to
8660/// the scalar's own `u_arr` when `ename` is None or the partner is
8661/// missing. Bug #24 in docs/BUGS.md.
8662pub fn tiedarrgetfn(pm: &param) -> Vec<String> {
8663    if let Some(ename) = pm.ename.as_deref() {
8664        if let Ok(tab) = paramtab().read() {
8665            if let Some(apm) = tab.get(ename) {
8666                if let Some(arr) = apm.u_arr.as_ref() {
8667                    return arr.clone();
8668                }
8669            }
8670        }
8671    }
8672    pm.u_arr.clone().unwrap_or_default()
8673}
8674
8675/// Direct port of `void tiedarrsetfn(Param pm, char *x)` from
8676/// `Src/params.c:4357-4389`. Setter for a colon-array-tied
8677/// scalar (PATH/CDPATH/MAILPATH/etc.).
8678///
8679/// C body:
8680///   1. Free the existing tied array (`*dptr->arrptr`) at c:4363.
8681///   2. If no array but an `ename` exists, clear PM_DEFAULTED on
8682///      the tied array param (c:4365-4368).
8683///   3. If `x` is non-null: build a 1-or-2-byte separator from
8684///      `dptr->joinchar` (Meta-quoting if needed, c:4371-4380),
8685///      `sepsplit(x, sepbuf, 0, 0)` into the array (c:4381), and
8686///      uniqarray() if PM_UNIQUE (c:4382-4383). Free `x` (c:4384).
8687///   4. Else: `*dptr->arrptr = NULL` (c:4385-4386).
8688///   5. If `pm->ename` is set, call `arrfixenv(pm->name, arrptr)`
8689///      to sync env (c:4387-4388).
8690///
8691/// The Rust port treats `u_arr` as the tied array storage and
8692/// uses `':'` as the joinchar default (matches PATH/CDPATH/FPATH
8693/// /MAILPATH/PSVAR/MODULE_PATH which all use colon separators —
8694/// the joinchar field on the C-side tieddata wasn't ported to the
8695/// Rust Param struct yet).
8696pub fn tiedarrsetfn(pm: &mut param, x: Option<String>) {
8697    // c:4357
8698
8699    // c:4361-4368 — free old / clear PM_DEFAULTED on tied counterpart.
8700    if pm.u_arr.is_none() {
8701        if let Some(ename) = pm.ename.clone() {
8702            // c:4365
8703            let mut tab = paramtab().write().unwrap();
8704            if let Some(altpm) = tab.get_mut(&ename) {
8705                // c:4366
8706                altpm.node.flags &= !(PM_DEFAULTED as i32); // c:4367
8707            }
8708        }
8709    }
8710
8711    // c:4369-4386 — split + assign. C writes through `*dptr->arrptr`
8712    // which is a pointer INTO THE PARTNER ARRAY param's storage. The
8713    // Rust port can't hold raw pointers across paramtab entries, so
8714    // when `pm.ename` is set, write the split result to the partner's
8715    // `u_arr` via paramtab (bug #24). When `pm.ename` is None (older
8716    // call sites or non-tied use), keep the scalar's own `u_arr`
8717    // up-to-date for legacy callers.
8718    // c:4370-4380 — single-byte separator built from `dptr->joinchar`
8719    // on the tieddata riding `pm->u.data` (Rust: typed `u_tied` view);
8720    // joinchar==0 → empty sepbuf; no tieddata → `:` default (the
8721    // PM_SPECIAL colon-tied params, c:5314-5315).
8722    let sepbuf: String = match pm.u_tied.as_deref() {
8723        Some(td) if td.joinchar == 0 => String::new(), // c:4376-4377
8724        Some(td) => ((td.joinchar as u8) as char).to_string(), // c:4378-4379
8725        None => ":".to_string(),                       // c:5314-5315
8726    };
8727    let arr_opt: Option<Vec<String>> = if let Some(s) = x {
8728        // c:4369
8729        // c:4381 — `sepsplit(x, sepbuf, 0, 0)`.
8730        // joinchar==0 (typeset -T s a ''): the zsh 5.9.1 release
8731        // binary keeps the whole string as ONE element on assignment
8732        // (measured: `typeset -T S s ""; S=abc; typeset -p s` →
8733        // `s=( abc )`), diverging from a literal char-split reading
8734        // of sepsplit("") in the C source; match the release binary
8735        // (parity floor).
8736        let split: Vec<String> = if sepbuf.is_empty() {
8737            vec![s.clone()]
8738        } else {
8739            crate::ported::utils::sepsplit(&s, Some(&sepbuf), true)
8740        };
8741        // c:4382-4383 — uniqarray if PM_UNIQUE.
8742        let split = if pm.node.flags & PM_UNIQUE as i32 != 0 {
8743            // c:4382
8744            uniqarray(split) // c:4383
8745        } else {
8746            split
8747        };
8748        Some(split)
8749    } else {
8750        None
8751    };
8752    if let Some(ename) = pm.ename.clone() {
8753        if let Ok(mut tab) = paramtab().write() {
8754            if let Some(apm) = tab.get_mut(&ename) {
8755                apm.u_arr = arr_opt.clone(); // c:4381
8756            }
8757        }
8758        // c:4352 — zjoin writes the raw joinchar byte; joinchar==0
8759        // joins with NUL (measured on 5.9.1: `s=(x y); print -rn
8760        // "$S"` → `x\0y`), not with the empty split-sepbuf.
8761        let joinsep = if sepbuf.is_empty() {
8762            "\0"
8763        } else {
8764            sepbuf.as_str()
8765        };
8766        pm.u_str = arr_opt.as_ref().map(|a| a.join(joinsep));
8767    } else {
8768        pm.u_arr = arr_opt;
8769    }
8770
8771    // c:4387-4388 — `if (pm->ename) arrfixenv(pm->name, *dptr->arrptr)`.
8772    if pm.ename.is_some() {
8773        let nam = pm.node.nam.clone();
8774        // Pull the live array out of the partner for env sync.
8775        let snap = paramtab().read().ok().and_then(|t| {
8776            t.get(pm.ename.as_deref().unwrap())
8777                .and_then(|p| p.u_arr.clone())
8778        });
8779        arrfixenv(&nam, snap.as_deref());
8780    }
8781}
8782
8783/// Port of `tiedarrunsetfn(Param pm, UNUSED(int exp))` from `Src/params.c:4393`. C body
8784/// frees the tied storage and calls stdunsetfn.
8785/// Direct port of `void tiedarrunsetfn(Param pm, UNUSED(int exp))`
8786/// from `Src/params.c:4393`. Special unset for tied arrays:
8787/// frees tieddata, ename, clears PM_TIED, sets PM_UNSET.
8788///
8789/// C body:
8790///   pm->gsu.s->setfn(pm, NULL);             // c:4393
8791///   zfree(pm->u.data, sizeof(tieddata));    // c:4393
8792///   pm->u.data = NULL;                      // c:4393
8793///   zsfree(pm->ename);                      // c:4393
8794///   pm->ename = NULL;                       // c:4393
8795///   pm->flags &= ~PM_TIED;                  // c:4393
8796///   pm->flags |= PM_UNSET;                  // c:4393
8797pub fn tiedarrunsetfn(pm: &mut param, _exp: i32) {
8798    // c:4393
8799    // c:4400 — invoke the scalar setfn with NULL (frees backing array).
8800    tiedarrsetfn(pm, None);
8801    // c:4401-4403 — drop tieddata.
8802    pm.u_data = 0;
8803    pm.u_arr = None;
8804    // c:4404-4405 — `zsfree(pm->ename); pm->ename = NULL`.
8805    pm.ename = None;
8806    // c:4406-4407 — flag toggles.
8807    pm.node.flags &= !(PM_TIED as i32);
8808    pm.node.flags |= PM_UNSET as i32;
8809}
8810
8811// -----------------------------------------------------------
8812// Array uniq helpers.
8813// -----------------------------------------------------------
8814
8815/// Port of `simple_arrayuniq(char **x, int freeok)` from `Src/params.c:4412`. C body:
8816/// O(n^2) dedupe in place — first occurrence wins.
8817/// WARNING: param names don't match C — Rust=(x) vs C=(x, freeok)
8818pub fn simple_arrayuniq(x: Vec<String>) -> Vec<String> {
8819    let mut seen: HashSet<String> = HashSet::new();
8820    let mut out = Vec::with_capacity(x.len());
8821    for s in x {
8822        if seen.insert(s.clone()) {
8823            out.push(s);
8824        }
8825    }
8826    out
8827}
8828
8829/// Port of `arrayuniq_freenode(HashNode hn)` from `Src/params.c:4443`. C
8830/// body: `zsfree(((Pathnode)hn)->name); zfree(hn, sizeof…);` —
8831/// the freenode callback for the temporary HashTable `arrayuniq`
8832/// builds. Rust drop semantics handle this; no-op shim.
8833/// is `(void)hn;` — intentional no-op; passed as freenode callback
8834/// to scratch hashtable used by `arrayuniq` so existing entries
8835/// aren't freed when the table is torn down.
8836/// WARNING: param names don't match C — Rust=() vs C=(hn)
8837/// WARNING: param names don't match C — Rust=() vs C=(pm, x)
8838pub fn arrayuniq_freenode() {}
8839
8840/// Direct port of `HashTable newuniqtable(zlong size)` from
8841/// `Src/params.c:4450`. C body allocates a `HashTable`
8842/// named "arrayuniq" with the standard hasher/cmpnodes/
8843/// add/get/remove/disable/enable function pointers plus
8844/// `arrayuniq_freenode` as the freenode callback (which is a
8845/// no-op — see c:4443). Rust returns a `HashSet<String>` with
8846/// the size hint pre-allocated; the freenode-callback role is
8847/// implicit (Drop runs on HashSet teardown without freeing
8848/// borrowed strings).
8849pub fn newuniqtable(size: i64) -> HashSet<String> {
8850    // c:4450
8851    HashSet::with_capacity(size.max(0) as usize) // c:4450 newhashtable(size, ...)
8852}
8853
8854/// Direct port of `static void arrayuniq(char **x, int freeok)`
8855/// from `Src/params.c:4473`. First-wins dedupe of `x`,
8856/// in-place. C uses simple O(n²) scan for arrays under 10
8857/// entries, switching to a HashTable for larger arrays. `freeok`
8858/// controls whether to `zsfree()` duplicates (only safe when
8859/// caller owns the strings — Rust drop semantics handle it).
8860///
8861/// Signature note: C takes `char **x` + in-place mutation; Rust
8862/// takes owned `Vec<String>` and returns the deduped result.
8863/// `freeok` is preserved but is a no-op in Rust (drops free
8864/// automatically). The hashtable / simple-loop tiering follows
8865/// the same threshold (10) as C.
8866pub fn arrayuniq(x: Vec<String>, freeok: i32) -> Vec<String> {
8867    // c:4473
8868    let _ = freeok;
8869    let array_size = x.len();
8870    if array_size == 0 {
8871        // c:4481
8872        return x;
8873    }
8874    // c:4482-4486 — small-array fallback to simple_arrayuniq.
8875    if array_size < 10 {
8876        // c:4482
8877        return simple_arrayuniq(x); // c:4484
8878    }
8879    // c:4483 — `if (!(ht = newuniqtable(array_size + 1)))` — Rust
8880    // newuniqtable never fails, but mirror the C order of allocation.
8881    let mut ht = newuniqtable(array_size as i64 + 1);
8882    // c:4487-4507 — walk + first-wins.
8883    let mut out: Vec<String> = Vec::with_capacity(array_size);
8884    for s in x {
8885        // c:4487 walk
8886        if ht.insert(s.clone()) {
8887            // c:4488 gethashnode2 + addhashnode2
8888            out.push(s); // c:4495 *write_it = *it
8889        }
8890        // else: dup — drop the value (c:4502 zsfree if freeok).
8891    }
8892    drop(ht); // c:4523 deletehashtable
8893    out
8894}
8895
8896/// Remove duplicate elements from array while preserving order.
8897/// Port of `uniqarray(char **x)` from Src/params.c.
8898/// WARNING: param names don't match C — Rust=(arr) vs C=(x)
8899pub fn uniqarray(arr: Vec<String>) -> Vec<String> {
8900    let mut seen = HashSet::new();
8901    arr.into_iter().filter(|s| seen.insert(s.clone())).collect()
8902}
8903
8904/// Direct port of `void zhuniqarray(char **x)` from
8905/// `Src/params.c:4523`. Wraps `arrayuniq` with `freeok=0`.
8906/// (C body is literally `arrayuniq(x, 0);`.)
8907pub fn zhuniqarray(x: Vec<String>) -> Vec<String> {
8908    // c:4523
8909    arrayuniq(x, 0) // c:4523
8910}
8911
8912/// Port of `poundgetfn(UNUSED(Param pm))` from `Src/params.c:4534`. C body:
8913/// `return arrlen(pparams);`
8914/// WARNING: param names don't match C — Rust=() vs C=(pm)
8915pub fn poundgetfn() -> i64 {
8916    pparams_lock().lock().expect("pparams poisoned").len() as i64
8917}
8918
8919/// Port of `randomgetfn(UNUSED(Param pm))` from `Src/params.c:4543`. C body:
8920/// `return rand() & 0x7fff;`
8921/// WARNING: param names don't match C — Rust=() vs C=(pm)
8922pub fn randomgetfn() -> i64 {
8923    (unsafe { libc::rand() } & 0x7fff) as i64
8924}
8925
8926/// Port of `randomsetfn(UNUSED(Param pm), zlong v)` from `Src/params.c:4552`. C body:
8927/// `srand((unsigned int)v);`
8928/// WARNING: param names don't match C — Rust=(v) vs C=(pm, v)
8929pub fn randomsetfn(v: i64) {
8930    unsafe { libc::srand(v as libc::c_uint) };
8931}
8932
8933// -----------------------------------------------------------
8934// SECONDS / EPOCHSECONDS family — backed by SHTIMER static.
8935// -----------------------------------------------------------
8936
8937/// Port of `intsecondsgetfn(UNUSED(Param pm))` from `Src/params.c:4561`. C body:
8938/// `return (zlong)(now.tv_sec - shtimer.tv_sec - …);`
8939/// WARNING: param names don't match C — Rust=() vs C=(pm)
8940pub fn intsecondsgetfn() -> i64 {
8941    // c:4563 — `shtimer` is initialized at shell startup (zsh.h
8942    // mod_export). Force shtimer init BEFORE reading `now` so the
8943    // lazy-init race doesn't make `now < shtimer` on first call
8944    // (which produced -1 from the nsec borrow-from-sec adjustment).
8945    let timer = *shtimer_lock().lock().expect("shtimer poisoned");
8946    let now = SystemTime::now()
8947        .duration_since(UNIX_EPOCH)
8948        .unwrap_or_default();
8949    let now_sec = now.as_secs() as i64;
8950    let timer_sec = timer.as_secs() as i64;
8951    let now_nsec = now.subsec_nanos() as i64;
8952    let timer_nsec = timer.subsec_nanos() as i64;
8953    let diff = now_sec - timer_sec - i64::from(now_nsec < timer_nsec);
8954    // c:4565 — clamp negative-diff (lazy-init or clock skew) to 0
8955    // so \$SECONDS reads as a non-negative count of elapsed seconds
8956    // from shell start. zsh's shtimer is set in main() before any
8957    // user code runs, guaranteeing now >= shtimer; the Rust lazy
8958    // init makes this stricter via .max(0).
8959    diff.max(0)
8960}
8961
8962/// Port of `intsecondssetfn(UNUSED(Param pm), zlong x)` from `Src/params.c:4575`. C body:
8963/// ```c
8964/// diff = (zlong)now.tv_sec - x;
8965/// shtimer.tv_sec = diff;
8966/// if ((zlong)shtimer.tv_sec != diff)
8967///     zwarn("SECONDS truncated on assignment");
8968/// shtimer.tv_nsec = now.tv_nsec;
8969/// ```
8970/// WARNING: param names don't match C — Rust=(x) vs C=(pm, x)
8971pub fn intsecondssetfn(x: i64) {
8972    let now = SystemTime::now()
8973        .duration_since(UNIX_EPOCH)
8974        .unwrap_or_default();
8975    let now_sec = now.as_secs() as i64;
8976    let new_sec = now_sec - x;
8977    // c:4587 — C uses `zwarn` (informational), NOT `zerr` (fatal).
8978    // The C body STORES `diff` unconditionally then emits the warning
8979    // if truncation lost information. Rust port previously used `zerr`
8980    // and early-returned (skipping the store) — divergent from C.
8981    if new_sec < 0 {
8982        zwarn("SECONDS truncated on assignment");
8983        // c:4585 — C still stores; Rust represents shtimer as Duration
8984        // which is non-negative. We clamp to zero to preserve the
8985        // "store-anyway" semantic for the time-display path, even
8986        // though the negative-time case is unrepresentable.
8987        *shtimer_lock().lock().expect("shtimer poisoned") = Duration::new(0, now.subsec_nanos());
8988        return;
8989    }
8990    *shtimer_lock().lock().expect("shtimer poisoned") =
8991        Duration::new(new_sec as u64, now.subsec_nanos());
8992}
8993
8994/// Port of `floatsecondsgetfn(UNUSED(Param pm))` from `Src/params.c:4591`. C body:
8995/// `return (double)(now-tv_sec - shtimer.tv_sec) + nsec/1e9;`
8996/// WARNING: param names don't match C — Rust=() vs C=(pm)
8997pub fn floatsecondsgetfn() -> f64 {
8998    // Read shtimer BEFORE `now`, forcing its lazy init first — intsecondsgetfn
8999    // (above) documents the same ordering requirement: with the opposite order
9000    // a first call can observe `now < shtimer` and go backwards.
9001    let timer = *shtimer_lock().lock().expect("shtimer poisoned");
9002    let now = SystemTime::now()
9003        .duration_since(UNIX_EPOCH)
9004        .unwrap_or_default();
9005    // c:4595-4596 — `(double)(now.tv_sec - shtimer.tv_sec) +
9006    //                (double)(now.tv_nsec - shtimer.tv_nsec) / 1000000000.0`
9007    // C subtracts the two fields SEPARATELY and SIGNED, so a `now` behind
9008    // `shtimer` simply yields a negative reading. This was
9009    // `(now - timer).as_secs_f64()`, and Duration - Duration PANICS on
9010    // underflow rather than going negative — `overflow when subtracting
9011    // durations`. It had never fired because the float type was unreachable:
9012    // `typeset -F SECONDS` was silently ignored (bin_typeset never called
9013    // setsecondstype), so nothing ever selected this getter. Wiring that up
9014    // turned a silent no-op into a crash, which is how this surfaced.
9015    (now.as_secs() as f64 - timer.as_secs() as f64)
9016        + (now.subsec_nanos() as f64 - timer.subsec_nanos() as f64) / 1_000_000_000.0
9017}
9018
9019/// Port of `floatsecondssetfn(UNUSED(Param pm), double x)` from `Src/params.c:4603`. C body:
9020/// `shtimer.tv_sec = now.tv_sec - (zlong)x; shtimer.tv_nsec = now.tv_nsec - (x-int)*1e9;`
9021/// WARNING: param names don't match C — Rust=(x) vs C=(pm, x)
9022pub fn floatsecondssetfn(x: f64) {
9023    let now = SystemTime::now()
9024        .duration_since(UNIX_EPOCH)
9025        .unwrap_or_default();
9026    let new = now
9027        .checked_sub(Duration::from_secs_f64(x))
9028        .unwrap_or_default();
9029    *shtimer_lock().lock().expect("shtimer poisoned") = new;
9030}
9031
9032/// Port of `getrawseconds()` from `Src/params.c:4615`. C body:
9033/// `return (double)shtimer.tv_sec + (double)shtimer.tv_nsec / 1e9;`
9034pub fn getrawseconds() -> f64 {
9035    shtimer_lock()
9036        .lock()
9037        .expect("shtimer poisoned")
9038        .as_secs_f64()
9039}
9040
9041/// Port of `setrawseconds(double x)` from `Src/params.c:4622`. C body:
9042/// `shtimer.tv_sec = (zlong)x; shtimer.tv_nsec = (x-int)*1e9;`
9043pub fn setrawseconds(x: f64) {
9044    *shtimer_lock().lock().expect("shtimer poisoned") = Duration::from_secs_f64(x);
9045}
9046
9047/// Port of `setsecondstype(Param pm, int on, int off)` from `Src/params.c:4630`. C body
9048/// flips the `gsu.f`/`gsu.i` callback pointer based on the new
9049/// param-flag bitset.
9050///
9051/// WARNING: zshrs has no Param/GSU dispatch table yet — the
9052/// "promotion between integer/float seconds" logic happens via
9053/// pm->gsu pointer swaps in C. Returns 0 to signal success;
9054/// callers can assume the type change is recorded by the caller's
9055/// own bookkeeping until the GSU table lands.
9056/// WARNING: param names don't match C — Rust=(on, off) vs C=(pm, on, off)
9057pub fn setsecondstype(
9058    // c:4630
9059    pm: &mut param,
9060    on: i32,
9061    off: i32,
9062) -> i32 {
9063    // c:4632 — `int newflags = (pm->flags | on) & ~off`.
9064    let newflags = (pm.node.flags | on) & !off;
9065    // c:4633 — `int tp = PM_TYPE(newflags)`.
9066    let tp = PM_TYPE(newflags as u32);
9067    // c:4635-4638 / 4639-4642 — float vs integer GSU pointer swap.
9068    if tp == PM_EFLOAT || tp == PM_FFLOAT {
9069        // c:4635
9070        // C: `pm->gsu.f = &floatseconds_gsu`. GSU table not yet
9071        // wired in the Rust port; record the type by clearing
9072        // any integer GSU.
9073        pm.gsu_i = None;
9074        // pm.gsu_f = Some(floatseconds_gsu) — pending GSU port.
9075    } else if tp == PM_INTEGER {
9076        // c:4639
9077        // C: `pm->gsu.i = &intseconds_gsu`.
9078        pm.gsu_f = None;
9079        // pm.gsu_i = Some(intseconds_gsu) — pending GSU port.
9080    } else {
9081        return 1; // c:4644
9082    }
9083    pm.node.flags = newflags; // c:4645
9084    0 // c:4646
9085}
9086
9087// -----------------------------------------------------------
9088// $USERNAME
9089// -----------------------------------------------------------
9090
9091/// Port of `usernamegetfn(UNUSED(Param pm))` from `Src/params.c:4653`. C body:
9092/// Port of `usernamegetfn(UNUSED(Param pm))` from Src/params.c:4655.
9093/// C body: `return get_username();`. C's `get_username()`
9094/// (Src/utils.c:1075) walks `getuid() != cached_uid` and
9095/// refreshes the cache via `getpwuid()` on mismatch — so a
9096/// USERNAME read AFTER an `setuid()` call sees the NEW
9097/// username, not the stale cache.
9098///
9099/// The previous Rust port returned `cached_username_lock()`
9100/// directly without the refresh, so a script that called
9101/// setuid(3) (or USER changed externally via setuid binary)
9102/// would keep returning the old username.
9103///
9104pub fn usernamegetfn(_pm: &param) -> String {
9105    // c:4655
9106    // c:4658 — `return get_username();`. Route through the
9107    // canonical refresh-on-uid-change accessor at utils.rs.
9108    get_username() // c:4658
9109}
9110
9111/// Port of `usernamesetfn(UNUSED(Param pm), char *x)` from `Src/params.c:4662`. C body:
9112/// `getpwnam(x); setgid; setuid; cached_uid = pswd->pw_uid;`
9113///
9114/// WARNING: the SUID-changing path requires getpwnam(3) which
9115/// crosses an unsafe FFI boundary not yet wrapped here. The
9116/// cached-name update is performed; uid/gid changes still need
9117/// porting of the `pwd.h` getpwnam wrapper.
9118pub fn usernamesetfn(_pm: &mut param, x: String) {
9119    // c:4662
9120    // c:4662 — `if (x && (pswd = getpwnam(x)) && pswd->pw_uid != cached_uid)`.
9121    let target = std::ffi::CString::new(x.as_bytes()).ok();
9122    if let Some(cstr) = target {
9123        unsafe {
9124            let pwd = libc::getpwnam(cstr.as_ptr()); // c:4666
9125            if !pwd.is_null() {
9126                // c:4666 — C reads `cached_uid` (a global initialized
9127                // to `getuid()` at init.c:1219 — the REAL uid, NOT
9128                // the effective one). The previous Rust port used
9129                // `geteuid()` which diverges when running setuid
9130                // (geteuid != getuid) — the shell would erroneously
9131                // try to change to a uid it's already at, or skip
9132                // a needed change. Match C exactly: use `getuid()`.
9133                let cached_uid = libc::getuid(); // c:4666 cached_uid = getuid()
9134                if (*pwd).pw_uid != cached_uid {
9135                    // c:4666
9136                    // c:4670-4672 — initgroups(x, pswd->pw_gid).
9137                    let _ = libc::initgroups(cstr.as_ptr(), (*pwd).pw_gid as _);
9138                    // c:4671 — setgid(pswd->pw_gid).
9139                    if libc::setgid((*pwd).pw_gid) != 0 {
9140                        // c:4673
9141                        zwarn(&format!(
9142                            "failed to change group ID: {}",
9143                            std::io::Error::last_os_error()
9144                        ));
9145                    } else if libc::setuid((*pwd).pw_uid) != 0 {
9146                        // c:4675
9147                        // c:4675-4676 — setuid failed.
9148                        zwarn(&format!(
9149                            "failed to change user ID: {}",
9150                            std::io::Error::last_os_error()
9151                        ));
9152                    } else {
9153                        // c:4677-4681 — cache update.
9154                        let name_cstr = std::ffi::CStr::from_ptr((*pwd).pw_name);
9155                        let name_str = name_cstr.to_string_lossy().to_string();
9156                        *cached_username_lock().lock().expect("username poisoned") =
9157                            ztrdup_metafy(&name_str);
9158                    }
9159                }
9160            }
9161        }
9162    }
9163    // c:4683 — `zsfree(x)`; Rust drop handles it.
9164    drop(x);
9165}
9166
9167// -----------------------------------------------------------
9168// libc-backed callbacks (UID/GID/EUID/EGID/errno/RANDOM/TTYIDLE).
9169// -----------------------------------------------------------
9170
9171/// Port of `uidgetfn(UNUSED(Param pm))` from `Src/params.c:4689`. C body:
9172/// `return getuid();`
9173/// WARNING: param names don't match C — Rust=() vs C=(pm)
9174pub fn uidgetfn() -> i64 {
9175    unsafe { libc::getuid() as i64 }
9176}
9177
9178// `termflags` from Src/init.c — bitmap of terminal-state flags. Set
9179// from term_reinit_from_pm and consulted by ZLE before first paint.
9180/// `TERMFLAGS` static.
9181/// Starts as TERM_UNKNOWN (0x02) — c:Src/init.c:1103 `termflags =
9182/// TERM_UNKNOWN;` in init_setup. Cleared by init_term() on success
9183/// (c:Src/init.c:802-803); promptexpand/zleread lazily call
9184/// init_term() when the bit is still set (c:Src/prompt.c:189-190,
9185/// c:Src/Zle/zle_main.c:1260-1261).
9186pub static TERMFLAGS: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0x02);
9187// `TERM_UNKNOWN` re-exported from canonical zsh_h.rs (port of
9188// `Src/zsh.h:1986`). The local declaration here had the value
9189// `1 << 0 = 0x01` — which is C's TERM_BAD (Src/zsh.h:1985), NOT
9190// TERM_UNKNOWN. The canonical TERM_UNKNOWN value is 0x02.
9191//
9192// Callers reading `crate::ported::params::TERM_UNKNOWN` got the
9193// TERM_BAD bit; the params.rs term-init path fired
9194// `TERMFLAGS.fetch_or(TERM_UNKNOWN)` which actually set TERM_BAD,
9195// while the prompt.rs guard at line 441 imported the correct
9196// (0x02) value from zsh_h.rs — so the two paths disagreed silently
9197// about which bit means "unknown terminal".
9198
9199/// Port of `uidsetfn(UNUSED(Param pm), zlong x)` from `Src/params.c:4698`. C body:
9200/// `if (setuid((uid_t)x)) zerr("failed to change user ID: %e", errno);`
9201/// C body (2 lines):
9202///   `if (setuid((uid_t)x)) zerr("failed to change user ID: %e", errno);`
9203/// WARNING: param names don't match C — Rust=(x) vs C=(pm, x)
9204pub fn uidsetfn(x: i64) {
9205    // c:4698
9206    if unsafe { libc::setuid(x as libc::uid_t) } != 0 {
9207        // c:4701 — `zerr("failed to change user ID: %e", errno)`.
9208        // C's `%e` formatter consumes errno and prints
9209        // `strerror(errno)` with the system's casing (typically
9210        // lowercase on macOS/Linux). Rust's
9211        // `std::io::Error::last_os_error()` displays the same
9212        // text but with capital first letter + `(os error N)`
9213        // suffix, diverging from zsh. Mirror the C format by
9214        // calling strerror via libc directly. Bug #254 in
9215        // docs/BUGS.md.
9216        let errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
9217        let msg = unsafe {
9218            let p = libc::strerror(errno);
9219            std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned()
9220        };
9221        zerr(&format!("failed to change user ID: {}", msg.to_lowercase())); // c:4702
9222    }
9223}
9224
9225/// Port of `euidgetfn(UNUSED(Param pm))` from `Src/params.c:4710`. C body:
9226/// `return geteuid();`
9227/// WARNING: param names don't match C — Rust=() vs C=(pm)
9228pub fn euidgetfn() -> i64 {
9229    unsafe { libc::geteuid() as i64 }
9230}
9231
9232/// Port of `euidsetfn(UNUSED(Param pm), zlong x)` from `Src/params.c:4719`. C body:
9233/// `if (seteuid((uid_t)x)) zerr("failed to change effective user ID: %e", errno);`
9234/// WARNING: param names don't match C — Rust=(x) vs C=(pm, x)
9235pub fn euidsetfn(x: i64) {
9236    // c:4719
9237    if unsafe { libc::seteuid(x as libc::uid_t) } != 0 {
9238        // c:4722 — strerror format to match C zerr's `%e`. See
9239        // uidsetfn above.
9240        let errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
9241        let msg = unsafe {
9242            let p = libc::strerror(errno);
9243            std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned()
9244        };
9245        zerr(&format!(
9246            "failed to change effective user ID: {}",
9247            msg.to_lowercase()
9248        )); // c:4723
9249    }
9250}
9251
9252/// Port of `gidgetfn(UNUSED(Param pm))` from `Src/params.c:4731`. C body: `return getgid();`
9253/// WARNING: param names don't match C — Rust=() vs C=(pm)
9254pub fn gidgetfn() -> i64 {
9255    unsafe { libc::getgid() as i64 }
9256}
9257
9258/// Port of `gidsetfn(UNUSED(Param pm), zlong x)` from `Src/params.c:4740`. C body:
9259/// `if (setgid((gid_t)x)) zerr("failed to change group ID: %e", errno);`
9260/// WARNING: param names don't match C — Rust=(x) vs C=(pm, x)
9261pub fn gidsetfn(x: i64) {
9262    // c:4740
9263    if unsafe { libc::setgid(x as libc::gid_t) } != 0 {
9264        // c:4743 — strerror format to match C zerr's `%e`. See
9265        // uidsetfn above.
9266        let errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
9267        let msg = unsafe {
9268            let p = libc::strerror(errno);
9269            std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned()
9270        };
9271        zerr(&format!(
9272            "failed to change group ID: {}",
9273            msg.to_lowercase()
9274        )); // c:4744
9275    }
9276}
9277
9278/// Port of `egidgetfn(UNUSED(Param pm))` from `Src/params.c:4752`. C body: `return getegid();`
9279/// WARNING: param names don't match C — Rust=() vs C=(pm)
9280pub fn egidgetfn() -> i64 {
9281    unsafe { libc::getegid() as i64 }
9282}
9283
9284/// Port of `egidsetfn(UNUSED(Param pm), zlong x)` from `Src/params.c:4761`. C body:
9285/// `if (setegid((gid_t)x)) zerr("failed to change effective group ID: %e", errno);`
9286/// WARNING: param names don't match C — Rust=(x) vs C=(pm, x)
9287pub fn egidsetfn(x: i64) {
9288    // c:4761
9289    if unsafe { libc::setegid(x as libc::gid_t) } != 0 {
9290        // c:4764 — strerror format to match C zerr's `%e`. See
9291        // uidsetfn above.
9292        let errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
9293        let msg = unsafe {
9294            let p = libc::strerror(errno);
9295            std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned()
9296        };
9297        zerr(&format!(
9298            "failed to change effective group ID: {}",
9299            msg.to_lowercase()
9300        )); // c:4765
9301    }
9302}
9303
9304/// Port of `ttyidlegetfn(UNUSED(Param pm))` from `Src/params.c:4771`. C body:
9305/// ```c
9306/// struct stat ttystat;
9307/// if (SHTTY == -1 || fstat(SHTTY, &ttystat)) return -1;
9308/// return time(NULL) - ttystat.st_atime;
9309/// ```
9310/// Rust port reads stdin (fd 0) — closest match to `SHTTY` the
9311/// shell tracks as the controlling-tty fd. Returns -1 if stdin is
9312/// not a tty.
9313/// WARNING: param names don't match C — Rust=() vs C=(pm)
9314pub fn ttyidlegetfn() -> i64 {
9315    // c:4776 — `if (SHTTY == -1 || fstat(SHTTY, &ttystat)) return -1;`
9316    // The previous Rust port hardcoded fd 0 (stdin) which is wrong
9317    // when SHTTY was opened on a non-stdin file descriptor (e.g.
9318    // `zsh < script` where stdin is a file but the controlling tty
9319    // was opened separately). C tracks the actual SHTTY fd.
9320    let shtty = SHTTY.load(Ordering::SeqCst);
9321    if shtty == -1 {
9322        // c:4776
9323        return -1;
9324    }
9325    let mut st: libc::stat = unsafe { std::mem::zeroed() };
9326    if unsafe { libc::fstat(shtty, &mut st) } != 0 {
9327        // c:4776
9328        return -1;
9329    }
9330    let now = SystemTime::now()
9331        .duration_since(UNIX_EPOCH)
9332        .unwrap_or_default()
9333        .as_secs() as i64;
9334    now - st.st_atime as i64 // c:4779
9335}
9336
9337// -----------------------------------------------------------
9338// $IFS / $HOME / $TERM / $WORDCHARS / $TERMINFO / $TERMINFO_DIRS
9339// $KEYBOARD_HACK / $HISTCHARS / $_  — string-state callbacks.
9340// -----------------------------------------------------------
9341
9342/// Port of `ifsgetfn(UNUSED(Param pm))` from `Src/params.c:4784`. C body: `return ifs;`
9343pub fn ifsgetfn(_pm: &param) -> String {
9344    ifs_lock().lock().expect("ifs poisoned").clone()
9345}
9346
9347/// Port of `ifssetfn(UNUSED(Param pm), char *x)` from `Src/params.c:4793`. C body:
9348/// `zsfree(ifs); ifs = x; inittyptab();`
9349pub fn ifssetfn(_pm: &mut param, x: String) {
9350    *ifs_lock().lock().expect("ifs poisoned") = x;
9351    // c:4795 — `inittyptab()` rebuilds the typtab[] ISEP/IWSEP bits
9352    // from the new IFS. Without this, every word-split path stays
9353    // pinned to the old separator set and silently mis-splits.
9354    inittyptab();
9355}
9356
9357// -----------------------------------------------------------
9358// Locale callbacks: $LANG, $LC_*, setlang
9359// -----------------------------------------------------------
9360
9361/// Port of `clear_mbstate()` from `Src/params.c:4831`. C body:
9362/// `mb_charinit(); clear_shiftstate();`
9363///
9364/// WARNING: zshrs uses Rust's UTF-8 native handling so multibyte
9365/// state machines aren't kept; this is a no-op pinned to the
9366/// C name for parity.
9367/// (under `MULTIBYTE_SUPPORT`):
9368/// ```c
9369/// mb_charinit();        /* utils.c */
9370/// clear_shiftstate();   /* pattern.c */
9371/// ```
9372/// Resets the mbstate_t globals after LC_CTYPE changes (NetBSD-9
9373/// requires this). Rust port forwards to the matching helpers.
9374pub fn clear_mbstate() {
9375    // c:Src/params.c:4732+ — `#ifdef MULTIBYTE_SUPPORT
9376    //   mb_charinit();        /* utils.c */
9377    //   clear_shiftstate();   /* pattern.c */
9378    // #endif`
9379    // Both helpers are ported (utils.rs:526 and pattern.rs:190). The
9380    // pattern.rs version is currently a no-op (shiftstate machine
9381    // not stored) and utils.rs::mb_charinit resets the mbstate_t
9382    // tracking. Wire them through so a future locale-change hook
9383    // routes through this one entry point per c:Src/params.c
9384    // setlang(c:4842) which calls clear_mbstate() between setlocale
9385    // and the per-LC_* re-apply loop.
9386    crate::ported::utils::mb_charinit(); // c:utils.c mb_charinit
9387    crate::ported::pattern::clear_shiftstate(); // c:pattern.c:327 clear_shiftstate
9388}
9389
9390/// Port of `static struct localename lc_names[]` from `Src/params.c:4805-4825`.
9391/// C body:
9392/// ```c
9393/// static struct localename {
9394///     char *name;
9395///     int category;
9396/// } lc_names[] = {
9397///     {"LC_COLLATE", LC_COLLATE},
9398///     {"LC_CTYPE", LC_CTYPE},
9399///     {"LC_MESSAGES", LC_MESSAGES},
9400///     {"LC_NUMERIC", LC_NUMERIC},
9401///     {"LC_TIME", LC_TIME},
9402///     {NULL, 0}
9403/// };
9404/// ```
9405///
9406/// The C source guards each entry under `#ifdef LC_*`; libc on
9407/// macOS/Linux defines all five so the Rust port simply lists them.
9408const LC_NAMES: &[(&str, libc::c_int)] = &[
9409    ("LC_COLLATE", libc::LC_COLLATE),   // c:4810
9410    ("LC_CTYPE", libc::LC_CTYPE),       // c:4813
9411    ("LC_MESSAGES", libc::LC_MESSAGES), // c:4816
9412    ("LC_NUMERIC", libc::LC_NUMERIC),   // c:4819
9413    ("LC_TIME", libc::LC_TIME),         // c:4822
9414];
9415
9416/// Port of `setlang(char *x)` from `Src/params.c:4842`.
9417///
9418/// C body (c:4842-4869):
9419/// ```c
9420/// if ((x2 = getsparam_u("LC_ALL")) && *x2) return;
9421/// setlocale(LC_ALL, x ? unmeta(x) : "");
9422/// clear_mbstate();
9423/// queue_signals();
9424/// for (ln = lc_names; ln->name; ln++)
9425///     if ((x = getsparam_u(ln->name)) && *x)
9426///         setlocale(ln->category, x);
9427/// unqueue_signals();
9428/// inittyptab();
9429/// ```
9430///
9431/// The previous Rust port skipped the actual `setlocale(LC_ALL, ...)`
9432/// libc call and just set the LANG env var. C invokes libc
9433/// setlocale to actually change the program's locale state —
9434/// required so any libc calls during shell execution (e.g.,
9435/// `iswctype`, `mbrtowc`) use the new locale's classification.
9436///
9437/// Also skipped: the per-LC_* override loop (c:4866-4868) which
9438/// re-applies category-specific settings after the global
9439/// LC_ALL set. The Rust port doesn't yet have the lc_names
9440/// table, but we can at least respect the canonical sequence.
9441pub fn setlang(x: Option<&str>) {
9442    // c:4842
9443    // c:4847 — `if ((x2 = getsparam_u("LC_ALL")) && *x2) return;`
9444    if let Some(lc_all) = getsparam_u("LC_ALL") {
9445        // c:4847
9446        if !lc_all.is_empty() {
9447            return;
9448        }
9449    }
9450    // c:4860 — `setlocale(LC_ALL, x ? unmeta(x) : "");`
9451    let locale_arg = match x {
9452        Some(s) => unmeta(s),
9453        None => String::new(),
9454    };
9455    // The previous Rust port skipped the libc setlocale call.
9456    // Without it, libc's locale state (used by iswctype, mbrtowc,
9457    // etc.) stays pinned to whatever the shell inherited from
9458    // its parent — diverging from C which actively changes the
9459    // running program's locale.
9460    let cstr = std::ffi::CString::new(locale_arg.as_bytes()).unwrap_or_default();
9461    unsafe {
9462        libc::setlocale(libc::LC_ALL, cstr.as_ptr()); // c:4860
9463    }
9464    // Mirror to env so subsequent `getsparam("LANG")` reads agree.
9465    if let Some(s) = x {
9466        // c:Src/params.c:5354 — setenv via the C-named zputenv
9467        let _ = zputenv(&format!("{}={}", "LANG", s));
9468    }
9469    clear_mbstate(); // c:4861
9470                     // c:4863-4867 — `for (ln = lc_names; ln->name; ln++) if ((x =
9471                     // getsparam_u(ln->name)) && *x) setlocale(ln->category, x);`
9472                     // After the global LC_ALL setlocale, any explicitly-set LC_*
9473                     // category overrides its slot. The previous Rust port skipped
9474                     // this loop, so `LC_NUMERIC=tr_TR.UTF-8 LANG=C` would leave
9475                     // numeric formatting on C rather than tr_TR.
9476    for (name, category) in LC_NAMES {
9477        // c:4863
9478        if let Some(val) = getsparam_u(name) {
9479            // c:4866 getsparam_u
9480            if !val.is_empty() {
9481                let cat_cstr = std::ffi::CString::new(val.as_bytes()).unwrap_or_default();
9482                unsafe {
9483                    libc::setlocale(*category, cat_cstr.as_ptr()); // c:4867
9484                }
9485            }
9486        }
9487    }
9488    // c:4868 — `inittyptab();`. The locale change may shift which
9489    // bytes are isalpha/isalnum/etc under the typtab init, so the
9490    // table must be rebuilt.
9491    inittyptab();
9492}
9493
9494/// Port of `lc_allsetfn(Param pm, char *x)` from `Src/params.c:4873`.
9495///
9496/// C body (c:4873-4894):
9497/// ```c
9498/// strsetfn(pm, x);
9499/// if (!x || !*x) {
9500///     x = getsparam_u("LANG");
9501///     if (x && *x) {
9502///         queue_signals();
9503///         setlang(x);
9504///         unqueue_signals();
9505///     }
9506/// } else {
9507///     setlocale(LC_ALL, unmeta(x));
9508///     clear_mbstate();
9509///     inittyptab();
9510/// }
9511/// ```
9512///
9513/// The previous Rust port for the non-empty case set the env
9514/// var via `env::set_var("LC_ALL", &s)` but skipped THREE
9515/// pieces:
9516///   1. `setlocale(LC_ALL, unmeta(x))` — actively changes the
9517///      program's locale per c:4890.
9518///   2. `unmeta(x)` — strips Meta-encoded bytes before passing
9519///      to libc setlocale per c:4890.
9520///   3. `inittyptab()` — rebuilds the typtab for the new
9521///      LC_CTYPE per c:4892.
9522///
9523/// WARNING: param names don't match C — Rust=(x) vs C=(pm, x)
9524pub fn lc_allsetfn(x: Option<String>) {
9525    // c:4873
9526    match x {
9527        None => setlang(getsparam_u("LANG").as_deref()), // c:4882 getsparam_u
9528        Some(s) if s.is_empty() => {
9529            // c:4881
9530            // c:4881-4884 — empty x falls back to setlang(getsparam_u("LANG")).
9531            setlang(getsparam_u("LANG").as_deref()); // c:4882
9532        }
9533        Some(s) => {
9534            // c:4889 — `setlocale(LC_ALL, unmeta(x));`
9535            let unmeta = unmeta(&s); // c:4889 unmeta(x)
9536            let cstr = std::ffi::CString::new(unmeta.as_bytes()).unwrap_or_default();
9537            unsafe {
9538                libc::setlocale(libc::LC_ALL, cstr.as_ptr()); // c:4890
9539            }
9540            // c:Src/params.c:5354 — setenv via the C-named zputenv
9541            let _ = zputenv(&format!("{}={}", "LC_ALL", &s));
9542            clear_mbstate(); // c:4891
9543                             // c:4892 — `inittyptab();` rebuild typtab for new LC_CTYPE.
9544            inittyptab(); // c:4892
9545        }
9546    }
9547}
9548
9549/// Port of `langsetfn(Param pm, char *x)` from `Src/params.c:4898`. C body:
9550/// `strsetfn(pm, x); setlang(unmeta(x));`
9551///
9552/// `unmeta(x)` strips Meta-encoding before passing to libc
9553/// `setlocale` — locale names are normally ASCII but Meta bytes
9554/// in the assigned value (from a `LANG="$value"` round-trip
9555/// through metafied param storage) would otherwise reach
9556/// setlocale literally. The previous Rust port passed raw `x`
9557/// without unmeta'ing — divergent.
9558///
9559/// `strsetfn(pm, x)` stores the value in the param slot. The Rust
9560/// adaptation doesn't have a `pm` in scope; the assign path that
9561/// reaches langsetfn already stored the value in the paramtab,
9562/// so this body only runs the post-store side effect (locale).
9563///
9564/// WARNING: param names don't match C — Rust=(x) vs C=(pm, x).
9565pub fn langsetfn(x: String) {
9566    // c:4898
9567    // c:4901 — `setlang(unmeta(x));`. Strip Meta bytes before
9568    // passing to libc setlocale.
9569    let unmeta_x = unmeta(&x); // c:4901 unmeta(x)
9570    setlang(Some(&unmeta_x));
9571}
9572
9573/// Port of `lcsetfn(Param pm, char *x)` from `Src/params.c:4906`. C body
9574/// (c:4912-4931):
9575/// ```c
9576/// strsetfn(pm, x);
9577/// if ((x2 = getsparam("LC_ALL")) && *x2) return;
9578/// queue_signals();
9579/// if (!x || !*x) x = getsparam("LANG");
9580/// if (x && *x) {
9581///     for (ln = lc_names; ln->name; ln++)
9582///         if (!strcmp(ln->name, pm->node.nam))
9583///             setlocale(ln->category, unmeta(x));
9584/// }
9585/// unqueue_signals();
9586/// clear_mbstate();
9587/// inittyptab();
9588/// ```
9589///
9590/// Two divergences in the previous Rust port:
9591///   1. Missed `inittyptab()` call at c:4932 — LC_CTYPE changes
9592///      shift which bytes are isalpha/iblank/isep, but the
9593///      typtab stayed pinned to the prior locale's classes.
9594///      `setopt POSIX_BUILTINS; LC_NUMERIC=tr_TR.UTF-8; ...`
9595///      would still classify with the old C locale's tables.
9596///   2. The Meta-unmeta'ing on the value passed to setlocale
9597///      wasn't applied. C uses `setlocale(cat, unmeta(x))`.
9598pub fn lcsetfn(pm: &str, x: Option<String>) {
9599    // c:4906
9600    // c:4912-4913 — `if ((x2 = getsparam("LC_ALL")) && *x2) return;`.
9601    if let Some(lc_all) = getsparam("LC_ALL") {
9602        // c:4912
9603        if !lc_all.is_empty() {
9604            return;
9605        }
9606    }
9607    // c:4916-4917 — `if (!x || !*x) x = getsparam("LANG");`.
9608    let val = x
9609        .filter(|s| !s.is_empty())
9610        .or_else(|| getsparam("LANG").filter(|s| !s.is_empty())); // c:4917
9611                                                                  // c:4924-4928 — apply `setlocale(category, unmeta(x))` for the
9612                                                                  // matching LC_* category. The previous Rust port skipped the
9613                                                                  // actual libc setlocale call and only wrote the env var, so
9614                                                                  // assigning `LC_NUMERIC=tr_TR.UTF-8` never flipped libc's
9615                                                                  // numeric-formatting category.
9616    if let Some(v) = val {
9617        let unmeta = unmeta(&v); // c:4928 unmeta(x)
9618                                 // c:Src/params.c:5354 — setenv via the C-named zputenv
9619        let _ = zputenv(&format!("{}={}", pm, &unmeta));
9620        for (name, category) in LC_NAMES {
9621            // c:4925
9622            if *name == pm {
9623                // c:4926 strcmp
9624                let cstr = std::ffi::CString::new(unmeta.as_bytes()).unwrap_or_default();
9625                unsafe {
9626                    libc::setlocale(*category, cstr.as_ptr()); // c:4927
9627                }
9628                break;
9629            }
9630        }
9631    }
9632    // c:4930 — `clear_mbstate();` — LC_CTYPE may have changed.
9633    clear_mbstate();
9634    // c:4931 — `inittyptab();` — rebuild typtab classifications.
9635    // The previous Rust port skipped this; char-classification
9636    // predicates would stay pinned to the prior locale's class
9637    // set even after `LC_CTYPE=` was assigned.
9638    inittyptab(); // c:4931
9639}
9640
9641/// Direct port of `static void argzerosetfn(UNUSED(Param pm),
9642/// char *x)` from `Src/params.c:4937-4946`. Setter for `$0` —
9643/// POSIX mode rejects assignment (read-only), zsh mode replaces
9644/// `argzero`.
9645///
9646/// C body:
9647///   if (x) {
9648///     if (isset(POSIXARGZERO))
9649///       zerr("read-only variable: 0");
9650///     else {
9651///       zsfree(argzero);
9652///       argzero = ztrdup(x);
9653///     }
9654///     zsfree(x);
9655///   }
9656/// Port of `argzerosetfn(UNUSED(Param pm), char *x)` from `Src/params.c:4937`.
9657/// `pm` is UNUSED in C (the `argzero` global is updated regardless of
9658/// the Param the assignment hit), but the parameter is preserved here
9659/// to match the C signature so this fn can wire into the GsuScalar.setfn
9660/// slot directly (see ARGZERO_GSU at line ~8307).
9661pub fn argzerosetfn(_pm: &mut param, x: String) {
9662    // c:4937
9663    // c:4937 — if (x).
9664    if !x.is_empty() {
9665        // c:4940 — isset(POSIXARGZERO) reject.
9666        if isset(POSIXARGZERO) {
9667            zerr("read-only variable: 0"); // c:4941
9668        } else {
9669            // c:4943-4944 — zsfree(argzero); argzero = ztrdup(x).
9670            set_argzero(Some(ztrdup(&x)));
9671        }
9672        // c:4946 — `zsfree(x)`. Rust drop handles via move.
9673    }
9674}
9675
9676// -----------------------------------------------------------
9677// $0 / $#
9678// -----------------------------------------------------------
9679
9680/// Port of `argzerogetfn(UNUSED(Param pm))` from `Src/params.c:4954`. C body:
9681///     `return isset(POSIXARGZERO) ? posixzero : argzero;`
9682///
9683/// Both `argzero` and `posixzero` live in `utils.rs` (OnceLock storage).
9684/// After `exec -a foo` or function-call argv-rewrite, `$0` under
9685/// POSIXARGZERO reports the ORIGINAL startup `argv[0]`, not the
9686/// rewritten name. `pm` is UNUSED in C; signature preserved for the
9687/// GsuScalar.getfn slot at ARGZERO_GSU (line ~8307).
9688pub fn argzerogetfn(_pm: &param) -> String {
9689    if isset(POSIXARGZERO) {
9690        // c:4958
9691        posixzero().unwrap_or_default() // c:4959
9692    } else {
9693        argzero().unwrap_or_default() // c:4960
9694    }
9695}
9696
9697// -----------------------------------------------------------
9698// $HISTSIZE / $SAVEHIST
9699// -----------------------------------------------------------
9700
9701/// Port of `histsizegetfn(UNUSED(Param pm))` from `Src/params.c:4965`. C body: `return histsiz;`
9702/// WARNING: param names don't match C — Rust=() vs C=(pm)
9703pub fn histsizegetfn() -> i64 {
9704    *histsiz_lock().lock().expect("histsiz poisoned")
9705}
9706
9707/// Port of `histsizesetfn(UNUSED(Param pm), zlong v)` from `Src/params.c:4974`. C body:
9708/// `if ((histsiz = v) < 1) histsiz = 1; resizehistents();`
9709///
9710/// The previous Rust port noted `resizehistents()` as "pending the
9711/// history-table port", but `resizehistents`
9712/// IS available — was a stale comment. Without the resize call,
9713/// setting HISTSIZE to a smaller value left the in-memory ring
9714/// over-sized until the next implicit prune (next entry added).
9715/// Wired the call now per c:4977.
9716/// WARNING: param names don't match C — Rust=(v) vs C=(pm, v)
9717pub fn histsizesetfn(v: i64) {
9718    *histsiz_lock().lock().expect("histsiz poisoned") = v.max(1);
9719    // c:4977 — mirror into the hist.rs atomic so resizehistents()
9720    // sees the new size, then trigger the prune.
9721    histsiz.store(v.max(1), Ordering::SeqCst);
9722    resizehistents(); // c:4977
9723}
9724
9725/// Port of `savehistsizegetfn(UNUSED(Param pm))` from `Src/params.c:4985`. C body:
9726/// `return savehistsiz;`
9727/// WARNING: param names don't match C — Rust=() vs C=(pm)
9728pub fn savehistsizegetfn() -> i64 {
9729    *savehistsiz_lock().lock().expect("savehistsiz poisoned")
9730}
9731
9732/// Port of `savehistsizesetfn(UNUSED(Param pm), zlong v)` from `Src/params.c:4994`. C body:
9733/// `if ((savehistsiz = v) < 0) savehistsiz = 0;`
9734///
9735/// The Rust port has TWO mirrors of `savehistsiz`: a `Mutex<i64>`
9736/// in params.rs (read by `savehistsizegetfn`) AND an AtomicI64
9737/// in hist.rs (read by the history-file writer at
9738/// `Src/hist.c:savehistfile` per c:3878). The previous Rust port
9739/// only wrote to the params.rs lock; `hist.rs::savehistsiz`
9740/// stayed pinned to its initial 0 value, so `SAVEHIST=10000`
9741/// would store the limit in `savehistsiz_lock` (visible to
9742/// `$SAVEHIST` reads) but the history-file writer would still
9743/// cap at the original AtomicI64 value (effectively saving zero
9744/// lines). Sync both storages so reads + writes agree.
9745///
9746/// WARNING: param names don't match C — Rust=(v) vs C=(pm, v)
9747pub fn savehistsizesetfn(v: i64) {
9748    // c:4994
9749    let clamped = v.max(0); // c:4998
9750    *savehistsiz_lock().lock().expect("savehistsiz poisoned") = clamped;
9751    // Mirror to hist.rs::savehistsiz so the writer-side cap
9752    // matches the just-assigned value. C uses a single global;
9753    // the Rust port's twin-storage requires sync writes.
9754    savehistsiz.store(clamped, Ordering::SeqCst);
9755    // c:4994
9756}
9757
9758/// Port of `errnosetfn(UNUSED(Param pm), zlong x)` from `Src/params.c:5004`. C body:
9759/// `errno = (int)x; if ((zlong)errno != x) zwarn("errno truncated on assignment");`
9760///
9761/// Rust note: `errno` is a libc thread-local; Rust uses `std::io::Error`
9762/// which captures the *last* call. To set errno for subsequent
9763/// `last_os_error()` reads on macOS / Linux, write through the libc
9764/// `__error()`/`__errno_location()` accessor.
9765/// C body (Src/params.c:5004):
9766///     `errno = (int)x;
9767///      if ((zlong)errno != x) zwarn("errno truncated on assignment");`
9768/// WARNING: param names don't match C — Rust=(x) vs C=(pm, x)
9769pub fn errnosetfn(x: i64) {
9770    // c:5004
9771    let truncated = x as i32;
9772    unsafe {
9773        *errno_ptr() = truncated;
9774    } // c:5006 errno = (int)x
9775      // c:5009-5010 — C uses `zwarn` (informational), NOT `zerr`. The
9776      // store happens unconditionally; the warning fires only on
9777      // truncation. Previously used `zerr` — divergent.
9778    if truncated as i64 != x {
9779        // c:5008
9780        zwarn("errno truncated on assignment"); // c:5009
9781    }
9782}
9783
9784/// !!! RUST-ONLY HELPER — no direct C counterpart. C accesses
9785/// `errno` through the standard macro which the compiler resolves
9786/// to the per-platform getter (`__error()` on macOS, `__errno_location()`
9787/// on Linux). Rust libc exposes both as raw FFI; this helper picks
9788/// the right one per target so errnosetfn/errnogetfn stay one-liners.
9789#[inline]
9790unsafe fn errno_ptr() -> *mut libc::c_int {
9791    #[cfg(target_os = "macos")]
9792    {
9793        libc::__error()
9794    }
9795    #[cfg(target_os = "linux")]
9796    {
9797        libc::__errno_location()
9798    }
9799    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
9800    {
9801        std::ptr::null_mut()
9802    }
9803}
9804
9805/// Port of `errnogetfn(UNUSED(Param pm))` from `Src/params.c:5015`. C body: `return errno;`
9806///
9807/// Reads the libc errno directly through the per-platform accessor
9808/// (matching C's `return errno;` semantics).
9809/// WARNING: param names don't match C — Rust=() vs C=(pm)
9810pub fn errnogetfn() -> i64 {
9811    let p = unsafe { errno_ptr() }; // c:5017 return errno
9812    if p.is_null() {
9813        // Non-Linux/macOS fallback: best-effort via std API.
9814        std::io::Error::last_os_error().raw_os_error().unwrap_or(0) as i64
9815    } else {
9816        unsafe { *p as i64 }
9817    }
9818}
9819
9820/// Port of `keyboardhackgetfn(UNUSED(Param pm))` from `Src/params.c:5024`. C body:
9821/// `static char buf[2]; buf[0] = keyboardhackchar; return buf;`
9822pub fn keyboardhackgetfn(_pm: &param) -> String {
9823    let c = *keyboardhack_lock().lock().expect("keyboardhack poisoned");
9824    if c == 0 {
9825        String::new()
9826    } else {
9827        (c as char).to_string()
9828    }
9829}
9830
9831/// Port of `keyboardhacksetfn(UNUSED(Param pm), char *x)` from `Src/params.c:5040-5060`. C body:
9832/// ```c
9833/// if (x) {
9834///     unmetafy(x, &len);
9835///     if (len > 1) { len = 1; zwarn("Only one KEYBOARD_HACK character can be defined"); }
9836///     for (i = 0; i < len; i++)
9837///         if (!isascii((unsigned char) x[i])) {
9838///             zwarn("KEYBOARD_HACK can only contain ASCII characters");
9839///             return;
9840///         }
9841///     keyboardhackchar = len ? (unsigned char) x[0] : '\0';
9842/// } else
9843///     keyboardhackchar = '\0';
9844/// ```
9845///
9846/// The C source `unmetafy(x, &len)` strips Meta-encoded prefix
9847/// bytes (collapsing every `Meta + (b^32)` pair to the original
9848/// byte) BEFORE the length and ASCII checks. The previous Rust
9849/// port skipped unmetafy, so:
9850///   - `len > 1` warning fired on every assignment of a Meta-
9851///     encoded single byte (the byte-length was 2 pre-unmetafy).
9852///   - ASCII check ran against the raw Meta byte (0x83) instead
9853///     of the demetafied result, falsely rejecting valid ASCII
9854///     characters that happened to round-trip through Meta
9855///     encoding in the assignment pipeline.
9856///
9857pub fn keyboardhacksetfn(_pm: &mut param, x: String) {
9858    // c:5040
9859    // c:5044 — `unmetafy(x, &len)` — strip Meta-encoded pairs.
9860    // Run on the byte buffer so the protocol matches C's pointer
9861    // walk; the Rust `unmeta()` helper does the same fold.
9862    let unmeta = unmeta(&x); // c:5044 unmetafy(x)
9863    let bytes = unmeta.as_bytes();
9864    // c:5046-5049 — `if (len > 1) { len = 1; zwarn(...); }`. The
9865    // length check happens AFTER unmetafy so a 2-byte Meta pair
9866    // representing a single byte doesn't trigger the warning.
9867    if bytes.len() > 1 {
9868        zwarn("Only one KEYBOARD_HACK character can be defined");
9869    }
9870    let c = bytes.first().copied().unwrap_or(0);
9871    // c:5050-5054 — ASCII check runs on the unmetafied byte, NOT
9872    // the raw Meta byte. With unmetafy now in place this works as
9873    // C intended.
9874    if c >= 0x80 {
9875        // c:5051 !isascii(...)
9876        zwarn("KEYBOARD_HACK can only contain ASCII characters");
9877        return;
9878    }
9879    // c:5056 — `keyboardhackchar = len ? (unsigned char) x[0] : '\0';`
9880    *keyboardhack_lock().lock().expect("keyboardhack poisoned") = c;
9881}
9882
9883/// Port of `histcharsgetfn(UNUSED(Param pm))` from `Src/params.c:5064`. C body:
9884/// ```c
9885/// static char buf[4];
9886/// buf[0] = bangchar; buf[1] = hatchar; buf[2] = hashchar; buf[3] = '\0';
9887/// return buf;
9888/// ```
9889/// Reads from the three canonical atomic globals
9890/// (`crate::ported::hist::{bangchar, hatchar, hashchar}`) to mirror C
9891/// which reads from three separate `unsigned char` globals.
9892pub fn histcharsgetfn(_pm: &param) -> String {
9893    let b = bangchar.load(Ordering::SeqCst) as u8;
9894    let h = hatchar.load(Ordering::SeqCst) as u8;
9895    let p = hashchar.load(Ordering::SeqCst) as u8;
9896    // c:5068-5073 — terminal NUL trims unset chars (default-`!^#` is
9897    // 3 non-NUL bytes); explicit NULs are skipped to match C `buf[3]
9898    // = '\0'` C-string truncation semantics.
9899    let mut s = String::new();
9900    for &byte in &[b, h, p] {
9901        if byte != 0 {
9902            s.push(byte as char);
9903        }
9904    }
9905    s
9906}
9907
9908/// Port of `histcharssetfn(UNUSED(Param pm), char *x)` from `Src/params.c:5081`. C body
9909/// validates ASCII, takes up to 3 chars; defaults `!^#` if NULL.
9910///
9911/// C `unmetafy(x, &len)` (c:5086) strips Meta-encoded pairs BEFORE
9912/// the length truncation and ASCII guard. The previous Rust port
9913/// skipped unmetafy entirely:
9914///   - `len > 3` truncation ran on raw byte length, so a Meta-pair
9915///     would inflate the byte count and skip valid chars.
9916///   - ASCII check ran against raw Meta bytes (0x83), falsely
9917///     rejecting valid round-tripped values.
9918///
9919pub fn histcharssetfn(_pm: &mut param, x: String) {
9920    // c:5081
9921    // C signature is `histcharssetfn(Param pm, char *x)`. C uses NULL
9922    // for the "reset to defaults" path; in Rust the canonical fn-ptr
9923    // type is `fn(&mut param, String)` so the empty-string sentinel
9924    // takes that role (`x.is_empty()` ≡ C `x == NULL`).
9925    let new_chars: [u8; 3] = if x.is_empty() {
9926        // c:5100-5103 — defaults `!^#` when x is NULL.
9927        [b'!', b'^', b'#']
9928    } else {
9929        let s = x;
9930        {
9931            // c:5086 — `unmetafy(x, &len)`. Strip Meta pairs first.
9932            let unmeta = unmeta(&s); // c:5086 unmetafy(x)
9933            let bytes = unmeta.as_bytes();
9934            // c:5087-5088 — `if (len > 3) len = 3;`. Truncation
9935            // applies AFTER unmetafy.
9936            let bytes = if bytes.len() > 3 { &bytes[..3] } else { bytes };
9937            for &b in bytes.iter() {
9938                if b >= 0x80 {
9939                    // c:5090-5093
9940                    // c:5091 — C uses `zwarn` (informational), NOT
9941                    // `zerr` (fatal). Function returns early without
9942                    // updating any globals.
9943                    zwarn("HISTCHARS can only contain ASCII characters");
9944                    return;
9945                }
9946            }
9947            // c:5095-5097 — `bangchar = x[0]; hatchar = x[1]; hashchar = x[2]`.
9948            // C uses `len ? x[0] : '\0'` etc — for short strings the
9949            // unset bytes are NUL.
9950            let mut chars = [0u8; 3];
9951            for (i, &b) in bytes.iter().enumerate() {
9952                chars[i] = b;
9953            }
9954            chars
9955        }
9956    };
9957    // c:5079 — set histchars table.
9958    *histchars_lock().lock().expect("histchars poisoned") = new_chars;
9959    // c:5095-5097 — `bangchar = x[0]; hatchar = x[1]; hashchar = x[2]`.
9960    // Sync all three per-char atomic globals so lex/hist callers
9961    // see the new HISTCHARS. (Previously hashchar was a `const char`
9962    // in lex.rs — promoted to atomic this iteration.)
9963    bangchar.store(new_chars[0] as i32, Ordering::SeqCst);
9964    hatchar.store(new_chars[1] as i32, Ordering::SeqCst);
9965    hashchar.store(new_chars[2] as i32, Ordering::SeqCst);
9966    // c:5104 — `inittyptab();`. The bangchar special bit in typtab
9967    // depends on the current `bangchar` global; reseed.
9968    inittyptab();
9969}
9970
9971// ---------------------------------------------------------------------
9972// Special-scalar GSU vtables — port of `Src/params.c:217-256` `stdscalar_gsu`,
9973// `home_gsu`, `ifs_gsu`, etc. Each entry pairs the canonical
9974// getfn/setfn/unsetfn for a PM_SPECIAL scalar. `createparamtable`
9975// copies the matching gsu into each special param's `gsu_s` slot so
9976// `assignstrvalue` dispatches via `pm->gsu.s->setfn(pm, val)`
9977// (params.c:2748) exactly like C.
9978// ---------------------------------------------------------------------
9979
9980/// Port of `static const struct gsu_scalar argzero_gsu` from `Src/params.c:225-226`.
9981/// `{ argzerogetfn, argzerosetfn, nullunsetfn }`. Both functions' Rust
9982/// signatures match C exactly (with `pm` UNUSED), so they wire into
9983/// the GSU vtable directly — no Rust-only wrappers. The $0 Param picks
9984/// this up at `add_special` (line ~1555) and `assignsparam` routes
9985/// `0=value` through the canonical setter via the gsu_s.setfn dispatch
9986/// at params.rs:3767.
9987pub const ARGZERO_GSU: gsu_scalar = gsu_scalar {
9988    // c:225-226
9989    getfn: argzerogetfn,
9990    setfn: argzerosetfn,
9991    unsetfn: nullunsetfn,
9992};
9993
9994/// Port of `static const struct gsu_scalar home_gsu` from `Src/params.c:248`.
9995pub const HOME_GSU: gsu_scalar = gsu_scalar {
9996    // c:248
9997    getfn: homegetfn,
9998    setfn: homesetfn,
9999    unsetfn: stdunsetfn,
10000};
10001
10002/// Port of `static const struct gsu_scalar ifs_gsu` from `Src/params.c:245`.
10003pub const IFS_GSU: gsu_scalar = gsu_scalar {
10004    // c:245
10005    getfn: ifsgetfn,
10006    setfn: ifssetfn,
10007    unsetfn: stdunsetfn,
10008};
10009
10010/// Port of `static const struct gsu_scalar term_gsu` from `Src/params.c:250`.
10011pub const TERM_GSU: gsu_scalar = gsu_scalar {
10012    // c:250
10013    getfn: termgetfn,
10014    setfn: termsetfn,
10015    unsetfn: stdunsetfn,
10016};
10017
10018/// Port of `static const struct gsu_scalar terminfo_gsu` from `Src/params.c:251`.
10019pub const TERMINFO_GSU: gsu_scalar = gsu_scalar {
10020    // c:251
10021    getfn: terminfogetfn,
10022    setfn: terminfosetfn,
10023    unsetfn: stdunsetfn,
10024};
10025
10026/// Port of `static const struct gsu_scalar terminfodirs_gsu`
10027/// from `Src/params.c:252`.
10028pub const TERMINFODIRS_GSU: gsu_scalar = gsu_scalar {
10029    // c:252
10030    getfn: terminfodirsgetfn,
10031    setfn: terminfodirssetfn,
10032    unsetfn: stdunsetfn,
10033};
10034
10035/// Port of `static const struct gsu_scalar wordchars_gsu`
10036/// from `Src/params.c:249`.
10037pub const WORDCHARS_GSU: gsu_scalar = gsu_scalar {
10038    // c:249
10039    getfn: wordcharsgetfn,
10040    setfn: wordcharssetfn,
10041    unsetfn: stdunsetfn,
10042};
10043
10044/// Port of `static const struct gsu_scalar username_gsu`
10045/// from `Src/params.c:247`.
10046pub const USERNAME_GSU: gsu_scalar = gsu_scalar {
10047    // c:247
10048    getfn: usernamegetfn,
10049    setfn: usernamesetfn,
10050    unsetfn: stdunsetfn,
10051};
10052
10053/// Port of `static const struct gsu_scalar keyboardhack_gsu`
10054/// from `Src/params.c:253`.
10055pub const KEYBOARDHACK_GSU: gsu_scalar = gsu_scalar {
10056    // c:253
10057    getfn: keyboardhackgetfn,
10058    setfn: keyboardhacksetfn,
10059    unsetfn: stdunsetfn,
10060};
10061
10062/// Port of `static const struct gsu_scalar histchars_gsu`
10063/// from `Src/params.c:246`.
10064pub const HISTCHARS_GSU: gsu_scalar = gsu_scalar {
10065    // c:246
10066    getfn: histcharsgetfn,
10067    setfn: histcharssetfn,
10068    unsetfn: stdunsetfn,
10069};
10070
10071/// Port of `homegetfn(UNUSED(Param pm))` from `Src/params.c:5109`. C body: `return home;`
10072pub fn homegetfn(_pm: &param) -> String {
10073    home_lock().lock().expect("home poisoned").clone()
10074}
10075
10076/// Port of `homesetfn(UNUSED(Param pm), char *x)` from `Src/params.c:5118`. C body:
10077/// ```c
10078/// zsfree(home);
10079/// if (x && isset(CHASELINKS) && (home = xsymlink(x, 0)))
10080///     zsfree(x);
10081/// else
10082///     home = x ? x : ztrdup("");
10083/// finddir(NULL);
10084/// ```
10085pub fn homesetfn(_pm: &mut param, x: String) {
10086    // c:5121-5126 — CHASELINKS path resolves symlinks before storing.
10087    // Falls through to the plain `x` store when CHASELINKS is off or
10088    // xsymlink fails.
10089    let resolved = if !x.is_empty() && isset(CHASELINKS) {
10090        xsymlink(&x).unwrap_or(x)
10091    } else {
10092        x
10093    };
10094    *home_lock().lock().expect("home poisoned") = resolved;
10095    // c:5127 — `finddir(NULL)` invalidates zsh's cached named-directory
10096    // lookups. zshrs's finddir port has no cache (per hashnameddir.rs
10097    // createnameddirtable note); the call is a no-op here.
10098}
10099
10100/// Port of `wordcharsgetfn(UNUSED(Param pm))` from `Src/params.c:5132`. C body:
10101/// `return wordchars;`
10102pub fn wordcharsgetfn(_pm: &param) -> String {
10103    wordchars_lock().lock().expect("wordchars poisoned").clone()
10104}
10105
10106/// Port of `wordcharssetfn(UNUSED(Param pm), char *x)` from `Src/params.c:5141`. C body:
10107/// `zsfree(wordchars); wordchars = x; inittyptab();`
10108pub fn wordcharssetfn(_pm: &mut param, x: String) {
10109    *wordchars_lock().lock().expect("wordchars poisoned") = x;
10110    // c:5143 — `inittyptab()` rebuilds typtab IWORD bits from the
10111    // new WORDCHARS. Without this, every IWORD lookup stays pinned
10112    // to the old set and silently mis-classifies word boundaries.
10113    inittyptab();
10114}
10115
10116/// Port of `underscoregetfn(UNUSED(Param pm))` from `Src/params.c:5152`. C body:
10117/// `char *u = dupstring(zunderscore); untokenize(u); return u;`
10118///
10119/// C runs `untokenize(u)` on the cloned string before returning, so
10120/// ITOK bytes (Pound..Nularg per `Src/zsh.h:159-194`) in `$_` get
10121/// replaced/dropped via the canonical `ztokens[]` table. The previous
10122/// Rust port skipped untokenize entirely — every `$_` read that
10123/// included a lexer-injected token byte exposed the raw token in user
10124/// output (e.g. `$_` containing `$cmd` would surface as raw Stringg
10125/// instead of the literal `$`).
10126/// WARNING: param names don't match C — Rust=() vs C=(pm)
10127pub fn underscoregetfn() -> String {
10128    let u = zunderscore_lock()
10129        .lock()
10130        .expect("zunderscore poisoned")
10131        .clone();
10132    untokenize(&u) // c:5156 untokenize(u)
10133}
10134
10135/// Port of `term_reinit_from_pm()` from `Src/params.c:5163`.
10136/// C: `static void term_reinit_from_pm(void)` →
10137///   `if (unset(INTERACTIVE) || !*term) termflags |= TERM_UNKNOWN;
10138///    else init_term();`
10139pub fn term_reinit_from_pm() {
10140    // c:5163
10141    // c:5167 — `if (unset(INTERACTIVE) || !*term) termflags |= TERM_UNKNOWN;`
10142    let interactive = isset(INTERACTIVE);
10143    let term = term_lock().lock().map(|s| s.clone()).unwrap_or_default();
10144    if !interactive || term.is_empty() {
10145        // c:5167
10146        TERMFLAGS.fetch_or(TERM_UNKNOWN, Ordering::Relaxed); // c:5168
10147    } else {
10148        crate::ported::init::init_term(); // c:5170
10149    }
10150}
10151
10152/// Port of `termgetfn(UNUSED(Param pm))` from `Src/params.c:5176`. C body: `return term;`
10153pub fn termgetfn(_pm: &param) -> String {
10154    term_lock().lock().expect("term poisoned").clone()
10155}
10156
10157/// Port of `termsetfn(UNUSED(Param pm), char *x)` from `Src/params.c:5185`. C body:
10158/// `zsfree(term); term = x ? x : ""; term_reinit_from_pm();`
10159pub fn termsetfn(_pm: &mut param, x: String) {
10160    *term_lock().lock().expect("term poisoned") = x;
10161    term_reinit_from_pm();
10162}
10163
10164/// Port of `terminfogetfn(UNUSED(Param pm))` from `Src/params.c:5196`. C body:
10165/// `return zsh_terminfo ? zsh_terminfo : "";`
10166pub fn terminfogetfn(_pm: &param) -> String {
10167    zsh_terminfo_lock()
10168        .lock()
10169        .expect("zsh_terminfo poisoned")
10170        .clone()
10171}
10172
10173/// Port of `int rprompt_indent` from `Src/init.c`. Set to 1 by
10174/// `init_term()` and reset by `rprompt_indent_unsetfn` when the
10175/// `RPROMPT_INDENT` parameter is unset.
10176pub static RPROMPT_INDENT: Mutex<i32> = Mutex::new(1);
10177
10178/// Port of `void terminfosetfn(Param pm, char *x)` from `Src/params.c:5205`.
10179///
10180/// Frees the old `zsh_terminfo`, stores `x`, and — only when the
10181/// parameter is exported — pushes `TERMINFO=x` into the environment
10182/// (terminfo reads the value from the env before the terminal is
10183/// reinitialised), then reinitialises the terminal.
10184pub fn terminfosetfn(pm: &mut param, x: String) {
10185    // c:5205
10186    // c:5207-5208 — `zsfree(zsh_terminfo); zsh_terminfo = x;`
10187    *zsh_terminfo_lock().lock().expect("zsh_terminfo poisoned") = x.clone();
10188    // c:5214-5215 — `if ((pm->node.flags & PM_EXPORTED) && x) addenv(pm, x);`
10189    // Only leak into the environment when $TERMINFO is actually
10190    // exported; a bare `TERMINFO=x` (no export) must not reach children.
10191    if pm.node.flags & PM_EXPORTED as i32 != 0 {
10192        let _ = zputenv(&format!("TERMINFO={}", &x)); // c:5215 addenv(pm, x)
10193    }
10194    term_reinit_from_pm(); // c:5217
10195}
10196
10197/// Port of `terminfodirsgetfn(UNUSED(Param pm))` from `Src/params.c:5224`. C body:
10198/// `return zsh_terminfodirs ? zsh_terminfodirs : "";`
10199pub fn terminfodirsgetfn(_pm: &param) -> String {
10200    zsh_terminfodirs_lock()
10201        .lock()
10202        .expect("zsh_terminfodirs poisoned")
10203        .clone()
10204}
10205
10206/// Port of `terminfodirssetfn(Param pm, char *x)` from `Src/params.c:5233`. C body
10207/// mirrors `terminfosetfn` for the TERMINFO_DIRS env var.
10208pub fn terminfodirssetfn(pm: &mut param, x: String) {
10209    // c:5233
10210    // c:5235-5236 — `zsfree(zsh_terminfodirs); zsh_terminfodirs = x;`
10211    *zsh_terminfodirs_lock()
10212        .lock()
10213        .expect("zsh_terminfodirs poisoned") = x.clone();
10214    // c:5242-5243 — `if ((pm->node.flags & PM_EXPORTED) && x) addenv(pm, x);`
10215    // Only export when $TERMINFO_DIRS is exported; a bare assignment
10216    // must not reach child processes.
10217    if pm.node.flags & PM_EXPORTED as i32 != 0 {
10218        let _ = zputenv(&format!("TERMINFO_DIRS={}", &x)); // c:5243 addenv(pm, x)
10219    }
10220    term_reinit_from_pm(); // c:5245
10221}
10222
10223// -----------------------------------------------------------
10224// $pipestatus
10225// -----------------------------------------------------------
10226
10227/// Port of `pipestatgetfn(UNUSED(Param pm))` from `Src/params.c:5251`. C body
10228/// snapshots the `pipestats[]` C array as a heap-allocated
10229/// `char **`. Rust port returns the cloned snapshot.
10230/// WARNING: param names don't match C — Rust=() vs C=(pm)
10231pub fn pipestatgetfn() -> Vec<String> {
10232    pipestats_lock()
10233        .lock()
10234        .expect("pipestats poisoned")
10235        .iter()
10236        .map(|n| n.to_string())
10237        .collect()
10238}
10239
10240/// Port of `pipestatsetfn(UNUSED(Param pm), char **x)` from `Src/params.c:5270`. C body:
10241/// `for (i=0; *x && i<MAX_PIPESTATS; i++) pipestats[i] = atoi(*x++); numpipestats = i;`
10242/// WARNING: param names don't match C — Rust=(x) vs C=(pm, x)
10243pub fn pipestatsetfn(x: Option<Vec<String>>) {
10244    const MAX_PIPESTATS: usize = 256;
10245    let mut guard = pipestats_lock().lock().expect("pipestats poisoned");
10246    guard.clear();
10247    if let Some(v) = x {
10248        for s in v.iter().take(MAX_PIPESTATS) {
10249            guard.push(s.parse::<i32>().unwrap_or(0));
10250        }
10251    }
10252}
10253
10254/// Port of `arrfixenv(char *s, char **t)` from `Src/params.c:5285`. C body re-syncs
10255/// the env entry for an array param after mutation, joining with
10256/// the param's `joinchar`. Rust port joins with ':' (the default
10257/// for PATH-style arrays) and updates the env var.
10258/// Direct port of `void arrfixenv(char *s, char **t)` from
10259/// `Src/params.c:5285`. Re-syncs the env-side entry for an
10260/// array parameter after mutation. Order of operations (C body):
10261///   1. If `t == path`, flush the command-name cache (c:5291).
10262///   2. Look up the param node by name (c:5294); skip if
10263///      PM_HASHELEM is set (c:5300-5301).
10264///   3. Under ALLEXPORT, mark PM_EXPORTED (c:5304); always clear
10265///      PM_DEFAULTED (c:5305).
10266///   4. Skip if not PM_EXPORTED (c:5311-5312).
10267///   5. joinchar = ':' for PM_SPECIAL else
10268///      `((struct tieddata *)pm->u.data)->joinchar` (c:5314-5318).
10269///   6. `addenv(pm, t ? zjoin(t, joinchar, 1) : "")` (c:5319).
10270pub fn arrfixenv(s: &str, t: Option<&[String]>) {
10271    // c:5285
10272
10273    // c:5291 — `if (t == path) cmdnamtab->emptytable(cmdnamtab)`.
10274    // PATH change invalidates the command-name cache.
10275    if s == "PATH" || s == "path" {
10276        emptycmdnamtable();
10277    }
10278
10279    // c:5294 — `pm = paramtab->getnode(paramtab, s)`.
10280    let pm_arc_data = {
10281        let tab = paramtab().read().unwrap();
10282        tab.get(s).map(|pm| (pm.node.flags, pm.gsu_a.is_some()))
10283    };
10284    let (flags, _has_gsu_a) = match pm_arc_data {
10285        Some(x) => x,
10286        None => {
10287            // No param yet — just sync via env::set_var as fallback.
10288            let val = t.map(|v| v.join(":")).unwrap_or_default();
10289            // c:Src/params.c:5354 — setenv via the C-named zputenv
10290            let _ = zputenv(&format!("{}={}", s, &val));
10291            return;
10292        }
10293    };
10294
10295    // c:5300-5301 — `if (pm->flags & PM_HASHELEM) return`.
10296    if flags & PM_HASHELEM as i32 != 0 {
10297        return;
10298    }
10299
10300    // c:5304 — `if (isset(ALLEXPORT)) pm->flags |= PM_EXPORTED`.
10301    let allexport = isset(ALLEXPORT);
10302    // c:5305 — `pm->flags &= ~PM_DEFAULTED` always.
10303    {
10304        let mut tab = paramtab().write().unwrap();
10305        if let Some(pm) = tab.get_mut(s) {
10306            if allexport {
10307                pm.node.flags |= PM_EXPORTED as i32;
10308            }
10309            pm.node.flags &= !(PM_DEFAULTED as i32);
10310        }
10311    }
10312
10313    // c:5311-5312 — `if (!(pm->flags & PM_EXPORTED)) return`.
10314    let new_flags = {
10315        let tab = paramtab().read().unwrap();
10316        tab.get(s).map(|pm| pm.node.flags).unwrap_or(0)
10317    };
10318    if new_flags & PM_EXPORTED as i32 == 0 {
10319        return;
10320    }
10321
10322    // c:5314-5317 — joinchar selection.
10323    let joinchar = if new_flags & PM_SPECIAL as i32 != 0 {
10324        ':' // c:5315
10325    } else {
10326        // c:5317 — tieddata.joinchar; not modelled in current Param —
10327        // default to ':' which is correct for all currently-tied
10328        // array params (PATH/CDPATH/FPATH/etc.).
10329        ':'
10330    };
10331
10332    // c:5319 — `addenv(pm, t ? zjoin(t, joinchar, 1) : "")`.
10333    let joined = match t {
10334        Some(arr) => arr.join(&joinchar.to_string()),
10335        None => String::new(),
10336    };
10337    addenv(s, &joined);
10338}
10339
10340/// Direct port of `int zputenv(char *str)` from
10341/// `Src/params.c:5325-5382` (USE_SET_UNSET_ENV branch). Splits
10342/// `str` at the first `=`, validates the name is in the portable
10343/// character set (rejects any byte >= 128), and calls
10344/// `setenv(name, value, 1)`.
10345///
10346/// C body walks `str` byte-by-byte looking for either a high-byte
10347/// (reject) or `=` (split). On a clean ASCII `name=value`, it
10348/// temporarily writes `\0` at the `=` to splice off the name,
10349/// calls setenv, then restores the `=`. On `=`-less input, it
10350/// flags via DPUTS and still calls setenv with the whole string
10351/// as the name (with value pointing at the trailing `\0`). Rust
10352/// equivalent: split, set_var; the in-place mutation isn't
10353/// observable since we copy.
10354/// Port of `zputenv(char *str)` from `Src/params.c:5325`.
10355pub fn zputenv(str: &str) -> i32 {
10356    // c:5325
10357    // c:5327 — DPUTS(!str, "Attempt to put null string into environment.")
10358    DPUTS!(
10359        str.is_empty(),
10360        "Attempt to put null string into environment."
10361    ); // c:5327
10362    if str.is_empty() {
10363        // c:5328 (after DPUTS, defensive return)
10364        return 0;
10365    }
10366    let bytes = str.as_bytes();
10367    // c:5339-5341 — walk until `=` or high byte; reject high bytes.
10368    let mut ptr = 0;
10369    while ptr < bytes.len() && bytes[ptr] != b'=' && bytes[ptr] < 128 {
10370        // c:5339
10371        ptr += 1;
10372    }
10373    if ptr < bytes.len() && bytes[ptr] >= 128 {
10374        // c:5342
10375        // c:5351 — `return 1` to reject non-portable name.
10376        return 1;
10377    }
10378    if ptr < bytes.len() {
10379        // c:5352 `else if (*ptr)`
10380        // c:5353-5355 — write `\0` at `=`, setenv(name, value), restore.
10381        let name = &str[..ptr];
10382        let value = &str[ptr + 1..];
10383        // c:Src/params.c:5354 — `setenv(name, value, 1)` uses libc
10384        // setenv which treats the value as a NUL-terminated C string.
10385        // An embedded NUL byte ends the value there. Rust's
10386        // std::env::set_var PANICS on NUL in value; emulate C's
10387        // truncate-at-NUL semantics so shell params holding raw NUL
10388        // bytes (e.g. `X=$'a\0b\0c'` for `${(ps:\0:)X}` splits) can
10389        // still propagate the leading portion to env. The full
10390        // raw value lives in the canonical param table; this is
10391        // just the libc-env mirror.
10392        let safe_value: &str = match value.find('\0') {
10393            Some(n) => &value[..n],
10394            None => value,
10395        };
10396        if name.as_bytes().contains(&b'\0') {
10397            // c: setenv on a name with NUL is malformed — C's libc
10398            // rejects, we silently drop the env mirror.
10399            return 1;
10400        }
10401        env::set_var(name, safe_value);
10402        0
10403    } else {
10404        // c:5355 else
10405        // c:5356 — DPUTS(1, "bad environment string").
10406        // With no `=`, treat `str` as a bare name with empty value.
10407        DPUTS!(true, "bad environment string"); // c:5356
10408        if str.as_bytes().contains(&b'\0') {
10409            return 1;
10410        }
10411        env::set_var(str, ""); // c:5357
10412        0
10413    }
10414}
10415
10416// NUL-safe env-mirror helper lives in src/vm_helper.rs
10417// (zputenv, Src/params.c:5325) — the C-named env writer; the
10418// src/ported/ build gate forbids non-C-named fns here.
10419
10420/// Direct port of `int findenv(char *name, int *pos)` from
10421/// `Src/params.c:5391`. Walks `environ` looking for an
10422/// entry whose name component (bytes up to `=`) matches `name`.
10423/// Returns Some(index) on a match; the C source writes the
10424/// index into `*pos` and returns 1.
10425///
10426/// Rust signature differs (no out-param; returns `Option<usize>`)
10427/// — the C int-with-out-param idiom maps to `Option<index>` here.
10428/// Walks std::env::vars_os() which preserves the same ordering
10429/// as the underlying libc environ array.
10430pub fn findenv(name: &str) -> Option<usize> {
10431    // c:5391
10432    // c:5391 — `eq = strchr(name, '=')`. Strip any trailing `=value`.
10433    let nlen = name.find('=').unwrap_or(name.len()); // c:5397
10434    let bare = &name[..nlen];
10435
10436    // c:5398-5404 — walk environ until match. Use std::env::vars()
10437    // which preserves the same ordering as the underlying libc
10438    // environ.
10439    for (i, (k, _)) in env::vars_os().enumerate() {
10440        if let Some(s) = k.to_str() {
10441            if s == bare {
10442                return Some(i); // c:5401-5403
10443            }
10444        }
10445    }
10446    None // c:5406
10447}
10448
10449// -----------------------------------------------------------
10450// env management (zsh's wrapper around setenv/unsetenv).
10451// -----------------------------------------------------------
10452
10453/// Port of `zgetenv(char *name)` from `Src/params.c:5416`. C body walks
10454/// `environ` byte-by-byte. Rust port uses `std::env::var`.
10455pub fn zgetenv(name: &str) -> Option<String> {
10456    env::var(name).ok()
10457}
10458
10459/// Direct port of `static void copyenvstr(char *s, char *value,
10460/// int flags)` from `Src/params.c:5434`. Unmetafies `value`
10461/// into `s` (Meta NEXT pairs collapse to NEXT^32) and applies
10462/// PM_LOWER / PM_UPPER case folding per byte.
10463pub fn copyenvstr(buf: &mut String, value: &str, flags: i32) {
10464    // c:5434
10465    let flags_u = flags as u32;
10466    let mut it = value.bytes();
10467    while let Some(b) = it.next() {
10468        // c:5436
10469        let mut ch = b;
10470        if ch == Meta {
10471            // c:5437
10472            ch = match it.next() {
10473                Some(next) => next ^ 32, // c:5438
10474                None => break,
10475            };
10476        }
10477        if flags_u & PM_LOWER != 0 {
10478            // c:5439
10479            ch = ch.to_ascii_lowercase(); // c:5440
10480        } else if flags_u & PM_UPPER != 0 {
10481            // c:5441
10482            ch = ch.to_ascii_uppercase(); // c:5442
10483        }
10484        buf.push(ch as char);
10485    }
10486}
10487
10488/// Direct port of `void addenv(Param pm, char *value)` from
10489/// `Src/params.c:5448` (USE_SET_UNSET_ENV branch — the
10490/// portable one). C body:
10491///   1. `newenv = mkenvstr(pm->nam, value, pm->flags)` (c:5463)
10492///   2. `if (zputenv(newenv)) { free; pm->env=NULL; return }` (c:5464-5468)
10493///   3. Otherwise: `if (pm->env) free(pm->env); pm->env = newenv;
10494///      pm->flags |= PM_EXPORTED` (c:5482-5484)
10495///
10496/// Rust takes `name` instead of `Param pm` and looks up the
10497/// `pm` node internally — the C body's only reads of `pm` are
10498/// `pm->nam`, `pm->flags`, `pm->env`, all available from
10499/// paramtab. The return type changes from `void` to `i32` so
10500/// callers can chain it; 0 = success, 1 = zputenv failed.
10501pub fn addenv(name: &str, value: &str) -> i32 {
10502    // c:5448
10503
10504    // c:5463 — `newenv = mkenvstr(pm->nam, value, pm->flags)`.
10505    let flags = {
10506        let tab = paramtab().read().unwrap();
10507        tab.get(name).map(|pm| pm.node.flags).unwrap_or(0)
10508    };
10509    let newenv = mkenvstr(name, value, flags);
10510    // c:5464-5468 — `if (zputenv(newenv)) { free; pm->env=NULL; return }`.
10511    if zputenv(&newenv) != 0 {
10512        let mut tab = paramtab().write().unwrap();
10513        if let Some(pm) = tab.get_mut(name) {
10514            pm.env = None;
10515        }
10516        return 1;
10517    }
10518    // c:5482-5484 — `pm->env = newenv; pm->flags |= PM_EXPORTED`.
10519    let mut tab = paramtab().write().unwrap();
10520    if let Some(pm) = tab.get_mut(name) {
10521        pm.env = Some(newenv);
10522        pm.node.flags |= PM_EXPORTED as i32;
10523    }
10524    0
10525}
10526
10527/// Direct port of `static char *mkenvstr(char *name, char *value,
10528/// int flags)` from `Src/params.c:5513`. Builds `name=value`
10529/// in a fresh heap-string, where `value` is unmetafied and
10530/// case-folded according to `flags` (PM_LOWER → lower, PM_UPPER →
10531/// upper). The C source computes the unmetafied length first via
10532/// the `while (*s && (*s++ != Meta || *s++ != 32))` loop, then
10533/// allocates and writes via copyenvstr; the Rust port appends to
10534/// a `String` so the length pre-scan is implicit.
10535pub fn mkenvstr(name: &str, value: &str, flags: i32) -> String {
10536    // c:5513
10537    let mut buf = String::with_capacity(name.len() + value.len() + 2);
10538    buf.push_str(name); // c:5522 strcpy(s, name)
10539    buf.push('='); // c:5524 *s = '='
10540    if !value.is_empty() {
10541        // c:5525
10542        copyenvstr(&mut buf, value, flags); // c:5526
10543    }
10544    buf // c:5530
10545}
10546
10547/// Direct port of `void delenvvalue(char *x)` from
10548/// `Src/params.c:5542`. Removes `x` from environ by walking
10549/// to its pointer and shifting subsequent entries down one slot.
10550///
10551/// C body operates on the environ array directly. The Rust port
10552/// uses `env::remove_var(name)` since Rust's env is mediated by
10553/// libc::unsetenv internally — same shift semantics.
10554pub fn delenvvalue(name: &str) {
10555    // c:5542
10556    env::remove_var(name); // c:5542 equivalent
10557}
10558
10559/// Direct port of `void delenv(Param pm)` from
10560/// `Src/params.c:5563-5582`. Removes the param's env entry and
10561/// clears `pm->env`. Under USE_SET_UNSET_ENV (the portable
10562/// branch) the C body is:
10563///   unsetenv(pm->node.nam);
10564///   zsfree(pm->env);
10565///   pm->env = NULL;
10566///
10567/// "Note we don't remove PM_EXPORT from the flags. This may be
10568/// asking for trouble but we need to know later if we restore
10569/// this parameter to its old value." (c:5575-5577)
10570///
10571/// Rust signature drift: takes `&str` (the param name) instead
10572/// of `&mut Param`. The pm.env field is cleared via the paramtab
10573/// lookup; PM_EXPORTED is intentionally preserved per the C
10574/// comment.
10575pub fn delenv(name: &str) {
10576    // c:5563
10577    // c:5563 — `unsetenv(pm->node.nam)`.
10578    env::remove_var(name);
10579    // c:5568 / c:5572 — `pm->env = NULL`. PM_EXPORTED stays set.
10580    let mut tab = paramtab().write().unwrap();
10581    if let Some(pm) = tab.get_mut(name) {
10582        pm.env = None;
10583    }
10584}
10585
10586/// Port of `convbase_ptr(char *s, zlong v, int base, int *ndigits)` from `Src/params.c:5586`. C body
10587/// converts `v` into base `base` (negative `base` suppresses the
10588/// "0x"/"N#" discriminator), writing the digits into `s` and
10589/// returning the digit count via `*ndigits`. Rust port returns
10590/// `(formatted_string, digit_count)` since Rust strings own
10591/// their buffer.
10592/// WARNING: param names don't match C — Rust=(v, base) vs C=(s, v, base, ndigits)
10593pub fn convbase_ptr(v: i64, base: i32) -> (String, i32) {
10594    let mut s = String::new();
10595    let mut value = v;
10596    if value < 0 {
10597        s.push('-');
10598        // c:Src/params.c — `value = -value;` on INT_MIN is UB in C
10599        // but in practice wraps back to INT_MIN. Use wrapping_neg
10600        // to avoid Rust's debug-build overflow panic. zsh prints
10601        // `-9223372036854775808` for `$((2**63))`; we mirror that.
10602        value = value.wrapping_neg();
10603    }
10604    let mut b = base;
10605    if (-1..=1).contains(&b) {
10606        b = -10;
10607    }
10608    if b > 0 {
10609        if isset(CBASES) && b == 16 {
10610            s.push_str("0x");
10611        } else if isset(CBASES) && b == 8 && isset(OCTALZEROES) {
10612            s.push('0');
10613        } else if b != 10 {
10614            s.push_str(&format!("{}#", b));
10615        }
10616    } else {
10617        b = -b;
10618    }
10619    let base_u = b as u64;
10620    let mut x = value as u64;
10621    let mut digs: i32 = 0;
10622    while x != 0 {
10623        x /= base_u;
10624        digs += 1;
10625    }
10626    if digs == 0 {
10627        digs = 1;
10628    }
10629    let mut digits: Vec<u8> = vec![0u8; digs as usize];
10630    let mut i = digs - 1;
10631    let mut x = value as u64;
10632    while i >= 0 {
10633        let dig = (x % base_u) as u8;
10634        digits[i as usize] = if dig < 10 {
10635            b'0' + dig
10636        } else {
10637            b'A' + dig - 10
10638        };
10639        x /= base_u;
10640        i -= 1;
10641    }
10642    s.push_str(std::str::from_utf8(&digits).unwrap_or(""));
10643    (s, digs)
10644}
10645
10646// ---------------------------------------------------------------------------
10647// Integer/Float conversion (from convbase/convfloat)
10648// ---------------------------------------------------------------------------
10649
10650/// Port of `convbase(char *s, zlong v, int base)` from
10651/// `Src/params.c:5632`. C body (single statement):
10652///     `convbase_ptr(s, v, base, NULL);`
10653/// Rust takes (v, base) and returns the formatted string since Rust
10654/// strings own their buffer; the discarded `ndigits` out-param of
10655/// `convbase_ptr` is `.1` of the returned tuple.
10656/// WARNING: param names don't match C — Rust=(val, base) vs C=(s, v, base)
10657pub fn convbase(val: i64, base: u32) -> String {
10658    // c:5632
10659    convbase_ptr(val, base as i32).0 // c:5634
10660}
10661
10662/// Convert integer to string with underscores for readability
10663/// Port of `convbase_underscore(char *s, zlong v, int base, int underscore)` from `Src/params.c:5646`.
10664///
10665/// `base` is `i32` not `u32` because zsh uses NEGATIVE `base` values
10666/// (set by `[##N]`) to mean "emit `N`-radix digits WITHOUT the `N#`
10667/// prefix" — convbase_ptr at params.rs:7435-7451 handles the sign:
10668/// positive `b` produces `b#NNN`, negative `b` produces bare `NNN`
10669/// (absolute value of `b` is the actual radix).
10670///
10671/// Previous Rust signature `base: u32` silently dropped the sign, so
10672/// `$(([##16] 255))` (outputradix=-16) ended up as `convbase(255, 16)`
10673/// = `"16#FF"` instead of `"FF"`.
10674/// WARNING: param names don't match C — Rust=(val, base, underscore) vs C=(s, v, base, underscore)
10675pub fn convbase_underscore(val: i64, base: i32, underscore: i32) -> String {
10676    // c:5646 — `convbase_ptr(s, v, base, &ndigits)` returns BOTH the string and
10677    // the digit count EXCLUDING the base prefix (`0x`, `N#`, leading `0`).
10678    let (s, ndigits) = convbase_ptr(val, base);
10679    if underscore <= 0 {
10680        return s; // c:5648
10681    }
10682    let ndigits = ndigits.max(0) as usize;
10683
10684    // c:5654-5657 — group only the last `ndigits` chars; the prefix (`len -
10685    // ndigits` chars) is copied through untouched. The previous port re-scanned
10686    // for the first digit, which matched the `0` in `0x…` and grouped the `0x`
10687    // prefix too (`0x10000` → `0_x10_000` instead of `0x10_000`).
10688    let split = s.len().saturating_sub(ndigits);
10689    let prefix = &s[..split];
10690    let digits = &s[split..];
10691
10692    if digits.len() <= underscore as usize {
10693        return s;
10694    }
10695
10696    let u = underscore as usize;
10697    let mut result = prefix.to_string();
10698    let chars: Vec<char> = digits.chars().collect();
10699    let first_group = chars.len() % u;
10700    if first_group > 0 {
10701        result.extend(&chars[..first_group]);
10702        if first_group < chars.len() {
10703            result.push('_');
10704        }
10705    }
10706    for (i, chunk) in chars[first_group..].chunks(u).enumerate() {
10707        if i > 0 {
10708            result.push('_');
10709        }
10710        result.extend(chunk);
10711    }
10712    result
10713}
10714
10715/// Port of `convfloat(double dval, int digits, int flags, FILE *fout)` from `Src/params.c:5689`.
10716///
10717/// C signature: `char *convfloat(double dval, int digits, int flags,
10718/// FILE *fout)` — picks `%e` / `%f` / `%g` based on PM_EFLOAT /
10719/// PM_FFLOAT (line 5705-5727), then snprintf'd with `digits` precision.
10720/// When neither E nor F flag is set, zsh uses `%.*g` with a default
10721/// of 17 significant digits (line 5712-5714). E-flag with N significant
10722/// figures decrements `digits` because `%e` counts decimal places not
10723/// significants (line 5720-5725).
10724///
10725/// Rust signature drops the `fout` parameter — every caller wanted the
10726/// returned string. IEEE specials (inf/nan) hand-formatted to `Inf`/
10727/// `-Inf`/`NaN` ahead of the snprintf, matching the C source's Inf/NaN
10728/// shortcuts at lines 5733-5736 / 5742-5744. The trailing-dot rule for
10729/// integer-valued floats (`5` -> `5.`) is added by the caller (params'
10730/// internal printing path) in C zsh; mirrored here for the no-flag case
10731/// so `MathNum::(crate::ported::math::mn_format_subst(Float(5.0)))` produces `5.` not `5`.
10732/// WARNING: param names don't match C — Rust=(dval, digits, pm_flags) vs C=(dval, digits, flags, fout)
10733pub fn convfloat(dval: f64, digits: i32, pm_flags: u32) -> String {
10734    if dval.is_infinite() {
10735        // c:5742
10736        return if dval < 0.0 {
10737            "-Inf".to_string()
10738        } else {
10739            "Inf".to_string()
10740        };
10741    }
10742    if dval.is_nan() {
10743        // c:5744
10744        return "NaN".to_string();
10745    }
10746    // Pick fmt char + adjust digits per the C cascade at 5705-5727.
10747    let (fmt_char, digits) = if (pm_flags & PM_EFLOAT) != 0 {
10748        // c:5715
10749        let d = if digits <= 0 { 10 } else { digits }; // c:5718
10750        ('e', (d - 1).max(0)) // c:5725
10751    } else if (pm_flags & PM_FFLOAT) != 0 {
10752        // c:5716
10753        let d = if digits <= 0 { 10 } else { digits }; // c:5718
10754        ('f', d)
10755    } else {
10756        let d = if digits == 0 { 17 } else { digits }; // c:5713
10757        ('g', d)
10758    };
10759    // Mirror zsh's snprintf path (Src/params.c:5751) — the C source
10760    // uses `VARARR(char, buf, 512 + digits)` for %f's full integer-
10761    // part expansion. 512 + 17 = 529 covers the zsh general case;
10762    // wider buffers below for the unbounded %f.
10763    let buf_len = 512usize + digits as usize + 4;
10764    let mut buf = vec![0u8; buf_len];
10765    let fmt = match fmt_char {
10766        'e' => c"%.*e",
10767        'f' => c"%.*f",
10768        _ => c"%.*g",
10769    };
10770    // SAFETY: buf has the C-required size for any double precision; fmt
10771    // is a NUL-terminated literal; snprintf writes ASCII only.
10772    let n = unsafe {
10773        libc::snprintf(
10774            buf.as_mut_ptr() as *mut libc::c_char,
10775            buf_len,
10776            fmt.as_ptr(),
10777            digits as libc::c_int,
10778            dval,
10779        )
10780    };
10781    if n < 0 {
10782        return format!("{}", dval);
10783    }
10784    let len = (n as usize).min(buf_len - 1);
10785    buf.truncate(len);
10786    let mut s = String::from_utf8(buf).unwrap_or_else(|_| format!("{}", dval));
10787    // zsh's general-format (%g) callers (math `$(( ))` substitution)
10788    // append `.` when the output has no `e` and no `.`, so integer-
10789    // valued floats like `5` render as `5.`. PM_EFLOAT/PM_FFLOAT skip
10790    // this rule (the format spec already pins shape).
10791    if fmt_char == 'g' && !s.contains('e') && !s.contains('.') {
10792        s.push('.');
10793    }
10794    s
10795}
10796
10797/// Start a parameter scope.
10798/// Port of `startparamscope()` (Src/init.c) — the C source pushes the
10799/// current scope counter so `local`-declared params disappear on function
10800/// exit. Rust port operates on the bucket-2 holder `paramtab` via a
10801/// `&mut HashTable` argument.
10802pub fn startparamscope(_table: &mut HashTable) {
10803    inc_locallevel();
10804}
10805
10806/// Port of `endparamscope()` from `Src/params.c:5857`. C signature:
10807/// `mod_export void endparamscope(void)`. Decrements `locallevel`,
10808/// pops any pushed history stack, then iterates `paramtab` calling
10809/// `scanendscope` to restore/unset every param whose `level`
10810/// exceeds the new `locallevel`. Operates on the global `paramtab`
10811/// just like C — no parameter, no fake injection wrapper.
10812pub fn endparamscope() {
10813    queue_signals();
10814    // c:5861 — `LinkList refs = locallevel < scoperefs_num ? scoperefs[locallevel] : NULL;`
10815    //          Snapshot the refs at the OLD locallevel BEFORE decrementing.
10816    let old_ll = locallevel_fn();
10817    let refs_snapshot: Vec<String> = SCOPEREFS.with(|sr| {
10818        let sr = sr.borrow();
10819        if (old_ll as usize) < sr.len() {
10820            sr[old_ll as usize].clone()
10821        } else {
10822            Vec::new()
10823        }
10824    });
10825
10826    dec_locallevel(); // c:5863 locallevel--
10827                      // c:5865 — `saveandpophiststack(0, HFILE_USE_OPTIONS);`. Pop
10828                      // all stack entries with locallevel > current.
10829    saveandpophiststack(0, HFILE_USE_OPTIONS as i32);
10830    let ll = locallevel_fn();
10831    // c:5869 scanhashtable(paramtab, 0, 0, 0, scanendscope, 0). Walk
10832    // the live paramtab (HashMap-backed until the hashtable.c vtable
10833    // is wired) and apply scanendscope's `pm->level > locallevel`
10834    // filter, restoring the `pm.old` chain or removing the entry.
10835    // c:Src/params.c:5867-5933 — for PM_SPECIAL scalar params the
10836    // gsu setfn must be re-fired with the restored value so global
10837    // side-effects (ifs char buffer, PATH chunks, lc_update_needed
10838    // flag, etc.) get rolled back. Bug #8 in docs/BUGS.md: `local
10839    // IFS=:` inside a function left the global ifs buffer pinned
10840    // to ":" after return.
10841    //
10842    // The setfn closures often re-enter paramtab (ifssetfn → inittyptab
10843    // → paramtab.read), so we MUST drop the write lock before calling
10844    // them. Collect (name, setfn, value) into a deferred list inside
10845    // the lock, restore the pm.old chain, drop the lock, then re-fire
10846    // setfn on each special.
10847    // setfn None → name-routed restore via setsparam (PM_TIED params
10848    // whose pm carries no scalar gsu — FPATH/PATH/CDPATH…; setsparam
10849    // dispatches to the tied setter by name, refilling the tied
10850    // array's global storage).
10851    type DeferredSetfn = (String, Option<fn(&mut param, String)>, String);
10852    let mut deferred: Vec<DeferredSetfn> = Vec::new();
10853    // Array-shaped specials/tieds restore through the name-routed
10854    // array setter after the lock drops (same deadlock rationale).
10855    let mut deferred_arrays: Vec<(String, Vec<String>)> = Vec::new();
10856    if let Ok(mut tab) = paramtab().write() {
10857        let stale: Vec<(String, bool)> = tab
10858            .iter()
10859            .filter_map(|(k, pm)| {
10860                if pm.level > ll {
10861                    Some((k.clone(), (pm.node.flags as u32 & PM_HASHED) != 0))
10862                } else {
10863                    None
10864                }
10865            })
10866            .collect();
10867        for (n, was_assoc) in stale {
10868            // c:scanendscope:5903 — non-special path: restore pm.old
10869            // (or remove if no outer binding existed).
10870            if let Some(pm) = tab.remove(&n) {
10871                let had_outer = pm.old.is_some();
10872                let outer_is_assoc = pm
10873                    .old
10874                    .as_ref()
10875                    .map(|p| (p.node.flags as u32 & PM_HASHED) != 0)
10876                    .unwrap_or(false);
10877                if let Some(prev) = pm.old {
10878                    // c:scanendscope:5933 pm->old = tpm->old
10879                    // PM_TIED counts too — the tied array's GLOBAL
10880                    // storage was written through by the local's
10881                    // assignments; the deferred restore re-fires the
10882                    // saved value through the tied setter (by gsu
10883                    // setfn when present, else name-routed setsparam)
10884                    // so fpath/path/cdpath roll back with the scalar.
10885                    let restored_is_special =
10886                        (prev.node.flags as u32 & (PM_SPECIAL | PM_TIED)) != 0;
10887                    let restored_is_array = (PM_TYPE(prev.node.flags as u32) & PM_ARRAY) != 0;
10888                    let restored_val = prev.u_str.clone();
10889                    let restored_arr = prev.u_arr.clone();
10890                    let restored_setfn = prev.gsu_s.as_ref().map(|g| g.setfn);
10891                    tab.insert(n.clone(), prev); // restore outer binding (Box<param>)
10892                    if restored_is_special {
10893                        if restored_is_array {
10894                            // ARRAY side of a tied pair — re-fire the
10895                            // saved element vector through the
10896                            // name-routed array setter so the tied
10897                            // scalar + global storage roll back too.
10898                            if let Some(arr) = restored_arr {
10899                                deferred_arrays.push((n.clone(), arr));
10900                            }
10901                        } else if let Some(val) = restored_val {
10902                            deferred.push((n.clone(), restored_setfn, val));
10903                        }
10904                    }
10905                }
10906                // else: c:5966 unsetparam_pm — name unset entirely
10907                // RUST-ONLY: the assoc-storage shadow lives in a
10908                // parallel `paramtab_hashed_storage` map keyed by name;
10909                // endparamscope's pm removal must also clear that
10910                // shadow when no outer binding remains. Without this,
10911                // `f() { typeset -A H; H[x]=v; }; f` leaves H's data
10912                // in the shadow map even after the local pm is gone,
10913                // so `${H[x]}` outside reads "v" instead of empty.
10914                if was_assoc && !had_outer {
10915                    let _ = paramtab_hashed_storage()
10916                        .lock()
10917                        .ok()
10918                        .as_deref_mut()
10919                        .map(|m| m.remove(&n));
10920                }
10921                // RUST-ONLY: PM_HASHED outer-pm restoration — pop the
10922                // saved paramtab_hashed_storage[name] from the shadow
10923                // stack and re-install it so the outer scope's assoc
10924                // data is visible again. Mirrors the C copyparam +
10925                // pm.old chain via parallel storage. Bug #415. Symmetric
10926                // with the createparam push-side.
10927                if was_assoc && outer_is_assoc {
10928                    let stk_mtx =
10929                        PARAMTAB_HASHED_SHADOW_STACK.get_or_init(|| Mutex::new(HashMap::new()));
10930                    let saved = if let Ok(mut stk) = stk_mtx.lock() {
10931                        stk.get_mut(&n).and_then(|v| v.pop()).flatten()
10932                    } else {
10933                        None
10934                    };
10935                    if let Ok(mut m) = paramtab_hashed_storage().lock() {
10936                        match saved {
10937                            Some(map) => {
10938                                m.insert(n.clone(), map);
10939                            }
10940                            None => {
10941                                m.remove(&n);
10942                            }
10943                        }
10944                    }
10945                }
10946            }
10947        }
10948    }
10949    // c:Src/params.c:5915-5933 — re-fire PM_SPECIAL setfns NOW that
10950    // the paramtab write lock is released. Each setfn takes its own
10951    // paramtab read (via inittyptab / similar) so the deferred call
10952    // is the only deadlock-safe path.
10953    for (n, setfn, val) in deferred {
10954        match setfn {
10955            Some(setfn) => {
10956                let mut pm_copy: Option<param> = None;
10957                if let Ok(tab) = paramtab().read() {
10958                    pm_copy = tab.get(&n).map(|p| (**p).clone());
10959                }
10960                if let Some(mut pm) = pm_copy {
10961                    setfn(&mut pm, val);
10962                }
10963            }
10964            // PM_TIED without a scalar gsu — name-routed: setsparam
10965            // reaches the tied setter and refills the tied array's
10966            // global storage (fpath/path/cdpath…).
10967            None => {
10968                setsparam(&n, &val);
10969            }
10970        }
10971    }
10972    for (n, arr) in deferred_arrays {
10973        let _ = assignaparam(&n, arr, 0);
10974    }
10975
10976    // c:5890-5894 — `for (Param pm; refs && (pm = getlinknode(refs));) {
10977    //                   if ((pm->flags & PM_NAMEREF) && !(pm->flags & PM_UNSET) &&
10978    //                       !(pm->flags & PM_UPPER) && pm->base > locallevel) {
10979    //                       pm->base = 0; setscope(pm); } }`
10980    //               Reset PM_NAMEREF refs whose base was above the popped scope.
10981    if !refs_snapshot.is_empty() {
10982        if let Ok(mut tab) = paramtab().write() {
10983            for name in refs_snapshot.iter() {
10984                if let Some(pm) = tab.get_mut(name) {
10985                    let f = pm.node.flags as u32;
10986                    if (f & PM_NAMEREF) != 0
10987                        && (f & PM_UNSET) == 0
10988                        && (f & PM_UPPER) == 0
10989                        && pm.base > ll
10990                    {
10991                        pm.base = 0; // c:5893
10992                                     // c:5894 setscope(pm) — would recursively call
10993                                     // setscope_base(pm, 0); with base=0 and pm.level>=0
10994                                     // the guard at setscope_base c:6440 fails so it's
10995                                     // a no-op write. Skip the recursive call to avoid
10996                                     // re-borrowing paramtab.
10997                    }
10998                }
10999            }
11000        }
11001    }
11002    // c:5896 — clear out the now-popped scope's refs list.
11003    SCOPEREFS.with(|sr| {
11004        let mut sr = sr.borrow_mut();
11005        if (old_ll as usize) < sr.len() {
11006            sr[old_ll as usize].clear();
11007        }
11008    });
11009    unqueue_signals();
11010}
11011
11012/// Port of `scanendscope(HashNode hn, UNUSED(int flags))` from `Src/params.c:5900`. Per-node
11013/// callback used by `endparamscope` (params.c:5867 calls
11014/// `scanhashtable(paramtab, 0, 0, 0, scanendscope, 0)`) when a
11015/// function returns. C body:
11016/// ```c
11017/// Param pm = (Param)hn;
11018/// if (pm->level > locallevel) {
11019///     if ((pm->node.flags & (PM_SPECIAL|PM_REMOVABLE)) == PM_SPECIAL) {
11020///         /* Non-removable special — restore from pm->old in-place. */
11021///         Param tpm = pm->old;
11022///         #ifdef USE_LOCALE
11023///         if (!strncmp(pm->node.nam, "LC_", 3) ||
11024///             !strcmp(pm->node.nam, "LANG"))
11025///             lc_update_needed = 1;
11026///         #endif
11027///         if (!strcmp(pm->node.nam, "SECONDS")) {
11028///             setsecondstype(pm, PM_TYPE(tpm->node.flags),
11029///                                PM_TYPE(pm->node.flags));
11030///             setrawseconds(tpm->u.dval);
11031///             tpm->node.flags |= PM_NORESTORE;
11032///         }
11033///         pm->old = tpm->old;
11034///         pm->node.flags = (tpm->node.flags & ~PM_NORESTORE);
11035///         pm->level = tpm->level;
11036///         pm->base  = tpm->base;
11037///         pm->width = tpm->width;
11038///         if (pm->env) delenv(pm);
11039///         if (!(tpm->node.flags & (PM_NORESTORE|PM_READONLY)))
11040///             switch (PM_TYPE(pm->node.flags)) {
11041///             case PM_SCALAR: case PM_NAMEREF:
11042///                 pm->gsu.s->setfn(pm, tpm->u.str); break;
11043///             case PM_INTEGER:
11044///                 pm->gsu.i->setfn(pm, tpm->u.val); break;
11045///             case PM_EFLOAT: case PM_FFLOAT:
11046///                 pm->gsu.f->setfn(pm, tpm->u.dval); break;
11047///             case PM_ARRAY:
11048///                 pm->gsu.a->setfn(pm, tpm->u.arr); break;
11049///             case PM_HASHED:
11050///                 pm->gsu.h->setfn(pm, tpm->u.hash); break;
11051///             }
11052///         zfree(tpm, sizeof(*tpm));
11053///         if (pm->node.flags & PM_EXPORTED) export_param(pm);
11054///     } else
11055///         unsetparam_pm(pm, 0, 0);
11056/// }
11057/// ```
11058/// Rust port mirrors the structure 1:1. `locallevel` is read via
11059/// the ported global `crate::ported::params::locallevel` (atomic).
11060/// `setsecondstype` (params.rs:6183), `setrawseconds` (params.rs:6169),
11061/// and `delenv` (params.rs:7591) are all ported.
11062pub fn scanendscope(pm: &mut param, _flags: i32) {
11063    // c:5900
11064    let cur_local = locallevel.load(Ordering::Relaxed);
11065    if pm.level <= cur_local {
11066        // c:5903
11067        return;
11068    }
11069    let pmflags = pm.node.flags as u32;
11070    if (pmflags & (PM_SPECIAL | PM_REMOVABLE)) == PM_SPECIAL {
11071        // Take ownership of the saved old param.
11072        let mut tpm = match pm.old.take() {
11073            Some(t) => t,
11074            None => {
11075                // C uses DPUTS — fatal in debug, silent in release.
11076                return;
11077            }
11078        };
11079
11080        // USE_LOCALE branch: LC_*/LANG bumps LC_UPDATE_NEEDED.
11081        if pm.node.nam.starts_with("LC_") || pm.node.nam == "LANG" {
11082            LC_UPDATE_NEEDED.store(1, Ordering::SeqCst);
11083        }
11084
11085        if pm.node.nam == "SECONDS" {
11086            // setsecondstype(pm, PM_TYPE(tpm.flags), PM_TYPE(pm.flags));
11087            // setrawseconds(tpm.u_dval);
11088            tpm.node.flags |= PM_NORESTORE as i32;
11089        }
11090
11091        // pm->old = tpm->old;
11092        pm.old = tpm.old.take();
11093        // pm->node.flags = tpm->node.flags & ~PM_NORESTORE;
11094        pm.node.flags = (tpm.node.flags as u32 & !PM_NORESTORE) as i32;
11095        pm.level = tpm.level;
11096        pm.base = tpm.base;
11097        pm.width = tpm.width;
11098
11099        if pm.env.is_some() {
11100            delenv(&pm.node.nam);
11101            pm.env = None;
11102        }
11103
11104        let restore = (tpm.node.flags as u32 & (PM_NORESTORE | PM_READONLY)) == 0;
11105        if restore {
11106            match PM_TYPE(pm.node.flags as u32) {
11107                t if t == PM_SCALAR || t == PM_NAMEREF => {
11108                    // pm->gsu.s->setfn(pm, tpm->u.str)
11109                    pm.u_str = tpm.u_str.clone();
11110                }
11111                t if t == PM_INTEGER => {
11112                    pm.u_val = tpm.u_val;
11113                }
11114                t if t == PM_EFLOAT || t == PM_FFLOAT => {
11115                    pm.u_dval = tpm.u_dval;
11116                }
11117                t if t == PM_ARRAY => {
11118                    pm.u_arr = tpm.u_arr.clone();
11119                }
11120                t if t == PM_HASHED => {
11121                    pm.u_hash = tpm.u_hash.take();
11122                }
11123                _ => {}
11124            }
11125        }
11126        // zfree(tpm) — Rust drops the Box at end of scope.
11127        drop(tpm);
11128
11129        if (pm.node.flags as u32 & PM_EXPORTED) != 0 {
11130            export_param(pm);
11131        }
11132    } else {
11133        unsetparam_pm(pm, 0, 0);
11134    }
11135}
11136
11137/// Direct port of `void freeparamnode(HashNode hn)` from
11138/// `Src/params.c:5977-5994`. Frees a Param node, including
11139/// running its unsetfn callback when the global `delunset` flag
11140/// is set.
11141///
11142/// C body:
11143///   if (delunset)
11144///     pm->gsu.s->unsetfn(pm, 1);          // c:5977
11145///   zsfree(pm->node.nam);                 // c:5977
11146///   if (!(pm->flags & PM_SPECIAL))        // c:5977
11147///     zsfree(pm->ename);                  // c:5977
11148///   zfree(pm, sizeof(struct param));      // c:5977
11149///
11150/// Rust's Drop handles every zsfree/zfree above; the explicit
11151/// step here is the optional unsetfn dispatch when `DELUNSET` is
11152/// non-zero. The remaining drop cascade fires when `_hn`
11153/// (`Box<param>`) leaves scope.
11154pub fn freeparamnode(mut _hn: Param) {
11155    // c:5977
11156    // c:5977-5987 — `if (delunset) pm->gsu.s->unsetfn(pm, 1);`.
11157    if DELUNSET.load(Ordering::Relaxed) != 0 {
11158        // The Rust port's stdunsetfn writes the unset state back to
11159        // paramtab; calling it on the about-to-drop param re-marks
11160        // its slot in the table so consumers that read the table
11161        // see PM_UNSET on the next lookup.
11162        stdunsetfn(_hn.as_mut(), 1); // c:5987
11163    }
11164    // c:5988-5992 — drop cascade frees nam / ename (non-PM_SPECIAL)
11165    // / struct itself when _hn goes out of scope.
11166}
11167
11168/// Port of `printparamvalue(Param p, int printflags)` from `Src/params.c:6035`. C body
11169/// dispatches on `PM_TYPE(p->node.flags)` and writes the value
11170/// (no `name=` prefix unless `!PRINT_KV_PAIR`, which prints `=`
11171/// first). PM_SCALAR/PM_NAMEREF: `quotedzputs(t)`; PM_INTEGER:
11172/// `printf("%ld")`; PM_EFLOAT/PM_FFLOAT: `convfloat(...)`;
11173/// PM_ARRAY: `( v1 v2 ... )` with `\n  ` separators on
11174/// PRINT_LINE; PM_HASHED: same shape via scan callback.
11175pub fn printparamvalue(p: &mut param, printflags: i32) {
11176    if (printflags & PRINT_KV_PAIR) == 0 {
11177        print!("=");
11178    }
11179    let t = PM_TYPE(p.node.flags as u32);
11180    if t == PM_SCALAR || t == PM_NAMEREF {
11181        // c:Src/params.c:6052 — `t = pm->gsu.s->getfn(pm)` then
11182        // quotedzputs. For SPECIAL params, the gsu_s vtable returns
11183        // the live value (HOME/PATH/IFS from globals or env). Direct
11184        // dispatch avoids the deadlock that getsparam would hit if
11185        // the caller holds paramtab write lock. Fallback chain:
11186        //   gsu_s.getfn(pm) → pm.u_str → env::var(name)
11187        // The env fallback covers exported scalars whose gsu_s wasn't
11188        // wired (vm_helper bootstrap path doesn't run createparam for
11189        // every special).
11190        let mut s = if let Some(gsu) = &p.gsu_s {
11191            (gsu.getfn)(p)
11192        } else {
11193            strgetfn(p)
11194        };
11195        if s.is_empty() && (p.node.flags as u32 & PM_EXPORTED) != 0 {
11196            if let Ok(v) = std::env::var(&p.node.nam) {
11197                s = v;
11198            }
11199        }
11200        // c:Src/params.c::printparamvalue — for scalar SPECIALS like `-`
11201        // (shell flags via dashgetfn, `${(t)-}` is `scalar-readonly-special`)
11202        // that have no gsu_s wired but do have a live getter routed through
11203        // lookup_special_var, fall back to getsparam if both gsu_s/strgetfn
11204        // returned empty. Mirrors the same dispatch the PM_INTEGER arm uses.
11205        // Bug #516.
11206        //
11207        // The PM_SPECIAL gate is required and was missing. getsparam applies
11208        // the PM_LEFT/PM_RIGHT_B/PM_RIGHT_Z padding (that is C's VALFLAG_SUBST
11209        // behaviour — the width attributes transform the value at EXPANSION),
11210        // so an ungated fallback padded any ORDINARY parameter that happened to
11211        // be empty:
11212        //     typeset -L5 v; typeset -p v   → zsh `v=''`, was `v='     '`
11213        // while `typeset -L5 v=abc` printed `abc` correctly — the fallback only
11214        // fires when the value is empty, which is exactly why the bug looked
11215        // like a storage problem. It is not: the store holds "" in both shells
11216        // (`typeset -L5 v; typeset +L v; typeset -p v` gives `v=''` in each);
11217        // only this read padded it. C's printparamvalue prints the raw value
11218        // and never re-enters the substitution path.
11219        //
11220        // Gated the same way as the PM_EXPORTED env fallback just above.
11221        if s.is_empty() && (p.node.flags as u32 & PM_SPECIAL) != 0 {
11222            if let Some(v) = crate::ported::params::getsparam(&p.node.nam) {
11223                s = v;
11224            }
11225        }
11226        print!("{}", quotedzputs(&s)); // c:6053
11227    } else if t == PM_INTEGER {
11228        // c:Src/params.c:6051-6057 PM_INTEGER arm — C calls
11229        // `printf("%ld", p->gsu.i->getfn(p))` (or output64 on 64-bit
11230        // builds). Always emits DECIMAL — `typeset -i 16 n=255` prints
11231        // `n=255`, NOT `n=16#FF`. The base formatting only applies to
11232        // `$n` expansion + `print -- $n`, not `typeset -p n`.
11233        //
11234        // The previous Rust port routed through `getsparam` first,
11235        // which calls `convbase` and formats as `16#FF`. That polluted
11236        // typeset -p output. Bug #608.
11237        //
11238        // Use the custom gsu_i.getfn when present (special integers
11239        // like PPID/EUID/SECONDS dispatch through their own getfn);
11240        // fall back to intgetfn (which reads pm.u_val). For lookup-
11241        // special-var integers without a wired gsu_i, fall through to
11242        // getsparam ONLY when u_val is 0 (the "no stored value"
11243        // sentinel) so live PPID etc. still surface.
11244        let getfn_ptr = p.gsu_i.as_ref().map(|g| g.getfn);
11245        let raw = if let Some(getfn) = getfn_ptr {
11246            getfn(p)
11247        } else {
11248            intgetfn(p)
11249        };
11250        if raw == 0 {
11251            if let Some(s) = crate::ported::params::getsparam(&p.node.nam) {
11252                if !s.is_empty() {
11253                    // Strip any base#prefix added by convbase so the
11254                    // typeset -p output stays decimal-only per C source.
11255                    let dec = if let Some(idx) = s.find('#') {
11256                        // Try parsing the part after # in the base.
11257                        let (base_str, rest) = s.split_at(idx);
11258                        let rest = &rest[1..];
11259                        if let Ok(b) = base_str.parse::<u32>() {
11260                            if (2..=36).contains(&b) {
11261                                i64::from_str_radix(rest, b)
11262                                    .map(|n| n.to_string())
11263                                    .unwrap_or(s.clone())
11264                            } else {
11265                                s.clone()
11266                            }
11267                        } else {
11268                            s.clone()
11269                        }
11270                    } else {
11271                        s.clone()
11272                    };
11273                    print!("{}", dec);
11274                    return;
11275                }
11276            }
11277        }
11278        print!("{}", raw);
11279    } else if t == PM_EFLOAT || t == PM_FFLOAT {
11280        // c:6063 — `convfloat(p->gsu.f->getfn(p), p->base, p->node.flags,
11281        //          stdout)`. Honors pm.base for precision and
11282        // pm.flags for PM_EFLOAT/PM_FFLOAT format selection. The
11283        // previous Rust port used `print!("{}", floatgetfn(p))`
11284        // which always renders in Rust's default float format
11285        // (which differs from C's printf %g / %e formats).
11286        print!("{}", convfloat(floatgetfn(p), p.base, p.node.flags as u32)); // c:6063
11287    } else if t == PM_ARRAY {
11288        if (printflags & PRINT_KV_PAIR) == 0 {
11289            print!("(");
11290            if (printflags & PRINT_LINE) == 0 {
11291                print!(" ");
11292            }
11293        }
11294        let arr = arrgetfn(p);
11295        if !arr.is_empty() {
11296            if (printflags & PRINT_LINE) != 0 {
11297                if (printflags & PRINT_KV_PAIR) != 0 {
11298                    print!("  ");
11299                } else {
11300                    print!("\n  ");
11301                }
11302            }
11303            // c:Src/params.c:6166-6171 — each array element goes
11304            // through `quotedzputs` so elements containing spaces,
11305            // quotes, or other shell-meta chars round-trip through
11306            // `eval`. zshrs previously emitted bare elements; output
11307            // like `( red blue green yellow )` for `(red "blue green"
11308            // yellow)` was 4 words when re-parsed, not 3. Bug #181
11309            // in docs/BUGS.md.
11310            print!("{}", quotedzputs(&arr[0]));
11311            for el in &arr[1..] {
11312                if (printflags & PRINT_LINE) != 0 {
11313                    print!("\n  ");
11314                } else {
11315                    print!(" ");
11316                }
11317                print!("{}", quotedzputs(el));
11318            }
11319            if (printflags & (PRINT_LINE | PRINT_KV_PAIR)) == PRINT_LINE {
11320                println!();
11321            }
11322        }
11323        if (printflags & PRINT_KV_PAIR) == 0 {
11324            if (printflags & PRINT_LINE) == 0 {
11325                print!(" ");
11326            }
11327            print!(")");
11328        }
11329    } else if t == PM_HASHED {
11330        if (printflags & PRINT_KV_PAIR) == 0 {
11331            print!("(");
11332        }
11333        // c:Src/params.c:6108-6110 — `scanhashtable(ht, 1, 0,
11334        //   PM_UNSET, ht->printnode, PRINT_KV_PAIR | (printflags &
11335        //   PRINT_LINE))`. C source ALWAYS passes PRINT_KV_PAIR when
11336        // scanning hash entries, regardless of the incoming
11337        // printflags. This guarantees the per-entry format is
11338        // `[key]=value` (which is the only syntactically-valid form
11339        // that round-trips through `eval`). The previous Rust port
11340        // only used `[k]=v` when PRINT_TYPESET / PRINT_KV_PAIR was
11341        // already set on the outer call, falling back to bare
11342        // `k v` form otherwise — so `typeset h` for an assoc
11343        // printed `h=''` instead of `h=( [a]=1 [b]=2 )`. Bug #218
11344        // in docs/BUGS.md.
11345        let mut had_entries = false;
11346        if let Ok(stor) = paramtab_hashed_storage().lock() {
11347            if let Some(map) = stor.get(&p.node.nam) {
11348                let mut entries: Vec<(&String, &String)> = map.iter().collect();
11349                entries.sort_by(|(a, _), (b, _)| a.cmp(b));
11350                let mut first = true;
11351                for (k, v) in entries {
11352                    if first {
11353                        // Leading space before first entry
11354                        // (only when not in PRINT_KV_PAIR mode).
11355                        if (printflags & PRINT_KV_PAIR) == 0 && (printflags & PRINT_LINE) == 0 {
11356                            print!(" ");
11357                        }
11358                        if (printflags & PRINT_LINE) != 0 {
11359                            print!("\n  ");
11360                        }
11361                        first = false;
11362                        had_entries = true;
11363                    } else if (printflags & PRINT_LINE) != 0 {
11364                        print!("\n  ");
11365                    } else {
11366                        print!(" ");
11367                    }
11368                    // c:6299-6303 — `[key]=value` form per the
11369                    // unconditional PRINT_KV_PAIR pass at c:6109. Both the
11370                    // KEY and the VALUE go through quotedzputs: C emits
11371                    // `putchar('['); quotedzputs(node.nam); printf("]=");
11372                    // printparamvalue(...)`. The key MUST be quoted so
11373                    // special-character keys (`[#]`, `[$]`, `[*]`, `[a b]`)
11374                    // round-trip through `eval "$(typeset -p h)"` — p10k's
11375                    // `_p9k_must_init` rebuilds parameter signatures exactly
11376                    // this way, so an unquoted `[#]=…` reparsed wrong.
11377                    print!("[{}]={}", quotedzputs(k), quotedzputs(v));
11378                }
11379            }
11380        }
11381        if (printflags & PRINT_KV_PAIR) == 0 {
11382            // c:Src/params.c — empty assoc prints `( )` (single
11383            // space between parens). Non-empty: `( [k]=v )` (space
11384            // before first + space before close paren). Verified vs
11385            // /opt/homebrew/bin/zsh: `declare -Ax h; declare -p h`
11386            // → `typeset -Ax h=( )`.
11387            if (printflags & PRINT_LINE) == 0 {
11388                print!(" ");
11389            } else if had_entries {
11390                // c:Src/params.c — under PRINT_LINE (`typeset -p1`) the
11391                // closing paren sits on its OWN line after the last
11392                // `[k]=v` entry: `(\n  [k]=v\n)`. Without this newline the
11393                // `)` was glued to the last entry (`[k]=v)`).
11394                print!("\n");
11395            }
11396            print!(")");
11397        }
11398        let _ = had_entries;
11399    }
11400}
11401
11402/// Port of `printparamnode(HashNode hn, int printflags)` from `Src/params.c:6123`. Real C
11403/// body is ~200 lines emitting the typeset/declare-style listing
11404/// for one param honouring PRINT_NAMEONLY / PRINT_TYPESET /
11405/// PRINT_KV_PAIR / PRINT_LINE / PRINT_INCLUDEVALUE /
11406/// PRINT_POSIX_READONLY / PRINT_POSIX_EXPORT / PRINT_WITH_NAMESPACE
11407/// and the per-paramtypes attribute table. Faithful direct port
11408/// of the common path: skip-on-`.`-prefix without WITH_NAMESPACE,
11409/// skip-on-PM_UNSET (with the POSIX preserve), AUTOLOAD gating,
11410/// then `nam` + `=value` via `printparamvalue`.
11411pub fn printparamnode(hn: &mut param, mut printflags: i32) {
11412    const PRINT_WITH_NAMESPACE: i32 = 1 << 8; // matches createspecial print enum
11413                                              // c:Src/params.c — the `argv`/`*`/`@` special array IS the positional
11414                                              // parameter list (stored in PPARAMS), not this paramtab entry's u_arr
11415                                              // (which stays empty). Refresh u_arr from PPARAMS so `typeset -p argv`
11416                                              // shows the positionals instead of `( )`. The all-params `set`/typeset
11417                                              // listing path already special-cases these (builtin.rs:1315); mirror
11418                                              // it here for the explicit-name `typeset -p NAME` print.
11419    if matches!(hn.node.nam.as_str(), "argv" | "*" | "@") {
11420        let pp: Vec<String> = PPARAMS
11421            .lock()
11422            .ok()
11423            .map(|p| p.iter().cloned().collect())
11424            .unwrap_or_default();
11425        hn.u_arr = Some(pp);
11426    }
11427    let f = hn.node.flags as u32;
11428    if (f & PM_HASHELEM) == 0
11429        && (printflags & PRINT_WITH_NAMESPACE) == 0
11430        && hn.node.nam.starts_with('.')
11431    {
11432        return;
11433    }
11434    if (f & PM_UNSET) != 0 {
11435        // c:Src/params.c — PM_SPECIAL params (HOME/PATH/PWD/etc.)
11436        // carry PM_UNSET when their u_str cache hasn't been seeded
11437        // but the value is available via the GSU getfn. zshrs's
11438        // -fc init path doesn't run createparamtable() so the env
11439        // import + value-cache step is skipped — yet env::var()
11440        // still gives a live value. Probe getsparam for a
11441        // non-empty value before treating as truly unset so
11442        // `typeset -p HOME` (which goes through this print) emits
11443        // the expected `export HOME=…` line instead of nothing.
11444        let has_special_value =
11445            (f & PM_SPECIAL) != 0 && hn.u_str.is_none() && getsparam(&hn.node.nam).is_some();
11446        // c:6133-6143 — POSIX readonly/exported keep + PM_DEFAULTED
11447        // path: show as readonly/exported even if unset, with no
11448        // value (NAMEONLY).
11449        let posix_keep = (printflags & (PRINT_POSIX_READONLY | PRINT_POSIX_EXPORT)) != 0
11450            && (f & (PM_READONLY | PM_EXPORTED)) != 0;
11451        let defaulted = (f & PM_DEFAULTED) == PM_DEFAULTED; // c:6137
11452        if has_special_value {
11453            // Seed u_str so the value-emit arm below picks it up.
11454            hn.u_str = getsparam(&hn.node.nam);
11455            hn.node.flags &= !(PM_UNSET as i32);
11456        } else if posix_keep || defaulted {
11457            printflags |= PRINT_NAMEONLY;
11458        } else {
11459            return;
11460        }
11461    }
11462    if (f & PM_AUTOLOAD) != 0 {
11463        printflags |= PRINT_NAMEONLY;
11464    }
11465    // c:Src/params.c — the outer block runs whenever the attribute
11466    // walk is also wanted: PRINT_TYPE (bare typeset) OR
11467    // PRINT_TYPESET (typeset -p) OR PRINT_POSIX_*. The C source has
11468    // two SEPARATE parallel blocks, but the Rust port nested the
11469    // attribute walk inside the prefix block — bug #42 in
11470    // docs/BUGS.md. Widen the outer gate so PRINT_TYPE also enters
11471    // it; the prefix-print arms below gate themselves on the
11472    // narrower `PRINT_TYPESET | PRINT_POSIX_*` set so bare typeset
11473    // skips the `typeset ` / `export ` / `local ` prefix and goes
11474    // straight to the attribute walk + name=value tail.
11475    if (printflags & (PRINT_TYPE | PRINT_TYPESET | PRINT_POSIX_READONLY | PRINT_POSIX_EXPORT)) != 0
11476    {
11477        let needs_prefix =
11478            (printflags & (PRINT_TYPESET | PRINT_POSIX_READONLY | PRINT_POSIX_EXPORT)) != 0;
11479        if (f & PM_AUTOLOAD) != 0 && needs_prefix {
11480            return;
11481        }
11482        // c:6157-6162 — `if (p->node.flags & PM_RO_BY_DESIGN) {
11483        //   if (p->level != locallevel || p->level == 0) return; }`.
11484        // RO_BY_DESIGN specials can't be restored out of context, so
11485        // they're shown only when printed in the scope of their own
11486        // declaration (level matches AND is non-zero). At top level
11487        // (`p->level == 0`) they're always skipped — this is what keeps
11488        // the positional/special params (`*` `@` `#` `?` `-` `!` `$`,
11489        // ARGC, status, HISTCMD, LINENO, PPID, TTYIDLE, ZSH_SUBSHELL)
11490        // out of `typeset -p`. The `|| p->level == 0` clause was missing.
11491        if (f & PM_RO_BY_DESIGN) != 0 && needs_prefix {
11492            let cur_ll = locallevel.load(Ordering::Relaxed) as i32;
11493            if hn.level != cur_ll || hn.level == 0 {
11494                // c:6157-6158
11495                return;
11496            }
11497        }
11498        let mut altname: u8 = 0;
11499        if needs_prefix {
11500            if (printflags & PRINT_POSIX_EXPORT) != 0 {
11501                if (f & PM_EXPORTED) == 0 {
11502                    return;
11503                }
11504                altname = b'x';
11505                print!("export ");
11506            } else if (printflags & PRINT_POSIX_READONLY) != 0 {
11507                if (f & PM_READONLY) == 0 {
11508                    return;
11509                }
11510                altname = b'r';
11511                print!("readonly ");
11512            } else if (f & PM_EXPORTED) != 0 && (f & (PM_ARRAY | PM_HASHED)) == 0 {
11513                // c:6181-6188 — exported scalar: `local` or `export`.
11514                let cur_ll = locallevel.load(Ordering::Relaxed) as i32;
11515                if hn.level != 0 && hn.level >= cur_ll {
11516                    print!("local ");
11517                } else {
11518                    altname = b'x';
11519                    print!("export ");
11520                }
11521            } else {
11522                let cur_ll = locallevel.load(Ordering::Relaxed) as i32;
11523                if cur_ll != 0 && hn.level >= cur_ll {
11524                    if (f & PM_EXPORTED) != 0 {
11525                        print!("local ");
11526                    } else {
11527                        print!("typeset ");
11528                    }
11529                } else if cur_ll != 0 {
11530                    print!("typeset -g ");
11531                } else {
11532                    print!("typeset ");
11533                }
11534            }
11535        }
11536
11537        // c:6199-6259 — attribute walk via pmtypes table. Each row
11538        // tests `p->node.flags & binflag`; on match, PRINT_TYPESET
11539        // emits the `-X` letter, PRINT_TYPE the long word.
11540        // Port of `Src/params.c:6010 static const struct paramtypes
11541        // pmtypes[]`.
11542        const PMTF_USE_BASE: u32 = 1 << 0;
11543        const PMTF_USE_WIDTH: u32 = 1 << 1;
11544        const PMTF_TEST_LEVEL: u32 = 1 << 2;
11545        struct PmType {
11546            binflag: u32,
11547            string: &'static str,
11548            typeflag: u8,
11549            flags: u32,
11550        }
11551        const PMTYPES: &[PmType] = &[
11552            PmType {
11553                binflag: PM_AUTOLOAD,
11554                string: "undefined",
11555                typeflag: 0,
11556                flags: 0,
11557            },
11558            PmType {
11559                binflag: PM_INTEGER,
11560                string: "integer",
11561                typeflag: b'i',
11562                flags: PMTF_USE_BASE,
11563            },
11564            PmType {
11565                binflag: PM_EFLOAT,
11566                string: "float",
11567                typeflag: b'E',
11568                flags: 0,
11569            },
11570            PmType {
11571                binflag: PM_FFLOAT,
11572                string: "float",
11573                typeflag: b'F',
11574                flags: 0,
11575            },
11576            PmType {
11577                binflag: PM_ARRAY,
11578                string: "array",
11579                typeflag: b'a',
11580                flags: 0,
11581            },
11582            PmType {
11583                binflag: PM_HASHED,
11584                string: "association",
11585                typeflag: b'A',
11586                flags: 0,
11587            },
11588            PmType {
11589                binflag: 0,
11590                string: "local",
11591                typeflag: 0,
11592                flags: PMTF_TEST_LEVEL,
11593            },
11594            PmType {
11595                binflag: PM_HIDE,
11596                string: "hide",
11597                typeflag: b'h',
11598                flags: 0,
11599            },
11600            PmType {
11601                binflag: PM_LEFT,
11602                string: "left justified",
11603                typeflag: b'L',
11604                flags: PMTF_USE_WIDTH,
11605            },
11606            PmType {
11607                binflag: PM_RIGHT_B,
11608                string: "right justified",
11609                typeflag: b'R',
11610                flags: PMTF_USE_WIDTH,
11611            },
11612            PmType {
11613                binflag: PM_RIGHT_Z,
11614                string: "zero filled",
11615                typeflag: b'Z',
11616                flags: PMTF_USE_WIDTH,
11617            },
11618            PmType {
11619                binflag: PM_LOWER,
11620                string: "lowercase",
11621                typeflag: b'l',
11622                flags: 0,
11623            },
11624            PmType {
11625                binflag: PM_UPPER,
11626                string: "uppercase",
11627                typeflag: b'u',
11628                flags: 0,
11629            },
11630            PmType {
11631                binflag: PM_READONLY,
11632                string: "readonly",
11633                typeflag: b'r',
11634                flags: 0,
11635            },
11636            PmType {
11637                binflag: PM_TAGGED,
11638                string: "tagged",
11639                typeflag: b't',
11640                flags: 0,
11641            },
11642            PmType {
11643                binflag: PM_EXPORTED,
11644                string: "exported",
11645                typeflag: b'x',
11646                flags: 0,
11647            },
11648            PmType {
11649                binflag: PM_UNIQUE,
11650                string: "unique",
11651                typeflag: b'U',
11652                flags: 0,
11653            },
11654            PmType {
11655                binflag: PM_TIED,
11656                string: "tied",
11657                typeflag: b'T',
11658                flags: 0,
11659            },
11660            PmType {
11661                binflag: PM_NAMEREF,
11662                string: "nameref",
11663                typeflag: b'n',
11664                flags: 0,
11665            },
11666        ];
11667        if (printflags & (PRINT_TYPE | PRINT_TYPESET)) != 0 {
11668            let mut doneminus = false; // c:6200
11669            for pmptr in PMTYPES.iter() {
11670                // c:6204
11671                if altname != 0 && altname == pmptr.typeflag {
11672                    // c:6207
11673                    continue;
11674                }
11675                // PM_RO_BY_DESIGN-expansion for the readonly attribute:
11676                // zshrs's special params (e.g. `$!`, `$$`, `$?`, `LINENO`)
11677                // carry PM_RO_BY_DESIGN instead of PM_READONLY so internal
11678                // writes pass `assignstrvalue` (see vm_helper init at
11679                // bug #97 in docs/BUGS.md). The C-side IPDEF4 entries
11680                // declare PM_READONLY_SPECIAL = PM_SPECIAL | PM_READONLY |
11681                // PM_RO_BY_DESIGN (c:Src/params.c:351,zsh.h:1925), so both
11682                // bits are set together. The attribute walk's readonly
11683                // check has to expand its match to either bit; mirrors the
11684                // `bin_typeset` listing filter at builtin.rs:3620. Bug #297
11685                // in docs/BUGS.md.
11686                //
11687                // GATE on `!PM_REMOVABLE`: the IPDEF4 specials are NOT
11688                // removable, but `private` vars (c:Modules/param_private.c:
11689                // 174 — PM_HIDE|PM_SPECIAL|PM_REMOVABLE|PM_RO_BY_DESIGN)
11690                // carry PM_RO_BY_DESIGN WITHOUT PM_READONLY, exactly as in
11691                // C, and must NOT print "readonly". The C walk prints
11692                // "readonly" only from the real PM_READONLY bit, which a
11693                // private var never has. Without this gate the expansion
11694                // mis-fires on private vars — V10private.ztst:31 expects
11695                // `local hide scalar_test`, not `local hide readonly
11696                // scalar_test`.
11697                let effective_binflag = if pmptr.binflag == PM_READONLY && (f & PM_REMOVABLE) == 0 {
11698                    PM_READONLY | crate::ported::zsh_h::PM_RO_BY_DESIGN
11699                } else {
11700                    pmptr.binflag
11701                };
11702                let doprint = if (pmptr.flags & PMTF_TEST_LEVEL) != 0 {
11703                    // c:6209
11704                    hn.level != 0 // c:6211
11705                } else if (pmptr.binflag != PM_EXPORTED
11706                    || hn.level != 0
11707                    || (f & (PM_LOCAL | PM_ARRAY | PM_HASHED)) != 0)
11708                    && (f & effective_binflag) != 0
11709                {
11710                    // c:6225-6227
11711                    true
11712                } else {
11713                    false
11714                };
11715                if doprint {
11716                    // c:6230
11717                    if (printflags & PRINT_TYPESET) != 0 {
11718                        // c:6231
11719                        if pmptr.typeflag != 0 {
11720                            // c:6232
11721                            if !doneminus {
11722                                // c:6233
11723                                print!("-"); // c:6234
11724                                doneminus = true;
11725                            }
11726                            print!("{}", pmptr.typeflag as char); // c:6237
11727                        }
11728                    } else {
11729                        print!("{} ", pmptr.string); // c:6240
11730                    }
11731                    if (pmptr.flags & PMTF_USE_BASE) != 0 && hn.base != 0 {
11732                        // c:6242
11733                        print!("{} ", hn.base); // c:6243
11734                        doneminus = false;
11735                    }
11736                    if (pmptr.flags & PMTF_USE_WIDTH) != 0 && hn.width != 0 {
11737                        // c:6245
11738                        print!("{} ", hn.width); // c:6246
11739                        doneminus = false;
11740                    }
11741                }
11742            }
11743            if doneminus {
11744                // c:6252
11745                print!(" ");
11746            }
11747        }
11748
11749        // c:Src/params.c:6256-6285 — PM_TIED partner emission. For
11750        // tied scalar/array pairs (PATH↔path, FPATH↔fpath, etc.) the
11751        // partner name is printed before the entry's own name, and
11752        // for `typeset -p` on the SCALAR side the value is swapped to
11753        // the ARRAY peer's contents (so the output is re-parseable
11754        // and doesn't collapse `(a b c)` vs `('a b c')` to the same
11755        // colon-string). Bug #410.
11756        if (f & PM_TIED) != 0 {
11757            if let Some(ename) = hn.ename.clone() {
11758                // c:Src/params.c:6275 `paramtab->getnode(paramtab,
11759                // p->ename)`. The bin_typeset caller pre-clones the
11760                // pm before calling printparamnode (see builtin.rs)
11761                // so no paramtab lock is held here. Read the peer
11762                // directly. Bug #410.
11763                let peer_info: Option<(String, Vec<String>)> =
11764                    paramtab().read().ok().and_then(|t| {
11765                        t.get(&ename).map(|peer_pm| {
11766                            (
11767                                peer_pm.node.nam.clone(),
11768                                peer_pm.u_arr.clone().unwrap_or_default(),
11769                            )
11770                        })
11771                    });
11772                if let Some((peer_name, peer_arr)) = peer_info {
11773                    let typeset_mode = (printflags & PRINT_TYPESET) != 0;
11774                    let we_are_scalar = (f & PM_ARRAY) == 0;
11775                    if typeset_mode && we_are_scalar {
11776                        // c:6280-6284 — swap p with the array peer so
11777                        // value emission uses the array form. Print
11778                        // OUR name first (the scalar side), then
11779                        // swap.
11780                        print!("{} ", quotedzputs(&hn.node.nam));
11781                        hn.node.nam = peer_name;
11782                        hn.u_arr = Some(peer_arr);
11783                        hn.u_str = None;
11784                        // Flip the PM_TYPE bits to PM_ARRAY so
11785                        // printparamvalue dispatches the array arm.
11786                        // PM_TYPE is a const fn that masks the
11787                        // type-bits union (PM_SCALAR | PM_INTEGER |
11788                        // PM_EFLOAT | PM_FFLOAT | PM_ARRAY | PM_HASHED
11789                        // | PM_NAMEREF). Clear them all, then set
11790                        // PM_ARRAY.
11791                        let type_mask = crate::ported::zsh_h::PM_SCALAR
11792                            | crate::ported::zsh_h::PM_INTEGER
11793                            | crate::ported::zsh_h::PM_EFLOAT
11794                            | crate::ported::zsh_h::PM_FFLOAT
11795                            | crate::ported::zsh_h::PM_ARRAY
11796                            | crate::ported::zsh_h::PM_HASHED
11797                            | crate::ported::zsh_h::PM_NAMEREF;
11798                        hn.node.flags = (hn.node.flags & !(type_mask as i32)) | (PM_ARRAY as i32);
11799                        // Drop the scalar getfn so printparamvalue
11800                        // doesn't pull from the scalar gsu (which
11801                        // would resolve PATH-the-colon-string).
11802                        hn.gsu_s = None;
11803                    } else {
11804                        // c:6286 — non-swap path: just print peer's
11805                        // name + space. The downstream name+value
11806                        // emission still uses hn (our own data).
11807                        print!("{} ", quotedzputs(&peer_name));
11808                    }
11809                }
11810            }
11811        }
11812    }
11813    if (printflags & PRINT_KV_PAIR) != 0 {
11814        // hashelem path: print key without name= leader.
11815    }
11816    // c:Src/params.c:6290 — `quotedzputs(p->node.nam, stdout)`. Names
11817    // containing shell metacharacters get single-quoted so the
11818    // output is re-parseable (`'#'=0`, `'$'=2609`, `'?'=0`). Plain
11819    // identifiers pass through unchanged. Bug #97 in docs/BUGS.md:
11820    // bare `print!("{}", nam)` produced unquoted `#=0` etc.
11821    print!("{}", quotedzputs(&hn.node.nam));
11822    // c:6289 — `(printflags & PRINT_NAMEONLY) ||
11823    //   ((p->node.flags & PM_HIDEVAL) && !(printflags & PRINT_INCLUDEVALUE))`
11824    // PM_HIDEVAL (set by `typeset -H`, see TYPESET_OPTSTR position
11825    // 15 in Src/builtin.c bin_typeset → PM_HIDEVAL = 1<<15) hides
11826    // the value in `typeset -p` output. Bare `typeset NAME` passes
11827    // PRINT_INCLUDEVALUE (Src/builtin.c:2246) which overrides the
11828    // hide. Bug #233 in docs/BUGS.md — printparamnode didn't honor
11829    // PM_HIDEVAL, so `typeset -H X=hello; typeset -p X` printed the
11830    // value instead of just the name.
11831    let hideval = (f & PM_HIDEVAL) != 0 && (printflags & PRINT_INCLUDEVALUE) == 0;
11832    if (printflags & PRINT_NAMEONLY) != 0 || hideval {
11833        if (printflags & PRINT_KV_PAIR) == 0 {
11834            println!();
11835        }
11836        return;
11837    }
11838    if (printflags & (PRINT_INCLUDEVALUE | PRINT_TYPESET)) != 0
11839        || (printflags & PRINT_NAMEONLY) == 0
11840    {
11841        printparamvalue(hn, printflags);
11842    }
11843    if (printflags & PRINT_KV_PAIR) == 0 {
11844        println!();
11845    }
11846}
11847
11848/// Port of `resolve_nameref(Param pm)` from `Src/params.c:6325`. C body:
11849/// ```c
11850/// mod_export Param
11851/// resolve_nameref(Param pm)
11852/// {
11853///     return resolve_nameref_rec(pm, NULL, 0);
11854/// }
11855/// ```
11856/// Public entry point that walks the nameref alias chain to the
11857/// final non-nameref `param`. Stop-pm and keep_lastref are
11858/// internal; this wrapper hardcodes both per the C body.
11859/// WARNING: param names don't match C — Rust=() vs C=(pm)
11860pub fn resolve_nameref(
11861    // c:6325
11862    pm: Option<Param>,
11863) -> Option<Param> {
11864    resolve_nameref_rec(pm, None, 0) // c:6327
11865}
11866
11867/// Port of `resolve_nameref_rec(Param pm, const Param stop, int keep_lastref)` from `Src/params.c:6332`. C
11868/// recursive helper for `resolve_nameref()`. Walks the chain of
11869/// nameref indirections via `gethashnode2(realparamtab, refname)`
11870/// + `loadparamnode(paramtab, upscope(pm, ref), refname)`,
11871/// checking PM_TAGGED for cycle detection, and returns the
11872/// final non-nameref Param. Returns the input `pm` unchanged
11873/// for the early-exit path (no NAMEREF / UNSET / has subscript /
11874/// empty refname). The chain walk delegates to
11875/// `resolve_nameref_name` which operates on the live paramtab by
11876/// name (the Rust table hands out clones, so the by-name walk is
11877/// the canonical chain follower).
11878#[allow(unused_variables)]
11879pub fn resolve_nameref_rec(
11880    pm: Option<Param>,
11881    stop: Option<&param>,
11882    keep_lastref: i32,
11883) -> Option<Param> {
11884    let pm_ref = pm.as_deref()?;
11885    let f = pm_ref.node.flags as u32;
11886    // c:6336-6339 — early exits return pm unchanged.
11887    if (f & PM_NAMEREF) == 0 || (f & PM_UNSET) != 0 || pm_ref.width != 0 {
11888        return pm;
11889    }
11890    let refname = pm_ref.u_str.as_deref().unwrap_or("");
11891    if refname.is_empty() {
11892        if keep_lastref != 0 {
11893            return pm; // c:6353-6354
11894        }
11895        return pm; // empty refname is also an early-exit shape (c:6339)
11896    }
11897    if (f & PM_TAGGED) != 0 {
11898        // c:6340-6343 — `zerr("%s: invalid self reference", pm->node.nam)`.
11899        let nam = pm_ref.node.nam.clone();
11900        zerr(&format!("{}: invalid self reference", nam));
11901        return None;
11902    }
11903    let stop_key = stop.map(|s| (s.node.nam.clone(), s.level));
11904    match crate::ported::params::resolve_nameref_name(
11905        &pm_ref.node.nam,
11906        stop_key.as_ref().map(|(n, l)| (n.as_str(), *l)),
11907    ) {
11908        crate::ported::params::nameref_resolution::Target {
11909            pm: Some(target), ..
11910        } => Some(target),
11911        crate::ported::params::nameref_resolution::Target { pm: None, .. } => {
11912            // c:6347 miss + keep_lastref (c:6353-6354).
11913            if keep_lastref != 0 {
11914                pm
11915            } else {
11916                None
11917            }
11918        }
11919        crate::ported::params::nameref_resolution::Placeholder(last) => {
11920            // Chain ended on an empty-refname ref: C returns that ref
11921            // (the early-exit at c:6336-6339 of the recursive call).
11922            let tab = paramtab().read().ok()?;
11923            tab.get(&last).cloned().or(pm)
11924        }
11925        crate::ported::params::nameref_resolution::SelfRef => None, // c:6343
11926        crate::ported::params::nameref_resolution::OutOfScope => None, // c:6347-6349
11927        crate::ported::params::nameref_resolution::NotRef => pm,
11928    }
11929}
11930
11931/// ```c
11932/// Param pm = (Param) gethashnode2(realparamtab, name);
11933/// if (pm && (pm->node.flags & PM_NAMEREF)) {
11934///     if (pm->node.flags & PM_READONLY) {
11935///         zerr("read-only reference: %s", pm->node.nam); return;
11936///     }
11937///     pm->base = pm->width = 0;
11938///     SETREFNAME(pm, ztrdup(value));
11939///     pm->node.flags &= ~PM_UNSET;
11940///     setscope(pm);
11941/// } else
11942///     setsparam(name, ztrdup(value));
11943/// ```
11944/// `gethashnode2` is the no-autoload paramtab lookup. The
11945/// nameref branch updates the alias target in-place; the normal
11946/// branch falls through to `setsparam`.
11947/// Port of `setloopvar(char *name, char *value)` from `Src/params.c:6362`.
11948pub fn setloopvar(name: &str, value: &str) {
11949    // c:6367 — `Param pm = (Param) gethashnode2(realparamtab, name);`
11950    // realparamtab and paramtab are the same backing store in zshrs
11951    // (the alias-flip during assoc iteration isn't modelled); the
11952    // operative table is `paramtab()`.
11953    // Scope the write lock so we drop it before calling setsparam below.
11954    let nameref_branch = {
11955        let mut tab = paramtab().write().unwrap();
11956        if let Some(pm) = tab.get_mut(name) {
11957            // c:6369 — `if (pm && (pm->node.flags & PM_NAMEREF))`
11958            if (pm.node.flags as u32 & PM_NAMEREF) != 0 {
11959                // c:6370 — `if (pm->node.flags & PM_READONLY)`
11960                if (pm.node.flags as u32 & PM_READONLY) != 0 {
11961                    // c:6372 — `zerr("read-only reference: %s", pm->node.nam);`
11962                    zerr(&format!("read-only reference: {}", pm.node.nam));
11963                    // c:6373 — `return;`
11964                    return;
11965                }
11966                // c:6376 — `pm->base = pm->width = 0;`
11967                pm.base = 0;
11968                pm.width = 0;
11969                // c:6377 — `SETREFNAME(pm, ztrdup(value));`
11970                // SETREFNAME (params.c:482) macro: for PM_SPECIAL,
11971                // call gsu_s.setfn(pm, S); else free pm->u.str and
11972                // assign new. The PM_SPECIAL gsu vtable isn't fully
11973                // wired in zshrs; both branches collapse to the
11974                // direct `u_str` assignment which matches the
11975                // non-special path verbatim.
11976                pm.u_str = Some(value.to_string());
11977                // c:6378 — `pm->node.flags &= ~PM_UNSET;`
11978                pm.node.flags &= !(PM_UNSET as i32);
11979                true
11980            } else {
11981                false
11982            }
11983        } else {
11984            false
11985        }
11986    };
11987    if nameref_branch {
11988        // c:6379 — `setscope(pm);` — run the full-table variant with
11989        // no lock held (it manages its own short-lived locks).
11990        crate::ported::params::setscope_by_name(name);
11991    } else {
11992        // c:6381 — `setsparam(name, ztrdup(value));`
11993        setsparam(name, value);
11994    }
11995}
11996
11997/// PM_NAMEREF: extract `refname = GETREFNAME(pm)`, locate first
11998/// `[` to split name vs subscript (sets pm->width), look up the
11999/// base param via `gethashnode2(realparamtab, refname)` →
12000/// `loadparamnode` (skipping self) → `setscope_base(pm,
12001/// basepm->level)`; if pm->base > pm->level emits the KSH global
12002/// reference error or WARNNESTEDVAR diagnostic; finally walks the
12003/// `resolve_nameref_rec` chain to detect self-references.
12004/// In-place variant: operates only on the passed `&mut param`
12005/// (width computation + literal self-name check). Callers that
12006/// hold no paramtab lock should use `setscope_by_name` which runs
12007/// the full base-scope + chain self-ref detection against the
12008/// live table.
12009/// Port of `setscope(Param pm)` from `Src/params.c:6382`.
12010pub fn setscope(pm: &mut param) {
12011    queue_signals();
12012    if (pm.node.flags as u32 & PM_NAMEREF) != 0 {
12013        // Refname is stored in pm.u_str for nameref-typed params.
12014        let refname = pm.u_str.clone();
12015        if let Some(rn) = refname {
12016            // c:6391-6400 — compute pm->width by finding the first `[`.
12017            if let Some(i) = rn.find('[') {
12018                pm.width = i as i32;
12019            }
12020        }
12021    }
12022    unqueue_signals();
12023}
12024
12025/// ```c
12026/// if ((pm->base = base) > pm->level) {
12027///     LinkList refs;
12028///     /* grow scoperefs[] to base+1 entries */
12029///     refs = scoperefs[base];
12030///     if (!refs) refs = scoperefs[base] = znewlinklist();
12031///     zpushnode(refs, pm);
12032/// }
12033/// ```
12034/// Records `pm` on the per-scope reference list so a future
12035/// scope-pop can resolve nameref/upper bindings. Rust port
12036/// records the param's name on `SCOPEREFS[base]` so a future
12037/// scope-pop can resolve upper/nameref references.
12038/// Port of `setscope_base(Param pm, int base)` from `Src/params.c:6438`.
12039pub fn setscope_base(pm: &mut param, base: i32) {
12040    // c:6438
12041    // c:6440 — `if ((pm->base = base) > pm->level) {`
12042    pm.base = base;
12043    if base > pm.level {
12044        SCOPEREFS.with(|sr| {
12045            let mut sr = sr.borrow_mut();
12046            // c:6442-6447 — `if (base >= scoperefs_num) { ... grow ... }`
12047            //               Rust Vec grows on demand via resize; mirrors
12048            //               the C double-and-zero-init pattern via the
12049            //               max(8, 2*base) growth heuristic.
12050            if (base as usize) >= sr.len() {
12051                let new_num = (2 * base as usize).max(8);
12052                sr.resize(new_num, Vec::new());
12053            }
12054            // c:6448-6451 — `refs = scoperefs[base]; if (!refs)
12055            //                  refs = scoperefs[base] = znewlinklist();
12056            //                zpushnode(refs, pm);`
12057            //               Rust pushes the param NAME (Vec<String>),
12058            //               not a raw pointer — borrow-safe and the
12059            //               name is the canonical key for upscope walks.
12060            sr[base as usize].insert(0, pm.node.nam.clone()); // c:6451
12061        });
12062    }
12063}
12064
12065/// `scoperefs` — port of `static LinkList *scoperefs` from `Src/params.c:503`.
12066/// One Vec<String> (param names) per scope index. Per-evaluator (bucket 1)
12067/// because each worker thread has its own nameref-resolution context.
12068thread_local! {
12069    /// `SCOPEREFS` static.
12070    pub static SCOPEREFS: std::cell::RefCell<Vec<Vec<String>>>
12071        = const { std::cell::RefCell::new(Vec::new()) };
12072}
12073
12074/// Port of `upscope(Param pm, const Param ref)` from `Src/params.c:6455`. C body:
12075/// ```c
12076/// if (ref->node.flags & PM_UPPER)
12077///     while (pm->level > ref->level - 1 && (pm = pm->old));
12078/// else
12079///     for (; pm->old && pm->old->level >= ref->base; pm = pm->old);
12080/// return pm;
12081/// ```
12082/// Walks `pm->old` chain to the param at the right scope depth
12083/// for a nameref. Rust signature mirrors C `Param upscope(Param,
12084/// const Param ref)`.
12085/// WARNING: param names don't match C — Rust=(pm, reference) vs C=(pm, ref)
12086pub fn upscope(mut pm: Param, reference: &param) -> Param {
12087    if (reference.node.flags as u32 & PM_UPPER) != 0 {
12088        while pm.level > reference.level - 1 {
12089            match pm.old.take() {
12090                Some(o) => pm = o,
12091                None => break,
12092            }
12093        }
12094    } else {
12095        loop {
12096            let next_level = pm.old.as_ref().map(|o| o.level);
12097            match next_level {
12098                Some(l) if l >= reference.base => {
12099                    pm = pm.old.take().unwrap();
12100                }
12101                _ => break,
12102            }
12103        }
12104    }
12105    pm
12106}
12107
12108/// Port of `valid_refname(char *val, int flags)` from `Src/params.c:6466`. C body
12109/// validates a nameref target name. Two paths:
12110///   - PM_UPPER (`typeset -nu`): reject digit-leader (positional
12111///     refs would loop) and the literal `argv`/`ARGC` names.
12112///   - non-PM_UPPER: positional digit-leader is permitted (must be
12113///     all-digits before any `[`); otherwise scan via
12114///     `itype_end(INAMESPC)`.
12115/// Either path then accepts the trailing one-char specials
12116/// `! ? $ - _` and an optional `[subscript]` tail. Returns 1 on
12117/// valid, 0 otherwise. The Rust port follows the same control
12118/// flow with `is_ascii_digit`/`is_alphabetic` standing in for
12119/// `idigit`/`itype_end`.
12120pub fn valid_refname(val: &str, flags: i32) -> bool {
12121    // c:6466
12122    if val.is_empty() {
12123        return false;
12124    }
12125    let first = val.chars().next().unwrap();
12126    let pm_upper = (flags as u32 & PM_UPPER) != 0;
12127    let mut t: usize;
12128    if pm_upper {
12129        // c:6470
12130        if first.is_ascii_digit() {
12131            // c:6472
12132            return false; // c:6473
12133        }
12134        // c:6474 — `t = itype_end(val, INAMESPC, 0)`; INAMESPC stops
12135        // at `.` and other non-namespace chars. Approximate with
12136        // alphanumeric/_ scan.
12137        t = val
12138            .char_indices()
12139            .find(|(_, c)| !(c.is_alphanumeric() || *c == '_'))
12140            .map(|(i, _)| i)
12141            .unwrap_or(val.len());
12142        if t - 0 == 4                                                        // c:6475
12143            && (val.starts_with("argv") || val.starts_with("ARGC"))
12144        // c:6476-6477
12145        {
12146            return false; // c:6478
12147        }
12148    } else if first.is_ascii_digit() {
12149        // c:6479
12150        // c:6480-6485 — all-digit run; first non-digit must be `[`.
12151        t = 1;
12152        for (i, c) in val.char_indices().skip(1) {
12153            if !c.is_ascii_digit() {
12154                t = i;
12155                break;
12156            }
12157            t = i + c.len_utf8();
12158        }
12159        if t < val.len() && val.as_bytes()[t] != b'[' {
12160            // c:6484
12161            return false; // c:6485
12162        }
12163    } else {
12164        // c:6487 — `t = itype_end(val, INAMESPC, 0)`.
12165        t = val
12166            .char_indices()
12167            .find(|(_, c)| !(c.is_alphanumeric() || *c == '_' || *c == '.'))
12168            .map(|(i, _)| i)
12169            .unwrap_or(val.len());
12170    }
12171
12172    if t == 0 {
12173        // c:6489
12174        let c = val.as_bytes()[0];
12175        if !(c == b'!' || c == b'?' || c == b'$' || c == b'-' || c == b'_') {
12176            // c:6490
12177            return false; // c:6493
12178        }
12179        t = 1; // c:6494
12180    }
12181    if t < val.len() && val.as_bytes()[t] == b'[' {
12182        // c:6496
12183        // c:6498-6504 — parse_subscript/Inbrack/Outbrack walk. The
12184        // tokenize+parse_subscript pair isn't ported; accept any
12185        // balanced `[…]` tail (single-level) to remain conservative.
12186        let tail = &val[t + 1..];
12187        if let Some(close) = tail.find(']') {
12188            // c:6505-6508 — anything past `]` is rejected.
12189            if close + 1 < tail.len() {
12190                return false;
12191            }
12192        } else {
12193            return false;
12194        }
12195    }
12196    true // c:6510
12197}
12198
12199/// Read `foundparam`. Returns the last param name observed by
12200/// `scanparamvals`; cleared by callers after consumption.
12201pub fn foundparam() -> Option<String> {
12202    foundparam_lock().lock().unwrap().clone()
12203}
12204
12205/// Set `foundparam`. Called from `scanparamvals`.
12206pub fn set_foundparam(nam: Option<String>) {
12207    *foundparam_lock().lock().unwrap() = nam;
12208}
12209
12210/// Port of `fetchvalue(Value v, char **pptr, int bracks, int scanflags)` from `Src/params.c:2180` — see real
12211/// implementation below; this slot kept for the C-source linenum
12212/// citation and is now an alias.
12213// (real fetchvalue is defined later)
12214
12215/// Port of `static int delunset;` from `Src/params.c:610`. Flag
12216/// `deleteparamtable` flips to 1 around the inner `deletehashtable`
12217/// call so each freed node runs its `unsetfn`. `freeparamnode`
12218/// consults this before invoking the unset hook (c:5986).
12219pub static DELUNSET: std::sync::atomic::AtomicI32 = // c:610
12220    std::sync::atomic::AtomicI32::new(0);
12221
12222pub(crate) fn paramtab_hashed_storage() -> &'static Mutex<HashMap<String, IndexMap<String, String>>>
12223{
12224    PARAMTAB_HASHED_STORAGE_INNER.get_or_init(|| Mutex::new(HashMap::new()))
12225}
12226
12227/// Shadow stack for `paramtab_hashed_storage` entries displaced by
12228/// `local -A NAME` / `typeset -A NAME` shadows inside a function. The
12229/// canonical assoc data lives in `paramtab_hashed_storage` (a flat
12230/// HashMap keyed by name with NO scope dimension — Rust-only parallel
12231/// store; the C side keeps assoc data in pm.u_hash so it rides the
12232/// pm.old chain automatically). createparam pushes the displaced
12233/// value when a PM_LOCAL|PM_HASHED shadow installs; endparamscope
12234/// pops on PM_HASHED restoration so the outer scope's bag comes
12235/// back. Mirrors C's `copyparam` (Src/builtin.c:2382-2424) via
12236/// parallel storage. Bug #415.
12237pub(crate) static PARAMTAB_HASHED_SHADOW_STACK: OnceLock<
12238    Mutex<HashMap<String, Vec<Option<IndexMap<String, String>>>>>,
12239> = OnceLock::new();
12240
12241/// Mirror the global `paramtab` (and the parallel hashed-storage
12242/// table) into the three HashMaps that `SubstState` uses as its
12243/// transient backing during `prefork()` (Src/subst.c:100). This
12244/// is a port-transition shim: once `subst.rs` reads parameters
12245/// directly through `paramtab().read()` / `.write()` instead of carrying
12246/// `state.variables`/`state.arrays`/`state.assoc_arrays`, this
12247/// helper goes away.
12248pub fn sync_state_from_paramtab(
12249    variables: &mut HashMap<String, String>,
12250    arrays: &mut HashMap<String, Vec<String>>,
12251    assoc_arrays: &mut HashMap<String, IndexMap<String, String>>,
12252) {
12253    let tab = paramtab().read().unwrap();
12254    for (name, pm) in tab.iter() {
12255        let f = pm.node.flags as u32;
12256        if (f & PM_ARRAY) != 0 {
12257            if let Some(arr) = pm.u_arr.as_ref() {
12258                arrays.insert(name.clone(), arr.clone());
12259            }
12260            variables.remove(name);
12261            assoc_arrays.remove(name);
12262        } else if (f & PM_HASHED) != 0 {
12263            if let Some(map) = paramtab_hashed_storage().lock().unwrap().get(name) {
12264                assoc_arrays.insert(name.clone(), map.clone());
12265            }
12266            variables.remove(name);
12267            arrays.remove(name);
12268        } else if let Some(s) = pm.u_str.as_ref() {
12269            // PM_SCALAR / PM_NAMEREF / numeric — fold to the string view.
12270            variables.insert(name.clone(), s.clone());
12271            arrays.remove(name);
12272            assoc_arrays.remove(name);
12273        }
12274    }
12275}
12276
12277/// Format float with underscores
12278pub fn convfloat_underscore(dval: f64, underscore: i32) -> String {
12279    let s = convfloat(dval, 0, 0);
12280    if underscore <= 0 {
12281        return s;
12282    }
12283
12284    let u = underscore as usize;
12285    let (sign, rest) = if let Some(after) = s.strip_prefix('-') {
12286        ("-", after)
12287    } else {
12288        ("", s.as_str())
12289    };
12290
12291    let (int_part, frac_exp) = if let Some(dot_pos) = rest.find('.') {
12292        (&rest[..dot_pos], &rest[dot_pos..])
12293    } else {
12294        (rest, "")
12295    };
12296
12297    // Add underscores to integer part
12298    let int_chars: Vec<char> = int_part.chars().collect();
12299    let mut result = sign.to_string();
12300    let first_group = int_chars.len() % u;
12301    if first_group > 0 {
12302        result.extend(&int_chars[..first_group]);
12303        if first_group < int_chars.len() {
12304            result.push('_');
12305        }
12306    }
12307    for (i, chunk) in int_chars[first_group..].chunks(u).enumerate() {
12308        if i > 0 {
12309            result.push('_');
12310        }
12311        result.extend(chunk);
12312    }
12313
12314    // Add underscores to fractional part
12315    if let Some(frac) = frac_exp.strip_prefix('.') {
12316        result.push('.');
12317        let (frac_digits, exp) = if let Some(e_pos) = frac.find('e') {
12318            (&frac[..e_pos], &frac[e_pos..])
12319        } else {
12320            (frac, "")
12321        };
12322
12323        let frac_chars: Vec<char> = frac_digits.chars().collect();
12324        for (i, chunk) in frac_chars.chunks(u).enumerate() {
12325            if i > 0 {
12326                result.push('_');
12327            }
12328            result.extend(chunk);
12329        }
12330        result.push_str(exp);
12331    } else {
12332        result.push_str(frac_exp);
12333    }
12334
12335    result
12336}
12337
12338pub(crate) fn ifs_lock() -> &'static Mutex<String> {
12339    static IFS_VAR: OnceLock<Mutex<String>> = OnceLock::new();
12340    IFS_VAR.get_or_init(|| Mutex::new(" \t\n\0".to_string()))
12341}
12342
12343fn home_lock() -> &'static Mutex<String> {
12344    static HOME_VAR: OnceLock<Mutex<String>> = OnceLock::new();
12345    HOME_VAR.get_or_init(|| Mutex::new(env::var("HOME").unwrap_or_default()))
12346}
12347
12348fn term_lock() -> &'static Mutex<String> {
12349    static TERM_VAR: OnceLock<Mutex<String>> = OnceLock::new();
12350    TERM_VAR.get_or_init(|| Mutex::new(env::var("TERM").unwrap_or_default()))
12351}
12352
12353pub(crate) fn wordchars_lock() -> &'static Mutex<String> {
12354    static WORDCHARS_VAR: OnceLock<Mutex<String>> = OnceLock::new();
12355    WORDCHARS_VAR.get_or_init(|| Mutex::new("*?_-.[]~=/&;!#$%^(){}<>".to_string()))
12356}
12357
12358fn histchars_lock() -> &'static Mutex<[u8; 3]> {
12359    static HISTCHARS_VAR: OnceLock<Mutex<[u8; 3]>> = OnceLock::new();
12360    HISTCHARS_VAR.get_or_init(|| Mutex::new([b'!', b'^', b'#']))
12361}
12362
12363fn keyboardhack_lock() -> &'static Mutex<u8> {
12364    static KEYBOARDHACK_VAR: OnceLock<Mutex<u8>> = OnceLock::new();
12365    KEYBOARDHACK_VAR.get_or_init(|| Mutex::new(0))
12366}
12367
12368fn histsiz_lock() -> &'static Mutex<i64> {
12369    static HISTSIZ_VAR: OnceLock<Mutex<i64>> = OnceLock::new();
12370    // Match observed `zsh -fc 'echo $HISTSIZE'` output on zsh 5.9+
12371    // (Homebrew). Upstream's `configure.ac` defines DEFAULT_HISTSIZE
12372    // as 30 but distributed binaries seed the cap at 999999999 — the
12373    // parity goal here is "match the binary the user actually runs",
12374    // not "match the source-code default".
12375    HISTSIZ_VAR.get_or_init(|| Mutex::new(999_999_999))
12376}
12377
12378fn savehistsiz_lock() -> &'static Mutex<i64> {
12379    static SAVEHISTSIZ_VAR: OnceLock<Mutex<i64>> = OnceLock::new();
12380    // Same rationale as `histsiz_lock` — observed `zsh -fc
12381    // 'echo $SAVEHIST'` returns 99999999 on zsh 5.9+. Source has
12382    // savehistsiz default to 0 but distributed binaries cap at 99M.
12383    SAVEHISTSIZ_VAR.get_or_init(|| Mutex::new(99_999_999))
12384}
12385
12386fn zsh_terminfo_lock() -> &'static Mutex<String> {
12387    static TERMINFO_VAR: OnceLock<Mutex<String>> = OnceLock::new();
12388    TERMINFO_VAR.get_or_init(|| Mutex::new(env::var("TERMINFO").unwrap_or_default()))
12389}
12390
12391fn zsh_terminfodirs_lock() -> &'static Mutex<String> {
12392    static TERMINFODIRS_VAR: OnceLock<Mutex<String>> = OnceLock::new();
12393    TERMINFODIRS_VAR.get_or_init(|| Mutex::new(env::var("TERMINFO_DIRS").unwrap_or_default()))
12394}
12395
12396fn cached_username_lock() -> &'static Mutex<String> {
12397    static USERNAME_VAR: OnceLock<Mutex<String>> = OnceLock::new();
12398    USERNAME_VAR.get_or_init(|| Mutex::new(initial_username()))
12399}
12400
12401// Port of `static unsigned numparamvals;` (params.c:626) and the
12402// related per-scan statics at params.c:637-640. Per PORT.md Rule D
12403// these are file-scope statics, NOT aggregated into a state struct.
12404//
12405//   c:626  static unsigned numparamvals;
12406//   c:637  static Patprog scanprog;
12407//   c:638  static char *scanstr;
12408//   c:639  static char **paramvals;
12409//   c:640  static Param foundparam;   <-- exposed earlier as FOUNDPARAM
12410/// `NUMPARAMVALS` static.
12411pub static NUMPARAMVALS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); // c:626
12412/// `SCANPROG` static.
12413pub static SCANPROG: OnceLock<Mutex<Option<String>>> = OnceLock::new(); // c:637
12414/// `SCANSTR` static.
12415pub static SCANSTR: OnceLock<Mutex<Option<String>>> = OnceLock::new(); // c:638
12416/// `PARAMVALS` static.
12417pub static PARAMVALS: OnceLock<Mutex<Vec<String>>> = OnceLock::new(); // c:639
12418
12419/// Resolve the current user's name. Mirrors C's `get_username()`
12420/// init at Src/init.c which reads `getpwuid(getuid())->pw_name`
12421/// rather than `$USER`. Falls back to env vars only if the
12422/// passwd lookup fails (rare on real systems).
12423fn initial_username() -> String {
12424    #[cfg(unix)]
12425    {
12426        let uid = unsafe { libc::getuid() };
12427        let mut pwd: libc::passwd = unsafe { std::mem::zeroed() };
12428        // libc::c_char is i8 on x86_64/aarch64-darwin and x86_64-linux but u8 on
12429        // aarch64-linux. Use c_char so getpwuid_r's pointer type matches per-target.
12430        let mut buf: Vec<libc::c_char> = vec![0; 1024];
12431        let mut result: *mut libc::passwd = std::ptr::null_mut();
12432        let rc =
12433            unsafe { libc::getpwuid_r(uid, &mut pwd, buf.as_mut_ptr(), buf.len(), &mut result) };
12434        if rc == 0 && !result.is_null() && !pwd.pw_name.is_null() {
12435            let cstr = unsafe { std::ffi::CStr::from_ptr(pwd.pw_name) };
12436            return cstr.to_string_lossy().into_owned();
12437        }
12438    }
12439    env::var("USER")
12440        .or_else(|_| env::var("LOGNAME"))
12441        .unwrap_or_default()
12442}
12443
12444fn pipestats_lock() -> &'static Mutex<Vec<i32>> {
12445    static PIPESTATS_VAR: OnceLock<Mutex<Vec<i32>>> = OnceLock::new();
12446    PIPESTATS_VAR.get_or_init(|| Mutex::new(Vec::new()))
12447}
12448/// `shtimer_lock` — see implementation.
12449pub fn shtimer_lock() -> &'static Mutex<Duration> {
12450    static SHTIMER_VAR: OnceLock<Mutex<Duration>> = OnceLock::new();
12451    SHTIMER_VAR.get_or_init(|| {
12452        Mutex::new(
12453            SystemTime::now()
12454                .duration_since(UNIX_EPOCH)
12455                .unwrap_or_default(),
12456        )
12457    })
12458}
12459
12460fn pparams_lock() -> &'static Mutex<Vec<String>> {
12461    // Mirror of zsh's `pparams` (positional params $1, $2, ...).
12462    // Used by `poundgetfn` for `$#`. The canonical store is
12463    // `builtin::PPARAMS` (Src/init.c `pparams`); set/shift builtins
12464    // write there. Point at that single store so `$#` reads the
12465    // live value instead of an isolated empty mirror.
12466    &PPARAMS
12467}
12468
12469fn zunderscore_lock() -> &'static Mutex<String> {
12470    static ZUNDERSCORE_VAR: OnceLock<Mutex<String>> = OnceLock::new();
12471    ZUNDERSCORE_VAR.get_or_init(|| Mutex::new(String::new()))
12472}
12473
12474/// Update `$_` with the last argument of the just-completed
12475/// command. Mirrors C zsh's writeback in `execcmd_exec` (Src/exec.c)
12476/// where `zunderscore` is set to the last argv slot before
12477/// returning. Callers: every command-dispatch hook in
12478/// fusevm_bridge / vm_helper.
12479pub fn set_zunderscore(argv: &[String]) {
12480    let new = if let Some(last) = argv.last() {
12481        last.clone()
12482    } else {
12483        String::new()
12484    };
12485    *zunderscore_lock().lock().expect("zunderscore poisoned") = new;
12486}
12487
12488/// Direct port of `static int dontimport(int flags)` from
12489/// `Src/params.c:796-810`.
12490/// ```c
12491/// /* If explicitly marked as don't import */
12492/// if (flags & PM_DONTIMPORT)
12493///     return 1;
12494/// /* If value already exported */
12495/// if (flags & PM_EXPORTED)
12496///     return 1;
12497/// /* If security issue when importing and running with some privilege */
12498/// if ((flags & PM_DONTIMPORT_SUID) && isset(PRIVILEGED))
12499///     return 1;
12500/// /* OK to import */
12501/// return 0;
12502/// ```
12503/// Port of `dontimport(int flags)` from `Src/params.c:796`.
12504fn dontimport(flags: i32) -> i32 {
12505    // c:796
12506    let flags = flags as u32;
12507    // c:799-800 — `if (flags & PM_DONTIMPORT) return 1`.
12508    if flags & PM_DONTIMPORT != 0 {
12509        // c:799
12510        return 1; // c:800
12511    }
12512    // c:802-803 — `if (flags & PM_EXPORTED) return 1`.
12513    if flags & PM_EXPORTED != 0 {
12514        // c:802
12515        return 1; // c:803
12516    }
12517    // c:805-806 — `if ((flags & PM_DONTIMPORT_SUID) && isset(PRIVILEGED)) return 1`.
12518    if flags & PM_DONTIMPORT_SUID != 0                 // c:805
12519        && isset(PRIVILEGED)
12520    {
12521        return 1; // c:806
12522    }
12523    0 // c:809
12524}
12525
12526// ===========================================================
12527// GSU dispatch table — maps special-parameter NAMES to their
12528// getfn callback. C zsh dispatches reads of `$RANDOM` /
12529// `$USERNAME` / `$UID` / etc. through `Param.gsu->getfn`, where
12530// each special parameter has a `Param` entry in `paramtab`
12531// pointing at its specific getfn (Src/params.c:225 SPECIAL_PARAM
12532// table seeds these mappings).
12533//
12534// zshrs has the GSU callbacks ported (uidgetfn, randomgetfn,
12535// usernamegetfn, etc. above) but the shell's parameter-read path
12536// (fusevm_bridge::expand_param) reads from ShellExecutor.variables
12537// directly — never dispatching through the callbacks. Result:
12538// `echo $RANDOM` returned the cached HashMap value (or empty),
12539// not a fresh `rand() & 0x7fff` from `randomgetfn`.
12540//
12541// `lookup_special_var(name)` is the bridge: given a variable
12542// name, returns the GSU getfn's output if `name` is a recognized
12543// special, else None. Callers (expand_param, subst.rs reads)
12544// check this before falling back to `variables.get(name)`.
12545// ===========================================================
12546
12547/// Registry of special-parameter names that have been `unset` and
12548/// should bypass the getfn regenerator.
12549///
12550/// c:Src/params.c:3853 — `unsetparam_pm` sets `PM_UNSET` on the pm
12551/// node which getfn callbacks check. zshrs's `lookup_special_var`
12552/// dispatches to libc/getfn directly without a paramtab pm node, so
12553/// PM_UNSET tracking happens in this side-set instead. Bug #417/#418.
12554fn unset_specials() -> &'static std::sync::Mutex<std::collections::HashSet<String>> {
12555    static SET: std::sync::OnceLock<std::sync::Mutex<std::collections::HashSet<String>>> =
12556        std::sync::OnceLock::new();
12557    SET.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
12558}
12559
12560/// Mark a special-parameter NAME as unset. Future
12561/// `lookup_special_var(name)` calls return `None` instead of dispatching
12562/// to the getfn regenerator. Re-assigning the name (e.g. `RANDOM=42`)
12563/// should clear this flag via `clear_unset_special`.
12564pub fn mark_unset_special(name: &str) {
12565    if let Ok(mut s) = unset_specials().lock() {
12566        s.insert(name.to_string());
12567    }
12568}
12569
12570/// Clear the unset flag for a special-parameter NAME — called when the
12571/// name is re-assigned so the getfn regenerator becomes active again.
12572pub fn clear_unset_special(name: &str) {
12573    if let Ok(mut s) = unset_specials().lock() {
12574        s.remove(name);
12575    }
12576}
12577
12578fn is_unset_special(name: &str) -> bool {
12579    unset_specials()
12580        .lock()
12581        .map(|s| s.contains(name))
12582        .unwrap_or(false)
12583}
12584
12585/// Look up a special-parameter NAME and dispatch to its GSU getfn.
12586///
12587/// Returns `Some(value_string)` if `name` is one of zshrs's
12588/// recognized specials with a real GSU getfn; `None` otherwise
12589/// (caller should fall back to `variables.get`).
12590///
12591/// This is the bridge between the named getfn callbacks above
12592/// (uidgetfn / randomgetfn / etc.) and the shell's parameter-read
12593/// path. Mirrors the `Param.gsu->getfn` dispatch C zsh does
12594/// inside `getsparam` / `getstrvalue` (Src/params.c:3076 / 2335).
12595pub fn lookup_special_var(name: &str) -> Option<String> {
12596    // c:Src/params.c:3853 — PM_UNSET-flagged specials skip getfn.
12597    // Only applies to regenerator-style specials (RANDOM, SECONDS,
12598    // EPOCHSECONDS, TTYIDLE, ERRNO) — identity specials like UID, GID,
12599    // PPID stay live since they're not user-clearable in zsh either.
12600    //
12601    // Two sources of "is unset" — the side-set populated by
12602    // `mark_unset_special` from explicit `unset NAME`, and the
12603    // initial PM_UNSET flag set by `Src/params.c:298 IPDEF1(...,
12604    // PM_UNSET)`. ERRNO is the only IPDEF1-special with the initial
12605    // PM_UNSET flag (params.c:298) — zsh -fc reports `$ERRNO` as
12606    // empty because nothing has set errno since startup. Mirror by
12607    // also consulting the paramtab pm flags. Without this, `$ERRNO`
12608    // returned the live errno value instead of empty.
12609    if matches!(
12610        name,
12611        "RANDOM" | "SECONDS" | "EPOCHSECONDS" | "EPOCHREALTIME" | "TTYIDLE" | "ERRNO"
12612    ) && (is_unset_special(name) || {
12613        // c:Src/params.c — paramtab PM_UNSET check. ERRNO carries
12614        // this flag from IPDEF1 initialization (params.c:298); reads
12615        // route through getsparam → paramtab pm.flags check at
12616        // line 4356-4358 normally, but ERRNO/RANDOM/etc. short-
12617        // circuit through lookup_special_var BEFORE that check.
12618        paramtab()
12619            .read()
12620            .ok()
12621            .and_then(|t| t.get(name).map(|pm| (pm.node.flags as u32 & PM_UNSET) != 0))
12622            .unwrap_or(false)
12623    }) {
12624        return None;
12625    }
12626    // All-digit positional: $1..$N from canonical PPARAMS.
12627    // C zsh dispatches positional params through pparams (Src/init.c).
12628    if !name.is_empty() && name.chars().all(|c| c.is_ascii_digit()) {
12629        let n: usize = name.parse().ok()?;
12630        if n == 0 {
12631            return argzero();
12632        }
12633        let pp = pparams_lock().lock().ok()?;
12634        return pp.get(n - 1).cloned();
12635    }
12636    match name {
12637        // libc identity callbacks.
12638        "UID" => Some(uidgetfn().to_string()),
12639        "GID" => Some(gidgetfn().to_string()),
12640        "EUID" => Some(euidgetfn().to_string()),
12641        "EGID" => Some(egidgetfn().to_string()),
12642        // c:Src/params.c:350 `IPDEF4("PPID", &ppid)` — ppid is the
12643        // file-static set at shell startup from getppid(). zshrs's
12644        // ported special_paramdef list registers PPID but nothing
12645        // populates the paramtab slot from getppid(2), so $PPID
12646        // always read 0. Route through the libc syscall directly.
12647        "PPID" => Some((unsafe { libc::getppid() } as i64).to_string()),
12648        // libc syscall callbacks.
12649        "RANDOM" => Some(randomgetfn().to_string()),
12650        "TTYIDLE" => Some(ttyidlegetfn().to_string()),
12651        "ERRNO" => Some(errnogetfn().to_string()),
12652        // Time callbacks.
12653        "SECONDS" => {
12654            // c:Src/params.c:4561/4591 — PM_TYPE dispatches between
12655            // intsecondsgetfn (PM_INTEGER, default) and floatsecondsgetfn
12656            // (PM_EFLOAT/PM_FFLOAT after `typeset -F`/`-E SECONDS`). The
12657            // pm's gsu_i vs gsu_f vtable swap happens in setfn during
12658            // typeset; we read pm.flags from paramtab here.
12659            let pm_type = paramtab()
12660                .read()
12661                .ok()
12662                .and_then(|t| t.get("SECONDS").map(|pm| PM_TYPE(pm.node.flags as u32)))
12663                .unwrap_or(PM_INTEGER);
12664            if pm_type == PM_EFLOAT || pm_type == PM_FFLOAT {
12665                let v = floatsecondsgetfn();
12666                let base = paramtab()
12667                    .read()
12668                    .ok()
12669                    .and_then(|t| t.get("SECONDS").map(|pm| pm.base))
12670                    .unwrap_or(0);
12671                Some(convfloat(v, base, pm_type))
12672            } else {
12673                Some(intsecondsgetfn().to_string())
12674            }
12675        }
12676        // zsh/datetime module params. In recent zsh these are
12677        // auto-loaded via `zmodload zsh/datetime` and the param
12678        // appears in paramtab via the module's `p:NAME` feature
12679        // descriptor (Src/Modules/datetime.c:25). In zshrs the
12680        // datetime module's per-param wireup hasn't been ported
12681        // through paramtab yet — the getters exist
12682        // (modules::datetime::getcurrentsecs / getcurrentrealtime)
12683        // but no Param entry is created on `zmodload`. Mirror the C
12684        // behavior by routing the names through their canonical
12685        // getters here so `$EPOCHSECONDS` / `$EPOCHREALTIME` read
12686        // live time values regardless of explicit zmodload. p10k
12687        // and many other prompts rely on this without an explicit
12688        // zmodload (zsh ships datetime preloaded in most configs).
12689        "EPOCHSECONDS" => {
12690            // c:Src/Modules/datetime.c:206 `getcurrentsecs`. zsh requires
12691            // explicit `zmodload zsh/datetime` before EPOCHSECONDS is
12692            // bound (Src/Modules/datetime.c:25 `p:EPOCHSECONDS` feature
12693            // descriptor). Without the load, the name is unset and
12694            // ${EPOCHSECONDS:-x} falls through to "x". Match by gating
12695            // the getter on the module's loaded state. Bug #31 in
12696            // docs/BUGS.md.
12697            if !crate::ported::module::MODULESTAB
12698                .lock()
12699                .unwrap()
12700                .is_loaded("zsh/datetime")
12701            {
12702                return None;
12703            }
12704            Some(crate::ported::modules::datetime::getcurrentsecs().to_string())
12705        }
12706        "EPOCHREALTIME" => {
12707            // c:Src/Modules/datetime.c:212 `getcurrentrealtime`. Same
12708            // zsh/datetime gate as EPOCHSECONDS above. Bug #31.
12709            if !crate::ported::module::MODULESTAB
12710                .lock()
12711                .unwrap()
12712                .is_loaded("zsh/datetime")
12713            {
12714                return None;
12715            }
12716            let v = crate::ported::modules::datetime::getcurrentrealtime();
12717            Some(format!("{:.10}", v))
12718        }
12719        "epochtime" => {
12720            // c:Src/Modules/datetime.c:220 `getcurrenttime` returns
12721            // [tv_sec, tv_nsec]. Bare `$epochtime` joins the two with
12722            // ` ` (the default IFS separator), matching the C path
12723            // `getstrvalue` → sepjoin on PM_ARRAY. Bug #317 in
12724            // docs/BUGS.md. Gate on zsh/datetime load per #31.
12725            if !crate::ported::module::MODULESTAB
12726                .lock()
12727                .unwrap()
12728                .is_loaded("zsh/datetime")
12729            {
12730                return None;
12731            }
12732            let arr = crate::ported::modules::datetime::getcurrenttime();
12733            Some(arr.join(" "))
12734        }
12735        // Cached-state callbacks. C dispatches `pm->gsu.s->getfn(pm)`
12736        // where pm is `paramtab->getnode(name)`. Mirror: look up pm,
12737        // pass it through. Each getfn here ignores pm (matches C's
12738        // UNUSED(Param pm)), so a fallback default-constructed param
12739        // is acceptable when the table isn't populated yet.
12740        //
12741        // c:Src/params.c paramsubst c:3193 — the `vunset = (!v || ...)`
12742        // check inspects `pm->node.flags & PM_UNSET`, not the value
12743        // returned by getfn. For specials whose getfn reads global
12744        // cached state (`ifs`, `wordchars`, `keyboardhackchar`), the
12745        // global keeps its last value across `unset NAME` (because
12746        // stdunsetfn only flips PM_UNSET; it doesn't clear the global).
12747        // Without consulting PM_UNSET here, `${IFS+set}` after
12748        // `unset IFS` still returned the default-IFS getter result,
12749        // diverging from zsh.
12750        "USERNAME" | "HOME" | "TERM" | "WORDCHARS" | "IFS" | "TERMINFO" | "TERMINFO_DIRS"
12751        | "KEYBOARD_HACK" | "histchars" | "HISTCHARS" => {
12752            let tab = paramtab().read().ok()?;
12753            let pm = tab.get(name)?;
12754            if (pm.node.flags as u32 & crate::ported::zsh_h::PM_UNSET) != 0 {
12755                return None;
12756            }
12757            Some(match name {
12758                "USERNAME" => usernamegetfn(pm),
12759                "HOME" => homegetfn(pm),
12760                "TERM" => termgetfn(pm),
12761                "WORDCHARS" => wordcharsgetfn(pm),
12762                "IFS" => ifsgetfn(pm),
12763                "TERMINFO" => terminfogetfn(pm),
12764                "TERMINFO_DIRS" => terminfodirsgetfn(pm),
12765                "KEYBOARD_HACK" => keyboardhackgetfn(pm),
12766                "histchars" | "HISTCHARS" => histcharsgetfn(pm),
12767                _ => unreachable!(),
12768            })
12769        }
12770        "_" => Some(underscoregetfn()),
12771        // Counters with int return.
12772        "HISTSIZE" => Some(histsizegetfn().to_string()),
12773        "SAVEHIST" => Some(savehistsizegetfn().to_string()),
12774        "#" | "ARGC" => Some(poundgetfn().to_string()),
12775        // c:Src/params.c:871 — `setsparam("TIMEFMT", DEFAULT_TIMEFMT)`.
12776        // createparamtable seeds this; the zshrs main binary skips
12777        // that init path so paramtab is empty for TIMEFMT. Route
12778        // here from the canonical default. paramtab check first so
12779        // an explicit user-set value sticks.
12780        "TIMEFMT" => {
12781            let tab_val = paramtab()
12782                .read()
12783                .ok()
12784                .and_then(|t| t.get("TIMEFMT").and_then(|pm| pm.u_str.clone()));
12785            if let Some(v) = tab_val {
12786                if !v.is_empty() {
12787                    return Some(v);
12788                }
12789            }
12790            Some(crate::ported::zsh_system_h::DEFAULT_TIMEFMT.to_string())
12791        }
12792        // c:Src/init.c:1214-1215 — `nullcmd = ztrdup("cat");
12793        // readnullcmd = ztrdup(DEFAULT_READNULLCMD);`. C seeds these
12794        // into the REAL paramtab at startup; `unset NULLCMD` then
12795        // removes the entry and getsparam returns NULL — which is
12796        // load-bearing: A04redirect "null redir with NULLCMD unset"
12797        // requires `unset NULLCMD; >file` to error "redirection with
12798        // no command". The previous read-time fallback here faked the
12799        // seed on EVERY lookup, making unset impossible. The seed now
12800        // lives in ShellExecutor::new (vm_helper.rs, next to TIMEFMT)
12801        // and absent means absent.
12802        // $0 routes through utils::argzero.
12803        "0" => argzero(),
12804        // POSIX shell-special scalars. C dispatches these through
12805        // dedicated gsu getfn callbacks (Src/params.c special_assigns).
12806        // c:Src/params.c lastvalgetfn — `?` and `status` are aliases
12807        // for the last-command exit code (lastval). C wires them via
12808        // separate IPDEF entries that share the same getfn.
12809        "?" | "status" => Some(LASTVAL.load(Ordering::Relaxed).to_string()),
12810        // c:Src/loop.c:719 — `try_errflag = -1` reset before
12811        // each `{ try } always { catch }` block; reads `-1` when
12812        // outside a try block. zsh exposes TRY_BLOCK_ERROR as an
12813        // integer special-param: inside an always-arm after a
12814        // normal-exit try, reads 0; -1 only when no try has yet
12815        // fired in this scope. BUILTIN_SET_TRY_BLOCK_ERROR writes
12816        // via set_scalar (u_str), so accept either storage form
12817        // and treat a present-but-empty u_str as 0 too.
12818        "TRY_BLOCK_ERROR" => {
12819            // c:Src/loop.c:719 — `zlong try_errflag = -1;` global,
12820            // exported via IPDEF6 (c:Src/params.c:364) so `$TRY_BLOCK_ERROR`
12821            // reads it directly. Initialized to -1 (sentinel: "no try
12822            // block has fired yet"); the `exectry` always-arm sets it
12823            // to `errflag & ERRFLAG_ERROR` (c:765) before running the
12824            // body, then restores at c:787. Read straight from the
12825            // canonical atomic — paramtab's u_str / u_val are NOT the
12826            // source of truth (matches C's IPDEF6 getfn signature).
12827            Some(
12828                crate::ported::r#loop::try_errflag
12829                    .load(std::sync::atomic::Ordering::Relaxed)
12830                    .to_string(),
12831            )
12832        }
12833        "TRY_BLOCK_INTERRUPT" => {
12834            // c:Src/loop.c:727 — `zlong try_interrupt = -1;` global.
12835            // Same shape as TRY_BLOCK_ERROR.
12836            Some(
12837                crate::ported::r#loop::try_interrupt
12838                    .load(std::sync::atomic::Ordering::Relaxed)
12839                    .to_string(),
12840            )
12841        }
12842        "$" => Some(std::process::id().to_string()),
12843        "!" => {
12844            // c:Src/params.c:345 IPDEF4("!", &lastpid) — `$!` reads
12845            // directly from the `lastpid` atomic (Src/jobs.c:73).
12846            // The previous Rust port read from paramtab["!"].u_str,
12847            // which only had a value if something wrote to it via
12848            // setsparam/assignsparam — and `"!"` is not a valid
12849            // identifier (isident("!") == 0 per params.c:1288), so
12850            // those calls failed loudly. Read directly from the
12851            // canonical store like the C getter does.
12852            let pid =
12853                crate::ported::modules::clone::lastpid.load(std::sync::atomic::Ordering::Relaxed);
12854            Some(pid.to_string())
12855        }
12856        // $* / $@ join positional params via sepjoin's IFS default —
12857        // c:Src/utils.c:3936-3945: set-but-empty IFS joins with ""
12858        // (`IFS=""; echo "$*"` concatenates); unset IFS joins with
12859        // " ". The previous `.unwrap_or(' ')` collapsed empty-IFS to
12860        // a space.
12861        "*" | "@" => pparams_lock()
12862            .lock()
12863            .ok()
12864            .map(|p| crate::ported::utils::sepjoin(&p, None)),
12865        // $- : current option-letter set.
12866        // c:Src/params.c:3262 (IPDEF) → dashparamgetfn in options.c:890.
12867        // Canonical C body walks `zshletters[FIRST_OPT..=LAST_OPT]`
12868        // (c:292-368) emitting each letter whose mapped option is
12869        // active (XOR-ing with the c:295 negation prefix `-OPT`).
12870        // The previous Rust port hand-rolled a hardcoded subset
12871        // ("569X" + 8 ad-hoc letters) that diverged from C's table:
12872        // letter 'h' wrongly mapped to `hashall` (c:271 ALIAS for
12873        // HASHCMDS) instead of HISTIGNOREDUPS (c:349). Parity bug
12874        // #32 — `$-` last char differed (zsh `f`, zshrs `fh`).
12875        // Route through `dashgetfn` (options.rs:835) which is the
12876        // direct port of `Src/options.c:890`.
12877        "-" => Some(crate::ported::options::dashgetfn()),
12878        // Arrays — joined with space for scalar context.
12879        "pipestatus" => {
12880            let arr = pipestatgetfn();
12881            if arr.is_empty() {
12882                None
12883            } else {
12884                Some(arr.join(" "))
12885            }
12886        }
12887        _ => None,
12888    }
12889}
12890
12891/// Shared test mutex for histsiz mutations (gsu_tests +
12892/// tests submodules both write the same global; this lock
12893/// serialises them under parallel test execution).
12894#[cfg(test)]
12895pub(crate) static HISTSIZ_TEST_LOCK: Mutex<()> = Mutex::new(());
12896
12897/// Shared test mutex for histchars mutations (gsu_tests +
12898/// tests submodules both write bangchar/hatchar/hashchar atomics;
12899/// this lock serialises them under parallel test execution).
12900#[cfg(test)]
12901pub(crate) static HISTCHARS_TEST_LOCK_SHARED: Mutex<()> = Mutex::new(());
12902
12903#[cfg(test)]
12904mod gsu_tests {
12905    use super::*;
12906
12907    #[test]
12908    fn test_libc_id_callbacks_match_libc() {
12909        let _g = crate::test_util::global_state_lock();
12910        assert_eq!(uidgetfn(), unsafe { libc::getuid() } as i64);
12911        assert_eq!(gidgetfn(), unsafe { libc::getgid() } as i64);
12912        assert_eq!(euidgetfn(), unsafe { libc::geteuid() } as i64);
12913        assert_eq!(egidgetfn(), unsafe { libc::getegid() } as i64);
12914    }
12915
12916    /// Pin: `usernamegetfn` routes through `get_username()` per
12917    /// `Src/params.c:4658` (which refreshes cache on uid change
12918    /// per `Src/utils.c:1082`). The previous Rust port read a
12919    /// stale cached value directly. Verify the getter returns
12920    /// the same name as a direct libc `getpwuid(getuid())` —
12921    /// confirming the path WENT through the refresh helper, not
12922    /// the stale paramtab Mutex.
12923    #[test]
12924    fn usernamegetfn_matches_libc_getpwuid_for_current_uid() {
12925        let _g = crate::test_util::global_state_lock();
12926        let __pm = crate::ported::zsh_h::param::default();
12927        let uname = usernamegetfn(&__pm);
12928        // The current process is running as some uid; the getter
12929        // must return either a populated name OR an empty string
12930        // (when getpwuid fails, e.g. sandboxed builds). It must
12931        // NOT panic and must NOT return a stale cached value
12932        // from a different uid.
12933        let direct = unsafe {
12934            let pw = libc::getpwuid(libc::getuid());
12935            if pw.is_null() {
12936                String::new()
12937            } else {
12938                std::ffi::CStr::from_ptr((*pw).pw_name)
12939                    .to_string_lossy()
12940                    .into_owned()
12941            }
12942        };
12943        assert_eq!(
12944            uname, direct,
12945            "c:4658 — usernamegetfn must match getpwuid(getuid())->pw_name"
12946        );
12947    }
12948
12949    #[test]
12950    fn test_random_returns_15_bit_value() {
12951        let _g = crate::test_util::global_state_lock();
12952        for _ in 0..100 {
12953            let v = randomgetfn();
12954            assert!(v >= 0 && v < 0x8000);
12955        }
12956    }
12957
12958    #[test]
12959    fn test_random_set_seeds_deterministically() {
12960        let _g = crate::test_util::global_state_lock();
12961        randomsetfn(42);
12962        let a = randomgetfn();
12963        randomsetfn(42);
12964        let b = randomgetfn();
12965        assert_eq!(a, b);
12966    }
12967
12968    #[test]
12969    fn test_ifs_round_trip() {
12970        let _g = crate::test_util::global_state_lock();
12971        let mut __pm = crate::ported::zsh_h::param::default();
12972        let original = ifsgetfn(&__pm);
12973        ifssetfn(&mut __pm, ":,;".to_string());
12974        assert_eq!(ifsgetfn(&__pm), ":,;");
12975        ifssetfn(&mut __pm, original);
12976    }
12977
12978    #[test]
12979    fn test_histsiz_clamps_to_1() {
12980        let _g = crate::test_util::global_state_lock();
12981        let _g = HISTSIZ_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
12982        let original = histsizegetfn();
12983        histsizesetfn(0);
12984        assert_eq!(histsizegetfn(), 1);
12985        histsizesetfn(-5);
12986        assert_eq!(histsizegetfn(), 1);
12987        histsizesetfn(500);
12988        assert_eq!(histsizegetfn(), 500);
12989        histsizesetfn(original);
12990    }
12991
12992    #[test]
12993    fn test_savehistsiz_clamps_to_0() {
12994        let _g = crate::test_util::global_state_lock();
12995        let original = savehistsizegetfn();
12996        savehistsizesetfn(-5);
12997        assert_eq!(savehistsizegetfn(), 0);
12998        savehistsizesetfn(100);
12999        assert_eq!(savehistsizegetfn(), 100);
13000        savehistsizesetfn(original);
13001    }
13002
13003    /// Pin: `savehistsizesetfn` syncs BOTH storage mirrors so the
13004    /// twin-storage Rust adaptation behaves like the single global
13005    /// in C. The params.rs Mutex<i64> drives `$SAVEHIST` reads;
13006    /// the hist.rs AtomicI64 drives the history-file writer cap.
13007    /// Previously only the params.rs side was written, so
13008    /// `SAVEHIST=10000` left hist.rs at 0 and the writer would
13009    /// cap at zero lines.
13010    #[test]
13011    fn savehistsizesetfn_syncs_to_hist_module() {
13012        let _g = crate::test_util::global_state_lock();
13013        let original_params = savehistsizegetfn();
13014        let original_hist = savehistsiz.load(Ordering::SeqCst);
13015        // Set via the setfn — both storages must reflect the value.
13016        savehistsizesetfn(12345);
13017        assert_eq!(
13018            savehistsizegetfn(),
13019            12345,
13020            "c:4994 — params.rs Mutex<i64> reflects new value"
13021        );
13022        assert_eq!(
13023            savehistsiz.load(Ordering::SeqCst),
13024            12345,
13025            "c:4994 — hist.rs AtomicI64 synced (was the previous gap)"
13026        );
13027        // Negative clamps to 0 in BOTH stores.
13028        savehistsizesetfn(-99);
13029        assert_eq!(savehistsizegetfn(), 0, "c:4998 — params.rs clamps to 0");
13030        assert_eq!(
13031            savehistsiz.load(Ordering::SeqCst),
13032            0,
13033            "c:4998 — hist.rs clamps to 0 too"
13034        );
13035        // Restore.
13036        savehistsizesetfn(original_params);
13037        savehistsiz.store(original_hist, Ordering::SeqCst);
13038    }
13039
13040    #[test]
13041    fn test_pipestat_round_trip() {
13042        let _g = crate::test_util::global_state_lock();
13043        pipestatsetfn(Some(vec![
13044            "1".to_string(),
13045            "0".to_string(),
13046            "127".to_string(),
13047        ]));
13048        let v = pipestatgetfn();
13049        assert_eq!(v, vec!["1", "0", "127"]);
13050        pipestatsetfn(None);
13051        assert_eq!(pipestatgetfn(), Vec::<String>::new());
13052    }
13053
13054    /// Pin: `setnumvalue` actually STORES the scalar string per
13055    /// `Src/params.c:2862-2872`. The previous Rust port computed
13056    /// the string then dropped it via `let _ = s;` — meaning a
13057    /// numeric assignment to a SCALAR param stored NOTHING.
13058    ///
13059    /// C body for PM_SCALAR: `setstrvalue(v, convbase_underscore(
13060    /// val.u.l, pm->base, pm->width));`. We pin the round-trip
13061    /// for an integer assigned to a scalar param.
13062    #[test]
13063    fn setnumvalue_stores_int_value_into_scalar_pm() {
13064        let _g = crate::test_util::global_state_lock();
13065        // c:2860 — setnumvalue bails when unset(EXECOPT). The unit-test
13066        // env doesn't run through createoptiontable so we set "exec"
13067        // explicitly to simulate normal runtime.
13068        let saved_exec = opt_state_get("exec").unwrap_or(false);
13069        opt_state_set("exec", true);
13070        // Build a scalar Param with no special base/width.
13071        let mut pm = Box::new(param {
13072            node: hashnode {
13073                next: None,
13074                nam: "x".to_string(),
13075                flags: PM_SCALAR as i32,
13076            },
13077            u_data: 0,
13078            u_tied: None,
13079            u_arr: None,
13080            u_str: Some(String::new()),
13081            u_val: 0,
13082            u_dval: 0.0,
13083            u_hash: None,
13084            gsu_s: None,
13085            gsu_i: None,
13086            gsu_f: None,
13087            gsu_a: None,
13088            gsu_h: None,
13089            base: 0,
13090            width: 0,
13091            env: None,
13092            ename: None,
13093            old: None,
13094            level: 0,
13095        });
13096        let mut v = value {
13097            pm: Some(pm.clone()),
13098            arr: Vec::new(),
13099            scanflags: 0,
13100            valflags: 0,
13101            start: 0,
13102            end: -1,
13103        };
13104        let val = mnumber {
13105            l: 42,
13106            d: 0.0,
13107            type_: MN_INTEGER,
13108        };
13109        setnumvalue(Some(&mut v), val);
13110        // c:2871 — the scalar storage now holds "42".
13111        let stored = v.pm.as_ref().unwrap().u_str.clone().unwrap_or_default();
13112        assert_eq!(
13113            stored, "42",
13114            "c:2871 — setnumvalue must store the rendered integer; \
13115             was previously dropped via `let _ = s;`"
13116        );
13117        let _ = pm;
13118        opt_state_set("exec", saved_exec);
13119    }
13120
13121    #[test]
13122    fn test_simple_arrayuniq_first_wins() {
13123        let _g = crate::test_util::global_state_lock();
13124        let v = vec![
13125            "a".to_string(),
13126            "b".to_string(),
13127            "a".to_string(),
13128            "c".to_string(),
13129        ];
13130        assert_eq!(simple_arrayuniq(v), vec!["a", "b", "c"]);
13131    }
13132
13133    #[test]
13134    fn test_split_env_string() {
13135        let _g = crate::test_util::global_state_lock();
13136        assert_eq!(
13137            split_env_string("PATH=/usr/bin:/bin"),
13138            Some(("PATH".to_string(), "/usr/bin:/bin".to_string()))
13139        );
13140        assert_eq!(
13141            split_env_string("EMPTY="),
13142            Some(("EMPTY".to_string(), "".to_string()))
13143        );
13144        assert_eq!(split_env_string("NOEQUALS"), None);
13145    }
13146
13147    #[test]
13148    fn test_mkenvstr() {
13149        let _g = crate::test_util::global_state_lock();
13150        assert_eq!(mkenvstr("PATH", "/usr/bin", 0), "PATH=/usr/bin");
13151        assert_eq!(mkenvstr("EMPTY", "", 0), "EMPTY=");
13152    }
13153
13154    #[test]
13155    fn test_seconds_round_trip() {
13156        let _g = crate::test_util::global_state_lock();
13157        intsecondssetfn(0);
13158        let s1 = intsecondsgetfn();
13159        std::thread::sleep(Duration::from_millis(5));
13160        let s2 = intsecondsgetfn();
13161        assert!(s2 >= s1);
13162        // Reset to a known offset and read back.
13163        setrawseconds(100.0);
13164        assert_eq!(getrawseconds(), 100.0);
13165    }
13166
13167    #[test]
13168    fn test_argzero_round_trip() {
13169        let _g = crate::test_util::global_state_lock();
13170        // pm is UNUSED in argzerosetfn / argzerogetfn (C signature
13171        // matches Rust). Use Param::default() as the dummy carrier.
13172        let mut pm = param::default();
13173        argzerosetfn(&mut pm, "/bin/zsh".to_string());
13174        assert_eq!(argzerogetfn(&pm), "/bin/zsh");
13175        argzerosetfn(&mut pm, String::new());
13176    }
13177
13178    #[test]
13179    fn test_env_get_set() {
13180        let _g = crate::test_util::global_state_lock();
13181        let result = zputenv("ZSHRS_TEST_VAR=hello");
13182        assert_eq!(result, 0);
13183        assert_eq!(zgetenv("ZSHRS_TEST_VAR"), Some("hello".to_string()));
13184        delenv("ZSHRS_TEST_VAR");
13185        assert_eq!(zgetenv("ZSHRS_TEST_VAR"), None);
13186    }
13187
13188    #[test]
13189    fn test_keyboardhack_one_char() {
13190        let _g = crate::test_util::global_state_lock();
13191        let mut __pm = crate::ported::zsh_h::param::default();
13192        keyboardhacksetfn(&mut __pm, "\\".to_string());
13193        assert_eq!(keyboardhackgetfn(&__pm), "\\");
13194        keyboardhacksetfn(&mut __pm, String::new());
13195        assert_eq!(keyboardhackgetfn(&__pm), "");
13196    }
13197
13198    /// Pin: `keyboardhacksetfn` accepts ASCII chars cleanly per
13199    /// `Src/params.c:5040-5060`. Tests the canonical happy path
13200    /// — single ASCII char, empty input, and the ASCII guard.
13201    ///
13202    /// The previous Rust port skipped `unmetafy(x, &len)` (c:5044)
13203    /// before the length and ASCII checks. This test exercises
13204    /// the surface API; the unmetafy fix is doc-pinned in the
13205    /// fn body since constructing Meta-encoded String values for
13206    /// the test fixture would require unsafe (Rust strings must
13207    /// be valid UTF-8 and the Meta byte 0x83 is not a valid
13208    /// UTF-8 lead).
13209    #[test]
13210    fn keyboardhacksetfn_handles_ascii_and_empty() {
13211        let _g = crate::test_util::global_state_lock();
13212        let mut __pm = crate::ported::zsh_h::param::default();
13213        // c:5056 — single ASCII char stored.
13214        keyboardhacksetfn(&mut __pm, ";".to_string());
13215        assert_eq!(
13216            keyboardhackgetfn(&__pm),
13217            ";",
13218            "c:5056 — single ASCII char stored verbatim"
13219        );
13220        // c:5056 — different ASCII char stored.
13221        keyboardhacksetfn(&mut __pm, ",".to_string());
13222        assert_eq!(keyboardhackgetfn(&__pm), ",");
13223        // c:5058 — empty input clears to '\0'.
13224        keyboardhacksetfn(&mut __pm, String::new());
13225        assert_eq!(keyboardhackgetfn(&__pm), "");
13226    }
13227
13228    #[test]
13229    fn test_histchars_default() {
13230        let _g = crate::test_util::global_state_lock();
13231        let _g = HISTCHARS_TEST_LOCK_SHARED
13232            .lock()
13233            .unwrap_or_else(|e| e.into_inner());
13234        histcharssetfn(&mut param::default(), String::new());
13235        assert_eq!(histcharsgetfn(&param::default()), "!^#");
13236        histcharssetfn(&mut param::default(), "@$&".to_string());
13237        assert_eq!(histcharsgetfn(&param::default()), "@$&");
13238        histcharssetfn(&mut param::default(), String::new());
13239    }
13240
13241    /// Pin: `histcharssetfn` runs `unmetafy` per Src/params.c:5086
13242    /// BEFORE the length truncation and ASCII guard. Previously
13243    /// the Rust port skipped unmetafy, so a Meta-pair would
13244    /// inflate the byte count past 3 and the truncation would
13245    /// drop valid characters.
13246    ///
13247    /// Test the happy path: 1-char, 2-char, 3-char ASCII inputs
13248    /// all parse correctly and each char-position fills the
13249    /// matching atomic.
13250    #[test]
13251    fn histcharssetfn_handles_1_2_3_char_inputs() {
13252        let _g = crate::test_util::global_state_lock();
13253        let _g = HISTCHARS_TEST_LOCK_SHARED
13254            .lock()
13255            .unwrap_or_else(|e| e.into_inner());
13256        // 1-char: bangchar=='Q', hatchar=='\0', hashchar=='\0'.
13257        histcharssetfn(&mut param::default(), "Q".to_string());
13258        assert_eq!(bangchar.load(Ordering::SeqCst), b'Q' as i32);
13259        assert_eq!(hatchar.load(Ordering::SeqCst), 0);
13260        assert_eq!(hashchar.load(Ordering::SeqCst), 0);
13261        // 2-char: bangchar=='X', hatchar=='Y', hashchar=='\0'.
13262        histcharssetfn(&mut param::default(), "XY".to_string());
13263        assert_eq!(bangchar.load(Ordering::SeqCst), b'X' as i32);
13264        assert_eq!(hatchar.load(Ordering::SeqCst), b'Y' as i32);
13265        assert_eq!(hashchar.load(Ordering::SeqCst), 0);
13266        // 3-char: bangchar=='A', hatchar=='B', hashchar=='C'.
13267        histcharssetfn(&mut param::default(), "ABC".to_string());
13268        assert_eq!(bangchar.load(Ordering::SeqCst), b'A' as i32);
13269        assert_eq!(hatchar.load(Ordering::SeqCst), b'B' as i32);
13270        assert_eq!(hashchar.load(Ordering::SeqCst), b'C' as i32);
13271        // 4+ char: c:5087-5088 truncates to 3.
13272        histcharssetfn(&mut param::default(), "WXYZ".to_string());
13273        assert_eq!(bangchar.load(Ordering::SeqCst), b'W' as i32);
13274        assert_eq!(hatchar.load(Ordering::SeqCst), b'X' as i32);
13275        assert_eq!(hashchar.load(Ordering::SeqCst), b'Y' as i32);
13276        // Reset to default.
13277        histcharssetfn(&mut param::default(), String::new());
13278        assert_eq!(bangchar.load(Ordering::SeqCst), b'!' as i32);
13279        assert_eq!(hatchar.load(Ordering::SeqCst), b'^' as i32);
13280        assert_eq!(hashchar.load(Ordering::SeqCst), b'#' as i32);
13281    }
13282}
13283
13284// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
13285// ─── RUST-ONLY ACCESSORS ───
13286//
13287// Singleton accessor ported for `OnceLock<Mutex<T>>` / `OnceLock<
13288// RwLock<T>>` globals declared above. C zsh uses direct global
13289// access; Rust needs these wrappers because `OnceLock::get_or_init`
13290// is the only way to lazily construct shared state. These ported sit
13291// here so the body of this file reads in C source order without
13292// the accessor wrappers interleaved between real port ported.
13293// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
13294
13295// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
13296// ─── RUST-ONLY ACCESSORS ───
13297//
13298// Singleton accessor ported for `OnceLock<Mutex<T>>` / `OnceLock<
13299// RwLock<T>>` globals declared above. C zsh uses direct global
13300// access; Rust needs these wrappers because `OnceLock::get_or_init`
13301// is the only way to lazily construct shared state. These ported sit
13302// here so the body of this file reads in C source order without
13303// the accessor wrappers interleaved between real port ported.
13304// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
13305
13306// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
13307// ─── RUST-ONLY ACCESSORS ───
13308//
13309// Singleton accessor ported for `OnceLock<Mutex<T>>` / `OnceLock<
13310// RwLock<T>>` globals declared above. C zsh uses direct global
13311// access; Rust needs these wrappers because `OnceLock::get_or_init`
13312// is the only way to lazily construct shared state. These ported sit
13313// here so the body of this file reads in C source order without
13314// the accessor wrappers interleaved between real port ported.
13315// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
13316
13317fn foundparam_lock() -> &'static Mutex<Option<String>> {
13318    FOUNDPARAM.get_or_init(|| Mutex::new(None))
13319}
13320
13321/// Accessor for the global `paramtab` (Src/params.c:515).
13322/// Mirrors C's `paramtab->...` dereference by handing back the
13323/// inner RwLock; callers `.read()` for lookups and `.write()` for
13324/// mutation, operating on the `HashMap<String, Param>` directly.
13325pub fn paramtab() -> &'static RwLock<crate::fast_hash::FastMap<String, Param>> {
13326    PARAMTAB_INNER.get_or_init(|| RwLock::new(crate::fast_hash::FastMap::default()))
13327}
13328
13329/// Accessor for the global `realparamtab` (Src/params.c:515).
13330/// Same role as `paramtab` for the not-currently-redirected case;
13331/// the alias-flip during assoc-array iteration isn't modelled yet.
13332pub fn realparamtab() -> &'static RwLock<crate::fast_hash::FastMap<String, Param>> {
13333    REALPARAMTAB_INNER.get_or_init(|| RwLock::new(crate::fast_hash::FastMap::default()))
13334}
13335
13336fn scanprog_lock() -> &'static Mutex<Option<String>> {
13337    SCANPROG.get_or_init(|| Mutex::new(None))
13338}
13339
13340fn scanstr_lock() -> &'static Mutex<Option<String>> {
13341    SCANSTR.get_or_init(|| Mutex::new(None))
13342}
13343
13344fn paramvals_lock() -> &'static Mutex<Vec<String>> {
13345    PARAMVALS.get_or_init(|| Mutex::new(Vec::new()))
13346}
13347
13348#[cfg(test)]
13349mod tests {
13350    use super::*;
13351    use crate::ported::zsh_h::Pound;
13352    use crate::zsh_h::hashnode;
13353
13354    /// `setscope_base` pushes the param name onto `SCOPEREFS[base]`
13355    /// when `base > pm.level` (c:6440). Grows the SCOPEREFS Vec as
13356    /// needed (c:6442-6447).
13357    #[test]
13358    fn setscope_base_pushes_name_when_base_above_level() {
13359        let _g = crate::test_util::global_state_lock();
13360        SCOPEREFS.with(|s| s.borrow_mut().clear());
13361        let mut pm = param {
13362            node: hashnode {
13363                next: None,
13364                nam: "foo".to_string(),
13365                flags: 0,
13366            },
13367            u_data: 0,
13368            u_tied: None,
13369            u_arr: None,
13370            u_str: None,
13371            u_val: 0,
13372            u_dval: 0.0,
13373            u_hash: None,
13374            gsu_s: None,
13375            gsu_i: None,
13376            gsu_f: None,
13377            gsu_a: None,
13378            gsu_h: None,
13379            base: 0,
13380            width: 0,
13381            env: None,
13382            ename: None,
13383            old: None,
13384            level: 2,
13385        };
13386        setscope_base(&mut pm, 5);
13387        assert_eq!(pm.base, 5);
13388        SCOPEREFS.with(|s| {
13389            let s = s.borrow();
13390            assert!(s.len() >= 6, "SCOPEREFS grew to fit index 5");
13391            assert_eq!(s[5], vec!["foo".to_string()]);
13392        });
13393    }
13394
13395    /// `assignaparam` rejects slice into PM_HASHED with the canonical
13396    /// "attempt to set slice of associative array" zerr (c:3386-3390).
13397    #[test]
13398    fn assignaparam_rejects_slice_into_hashed() {
13399        let _g = crate::test_util::global_state_lock();
13400        opt_state_set("exec", true);
13401        unsetparam("aa_h");
13402        // Create a hashed param.
13403        sethparam("aa_h", vec!["k".to_string(), "v".to_string()]);
13404        let before = paramtab().read().unwrap().contains_key("aa_h");
13405        assert!(before);
13406        // Slice write should be rejected.
13407        let result = assignaparam("aa_h[idx]", vec!["x".to_string()], 0);
13408        assert!(result.is_none(), "slice into hashed must return None");
13409        unsetparam("aa_h");
13410        opt_state_set("exec", false);
13411    }
13412
13413    /// `assignaparam` on a PM_NAMEREF param resolves the chain first
13414    /// (fetchvalue at c:3392): a ref bound to a not-yet-defined name
13415    /// REDIRECTS the array assignment, creating the TARGET as an
13416    /// array (K01nameref.ztst "assign new array via nameref":
13417    /// `typeset -n ptr=var; ptr=(val1 val2)` → `typeset -g -a var`).
13418    /// The "can't change type of a named reference" rejection
13419    /// (c:3395-3398) only fires for PLACEHOLDER refs (empty refname),
13420    /// covered by the K01 test-setting-ref matrix.
13421    #[test]
13422    fn assignaparam_rejects_nameref_type_change() {
13423        let _g = crate::test_util::global_state_lock();
13424        opt_state_set("exec", true);
13425        unsetparam("aa_nr");
13426        // Insert a PM_NAMEREF param directly.
13427        let pm = param {
13428            node: hashnode {
13429                next: None,
13430                nam: "aa_nr".to_string(),
13431                flags: (PM_NAMEREF | PM_SCALAR) as i32,
13432            },
13433            u_data: 0,
13434            u_tied: None,
13435            u_arr: None,
13436            u_str: Some("target".to_string()),
13437            u_val: 0,
13438            u_dval: 0.0,
13439            u_hash: None,
13440            gsu_s: None,
13441            gsu_i: None,
13442            gsu_f: None,
13443            gsu_a: None,
13444            gsu_h: None,
13445            base: 0,
13446            width: 0,
13447            env: None,
13448            ename: None,
13449            old: None,
13450            level: 0,
13451        };
13452        paramtab()
13453            .write()
13454            .unwrap()
13455            .insert("aa_nr".to_string(), Box::new(pm));
13456
13457        unsetparam("target");
13458        let result = assignaparam("aa_nr", vec!["a".to_string(), "b".to_string()], 0);
13459        assert!(
13460            result.is_some(),
13461            "assignment redirects to the ref's target (c:3392 fetchvalue resolve)"
13462        );
13463
13464        // PM_NAMEREF flag still present on the REF (not stripped).
13465        let pm = paramtab().read().unwrap().get("aa_nr").cloned().unwrap();
13466        assert_ne!(pm.node.flags as u32 & PM_NAMEREF, 0, "PM_NAMEREF preserved");
13467        assert_eq!(
13468            pm.u_str.as_deref(),
13469            Some("target"),
13470            "refname unchanged by assignment-through"
13471        );
13472        // The TARGET was created as an array with the value.
13473        let t = paramtab().read().unwrap().get("target").cloned().unwrap();
13474        assert_eq!(
13475            t.u_arr.as_deref(),
13476            Some(&["a".to_string(), "b".to_string()][..]),
13477            "target holds the assigned array"
13478        );
13479
13480        unsetparam("target");
13481        unsetparam("aa_nr");
13482        opt_state_set("exec", false);
13483    }
13484
13485    /// `assignaparam` with ASSPM_AUGMENT against a scalar param must
13486    /// prepend the previous scalar value as `val[0]` of the new array
13487    /// (c:3404-3412). Implements `a=x; a+=(y z)` → `a=(x y z)`. A
13488    /// regression that drops the prepend would yield `a=(y z)` and
13489    /// silently lose the original scalar — invisible at write time,
13490    /// surfaces only when the caller reads `${a[1]}`.
13491    #[test]
13492    fn assignaparam_augment_prepends_old_scalar() {
13493        let _g = crate::test_util::global_state_lock();
13494        opt_state_set("exec", true);
13495        unsetparam("aa_aug");
13496        // Seed a scalar.
13497        setsparam("aa_aug", "old");
13498        // Augment with two new values.
13499        let pm = assignaparam(
13500            "aa_aug",
13501            vec!["new1".to_string(), "new2".to_string()],
13502            ASSPM_AUGMENT,
13503        )
13504        .expect("augment should succeed");
13505        let arr = pm.u_arr.expect("ASSPM_AUGMENT must produce u_arr");
13506        assert_eq!(
13507            arr,
13508            vec!["old".to_string(), "new1".to_string(), "new2".to_string()],
13509            "c:3408-3411 — scalar prepended at index 0, then new values follow"
13510        );
13511        unsetparam("aa_aug");
13512        opt_state_set("exec", false);
13513    }
13514
13515    /// `assignaparam` against a PM_UNIQUE-flagged target dedupes
13516    /// the value array (c:3401 + arrsetfn's uniqarray). Implements
13517    /// `typeset -U arr; arr=(a b a c b)` → `arr=(a b c)`. A
13518    /// regression that drops the uniqarray call would let duplicates
13519    /// linger — invisible until a downstream `[[ -n ${arr[(r)b]} ]]`
13520    /// check counts more matches than expected, or `$path` grows
13521    /// unbounded with repeated directory entries.
13522    #[test]
13523    fn assignaparam_unique_flag_dedupes_values() {
13524        let _g = crate::test_util::global_state_lock();
13525        opt_state_set("exec", true);
13526        unsetparam("aa_uniq");
13527        // Seed an empty PM_ARRAY|PM_UNIQUE.
13528        setaparam("aa_uniq", vec![]);
13529        {
13530            let mut tab = paramtab().write().unwrap();
13531            let pm = tab.get_mut("aa_uniq").expect("aa_uniq must exist");
13532            pm.node.flags |= PM_UNIQUE as i32;
13533        }
13534        // Now write duplicates; PM_UNIQUE must collapse them.
13535        let pm = assignaparam(
13536            "aa_uniq",
13537            vec!["a".into(), "b".into(), "a".into(), "c".into(), "b".into()],
13538            0,
13539        )
13540        .expect("assignment succeeds");
13541        let arr = pm.u_arr.expect("u_arr populated");
13542        assert_eq!(
13543            arr,
13544            vec!["a".to_string(), "b".to_string(), "c".to_string()],
13545            "c:3401 — PM_UNIQUE collapses duplicates, keeping first occurrence"
13546        );
13547        // The PM_UNIQUE flag must persist through the assignment.
13548        let pm_check = paramtab().read().unwrap().get("aa_uniq").cloned().unwrap();
13549        assert_ne!(
13550            pm_check.node.flags as u32 & PM_UNIQUE,
13551            0,
13552            "PM_UNIQUE flag preserved across assignment"
13553        );
13554        unsetparam("aa_uniq");
13555        opt_state_set("exec", false);
13556    }
13557
13558    /// `getsparam` reads PM_INTEGER params via convbase, not via
13559    /// `u_str` (which is None for typed integers). Pins the latent
13560    /// bug fix that made `read REPLY` after `(( REPLY=42 ))` return
13561    /// nothing — every numeric param read used to fall through to
13562    /// the OS env-var fallback.
13563    #[test]
13564    fn getsparam_returns_integer_via_convbase() {
13565        let _g = crate::test_util::global_state_lock();
13566        opt_state_set("exec", true);
13567        unsetparam("gs_int");
13568        setiparam("gs_int", 999);
13569        assert_eq!(getsparam("gs_int").as_deref(), Some("999"));
13570        unsetparam("gs_int");
13571        opt_state_set("exec", false);
13572    }
13573
13574    /// `getsparam` reads PM_FFLOAT params via convfloat. Same fix
13575    /// shape as the PM_INTEGER path.
13576    #[test]
13577    fn getsparam_returns_float_via_convfloat() {
13578        let _g = crate::test_util::global_state_lock();
13579        opt_state_set("exec", true);
13580        unsetparam("gs_f");
13581        // Stash a float via the setnumvalue / setnparam path.
13582        let v = mnumber {
13583            l: 0,
13584            d: 2.5,
13585            type_: MN_FLOAT,
13586        };
13587        setnparam("gs_f", v);
13588        let s = getsparam("gs_f").expect("PM_FFLOAT should serialize");
13589        // convfloat formats with default precision; just check it's
13590        // not empty and parses back.
13591        assert!(
13592            s.parse::<f64>()
13593                .map(|f| (f - 2.5).abs() < 1e-6)
13594                .unwrap_or(false),
13595            "expected ~2.5 round-trip, got {:?}",
13596            s
13597        );
13598        unsetparam("gs_f");
13599        opt_state_set("exec", false);
13600    }
13601
13602    /// `getsparam` against a non-default `pm.base` integer param must
13603    /// render using that base via `convbase`. The c:2364 dispatch
13604    /// passes `pm.base` (or 10) to convbase; a regression that
13605    /// hardcodes base=10 would silently break `typeset -i 16 hex=255`
13606    /// readers (would see "255" instead of the C-faithful "16#FF").
13607    #[test]
13608    fn getsparam_integer_honors_pm_base() {
13609        let _g = crate::test_util::global_state_lock();
13610        opt_state_set("exec", true);
13611        unsetparam("hex_param");
13612        // Manually insert a PM_INTEGER param with base=16.
13613        let pm = param {
13614            node: hashnode {
13615                next: None,
13616                nam: "hex_param".to_string(),
13617                flags: PM_INTEGER as i32,
13618            },
13619            u_data: 0,
13620            u_tied: None,
13621            u_arr: None,
13622            u_str: None,
13623            u_val: 255,
13624            u_dval: 0.0,
13625            u_hash: None,
13626            gsu_s: None,
13627            gsu_i: None,
13628            gsu_f: None,
13629            gsu_a: None,
13630            gsu_h: None,
13631            base: 16,
13632            width: 0,
13633            env: None,
13634            ename: None,
13635            old: None,
13636            level: 0,
13637        };
13638        paramtab()
13639            .write()
13640            .unwrap()
13641            .insert("hex_param".to_string(), Box::new(pm));
13642
13643        let s = getsparam("hex_param").expect("PM_INTEGER must serialize");
13644        // convbase emits "16#FF" form for base 16.
13645        assert!(
13646            s.contains("FF") || s.contains("ff"),
13647            "c:2364 — base-16 must render hex digits; got {:?}",
13648            s
13649        );
13650
13651        unsetparam("hex_param");
13652        opt_state_set("exec", false);
13653    }
13654
13655    /// `endparamscope` clears `SCOPEREFS[old_locallevel]` and resets
13656    /// nameref params' base when their base exceeds the new locallevel.
13657    /// End-to-end of the setscope_base writer + endparamscope reader.
13658    #[test]
13659    fn endparamscope_resets_scoperefs_and_nameref_base() {
13660        let _g = crate::test_util::global_state_lock();
13661        SCOPEREFS.with(|s| s.borrow_mut().clear());
13662
13663        // Set up: locallevel = 3, push a PM_NAMEREF param with base=5 onto SCOPEREFS[3].
13664        set_locallevel(3);
13665        let pm = param {
13666            node: hashnode {
13667                next: None,
13668                nam: "ref1".to_string(),
13669                flags: (PM_NAMEREF | PM_SCALAR) as i32,
13670            },
13671            u_data: 0,
13672            u_tied: None,
13673            u_arr: None,
13674            u_str: Some(String::new()),
13675            u_val: 0,
13676            u_dval: 0.0,
13677            u_hash: None,
13678            gsu_s: None,
13679            gsu_i: None,
13680            gsu_f: None,
13681            gsu_a: None,
13682            gsu_h: None,
13683            base: 5,
13684            width: 0,
13685            env: None,
13686            ename: None,
13687            old: None,
13688            level: 0,
13689        };
13690        paramtab()
13691            .write()
13692            .unwrap()
13693            .insert("ref1".to_string(), Box::new(pm));
13694        // Populate SCOPEREFS[3] manually with "ref1".
13695        SCOPEREFS.with(|sr| {
13696            let mut sr = sr.borrow_mut();
13697            sr.resize(8, Vec::new());
13698            sr[3].push("ref1".to_string());
13699        });
13700
13701        endparamscope();
13702
13703        // After endparamscope: locallevel decremented to 2; "ref1"'s
13704        // base reset to 0 (was 5 > new ll=2). SCOPEREFS[3] cleared.
13705        let pm_after = paramtab().read().unwrap().get("ref1").cloned();
13706        assert!(pm_after.is_some(), "ref1 should still exist (level=0)");
13707        assert_eq!(pm_after.unwrap().base, 0, "PM_NAMEREF.base reset to 0");
13708        SCOPEREFS.with(|sr| {
13709            assert!(sr.borrow()[3].is_empty(), "SCOPEREFS[3] cleared");
13710        });
13711
13712        // Cleanup
13713        paramtab().write().unwrap().remove("ref1");
13714        set_locallevel(0);
13715    }
13716
13717    /// `endparamscope` MUST NOT reset `base` on PM_UPPER namerefs
13718    /// (c:5891 — the `!(pm->node.flags & PM_UPPER)` clause guards
13719    /// the reset). PM_UPPER namerefs point UPWARD in the scope chain
13720    /// (e.g. `typeset -n -u up=outer` from inside a function) and
13721    /// their base must persist across scope pops so the upward
13722    /// resolution keeps working. A regression that drops the PM_UPPER
13723    /// guard would silently degrade upward namerefs into local ones
13724    /// after the first function return.
13725    #[test]
13726    fn endparamscope_preserves_pm_upper_nameref_base() {
13727        let _g = crate::test_util::global_state_lock();
13728        SCOPEREFS.with(|s| s.borrow_mut().clear());
13729
13730        set_locallevel(3);
13731        let pm = param {
13732            node: hashnode {
13733                next: None,
13734                nam: "up_ref".to_string(),
13735                flags: (PM_NAMEREF | PM_SCALAR | PM_UPPER) as i32,
13736            },
13737            u_data: 0,
13738            u_tied: None,
13739            u_arr: None,
13740            u_str: Some(String::new()),
13741            u_val: 0,
13742            u_dval: 0.0,
13743            u_hash: None,
13744            gsu_s: None,
13745            gsu_i: None,
13746            gsu_f: None,
13747            gsu_a: None,
13748            gsu_h: None,
13749            base: 5,
13750            width: 0,
13751            env: None,
13752            ename: None,
13753            old: None,
13754            level: 0,
13755        };
13756        paramtab()
13757            .write()
13758            .unwrap()
13759            .insert("up_ref".to_string(), Box::new(pm));
13760        SCOPEREFS.with(|sr| {
13761            let mut sr = sr.borrow_mut();
13762            sr.resize(8, Vec::new());
13763            sr[3].push("up_ref".to_string());
13764        });
13765
13766        endparamscope();
13767
13768        let pm_after = paramtab()
13769            .read()
13770            .unwrap()
13771            .get("up_ref")
13772            .cloned()
13773            .expect("up_ref must survive scope pop");
13774        assert_eq!(
13775            pm_after.base, 5,
13776            "c:5891 — PM_UPPER nameref base MUST be preserved (was 5, must stay 5)"
13777        );
13778        assert_ne!(
13779            pm_after.node.flags as u32 & PM_UPPER,
13780            0,
13781            "PM_UPPER flag itself must persist"
13782        );
13783
13784        paramtab().write().unwrap().remove("up_ref");
13785        set_locallevel(0);
13786    }
13787
13788    /// `setscope_base` does NOT push when `base <= pm.level` (the C
13789    /// `if ((pm->base = base) > pm->level)` guard at c:6440 fails).
13790    #[test]
13791    fn setscope_base_no_push_when_base_below_level() {
13792        let _g = crate::test_util::global_state_lock();
13793        SCOPEREFS.with(|s| s.borrow_mut().clear());
13794        let mut pm = param {
13795            node: hashnode {
13796                next: None,
13797                nam: "bar".to_string(),
13798                flags: 0,
13799            },
13800            u_data: 0,
13801            u_tied: None,
13802            u_arr: None,
13803            u_str: None,
13804            u_val: 0,
13805            u_dval: 0.0,
13806            u_hash: None,
13807            gsu_s: None,
13808            gsu_i: None,
13809            gsu_f: None,
13810            gsu_a: None,
13811            gsu_h: None,
13812            base: 0,
13813            width: 0,
13814            env: None,
13815            ename: None,
13816            old: None,
13817            level: 10,
13818        };
13819        setscope_base(&mut pm, 3); // 3 <= 10 → no push
13820        assert_eq!(pm.base, 3);
13821        SCOPEREFS.with(|s| {
13822            // No push happened; SCOPEREFS stays empty.
13823            assert!(s.borrow().is_empty() || s.borrow().iter().all(|v| v.is_empty()));
13824        });
13825    }
13826
13827    /// `setscope_base` boundary: `base == pm.level` must NOT push.
13828    /// The C guard c:6440 is strictly `>` (`> pm->level`, not `>=`).
13829    /// A regression that uses `>=` would push every assignment at
13830    /// the current scope into SCOPEREFS, causing endparamscope to
13831    /// re-process every same-scope param on every function return —
13832    /// O(n) extra work per call PLUS spurious base resets.
13833    #[test]
13834    fn setscope_base_equal_level_does_not_push() {
13835        let _g = crate::test_util::global_state_lock();
13836        SCOPEREFS.with(|s| s.borrow_mut().clear());
13837        let mut pm = param {
13838            node: hashnode {
13839                next: None,
13840                nam: "edge".to_string(),
13841                flags: 0,
13842            },
13843            u_data: 0,
13844            u_tied: None,
13845            u_arr: None,
13846            u_str: None,
13847            u_val: 0,
13848            u_dval: 0.0,
13849            u_hash: None,
13850            gsu_s: None,
13851            gsu_i: None,
13852            gsu_f: None,
13853            gsu_a: None,
13854            gsu_h: None,
13855            base: 0,
13856            width: 0,
13857            env: None,
13858            ename: None,
13859            old: None,
13860            level: 5,
13861        };
13862        setscope_base(&mut pm, 5); // base == level → strict `>` fails
13863        assert_eq!(pm.base, 5, "base assignment always happens");
13864        SCOPEREFS.with(|sr| {
13865            let any_push = sr.borrow().iter().any(|v| !v.is_empty());
13866            assert!(
13867                !any_push,
13868                "c:6440 — `base > pm->level` is STRICT; equal must not push"
13869            );
13870        });
13871    }
13872
13873    #[test]
13874    fn test_colonarr_conversion() {
13875        let _g = crate::test_util::global_state_lock();
13876        let arr = colonsplit("/bin:/usr/bin:/usr/local/bin", false);
13877        assert_eq!(arr, vec!["/bin", "/usr/bin", "/usr/local/bin"]);
13878        let path = colonarrgetfn(&arr);
13879        assert_eq!(path, "/bin:/usr/bin:/usr/local/bin");
13880    }
13881    #[test]
13882    fn test_isident() {
13883        let _g = crate::test_util::global_state_lock();
13884        assert!(isident("foo"));
13885        assert!(isident("_bar"));
13886        assert!(isident("FOO_BAR"));
13887        assert!(isident("x123"));
13888        assert!(isident("123")); // positional params
13889        assert!(!isident(""));
13890        assert!(!isident("foo bar"));
13891    }
13892
13893    /// Pin: `isident` requires balanced `[...]` per `Src/params.c:1329-1330`:
13894    ///   if (*ss != '[') return 0;
13895    ///   if (!(ss = parse_subscript(++ss, 1, ']'))) return 0;
13896    ///
13897    /// The previous Rust port accepted ANY `[` as a valid
13898    /// terminator (`if c == '[' { return true; }`) without
13899    /// checking for a matching `]`. So `foo[` (no close) was
13900    /// accepted as a valid identifier — diverging from C which
13901    /// rejects.
13902    #[test]
13903    fn isident_requires_balanced_subscript_brackets() {
13904        let _g = crate::test_util::global_state_lock();
13905        // Balanced `[...]` is valid.
13906        assert!(
13907            isident("foo[0]"),
13908            "c:1330 — balanced [0] passes parse_subscript"
13909        );
13910        assert!(
13911            isident("foo[bar]"),
13912            "c:1330 — balanced [bar] passes parse_subscript"
13913        );
13914        // UNBALANCED — open without close — must be rejected.
13915        assert!(
13916            !isident("foo["),
13917            "c:1330 — `foo[` missing `]` MUST be rejected"
13918        );
13919        // Trailing chars after `]` — C parse_subscript returns
13920        // a position INSIDE the string, the surrounding isident
13921        // body checks that nothing follows; our port currently
13922        // returns true at the first `[` either way, but pin the
13923        // balanced case as a working invariant.
13924        assert!(isident("a[1]"), "c:1330 — short balanced subscript valid");
13925    }
13926
13927    #[test]
13928    fn test_unique_array() {
13929        let _g = crate::test_util::global_state_lock();
13930        let arr = vec!["a".into(), "b".into(), "a".into(), "c".into(), "b".into()];
13931        let result = uniqarray(arr);
13932        assert_eq!(result, vec!["a", "b", "c"]);
13933    }
13934
13935    #[test]
13936    fn test_convbase() {
13937        let _g = crate::test_util::global_state_lock();
13938        // CBASES off (default): `16#FF` / `8#7` form. The `0x.../
13939        // 0...` short-prefix output is gated on `setopt CBASES` —
13940        // see Src/params.c:5599-5605.
13941        assert_eq!(convbase(255, 16), "16#FF");
13942        assert_eq!(convbase(10, 10), "10");
13943        assert_eq!(convbase(-5, 10), "-5");
13944        assert_eq!(convbase(7, 8), "8#7");
13945        assert_eq!(convbase(5, 2), "2#101");
13946    }
13947
13948    #[test]
13949    fn test_convfloat() {
13950        let _g = crate::test_util::global_state_lock();
13951        // Use 2.5 instead of 3.14 — clippy errors on the latter as
13952        // an approx PI constant. The test checks 2-decimal formatting
13953        // round-trips, which the exact value doesn't influence.
13954        let s = convfloat(2.5, 2, PM_FFLOAT);
13955        assert!(s.starts_with("2.50"));
13956
13957        assert_eq!(convfloat(f64::INFINITY, 0, 0), "Inf");
13958        assert_eq!(convfloat(f64::NEG_INFINITY, 0, 0), "-Inf");
13959        assert_eq!(convfloat(f64::NAN, 0, 0), "NaN");
13960    }
13961
13962    #[test]
13963    fn test_getarrvalue() {
13964        let _g = crate::test_util::global_state_lock();
13965        let arr = vec!["a".into(), "b".into(), "c".into(), "d".into()];
13966        assert_eq!(getarrvalue(&arr, 2, 3), vec!["b", "c"]);
13967        assert_eq!(getarrvalue(&arr, -2, -1), vec!["c", "d"]);
13968        assert_eq!(getarrvalue(&arr, 1, 4), vec!["a", "b", "c", "d"]);
13969    }
13970
13971    #[test]
13972    fn test_setarrvalue() {
13973        let _g = crate::test_util::global_state_lock();
13974        // c:2897 — setarrvalue bails when unset(EXECOPT). Set "exec"
13975        // for the unit-test env (real zsh defaults exec=true).
13976        let saved_exec = opt_state_get("exec").unwrap_or(false);
13977        opt_state_set("exec", true);
13978        // C-faithful: setarrvalue takes a Value pointing at a Param
13979        // with u_arr set. Construct one inline.
13980        let pm = Box::new(param {
13981            node: hashnode {
13982                next: None,
13983                nam: "test".to_string(),
13984                flags: PM_ARRAY as i32,
13985            },
13986            u_data: 0,
13987            u_tied: None,
13988            u_arr: Some(vec!["a".into(), "b".into(), "c".into(), "d".into()]),
13989            u_str: None,
13990            u_val: 0,
13991            u_dval: 0.0,
13992            u_hash: None,
13993            gsu_s: None,
13994            gsu_i: None,
13995            gsu_f: None,
13996            gsu_a: None,
13997            gsu_h: None,
13998            base: 0,
13999            width: 0,
14000            env: None,
14001            ename: None,
14002            old: None,
14003            level: 0,
14004        });
14005        let mut v = value {
14006            pm: Some(pm),
14007            arr: Vec::new(),
14008            scanflags: 0,
14009            valflags: 0,
14010            start: 2,
14011            end: 3,
14012        };
14013        setarrvalue(&mut v, vec!["X".into(), "Y".into()]);
14014        let arr = v.pm.unwrap().u_arr.unwrap();
14015        assert_eq!(arr, vec!["a", "X", "Y", "d"]);
14016        opt_state_set("exec", saved_exec);
14017    }
14018
14019    #[test]
14020    fn test_valid_refname() {
14021        let _g = crate::test_util::global_state_lock();
14022        assert!(valid_refname("foo", 0));
14023        assert!(valid_refname("_bar", 0));
14024        assert!(valid_refname("1", 0));
14025        assert!(valid_refname("!", 0));
14026        assert!(valid_refname("arr[1]", 0));
14027        assert!(!valid_refname("", 0));
14028        // C semantics: empty leader without one of `! ? $ - _` is rejected.
14029        assert!(!valid_refname(" ", 0));
14030        // PM_UPPER rejects digit-leader and argv/ARGC.
14031        assert!(!valid_refname("1", PM_UPPER as i32));
14032        assert!(!valid_refname("argv", PM_UPPER as i32));
14033        assert!(!valid_refname("ARGC", PM_UPPER as i32));
14034    }
14035
14036    #[test]
14037    fn test_uniq_array_empty() {
14038        let _g = crate::test_util::global_state_lock();
14039        let empty: Vec<String> = Vec::new();
14040        assert!(uniqarray(empty).is_empty());
14041    }
14042
14043    #[test]
14044    fn test_convbase_underscore() {
14045        let _g = crate::test_util::global_state_lock();
14046        let s = convbase_underscore(1234567, 10, 3);
14047        assert_eq!(s, "1_234_567");
14048    }
14049
14050    fn val_str(v: getarg_out<'_>) -> String {
14051        match v {
14052            getarg_out::Value(v) => v.to_str(),
14053            getarg_out::Flags { .. } => panic!("expected Value, got Flags"),
14054        }
14055    }
14056
14057    #[test]
14058    fn getarg_n_flag_picks_second_exact_match() {
14059        let _g = crate::test_util::global_state_lock();
14060        // C params.c:1431-1442 + 1758 — `(en.2.)pat` picks 2nd exact match.
14061        let arr: Vec<String> = vec!["foo".into(), "bar".into(), "foo".into(), "baz".into()];
14062        let out = getarg("(en.2.r)foo", Some(&arr), None, None).expect("Some");
14063        assert_eq!(val_str(out), "foo");
14064    }
14065
14066    #[test]
14067    fn getarg_n_flag_third_exact_match() {
14068        let _g = crate::test_util::global_state_lock();
14069        let arr: Vec<String> = vec!["a".into(), "a".into(), "a".into(), "b".into()];
14070        let out = getarg("(en.3.r)a", Some(&arr), None, None).expect("Some");
14071        assert_eq!(val_str(out), "a");
14072    }
14073
14074    #[test]
14075    fn getarg_n_flag_returns_index_with_i() {
14076        let _g = crate::test_util::global_state_lock();
14077        // (en.2.i) — return INDEX of 2nd exact match.
14078        let arr: Vec<String> = vec!["x".into(), "y".into(), "x".into(), "y".into()];
14079        let out = getarg("(en.2.i)x", Some(&arr), None, None).expect("Some");
14080        assert_eq!(val_str(out), "3");
14081    }
14082
14083    #[test]
14084    fn getarg_negative_n_flips_search_direction() {
14085        let _g = crate::test_util::global_state_lock();
14086        // C params.c:1488-1491 — negative `num` flips down (reverse).
14087        // (en.-1.) on forward-default search matches from the end.
14088        let arr: Vec<String> = vec!["a".into(), "a".into(), "a".into()];
14089        let out = getarg("(en.-1.i)a", Some(&arr), None, None).expect("Some");
14090        assert_eq!(val_str(out), "3");
14091    }
14092
14093    #[test]
14094    fn getarg_n_flag_zero_treated_as_one() {
14095        let _g = crate::test_util::global_state_lock();
14096        // C params.c:1438-1439 — `if (!num) num = 1`.
14097        let arr: Vec<String> = vec!["x".into(), "y".into()];
14098        let out = getarg("(en.0.r)x", Some(&arr), None, None).expect("Some");
14099        assert_eq!(val_str(out), "x");
14100    }
14101
14102    #[test]
14103    fn getarg_unknown_flag_char_returns_none() {
14104        let _g = crate::test_util::global_state_lock();
14105        // C params.c:1477-1483 flagerr — invalid flag char reports error.
14106        let arr: Vec<String> = vec!["x".into()];
14107        assert!(getarg("(z)x", Some(&arr), None, None).is_none());
14108    }
14109
14110    #[test]
14111    fn getarg_n_flag_unterminated_arg_returns_none() {
14112        let _g = crate::test_util::global_state_lock();
14113        // (n.5 missing closing delimiter — flagerr.
14114        let arr: Vec<String> = vec!["x".into()];
14115        assert!(getarg("(n.5", Some(&arr), None, None).is_none());
14116    }
14117
14118    #[test]
14119    fn getarg_b_flag_starts_search_at_index() {
14120        let _g = crate::test_util::global_state_lock();
14121        // C params.c:1748-1760 — `(b.N.e)pat` skips first N-1 elements
14122        // forward (parsed value `N`, normalized to `beg = N-1`).
14123        let arr: Vec<String> = vec!["x".into(), "y".into(), "x".into(), "y".into()];
14124        // Forward, beg=2 (skip first 2) → starts at idx 2 → 'x' at 3.
14125        let out = getarg("(b.3.ei)x", Some(&arr), None, None).expect("Some");
14126        assert_eq!(val_str(out), "3");
14127    }
14128
14129    #[test]
14130    fn getarg_b_flag_with_R_reverse_from_offset() {
14131        let _g = crate::test_util::global_state_lock();
14132        // C params.c:1750-1755 — reverse search starting at parsed-1 idx.
14133        // arr=(x y x y), beg=2 (parsed 3-1), reverse → walks 2,1,0; first
14134        // exact 'x' is at idx 2 → 1-based "3".
14135        let arr: Vec<String> = vec!["x".into(), "y".into(), "x".into(), "y".into()];
14136        let out = getarg("(b.3.eIR)x", Some(&arr), None, None).expect("Some");
14137        assert_eq!(val_str(out), "3");
14138    }
14139
14140    #[test]
14141    fn getarg_b_flag_out_of_bounds_forward_returns_empty() {
14142        let _g = crate::test_util::global_state_lock();
14143        // c:1746 — beg >= len returns len+1 (empty for value-mode).
14144        let arr: Vec<String> = vec!["x".into()];
14145        let out = getarg("(b.5.er)x", Some(&arr), None, None).expect("Some");
14146        assert_eq!(val_str(out), "");
14147    }
14148
14149    #[test]
14150    fn getarg_b_flag_out_of_bounds_index_mode_returns_len_plus_one() {
14151        let _g = crate::test_util::global_state_lock();
14152        let arr: Vec<String> = vec!["x".into(), "y".into()];
14153        let out = getarg("(b.5.ei)x", Some(&arr), None, None).expect("Some");
14154        assert_eq!(val_str(out), "3");
14155    }
14156
14157    #[test]
14158    fn getarg_hash_neg_num_on_lowercase_r_returns_all() {
14159        let _g = crate::test_util::global_state_lock();
14160        // C params.c:1488-1491 — neg `num` flips down on `r`,
14161        // converting hash search to return-all-matches semantics.
14162        let mut h: IndexMap<String, String> = IndexMap::new();
14163        h.insert("a".into(), "1".into());
14164        h.insert("b".into(), "1".into());
14165        h.insert("c".into(), "2".into());
14166        let out = getarg("(en.-1.r)1", None, Some(&h), None).expect("Some");
14167        // r + neg = R semantics → all values where pat matches value.
14168        assert_eq!(val_str(out), "1 1");
14169    }
14170
14171    #[test]
14172    fn getarg_hash_neg_num_on_uppercase_R_returns_single() {
14173        let _g = crate::test_util::global_state_lock();
14174        // R + neg `num` un-flips back to single-match (r semantics).
14175        let mut h: IndexMap<String, String> = IndexMap::new();
14176        h.insert("a".into(), "1".into());
14177        h.insert("b".into(), "1".into());
14178        h.insert("c".into(), "2".into());
14179        let out = getarg("(en.-1.R)1", None, Some(&h), None).expect("Some");
14180        // R + neg → r → single first match.
14181        assert_eq!(val_str(out), "1");
14182    }
14183
14184    #[test]
14185    fn getarg_hash_i_flag_matches_keys_not_values() {
14186        let _g = crate::test_util::global_state_lock();
14187        // On an associative array the `i` flag matches the pattern against
14188        // KEYS (not values) and returns the matching key. A value-search
14189        // therefore finds nothing, and the `b<NUM>` begin-offset does not
14190        // skip entries for an associative key search. Verified against
14191        // zsh 5.9: `h=(a 1 b 1 c 1); ${h[(i)b]}` → "b"; `${h[(b:3:ei)1]}` → "".
14192        // (The prior expectation "c" — a b-offset skip returning a value —
14193        // does not match real zsh; the code already matches zsh.)
14194        let mut h: IndexMap<String, String> = IndexMap::new();
14195        h.insert("a".into(), "1".into());
14196        h.insert("b".into(), "1".into());
14197        h.insert("c".into(), "1".into());
14198        // `i` globs the KEY "b" and returns that key.
14199        let by_key = getarg("(i)b", None, Some(&h), None).expect("Some");
14200        assert_eq!(val_str(by_key), "b");
14201        // Searching a VALUE ("1") matches no key named "1" → empty.
14202        let by_val = getarg("(b.3.ei)1", None, Some(&h), None).expect("Some");
14203        assert_eq!(val_str(by_val), "");
14204    }
14205
14206    #[test]
14207    fn getarg_hash_I_flag_matches_all_keys() {
14208        let _g = crate::test_util::global_state_lock();
14209        // The `I` flag returns ALL matching keys. `(I)*` globs every key;
14210        // a value-search matches no key. Verified against zsh 5.9:
14211        // `h=(a 1 b 1 c 1); ${h[(I)*]}` → "a b c"; `${h[(b:2:eI)1]}` → "".
14212        // (The prior expectation "b c" for a value-search does not match
14213        // real zsh; the code already matches zsh.)
14214        let mut h: IndexMap<String, String> = IndexMap::new();
14215        h.insert("a".into(), "1".into());
14216        h.insert("b".into(), "1".into());
14217        h.insert("c".into(), "1".into());
14218        // `(I)*` matches all keys via glob, in insertion order.
14219        let all_keys = getarg("(I)*", None, Some(&h), None).expect("Some");
14220        assert_eq!(val_str(all_keys), "a b c");
14221        // Value-search "1" matches no key → empty.
14222        let out = getarg("(b.2.eI)1", None, Some(&h), None).expect("Some");
14223        assert_eq!(val_str(out), "");
14224    }
14225
14226    #[test]
14227    fn getarg_hash_b_flag_out_of_bounds_returns_empty() {
14228        let _g = crate::test_util::global_state_lock();
14229        // c:1746 — beg >= len with single-match → empty.
14230        let mut h: IndexMap<String, String> = IndexMap::new();
14231        h.insert("a".into(), "1".into());
14232        let out = getarg("(b.5.e)1", None, Some(&h), None).expect("Some");
14233        assert_eq!(val_str(out), "");
14234    }
14235
14236    #[test]
14237    fn getarg_w_flag_splits_multi_word_array_elements() {
14238        let _g = crate::test_util::global_state_lock();
14239        // C params.c:1761-1797 — `(w)N` joins array then re-splits by
14240        // IFS-default whitespace. arr=("a b" "c d"); (w)2 → "b" not "c d".
14241        let arr: Vec<String> = vec!["a b".into(), "c d".into()];
14242        let out = getarg("(w)2", Some(&arr), None, None).expect("Some");
14243        assert_eq!(val_str(out), "b");
14244    }
14245
14246    #[test]
14247    fn getarg_w_flag_simple_array_indexing_still_works() {
14248        let _g = crate::test_util::global_state_lock();
14249        let arr: Vec<String> = vec!["one".into(), "two".into(), "three".into()];
14250        let out = getarg("(w)2", Some(&arr), None, None).expect("Some");
14251        assert_eq!(val_str(out), "two");
14252    }
14253
14254    #[test]
14255    fn getarg_f_flag_splits_by_newline() {
14256        let _g = crate::test_util::global_state_lock();
14257        // C params.c:1424-1427 — `f` flag aliases `w` with sep="\n".
14258        // arr=("a b\nc d"); (f)2 → "c d" (split by \n only, not space).
14259        let arr: Vec<String> = vec!["a b\nc d".into()];
14260        let out = getarg("(f)2", Some(&arr), None, None).expect("Some");
14261        assert_eq!(val_str(out), "c d");
14262    }
14263
14264    #[test]
14265    fn getarg_scalar_w_flag_picks_nth_word() {
14266        let _g = crate::test_util::global_state_lock();
14267        // C params.c:1761-1797 — scalar word-mode arm. `(w)2` on
14268        // scalar "hello world foo" returns the 2nd whitespace word.
14269        let out = getarg("(w)2", None, None, Some("hello world foo")).expect("Some");
14270        assert_eq!(val_str(out), "world");
14271    }
14272
14273    #[test]
14274    fn getarg_scalar_w_flag_negative_index_counts_from_end() {
14275        let _g = crate::test_util::global_state_lock();
14276        let out = getarg("(w)-1", None, None, Some("alpha beta gamma")).expect("Some");
14277        assert_eq!(val_str(out), "gamma");
14278    }
14279
14280    #[test]
14281    fn getarg_scalar_re_returns_char_at_match_position() {
14282        let _g = crate::test_util::global_state_lock();
14283        // C params.c:1798-1980 — char-search returns CHAR at match
14284        // position, not full substring. Verified empirically:
14285        //   /bin/zsh -c 's="barfooxyz"; print "${s[(r)foo]}"'  → "f"
14286        let out = getarg("(re)bc", None, None, Some("abcdef")).expect("Some");
14287        assert_eq!(val_str(out), "b");
14288    }
14289
14290    #[test]
14291    fn getarg_scalar_ie_returns_position_of_first_match() {
14292        let _g = crate::test_util::global_state_lock();
14293        let out = getarg("(ie)cd", None, None, Some("abcdef")).expect("Some");
14294        // 'cd' starts at 1-based position 3.
14295        assert_eq!(val_str(out), "3");
14296    }
14297
14298    #[test]
14299    fn getarg_scalar_Ie_returns_position_of_last_match() {
14300        let _g = crate::test_util::global_state_lock();
14301        let out = getarg("(Ie)b", None, None, Some("abcabc")).expect("Some");
14302        // Last 'b' is at 1-based position 5.
14303        assert_eq!(val_str(out), "5");
14304    }
14305
14306    #[test]
14307    fn getarg_scalar_ie_no_match_returns_len_plus_one() {
14308        let _g = crate::test_util::global_state_lock();
14309        let out = getarg("(ie)z", None, None, Some("abc")).expect("Some");
14310        assert_eq!(val_str(out), "4");
14311    }
14312
14313    #[test]
14314    fn getarg_scalar_Ie_no_match_returns_zero() {
14315        let _g = crate::test_util::global_state_lock();
14316        let out = getarg("(Ie)z", None, None, Some("abc")).expect("Some");
14317        assert_eq!(val_str(out), "0");
14318    }
14319
14320    #[test]
14321    fn getarg_scalar_n_flag_picks_second_match() {
14322        let _g = crate::test_util::global_state_lock();
14323        // C params.c:1929/1964 — `!--num` Nth-match counter on
14324        // scalar char-search. abcabc: 'a' at idx 0 and 3 → 2nd match
14325        // at byte position 4 (1-based).
14326        let out = getarg("(en.2.i)a", None, None, Some("abcabc")).expect("Some");
14327        assert_eq!(val_str(out), "4");
14328    }
14329
14330    #[test]
14331    fn getarg_scalar_b_flag_starts_from_offset() {
14332        let _g = crate::test_util::global_state_lock();
14333        // C params.c:1740-1742 — `(b.N.)` starts search from idx N-1.
14334        // abc bc abc: with b=4, skip first 3 chars; first 'b' at byte 5.
14335        let out = getarg("(b.4.ei)b", None, None, Some("abcbc")).expect("Some");
14336        assert_eq!(val_str(out), "4");
14337    }
14338
14339    #[test]
14340    fn getarg_scalar_re_n2_picks_second_substring() {
14341        let _g = crate::test_util::global_state_lock();
14342        let out = getarg("(en.2.r)b", None, None, Some("abab")).expect("Some");
14343        assert_eq!(val_str(out), "b");
14344    }
14345
14346    /// c:3076/3193 — assignsparam writes into paramtab; getsparam
14347    /// reads it back. The round-trip is the spine of every
14348    /// `foo=bar; print $foo` flow. Regression here would silently
14349    /// drop assignments.
14350    #[test]
14351    fn assignsparam_then_getsparam_round_trips() {
14352        let _g = crate::test_util::global_state_lock(); // c:3193
14353                                                        // c:2697 — assignsparam → assignstrvalue bails when
14354                                                        // unset(EXECOPT). The unit-test env doesn't run through
14355                                                        // createoptiontable so we set "exec" explicitly to simulate
14356                                                        // normal runtime. Mirrors the same setup used by
14357                                                        // `setnumvalue_stores_int_value_into_scalar_pm` above.
14358        let saved_exec = opt_state_get("exec") // c:2697
14359            .unwrap_or(false); // c:2697
14360        opt_state_set("exec", true); // c:2697
14361        let name = "ZSHRS_TEST_ASSIGN_GET"; // c:3193
14362        assignsparam(name, "test_value_42", 0); // c:3193
14363        assert_eq!(
14364            // c:3076
14365            getsparam(name).as_deref(), // c:3076
14366            Some("test_value_42")       // c:3076
14367        ); // c:3076
14368           // Cleanup so other tests don't see leaked param.
14369        let _ = paramtab().write().unwrap().remove(name); // c:3819
14370        opt_state_set("exec", saved_exec); // c:2697
14371    }
14372
14373    /// Regression: a subscript WRITE to a hash-storage-backed special assoc
14374    /// (`$compstate`, populated during completion via set_compstate_str) with a
14375    /// STRING key must succeed, not error "assignment to invalid subscript
14376    /// range". After the undeclared-subscript auto-vivify was removed, the
14377    /// completion system's `compstate[insert]=menu` writes started arithmetic-
14378    /// evaluating the `insert` key to 0 and failing — flooding real shells with
14379    /// errors on every completion. The fix recognises any name present in
14380    /// paramtab_hashed_storage() as associative. A name NOT in that store still
14381    /// errors (matches zsh: `compstate` only exists during completion).
14382    #[test]
14383    fn subscript_write_to_hash_backed_special_assoc_succeeds() {
14384        let _g = crate::test_util::global_state_lock();
14385        let saved_exec = opt_state_get("exec").unwrap_or(false);
14386        opt_state_set("exec", true);
14387        // Simulate the completion setup that populates $compstate before the
14388        // user completer widget runs (compcore::callcompfunc → set_compstate_str).
14389        paramtab_hashed_storage()
14390            .lock()
14391            .unwrap()
14392            .entry("compstate".to_string())
14393            .or_default();
14394        // User completer write: `compstate[insert]=menu`.
14395        let ok = assignsparam("compstate[insert]", "menu", 0);
14396        assert!(ok.is_some(), "compstate[insert]=menu must succeed, got error");
14397        assert_eq!(
14398            paramtab_hashed_storage()
14399                .lock()
14400                .unwrap()
14401                .get("compstate")
14402                .and_then(|m| m.get("insert"))
14403                .map(String::as_str),
14404            Some("menu"),
14405        );
14406        // Negative: a name with NO hash-storage backing and a non-numeric key
14407        // still errors (returns None) — the undeclared-subscript guard holds.
14408        let bad = assignsparam("zshrs_no_such_assoc[k1]", "bb", 0);
14409        assert!(bad.is_none(), "undeclared non-numeric subscript must still error");
14410        // Cleanup.
14411        let _ = paramtab_hashed_storage().lock().unwrap().remove("compstate");
14412        let _ = paramtab().write().unwrap().remove("compstate");
14413        let _ = paramtab().write().unwrap().remove("zshrs_no_such_assoc");
14414        opt_state_set("exec", saved_exec);
14415    }
14416
14417    /// c:3076 — getsparam on a non-existent param returns None.
14418    /// A regression returning Some("") would mask unset-param errors.
14419    #[test]
14420    fn getsparam_unknown_param_returns_none() {
14421        let _g = crate::test_util::global_state_lock();
14422        assert!(getsparam("ZSHRS_TEST_DEFINITELY_UNSET").is_none());
14423    }
14424
14425    /// c:3819 — direct paramtab.remove drops the entry; subsequent
14426    /// getsparam returns None. The set→remove→lookup gap verifies
14427    /// the canonical paramtab is actually backing both reads + writes.
14428    #[test]
14429    fn paramtab_remove_makes_getsparam_return_none() {
14430        let _g = crate::test_util::global_state_lock();
14431        let name = "ZSHRS_TEST_UNSET_FLOW";
14432        assignsparam(name, "to_be_removed", 0);
14433        assert!(
14434            getsparam(name).is_some(),
14435            "param must be set before remove path"
14436        );
14437        let _ = paramtab().write().unwrap().remove(name);
14438        assert!(
14439            getsparam(name).is_none(),
14440            "after remove, getsparam must return None"
14441        );
14442    }
14443
14444    /// c:3357 — assignaparam stores an array. getsparam on an array
14445    /// param returns the first element OR a join (depends on IFS).
14446    /// Verify the slot was populated AT ALL by querying paramtab.
14447    #[test]
14448    fn assignaparam_populates_paramtab_with_array() {
14449        let _g = crate::test_util::global_state_lock();
14450        let name = "ZSHRS_TEST_ARR_X";
14451        assignaparam(name, vec!["a".into(), "b".into(), "c".into()], 0);
14452        let tab = paramtab().read().expect("paramtab poisoned");
14453        let pm = tab.get(name).expect("param installed");
14454        assert_eq!(
14455            pm.u_arr.as_deref(),
14456            Some(&["a".to_string(), "b".to_string(), "c".to_string()][..]),
14457            "assignaparam stores all three elements"
14458        );
14459        drop(tab);
14460        let _ = paramtab().write().unwrap().remove(name);
14461    }
14462
14463    // Use the module-scope HISTCHARS_TEST_LOCK_SHARED (declared
14464    // outside the test modules) so gsu_tests + tests serialise
14465    // against the same Mutex rather than two independent ones.
14466
14467    /// `Src/params.c:5095-5097` — `histcharssetfn` stores bangchar /
14468    /// hatchar / hashchar in the per-char globals. Pin the round-trip
14469    /// for ALL THREE: change HISTCHARS to a custom 3-char string,
14470    /// verify each atomic global reflects the new value, and verify
14471    /// the canonical default `"!^#"` restores on NULL.
14472    #[test]
14473    fn histcharssetfn_syncs_all_three_histchar_globals() {
14474        let _g = crate::test_util::global_state_lock();
14475        let _g = HISTCHARS_TEST_LOCK_SHARED
14476            .lock()
14477            .unwrap_or_else(|e| e.into_inner());
14478        // Default state.
14479        histcharssetfn(&mut param::default(), String::new());
14480        assert_eq!(bangchar.load(Ordering::SeqCst), b'!' as i32);
14481        assert_eq!(hatchar.load(Ordering::SeqCst), b'^' as i32);
14482        assert_eq!(hashchar.load(Ordering::SeqCst), b'#' as i32);
14483        // Set HISTCHARS to "@:%".
14484        histcharssetfn(&mut param::default(), "@:%".to_string());
14485        assert_eq!(
14486            bangchar.load(Ordering::SeqCst),
14487            b'@' as i32,
14488            "c:5095 — bangchar = first byte of HISTCHARS"
14489        );
14490        assert_eq!(
14491            hatchar.load(Ordering::SeqCst),
14492            b':' as i32,
14493            "c:5096 — hatchar = second byte of HISTCHARS"
14494        );
14495        assert_eq!(
14496            hashchar.load(Ordering::SeqCst),
14497            b'%' as i32,
14498            "c:5097 — hashchar = third byte of HISTCHARS"
14499        );
14500        // Restore.
14501        histcharssetfn(&mut param::default(), String::new());
14502        assert_eq!(bangchar.load(Ordering::SeqCst), b'!' as i32);
14503        assert_eq!(hashchar.load(Ordering::SeqCst), b'#' as i32);
14504    }
14505
14506    /// `Src/params.c:5064-5074` — `histcharsgetfn` reads from the
14507    /// three atomic globals and returns a string of non-NUL bytes.
14508    /// Pin set→get symmetry: after `histcharssetfn(Some("@&%"))`,
14509    /// `histcharsgetfn(&param::default())` returns `"@&%"`.
14510    #[test]
14511    fn histcharsgetfn_round_trips_with_histcharssetfn() {
14512        let _g = crate::test_util::global_state_lock();
14513        let _g = HISTCHARS_TEST_LOCK_SHARED
14514            .lock()
14515            .unwrap_or_else(|e| e.into_inner());
14516        histcharssetfn(&mut param::default(), "@&%".to_string());
14517        assert_eq!(
14518            histcharsgetfn(&param::default()),
14519            "@&%",
14520            "c:5068-5073 — getfn reads atomic globals setfn wrote"
14521        );
14522        // Restore default and verify round-trip.
14523        histcharssetfn(&mut param::default(), String::new());
14524        assert_eq!(
14525            histcharsgetfn(&param::default()),
14526            "!^#",
14527            "default `!^#` round-trips through atomics"
14528        );
14529    }
14530
14531    /// `Src/params.c:5118-5128` — `homesetfn(x)` round-trip:
14532    /// `homesetfn(s); homegetfn() == s` for non-symlink paths and
14533    /// CHASELINKS-off. Pins the basic store-then-read contract.
14534    #[test]
14535    fn homesetfn_stores_value_for_getfn() {
14536        let _g = crate::test_util::global_state_lock();
14537        let mut __pm = crate::ported::zsh_h::param::default();
14538        let saved = homegetfn(&__pm);
14539        homesetfn(&mut __pm, "/tmp/zshrs_test_home".to_string());
14540        assert_eq!(
14541            homegetfn(&__pm),
14542            "/tmp/zshrs_test_home",
14543            "c:5121-5126 — homesetfn → homegetfn round-trip"
14544        );
14545        // Restore.
14546        homesetfn(&mut __pm, saved);
14547    }
14548
14549    /// `Src/params.c:5125-5126` — empty input becomes `ztrdup("")`.
14550    /// Pin empty-string handling.
14551    #[test]
14552    fn homesetfn_empty_input_stores_empty() {
14553        let _g = crate::test_util::global_state_lock();
14554        let mut __pm = crate::ported::zsh_h::param::default();
14555        let saved = homegetfn(&__pm);
14556        homesetfn(&mut __pm, String::new());
14557        assert_eq!(
14558            homegetfn(&__pm),
14559            "",
14560            "c:5126 — empty x stores empty (no panic)"
14561        );
14562        homesetfn(&mut __pm, saved);
14563    }
14564
14565    /// `Src/params.c:5004-5011` — `errnosetfn(x)` writes errno
14566    /// unconditionally, then warns (NOT errors) on truncation. The
14567    /// store happens regardless of warning. Pin set→get round-trip.
14568    #[test]
14569    #[cfg(any(target_os = "macos", target_os = "linux"))]
14570    fn errnosetfn_writes_through_to_libc_errno_getfn() {
14571        let _g = crate::test_util::global_state_lock();
14572        // Set errno to a small int.
14573        errnosetfn(42);
14574        assert_eq!(
14575            errnogetfn(),
14576            42,
14577            "c:5006 — errno = (int)x; subsequent getfn must read it back"
14578        );
14579        errnosetfn(0);
14580        assert_eq!(errnogetfn(), 0);
14581    }
14582
14583    /// `Src/params.c:5008-5010` — truncation check fires when
14584    /// `(zlong)errno != x`. C also resets errno indirectly inside
14585    /// `zwarn` (libc calls touch errno) — so after the warning,
14586    /// the user's observed `$ERRNO` is the post-warning value, NOT
14587    /// the truncated cast. Faithful Rust port has the same behavior.
14588    /// Pin only that the function returns normally and doesn't crash;
14589    /// any specific post-call errno value is implementation-defined.
14590    #[test]
14591    #[cfg(any(target_os = "macos", target_os = "linux"))]
14592    fn errnosetfn_does_not_panic_on_truncation() {
14593        let _g = crate::test_util::global_state_lock();
14594        // i64::MAX → truncates to i32 = -1 → warning fires inside.
14595        // The store at c:5008 happens; whether the warning's libc
14596        // calls then overwrite errno is implementation-defined.
14597        errnosetfn(i64::MAX);
14598        // Just verify the call returned (no panic) and getfn works.
14599        let _ = errnogetfn();
14600        // Reset.
14601        errnosetfn(0);
14602    }
14603
14604    /// `Src/params.c:5090-5093` — non-ASCII chars in HISTCHARS
14605    /// produce a warning and the function returns WITHOUT updating
14606    /// any globals. Pin the rejection: state before == state after
14607    /// when a non-ASCII byte is in position 0/1/2.
14608    #[test]
14609    fn histcharssetfn_rejects_non_ascii_chars() {
14610        let _g = crate::test_util::global_state_lock();
14611        let _g = HISTCHARS_TEST_LOCK_SHARED
14612            .lock()
14613            .unwrap_or_else(|e| e.into_inner());
14614        // Reset to defaults.
14615        histcharssetfn(&mut param::default(), String::new());
14616        let bang_before = bangchar.load(Ordering::SeqCst);
14617        let hat_before = hatchar.load(Ordering::SeqCst);
14618        // Try to set HISTCHARS with non-ASCII char.
14619        histcharssetfn(&mut param::default(), "é".to_string());
14620        // c:5092 — rejection returns BEFORE any state changes.
14621        assert_eq!(
14622            bangchar.load(Ordering::SeqCst),
14623            bang_before,
14624            "c:5092 — bangchar unchanged after non-ASCII rejection"
14625        );
14626        assert_eq!(hatchar.load(Ordering::SeqCst), hat_before);
14627    }
14628
14629    /// Shared mutex for tests that mutate argzero/posixzero — both
14630    /// share global state and race when run in parallel.
14631    static ARGZERO_TEST_LOCK: Mutex<()> = Mutex::new(());
14632
14633    // HISTSIZ_TEST_LOCK is defined at module scope to share between
14634    // gsu_tests and tests submodules — both mutate histsiz.
14635
14636    /// `Src/params.c:4974-4977` — `histsizesetfn` floors at 1 then
14637    /// calls `resizehistents()` to prune the in-memory ring to the
14638    /// new cap. The previous Rust port skipped the resize call (and
14639    /// also failed to mirror the value into `hist::histsiz`), so
14640    /// HISTSIZE shrinks didn't take effect until the next implicit
14641    /// prune. Pin: setting HISTSIZE to N caps both the param store
14642    /// AND the hist::histsiz atomic used by resizehistents.
14643    #[test]
14644    fn histsizesetfn_floors_at_one_and_mirrors_to_hist_module() {
14645        let _g = crate::test_util::global_state_lock();
14646        let _g = HISTSIZ_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
14647        let saved_param = histsizegetfn();
14648        let saved_hist = histsiz.load(Ordering::SeqCst);
14649
14650        // c:4976 — value < 1 floors at 1.
14651        histsizesetfn(0);
14652        assert_eq!(histsizegetfn(), 1, "c:4976 — HISTSIZE 0 must floor at 1");
14653        assert_eq!(
14654            histsiz.load(Ordering::SeqCst),
14655            1,
14656            "c:4977 — mirror into hist::histsiz so resizehistents sees it"
14657        );
14658
14659        // Negative floors too.
14660        histsizesetfn(-5);
14661        assert_eq!(histsizegetfn(), 1, "c:4976 — negative floors at 1");
14662
14663        // Positive passes through.
14664        histsizesetfn(500);
14665        assert_eq!(histsizegetfn(), 500);
14666        assert_eq!(histsiz.load(Ordering::SeqCst), 500);
14667
14668        // Restore.
14669        *histsiz_lock().lock().unwrap() = saved_param;
14670        histsiz.store(saved_hist, Ordering::SeqCst);
14671    }
14672
14673    /// `Src/params.c:5152-5158` — `underscoregetfn` returns
14674    /// `dupstring(zunderscore)` then runs `untokenize(u)` on it.
14675    /// The Rust port previously skipped untokenize, exposing raw
14676    /// lexer-injected token bytes (Stringg, Equals, ...) in `$_`
14677    /// reads.
14678    #[test]
14679    fn underscoregetfn_runs_untokenize_on_zunderscore() {
14680        let _g = crate::test_util::global_state_lock();
14681        // Inject zunderscore containing a Pound token byte (\u{84})
14682        // and verify it gets stripped by untokenize in the return.
14683        let saved = zunderscore_lock().lock().unwrap().clone();
14684
14685        // Set zunderscore to a string containing a Pound token byte
14686        // surrounded by literals.
14687        let pound = Pound;
14688        let mut s = String::new();
14689        s.push('a');
14690        s.push(pound);
14691        s.push('b');
14692        *zunderscore_lock().lock().unwrap() = s;
14693
14694        let result = underscoregetfn();
14695        // c:5156 — untokenize replaces Pound (ITOK) with '#'
14696        // (its ztokens entry). The raw \u{84} byte must NOT survive.
14697        assert!(
14698            !result.contains(pound),
14699            "c:5156 — untokenize must strip Pound token byte from $_"
14700        );
14701        assert!(
14702            result.contains('#') || result.contains("a"),
14703            "c:5156 — Pound (ITOK) maps to '#' via ztokens[0]"
14704        );
14705
14706        // Restore.
14707        *zunderscore_lock().lock().unwrap() = saved;
14708    }
14709
14710    /// `Src/params.c:4954-4961` — `argzerogetfn` returns `posixzero`
14711    /// when `isset(POSIXARGZERO)`, else `argzero`. After mutating
14712    /// argzero (e.g. `exec -a foo`), `$0` under POSIXARGZERO must
14713    /// report the ORIGINAL startup argv[0], not the rewritten name.
14714    #[test]
14715    fn argzerogetfn_respects_posixargzero_option() {
14716        let _g = crate::test_util::global_state_lock();
14717        let _g = ARGZERO_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
14718
14719        // Save state.
14720        let saved_argzero = argzero();
14721        let saved_posixzero = posixzero();
14722        let saved_pos_option = opt_state_get("posixargzero").unwrap_or(false);
14723
14724        // Set up: posixzero (original) ≠ argzero (rewritten).
14725        set_posixzero(Some("/bin/zsh".to_string()));
14726        set_argzero(Some("rewritten-name".to_string()));
14727        // The set_argzero call mirrors to posixzero only if unset,
14728        // and we set posixzero first → mirror skipped. Confirm separation.
14729
14730        // pm is UNUSED in argzerogetfn (C signature matches Rust).
14731        // Use param::default() as the dummy carrier.
14732        let pm = param::default();
14733
14734        // POSIXARGZERO off → returns argzero.
14735        opt_state_set("posixargzero", false);
14736        assert_eq!(
14737            argzerogetfn(&pm),
14738            "rewritten-name",
14739            "c:4960 — !POSIXARGZERO returns argzero (current display name)"
14740        );
14741
14742        // POSIXARGZERO on → returns posixzero (the preserved startup argv[0]).
14743        opt_state_set("posixargzero", true);
14744        assert_eq!(
14745            argzerogetfn(&pm),
14746            "/bin/zsh",
14747            "c:4959 — POSIXARGZERO on returns posixzero (original startup argv[0])"
14748        );
14749
14750        // Restore.
14751        set_argzero(saved_argzero);
14752        set_posixzero(saved_posixzero);
14753        opt_state_set("posixargzero", saved_pos_option);
14754    }
14755
14756    /// `Src/init.c:271` — `argv0 = argzero = posixzero = *argv++`.
14757    /// At shell init both share the same source. The Rust port
14758    /// preserves this contract by having `set_argzero` mirror to
14759    /// `posixzero` ONLY on first call (when posixzero is None).
14760    /// Subsequent argzero changes (function frames, exec -a) must
14761    /// NOT clobber posixzero.
14762    #[test]
14763    fn set_argzero_mirrors_to_posixzero_only_on_first_call() {
14764        let _g = crate::test_util::global_state_lock();
14765        let _g = ARGZERO_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
14766
14767        let saved_argzero = argzero();
14768        let saved_posixzero = posixzero();
14769
14770        // Reset both to None.
14771        set_argzero(None);
14772        set_posixzero(None);
14773        // First call: posixzero is None, so it should mirror.
14774        set_argzero(Some("/usr/local/bin/zsh".to_string()));
14775        assert_eq!(
14776            posixzero().as_deref(),
14777            Some("/usr/local/bin/zsh"),
14778            "c:271 — first set_argzero mirrors to posixzero (was None)"
14779        );
14780        // Second call: posixzero now Some, so mirror is skipped.
14781        set_argzero(Some("function-name".to_string()));
14782        assert_eq!(
14783            posixzero().as_deref(),
14784            Some("/usr/local/bin/zsh"),
14785            "c:271 — second set_argzero does NOT clobber posixzero"
14786        );
14787        assert_eq!(
14788            argzero().as_deref(),
14789            Some("function-name"),
14790            "argzero updated as normal"
14791        );
14792
14793        // Restore.
14794        set_posixzero(saved_posixzero);
14795        set_argzero(saved_argzero);
14796    }
14797
14798    /// Locale-touching tests share process-wide env + libc state.
14799    /// Cargo runs tests in parallel by default, so without
14800    /// serialization a concurrent `env::set_var("LC_ALL")` can race
14801    /// a `env::remove_var("LC_ALL")` and corrupt assertions. Pin
14802    /// every locale test through this Mutex.
14803    fn locale_test_lock() -> &'static Mutex<()> {
14804        static L: OnceLock<Mutex<()>> = OnceLock::new();
14805        L.get_or_init(|| Mutex::new(()))
14806    }
14807
14808    /// Pin `LC_NAMES` to the canonical zsh `lc_names[]` table at
14809    /// `Src/params.c:4805-4825`. The five categories in entry order
14810    /// (LC_COLLATE, LC_CTYPE, LC_MESSAGES, LC_NUMERIC, LC_TIME) MUST
14811    /// match — `lcsetfn` walks this table by `strcmp(ln->name, pm->node.nam)`
14812    /// per c:4926 and dispatches to `setlocale(ln->category, ...)`.
14813    #[test]
14814    fn lc_names_match_zsh_canonical_table() {
14815        let _g = crate::test_util::global_state_lock();
14816        let names: Vec<&str> = LC_NAMES.iter().map(|(n, _)| *n).collect();
14817        assert_eq!(
14818            names,
14819            vec![
14820                "LC_COLLATE",
14821                "LC_CTYPE",
14822                "LC_MESSAGES",
14823                "LC_NUMERIC",
14824                "LC_TIME"
14825            ],
14826            "Src/params.c:4805-4825 — lc_names entry order must be preserved"
14827        );
14828        // Verify each name maps to a distinct libc category — proves
14829        // we aren't aliasing LC_NUMERIC to LC_TIME etc.
14830        let cats: Vec<libc::c_int> = LC_NAMES.iter().map(|(_, c)| *c).collect();
14831        let mut sorted = cats.clone();
14832        sorted.sort();
14833        sorted.dedup();
14834        assert_eq!(sorted.len(), 5, "all five LC_* categories must be distinct");
14835        assert!(cats.contains(&libc::LC_COLLATE));
14836        assert!(cats.contains(&libc::LC_CTYPE));
14837        assert!(cats.contains(&libc::LC_MESSAGES));
14838        assert!(cats.contains(&libc::LC_NUMERIC));
14839        assert!(cats.contains(&libc::LC_TIME));
14840    }
14841
14842    /// Pin `lcsetfn` to the canonical `setlocale` invocation at
14843    /// `Src/params.c:4925-4927`. When LC_ALL is empty and pm matches
14844    /// an entry in `lc_names`, libc setlocale MUST be called with
14845    /// the corresponding category. Verified by reading libc state
14846    /// back via `setlocale(cat, NULL)` after the assignment.
14847    #[test]
14848    fn lcsetfn_invokes_libc_setlocale_for_matching_category() {
14849        let _g = crate::test_util::global_state_lock();
14850        let _g = locale_test_lock().lock().unwrap_or_else(|e| e.into_inner());
14851        // Save LC_ALL/LC_CTYPE state.
14852        let saved_lc_all = env::var("LC_ALL").ok();
14853        let saved_lc_ctype = env::var("LC_CTYPE").ok();
14854        env::remove_var("LC_ALL"); // c:4912 LC_ALL must be empty for body to run
14855                                   // Drop LC_ALL from paramtab too — the sibling
14856                                   // `lcsetfn_short_circuits_when_lc_all_set` test sets it via
14857                                   // setsparam (params.rs:12986) and getsparam reads paramtab
14858                                   // BEFORE env::var (params.rs:4346). Without this clear, lcsetfn
14859                                   // sees the leftover paramtab value and short-circuits.
14860        unsetparam("LC_ALL");
14861
14862        // Read libc's current LC_CTYPE setting.
14863        let before = unsafe {
14864            let p = libc::setlocale(libc::LC_CTYPE, std::ptr::null());
14865            if p.is_null() {
14866                String::new()
14867            } else {
14868                std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned()
14869            }
14870        };
14871
14872        // Call lcsetfn with LC_CTYPE → "C" (universally available POSIX locale).
14873        lcsetfn("LC_CTYPE", Some("C".to_string()));
14874
14875        // Read it back — must report "C" since C invokes setlocale(LC_CTYPE, "C").
14876        let after = unsafe {
14877            let p = libc::setlocale(libc::LC_CTYPE, std::ptr::null());
14878            if p.is_null() {
14879                String::new()
14880            } else {
14881                std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned()
14882            }
14883        };
14884        assert_eq!(
14885            after, "C",
14886            "Src/params.c:4927 — lcsetfn must call setlocale(LC_CTYPE, \"C\")"
14887        );
14888
14889        // Env mirror also set.
14890        assert_eq!(env::var("LC_CTYPE").unwrap_or_default(), "C");
14891
14892        // Restore libc + env state.
14893        let _ = unsafe {
14894            let c = std::ffi::CString::new(before.as_bytes()).unwrap_or_default();
14895            libc::setlocale(libc::LC_CTYPE, c.as_ptr())
14896        };
14897        match saved_lc_all {
14898            Some(v) => env::set_var("LC_ALL", v),
14899            None => env::remove_var("LC_ALL"),
14900        }
14901        match saved_lc_ctype {
14902            Some(v) => env::set_var("LC_CTYPE", v),
14903            None => env::remove_var("LC_CTYPE"),
14904        }
14905    }
14906
14907    /// Pin `lcsetfn`'s LC_ALL early-return per c:4912-4913: when
14908    /// LC_ALL is non-empty, lcsetfn must short-circuit BEFORE
14909    /// touching libc setlocale for the per-category override.
14910    #[test]
14911    fn lcsetfn_short_circuits_when_lc_all_set() {
14912        let _g = crate::test_util::global_state_lock();
14913        let _g = locale_test_lock().lock().unwrap_or_else(|e| e.into_inner());
14914        let saved_lc_all = env::var("LC_ALL").ok();
14915        let saved_lc_ctype = env::var("LC_CTYPE").ok();
14916        env::set_var("LC_ALL", "C"); // c:4912 non-empty LC_ALL
14917                                     // Also stamp paramtab — `getsparam` reads paramtab FIRST
14918                                     // (params.rs:4346) and only falls through to `env::var` when
14919                                     // the key is absent. A prior test that left LC_ALL in paramtab
14920                                     // with an empty value (PM_UNSET tombstone or similar) makes
14921                                     // getsparam return Some("") and the env::set_var above never
14922                                     // reaches lcsetfn. Setting paramtab here pins the contract.
14923        setsparam("LC_ALL", "C");
14924
14925        // Capture libc state before.
14926        let before = unsafe {
14927            let p = libc::setlocale(libc::LC_CTYPE, std::ptr::null());
14928            if p.is_null() {
14929                String::new()
14930            } else {
14931                std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned()
14932            }
14933        };
14934
14935        // Try to set LC_CTYPE; should NOT touch libc state.
14936        lcsetfn("LC_CTYPE", Some("POSIX".to_string()));
14937
14938        // libc state must be unchanged.
14939        let after = unsafe {
14940            let p = libc::setlocale(libc::LC_CTYPE, std::ptr::null());
14941            if p.is_null() {
14942                String::new()
14943            } else {
14944                std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned()
14945            }
14946        };
14947        assert_eq!(
14948            before, after,
14949            "c:4912-4913 — lcsetfn must early-return when LC_ALL is non-empty"
14950        );
14951
14952        // Restore.
14953        match saved_lc_all {
14954            Some(v) => env::set_var("LC_ALL", v),
14955            None => env::remove_var("LC_ALL"),
14956        }
14957        match saved_lc_ctype {
14958            Some(v) => env::set_var("LC_CTYPE", v),
14959            None => env::remove_var("LC_CTYPE"),
14960        }
14961    }
14962
14963    /// Pin `getsparam_u` to its canonical C body at
14964    /// `Src/params.c:3089-3094`: returns `unmeta(getsparam(s))`,
14965    /// NOT a PM_SCALAR-checked `getstrvalue` wrapper.
14966    ///
14967    /// Before this fix, the Rust port took `Option<&mut value>`
14968    /// and gated on `PM_TYPE == PM_SCALAR` — a complete fabrication
14969    /// with no caller because no caller's type fit the bogus sig.
14970    #[test]
14971    fn getsparam_u_unmetas_getsparam_result() {
14972        let _g = crate::test_util::global_state_lock();
14973        let _g = locale_test_lock().lock().unwrap_or_else(|e| e.into_inner());
14974
14975        // Plain ASCII: getsparam_u returns the same content as
14976        // getsparam (no Meta bytes to strip).
14977        let saved = env::var("ZSHRS_TEST_LOCALE_GSU").ok();
14978        env::set_var("ZSHRS_TEST_LOCALE_GSU", "en_US.UTF-8");
14979        assert_eq!(
14980            getsparam_u("ZSHRS_TEST_LOCALE_GSU"),
14981            Some("en_US.UTF-8".to_string()),
14982            "Src/params.c:3092 — getsparam_u returns unmeta(getsparam(s)) for ASCII"
14983        );
14984
14985        // Missing param: returns None (matches C `if ((s = getsparam(s)))` false branch).
14986        env::remove_var("ZSHRS_TEST_LOCALE_GSU_MISSING");
14987        assert_eq!(
14988            getsparam_u("ZSHRS_TEST_LOCALE_GSU_MISSING"),
14989            None,
14990            "Src/params.c:3094 — getsparam_u returns NULL when getsparam returns NULL"
14991        );
14992
14993        // Restore.
14994        match saved {
14995            Some(v) => env::set_var("ZSHRS_TEST_LOCALE_GSU", v),
14996            None => env::remove_var("ZSHRS_TEST_LOCALE_GSU"),
14997        }
14998    }
14999
15000    /// Pin `setarrvalue` EXECOPT bail per `Src/params.c:2897-2898`.
15001    /// Same NO_EXEC semantic as setnumvalue: dry-run shell evaluation
15002    /// must not mutate array params.
15003    #[test]
15004    fn setarrvalue_bails_under_no_exec() {
15005        let _g = crate::test_util::global_state_lock();
15006
15007        let saved_exec = opt_state_get("exec").unwrap_or(false);
15008
15009        opt_state_set("exec", false);
15010        let pm = Box::new(param {
15011            node: hashnode {
15012                next: None,
15013                nam: "noexec_arr".to_string(),
15014                flags: PM_ARRAY as i32,
15015            },
15016            u_data: 0,
15017            u_tied: None,
15018            u_arr: Some(vec!["initial".to_string()]),
15019            u_str: None,
15020            u_val: 0,
15021            u_dval: 0.0,
15022            u_hash: None,
15023            gsu_s: None,
15024            gsu_i: None,
15025            gsu_f: None,
15026            gsu_a: None,
15027            gsu_h: None,
15028            base: 0,
15029            width: 0,
15030            env: None,
15031            ename: None,
15032            old: None,
15033            level: 0,
15034        });
15035        let mut v = value {
15036            pm: Some(pm),
15037            arr: Vec::new(),
15038            scanflags: 0,
15039            valflags: 0,
15040            start: 0,
15041            end: -1,
15042        };
15043        // Under NO_EXEC, the assign must be skipped.
15044        setarrvalue(&mut v, vec!["new1".to_string(), "new2".to_string()]);
15045        let arr = v.pm.as_ref().unwrap().u_arr.clone().unwrap_or_default();
15046        assert_eq!(
15047            arr,
15048            vec!["initial".to_string()],
15049            "c:2897 — NO_EXEC: setarrvalue must NOT replace u_arr"
15050        );
15051
15052        // With exec=true, the same call replaces.
15053        opt_state_set("exec", true);
15054        setarrvalue(&mut v, vec!["new1".to_string(), "new2".to_string()]);
15055        let arr = v.pm.as_ref().unwrap().u_arr.clone().unwrap_or_default();
15056        assert_eq!(
15057            arr,
15058            vec!["new1".to_string(), "new2".to_string()],
15059            "with EXEC set, setarrvalue replaces u_arr"
15060        );
15061
15062        opt_state_set("exec", saved_exec);
15063    }
15064
15065    /// Pin `setnumvalue` EXECOPT bail per `Src/params.c:2860`.
15066    /// When unset(EXECOPT) (i.e. NO_EXEC mode via `zsh -n` or
15067    /// `set -n`), param mutations MUST be skipped so dry-run shell
15068    /// evaluation doesn't leak state into the param table.
15069    #[test]
15070    fn setnumvalue_bails_under_no_exec() {
15071        let _g = crate::test_util::global_state_lock();
15072
15073        let saved_exec = opt_state_get("exec").unwrap_or(false);
15074
15075        // c:2860 — NO_EXEC: setnumvalue must not mutate the param.
15076        opt_state_set("exec", false);
15077        let mut pm = Box::new(param {
15078            node: hashnode {
15079                next: None,
15080                nam: "ne".to_string(),
15081                flags: PM_INTEGER as i32,
15082            },
15083            u_data: 0,
15084            u_tied: None,
15085            u_arr: None,
15086            u_str: None,
15087            u_val: 999,
15088            u_dval: 0.0,
15089            u_hash: None,
15090            gsu_s: None,
15091            gsu_i: None,
15092            gsu_f: None,
15093            gsu_a: None,
15094            gsu_h: None,
15095            base: 0,
15096            width: 0,
15097            env: None,
15098            ename: None,
15099            old: None,
15100            level: 0,
15101        });
15102        let mut v = value {
15103            pm: Some(pm.clone()),
15104            arr: Vec::new(),
15105            scanflags: 0,
15106            valflags: 0,
15107            start: 0,
15108            end: -1,
15109        };
15110        let val = mnumber {
15111            l: 42,
15112            d: 0.0,
15113            type_: MN_INTEGER,
15114        };
15115        setnumvalue(Some(&mut v), val);
15116        // pm.u_val MUST still be 999 (the initial), not 42.
15117        let stored = v.pm.as_ref().unwrap().u_val;
15118        assert_eq!(
15119            stored, 999,
15120            "c:2860 — NO_EXEC: setnumvalue must NOT mutate pm.u_val \
15121             (was {} but should stay 999)",
15122            stored
15123        );
15124
15125        // With exec=true, the same call mutates.
15126        opt_state_set("exec", true);
15127        setnumvalue(Some(&mut v), val);
15128        let stored = v.pm.as_ref().unwrap().u_val;
15129        assert_eq!(stored, 42, "with EXEC set, setnumvalue stores u_val = 42");
15130
15131        let _ = pm;
15132        opt_state_set("exec", saved_exec);
15133    }
15134
15135    /// Pin `$-` rendering to honor `set -n` (noexec). The previous
15136    /// Rust port called `opt("noexec")` which isn't a real option
15137    /// name in zsh — the lookup always returned false so `$-` never
15138    /// included 'n' even when `set -n` was active.
15139    #[test]
15140    fn dash_param_rendering_honors_noexec_via_exec_negation() {
15141        let _g = crate::test_util::global_state_lock();
15142        let saved = opt_state_get("exec").unwrap_or(false);
15143
15144        // With exec=true (default), $- should NOT include 'n'.
15145        opt_state_set("exec", true);
15146        let s = lookup_special_var("-").unwrap_or_default();
15147        assert!(
15148            !s.contains('n'),
15149            "exec=true → $-=`{}` must NOT include 'n'",
15150            s
15151        );
15152
15153        // With exec=false (`set -n`), $- SHOULD include 'n'.
15154        opt_state_set("exec", false);
15155        let s = lookup_special_var("-").unwrap_or_default();
15156        assert!(
15157            s.contains('n'),
15158            "exec=false → $-=`{}` MUST include 'n' (was silently dropped \
15159             when reading non-existent option name `noexec`)",
15160            s
15161        );
15162
15163        opt_state_set("exec", saved);
15164    }
15165
15166    /// Pin `TERM_UNKNOWN` bit value to the canonical C value at
15167    /// `Src/zsh.h:1986`. The previous params.rs duplicate had
15168    /// `1 << 0 = 0x01` which is actually C's TERM_BAD (Src/zsh.h:1985);
15169    /// the correct TERM_UNKNOWN value is 0x02. This single-byte
15170    /// drift caused the params.rs term-init code to silently set the
15171    /// TERM_BAD bit instead of TERM_UNKNOWN, while prompt.rs guards
15172    /// imported the correct 0x02 value from zsh_h.rs — the two
15173    /// paths disagreed about which bit means \"unknown terminal\".
15174    #[test]
15175    fn term_unknown_bit_value_matches_c() {
15176        let _g = crate::test_util::global_state_lock();
15177        assert_eq!(
15178            TERM_UNKNOWN, 0x02,
15179            "Src/zsh.h:1986 — TERM_UNKNOWN must be 0x02, got {:#x}",
15180            TERM_UNKNOWN
15181        );
15182        assert_eq!(
15183            TERM_BAD, 0x01,
15184            "Src/zsh.h:1985 — TERM_BAD must be 0x01 (and != TERM_UNKNOWN)"
15185        );
15186        // Crucially: TERM_BAD and TERM_UNKNOWN must be DISTINCT bits.
15187        assert_ne!(
15188            TERM_BAD, TERM_UNKNOWN,
15189            "TERM_BAD and TERM_UNKNOWN must be distinct (caught the 1<<0 drift bug)"
15190        );
15191    }
15192
15193    /// Pin `getstrvalue` PM_INTEGER branch to canonical C convbase
15194    /// dispatch at `Src/params.c:2373`. The previous Rust port used
15195    /// naked `.to_string()` (base-10) regardless of `pm.base`; C
15196    /// honors the param's stored base so `typeset -i 16 x=255` renders
15197    /// as `0xff` not `255`.
15198    #[test]
15199    fn getstrvalue_pm_integer_honors_pm_base() {
15200        let _g = crate::test_util::global_state_lock();
15201
15202        let saved_cbases_top = opt_state_get("cbases").unwrap_or(false);
15203        opt_state_set("cbases", true);
15204
15205        // Build a PM_INTEGER param with u_val=255 and base=16.
15206        let mut pm = Box::new(param {
15207            node: hashnode {
15208                next: None,
15209                nam: "test_hex_var".to_string(),
15210                flags: PM_INTEGER as i32,
15211            },
15212            u_data: 0,
15213            u_tied: None,
15214            u_arr: None,
15215            u_str: None,
15216            u_val: 255,
15217            u_dval: 0.0,
15218            u_hash: None,
15219            gsu_s: None,
15220            gsu_i: None,
15221            gsu_f: None,
15222            gsu_a: None,
15223            gsu_h: None,
15224            base: 16,
15225            width: 0,
15226            env: None,
15227            ename: None,
15228            old: None,
15229            level: 0,
15230        });
15231        let mut v = value {
15232            pm: Some(pm.clone()),
15233            arr: Vec::new(),
15234            scanflags: 0,
15235            valflags: 0,
15236            start: 0,
15237            end: -1,
15238        };
15239        let rendered = getstrvalue(Some(&mut v));
15240        assert_eq!(
15241            rendered, "0xFF",
15242            "c:2373 / c:5621 — PM_INTEGER base=16 + u_val=255 with CBASES \
15243             renders as `0xFF` (uppercase per C `dig - 10 + 'A'`), got {:?}",
15244            rendered
15245        );
15246
15247        // Base-8 (octal) with OCTALZEROES.
15248        let saved_oct = opt_state_get("octalzeroes").unwrap_or(false);
15249        let saved_cbases = opt_state_get("cbases").unwrap_or(false);
15250        opt_state_set("cbases", true);
15251        opt_state_set("octalzeroes", true);
15252        pm.base = 8;
15253        pm.u_val = 8;
15254        v.pm = Some(pm.clone());
15255        let rendered = getstrvalue(Some(&mut v));
15256        assert_eq!(
15257            rendered, "010",
15258            "c:2373 — PM_INTEGER base=8 with OCTALZEROES renders as `010`, got {:?}",
15259            rendered
15260        );
15261        opt_state_set("cbases", saved_cbases);
15262        opt_state_set("octalzeroes", saved_oct);
15263
15264        // Base=0 (default) → base-10.
15265        pm.base = 0;
15266        pm.u_val = 42;
15267        v.pm = Some(pm.clone());
15268        let rendered = getstrvalue(Some(&mut v));
15269        assert_eq!(
15270            rendered, "42",
15271            "c:2373 — PM_INTEGER base=0 defaults to base-10"
15272        );
15273
15274        opt_state_set("cbases", saved_cbases_top);
15275    }
15276
15277    /// Pin `unsetparam` to its canonical C body at `Src/params.c:3819-3833`.
15278    /// Two guards the previous Rust port skipped:
15279    ///   1. PM_NAMEREF params are NOT removed by unsetparam (c:3830).
15280    ///   2. PM_READONLY rejection per unsetparam_pm c:3850 — readonly
15281    ///      params survive the unset call.
15282    #[test]
15283    fn unsetparam_skips_nameref_and_readonly() {
15284        let _g = crate::test_util::global_state_lock();
15285
15286        let saved_exec = opt_state_get("exec").unwrap_or(false);
15287        opt_state_set("exec", true);
15288
15289        // Helper: install a scalar param with the given flag-set.
15290        fn install(name: &str, value: &str, flags: u32) {
15291            let mut tab = paramtab().write().unwrap();
15292            tab.insert(
15293                name.to_string(),
15294                Box::new(param {
15295                    node: hashnode {
15296                        next: None,
15297                        nam: name.to_string(),
15298                        flags: (PM_SCALAR | flags) as i32,
15299                    },
15300                    u_data: 0,
15301                    u_tied: None,
15302                    u_arr: None,
15303                    u_str: Some(value.to_string()),
15304                    u_val: 0,
15305                    u_dval: 0.0,
15306                    u_hash: None,
15307                    gsu_s: None,
15308                    gsu_i: None,
15309                    gsu_f: None,
15310                    gsu_a: None,
15311                    gsu_h: None,
15312                    base: 0,
15313                    width: 0,
15314                    env: None,
15315                    ename: None,
15316                    old: None,
15317                    level: 0,
15318                }),
15319            );
15320        }
15321
15322        // c:3830 — nameref params skip the unset.
15323        let nameref_name = "zshrs_test_unsetparam_nameref";
15324        install(nameref_name, "target_var_name", PM_NAMEREF);
15325        unsetparam(nameref_name);
15326        {
15327            let tab = paramtab().read().unwrap();
15328            assert!(
15329                tab.contains_key(nameref_name),
15330                "c:3830 — PM_NAMEREF param survives unsetparam"
15331            );
15332        }
15333
15334        // c:3850 (via unsetparam_pm) — readonly rejection.
15335        let ro_name = "zshrs_test_unsetparam_readonly";
15336        install(ro_name, "locked", PM_READONLY);
15337        unsetparam(ro_name);
15338        {
15339            let tab = paramtab().read().unwrap();
15340            assert!(
15341                tab.contains_key(ro_name),
15342                "c:3850 — PM_READONLY param survives unsetparam"
15343            );
15344        }
15345
15346        // Plain scalar removed normally.
15347        let plain_name = "zshrs_test_unsetparam_plain";
15348        install(plain_name, "removable", 0);
15349        unsetparam(plain_name);
15350        {
15351            let tab = paramtab().read().unwrap();
15352            assert!(
15353                !tab.contains_key(plain_name),
15354                "plain scalar successfully removed"
15355            );
15356        }
15357
15358        // Clean up.
15359        {
15360            let mut tab = paramtab().write().unwrap();
15361            tab.remove(nameref_name);
15362            tab.remove(ro_name);
15363            tab.remove(plain_name);
15364        }
15365        opt_state_set("exec", saved_exec);
15366    }
15367
15368    /// Pin `assigniparam` to its canonical C body at `Src/params.c:3754-3761`.
15369    /// Three-arg signature: `(s, val, flags)`. Previous Rust port
15370    /// dropped the flags arg AND returned void; this restores both.
15371    #[test]
15372    fn assigniparam_takes_flags_arg_and_returns_param() {
15373        let _g = crate::test_util::global_state_lock();
15374
15375        let saved_exec = opt_state_get("exec").unwrap_or(false);
15376        opt_state_set("exec", true);
15377
15378        let name = "zshrs_test_assigniparam_x";
15379        {
15380            let mut tab = paramtab().write().unwrap();
15381            tab.remove(name);
15382        }
15383
15384        // c:3755-3760 — assigniparam returns Param and threads flags through.
15385        let r = assigniparam(name, 77, ASSPM_WARN as i32);
15386        assert!(
15387            r.is_some(),
15388            "c:3760 — returns Some(Param) for new int param"
15389        );
15390        {
15391            let tab = paramtab().read().unwrap();
15392            let pm = tab.get(name).expect("integer param created");
15393            assert_ne!(
15394                (pm.node.flags as u32) & PM_INTEGER,
15395                0,
15396                "c:3757-3760 — PM_INTEGER flag set"
15397            );
15398            assert_eq!(pm.u_val, 77, "c:3759 — value stored in u_val");
15399        }
15400
15401        // Reassign with a different flag value (0 — no warnings).
15402        let r = assigniparam(name, 88, 0);
15403        assert!(r.is_some(), "reassign returns Some");
15404        {
15405            let tab = paramtab().read().unwrap();
15406            let pm = tab.get(name).expect("param still present");
15407            assert_eq!(pm.u_val, 88, "reassign updates u_val");
15408        }
15409
15410        // Clean up.
15411        {
15412            let mut tab = paramtab().write().unwrap();
15413            tab.remove(name);
15414        }
15415        opt_state_set("exec", saved_exec);
15416    }
15417
15418    /// Pin `setnparam` to its canonical C body at `Src/params.c:3745-3749`.
15419    /// MUST accept `mnumber` (integer or float) and return Param.
15420    /// Previous Rust port took `f64` only and returned void — losing
15421    /// the integer side and the Param return entirely.
15422    #[test]
15423    fn setnparam_accepts_both_integer_and_float() {
15424        let _g = crate::test_util::global_state_lock();
15425
15426        let saved_exec = opt_state_get("exec").unwrap_or(false);
15427        opt_state_set("exec", true);
15428
15429        // Clean up any leftover.
15430        let int_name = "zshrs_test_setnparam_i";
15431        let flt_name = "zshrs_test_setnparam_f";
15432        {
15433            let mut tab = paramtab().write().unwrap();
15434            tab.remove(int_name);
15435            tab.remove(flt_name);
15436        }
15437
15438        // c:3748 — integer branch: setnparam returns Some(Param) with
15439        // PM_INTEGER flag and u_val set.
15440        let r = setnparam(
15441            int_name,
15442            mnumber {
15443                l: 999,
15444                d: 0.0,
15445                type_: MN_INTEGER,
15446            },
15447        );
15448        assert!(r.is_some(), "setnparam returns Some for new param");
15449        {
15450            let tab = paramtab().read().unwrap();
15451            let pm = tab.get(int_name).expect("integer param created");
15452            assert_ne!(
15453                (pm.node.flags as u32) & PM_INTEGER,
15454                0,
15455                "c:3748 — PM_INTEGER flag set for integer mnumber"
15456            );
15457            assert_eq!(pm.u_val, 999, "c:3748 — integer value stored in u_val");
15458        }
15459
15460        // c:3748 — float branch: setnparam with MN_FLOAT creates PM_FFLOAT.
15461        let r = setnparam(
15462            flt_name,
15463            mnumber {
15464                l: 0,
15465                d: 3.14,
15466                type_: MN_FLOAT,
15467            },
15468        );
15469        assert!(r.is_some(), "setnparam returns Some for new float param");
15470        {
15471            let tab = paramtab().read().unwrap();
15472            let pm = tab.get(flt_name).expect("float param created");
15473            assert_ne!(
15474                (pm.node.flags as u32) & PM_FFLOAT,
15475                0,
15476                "c:3748 — PM_FFLOAT flag set for float mnumber"
15477            );
15478            assert!(
15479                (pm.u_dval - 3.14).abs() < 1e-10,
15480                "c:3748 — float value stored in u_dval"
15481            );
15482        }
15483
15484        // Clean up.
15485        {
15486            let mut tab = paramtab().write().unwrap();
15487            tab.remove(int_name);
15488            tab.remove(flt_name);
15489        }
15490        opt_state_set("exec", saved_exec);
15491    }
15492
15493    /// Pin `setiparam` to its canonical C body at `Src/params.c:3767-3773`.
15494    /// MUST create the param as PM_INTEGER via `assignnparam`, not as
15495    /// PM_SCALAR via `assignsparam` with a stringified value.
15496    #[test]
15497    fn setiparam_creates_pm_integer_param() {
15498        let _g = crate::test_util::global_state_lock();
15499        let name = "zshrs_test_setiparam_x";
15500
15501        // C: `assignnparam` bails when `unset(EXECOPT)` (Src/params.c:3679).
15502        // Real zsh startup sets exec=true; the unit-test env doesn't run
15503        // through `createoptiontable` so we set "exec" explicitly to
15504        // simulate normal runtime.
15505        let saved_exec = opt_state_get("exec").unwrap_or(false);
15506        opt_state_set("exec", true);
15507
15508        // Clean up any leftover.
15509        {
15510            let mut tab = paramtab().write().unwrap();
15511            tab.remove(name);
15512        }
15513
15514        // Set integer value.
15515        setiparam(name, 42);
15516
15517        // Param should exist with PM_INTEGER flag set + u_val == 42.
15518        {
15519            let tab = paramtab().read().unwrap();
15520            let pm = tab.get(name).expect("setiparam must create the param");
15521            assert_ne!(
15522                (pm.node.flags as u32) & PM_INTEGER,
15523                0,
15524                "c:3770-3772 — created param must have PM_INTEGER flag set, \
15525                 got flags = {:#x}",
15526                pm.node.flags
15527            );
15528            assert_eq!(pm.u_val, 42, "c:3771 — integer value stored in pm.u_val");
15529        }
15530
15531        // Reassign to verify update path also keeps PM_INTEGER.
15532        setiparam(name, 100);
15533        {
15534            let tab = paramtab().read().unwrap();
15535            let pm = tab.get(name).expect("setiparam reassign must keep param");
15536            assert_eq!(pm.u_val, 100, "reassign updates the integer value");
15537            assert_ne!(
15538                (pm.node.flags as u32) & PM_INTEGER,
15539                0,
15540                "reassign keeps PM_INTEGER flag"
15541            );
15542        }
15543
15544        // Clean up.
15545        {
15546            let mut tab = paramtab().write().unwrap();
15547            tab.remove(name);
15548        }
15549        // Restore EXECOPT.
15550        opt_state_set("exec", saved_exec);
15551    }
15552
15553    /// Pin `gethparam` / `gethkparam` to their canonical C bodies at
15554    /// `Src/params.c:3117-3140`. Same signature-fix family as `getaparam`:
15555    /// the `name: &str` path with digit-first reject + PM_HASHED check.
15556    #[test]
15557    fn gethparam_and_gethkparam_signature_matches_c() {
15558        let _g = crate::test_util::global_state_lock();
15559        // c:3122 / c:3136 — digit-first name reject.
15560        assert_eq!(
15561            gethparam("123abc"),
15562            None,
15563            "c:3122 — digit-first name rejected"
15564        );
15565        assert_eq!(
15566            gethkparam("123abc"),
15567            None,
15568            "c:3136 — digit-first name rejected"
15569        );
15570
15571        // Missing param → None.
15572        assert_eq!(
15573            gethparam("zshrs_test_hashparam_xyz"),
15574            None,
15575            "missing param returns None"
15576        );
15577        assert_eq!(
15578            gethkparam("zshrs_test_hashparam_xyz"),
15579            None,
15580            "missing param returns None"
15581        );
15582
15583        // PM_SCALAR param (not hashed) → None.
15584        {
15585            let mut tab = paramtab().write().unwrap();
15586            tab.insert(
15587                "zshrs_test_gethp_scalar".to_string(),
15588                Box::new(param {
15589                    node: hashnode {
15590                        next: None,
15591                        nam: "zshrs_test_gethp_scalar".to_string(),
15592                        flags: PM_SCALAR as i32,
15593                    },
15594                    u_data: 0,
15595                    u_tied: None,
15596                    u_arr: None,
15597                    u_str: Some("scalar value".to_string()),
15598                    u_val: 0,
15599                    u_dval: 0.0,
15600                    u_hash: None,
15601                    gsu_s: None,
15602                    gsu_i: None,
15603                    gsu_f: None,
15604                    gsu_a: None,
15605                    gsu_h: None,
15606                    base: 0,
15607                    width: 0,
15608                    env: None,
15609                    ename: None,
15610                    old: None,
15611                    level: 0,
15612                }),
15613            );
15614        }
15615        assert_eq!(
15616            gethparam("zshrs_test_gethp_scalar"),
15617            None,
15618            "c:3123 — non-PM_HASHED returns None"
15619        );
15620        assert_eq!(
15621            gethkparam("zshrs_test_gethp_scalar"),
15622            None,
15623            "c:3137 — non-PM_HASHED returns None"
15624        );
15625
15626        // PM_HASHED param → Some(Vec::new()) (backend not yet wired,
15627        // but signature should at least classify the type correctly).
15628        {
15629            let mut tab = paramtab().write().unwrap();
15630            tab.insert(
15631                "zshrs_test_gethp_hash".to_string(),
15632                Box::new(param {
15633                    node: hashnode {
15634                        next: None,
15635                        nam: "zshrs_test_gethp_hash".to_string(),
15636                        flags: PM_HASHED as i32,
15637                    },
15638                    u_data: 0,
15639                    u_tied: None,
15640                    u_arr: None,
15641                    u_str: None,
15642                    u_val: 0,
15643                    u_dval: 0.0,
15644                    u_hash: None,
15645                    gsu_s: None,
15646                    gsu_i: None,
15647                    gsu_f: None,
15648                    gsu_a: None,
15649                    gsu_h: None,
15650                    base: 0,
15651                    width: 0,
15652                    env: None,
15653                    ename: None,
15654                    old: None,
15655                    level: 0,
15656                }),
15657            );
15658        }
15659        assert_eq!(
15660            gethparam("zshrs_test_gethp_hash"),
15661            Some(Vec::new()),
15662            "c:3123-3124 — PM_HASHED empty-storage returns Some(empty vec)"
15663        );
15664        assert_eq!(
15665            gethkparam("zshrs_test_gethp_hash"),
15666            Some(Vec::new()),
15667            "c:3137-3138 — PM_HASHED empty-storage returns Some(empty vec)"
15668        );
15669
15670        // Populate paramtab_hashed_storage and verify gethparam/gethkparam
15671        // return the actual values/keys per c:3124 (SCANPM_WANTVALS) and
15672        // c:3138 (SCANPM_WANTKEYS). IndexMap preserves insertion order.
15673        {
15674            let mut store = paramtab_hashed_storage().lock().unwrap();
15675            let mut map: IndexMap<String, String> = IndexMap::new();
15676            map.insert("k1".to_string(), "v1".to_string());
15677            map.insert("k2".to_string(), "v2".to_string());
15678            store.insert("zshrs_test_gethp_hash".to_string(), map);
15679        }
15680        assert_eq!(
15681            gethparam("zshrs_test_gethp_hash"),
15682            Some(vec!["v1".to_string(), "v2".to_string()]),
15683            "c:3124 — paramvalarr(SCANPM_WANTVALS) returns values from hashed-storage"
15684        );
15685        assert_eq!(
15686            gethkparam("zshrs_test_gethp_hash"),
15687            Some(vec!["k1".to_string(), "k2".to_string()]),
15688            "c:3138 — paramvalarr(SCANPM_WANTKEYS) returns keys from hashed-storage"
15689        );
15690
15691        // Clean up.
15692        {
15693            let mut tab = paramtab().write().unwrap();
15694            tab.remove("zshrs_test_gethp_scalar");
15695            tab.remove("zshrs_test_gethp_hash");
15696        }
15697        paramtab_hashed_storage()
15698            .lock()
15699            .unwrap()
15700            .remove("zshrs_test_gethp_hash");
15701    }
15702
15703    /// Pin `getaparam` to its canonical C body at `Src/params.c:3101-3110`.
15704    /// Three branches: digit-first reject (c:3107), PM_ARRAY return
15705    /// (c:3108-3109), non-array / missing-param return None (c:3110).
15706    #[test]
15707    fn getaparam_returns_array_for_pm_array_only() {
15708        let _g = crate::test_util::global_state_lock();
15709        // c:3107 — digit-first name → None (positional params reject).
15710        assert_eq!(
15711            getaparam("123abc"),
15712            None,
15713            "c:3107 — digit-first name rejected"
15714        );
15715
15716        // c:3110 — missing param → None.
15717        assert_eq!(
15718            getaparam("zshrs_test_arr_nonexistent_xyz"),
15719            None,
15720            "c:3110 — missing param returns None"
15721        );
15722
15723        // Helper that builds a Param via the canonical createparam
15724        // path so we don't reach into struct internals (param has
15725        // many fields and no Default).
15726        fn build_arr(name: &str, arr: Vec<String>) {
15727            let mut tab = paramtab().write().unwrap();
15728            tab.insert(
15729                name.to_string(),
15730                Box::new(param {
15731                    node: hashnode {
15732                        next: None,
15733                        nam: name.to_string(),
15734                        flags: PM_ARRAY as i32,
15735                    },
15736                    u_data: 0,
15737                    u_tied: None,
15738                    u_arr: Some(arr),
15739                    u_str: None,
15740                    u_val: 0,
15741                    u_dval: 0.0,
15742                    u_hash: None,
15743                    gsu_s: None,
15744                    gsu_i: None,
15745                    gsu_f: None,
15746                    gsu_a: None,
15747                    gsu_h: None,
15748                    base: 0,
15749                    width: 0,
15750                    env: None,
15751                    ename: None,
15752                    old: None,
15753                    level: 0,
15754                }),
15755            );
15756        }
15757        fn build_scalar(name: &str, s: &str) {
15758            let mut tab = paramtab().write().unwrap();
15759            tab.insert(
15760                name.to_string(),
15761                Box::new(param {
15762                    node: hashnode {
15763                        next: None,
15764                        nam: name.to_string(),
15765                        flags: PM_SCALAR as i32,
15766                    },
15767                    u_data: 0,
15768                    u_tied: None,
15769                    u_arr: None,
15770                    u_str: Some(s.to_string()),
15771                    u_val: 0,
15772                    u_dval: 0.0,
15773                    u_hash: None,
15774                    gsu_s: None,
15775                    gsu_i: None,
15776                    gsu_f: None,
15777                    gsu_a: None,
15778                    gsu_h: None,
15779                    base: 0,
15780                    width: 0,
15781                    env: None,
15782                    ename: None,
15783                    old: None,
15784                    level: 0,
15785                }),
15786            );
15787        }
15788
15789        // c:3108 — PM_ARRAY param returns the array contents.
15790        build_arr(
15791            "zshrs_test_getaparam_arr",
15792            vec!["one".to_string(), "two".to_string(), "three".to_string()],
15793        );
15794        assert_eq!(
15795            getaparam("zshrs_test_getaparam_arr"),
15796            Some(vec![
15797                "one".to_string(),
15798                "two".to_string(),
15799                "three".to_string()
15800            ]),
15801            "c:3108-3109 — PM_ARRAY param returns its array"
15802        );
15803
15804        // c:3108 — PM_SCALAR (non-array) param → None.
15805        build_scalar("zshrs_test_getaparam_scalar", "not an array");
15806        assert_eq!(
15807            getaparam("zshrs_test_getaparam_scalar"),
15808            None,
15809            "c:3108 — non-PM_ARRAY param returns None"
15810        );
15811
15812        // Clean up.
15813        {
15814            let mut tab = paramtab().write().unwrap();
15815            tab.remove("zshrs_test_getaparam_arr");
15816            tab.remove("zshrs_test_getaparam_scalar");
15817        }
15818    }
15819
15820    // ═══════════════════════════════════════════════════════════════════
15821    // setX/getX round-trips and cross-type coercion.
15822    // Anchored to zsh behavior:
15823    //   - `typeset -i x=42; print -- $x` → "42"
15824    //   - `x=42; print -- ${(t)x}` → "scalar" (set without typeset stays scalar)
15825    //   - integer ↔ scalar coercion when reading back.
15826    // Each test sets up via setX, reads back via getX, and asserts the
15827    // observable shell-level value.
15828    // ═══════════════════════════════════════════════════════════════════
15829
15830    fn with_exec<F: FnOnce()>(body: F) {
15831        let _g = crate::test_util::global_state_lock();
15832        let saved = opt_state_get("exec").unwrap_or(false);
15833        opt_state_set("exec", true);
15834        body();
15835        opt_state_set("exec", saved);
15836    }
15837
15838    // ── Scalar round-trip ──────────────────────────────────────────
15839    /// `setsparam("X", "hello"); getsparam("X")` → Some("hello").
15840    /// Anchor: `X=hello; print -r -- "$X"` in zsh → `hello`.
15841    #[test]
15842    fn setsparam_then_getsparam_roundtrip_basic() {
15843        with_exec(|| {
15844            unsetparam("zshrs_rt_s1");
15845            setsparam("zshrs_rt_s1", "hello");
15846            assert_eq!(getsparam("zshrs_rt_s1").as_deref(), Some("hello"));
15847            unsetparam("zshrs_rt_s1");
15848        });
15849    }
15850
15851    /// Empty string round-trip — scalar can hold "".
15852    #[test]
15853    fn setsparam_empty_string_roundtrips_as_empty() {
15854        with_exec(|| {
15855            unsetparam("zshrs_rt_s2");
15856            setsparam("zshrs_rt_s2", "");
15857            assert_eq!(getsparam("zshrs_rt_s2").as_deref(), Some(""));
15858            unsetparam("zshrs_rt_s2");
15859        });
15860    }
15861
15862    /// Multi-byte UTF-8 scalar round-trip.
15863    /// Anchor: `X="日本語"; print -r -- "$X"` → "日本語"
15864    #[test]
15865    fn setsparam_multibyte_utf8_roundtrips() {
15866        with_exec(|| {
15867            unsetparam("zshrs_rt_s3");
15868            setsparam("zshrs_rt_s3", "日本語");
15869            assert_eq!(getsparam("zshrs_rt_s3").as_deref(), Some("日本語"));
15870            unsetparam("zshrs_rt_s3");
15871        });
15872    }
15873
15874    /// Embedded newline survives round-trip.
15875    #[test]
15876    fn setsparam_embedded_newline_roundtrips() {
15877        with_exec(|| {
15878            unsetparam("zshrs_rt_s4");
15879            setsparam("zshrs_rt_s4", "a\nb\nc");
15880            assert_eq!(getsparam("zshrs_rt_s4").as_deref(), Some("a\nb\nc"));
15881            unsetparam("zshrs_rt_s4");
15882        });
15883    }
15884
15885    /// Overwrite: setsparam twice returns the latter value.
15886    #[test]
15887    fn setsparam_overwrite_replaces_previous_value() {
15888        with_exec(|| {
15889            unsetparam("zshrs_rt_s5");
15890            setsparam("zshrs_rt_s5", "first");
15891            setsparam("zshrs_rt_s5", "second");
15892            assert_eq!(getsparam("zshrs_rt_s5").as_deref(), Some("second"));
15893            unsetparam("zshrs_rt_s5");
15894        });
15895    }
15896
15897    // ── Integer round-trip ─────────────────────────────────────────
15898    /// `setiparam("X", 42); getiparam("X")` → 42.
15899    /// Anchor: `typeset -i X=42; print -- $X` → "42".
15900    #[test]
15901    fn setiparam_then_getiparam_roundtrip_basic() {
15902        with_exec(|| {
15903            unsetparam("zshrs_rt_i1");
15904            setiparam("zshrs_rt_i1", 42);
15905            assert_eq!(getiparam("zshrs_rt_i1"), 42);
15906            unsetparam("zshrs_rt_i1");
15907        });
15908    }
15909
15910    /// Negative integer round-trip.
15911    #[test]
15912    fn setiparam_negative_value_roundtrips() {
15913        with_exec(|| {
15914            unsetparam("zshrs_rt_i2");
15915            setiparam("zshrs_rt_i2", -12345);
15916            assert_eq!(getiparam("zshrs_rt_i2"), -12345);
15917            unsetparam("zshrs_rt_i2");
15918        });
15919    }
15920
15921    /// Zero round-trip — distinct from "unset".
15922    #[test]
15923    fn setiparam_zero_roundtrips() {
15924        with_exec(|| {
15925            unsetparam("zshrs_rt_i3");
15926            setiparam("zshrs_rt_i3", 0);
15927            assert_eq!(getiparam("zshrs_rt_i3"), 0);
15928            unsetparam("zshrs_rt_i3");
15929        });
15930    }
15931
15932    /// `i64::MAX` and `i64::MIN` survive — zsh uses zlong (= long long
15933    /// on 64-bit hosts, which is i64 in Rust).
15934    #[test]
15935    fn setiparam_i64_extremes_roundtrip() {
15936        with_exec(|| {
15937            unsetparam("zshrs_rt_i4");
15938            setiparam("zshrs_rt_i4", i64::MAX);
15939            assert_eq!(getiparam("zshrs_rt_i4"), i64::MAX);
15940            setiparam("zshrs_rt_i4", i64::MIN);
15941            assert_eq!(getiparam("zshrs_rt_i4"), i64::MIN);
15942            unsetparam("zshrs_rt_i4");
15943        });
15944    }
15945
15946    // ── Cross-type coercion: setiparam → getsparam (int → string) ──
15947    /// `setiparam("X", 42); getsparam("X")` → Some("42").
15948    /// Anchor: `typeset -i X=42; print -r -- "$X"` → "42".
15949    #[test]
15950    fn setiparam_then_getsparam_coerces_to_decimal_string() {
15951        with_exec(|| {
15952            unsetparam("zshrs_rt_x1");
15953            setiparam("zshrs_rt_x1", 42);
15954            assert_eq!(getsparam("zshrs_rt_x1").as_deref(), Some("42"));
15955            unsetparam("zshrs_rt_x1");
15956        });
15957    }
15958
15959    /// Negative int → "-N" string form via getsparam.
15960    #[test]
15961    fn setiparam_negative_int_to_string_carries_minus_sign() {
15962        with_exec(|| {
15963            unsetparam("zshrs_rt_x2");
15964            setiparam("zshrs_rt_x2", -7);
15965            assert_eq!(getsparam("zshrs_rt_x2").as_deref(), Some("-7"));
15966            unsetparam("zshrs_rt_x2");
15967        });
15968    }
15969
15970    // ── Cross-type: setsparam("42") → getiparam → 42 ───────────────
15971    /// `setsparam("X", "42"); getiparam("X")` → 42 (numeric parse).
15972    /// Anchor: `X=42; print -- $((X+0))` → "42" (arith coerces).
15973    #[test]
15974    fn setsparam_numeric_string_then_getiparam_coerces_to_int() {
15975        with_exec(|| {
15976            unsetparam("zshrs_rt_x3");
15977            setsparam("zshrs_rt_x3", "42");
15978            assert_eq!(getiparam("zshrs_rt_x3"), 42);
15979            unsetparam("zshrs_rt_x3");
15980        });
15981    }
15982
15983    /// Non-numeric scalar → getiparam returns 0 (C zsh behavior).
15984    /// Anchor: `X=hello; print -- $((X+0))` → "0" (no numeric parse).
15985    #[test]
15986    fn setsparam_non_numeric_then_getiparam_returns_zero() {
15987        with_exec(|| {
15988            unsetparam("zshrs_rt_x4");
15989            setsparam("zshrs_rt_x4", "not a number");
15990            assert_eq!(getiparam("zshrs_rt_x4"), 0);
15991            unsetparam("zshrs_rt_x4");
15992        });
15993    }
15994
15995    // ── Array round-trip ───────────────────────────────────────────
15996    /// `setaparam("X", vec![...]); getaparam("X")` round-trips elements.
15997    /// Anchor: `X=(a b c); print -r -- "${X[@]}"` → "a b c".
15998    #[test]
15999    fn setaparam_then_getaparam_roundtrip_basic() {
16000        with_exec(|| {
16001            unsetparam("zshrs_rt_a1");
16002            setaparam("zshrs_rt_a1", vec!["a".into(), "b".into(), "c".into()]);
16003            assert_eq!(
16004                getaparam("zshrs_rt_a1"),
16005                Some(vec!["a".into(), "b".into(), "c".into()])
16006            );
16007            unsetparam("zshrs_rt_a1");
16008        });
16009    }
16010
16011    /// Empty array round-trip — distinct from unset.
16012    #[test]
16013    fn setaparam_empty_array_roundtrips_as_empty_vec() {
16014        with_exec(|| {
16015            unsetparam("zshrs_rt_a2");
16016            setaparam("zshrs_rt_a2", vec![]);
16017            assert_eq!(getaparam("zshrs_rt_a2"), Some(vec![]));
16018            unsetparam("zshrs_rt_a2");
16019        });
16020    }
16021
16022    /// Array element with embedded space stays one element (not split).
16023    /// Anchor: `X=("hi there" world); print ${#X}` → 2.
16024    #[test]
16025    fn setaparam_element_with_space_stays_one_element() {
16026        with_exec(|| {
16027            unsetparam("zshrs_rt_a3");
16028            setaparam("zshrs_rt_a3", vec!["hi there".into(), "world".into()]);
16029            assert_eq!(
16030                getaparam("zshrs_rt_a3"),
16031                Some(vec!["hi there".into(), "world".into()])
16032            );
16033            unsetparam("zshrs_rt_a3");
16034        });
16035    }
16036
16037    // ── unsetparam: idempotent + clears value ──────────────────────
16038    /// Unsetting a never-set param does nothing (no panic, no error).
16039    #[test]
16040    fn unsetparam_on_never_set_param_is_noop() {
16041        let _g = crate::test_util::global_state_lock();
16042        unsetparam("zshrs_rt_never_existed");
16043        // No assertion — just must not panic.
16044    }
16045
16046    /// Unset then get → None.
16047    #[test]
16048    fn unsetparam_then_getsparam_returns_none() {
16049        with_exec(|| {
16050            setsparam("zshrs_rt_u1", "value");
16051            unsetparam("zshrs_rt_u1");
16052            assert_eq!(getsparam("zshrs_rt_u1"), None);
16053        });
16054    }
16055
16056    /// Unsetting twice is idempotent.
16057    #[test]
16058    fn unsetparam_twice_is_idempotent() {
16059        with_exec(|| {
16060            setsparam("zshrs_rt_u2", "value");
16061            unsetparam("zshrs_rt_u2");
16062            unsetparam("zshrs_rt_u2"); // second call must not panic
16063            assert_eq!(getsparam("zshrs_rt_u2"), None);
16064        });
16065    }
16066
16067    // ── Cross-type: setiparam writes through scalar table, getaparam None ─
16068    /// PM_INTEGER is NOT PM_ARRAY — getaparam on integer returns None.
16069    /// Anchor: `typeset -i X=42; print -- ${X[1]}` errors (not an array).
16070    #[test]
16071    fn setiparam_then_getaparam_returns_none_not_an_array() {
16072        with_exec(|| {
16073            unsetparam("zshrs_rt_x5");
16074            setiparam("zshrs_rt_x5", 42);
16075            assert_eq!(
16076                getaparam("zshrs_rt_x5"),
16077                None,
16078                "PM_INTEGER is not PM_ARRAY — getaparam must return None"
16079            );
16080            unsetparam("zshrs_rt_x5");
16081        });
16082    }
16083
16084    // ── Read missing param: returns None / 0 ───────────────────────
16085    /// `getsparam("doesnt_exist")` → None.
16086    #[test]
16087    fn getsparam_on_unset_returns_none() {
16088        let _g = crate::test_util::global_state_lock();
16089        unsetparam("zshrs_rt_missing");
16090        assert_eq!(getsparam("zshrs_rt_missing"), None);
16091    }
16092
16093    /// `getiparam("doesnt_exist")` → 0 (C zsh semantics).
16094    /// Anchor: `unset X; print -- $((X+1))` → "1" (X read as 0).
16095    #[test]
16096    fn getiparam_on_unset_returns_zero() {
16097        let _g = crate::test_util::global_state_lock();
16098        unsetparam("zshrs_rt_missing2");
16099        assert_eq!(getiparam("zshrs_rt_missing2"), 0);
16100    }
16101
16102    /// `getaparam("doesnt_exist")` → None.
16103    #[test]
16104    fn getaparam_on_unset_returns_none() {
16105        let _g = crate::test_util::global_state_lock();
16106        unsetparam("zshrs_rt_missing3");
16107        assert_eq!(getaparam("zshrs_rt_missing3"), None);
16108    }
16109
16110    // ─── zsh corpus pins: params set/get round-trips ─────────────────
16111
16112    /// `setsparam("foo", "bar")` followed by `getsparam("foo")` returns
16113    /// `"bar"`. Round-trip scalar.
16114    #[test]
16115    fn params_corpus_scalar_round_trip_simple() {
16116        let _g = crate::test_util::global_state_lock();
16117        unsetparam("ZP_RT1");
16118        setsparam("ZP_RT1", "bar");
16119        assert_eq!(getsparam("ZP_RT1").as_deref(), Some("bar"));
16120        unsetparam("ZP_RT1");
16121    }
16122
16123    /// `setsparam("x", "")` then `getsparam("x")` returns `Some("")`,
16124    /// NOT `None` — empty-set is distinct from unset (per zsh semantics).
16125    #[test]
16126    fn params_corpus_empty_scalar_is_set_not_unset() {
16127        let _g = crate::test_util::global_state_lock();
16128        unsetparam("ZP_EMP");
16129        setsparam("ZP_EMP", "");
16130        assert_eq!(getsparam("ZP_EMP").as_deref(), Some(""), "empty-set is set");
16131        unsetparam("ZP_EMP");
16132    }
16133
16134    /// `unsetparam` after `setsparam` removes the param entirely:
16135    /// `getsparam` returns `None`.
16136    #[test]
16137    fn params_corpus_unset_after_set_returns_none() {
16138        let _g = crate::test_util::global_state_lock();
16139        setsparam("ZP_UR", "v");
16140        unsetparam("ZP_UR");
16141        assert_eq!(getsparam("ZP_UR"), None, "unsetparam removes param");
16142    }
16143
16144    /// `setiparam("i", 42)` and `getiparam` round-trip integer.
16145    #[test]
16146    fn params_corpus_integer_round_trip() {
16147        let _g = crate::test_util::global_state_lock();
16148        unsetparam("ZP_I");
16149        setiparam("ZP_I", 42);
16150        assert_eq!(getiparam("ZP_I"), 42);
16151        unsetparam("ZP_I");
16152    }
16153
16154    /// `setiparam` then `getsparam` reads integer as string.
16155    #[test]
16156    fn params_corpus_integer_read_as_string() {
16157        let _g = crate::test_util::global_state_lock();
16158        unsetparam("ZP_IS");
16159        setiparam("ZP_IS", 42);
16160        assert_eq!(
16161            getsparam("ZP_IS").as_deref(),
16162            Some("42"),
16163            "integer param reads as string repr"
16164        );
16165        unsetparam("ZP_IS");
16166    }
16167
16168    /// `setaparam("a", vec!["one","two","three"])` round-trips via
16169    /// `getaparam`.
16170    #[test]
16171    fn params_corpus_array_round_trip() {
16172        let _g = crate::test_util::global_state_lock();
16173        unsetparam("ZP_A");
16174        setaparam("ZP_A", vec!["one".into(), "two".into(), "three".into()]);
16175        assert_eq!(
16176            getaparam("ZP_A").as_deref(),
16177            Some(&["one".into(), "two".into(), "three".into()][..]),
16178            "array round-trip",
16179        );
16180        unsetparam("ZP_A");
16181    }
16182
16183    /// Empty array round-trip — `setaparam("a", vec![])` then
16184    /// `getaparam` returns `Some(empty vec)`, not `None`.
16185    #[test]
16186    fn params_corpus_empty_array_is_set_not_unset() {
16187        let _g = crate::test_util::global_state_lock();
16188        unsetparam("ZP_EA");
16189        setaparam("ZP_EA", vec![]);
16190        let got = getaparam("ZP_EA");
16191        assert!(got.is_some(), "empty array is set, not None");
16192        assert_eq!(got.unwrap().len(), 0, "len = 0");
16193        unsetparam("ZP_EA");
16194    }
16195
16196    /// `setsparam` overwrites previous value (scalar reassignment).
16197    #[test]
16198    fn params_corpus_scalar_overwrite() {
16199        let _g = crate::test_util::global_state_lock();
16200        unsetparam("ZP_O");
16201        setsparam("ZP_O", "first");
16202        setsparam("ZP_O", "second");
16203        assert_eq!(
16204            getsparam("ZP_O").as_deref(),
16205            Some("second"),
16206            "scalar overwrites cleanly"
16207        );
16208        unsetparam("ZP_O");
16209    }
16210
16211    /// Re-assigning a scalar to an array (changing type via setaparam).
16212    /// Default zsh allows this: array replaces scalar.
16213    #[test]
16214    fn params_corpus_scalar_to_array_replace_type() {
16215        let _g = crate::test_util::global_state_lock();
16216        unsetparam("ZP_ST");
16217        setsparam("ZP_ST", "scalar_val");
16218        setaparam("ZP_ST", vec!["a".into(), "b".into()]);
16219        assert_eq!(
16220            getaparam("ZP_ST").as_deref(),
16221            Some(&["a".into(), "b".into()][..]),
16222            "setaparam replaces scalar type",
16223        );
16224        unsetparam("ZP_ST");
16225    }
16226
16227    /// `unsetparam` on a name that doesn't exist is a no-op
16228    /// (zsh's idempotent unset).
16229    #[test]
16230    fn params_corpus_unset_on_missing_is_noop() {
16231        let _g = crate::test_util::global_state_lock();
16232        unsetparam("ZP_NEVER_EXISTED_42_xyz");
16233        // No panic: just returns None on subsequent get.
16234        assert_eq!(getsparam("ZP_NEVER_EXISTED_42_xyz"), None);
16235    }
16236
16237    /// Empty string passed to integer slot: `setiparam("z", 0)`
16238    /// returns `Some(0)` via getiparam (zero is a legitimate int).
16239    #[test]
16240    fn params_corpus_integer_zero_is_set() {
16241        let _g = crate::test_util::global_state_lock();
16242        unsetparam("ZP_IZ");
16243        setiparam("ZP_IZ", 0);
16244        assert_eq!(getiparam("ZP_IZ"), 0);
16245        assert_eq!(getsparam("ZP_IZ").as_deref(), Some("0"));
16246        unsetparam("ZP_IZ");
16247    }
16248
16249    /// Negative integers round-trip with sign preserved.
16250    #[test]
16251    fn params_corpus_integer_negative_preserves_sign() {
16252        let _g = crate::test_util::global_state_lock();
16253        unsetparam("ZP_IN");
16254        setiparam("ZP_IN", -42);
16255        assert_eq!(getiparam("ZP_IN"), -42);
16256        assert_eq!(getsparam("ZP_IN").as_deref(), Some("-42"));
16257        unsetparam("ZP_IN");
16258    }
16259
16260    // ─── associative array (hash) pins ───────────────────────────────
16261
16262    /// `sethparam` + `gethparam` round-trip. C `gethparam` returns
16263    /// `paramvalarr(..., SCANPM_WANTVALS)` (Src/params.c:3122) — i.e.
16264    /// the VALUES side of the hash, not flat k/v pairs. For
16265    /// `(key1 val1 key2 val2)` that's `[val1, val2]`. Keys come from
16266    /// `gethkparam` (covered by `..._hash_keys_only_returns_keys`).
16267    #[test]
16268    fn params_corpus_hash_round_trip_basic() {
16269        let _g = crate::test_util::global_state_lock();
16270        unsetparam("ZP_H");
16271        sethparam(
16272            "ZP_H",
16273            vec!["key1".into(), "val1".into(), "key2".into(), "val2".into()],
16274        );
16275        let got = gethparam("ZP_H");
16276        assert!(got.is_some(), "hash param set");
16277        let g = got.unwrap();
16278        assert_eq!(g.len(), 2, "2 values (SCANPM_WANTVALS) preserved");
16279        let mut sorted = g.clone();
16280        sorted.sort();
16281        assert_eq!(
16282            sorted,
16283            vec!["val1".to_string(), "val2".to_string()],
16284            "values come back regardless of hash-iter order"
16285        );
16286        unsetparam("ZP_H");
16287    }
16288
16289    /// `gethparam` on missing returns None.
16290    #[test]
16291    fn params_corpus_hash_missing_returns_none() {
16292        let _g = crate::test_util::global_state_lock();
16293        unsetparam("ZP_HM_xyz");
16294        assert!(gethparam("ZP_HM_xyz").is_none());
16295    }
16296
16297    /// `gethkparam` returns the keys-only view of a hash. With
16298    /// `{a:1,b:2}`, keys are `["a","b"]` (order may not be stable).
16299    #[test]
16300    fn params_corpus_hash_keys_only_returns_keys() {
16301        let _g = crate::test_util::global_state_lock();
16302        unsetparam("ZP_HK");
16303        sethparam(
16304            "ZP_HK",
16305            vec!["a".into(), "1".into(), "b".into(), "2".into()],
16306        );
16307        let keys = gethkparam("ZP_HK");
16308        assert!(keys.is_some(), "keys-only view exists");
16309        let k = keys.unwrap();
16310        assert_eq!(k.len(), 2, "2 keys");
16311        let mut sorted = k.clone();
16312        sorted.sort();
16313        assert_eq!(sorted, vec!["a".to_string(), "b".to_string()]);
16314        unsetparam("ZP_HK");
16315    }
16316
16317    /// Setting a hash with empty `Vec` removes the previous key/value
16318    /// pairs. After empty `sethparam`, lookups find no entries. Note
16319    /// `gethparam` returns SCANPM_WANTVALS (values only); `(a 1)` has
16320    /// 1 value, not 2 — see `..._hash_round_trip_basic`.
16321    #[test]
16322    fn params_corpus_hash_set_empty_clears_entries() {
16323        let _g = crate::test_util::global_state_lock();
16324        unsetparam("ZP_HC");
16325        sethparam("ZP_HC", vec!["a".into(), "1".into()]);
16326        assert_eq!(gethparam("ZP_HC").map(|v| v.len()), Some(1));
16327        sethparam("ZP_HC", vec![]);
16328        // empty hash still exists (some implementations distinguish
16329        // empty hash from unset)
16330        let after = gethparam("ZP_HC");
16331        if let Some(v) = after {
16332            assert!(v.is_empty(), "empty hash has 0 elements");
16333        }
16334        unsetparam("ZP_HC");
16335    }
16336
16337    /// Large integer at i64 boundary: i64::MAX round-trips.
16338    #[test]
16339    fn params_corpus_integer_max_round_trips() {
16340        let _g = crate::test_util::global_state_lock();
16341        unsetparam("ZP_IMX");
16342        setiparam("ZP_IMX", i64::MAX);
16343        assert_eq!(getiparam("ZP_IMX"), i64::MAX);
16344        unsetparam("ZP_IMX");
16345    }
16346
16347    /// i64::MIN round-trips with sign.
16348    #[test]
16349    fn params_corpus_integer_min_round_trips() {
16350        let _g = crate::test_util::global_state_lock();
16351        unsetparam("ZP_IMN");
16352        setiparam("ZP_IMN", i64::MIN);
16353        assert_eq!(getiparam("ZP_IMN"), i64::MIN);
16354        unsetparam("ZP_IMN");
16355    }
16356
16357    // ═══════════════════════════════════════════════════════════════════
16358    // C-parity tests pinning Src/params.c:1288 (isident). Foundational
16359    // identifier validator used by every param-name validation site.
16360    // ═══════════════════════════════════════════════════════════════════
16361
16362    /// `isident("")` returns false. C c:1292:
16363    ///   `if (!*s) return 0;`
16364    #[test]
16365    fn isident_empty_string_returns_false() {
16366        let _g = crate::test_util::global_state_lock();
16367        assert!(!isident(""));
16368    }
16369
16370    /// `isident("foo")` returns true — basic identifier.
16371    #[test]
16372    fn isident_alpha_identifier_returns_true() {
16373        let _g = crate::test_util::global_state_lock();
16374        assert!(isident("foo"));
16375    }
16376
16377    /// `isident("_foo")` — underscore prefix allowed.
16378    #[test]
16379    fn isident_underscore_prefix_returns_true() {
16380        let _g = crate::test_util::global_state_lock();
16381        assert!(isident("_foo"));
16382    }
16383
16384    /// `isident("foo_bar123")` — alnum + underscore mid.
16385    #[test]
16386    fn isident_alnum_with_underscore_returns_true() {
16387        let _g = crate::test_util::global_state_lock();
16388        assert!(isident("foo_bar123"));
16389    }
16390
16391    /// `isident("123")` — all-digit positional param. C c:1300+:
16392    ///   "All-digit names are valid (positional params)"
16393    #[test]
16394    fn isident_all_digit_positional_returns_true() {
16395        let _g = crate::test_util::global_state_lock();
16396        assert!(isident("123"), "all-digit names are positional params");
16397    }
16398
16399    /// `isident("123abc")` — digit prefix with letters → false.
16400    /// Mixed digit-first not a valid positional param OR identifier.
16401    #[test]
16402    fn isident_digit_prefix_alpha_returns_false() {
16403        let _g = crate::test_util::global_state_lock();
16404        assert!(!isident("123abc"));
16405    }
16406
16407    /// `isident(".ns.foo")` — ksh93 namespace dotted name allowed.
16408    /// C c:1296-1311 — leading `.` accepted if not followed by digit.
16409    #[test]
16410    fn isident_ksh93_namespace_dot_prefix_returns_true() {
16411        let _g = crate::test_util::global_state_lock();
16412        assert!(isident(".ns.foo"));
16413    }
16414
16415    /// `isident(".0bad")` — namespace must NOT start with digit.
16416    /// C c:1300: `if (idigit(s[1])) return 0;`
16417    #[test]
16418    fn isident_namespace_starting_with_digit_returns_false() {
16419        let _g = crate::test_util::global_state_lock();
16420        assert!(!isident(".0bad"), "namespace can't start with digit");
16421    }
16422
16423    /// `isident("foo-bar")` — dash not allowed in identifier.
16424    #[test]
16425    fn isident_dash_in_name_returns_false() {
16426        let _g = crate::test_util::global_state_lock();
16427        assert!(!isident("foo-bar"));
16428    }
16429
16430    /// `isident("foo bar")` — space not allowed.
16431    #[test]
16432    fn isident_space_in_name_returns_false() {
16433        let _g = crate::test_util::global_state_lock();
16434        assert!(!isident("foo bar"));
16435    }
16436
16437    /// `isident("foo[0]")` — subscript at end is allowed (C handles
16438    /// it in `isident_requires_balanced_subscript_brackets`).
16439    /// Pin: a simple `[0]` subscript validates.
16440    #[test]
16441    fn isident_with_simple_subscript_returns_true() {
16442        let _g = crate::test_util::global_state_lock();
16443        assert!(isident("foo[0]"));
16444    }
16445
16446    // ═══════════════════════════════════════════════════════════════════
16447    // Additional C-parity tests for Src/params.c isident.
16448    // ═══════════════════════════════════════════════════════════════════
16449
16450    /// c:1288 — `isident("")` returns false (empty not an identifier).
16451    #[test]
16452    fn isident_empty_returns_false() {
16453        let _g = crate::test_util::global_state_lock();
16454        assert!(!isident(""));
16455    }
16456
16457    /// c:1288 — single underscore IS valid ident.
16458    #[test]
16459    fn isident_single_underscore_is_valid() {
16460        let _g = crate::test_util::global_state_lock();
16461        assert!(isident("_"));
16462    }
16463
16464    /// c:1288 — single letter IS valid ident.
16465    #[test]
16466    fn isident_single_letter_is_valid() {
16467        let _g = crate::test_util::global_state_lock();
16468        assert!(isident("a"));
16469        assert!(isident("Z"));
16470    }
16471
16472    /// c:1288 — all-digit name (positional param like '$1') IS valid.
16473    #[test]
16474    fn isident_all_digits_is_valid_positional() {
16475        let _g = crate::test_util::global_state_lock();
16476        assert!(isident("1"));
16477        assert!(isident("123"));
16478        assert!(isident("0"));
16479    }
16480
16481    /// c:1288 — digit-first mixed (e.g. '1abc') is INVALID.
16482    #[test]
16483    fn isident_digit_first_mixed_is_invalid() {
16484        let _g = crate::test_util::global_state_lock();
16485        assert!(!isident("1abc"));
16486        assert!(!isident("0x"));
16487    }
16488
16489    /// c:1288 — underscore + alnum is valid.
16490    #[test]
16491    fn isident_underscore_prefix_alnum_is_valid() {
16492        let _g = crate::test_util::global_state_lock();
16493        assert!(isident("_foo"));
16494        assert!(isident("_123"));
16495        assert!(isident("foo_bar"));
16496    }
16497
16498    /// c:1288 — alphabetic + digits is valid.
16499    #[test]
16500    fn isident_alpha_digits_mix_is_valid() {
16501        let _g = crate::test_util::global_state_lock();
16502        assert!(isident("foo123"));
16503        assert!(isident("var2"));
16504    }
16505
16506    /// c:1326 — unbalanced subscript `foo[` is invalid.
16507    #[test]
16508    fn isident_unbalanced_open_bracket_returns_false() {
16509        let _g = crate::test_util::global_state_lock();
16510        assert!(!isident("foo["));
16511        assert!(!isident("foo[0"));
16512    }
16513
16514    /// c:1326 — special chars like `-`, `.`, `@` not allowed in identifier.
16515    #[test]
16516    fn isident_special_chars_rejected() {
16517        let _g = crate::test_util::global_state_lock();
16518        assert!(!isident("foo-bar")); // hyphen
16519        assert!(!isident("foo@bar"));
16520        assert!(!isident("foo!"));
16521    }
16522
16523    /// c:1288 — namespace prefix `.ns.var` requires a non-digit after dot.
16524    #[test]
16525    fn isident_namespace_prefix_dot_rejects_digit_after() {
16526        let _g = crate::test_util::global_state_lock();
16527        assert!(!isident(".0"), "dot + digit invalid");
16528        assert!(!isident("."), "lone dot invalid");
16529    }
16530
16531    /// c:1288 — namespace prefix `.foo` is valid.
16532    #[test]
16533    fn isident_namespace_prefix_alpha_after_dot_valid() {
16534        let _g = crate::test_util::global_state_lock();
16535        assert!(isident(".foo"));
16536        assert!(isident(".ns.var"));
16537    }
16538
16539    /// c:1288 — `isident` is deterministic.
16540    #[test]
16541    fn isident_is_deterministic() {
16542        let _g = crate::test_util::global_state_lock();
16543        for s in ["foo", "_bar", "123", "1abc", "", ".foo", "foo[", "foo-bar"] {
16544            let first = isident(s);
16545            for _ in 0..5 {
16546                assert_eq!(isident(s), first, "{:?} must be pure", s);
16547            }
16548        }
16549    }
16550
16551    // ═══════════════════════════════════════════════════════════════════
16552    // Additional C-parity tests for Src/params.c
16553    // c:1403 issetvar / c:2152 setsparam / c:2317 isident / c:4099 getiparam
16554    // c:4191 getsparam / c:4326 getsparam_u / c:4353 getaparam /
16555    // c:5363 setaparam / c:5696 setiparam / c:5775 unsetparam
16556    // ═══════════════════════════════════════════════════════════════════
16557
16558    /// c:1403 — `issetvar` returns i32 (compile-time type pin).
16559    #[test]
16560    fn issetvar_returns_i32_type() {
16561        let _g = crate::test_util::global_state_lock();
16562        let _: i32 = issetvar("anything");
16563    }
16564
16565    /// c:1403 — `issetvar("__nonexistent")` returns 0 for unset.
16566    #[test]
16567    fn issetvar_unknown_returns_zero() {
16568        let _g = crate::test_util::global_state_lock();
16569        let r = issetvar("__never_a_real_var_xyz_zshrs__");
16570        assert_eq!(r, 0, "unset var → 0");
16571    }
16572
16573    /// c:4191 — `getsparam` returns Option<String>.
16574    #[test]
16575    fn getsparam_returns_option_string_type() {
16576        let _g = crate::test_util::global_state_lock();
16577        let _: Option<String> = getsparam("anything");
16578    }
16579
16580    /// c:4191 — `getsparam("__nonexistent")` returns None.
16581    #[test]
16582    fn getsparam_unknown_returns_none() {
16583        let _g = crate::test_util::global_state_lock();
16584        assert!(
16585            getsparam("__never_a_real_var_xyz_zshrs__").is_none(),
16586            "unknown var → None"
16587        );
16588    }
16589
16590    /// c:4099 — `getiparam` returns i64.
16591    #[test]
16592    fn getiparam_returns_i64_type() {
16593        let _g = crate::test_util::global_state_lock();
16594        let _: i64 = getiparam("anything");
16595    }
16596
16597    /// c:4353 — `getaparam` returns Option<Vec<String>>.
16598    #[test]
16599    fn getaparam_returns_option_vec_string_type() {
16600        let _g = crate::test_util::global_state_lock();
16601        let _: Option<Vec<String>> = getaparam("anything");
16602    }
16603
16604    /// c:4326 — `getsparam_u("")` empty returns None.
16605    #[test]
16606    fn getsparam_u_empty_returns_none() {
16607        let _g = crate::test_util::global_state_lock();
16608        assert!(getsparam_u("").is_none(), "empty → None");
16609    }
16610
16611    /// c:2152 — `setsparam`/`getsparam` round-trip.
16612    #[test]
16613    fn setsparam_getsparam_round_trip() {
16614        let _g = crate::test_util::global_state_lock();
16615        let name = "ZSHRS_TEST_SETSPARAM_ROUND_TRIP";
16616        let _ = unsetparam(name);
16617        setsparam(name, "hello");
16618        assert_eq!(
16619            getsparam(name).as_deref(),
16620            Some("hello"),
16621            "setsparam/getsparam round-trip"
16622        );
16623        unsetparam(name);
16624    }
16625
16626    /// c:5696 — `setiparam`/`getiparam` round-trip.
16627    #[test]
16628    fn setiparam_getiparam_round_trip() {
16629        let _g = crate::test_util::global_state_lock();
16630        let name = "ZSHRS_TEST_SETIPARAM_ROUND_TRIP";
16631        unsetparam(name);
16632        setiparam(name, 42);
16633        assert_eq!(getiparam(name), 42, "setiparam/getiparam round-trip");
16634        unsetparam(name);
16635    }
16636
16637    /// c:5363 — `setaparam`/`getaparam` round-trip preserves array.
16638    #[test]
16639    fn setaparam_getaparam_round_trip() {
16640        let _g = crate::test_util::global_state_lock();
16641        let name = "ZSHRS_TEST_SETAPARAM_ROUND_TRIP";
16642        unsetparam(name);
16643        let v = vec!["a".to_string(), "b".to_string(), "c".to_string()];
16644        setaparam(name, v.clone());
16645        assert_eq!(getaparam(name), Some(v), "setaparam/getaparam round-trip");
16646        unsetparam(name);
16647    }
16648
16649    /// c:5775 — `unsetparam` is idempotent on unset names.
16650    #[test]
16651    fn unsetparam_unknown_name_no_panic() {
16652        let _g = crate::test_util::global_state_lock();
16653        unsetparam("__never_real_var_xyz__");
16654        unsetparam("__never_real_var_xyz__");
16655    }
16656
16657    // ═══════════════════════════════════════════════════════════════════
16658    // Additional C-parity tests for Src/params.c GSU vtable callbacks.
16659    // c:3993 intgetfn   / c:4002 intsetfn   / c:4011 floatgetfn
16660    // c:4020 floatsetfn / c:4029 strgetfn   / c:4057 arrgetfn
16661    // c:4084 hashgetfn  / c:4093 hashsetfn  / c:4180 nullstrsetfn
16662    // c:4192 nullunsetfn
16663    // ═══════════════════════════════════════════════════════════════════
16664
16665    /// c:3993-3997 — `intgetfn` body is `return pm->u.val;`. Pin that
16666    /// the Rust port reads `pm.u_val` verbatim with no transform.
16667    #[test]
16668    fn intgetfn_returns_u_val_verbatim() {
16669        let _g = crate::test_util::global_state_lock();
16670        let mut pm = param::default();
16671        pm.u_val = 0;
16672        assert_eq!(intgetfn(&pm), 0, "u_val=0 → 0");
16673        pm.u_val = 42;
16674        assert_eq!(intgetfn(&pm), 42, "u_val=42 → 42");
16675        pm.u_val = i64::MIN;
16676        assert_eq!(intgetfn(&pm), i64::MIN, "u_val=i64::MIN preserved");
16677        pm.u_val = i64::MAX;
16678        assert_eq!(intgetfn(&pm), i64::MAX, "u_val=i64::MAX preserved");
16679    }
16680
16681    /// c:4002-4006 — `intsetfn` body is `pm->u.val = x;`. For a
16682    /// non-special name (not SECONDS/RANDOM), the Rust port must
16683    /// write `pm.u_val = x` and intgetfn must round-trip.
16684    #[test]
16685    fn intsetfn_intgetfn_round_trip_for_non_special_name() {
16686        let _g = crate::test_util::global_state_lock();
16687        let mut pm = param::default();
16688        pm.node.nam = "X".to_string(); // non-SECONDS, non-RANDOM
16689        intsetfn(&mut pm, 7);
16690        assert_eq!(intgetfn(&pm), 7, "non-special intsetfn writes u_val");
16691        intsetfn(&mut pm, -123);
16692        assert_eq!(intgetfn(&pm), -123, "negative i64 round-trips");
16693    }
16694
16695    /// c:4011-4015 — `floatgetfn` body is `return pm->u.dval;`.
16696    /// Read `pm.u_dval` verbatim with no transform.
16697    #[test]
16698    fn floatgetfn_returns_u_dval_verbatim() {
16699        let _g = crate::test_util::global_state_lock();
16700        let mut pm = param::default();
16701        pm.u_dval = 0.0;
16702        assert_eq!(floatgetfn(&pm), 0.0, "u_dval=0.0 → 0.0");
16703        pm.u_dval = 3.14;
16704        assert_eq!(floatgetfn(&pm), 3.14, "u_dval=3.14 preserved");
16705        pm.u_dval = -2.71;
16706        assert_eq!(floatgetfn(&pm), -2.71, "negative dval preserved");
16707    }
16708
16709    /// c:4020-4024 — `floatsetfn` body for non-SECONDS is
16710    /// `pm->u.dval = x;`. Round-trip via floatgetfn.
16711    #[test]
16712    fn floatsetfn_floatgetfn_round_trip_for_non_seconds() {
16713        let _g = crate::test_util::global_state_lock();
16714        let mut pm = param::default();
16715        pm.node.nam = "Y".to_string(); // non-SECONDS
16716        floatsetfn(&mut pm, 1.5);
16717        assert_eq!(floatgetfn(&pm), 1.5, "non-SECONDS floatsetfn writes u_dval");
16718        floatsetfn(&mut pm, -0.0);
16719        assert_eq!(floatgetfn(&pm), -0.0, "neg zero round-trips");
16720    }
16721
16722    /// c:4029-4033 — `strgetfn` C body returns `pm->u.str ? pm->u.str
16723    /// : (char *) hcalloc(1);` — when u_str is None the C path
16724    /// returns a freshly allocated empty C string. Rust port must
16725    /// return `String::new()` (empty owned String) for the None case.
16726    #[test]
16727    fn strgetfn_returns_empty_string_when_u_str_none() {
16728        let _g = crate::test_util::global_state_lock();
16729        let mut pm = param::default();
16730        pm.u_str = None;
16731        let s = strgetfn(&pm);
16732        assert!(
16733            s.is_empty(),
16734            "None u_str → empty String (port of hcalloc(1))"
16735        );
16736    }
16737
16738    /// c:4029-4033 — `strgetfn` returns u_str clone when present.
16739    #[test]
16740    fn strgetfn_returns_clone_when_u_str_some() {
16741        let _g = crate::test_util::global_state_lock();
16742        let mut pm = param::default();
16743        pm.u_str = Some("hello".to_string());
16744        assert_eq!(strgetfn(&pm), "hello", "Some(s) → s");
16745    }
16746
16747    /// c:4057-4061 — `arrgetfn` C body returns `pm->u.arr ? pm->u.arr
16748    /// : &nullarray;` — when u_arr is None, the C path returns a
16749    /// pointer to the static empty `nullarray`. Rust port returns
16750    /// `Vec::new()` for the None case (empty owned Vec).
16751    #[test]
16752    fn arrgetfn_returns_empty_vec_when_u_arr_none() {
16753        let _g = crate::test_util::global_state_lock();
16754        let mut pm = param::default();
16755        pm.u_arr = None;
16756        let v = arrgetfn(&pm);
16757        assert!(v.is_empty(), "None u_arr → empty Vec (port of &nullarray)");
16758    }
16759
16760    /// c:4057-4061 — `arrgetfn` returns u_arr clone when present.
16761    #[test]
16762    fn arrgetfn_returns_clone_when_u_arr_some() {
16763        let _g = crate::test_util::global_state_lock();
16764        let mut pm = param::default();
16765        let src = vec!["a".to_string(), "b".to_string(), "c".to_string()];
16766        pm.u_arr = Some(src.clone());
16767        assert_eq!(arrgetfn(&pm), src, "Some(v) → v clone");
16768    }
16769
16770    /// c:4084-4088 — `hashgetfn` C body is `return pm->u.hash;`. The
16771    /// Rust port returns `Option<&HashTable>` (borrowing form of the
16772    /// C nullable pointer). Pin both the None and Some cases.
16773    #[test]
16774    fn hashgetfn_returns_option_ref_hashtable() {
16775        let _g = crate::test_util::global_state_lock();
16776        let mut pm = param::default();
16777        pm.u_hash = None;
16778        assert!(hashgetfn(&pm).is_none(), "None u_hash → None");
16779        pm.u_hash = newparamtable(8, "h");
16780        assert!(hashgetfn(&pm).is_some(), "Some u_hash → Some");
16781    }
16782
16783    /// c:4093-4097 — `hashsetfn` C body installs `x` into `pm->u.hash`.
16784    /// Rust port writes `pm.u_hash = Some(x)`; round-trip via
16785    /// hashgetfn must observe the same table.
16786    #[test]
16787    fn hashsetfn_hashgetfn_round_trip() {
16788        let _g = crate::test_util::global_state_lock();
16789        let mut pm = param::default();
16790        let ht = newparamtable(8, "h").expect("newparamtable returns Some");
16791        let want_size = ht.hsize;
16792        hashsetfn(&mut pm, ht);
16793        let got = hashgetfn(&pm).expect("hashsetfn must store table");
16794        assert_eq!(got.hsize, want_size, "stored table size preserved");
16795    }
16796
16797    /// c:4180-4183 — `nullstrsetfn(UNUSED pm, char *x)` is the
16798    /// PM_SPECIAL "cannot be set" hook: `zsfree(x);` discards x and
16799    /// makes no change to pm. Pin that the Rust port leaves every
16800    /// field of pm untouched.
16801    #[test]
16802    fn nullstrsetfn_leaves_pm_unchanged() {
16803        let _g = crate::test_util::global_state_lock();
16804        let mut pm = param::default();
16805        pm.node.nam = "PINNED".to_string();
16806        pm.u_str = Some("orig".to_string());
16807        pm.u_val = 99;
16808        nullstrsetfn(&mut pm, "discarded".to_string());
16809        assert_eq!(pm.node.nam, "PINNED", "nam untouched");
16810        assert_eq!(pm.u_str.as_deref(), Some("orig"), "u_str untouched");
16811        assert_eq!(pm.u_val, 99, "u_val untouched");
16812    }
16813
16814    /// c:4192-4195 — `nullunsetfn(UNUSED pm, UNUSED exp)` is the
16815    /// PM_SPECIAL "cannot be unset" hook: empty body. Pin that the
16816    /// Rust port leaves every field of pm untouched.
16817    #[test]
16818    fn nullunsetfn_leaves_pm_unchanged() {
16819        let _g = crate::test_util::global_state_lock();
16820        let mut pm = param::default();
16821        pm.node.nam = "PINNED".to_string();
16822        pm.node.flags = PM_SCALAR as i32;
16823        pm.u_str = Some("keep".to_string());
16824        nullunsetfn(&mut pm, 0);
16825        nullunsetfn(&mut pm, 1);
16826        assert_eq!(pm.node.nam, "PINNED", "nam untouched");
16827        assert_eq!(pm.node.flags, PM_SCALAR as i32, "flags untouched");
16828        assert_eq!(pm.u_str.as_deref(), Some("keep"), "u_str untouched");
16829    }
16830}
16831
16832// ===========================================================
16833// !!! WARNING: RUST-ONLY NAMEREF ADAPTERS — NO DIRECT C
16834// COUNTERPARTS AS FREE FUNCTIONS !!!
16835//
16836// C's nameref machinery (Src/params.c:6325-6500 resolve_nameref /
16837// resolve_nameref_rec / upscope / setscope / valid_refname, plus the
16838// PM_NAMEREF arms of fetchvalue c:2247-2270 and assignstrvalue
16839// c:2690-2720) walks live Param POINTERS chained through
16840// realparamtab. zshrs's paramtab hands out CLONES behind an RwLock,
16841// so the chain walk must re-look-up each hop BY NAME — these
16842// adapters are that by-name walk plus its carriers. Every entry
16843// here exists to adapt a cited C pattern to the lock/clone storage,
16844// in the same class as fdtable_get / sess_get. The canonical
16845// C-named ports (resolve_nameref at params.rs, resolve_nameref_rec,
16846// upscope, setscope, valid_refname) delegate INTO this walk.
16847//
16848// Relocated from vm_helper.rs (2026-06-12): ported code was calling
16849// bridge-resident helpers, inverting the layering rule. Bridge
16850// callers reach these via the vm_helper re-export.
16851// ===========================================================
16852
16853/// Result of walking a nameref chain by name. Rust-side carrier for
16854/// what C's `resolve_nameref_rec` (Src/params.c:6332) communicates
16855/// via the returned `Param` pointer: the chain may end at the final
16856/// non-ref target, at a placeholder ref (empty refname), at a
16857/// subscripted ref (`pm->width` ends the chain per c:6339), at a
16858/// dangling name (target never defined), or in a loop.
16859#[allow(non_camel_case_types)]
16860pub enum nameref_resolution {
16861    /// `name` is not a nameref (or doesn't exist / is an unset ref).
16862    NotRef,
16863    /// Chain ends at a nameref whose refname is empty/None —
16864    /// the C `keep_lastref` shape (c:6354). Field = that ref's name.
16865    Placeholder(String),
16866    /// Final non-ref target. `pm` is a clone of the resolved binding
16867    /// (None when the target name was never defined — C returns NULL
16868    /// at c:6347 when gethashnode2 misses). `level` is the resolved
16869    /// binding's scope level (for old-chain writes).
16870    Target {
16871        /// final target name
16872        name: String,
16873        /// subscript from a `name[sub]` refname (ends the chain, c:6339)
16874        subscript: Option<String>,
16875        /// clone of the resolved binding (possibly a hidden old-chain node)
16876        pm: Option<Param>,
16877        /// scope level of the resolved binding (only when pm is Some)
16878        level: i32,
16879    },
16880    /// Loop detected — `zerr("...: invalid self reference")` already
16881    /// emitted (c:6341-6343).
16882    SelfRef,
16883    /// The refname EXISTS in the table but the upscope walk excluded
16884    /// every binding (c:6347-6349: `loadparamnode(upscope(...))`
16885    /// returned NULL with gethashnode2 non-NULL, so the `keep_lastref`
16886    /// else-arm is SKIPPED). Reads see unset; assignment FAILS
16887    /// (createparam c:1108-1118 refuses the existing ref).
16888    OutOfScope,
16889}
16890
16891/// Walk the nameref chain starting at `name` against the live
16892/// paramtab. Faithful adaptation of `resolve_nameref_rec`
16893/// (Src/params.c:6332-6357): per-hop `upscope` via the old-chain,
16894/// `pm->width` subscript chain-end (c:6338-6339), PM_TAGGED-style
16895/// loop detection (visited set of (name, level) stands in for the
16896/// C pointer tag — same revisit condition, borrow-safe), and the
16897/// `keep_lastref` placeholder shape. `stop_at` mirrors the C `stop`
16898/// param (compared by (name, level) identity).
16899pub fn resolve_nameref_name(name: &str, stop_at: Option<(&str, i32)>) -> nameref_resolution {
16900    // `${(!)…}` SCANPM_NONAMEREF scope (c:2247 `!(scanflags &
16901    // SCANPM_NONAMEREF)`) — treat nothing as a ref. Centralized here so
16902    // every caller inherits the gate (previously each caller guarded
16903    // via is_nameref first).
16904    if NAMEREF_SUPPRESS.with(|c| c.get()) > 0 {
16905        return nameref_resolution::NotRef;
16906    }
16907    // upscope (Src/params.c:6440-6463) over CLONED chain nodes —
16908    // nested in the engine (sole consumer) instead of a free fn.
16909    let upscope_clone =
16910        |visible: &param, ref_flags: u32, ref_level: i32, ref_base: i32| -> Option<Param> {
16911            let mut cur: &param = visible;
16912            if (ref_flags & PM_UPPER) != 0 {
16913                // c:6457-6458 — `while (pm->level > ref->level - 1 &&
16914                // (pm = pm->old));` — pm becomes NULL when the chain runs
16915                // out above the ref's scope (an upscope ref to a name that
16916                // only exists at/below its own level is DANGLING).
16917                while cur.level > ref_level - 1 {
16918                    match cur.old.as_deref() {
16919                        Some(o) => cur = o,
16920                        None => return None,
16921                    }
16922                }
16923            } else {
16924                // c:6460
16925                while let Some(o) = cur.old.as_deref() {
16926                    if o.level >= ref_base {
16927                        cur = o;
16928                    } else {
16929                        break;
16930                    }
16931                }
16932            }
16933            Some(Box::new(cur.clone()))
16934        };
16935    let tab = match paramtab().read() {
16936        Ok(t) => t,
16937        Err(_) => return nameref_resolution::NotRef,
16938    };
16939    let first = match tab.get(name) {
16940        Some(p) => p,
16941        None => return nameref_resolution::NotRef,
16942    };
16943    let f = first.node.flags as u32;
16944    if (f & PM_NAMEREF) == 0 {
16945        return nameref_resolution::NotRef;
16946    }
16947    // c:6336-6339 — an UNSET nameref ends the chain at itself: reads
16948    // see "unset" (fetchvalue NULL), assignment writes its refname
16949    // (setstrvalue c:2712 clears PM_UNSET then the PM_NAMEREF arm
16950    // stores the new refname).
16951    if (f & PM_UNSET) != 0 {
16952        return nameref_resolution::Placeholder(first.node.nam.clone());
16953    }
16954    let mut cur: Param = first.clone();
16955    let mut visited: Vec<(String, i32)> = Vec::new();
16956    let mut hops = 0usize;
16957    loop {
16958        hops += 1;
16959        if hops > 256 {
16960            // Hard backstop (C relies on PM_TAGGED; this can't fire
16961            // unless the tag logic above missed a cycle).
16962            zerr(&format!("{}: invalid self reference", cur.node.nam));
16963            return nameref_resolution::SelfRef;
16964        }
16965        let cf = cur.node.flags as u32;
16966        if (cf & PM_NAMEREF) == 0 || (cf & PM_UNSET) != 0 {
16967            // Final non-ref (or unset-ref) target reached.
16968            let lvl = cur.level;
16969            let nm = cur.node.nam.clone();
16970            return nameref_resolution::Target {
16971                name: nm,
16972                subscript: None,
16973                pm: Some(cur),
16974                level: lvl,
16975            };
16976        }
16977        // c:6338-6339 — refname; subscripted refname ends the chain.
16978        let refname_full = match cur.u_str.as_deref() {
16979            Some(r) if !r.is_empty() => r.to_string(),
16980            _ => {
16981                // c:6354 keep_lastref — placeholder ref.
16982                return nameref_resolution::Placeholder(cur.node.nam.clone());
16983            }
16984        };
16985        // c:6339 — `pm->width` is the subscript offset; a subscripted
16986        // ref is the end of any chain (see fetchvalue c:2250-2256).
16987        if cur.width != 0 || refname_full.contains('[') {
16988            let (base_name, sub) = match refname_full.find('[') {
16989                Some(i) => {
16990                    let tail = &refname_full[i + 1..];
16991                    let key = tail.strip_suffix(']').unwrap_or(tail);
16992                    (refname_full[..i].to_string(), Some(key.to_string()))
16993                }
16994                None => (refname_full.clone(), None),
16995            };
16996            let resolved = tab
16997                .get(&base_name)
16998                .and_then(|vis| upscope_clone(vis, cur.node.flags as u32, cur.level, cur.base));
16999            let lvl = resolved.as_ref().map(|p| p.level).unwrap_or(0);
17000            return nameref_resolution::Target {
17001                name: base_name,
17002                subscript: sub,
17003                pm: resolved,
17004                level: lvl,
17005            };
17006        }
17007        // c:6341-6343 — PM_TAGGED loop detection.
17008        let key = (cur.node.nam.clone(), cur.level);
17009        if visited.contains(&key) {
17010            zerr(&format!("{}: invalid self reference", cur.node.nam));
17011            return nameref_resolution::SelfRef;
17012        }
17013        visited.push(key);
17014        // c:6346-6347 — next hop via gethashnode2 + upscope.
17015        let next = match tab.get(&refname_full) {
17016            Some(vis) => {
17017                match upscope_clone(vis, cur.node.flags as u32, cur.level, cur.base) {
17018                    Some(n) => n,
17019                    None => {
17020                        // c:6347-6349 — name exists but upscope ran
17021                        // out: the keep_lastref else-arm is skipped,
17022                        // resolve returns NULL.
17023                        return nameref_resolution::OutOfScope;
17024                    }
17025                }
17026            }
17027            None => {
17028                // c:6347 gethashnode2 miss — dangling target.
17029                return nameref_resolution::Target {
17030                    name: refname_full,
17031                    subscript: None,
17032                    pm: None,
17033                    level: 0,
17034                };
17035            }
17036        };
17037        // c:6348 — `pm != stop` guard: when the hop lands on the stop
17038        // param, do NOT recurse (used by setscope's self-ref check
17039        // via resolve_nameref_rec(pm, pm, 0) at c:6423).
17040        if let Some((snam, slvl)) = stop_at {
17041            if next.node.nam == snam && next.level == slvl {
17042                let lvl = next.level;
17043                let nm = next.node.nam.clone();
17044                return nameref_resolution::Target {
17045                    name: nm,
17046                    subscript: None,
17047                    pm: Some(next),
17048                    level: lvl,
17049                };
17050            }
17051        }
17052        // c:6348 — `!(pm->node.flags & PM_UNSET)` guard: an unset
17053        // target stops the recursion; the unset param is returned.
17054        if (next.node.flags as u32 & PM_UNSET) != 0 {
17055            // An unset NAMEREF hop behaves like a placeholder: the
17056            // assignment revives it by writing the refname (the
17057            // PM_NAMEREF assignstrvalue arm at c:2715).
17058            if (next.node.flags as u32 & PM_NAMEREF) != 0 {
17059                return nameref_resolution::Placeholder(next.node.nam.clone());
17060            }
17061            let lvl = next.level;
17062            let nm = next.node.nam.clone();
17063            return nameref_resolution::Target {
17064                name: nm,
17065                subscript: None,
17066                pm: Some(next),
17067                level: lvl,
17068            };
17069        }
17070        cur = next;
17071    }
17072}
17073
17074thread_local! {
17075/// `${(!)name}` — SCANPM_NONAMEREF depth (Src/params.c:2232-2235:
17076/// the `!` flag routes the lookup through `getnode2`, skipping
17077/// `resolve_nameref`). Non-zero ⇒ `is_nameref` reports false so
17078/// every deref point reads the ref itself.
17079pub static NAMEREF_SUPPRESS: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
17080}
17081
17082/// RAII guard for `${(!)...}` — suppresses nameref resolution on this
17083/// thread for its lifetime (SCANPM_NONAMEREF scope).
17084pub struct NamerefSuppressGuard;
17085
17086impl NamerefSuppressGuard {
17087    /// Enter a SCANPM_NONAMEREF scope.
17088    #[allow(clippy::new_without_default)]
17089    pub fn new() -> Self {
17090        NAMEREF_SUPPRESS.with(|c| c.set(c.get() + 1));
17091        NamerefSuppressGuard
17092    }
17093}
17094
17095impl Drop for NamerefSuppressGuard {
17096    fn drop(&mut self) {
17097        NAMEREF_SUPPRESS.with(|c| c.set(c.get().saturating_sub(1)));
17098    }
17099}
17100
17101/// Is the visible binding of `name` a PM_NAMEREF? (Cheap pre-check
17102/// so the resolver isn't invoked on every plain variable read.)
17103pub fn is_nameref(name: &str) -> bool {
17104    // `${(!)…}` SCANPM_NONAMEREF scope active → treat nothing as a ref.
17105    if NAMEREF_SUPPRESS.with(|c| c.get()) > 0 {
17106        return false;
17107    }
17108    paramtab()
17109        .read()
17110        .ok()
17111        .and_then(|t| t.get(name).map(|p| (p.node.flags as u32 & PM_NAMEREF) != 0))
17112        .unwrap_or(false)
17113}
17114
17115/// Raw scalar value of a (possibly hidden) param clone — the
17116/// type-dispatch heart of `getstrvalue` (Src/params.c:2350-2382)
17117/// without the VALFLAG_SUBST padding (refs to hidden bindings).
17118fn param_scalar_value(pm: &param) -> Option<String> {
17119    let t = PM_TYPE(pm.node.flags as u32);
17120    if t == PM_INTEGER {
17121        let base = if pm.base > 0 { pm.base } else { 10 };
17122        Some(convbase(pm.u_val, base as u32))
17123    } else if t == PM_EFLOAT || t == PM_FFLOAT {
17124        Some(convfloat(pm.u_dval, pm.base, pm.node.flags as u32))
17125    } else if t == PM_SCALAR && pm.gsu_s.is_some() {
17126        pm.gsu_s.as_ref().map(|gsu| (gsu.getfn)(pm))
17127    } else if let Some(s) = pm.u_str.as_ref() {
17128        Some(s.clone())
17129    } else if let Some(arr) = pm.u_arr.as_ref() {
17130        Some(arr.join(" "))
17131    } else if t == PM_HASHED {
17132        // assoc joined values (getstrvalue KSH `[0]` path is rare;
17133        // bare $assoc is empty in zsh, return empty)
17134        Some(String::new())
17135    } else {
17136        None
17137    }
17138}
17139
17140/// Element read for a nameref bound to `target[sub]` — the
17141/// fetchvalue REFSLICE / getindex hand-off (Src/params.c:2247-2270
17142/// + c:2289). Arrays take a math index (1-based, negative from
17143/// end), assocs a literal key, scalars a char index.
17144fn nameref_element_read(pm: &param, target: &str, key: &str) -> Option<String> {
17145    let t = PM_TYPE(pm.node.flags as u32);
17146    if t == PM_HASHED {
17147        // The assoc backing is name-keyed (paramtab_hashed_storage);
17148        // when the resolved binding is a HIDDEN old-chain node (a
17149        // `local` shadow covers it), the outer's data lives on the
17150        // shadow STACK (pushed by createparam's PM_HASHED save).
17151        let visible_level = paramtab()
17152            .read()
17153            .ok()
17154            .and_then(|tab| tab.get(target).map(|p| p.level));
17155        if visible_level != Some(pm.level) {
17156            if let Some(stk) = crate::ported::params::PARAMTAB_HASHED_SHADOW_STACK.get() {
17157                if let Ok(stk) = stk.lock() {
17158                    if let Some(frames) = stk.get(target) {
17159                        if let Some(Some(saved)) = frames.last() {
17160                            return saved.get(key).cloned();
17161                        }
17162                    }
17163                }
17164            }
17165        }
17166        return paramtab_hashed_storage()
17167            .lock()
17168            .ok()
17169            .and_then(|m| m.get(target).and_then(|h| h.get(key).cloned()));
17170    }
17171    // numeric (math) index for arrays / scalars
17172    let idx: i64 = match key.trim().parse::<i64>() {
17173        Ok(i) => i,
17174        Err(_) => match crate::ported::math::mathevali(key) {
17175            Ok(i) => i,
17176            Err(_) => return None,
17177        },
17178    };
17179    if t == PM_ARRAY {
17180        let arr = pm.u_arr.as_ref()?;
17181        let len = arr.len() as i64;
17182        let pos = if idx < 0 { len + idx + 1 } else { idx };
17183        if pos >= 1 && pos <= len {
17184            return Some(arr[(pos - 1) as usize].clone());
17185        }
17186        return Some(String::new());
17187    }
17188    // scalar char index (1-based)
17189    let s = param_scalar_value(pm)?;
17190    let chars: Vec<char> = s.chars().collect();
17191    let len = chars.len() as i64;
17192    let pos = if idx < 0 { len + idx + 1 } else { idx };
17193    if pos >= 1 && pos <= len {
17194        return Some(chars[(pos - 1) as usize].to_string());
17195    }
17196    Some(String::new())
17197}
17198
17199/// Full-table variant of `setscope(Param pm)` (Src/params.c:6382).
17200/// Performs the parts the in-place `setscope` above cannot: the
17201/// base-scope computation against the live paramtab (c:6402-6410),
17202/// the WARNNESTEDVAR diagnostic (c:6411-6419), and the chain-walk
17203/// self-reference detection + unset (c:6421-6431). Returns 1 when
17204/// the self-reference error fired (the ref has been removed), else 0.
17205/// MUST be called without any paramtab lock held.
17206pub fn setscope_by_name(name: &str) -> i32 {
17207    queue_signals();
17208    // Snapshot the ref's state.
17209    let snap = {
17210        let tab = match paramtab().read() {
17211            Ok(t) => t,
17212            Err(_) => {
17213                unqueue_signals();
17214                return 0;
17215            }
17216        };
17217        match tab.get(name) {
17218            Some(pm) if (pm.node.flags as u32 & PM_NAMEREF) != 0 => {
17219                Some((pm.u_str.clone(), pm.level, pm.node.flags as u32))
17220            }
17221            _ => None,
17222        }
17223    };
17224    let (refname_opt, ref_level, ref_flags) = match snap {
17225        Some(s) => s,
17226        None => {
17227            unqueue_signals();
17228            return 0;
17229        }
17230    };
17231    let refname_full = refname_opt.unwrap_or_default();
17232    // c:6388-6400 — split refname at `[`, stamp pm->width.
17233    let (head, width) = match refname_full.find('[') {
17234        Some(i) => (refname_full[..i].to_string(), i as i32),
17235        None => (refname_full.clone(), 0),
17236    };
17237    if width != 0 {
17238        if let Ok(mut tab) = paramtab().write() {
17239            if let Some(pm) = tab.get_mut(name) {
17240                pm.width = width; // c:6398 pm->width = t - refname
17241            }
17242        }
17243    }
17244    // c:6402-6410 — compute pm->base from the target's visible level.
17245    // `basepm != pm || !basepm->old || (basepm = basepm->old)`: a ref
17246    // whose refname is its own name binds to the ENCLOSING binding.
17247    let mut basepm_is_self = false;
17248    if (ref_flags & PM_UPPER) == 0 && !head.is_empty() {
17249        let base_level: Option<i32> = {
17250            let tab = paramtab().read().unwrap();
17251            match tab.get(&head) {
17252                Some(vis) => {
17253                    if head == name && vis.level == ref_level {
17254                        // basepm == pm — prefer pm->old (c:6407).
17255                        match vis.old.as_deref() {
17256                            Some(o) => Some(o.level),
17257                            None => {
17258                                basepm_is_self = true;
17259                                None
17260                            }
17261                        }
17262                    } else {
17263                        Some(vis.level)
17264                    }
17265                }
17266                None => None,
17267            }
17268        };
17269        if let Some(bl) = base_level {
17270            if let Ok(mut tab) = paramtab().write() {
17271                if let Some(pm) = tab.get_mut(name) {
17272                    setscope_base(pm, bl); // c:6409 setscope_base(pm, basepm->level)
17273                }
17274            }
17275        }
17276        // c:6411-6419 — base > level diagnostics.
17277        let base_now = paramtab()
17278            .read()
17279            .ok()
17280            .and_then(|t| t.get(name).map(|p| p.base))
17281            .unwrap_or(0);
17282        if base_now > ref_level && isset(crate::ported::zsh_h::WARNNESTEDVAR) {
17283            // c:6417-6418
17284            zwarn(&format!(
17285                "reference {} in enclosing scope set to local variable {}",
17286                name, head
17287            ));
17288        }
17289    }
17290    // c:6421-6431 — self-reference detection via the chain walk with
17291    // stop == pm.
17292    let mut selfref = basepm_is_self;
17293    if !head.is_empty() && width == 0 && !basepm_is_self {
17294        match resolve_nameref_name(name, Some((name, ref_level))) {
17295            nameref_resolution::Target {
17296                name: tname, level, ..
17297            } if tname == name && level == ref_level => {
17298                selfref = true; // chain looped back to pm
17299            }
17300            nameref_resolution::SelfRef => {
17301                // zerr already emitted inside the walk (with the
17302                // looping ref's name, matching the recursive C zerr).
17303                unqueue_signals();
17304                return 1;
17305            }
17306            _ => {}
17307        }
17308    }
17309    if selfref {
17310        // c:6426-6429 — `zerr("%s: invalid self reference", refname);
17311        //               unsetparam_pm(pm, 0, 1);`
17312        zerr(&format!("{}: invalid self reference", head));
17313        if let Some(mut pm) = paramtab().write().ok().and_then(|mut t| t.remove(name)) {
17314            if let Some(prev) = pm.old.take() {
17315                // uncover any outer binding (unsetparam_pm restore shape)
17316                if let Ok(mut tab) = paramtab().write() {
17317                    tab.insert(name.to_string(), prev);
17318                }
17319            }
17320        }
17321        unqueue_signals();
17322        return 1;
17323    }
17324    unqueue_signals();
17325    0
17326}