Skip to main content

zsh/ported/
hashtable.rs

1//! Hash table implementations - port of hashtable.c
2//!
3//! Provides hash tables for commands, shell functions, reserved words, aliases,
4//! and history. The four tables whose iteration order is user-visible
5//! (`cmdnamtab`, `shfunctab`, `reswdtab`, `aliastab`/`sufaliastab`)
6//! store their nodes in `hashtable_nodes`, a port of the node-storage
7//! half of C's `struct hashtable` (`Src/zsh.h:1175-1235`), so
8//! `${(k)commands}` / `${(k)functions}` / `$reswords` /
9//! `${(k)aliases}` come out in C's bucket-walk order.
10//!
11//! `cmdnam_table` / `shfunc_table` / `reswd_table` / `alias_table` are
12//! Rust-side typed wrappers. C uses one polymorphic `struct hashtable`
13//! (`Src/zsh.h:1175-1235`) with function-pointer callbacks per table
14//! kind; the canonical Rust port of that struct lives at
15//! `zsh_h.rs:532`. These typed wrappers add fields that aren't part
16//! of `struct hashtable` (e.g. `cmdnam_table` carries
17//! `path_checked_index` + `path` + `hash_executables_only` for the
18//! `PATH`-walk fast-rehash that C tracks via the file-scope
19//! `pathchecked` / `hashed_anything` statics in `Src/hashtable.c`).
20//! Lowercase naming reflects that the wrappers are co-located with
21//! the canonical `hashtable` rather than mirror it 1:1.
22
23#![allow(non_camel_case_types)]
24
25use crate::compat::zgetcwd;
26use crate::hist::{hashchar, hist_ring};
27use crate::jobs::getsigidx;
28use crate::ported::hist::{hist_ignore_all_dups, histlinect, histremovedups, up_histent};
29use crate::ported::pattern::{patcompile, pattry};
30use crate::ported::signals::removetrap;
31use crate::ported::utils::scriptfilename_get;
32use crate::ported::zsh_h::{
33    alias, options, BANG_TOK, CASE, COPROC, DINBRACK, DOLOOP, DONE, ELIF, ELSE, ESAC, FI, FOR,
34    FOREACH, FUNC, IF, INBRACE_TOK, NOCORRECT, OUTBRACE_TOK, PAT_HEAPDUP, REPEAT, SELECT, THEN,
35    TIME, TYPESET, UNTIL, WHILE, ZEND,
36};
37use crate::signals::{settrap, unsettrap};
38use crate::text::{getpermtext, zoutputtab};
39use crate::utils::{nicezputs, quotedzputs, unmetafy_str, xsymlink, zputs, ztrcmp, zwarn};
40use crate::zsh_h::{
41    cmdnam, hashnode, hashtable, reswd, shfunc, ALIAS_GLOBAL, ALIAS_SUFFIX, DISABLED, EF_RUN,
42    HASHED, HIST_DUP, HIST_FOREIGN, HIST_MAKEUNIQUE, HIST_TMPSTORE, PM_CUR_FPATH, PM_KSHSTORED,
43    PM_LOADDIR, PM_TAGGED, PM_TAGGED_LOCAL, PM_UNALIASED, PM_UNDEFINED, PM_ZSHSTORED, PRINT_LIST,
44    PRINT_NAMEONLY, PRINT_WHENCE_CSH, PRINT_WHENCE_FUNCDEF, PRINT_WHENCE_SIMPLE,
45    PRINT_WHENCE_VERBOSE, PRINT_WHENCE_WORD, ZSIG_FUNC,
46};
47use std::collections::HashMap;
48use std::fs;
49use std::io;
50use std::os::unix::fs::PermissionsExt;
51use std::path::PathBuf;
52use std::sync::atomic::Ordering;
53
54/// Generic hash function (zsh's hasher)
55/// Compute the canonical zsh hash for a string.
56/// Port of `hasher(const char *str)` from Src/hashtable.c:86 — uses the same
57// Generic hash function                                                    // c:86
58/// `hash * 33 + char` polynomial the C source uses for every
59/// HashTable lookup.
60pub fn hasher(str: &str) -> u32 {
61    // c:86
62    let mut hashval: u32 = 0;
63    for c in str.bytes() {
64        hashval = hashval.wrapping_add(hashval.wrapping_shl(5).wrapping_add(c as u32));
65    }
66    hashval
67}
68
69/// Port of the node-storage half of `struct hashtable`
70/// (`Src/zsh.h:1175-1235`: `HashNode *nodes; int hsize; int ct;`) as
71/// `Src/hashtable.c` actually drives it — an OPEN-HASHED BUCKET ARRAY:
72///
73///   * bucket index is `ht->hash(nam) % ht->hsize` (c:176/236/260/280);
74///   * a new key goes to the FRONT of its chain (c:194-196 for an empty
75///     bucket, c:214-215 otherwise);
76///   * replacing an existing key keeps that key's POSITION in the chain
77///     (c:187-203 `replacing:` — `hn->next = hp->next`);
78///   * once `ct >= hsize * 2` the table quadruples and every node is
79///     re-added in old-traversal order (c:183/219 → `expandhashtable`
80///     c:458-482);
81///   * an unsorted scan walks bucket 0..hsize-1, each chain head→tail
82///     (`scanmatchtable` c:420-434).
83///
84/// That walk order is OBSERVABLE: `${(k)functions}` /
85/// `${(k)commands}` / `compadd -k <assoc>` all emit it verbatim
86/// (`Src/Modules/parameter.c:480-481` loops `shfunctab->nodes[i]`
87/// directly), and `join_clines` is a non-commutative fold, so the
88/// order matches are added in decides the common prefix `compadd -k`
89/// produces.
90///
91/// The port previously stood a `std::collections::HashMap` in for the
92/// bucket array. That is not merely a different order — `RandomState`
93/// re-seeds per process, so `print -rl -- ${(k)functions}` returned a
94/// DIFFERENT order on every run of the same binary against the same
95/// 46761-function table, and any completion enumerating the table was
96/// nondeterministic.
97///
98/// !!! WARNING: RUST-ONLY HELPER !!!
99/// C reaches these fields through one polymorphic `struct hashtable`
100/// with `hash`/`cmpnodes`/`addnode`/`getnode` function pointers. Rust
101/// has no equivalent of the `HashNode`-as-first-member downcast, so the
102/// storage is a generic struct and the typed wrappers below embed it.
103/// Every method is named for the `Src/hashtable.c` function it ports.
104#[derive(Debug, Clone)]
105pub struct hashtable_nodes<T> {
106    /// `HashNode *nodes` (`Src/zsh.h:1177`) — `hsize` chains. Index 0 of
107    /// a chain is the head, i.e. the most recently inserted key.
108    nodes: Vec<Vec<(String, T)>>,
109    /// `int hsize` (`Src/zsh.h:1179`) — number of buckets.
110    hsize: usize,
111    /// `int ct` (`Src/zsh.h:1181`) — number of live nodes.
112    ct: usize,
113}
114
115impl<T> hashtable_nodes<T> {
116    /// Port of `newhashtable(int size, …)` from `Src/hashtable.c:100` —
117    /// `zshcalloc(size * sizeof(HashNode))` + `hsize = size` + `ct = 0`.
118    pub fn newhashtable(size: usize) -> Self {
119        // c:100
120        let size = size.max(1); // c:115 — a 0-bucket table can't be indexed
121        let mut nodes = Vec::with_capacity(size);
122        nodes.resize_with(size, Vec::new); // c:116
123        Self {
124            nodes,
125            hsize: size, // c:117
126            ct: 0,       // c:118
127        }
128    }
129
130    /// Port of `expandhashtable(HashTable ht)` from `Src/hashtable.c:458`.
131    /// Quadruples `hsize`, zeroes `ct`, and re-adds every node walking the
132    /// OLD table in traversal order (bucket 0..osize-1, chain head→tail)
133    /// so the new chains come out in C's exact order.
134    fn expandhashtable(&mut self) {
135        // c:458
136        let osize = self.hsize; // c:463
137        let onodes = std::mem::take(&mut self.nodes); // c:464
138        self.hsize = osize * 4; // c:466
139        self.nodes = Vec::with_capacity(self.hsize);
140        self.nodes.resize_with(self.hsize, Vec::new); // c:467
141        self.ct = 0; // c:468
142                     // c:471-476 — `for (i = 0, ha = onodes; i < osize; i++, ha++)
143                     //                for (hn = *ha; hn;) { hp = hn->next;
144                     //                    ht->addnode(ht, hn->nam, hn); hn = hp; }`
145        for bucket in onodes {
146            for (nam, node) in bucket {
147                let hashval = hasher(&nam) as usize % self.hsize; // c:176
148                self.nodes[hashval].insert(0, (nam, node)); // c:214-215
149                self.ct += 1; // c:219 (the expand test can't re-fire here)
150            }
151        }
152    }
153
154    /// Port of `addhashnode2(HashTable ht, char *nam, void *nodeptr)` from
155    /// `Src/hashtable.c:168` — inserts, returning the displaced node.
156    pub fn addhashnode2(&mut self, nam: &str, nodeptr: T) -> Option<T> {
157        // c:168
158        let hashval = hasher(nam) as usize % self.hsize; // c:176
159                                                         // c:186-206 — an existing key is replaced IN PLACE, keeping its
160                                                         // position in the chain; ct does not move and no expand fires.
161        if let Some(pos) = self.nodes[hashval].iter().position(|(k, _)| k == nam) {
162            let old = std::mem::replace(&mut self.nodes[hashval][pos], (nam.to_string(), nodeptr));
163            return Some(old.1); // c:203
164        }
165        // c:193-196 / c:214-215 — otherwise the new node goes to the FRONT.
166        self.nodes[hashval].insert(0, (nam.to_string(), nodeptr));
167        self.ct += 1;
168        if self.ct >= self.hsize * 2 {
169            // c:183 / c:219
170            self.expandhashtable(); // c:184 / c:220
171        }
172        None
173    }
174
175    /// Port of `gethashnode2(HashTable ht, const char *nam)` from
176    /// `Src/hashtable.c:255` — lookup WITHOUT the DISABLED filter.
177    pub fn gethashnode2(&self, nam: &str) -> Option<&T> {
178        // c:255
179        let hashval = hasher(nam) as usize % self.hsize; // c:260
180        self.nodes[hashval]
181            .iter()
182            .find(|(k, _)| k == nam) // c:262-265
183            .map(|(_, v)| v)
184    }
185
186    /// Mutable companion of [`hashtable_nodes::gethashnode2`]. C mutates
187    /// straight through the returned `HashNode` pointer; Rust needs the
188    /// separate borrow.
189    pub fn get_mut(&mut self, nam: &str) -> Option<&mut T> {
190        // c:255
191        let hashval = hasher(nam) as usize % self.hsize;
192        self.nodes[hashval]
193            .iter_mut()
194            .find(|(k, _)| k == nam)
195            .map(|(_, v)| v)
196    }
197
198    /// Port of `removehashnode(HashTable ht, const char *nam)` from
199    /// `Src/hashtable.c:275` — unlinks the node from its chain and
200    /// decrements `ct`.
201    pub fn removehashnode(&mut self, nam: &str) -> Option<T> {
202        // c:275
203        let hashval = hasher(nam) as usize % self.hsize; // c:280
204        let pos = self.nodes[hashval].iter().position(|(k, _)| k == nam)?;
205        self.ct -= 1; // c:294
206        Some(self.nodes[hashval].remove(pos).1)
207    }
208
209    /// Port of `emptyhashtable(HashTable ht)` from `Src/hashtable.c:517`
210    /// (`resizehashtable(ht, ht->hsize)`) — frees every node, keeps `hsize`.
211    pub fn emptyhashtable(&mut self) {
212        // c:517
213        for bucket in self.nodes.iter_mut() {
214            bucket.clear(); // c:490-497
215        }
216        self.ct = 0; // c:509
217    }
218
219    /// C's `ht->ct` (`Src/zsh.h:1181`).
220    pub fn len(&self) -> usize {
221        self.ct
222    }
223
224    /// `ht->ct == 0`.
225    pub fn is_empty(&self) -> bool {
226        self.ct == 0
227    }
228
229    /// The unsorted scan order of `scanmatchtable` (`Src/hashtable.c:420-434`):
230    /// bucket 0..hsize-1, each chain head→tail. This IS the order zsh's
231    /// `${(k)assoc}` / `compadd -k` emit.
232    pub fn iter(&self) -> impl Iterator<Item = (&String, &T)> {
233        // c:420-434
234        self.nodes.iter().flatten().map(|(k, v)| (k, v))
235    }
236
237    /// Mutable companion of [`hashtable_nodes::iter`] — same
238    /// `scanmatchtable` walk (`Src/hashtable.c:420-434`); C hands the
239    /// scan function a writable `HashNode`, Rust needs the separate
240    /// borrow.
241    pub fn iter_mut(&mut self) -> impl Iterator<Item = (&String, &mut T)> {
242        // c:420-434
243        self.nodes.iter_mut().flatten().map(|(k, v)| (&*k, v))
244    }
245
246    /// The names in `scanmatchtable` order (`Src/hashtable.c:420-434`) —
247    /// C's scan reads `hn->nam` off each node in the same walk.
248    pub fn keys(&self) -> impl Iterator<Item = &String> {
249        // c:420-434
250        self.nodes.iter().flatten().map(|(k, _)| k)
251    }
252
253    /// The nodes in `scanmatchtable` order (`Src/hashtable.c:420-434`).
254    pub fn values(&self) -> impl Iterator<Item = &T> {
255        // c:420-434
256        self.nodes.iter().flatten().map(|(_, v)| v)
257    }
258
259    /// Mutable companion of [`hashtable_nodes::values`]
260    /// (`Src/hashtable.c:420-434`).
261    pub fn values_mut(&mut self) -> impl Iterator<Item = &mut T> {
262        // c:420-434
263        self.nodes.iter_mut().flatten().map(|(_, v)| v)
264    }
265
266    /// `ht->getnode2(ht, nam)` (`Src/hashtable.c:255` `gethashnode2`) —
267    /// map-shaped alias so tables converted from a `HashMap` keep their
268    /// call sites. Same O(1) bucket hash + short-chain walk as C.
269    pub fn get(&self, nam: &str) -> Option<&T> {
270        // c:255
271        self.gethashnode2(nam)
272    }
273
274    /// `ht->getnode2(ht, nam)` != NULL (`Src/hashtable.c:255`).
275    pub fn contains_key(&self, nam: &str) -> bool {
276        // c:255
277        self.gethashnode2(nam).is_some()
278    }
279
280    /// `ht->addnode(ht, ztrdup(nam), node)` (`Src/hashtable.c:157`
281    /// `addhashnode` → `addhashnode2` at `c:168`) — returns the
282    /// displaced node instead of running `freenode`.
283    pub fn insert(&mut self, nam: String, nodeptr: T) -> Option<T> {
284        // c:157 / c:168
285        self.addhashnode2(&nam, nodeptr)
286    }
287
288    /// `ht->removenode(ht, nam)` (`Src/hashtable.c:275`
289    /// `removehashnode`).
290    pub fn remove(&mut self, nam: &str) -> Option<T> {
291        // c:275
292        self.removehashnode(nam)
293    }
294
295    /// `ht->emptytable(ht)` (`Src/hashtable.c:517` `emptyhashtable`).
296    pub fn clear(&mut self) {
297        // c:517
298        self.emptyhashtable();
299    }
300
301    /// The `scanmatchtable` walk (`Src/hashtable.c:420-434`) with the
302    /// C `freenode` arm taken for every node the predicate rejects.
303    /// Chain order of the survivors is preserved exactly, which is what
304    /// makes this different from rebuilding the table.
305    pub fn retain<F: FnMut(&String, &mut T) -> bool>(&mut self, mut f: F) {
306        // c:420-434
307        let mut ct = 0usize;
308        for bucket in self.nodes.iter_mut() {
309            bucket.retain_mut(|(k, v)| f(k, v));
310            ct += bucket.len();
311        }
312        self.ct = ct; // c:294 — one decrement per unlinked node
313    }
314}
315
316impl<T> Default for hashtable_nodes<T> {
317    /// `newparamtable`/`newmoduletable` fall back to `size = 17` when
318    /// handed 0 (`Src/params.c:541-542`, and `Src/module.c:1602`
319    /// creates 17-bucket sub-tables); use that as the neutral default
320    /// for `#[derive(Default)]` containers.
321    fn default() -> Self {
322        // c:541-542
323        Self::newhashtable(17)
324    }
325}
326
327impl<T, Q: ?Sized + std::borrow::Borrow<str>> std::ops::Index<&Q> for hashtable_nodes<T> {
328    type Output = T;
329    /// `ht->getnode2(ht, nam)` with C's "caller already checked" contract
330    /// (`Src/hashtable.c:255`) — panics on a missing name, like the
331    /// `HashMap` indexing it replaces.
332    fn index(&self, nam: &Q) -> &T {
333        // c:255
334        let nam = nam.borrow();
335        self.gethashnode2(nam)
336            .unwrap_or_else(|| panic!("no hash node named {nam}"))
337    }
338}
339
340impl<'a, T> IntoIterator for &'a hashtable_nodes<T> {
341    type Item = (&'a String, &'a T);
342    type IntoIter = std::iter::Map<
343        std::iter::Flatten<std::slice::Iter<'a, Vec<(String, T)>>>,
344        fn(&'a (String, T)) -> (&'a String, &'a T),
345    >;
346    /// `for (k, v) in &table` — the `scanmatchtable` walk
347    /// (`Src/hashtable.c:420-434`).
348    fn into_iter(self) -> Self::IntoIter {
349        // c:420-434
350        self.nodes
351            .iter()
352            .flatten()
353            .map(|(k, v): &(String, T)| (k, v))
354    }
355}
356
357// ===========================================================
358// Direct ports of the generic `HashTable` lifecycle / mutation /
359// printer routines from Src/hashtable.c. The Rust port stores
360// command/alias/reswd/shfunc tables as `HashMap`-backed wrappers
361// (above), so most of these are free-fn shims for ABI/name
362// parity. Callers in the Rust executor reach the live state via
363// the typed table structs (`alias_table`, `shfunc_table`, etc.).
364// ===========================================================
365
366/// Port of `newhashtable(int size, UNUSED(char const *name), UNUSED(PrintTableStats printinfo))` from `Src/hashtable.c:100`.
367///
368/// C allocates a `HashTable` header with `size` buckets and the
369/// supplied `name` for `bin_hashinfo` reporting. Rust uses
370/// `HashMap` (auto-resizing) so the bucket count is informational;
371/// the named-table accounting is recorded for `printhashtabinfo`.
372///
373/// Returns a `(name, expected_size)` tuple — callers (the table-
374/// specific creators) typically discard since each Rust table
375/// type has its own constructor. Provided for C name parity.
376// Get a new hash table                                                     // c:100
377/// WARNING: param names don't match C — Rust=(size, name) vs C=(size, name, printinfo)
378pub fn newhashtable(size: i32, name: &str) -> (String, i32) {
379    // c:100
380    (name.to_string(), size)
381}
382
383/// Port of `deletehashtable(HashTable ht)` from `Src/hashtable.c:129`.
384///
385/// C frees every node via `emptytable` then frees the header.
386/// Rust port: `Drop` runs the equivalent on the typed table when
387/// it falls out of scope. The free fn here calls clear on the
388/// passed map for C name parity at call sites that explicitly
389/// invoke deletehashtable.
390pub fn deletehashtable<T>(ht: &mut HashMap<String, T>) {
391    // c:129
392    ht.clear();
393}
394
395// `cmdnam` struct + impl deleted — Rust-only duplicate of canonical
396// `crate::ported::zsh_h::cmdnam` (zsh.h:1301-1308). C struct:
397//
398//     struct cmdnam {
399//         struct hashnode node;
400//         union {
401//             char **name;   /* HASHED off: full $PATH array (u.name) */
402//             char  *cmd;    /* HASHED on:  resolved abs path (u.cmd) */
403//         } u;
404//     };
405//
406// The Rust-only version had a flat `name, flags, path: PathBuf,
407// dir_index` shape that lost the hashnode embedding and the
408// name/cmd union (the C source uses `flags & HASHED` to dispatch
409// which arm holds the value). Type alias surfaces the canonical
410// struct directly; the previous `path: PathBuf` becomes
411// `cmd: Option<String>` and `dir_index: Option<usize>` becomes
412// `name: Option<Vec<String>>` (the full PATH-segment slice the
413// command would be looked up against).
414// c:1301
415
416/// Port of `addhashnode(HashTable ht, char *nam, void *nodeptr)` from `Src/hashtable.c:157`.
417///
418/// C body:
419/// ```c
420/// HashNode oldnode = addhashnode2(ht, nam, nodeptr);
421/// if (oldnode) ht->freenode(oldnode);
422/// ```
423///
424/// Generic insert that drops the previous value at `nam` (Rust's
425// is now greater than twice the number of hash values,                    // c:157
426// the table is then expanded.                                              // c:157
427/// `HashMap::insert` returns the old value; dropping it runs the
428/// equivalent of `freenode`). For typed table-specific entry
429/// shapes use the table's own `add()` method.
430// Add a node to a hash table, returning the old node on replacement.      // c:168
431/// `addhashnode` — see implementation.
432pub fn addhashnode<T>(ht: &mut HashMap<String, T>, nam: &str, value: T) {
433    // c:157
434    ht.insert(nam.to_string(), value);
435}
436
437// Add a node to a hash table, returning the old node on replacement.      // c:168
438/// Port of `addhashnode2(HashTable ht, char *nam, void *nodeptr)` from `Src/hashtable.c:168`.
439///
440/// C body inserts and returns the OLD node (instead of freeing
441/// it via the freenode callback). Rust HashMap::insert already
442/// has this shape — return the displaced value.
443pub fn addhashnode2<T>(ht: &mut HashMap<String, T>, nam: &str, nodeptr: T) -> Option<T> {
444    // c:168
445    ht.insert(nam.to_string(), nodeptr)
446}
447
448/// Port of `gethashnode(HashTable ht, const char *nam)` from `Src/hashtable.c:231`.
449///
450/// C body returns NULL if the entry has the DISABLED flag set;
451// the hashnode.  If the node is DISABLED                                  // c:231
452// or isn't found, it returns NULL                                          // c:231
453/// otherwise returns the node. Generic lookup helper — `T` must
454/// expose its DISABLED flag via the [`HashNodeFlags`] trait so
455/// the disabled filter applies.
456/// WARNING: param names don't match C — Rust=(nam) vs C=(ht, nam)
457pub fn gethashnode<'a, T: HashNodeFlags>(
458    // c:231
459    ht: &'a HashMap<String, T>,
460    nam: &str,
461) -> Option<&'a T> {
462    ht.get(nam).filter(|t| !t.is_disabled())
463}
464
465impl cmdnam_table {
466    /// `new` — see implementation.
467    pub fn new() -> Self {
468        Self {
469            // hashtable.c:603 — `cmdnamtab = newhashtable(201, "cmdnamtab", NULL)`.
470            // The bucket count is observable: `${(k)commands}` and
471            // `compadd -k commands` emit the raw bucket walk.
472            table: crate::cow_map::CowArc::new(hashtable_nodes::newhashtable(201)), // c:603
473            path_checked_index: 0,
474            path: Vec::new(),
475            hash_executables_only: false,
476        }
477    }
478    /// `snapshot` — the table as the parent had it, for an in-process
479    /// subshell to hand back at its end. Same contract and the same C
480    /// reasoning as `alias_table::snapshot`: the forked child's `hash`,
481    /// `unhash`, `hash -r` and `commands[x]=…` write a private copy.
482    ///
483    /// O(1) for the table (`CowArc`). `path` is copied, but nothing sets
484    /// it (`set_path` has no callers), so it is always empty.
485    ///
486    /// !!! RUST-ONLY METHOD — no C counterpart (C forks) !!!
487    pub fn snapshot(&self) -> cmdnam_table {
488        self.clone()
489    }
490    /// `restore` — put back a table taken by `snapshot`.
491    ///
492    /// !!! RUST-ONLY METHOD — no C counterpart (C forks) !!!
493    pub fn restore(&mut self, snap: cmdnam_table) {
494        *self = snap;
495    }
496    /// `set_path` — see implementation.
497    pub fn set_path(&mut self, path: Vec<String>) {
498        self.path = path;
499        self.path_checked_index = 0;
500    }
501    /// `set_hash_executables_only` — see implementation.
502    pub fn set_hash_executables_only(&mut self, value: bool) {
503        self.hash_executables_only = value;
504    }
505    /// `add` — `addhashnode` (`Src/hashtable.c:157`).
506    pub fn add(&mut self, cmd: cmdnam) {
507        let nam = cmd.node.nam.clone();
508        let _ = self.table.addhashnode2(&nam, cmd); // c:168
509    }
510    /// `get` — `gethashnode` (`Src/hashtable.c:231`).
511    pub fn get(&self, name: &str) -> Option<&cmdnam> {
512        self.table
513            .gethashnode2(name)
514            .filter(|c| (c.node.flags & DISABLED as i32) == 0) // c:239
515    }
516    /// `get_including_disabled` — `gethashnode2` (`Src/hashtable.c:255`).
517    pub fn get_including_disabled(&self, name: &str) -> Option<&cmdnam> {
518        self.table.gethashnode2(name) // c:255
519    }
520    /// `remove` — `removehashnode` (`Src/hashtable.c:275`).
521    pub fn remove(&mut self, name: &str) -> Option<cmdnam> {
522        self.table.removehashnode(name) // c:275
523    }
524    /// `clear` — `emptyhashtable` (`Src/hashtable.c:517`).
525    pub fn clear(&mut self) {
526        self.table.emptyhashtable(); // c:517
527        self.path_checked_index = 0;
528    }
529    /// `len` — see implementation.
530    pub fn len(&self) -> usize {
531        self.table.len()
532    }
533    /// `is_empty` — see implementation.
534    pub fn is_empty(&self) -> bool {
535        self.table.is_empty()
536    }
537
538    /// Hash all commands in a directory
539    pub fn hash_dir(&mut self, dir: &str, dir_index: usize) {
540        if dir.starts_with('.') || dir.is_empty() {
541            return;
542        }
543
544        let Ok(entries) = fs::read_dir(dir) else {
545            return;
546        };
547
548        for entry in entries.flatten() {
549            let Ok(name) = entry.file_name().into_string() else {
550                continue;
551            };
552
553            if self.table.gethashnode2(&name).is_some() {
554                continue;
555            }
556
557            let should_add = if self.hash_executables_only {
558                // Inline of the deleted is_executable helper.
559                let path = entry.path();
560                #[cfg(unix)]
561                {
562                    path.metadata()
563                        .map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
564                        .unwrap_or(false)
565                }
566                #[cfg(not(unix))]
567                {
568                    path.is_file()
569                }
570            } else {
571                true
572            };
573
574            if should_add {
575                // C `cn->u.name = pathchecked;` at hashtable.c:712 —
576                // the unhashed entry carries the PATH-array slice it
577                // would scan. Rust port: snapshot the single PATH
578                // segment at `dir_index` so lookup later resolves
579                // the path. Older Rust-only code stored just the
580                // index; canonical port stores the actual segment.
581                let segment = self
582                    .path
583                    .get(dir_index)
584                    .cloned()
585                    .unwrap_or_else(|| dir.to_string());
586                let _ = self
587                    .table
588                    .addhashnode2(&name, cmdnam_unhashed(&name, vec![segment]));
589                // c:168
590            }
591        }
592    }
593
594    /// Fill table from PATH
595    pub fn fill(&mut self) {
596        for i in self.path_checked_index..self.path.len() {
597            let dir = self.path[i].clone();
598            self.hash_dir(&dir, i);
599        }
600        self.path_checked_index = self.path.len();
601    }
602
603    /// Iterate over all entries
604    pub fn iter(&self) -> impl Iterator<Item = (&String, &cmdnam)> {
605        self.table.iter()
606    }
607
608    /// Get full path for a command. Mirrors C's
609    /// `findcmd(name, 1, 0)` lookup via cmdnamtab (Src/exec.c:5260).
610    pub fn get_full_path(&self, name: &str) -> Option<PathBuf> {
611        let cmd = self.table.gethashnode2(name)?;
612        if (cmd.node.flags & DISABLED as i32) != 0 {
613            return None;
614        }
615        // HASHED branch: cn->u.cmd holds the resolved path.
616        if (cmd.node.flags & HASHED as i32) != 0 {
617            if let Some(ref s) = cmd.cmd {
618                return Some(PathBuf::from(s));
619            }
620        }
621        // Unhashed branch: cn->u.name holds PATH segments to scan.
622        if let Some(ref segs) = cmd.name {
623            if let Some(seg) = segs.first() {
624                let mut path = PathBuf::from(seg);
625                path.push(name);
626                return Some(path);
627            }
628        }
629        None
630    }
631}
632
633impl Default for cmdnam_table {
634    fn default() -> Self {
635        Self::new()
636    }
637}
638
639// `shfunc` struct + impl deleted — Rust-only duplicate of canonical
640// `crate::ported::zsh_h::shfunc` (zsh.h:1316-1325). Canonical:
641//
642//     struct shfunc {
643//         struct hashnode node;
644//         char *filename;
645//         zlong lineno;
646//         Eprog funcdef;
647//         Eprog redir;
648//         Emulation_options sticky;
649//     };
650//
651// Canonical was extended with a Rust-only `body: Option<String>`
652// field (deferred-compile source text) so callers using the old
653// `shfunc.body` access continue working. Type alias surfaces
654// canonical as `shfunc`; helpers below build instances with the
655// hashnode literal pre-populated.
656
657/// Port of `gethashnode2(HashTable ht, const char *nam)` from `Src/hashtable.c:255`.
658///
659/// Same as gethashnode but bypasses the DISABLED filter.
660pub fn gethashnode2<'a, T>(ht: &'a HashMap<String, T>, nam: &str) -> Option<&'a T> {
661    // c:255
662    ht.get(nam)
663}
664
665/// Port of `removehashnode(HashTable ht, const char *nam)` from `Src/hashtable.c:275`.
666///
667// table and returns a pointer to it.  If there                            // c:275
668// is no such node, then it returns NULL                                    // c:275
669/// C body removes the node from the bucket chain and returns the
670/// removed pointer (or NULL). Rust `HashMap::remove` has the
671/// matching shape.
672pub fn removehashnode<T>(ht: &mut HashMap<String, T>, nam: &str) -> Option<T> {
673    // c:275
674    ht.remove(nam)
675}
676
677/// Port of `disablehashnode(HashNode hn, UNUSED(int flags))` from `Src/hashtable.c:323`.
678///
679/// C body: `hn->flags |= DISABLED;`. Generic helper that flips
680/// the DISABLED bit on the named entry via [`HashNodeFlags`].
681pub fn disablehashnode<T: HashNodeFlags>(hn: &mut HashMap<String, T>, flags: &str) -> bool {
682    hn.get_mut(flags)
683        .map(|node| {
684            node.set_disabled(true);
685            true
686        })
687        .unwrap_or(false) // c:323
688}
689
690impl shfunc_table {
691    /// `new` — `shfunctab = newhashtable(7, "shfunctab", NULL)`
692    /// (`Src/hashtable.c:814`). The initial bucket count is part of the
693    /// observable scan order, so it must be C's 7 and grow only through
694    /// `expandhashtable`'s x4 rule.
695    pub fn new() -> Self {
696        Self {
697            table: std::sync::Arc::new(hashtable_nodes::newhashtable(7)), // hashtable.c:814
698        }
699    }
700    /// `snapshot` — clone the whole bucket array for subshell
701    /// save/restore. Used by `subshell_begin` to capture the parent's
702    /// function set before the subshell body runs, so `subshell_end`
703    /// can restore it (matches C fork-copy semantics at
704    /// `Src/exec.c::entersubsh`).
705    ///
706    /// This captures the TABLE, not a `HashMap` of its entries: the scan
707    /// order is part of the state (`Src/Modules/parameter.c:480-481`
708    /// walks `shfunctab->nodes[i]` directly), and rebuilding a bucket
709    /// array from an unordered map would reshuffle `${(k)functions}`
710    /// after every `( … )` / `$( … )`.
711    ///
712    /// O(1): the bucket array is `Arc`-shared (see the `table` field),
713    /// so this is a refcount bump. The deep copy is deferred to the
714    /// first `&mut self` method the subshell body calls, which goes
715    /// through `Arc::make_mut`. A body that never defines, removes,
716    /// disables or autoloads a function never pays for one.
717    pub fn snapshot(&self) -> std::sync::Arc<shfunc_table> {
718        std::sync::Arc::new(self.clone())
719    }
720    /// `restore` — replace the internal table with a saved snapshot.
721    /// Called by `subshell_end` after the subshell body completes.
722    /// Takes the `Arc`-shared snapshot stored in `SubshellSnapshot`;
723    /// unwraps in place when uniquely owned (the common case), else
724    /// clones out of the shared handle.
725    pub fn restore(&mut self, snap: std::sync::Arc<shfunc_table>) {
726        *self = std::sync::Arc::try_unwrap(snap).unwrap_or_else(|arc| (*arc).clone());
727    }
728    /// Formerly pre-sized the backing `HashMap`. C has no such call and
729    /// CANNOT have one: `hsize` only ever moves through
730    /// `expandhashtable`'s x4 steps (`Src/hashtable.c:466`), and the
731    /// bucket count is what `hasher(nam) % hsize` — hence the whole scan
732    /// order — is computed against. Pre-sizing to the batch size would
733    /// put every name in a different bucket than zsh does. Kept as a
734    /// no-op so the `compinit` call site (which is a Rust-only
735    /// optimisation) still compiles.
736    pub fn reserve(&mut self, _additional: usize) {}
737
738    /// `add` — `addhashnode2` (`Src/hashtable.c:168`) with the displaced
739    /// node handed back to the caller instead of `freenode`d.
740    pub fn add(&mut self, func: shfunc) -> Option<shfunc> {
741        let name = func.node.nam.clone();
742        std::sync::Arc::make_mut(&mut self.table)
743            .addhashnode2(&name, Box::new(func)) // c:168
744            .map(|b| *b)
745    }
746    /// `get` — `gethashnode` (`Src/hashtable.c:231`): DISABLED nodes read
747    /// as absent.
748    pub fn get(&self, name: &str) -> Option<&shfunc> {
749        self.table
750            .gethashnode2(name) // c:236-241
751            .map(|b| b.as_ref())
752            .filter(|f| (f.node.flags & DISABLED as i32) == 0) // c:239
753    }
754    /// `get_including_disabled` — `gethashnode2` (`Src/hashtable.c:255`).
755    pub fn get_including_disabled(&self, name: &str) -> Option<&shfunc> {
756        self.table.gethashnode2(name).map(|b| b.as_ref()) // c:255
757    }
758    /// `get_mut` — see implementation.
759    pub fn get_mut(&mut self, name: &str) -> Option<&mut shfunc> {
760        std::sync::Arc::make_mut(&mut self.table)
761            .get_mut(name)
762            .map(|b| b.as_mut())
763            .filter(|f| (f.node.flags & DISABLED as i32) == 0)
764    }
765    /// `remove` — `removehashnode` (`Src/hashtable.c:275`).
766    pub fn remove(&mut self, name: &str) -> Option<shfunc> {
767        std::sync::Arc::make_mut(&mut self.table)
768            .removehashnode(name)
769            .map(|b| *b) // c:275
770    }
771    /// `contains_key` — see implementation.
772    pub fn contains_key(&self, name: &str) -> bool {
773        self.table.gethashnode2(name).is_some()
774    }
775
776    /// Port of C's `HashTable.addnode` GSU function pointer
777    /// (`Src/zsh.h:281+`). Takes a `*mut shfunc` (typedef `Shfunc`)
778    /// previously obtained via `Box::into_raw` — reclaims ownership
779    /// into the table by name. After this call, the caller's `shf`
780    /// pointer is INVALIDATED in the Rust ownership sense; subsequent
781    /// reads must go through `getnode(name)` to get a fresh pointer.
782    /// In practice, C code re-uses the same `shf` pointer because the
783    /// Box stays at the same heap address — we keep that semantic by
784    /// boxing-on-heap. Replaces any prior entry with the same name
785    /// (matching C `addnode`'s overwrite-and-free-old behavior).
786    pub fn addnode(&mut self, shf: *mut shfunc) {
787        if shf.is_null() {
788            return;
789        }
790        let boxed = unsafe { Box::from_raw(shf) };
791        let name = boxed.node.nam.clone();
792        let _ = std::sync::Arc::make_mut(&mut self.table).addhashnode2(&name, boxed); // c:168
793    }
794
795    /// Port of C's `HashTable.getnode` GSU. Returns the raw `Shfunc`
796    /// pointer (typedef `*mut shfunc`) or null if missing or disabled.
797    /// Pointer stays valid as long as the underlying `Box<shfunc>`
798    /// lives in the table (i.e. until `remove`/`addnode`-overwrite).
799    ///
800    /// NOTE (copy-on-write): the bucket array is `Arc`-shared while a
801    /// subshell snapshot is live, so writing THROUGH this pointer would
802    /// bypass `Arc::make_mut` and mutate the snapshot too. It has no
803    /// callers — it is a name-parity anchor — and any future caller
804    /// must take `&mut` through `get_mut`/`add` instead.
805    pub fn getnode(&self, name: &str) -> *mut shfunc {
806        self.table
807            .gethashnode2(name)
808            .filter(|b| (b.node.flags & DISABLED as i32) == 0)
809            .map(|b| b.as_ref() as *const shfunc as *mut shfunc)
810            .unwrap_or(std::ptr::null_mut())
811    }
812
813    /// Port of C's `HashTable.getnode2` GSU — same as `getnode` but
814    /// returns disabled nodes too. Used by `unhash`/`enable -f` paths.
815    pub fn getnode2(&self, name: &str) -> *mut shfunc {
816        self.table
817            .gethashnode2(name)
818            .map(|b| b.as_ref() as *const shfunc as *mut shfunc)
819            .unwrap_or(std::ptr::null_mut())
820    }
821    /// `disable` — see implementation.
822    pub fn disable(&mut self, name: &str) -> bool {
823        if let Some(func) = std::sync::Arc::make_mut(&mut self.table).get_mut(name) {
824            func.node.flags |= DISABLED as i32;
825            true
826        } else {
827            false
828        }
829    }
830    /// `enable` — see implementation.
831    pub fn enable(&mut self, name: &str) -> bool {
832        if let Some(func) = std::sync::Arc::make_mut(&mut self.table).get_mut(name) {
833            func.node.flags &= !(DISABLED as i32);
834            true
835        } else {
836            false
837        }
838    }
839    /// `len` — see implementation.
840    pub fn len(&self) -> usize {
841        self.table.len()
842    }
843    /// `is_empty` — see implementation.
844    pub fn is_empty(&self) -> bool {
845        self.table.is_empty()
846    }
847    /// `iter` — see implementation.
848    pub fn iter(&self) -> impl Iterator<Item = (&String, &shfunc)> {
849        self.table.iter().map(|(k, b)| (k, b.as_ref()))
850    }
851    /// `iter_sorted` — see implementation.
852    pub fn iter_sorted(&self) -> Vec<(&String, &shfunc)> {
853        let mut entries: Vec<(&String, &shfunc)> =
854            self.table.iter().map(|(k, b)| (k, b.as_ref())).collect();
855        entries.sort_by(|a, b| a.0.cmp(b.0));
856        entries
857    }
858    /// `clear` — `emptyhashtable` (`Src/hashtable.c:517`): frees every
859    /// node but keeps the bucket count.
860    pub fn clear(&mut self) {
861        std::sync::Arc::make_mut(&mut self.table).emptyhashtable(); // c:517
862    }
863}
864
865impl Default for shfunc_table {
866    fn default() -> Self {
867        Self::new()
868    }
869}
870
871// `reswdToken` enum deleted — Rust-only enum duplicating the
872// canonical `lextok` i32 token constants already in zsh_h.rs
873// (BANG_TOK/DINBRACK/INBRACE_TOK/OUTBRACE_TOK/CASE/COPROC/DOLOOP
874// /DONE/ELIF/ELSE/ZEND/ESAC/FI/FOR/FOREACH/FUNC/IF/NOCORRECT/
875// REPEAT/SELECT/THEN/TIME/UNTIL/WHILE/TYPESET at zsh.h:345-371).
876// reswd.token now stores the raw i32 lextok matching C `struct
877// reswd { HashNode node; int token; }` at zsh.h:1246-1249.
878
879// `reswd` struct + impl deleted — Rust-only duplicate of canonical
880// `crate::ported::zsh_h::reswd` (zsh.h:1246-1249). The canonical
881// has `node: hashnode { nam, flags, next }` + `token: i32`; the
882// Rust-only had `name, flags: u32, token: i32` (missing the
883// hashnode embedding). Type alias surfaces the canonical struct
884// to in-file callers and external imports.
885// c:1246
886
887/// Public copy of the canonical `reswds[]` table from
888/// `Src/hashtable.c:1076-1108`. Each entry is `(name, lextok)`; the
889/// token identifies which grammar production the word triggers.
890///
891/// Callers outside the hashtable (LSP reflection dump, IntelliJ
892/// inventory) iterate this directly so they don't have to take the
893/// `reswdtab` lock or duplicate the list. Filtering: entries with
894/// `token == TYPESET` are declaration commands (local / typeset /
895/// declare / export / readonly / integer / float) — they're aliased
896/// to `typeset` at the grammar level but really live as builtins, so
897/// a "reserved word" inventory should exclude them.
898pub const RESWDS: &[(&str, i32)] = &[
899    ("!", BANG_TOK),
900    ("[[", DINBRACK),
901    ("{", INBRACE_TOK),
902    ("}", OUTBRACE_TOK),
903    ("case", CASE),
904    ("coproc", COPROC),
905    ("declare", TYPESET),
906    ("do", DOLOOP),
907    ("done", DONE),
908    ("elif", ELIF),
909    ("else", ELSE),
910    ("end", ZEND),
911    ("esac", ESAC),
912    ("export", TYPESET),
913    ("fi", FI),
914    ("float", TYPESET),
915    ("for", FOR),
916    ("foreach", FOREACH),
917    ("function", FUNC),
918    ("if", IF),
919    ("integer", TYPESET),
920    ("local", TYPESET),
921    ("nocorrect", NOCORRECT),
922    ("readonly", TYPESET),
923    ("repeat", REPEAT),
924    ("select", SELECT),
925    ("then", THEN),
926    ("time", TIME),
927    ("typeset", TYPESET),
928    ("until", UNTIL),
929    ("while", WHILE),
930];
931
932/// Port of `enablehashnode(HashNode hn, UNUSED(int flags))` from `Src/hashtable.c:332`.
933///
934/// C body: `hn->flags &= ~DISABLED;`. Inverse of [`disablehashnode`].
935pub fn enablehashnode<T: HashNodeFlags>(hn: &mut HashMap<String, T>, flags: &str) -> bool {
936    hn.get_mut(flags)
937        .map(|node| {
938            node.set_disabled(false);
939            true
940        })
941        .unwrap_or(false) // c:332
942}
943
944impl reswd_table {
945    /// `new` — port of `createreswdtable()` (`Src/hashtable.c:1120`):
946    /// `newhashtable(23, "reswdtab", NULL)` (`c:1124`) followed by
947    /// `for (rw = reswds; rw->node.nam; rw++) reswdtab->addnode(...)`
948    /// (`c:1138-1139`), which walks the static `reswds[]` array in
949    /// declaration order.
950    pub fn new() -> Self {
951        // c:1124 — `reswdtab = newhashtable(23, "reswdtab", NULL);`
952        let mut table = hashtable_nodes::newhashtable(23);
953
954        // Direct port of `static struct reswd reswds[]` at
955        // Src/hashtable.c:1076-1108. Token IDs are the lextok
956        // constants from zsh_h.rs (zsh.h:345-371).
957        //
958        // Same list is exposed via the public `RESWDS` const below so
959        // callers outside this module (LSP reflection dump, IntelliJ
960        // tool-window inventory) can enumerate reserved words without
961        // taking the table lock.
962        let words: [(&str, i32); 31] = [
963            // c:1076
964            ("!", BANG_TOK),          // c:1077
965            ("[[", DINBRACK),         // c:1078
966            ("{", INBRACE_TOK),       // c:1079
967            ("}", OUTBRACE_TOK),      // c:1080
968            ("case", CASE),           // c:1081
969            ("coproc", COPROC),       // c:1082
970            ("declare", TYPESET),     // c:1083
971            ("do", DOLOOP),           // c:1084
972            ("done", DONE),           // c:1085
973            ("elif", ELIF),           // c:1086
974            ("else", ELSE),           // c:1087
975            ("end", ZEND),            // c:1088
976            ("esac", ESAC),           // c:1089
977            ("export", TYPESET),      // c:1090
978            ("fi", FI),               // c:1091
979            ("float", TYPESET),       // c:1092
980            ("for", FOR),             // c:1093
981            ("foreach", FOREACH),     // c:1094
982            ("function", FUNC),       // c:1095
983            ("if", IF),               // c:1096
984            ("integer", TYPESET),     // c:1097
985            ("local", TYPESET),       // c:1098
986            ("nocorrect", NOCORRECT), // c:1099
987            ("readonly", TYPESET),    // c:1100
988            ("repeat", REPEAT),       // c:1101
989            ("select", SELECT),       // c:1102
990            ("then", THEN),           // c:1103
991            ("time", TIME),           // c:1104
992            ("typeset", TYPESET),     // c:1105
993            ("until", UNTIL),         // c:1106
994            ("while", WHILE),         // c:1107
995        ];
996        // Sanity: the local `words` array and the public `RESWDS` const
997        // below MUST stay in sync — both are direct ports of the same
998        // upstream `reswds[]` table at Src/hashtable.c:1076-1108.
999        debug_assert_eq!(words.len(), RESWDS.len());
1000
1001        for (name, token) in words {
1002            // Direct struct literal — canonical `reswd` has
1003            // `node: hashnode` (zsh.h:1246) so we build the
1004            // embedded hashnode inline. Mirrors C `{{NULL,
1005            // "if", 0}, IF}` at hashtable.c:1077+.
1006            // c:1138-1139 — `reswdtab->addnode(reswdtab, rw->node.nam, rw)`
1007            table.addhashnode2(
1008                name,
1009                reswd {
1010                    node: hashnode {
1011                        next: None,
1012                        nam: name.to_string(),
1013                        flags: 0,
1014                    },
1015                    token,
1016                },
1017            );
1018        }
1019
1020        Self { table }
1021    }
1022    /// `get` — `gethashnode` (`Src/hashtable.c:245`), the lookup that
1023    /// skips `DISABLED` nodes (`c:253`); `createreswdtable` wires
1024    /// `reswdtab->getnode = gethashnode` (`c:1131`).
1025    pub fn get(&self, name: &str) -> Option<&reswd> {
1026        // c:245
1027        self.table
1028            .gethashnode2(name)
1029            .filter(|r| (r.node.flags & DISABLED as i32) == 0)
1030    }
1031    /// `get_including_disabled` — `gethashnode2`
1032    /// (`Src/hashtable.c:255`), wired as `reswdtab->getnode2`
1033    /// (`c:1132`).
1034    pub fn get_including_disabled(&self, name: &str) -> Option<&reswd> {
1035        self.table.gethashnode2(name) // c:255
1036    }
1037    /// `disable` — see implementation.
1038    pub fn disable(&mut self, name: &str) -> bool {
1039        if let Some(rw) = self.table.get_mut(name) {
1040            rw.node.flags |= DISABLED as i32;
1041            true
1042        } else {
1043            false
1044        }
1045    }
1046    /// `enable` — see implementation.
1047    pub fn enable(&mut self, name: &str) -> bool {
1048        if let Some(rw) = self.table.get_mut(name) {
1049            rw.node.flags &= !(DISABLED as i32);
1050            true
1051        } else {
1052            false
1053        }
1054    }
1055    /// `is_reserved` — see implementation.
1056    pub fn is_reserved(&self, name: &str) -> bool {
1057        self.get(name).is_some()
1058    }
1059    /// `iter` — the raw bucket walk `getreswords`
1060    /// (`Src/Modules/parameter.c:877-880`) does over
1061    /// `reswdtab->nodes[]`, which is what `$reswords` /
1062    /// `$dis_reswords` expose.
1063    pub fn iter(&self) -> impl Iterator<Item = (&String, &reswd)> {
1064        self.table.iter() // c:420-434
1065    }
1066    /// Port of `addhashnode(HashTable ht, char *nam, void *nodeptr)`
1067    /// from `Src/hashtable.c:157`. C stores `nodeptr` under `nam` and
1068    /// frees any node it displaces (`ht->freenode(oldnode)`, c:161).
1069    /// Here the map replaces the entry and the displaced `reswd` is
1070    /// dropped, matching C's freenode semantics. Runtime companion to
1071    /// the seed-only `new()`; param_private's `setup_` uses it to
1072    /// register `private` as a TYPESET reserved word at module boot
1073    /// (param_private.c:687 `reswdtab->addnode(reswdtab, ...)`).
1074    pub fn insert(&mut self, name: &str, rw: reswd) {
1075        // c:157 addhashnode → c:159 addhashnode2 sets hn->nam = nam
1076        self.table.addhashnode2(name, rw);
1077    }
1078    /// Port of `removehashnode(HashTable ht, const char *nam)` from
1079    /// `Src/hashtable.c:275`. Unlinks the node keyed by `nam` and
1080    /// returns it (C returns the removed `HashNode`, or NULL when the
1081    /// key is absent — c:283). param_private's teardown uses it to
1082    /// unregister the `private` reserved word (param_private.c:722
1083    /// `removehashnode(reswdtab, "private")`).
1084    pub fn remove(&mut self, name: &str) -> Option<reswd> {
1085        // c:275
1086        self.table.removehashnode(name)
1087    }
1088}
1089
1090impl Default for reswd_table {
1091    fn default() -> Self {
1092        Self::new()
1093    }
1094}
1095
1096// `crate::ported::zsh_h::alias` struct + impl deleted — Rust-only duplicate of canonical
1097// `crate::ported::zsh_h::alias` (zsh.h:1253-1257). The canonical
1098// has `node: hashnode { nam, flags, next }` embedded (c:1254) +
1099// `text: String` (c:1255) + `inuse: i32` (c:1256); the Rust-only
1100// had a flat `name: String, flags: u32, text: String, inuse: i32`
1101// (missing the hashnode embedding).
1102
1103/// Port of `static int hnamcmp(const void *ap, const void *bp)`
1104/// from `Src/hashtable.c:341-346`. C body:
1105/// ```c
1106/// HashNode a = *(HashNode *)ap;
1107/// HashNode b = *(HashNode *)bp;
1108/// return ztrcmp(a->nam, b->nam);
1109/// ```
1110///
1111/// `ztrcmp` is a META-AWARE compare that XORs Meta-escaped bytes
1112/// with 32 before comparing (Src/utils.c:5106). The previous Rust
1113/// port used `str::cmp` which does naive byte-wise lexicographic
1114/// compare — for Meta-encoded hash-table keys this sorts them
1115/// incorrectly (Meta byte 0x83 sorts AFTER ASCII printable but the
1116/// real underlying byte 0x83^32=0xa3 should compare as a high byte).
1117///
1118/// Route through the canonical `crate::ported::utils::ztrcmp` so
1119/// `functions`, `alias`, etc. sort their key listings the same way
1120/// C does for Meta-encoded names.
1121pub fn hnamcmp(ap: &str, bp: &str) -> std::cmp::Ordering {
1122    ztrcmp(ap, bp) // c:345
1123}
1124
1125/// Port of `scanmatchtable(HashTable ht, Patprog pprog, int sorted, int flags1, int flags2, ScanFunc scanfunc, int scanflags)` from `Src/hashtable.c:373`.
1126///
1127/// C body walks every node calling `func(node, scanflags)` if
1128/// the node satisfies (a) optional pattern match, (b) `flags1`
1129/// require-at-least-one, (c) `flags2` require-none-of. The
1130/// `sorted` flag pre-sorts entries before scanning.
1131///
1132/// Rust port: same shape with closure callback. Returns the
1133/// match count.
1134/// WARNING: param names don't match C — Rust=() vs C=(ht, pprog, sorted, flags1, flags2, scanfunc, scanflags)
1135pub fn scanmatchtable<T: HashNodeFlags, F: FnMut(&str, &T)>(
1136    ht: &HashMap<String, T>,
1137    pattern: Option<&str>,
1138    sorted: bool,
1139    flags1: u32,
1140    flags2: u32,
1141    mut func: F,
1142) -> i32 {
1143    let mut entries: Vec<(&String, &T)> = ht.iter().collect();
1144    if sorted {
1145        // c:400 — `qsort(hnsorttab, ct, sizeof(HashNode), hnamcmp);`
1146        // hnamcmp routes through Meta-aware ztrcmp. The previous Rust
1147        // port used `str::cmp` (naive byte-wise) which sorts Meta-
1148        // encoded hash keys incorrectly. Use the canonical hnamcmp
1149        // to match C's qsort comparator exactly.
1150        entries.sort_by(|a, b| hnamcmp(a.0, b.0)); // c:400
1151    }
1152    let mut match_count = 0;
1153    for (name, node) in entries {
1154        if let Some(p) = pattern {
1155            if !simple_glob_match(p, name) {
1156                continue;
1157            }
1158        }
1159        let f = node.flags();
1160        if flags1 != 0 && (f & flags1) == 0 {
1161            continue;
1162        }
1163        if flags2 != 0 && (f & flags2) != 0 {
1164            continue;
1165        }
1166        func(name, node);
1167        match_count += 1;
1168    }
1169    match_count
1170}
1171
1172impl alias_table {
1173    /// `new` — `newhashtable(23, "aliastab", NULL)` +
1174    /// `createaliastable(aliastab)` (`Src/hashtable.c:1210-1212`),
1175    /// without the two default aliases. `sufaliastab` is created at
1176    /// `hsize = 11` (`Src/hashtable.c:1221`) so `sufaliastab_lock()`
1177    /// builds its table inline rather than going through here.
1178    pub fn new() -> Self {
1179        Self {
1180            table: crate::cow_map::CowArc::new(hashtable_nodes::newhashtable(23)), // c:1210
1181        }
1182    }
1183    /// `with_defaults` — `new()` plus the two aliases
1184    /// `createaliastables()` installs (`Src/hashtable.c:1215-1216`).
1185    /// They are added FIRST, before any user alias, so the chain heads
1186    /// come out in C's order.
1187    pub fn with_defaults() -> Self {
1188        let mut table = Self::new();
1189        // ZSHRS-ONLY. `run-help` / `which-command` are ZSH's default
1190        // aliases. A drop-in starts with the set ITS shell defines:
1191        // bash, dash and /bin/sh define none at all, ksh93 defines 19
1192        // (`r`, `functions`, `integer`, …) and mksh 11. Measured with
1193        // `<shell> -c alias` in an empty environment.
1194        if let Some(defaults) = crate::extensions::emulation_startup::default_aliases() {
1195            for (name, text) in defaults {
1196                table.add(createaliasnode(name, text, 0));
1197            }
1198            return table;
1199        }
1200        // C addaliasnode(aliastab, "run-help", createaliasnode("man", 0));
1201        // at hashtable.c:1215-1216.
1202        table.add(createaliasnode("run-help", "man", 0)); // c:1215
1203        table.add(createaliasnode("which-command", "whence", 0)); // c:1216
1204        table
1205    }
1206    /// `snapshot` — the table as the parent had it, for a subshell to
1207    /// hand back at its end.
1208    ///
1209    /// C never needs this: `getoutput` and `( … )` fork
1210    /// (`Src/exec.c:4816`, `c:2880`) and `entersubsh` (`c:1123-1261`)
1211    /// leaves `aliastab`/`sufaliastab` alone, so the child mutates a
1212    /// private copy that dies with it. zshrs runs both forms in process,
1213    /// so the parent's table is taken here and put back by `restore`.
1214    ///
1215    /// O(1): the bucket array is `CowArc`-shared. The deep copy happens
1216    /// only if the body adds, removes, disables or enables an alias.
1217    /// Expanding one does not count — `alias::inuse` is atomic.
1218    ///
1219    /// !!! RUST-ONLY METHOD — no C counterpart (C forks) !!!
1220    pub fn snapshot(&self) -> alias_table {
1221        alias_table {
1222            table: self.table.clone(),
1223        }
1224    }
1225    /// `restore` — put back a table taken by `snapshot`, bucket order
1226    /// and all (`${(k)aliases}` walks the buckets directly).
1227    ///
1228    /// !!! RUST-ONLY METHOD — no C counterpart (C forks) !!!
1229    pub fn restore(&mut self, snap: alias_table) {
1230        self.table = snap.table;
1231    }
1232    /// `add` — `addhashnode2` (`Src/hashtable.c:168`). C's `addnode`
1233    /// for this table is `addhashnode` (`c:1194`), which is
1234    /// `addhashnode2` + `freenode(oldnode)` (`c:157-162`); returning
1235    /// the displaced node lets the caller do the freeing, and dropping
1236    /// it is `freealiasnode` (`c:1243`).
1237    pub fn add(&mut self, alias: alias) -> Option<alias> {
1238        // c:157 / c:168
1239        let nam = alias.node.nam.clone(); // c:177 — `hn->nam = nam`
1240        self.table.addhashnode2(&nam, alias)
1241    }
1242    /// `get` — `gethashnode` (`Src/hashtable.c:245`), i.e. the lookup
1243    /// that skips `DISABLED` nodes (`c:253`).
1244    pub fn get(&self, name: &str) -> Option<&alias> {
1245        // c:245
1246        self.table
1247            .gethashnode2(name)
1248            .filter(|a| (a.node.flags & DISABLED as i32) == 0)
1249    }
1250    /// `get_including_disabled` — `gethashnode2`
1251    /// (`Src/hashtable.c:255`), the lookup WITHOUT the DISABLED filter.
1252    pub fn get_including_disabled(&self, name: &str) -> Option<&alias> {
1253        self.table.gethashnode2(name) // c:255
1254    }
1255    /// `get_mut` — mutable `gethashnode` (`Src/hashtable.c:245`); C
1256    /// mutates straight through the returned `HashNode` pointer.
1257    pub fn get_mut(&mut self, name: &str) -> Option<&mut alias> {
1258        // c:245
1259        self.table
1260            .get_mut(name)
1261            .filter(|a| (a.node.flags & DISABLED as i32) == 0)
1262    }
1263    /// `remove` — `removehashnode` (`Src/hashtable.c:275`).
1264    pub fn remove(&mut self, name: &str) -> Option<alias> {
1265        self.table.removehashnode(name) // c:275
1266    }
1267    /// `disable` — see implementation.
1268    pub fn disable(&mut self, name: &str) -> bool {
1269        if let Some(alias) = self.table.get_mut(name) {
1270            alias.node.flags |= DISABLED as i32;
1271            true
1272        } else {
1273            false
1274        }
1275    }
1276    /// `enable` — see implementation.
1277    pub fn enable(&mut self, name: &str) -> bool {
1278        if let Some(alias) = self.table.get_mut(name) {
1279            alias.node.flags &= !(DISABLED as i32);
1280            true
1281        } else {
1282            false
1283        }
1284    }
1285    /// `len` — see implementation.
1286    pub fn len(&self) -> usize {
1287        self.table.len()
1288    }
1289    /// `is_empty` — see implementation.
1290    pub fn is_empty(&self) -> bool {
1291        self.table.is_empty()
1292    }
1293    /// `clear` — `emptyhashtable` (`Src/hashtable.c:517`), which is
1294    /// `resizehashtable(ht, ht->hsize)`: every node freed, `hsize` kept.
1295    pub fn clear(&mut self) {
1296        self.table.emptyhashtable(); // c:517
1297    }
1298    /// `iter` — the unsorted `scanmatchtable` walk
1299    /// (`Src/hashtable.c:420-434`): bucket 0..hsize-1, each chain
1300    /// head→tail. This IS the order `${(k)aliases}` / `${(k)galiases}`
1301    /// / `${(k)saliases}` emit via `scanpmraliases` and friends
1302    /// (`Src/Modules/parameter.c:2005-2047`).
1303    pub fn iter(&self) -> impl Iterator<Item = (&String, &alias)> {
1304        self.table.iter() // c:420-434
1305    }
1306    /// `iter_sorted` — the `sorted` arm of `scanmatchtable`
1307    /// (`Src/hashtable.c:395-401`), which `qsort`s the collected nodes
1308    /// with `hnamcmp`.
1309    pub fn iter_sorted(&self) -> Vec<(&String, &alias)> {
1310        let mut entries: Vec<_> = self.table.iter().collect();
1311        entries.sort_by(|a, b| a.0.cmp(b.0)); // c:400
1312        entries
1313    }
1314}
1315
1316impl Default for alias_table {
1317    fn default() -> Self {
1318        Self::new()
1319    }
1320}
1321
1322/// Port of `scanhashtable(HashTable ht, int sorted, int flags1, int flags2, ScanFunc scanfunc, int scanflags)` from `Src/hashtable.c:446`.
1323///
1324/// C body delegates to `scanmatchtable` with `pprog = NULL`. Rust
1325/// port does the same.
1326/// WARNING: param names don't match C — Rust=() vs C=(ht, sorted, flags1, flags2, scanfunc, scanflags)
1327pub fn scanhashtable<T: HashNodeFlags, F: FnMut(&str, &T)>(
1328    ht: &HashMap<String, T>,
1329    sorted: bool,
1330    flags1: u32,
1331    flags2: u32,
1332    func: F,
1333) -> i32 {
1334    scanmatchtable(ht, None, sorted, flags1, flags2, func)
1335}
1336
1337/// Port of `expandhashtable(HashTable ht)` from `Src/hashtable.c:458`.
1338///
1339/// C grows the bucket array when load factor exceeds threshold.
1340/// Rust HashMap rehashes automatically — calling reserve on the
1341/// passed map gives the closest equivalent.
1342/// Rust idiom replacement: `HashMap::reserve` covers the C
1343/// `growhashtable` bucket-realloc + rehash loop.
1344pub fn expandhashtable<T>(ht: &mut HashMap<String, T>) {
1345    let want = ht.len() * 2;
1346    ht.reserve(want.saturating_sub(ht.capacity()));
1347}
1348
1349/// Port of `resizehashtable(HashTable ht, int newsize)` from `Src/hashtable.c:486`.
1350///
1351/// C reallocates buckets to a specific size. Rust HashMap reserves
1352/// capacity to ensure at least `newsize` entries fit without rehash.
1353/// Rust idiom replacement: `HashMap::reserve(need)` covers the C
1354/// `realloc(hsize * sizeof(HashNode))` + rehash dance.
1355pub fn resizehashtable<T>(ht: &mut HashMap<String, T>, newsize: i32) {
1356    let need = newsize.max(0) as usize;
1357    if need > ht.capacity() {
1358        ht.reserve(need - ht.capacity());
1359    }
1360}
1361
1362// Generic method to empty a hash table                                    // c:519
1363/// Port of `emptyhashtable(HashTable ht)` from `Src/hashtable.c:519`.
1364///
1365/// C body: `resizehashtable(ht, ht->hsize);` — drop all nodes
1366/// while keeping the bucket array. Rust HashMap::clear preserves
1367/// capacity, matching the semantic.
1368pub fn emptyhashtable<T>(ht: &mut HashMap<String, T>) {
1369    // c:519
1370    ht.clear();
1371}
1372
1373// Print info about hash table                                             // c:527
1374/// Port of `printhashtabinfo(HashTable ht)` from `Src/hashtable.c:78`.
1375///
1376/// C body prints chain-length distribution stats for hash-table
1377/// debug analysis (under ZSH_HASH_DEBUG). Rust HashMap doesn't
1378/// expose chain-length info; emit count + capacity which is the
1379/// equivalent visibility.
1380/// Rust idiom replacement: HashMap's open addressing doesn't expose
1381/// chain length, so we emit name+capacity+len — the equivalent
1382/// visibility under Rust's std::collections backend.
1383/// WARNING: param names don't match C — Rust=(name, ht) vs C=(ht)
1384pub fn printhashtabinfo<T>(name: &str, ht: &HashMap<String, T>) -> String {
1385    // c:78
1386    format!(
1387        "name of table   : {}\nsize of nodes[] : {}\nnumber of nodes : {}",
1388        name,
1389        ht.capacity(),
1390        ht.len()
1391    )
1392}
1393
1394/// Port of `bin_hashinfo(UNUSED(char *nam), UNUSED(char **args), UNUSED(Options ops), UNUSED(int func))` from `Src/hashtable.c:566`.
1395///
1396/// C iterates all registered hashtables (cmdnamtab, shfunctab,
1397/// aliastab, etc.) and emits stats for each. Rust port walks the
1398/// known-singleton tables.
1399pub fn bin_hashinfo(
1400    _nam: &str,
1401    _args: &[String], // c:566
1402    _ops: &options,
1403    _func: i32,
1404) -> i32 {
1405    let banner = "----------------------------------------------------";
1406    println!("{}", banner);
1407    {
1408        let tab = cmdnamtab_lock().read().expect("cmdnamtab poisoned");
1409        println!("name of table   : cmdnamtab");
1410        println!("number of nodes : {}", tab.len());
1411    }
1412    println!("{}", banner);
1413    {
1414        let tab = shfunctab_lock().read().expect("shfunctab poisoned");
1415        println!("name of table   : shfunctab");
1416        println!("number of nodes : {}", tab.len());
1417    }
1418    println!("{}", banner);
1419    {
1420        let tab = aliastab_lock().read().expect("aliastab poisoned");
1421        println!("name of table   : aliastab");
1422        println!("number of nodes : {}", tab.len());
1423    }
1424    println!("{}", banner);
1425    0
1426}
1427
1428// Old fake `dircache_lock(Mutex<HashMap<String, i32>>)` deleted —
1429// wrong shape (C uses `struct dircache_entry { name, refs }` not
1430// `HashMap<String, i32>`). Canonical port lives earlier in this
1431// file at the `dircache_entry` struct + `dircache_lock` accessor
1432// returning `Mutex<Vec<dircache_entry>>`.
1433
1434/// Port of `createcmdnamtable()` from `Src/hashtable.c:601`.
1435///
1436/// C body sets up the cmdnamtab GSU vtable (hash, addnode,
1437/// removenode, freenode, printnode = printcmdnamnode). Rust port
1438/// just touches the singleton to ensure it's initialised.
1439pub fn createcmdnamtable() {
1440    let _ = cmdnamtab_lock();
1441}
1442
1443/// Port of `emptycmdnamtable(HashTable ht)` from `Src/hashtable.c:623`.
1444///
1445/// C body:
1446/// ```c
1447/// emptyhashtable(ht);
1448/// pathchecked = path;
1449/// ```
1450///
1451/// Drops every PATH cache entry (used by `hash -r`) and resets
1452/// the per-PATH-entry "checked" cursor so subsequent lookups
1453/// re-scan from the start.
1454/// WARNING: param names don't match C — Rust=() vs C=(ht)
1455pub fn emptycmdnamtable() {
1456    // c:1015 — `emptyhashtable(ht);`
1457    cmdnamtab_lock()
1458        .write()
1459        .expect("cmdnamtab poisoned")
1460        .clear();
1461    // c:1016 — `pathchecked = path;`. Resetting the cursor here (not in
1462    // each caller) is what C does: every caller that empties the table
1463    // must also allow a subsequent `fillcmdnamtable` to re-walk PATH
1464    // from the start. Without this, emptying the table (e.g. a `PATH`
1465    // reassignment) left `pathchecked` exhausted, so the next
1466    // `${(k)commands}` / `compadd -k commands` scan refilled nothing.
1467    pathchecked.store(0, std::sync::atomic::Ordering::SeqCst);
1468}
1469
1470/// Port of `hashdir(char **dirp)` from `Src/hashtable.c:634`.
1471///
1472/// C body opendir's the directory, reads each entry, and adds
1473/// any executable to `cmdnamtab` (skipping names already present
1474/// from earlier PATH entries). Rust port routes through
1475/// `cmdnam_table::hash_dir`.
1476/// Rust idiom replacement: pure delegation to `hash_dir` on the
1477/// typed `CmdNamTable`; the C opendir/readdir/executable-test loop
1478/// lives there with `fs::read_dir` + `is_executable_via_metadata`.
1479/// WARNING: param names don't match C — Rust=(dir, dir_index) vs C=(dirp)
1480pub fn hashdir(dir: &str, dir_index: usize) {
1481    cmdnamtab_lock()
1482        .write()
1483        .expect("cmdnamtab poisoned")
1484        .hash_dir(dir, dir_index);
1485}
1486
1487/// Port of `fillcmdnamtable(UNUSED(HashTable ht))` from `Src/hashtable.c:712`.
1488///
1489/// C body:
1490/// ```c
1491/// for (pq = pathchecked; *pq; pq++) hashdir(pq);
1492/// pathchecked = pq;
1493/// ```
1494///
1495/// Walks every PATH entry calling `hashdir` for each. The
1496/// `pathchecked` cursor is updated so subsequent calls don't
1497/// re-walk PATH entries that were already scanned.
1498/// WARNING: param names don't match C — Rust=(path) vs C=(ht)
1499pub fn fillcmdnamtable(path: &[String]) {
1500    // c:716 — `for (pq = pathchecked; *pq; pq++) hashdir(pq);`. Start
1501    // from the cursor, NOT index 0: dirs already walked by an earlier
1502    // fill (or by `hashcmd`, which bumps `pathchecked`) must not be
1503    // re-scanned. Re-filling from 0 on every call made
1504    // `${(k)commands}` / `compadd -k commands` return the entire PATH
1505    // even when `pathchecked` was exhausted and the table had been
1506    // emptied — diverging from zsh, whose scan yields the current
1507    // (possibly empty) table. Symptom: `l<TAB>` listed all 230 PATH
1508    // commands vs zsh's 5 builtins.
1509    use std::sync::atomic::Ordering;
1510    let from = pathchecked.load(Ordering::SeqCst);
1511    if from < path.len() {
1512        let mut tab = cmdnamtab_lock().write().expect("cmdnamtab poisoned");
1513        for idx in from..path.len() {
1514            tab.hash_dir(&path[idx], idx);
1515        }
1516    }
1517    // c:719 — `pathchecked = pq;` — cursor advances to the end.
1518    pathchecked.store(path.len(), Ordering::SeqCst);
1519}
1520
1521/// Port of `freecmdnamnode(HashNode hn)` from `Src/hashtable.c:724`.
1522///
1523/// C body frees the entry's name + (if HASHED) cached path. Rust
1524/// port: drop runs both when the entry is removed from the table.
1525/// This helper performs the removal to trigger Drop.
1526pub fn freecmdnamnode(hn: &str) {
1527    cmdnamtab_lock()
1528        .write()
1529        .expect("cmdnamtab poisoned")
1530        .remove(hn);
1531}
1532
1533/// Port of `printcmdnamnode(HashNode hn, int printflags)` from `Src/hashtable.c:739`.
1534///
1535/// Emits one cmdnamtab entry for `hash` / `whence`. Each branch
1536/// returns; PRINT_LIST falls through to the tail that emits
1537/// `quotedzputs(nam) '=' quotedzputs(u.cmd|*u.name '/' nam) '\n'`.
1538pub fn printcmdnamnode(hn: &cmdnam, printflags: i32) {
1539    // c:741 — `Cmdnam cn = (Cmdnam) hn;` — Rust types give us cmdnam.
1540
1541    // c:743-747 — PRINT_WHENCE_WORD branch.
1542    if (printflags & PRINT_WHENCE_WORD) != 0 {
1543        // c:744-745 — `printf("%s: %s\n", nam, HASHED ? "hashed" : "command");`
1544        let kind = if (hn.node.flags & HASHED as i32) != 0 {
1545            "hashed"
1546        } else {
1547            "command"
1548        };
1549        println!("{}: {}", hn.node.nam, kind); // c:744
1550        return; // c:746
1551    }
1552
1553    // c:749-760 — PRINT_WHENCE_CSH | PRINT_WHENCE_SIMPLE branch.
1554    if (printflags & (PRINT_WHENCE_CSH | PRINT_WHENCE_SIMPLE)) != 0 {
1555        let mut so = io::stdout();
1556        if (hn.node.flags & HASHED as i32) != 0 {
1557            // c:750
1558            // c:751-752 — `zputs(u.cmd, stdout); putchar('\n');`
1559            if let Some(cmd) = &hn.cmd {
1560                let _ = zputs(cmd, &mut so); // c:751
1561            }
1562            println!(); // c:752
1563        } else {
1564            // c:753
1565            // c:754-757 — `zputs(*u.name); putchar('/'); zputs(nam); putchar('\n');`
1566            if let Some(name_arr) = &hn.name {
1567                if let Some(first) = name_arr.first() {
1568                    let _ = zputs(first, &mut so); // c:754
1569                }
1570            }
1571            print!("/"); // c:755
1572            let _ = zputs(&hn.node.nam, &mut so); // c:756
1573            println!(); // c:757
1574        }
1575        return; // c:759
1576    }
1577
1578    // c:762-777 — PRINT_WHENCE_VERBOSE branch.
1579    if (printflags & PRINT_WHENCE_VERBOSE) != 0 {
1580        let mut so = io::stdout();
1581        if (hn.node.flags & HASHED as i32) != 0 {
1582            // c:763
1583            // c:764-767 — `nicezputs(nam); printf(" is hashed to "); nicezputs(u.cmd); putchar('\n');`
1584            let _ = nicezputs(&hn.node.nam, &mut so); // c:764
1585            print!(" is hashed to "); // c:765
1586            if let Some(cmd) = &hn.cmd {
1587                let _ = nicezputs(cmd, &mut so); // c:766
1588            }
1589            println!(); // c:767
1590        } else {
1591            // c:768
1592            // c:769-774 — `nicezputs(nam); printf(" is "); nicezputs(*u.name); putchar('/'); nicezputs(nam); putchar('\n');`
1593            let _ = nicezputs(&hn.node.nam, &mut so); // c:769
1594            print!(" is "); // c:770
1595            if let Some(name_arr) = &hn.name {
1596                if let Some(first) = name_arr.first() {
1597                    let _ = nicezputs(first, &mut so); // c:771
1598                }
1599            }
1600            print!("/"); // c:772
1601            let _ = nicezputs(&hn.node.nam, &mut so); // c:773
1602            println!(); // c:774
1603        }
1604        return; // c:776
1605    }
1606
1607    // c:779-784 — PRINT_LIST prefix block; falls through to the tail.
1608    if (printflags & PRINT_LIST) != 0 {
1609        // c:779
1610        print!("hash "); // c:780
1611                         // c:782-783 — `-- ` for names starting with `-`.
1612        if hn.node.nam.starts_with('-') {
1613            // c:782
1614            print!("-- "); // c:783
1615        }
1616    }
1617
1618    // c:786-798 — common tail. HASHED uses u.cmd, !HASHED splices first
1619    // u.name PATH segment + '/' + nam.
1620    // ZSHRS-ONLY: bash prints `hits<TAB>path` rows under a header and
1621    // dash prints the bare path; only the Korn shells and zsh use the
1622    // `name=path` form below. See `emulation_output::hash_entry`.
1623    {
1624        let resolved = if (hn.node.flags & HASHED as i32) != 0 {
1625            hn.cmd.clone().unwrap_or_default()
1626        } else {
1627            let dir = hn
1628                .name
1629                .as_ref()
1630                .and_then(|a| a.first().cloned())
1631                .unwrap_or_default();
1632            format!("{dir}/{}", hn.node.nam)
1633        };
1634        if let Some(row) =
1635            crate::extensions::emulation_output::hash_entry(&hn.node.nam, &resolved)
1636        {
1637            println!("{row}");
1638            return;
1639        }
1640    }
1641    if (hn.node.flags & HASHED as i32) != 0 {
1642        // c:786
1643        let _ = io::Write::write_all(&mut io::stdout(), &unmetafy_str(&quotedzputs(&hn.node.nam))); // c:787
1644        print!("="); // c:788
1645        if let Some(cmd) = &hn.cmd {
1646            let _ = io::Write::write_all(&mut io::stdout(), &unmetafy_str(&quotedzputs(cmd))); // c:789
1647        }
1648        println!(); // c:790
1649    } else {
1650        // c:791
1651        let _ = io::Write::write_all(&mut io::stdout(), &unmetafy_str(&quotedzputs(&hn.node.nam))); // c:792
1652        print!("="); // c:793
1653        if let Some(name_arr) = &hn.name {
1654            if let Some(first) = name_arr.first() {
1655                let _ = io::Write::write_all(&mut io::stdout(), &unmetafy_str(&quotedzputs(first))); // c:794
1656            }
1657        }
1658        print!("/"); // c:795
1659        let _ = io::Write::write_all(&mut io::stdout(), &unmetafy_str(&quotedzputs(&hn.node.nam))); // c:796
1660        println!(); // c:797
1661    }
1662}
1663
1664/// Port of `createshfunctable()` from `Src/hashtable.c:812`.
1665///
1666/// C body:
1667/// ```c
1668/// shfunctab = newhashtable(7, "shfunctab", NULL);
1669/// shfunctab->hash        = hasher;
1670/// shfunctab->cmpnodes    = strcmp;
1671/// shfunctab->addnode     = addhashnode;
1672/// shfunctab->getnode     = gethashnode;
1673/// shfunctab->getnode2    = gethashnode2;
1674/// shfunctab->removenode  = removeshfuncnode;
1675/// shfunctab->disablenode = disableshfuncnode;
1676/// shfunctab->enablenode  = enableshfuncnode;
1677/// shfunctab->freenode    = freeshfuncnode;
1678/// shfunctab->printnode   = printshfuncnode;
1679/// ```
1680///
1681/// Rust port: idempotent — touching the OnceLock initialises the
1682/// singleton on first call. The GSU function-pointer assignments
1683/// from C are encoded as the free-fn names below (each callable
1684/// directly without a vtable lookup).
1685pub fn createshfunctable() {
1686    let _ = shfunctab_lock();
1687}
1688
1689/// Port of `removeshfuncnode(UNUSED(HashTable ht), const char *nam)` from `Src/hashtable.c:836`.
1690///
1691/// C body:
1692/// ```c
1693/// if (!strncmp(nam, "TRAP", 4) && (sigidx = getsigidx(nam + 4)) != -1)
1694///     hn = removetrap(sigidx);
1695/// else
1696///     hn = removehashnode(shfunctab, nam);
1697/// return hn;
1698/// ```
1699///
1700/// Drops the named function from `shfunctab`. If the name is a
1701/// `TRAP<sig>` form, also clears the trap via signals.rs.
1702/// Returns the removed function (or None if absent).
1703/// WARNING: param names don't match C — Rust=(nam) vs C=(ht, nam)
1704pub fn removeshfuncnode(nam: &str) -> Option<shfunc> {
1705    // c:841-844 — the two arms are EXCLUSIVE:
1706    //     if (!strncmp(nam, "TRAP", 4) && (sigidx = getsigidx(nam + 4)) != -1)
1707    //         hn = removetrap(sigidx);
1708    //     else
1709    //         hn = removehashnode(shfunctab, nam);
1710    // The Rust port ran BOTH: `removetrap` already pulls the TRAP<SIG>
1711    // node out of shfunctab (signals.rs c:832-841), so the follow-up
1712    // `remove` returned None and the caller (`bin_unhash`, c:4405) read
1713    // that as "no such hash table element". `unfunction TRAPZERR` then
1714    // reported an error AND — because the second remove ran outside
1715    // removetrap's dosavetrap window — the localtraps restore had
1716    // nothing to put back (C03traps:13,14).
1717    if let Some(sig_part) = nam.strip_prefix("TRAP") {
1718        // c:841
1719        if let Some(sig) = getsigidx(sig_part) {
1720            return removetrap(sig); // c:842
1721        }
1722    }
1723    // c:844 — `hn = removehashnode(shfunctab, nam);`
1724    shfunctab_lock()
1725        .write()
1726        .expect("shfunctab poisoned")
1727        .remove(nam)
1728}
1729
1730/// Port of `disableshfuncnode(HashNode hn, UNUSED(int flags))` from `Src/hashtable.c:855`.
1731///
1732/// C body:
1733/// ```c
1734/// hn->flags |= DISABLED;
1735/// if (!strncmp(hn->nam, "TRAP", 4)) {
1736///     int sigidx = getsigidx(hn->nam + 4);
1737///     if (sigidx != -1) {
1738///         sigtrapped[sigidx] &= ~ZSIG_FUNC;
1739///         unsettrap(sigidx);
1740///     }
1741/// }
1742/// ```
1743///
1744/// Sets the DISABLED flag on the function entry; for TRAP*
1745/// functions, also unsettraps the corresponding signal so the
1746/// shell stops invoking the (now-disabled) trap.
1747/// WARNING: param names don't match C — Rust=(hn) vs C=(hn, flags)
1748pub fn disableshfuncnode(hn: &str) {
1749    {
1750        let mut tab = shfunctab_lock().write().expect("shfunctab poisoned");
1751        tab.disable(hn);
1752    }
1753    if let Some(sig_part) = hn.strip_prefix("TRAP") {
1754        if let Some(sig) = getsigidx(sig_part) {
1755            unsettrap(sig);
1756        }
1757    }
1758}
1759
1760/// Port of `enableshfuncnode(HashNode hn, UNUSED(int flags))` from `Src/hashtable.c:873`.
1761///
1762/// C body:
1763/// ```c
1764/// shf->node.flags &= ~DISABLED;
1765/// if (!strncmp(shf->node.nam, "TRAP", 4)) {
1766///     int sigidx = getsigidx(shf->node.nam + 4);
1767///     if (sigidx != -1) settrap(sigidx, NULL, ZSIG_FUNC);
1768/// }
1769/// ```
1770///
1771/// Clears the DISABLED flag; for TRAP* functions, re-installs
1772/// the signal handler with `ZSIG_FUNC` semantics so the shell
1773/// dispatches the trap function on the next signal delivery.
1774/// WARNING: param names don't match C — Rust=(hn) vs C=(hn, flags)
1775pub fn enableshfuncnode(hn: &str) {
1776    {
1777        let mut tab = shfunctab_lock().write().expect("shfunctab poisoned");
1778        tab.enable(hn);
1779    }
1780    if let Some(sig_part) = hn.strip_prefix("TRAP") {
1781        if let Some(sig) = getsigidx(sig_part) {
1782            // c:882 — `settrap(sigidx, NULL, ZSIG_FUNC)`. The TRAPxxx
1783            // function body resolves through shfunctab at dispatch
1784            // (`gettrapnode`), not via the trap arrays directly.
1785            let _ = settrap(sig, None, ZSIG_FUNC);
1786            // c:Src/signals.c::settrap → unsettrap → removetrap also
1787            // clears any previously-registered string-form trap for
1788            // the same signal (single-slot sigtrapped[] array). The
1789            // zshrs port stores string-form bodies in a separate
1790            // `traps_table` HashMap that `removetrap` doesn't touch,
1791            // so the string body survives the function-form
1792            // registration and BOTH fire on the next signal. Drop
1793            // the string-form entry here so dotrap's
1794            // `traps_table` fallback doesn't double-dispatch. Bug
1795            // #541 in docs/BUGS.md.
1796            if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
1797                t.remove(sig_part);
1798            }
1799        }
1800    }
1801}
1802
1803/// Port of `freeshfuncnode(HashNode hn)` from `Src/hashtable.c:888`.
1804///
1805/// C body frees the function name, body Eprog, redir Eprog,
1806/// filename string, and sticky options struct. Rust port: drop
1807/// runs all of this when the entry is removed; this helper just
1808/// removes from the table to trigger the drop chain.
1809/// Rust idiom replacement: `HashMap::remove` triggers the `Box<T>`
1810/// drop cascade — same teardown as the C zfree chain, automated.
1811pub fn freeshfuncnode(hn: &str) {
1812    shfunctab_lock()
1813        .write()
1814        .expect("shfunctab poisoned")
1815        .remove(hn);
1816}
1817
1818/// Port of `printshfuncnode(HashNode hn, int printflags)` from `Src/hashtable.c:914`.
1819///
1820/// Emits one shfunctab entry for `functions` / `whence` / `typeset -f`.
1821/// PRINT_NAMEONLY and the PRINT_WHENCE_* variants return early; the
1822/// default body emits the full re-parseable `name () { body }` form
1823/// including autoload-stub, traced markers, and trailing redirections.
1824pub fn printshfuncnode(hn: &shfunc, printflags: i32) {
1825    // c:916 — `Shfunc f = (Shfunc) hn;` — Rust types give us shfunc.
1826    // c:917 — `char *t = 0;` — declared but only used by the funcdef/redir
1827    // branches; Rust scope-locals the `t` binding inside each branch.
1828
1829    // c:919-925 — PRINT_NAMEONLY (or PRINT_WHENCE_SIMPLE without FUNCDEF):
1830    // `zputs(nam); putchar('\n'); return;`
1831    if (printflags & PRINT_NAMEONLY) != 0
1832        || ((printflags & PRINT_WHENCE_SIMPLE) != 0 && (printflags & PRINT_WHENCE_FUNCDEF) == 0)
1833    {
1834        let mut so = io::stdout();
1835        let _ = zputs(&hn.node.nam, &mut so); // c:922
1836        println!(); // c:923
1837        return; // c:924
1838    }
1839
1840    // c:927-944 — PRINT_WHENCE_VERBOSE | PRINT_WHENCE_WORD (without FUNCDEF):
1841    // nicezputs(nam) ":" function | " is an autoload shell function" | " is a shell function"
1842    // [" from " quotedzputs(filename) [(PM_LOADDIR) "/" quotedzputs(nam)]] '\n'
1843    if (printflags & (PRINT_WHENCE_VERBOSE | PRINT_WHENCE_WORD)) != 0
1844        && (printflags & PRINT_WHENCE_FUNCDEF) == 0
1845    {
1846        let mut so = io::stdout();
1847        let _ = nicezputs(&hn.node.nam, &mut so); // c:929
1848                                                  // c:930-933 — printf one of three strings via nested ternary.
1849        let msg = if (printflags & PRINT_WHENCE_WORD) != 0 {
1850            ": function" // c:930
1851        } else if (hn.node.flags & PM_UNDEFINED as i32) != 0 {
1852            " is an autoload shell function" // c:932
1853        } else {
1854            " is a shell function" // c:933
1855        };
1856        print!("{}", msg);
1857        // c:934-941 — verbose-with-filename suffix.
1858        if (printflags & PRINT_WHENCE_VERBOSE) != 0 {
1859            if let Some(filename) = &hn.filename {
1860                // c:934
1861                print!(" from "); // c:935
1862                let _ = io::Write::write_all(&mut io::stdout(), &unmetafy_str(&quotedzputs(filename))); // c:936
1863                if (hn.node.flags & PM_LOADDIR as i32) != 0 {
1864                    // c:937
1865                    print!("/"); // c:938
1866                    let _ = io::Write::write_all(&mut io::stdout(), &unmetafy_str(&quotedzputs(&hn.node.nam))); // c:939
1867                }
1868            }
1869        }
1870        println!(); // c:942
1871        return; // c:943
1872    }
1873
1874    // c:946 — `quotedzputs(nam, stdout);`
1875    let _ = io::Write::write_all(&mut io::stdout(), &unmetafy_str(&quotedzputs(&hn.node.nam)));
1876
1877    // c:947-987 — funcdef-present branch (or PM_UNDEFINED stub) vs empty `() { }`.
1878    // RUST-ONLY EXTENSION: zshrs's shfunc carries a raw `body: Option<String>`
1879    // alongside (or instead of) the compiled `funcdef: Eprog` (see
1880    // `shfunc` doc at zsh_h.rs:670). The fusevm compile path stores the
1881    // body source there (parse.rs:7118 + parse.rs:1787) but never builds
1882    // a C-shaped Eprog — `funcdef` stays None. C zsh's getpermtext walks
1883    // the wordcode-Eprog; in zshrs we fall back to `body` text directly
1884    // when funcdef is absent. Without this, `functions NAME` prints
1885    // `f () { }` for every user-defined function.
1886    //
1887    // c:947 tests `f->funcdef`, which every DEFINED function has — an empty
1888    // `f() {}` holds an empty Eprog and lists as `f () {\n\t\n}`. Only a
1889    // node with no program at all takes the c:986 ` () { }` arm, so an
1890    // empty `body` still counts as present.
1891    let has_body_source = hn.body.is_some();
1892    if hn.funcdef.is_some() || has_body_source || (hn.node.flags & PM_UNDEFINED as i32) != 0 {
1893        // c:947
1894        print!(" () {{\n"); // c:948
1895        let _ = zoutputtab(&mut io::stdout()); // c:949
1896                                               // c:950-954 — `# undefined` marker or getpermtext body.
1897        let mut t: Option<String>;
1898        if (hn.node.flags & PM_UNDEFINED as i32) != 0 {
1899            // c:950
1900            println!(
1901                "{} undefined",
1902                hashchar.load(Ordering::Relaxed) as u8 as char
1903            ); // c:951
1904            let _ = zoutputtab(&mut io::stdout()); // c:952
1905            t = None;
1906        } else if let Some(fd) = hn.funcdef.as_ref() {
1907            // c:953
1908            t = Some(getpermtext(fd.clone(), None, 1)); // c:954
1909        } else {
1910            // Rust-only fallback: emit `body` text directly. C's
1911            // getpermtext walks the wordcode-Eprog and emits
1912            // canonicalized statement-per-line text. We don't have
1913            // the Eprog, so we normalize the captured raw body
1914            // source: strip a leading `{` + ws, trailing `}` + ws,
1915            // and trailing `;` before the `}`. These appear when
1916            // par_simple's body_argv path (parse.rs:7041) reuses
1917            // the raw input slice that still includes the framing
1918            // braces; par_funcdef's brace path strips them but the
1919            // short-form `name() { body }` path can capture the
1920            // closing `}` because cmdpos vs. cmdpos confusion
1921            // makes the `}` lex as STRING_LEX, missing the
1922            // OUTBRACE_TOK arm at parse.rs:7113.
1923            // c:Src/text.c gettext2 — C zsh re-emits function bodies
1924            // from parsed wordcode (`getpermtext`) with `\n\t` between
1925            // sibling statements AND recursive indenting of nested
1926            // function definitions. zshrs stores raw source (no Eprog
1927            // for shfunc bodies); the closure below applies the same
1928            // canonicalization at print time.
1929            // Bug #197 (top-level statements) + #124 (nested fns) in
1930            // docs/BUGS.md.
1931            //
1932            // canonicalize: walk char-by-char tracking quote state +
1933            // brace/paren depth. At brace_depth == 0:
1934            //   - top-level `;` (or `; `) becomes `\n\t` * (depth+1)
1935            //   - `name() {` or `name () {` opens a nested fn def;
1936            //     emit `name () {\n` then recurse on the body until
1937            //     the matching `}` with depth+1, then `\n\t` * depth
1938            //     + `}`.
1939            let canonicalize_body = |source: &str| -> String {
1940                fn fmt_body(s: &str, depth: usize, lead: bool) -> String {
1941                    let chars: Vec<char> = s.chars().collect();
1942                    let mut out = String::with_capacity(s.len());
1943                    let mut in_sq = false;
1944                    let mut in_dq = false;
1945                    let mut brace_depth: i32 = 0;
1946                    let mut paren_depth: i32 = 0;
1947                    let stmt_indent = "\t".repeat(depth);
1948                    let mut i = 0;
1949                    // NOTE: caller (the funcdef emit at hashtable.rs:1364)
1950                    // already wrote one leading `\t` via zoutputtab. Don't
1951                    // double-indent the first statement. With `lead`
1952                    // (recursive nested-fn body), we DO need the leading
1953                    // indent because the caller writes `name () {\n` then
1954                    // recurses without a prior tab.
1955                    if lead && !chars.is_empty() {
1956                        out.push_str(&stmt_indent);
1957                    }
1958                    while i < chars.len() {
1959                        let c = chars[i];
1960                        if !in_sq && !in_dq && c == '\\' && i + 1 < chars.len() {
1961                            out.push(c);
1962                            out.push(chars[i + 1]);
1963                            i += 2;
1964                            continue;
1965                        }
1966                        if !in_dq && c == '\'' {
1967                            in_sq = !in_sq;
1968                            out.push(c);
1969                            i += 1;
1970                            continue;
1971                        }
1972                        if !in_sq && c == '"' {
1973                            in_dq = !in_dq;
1974                            out.push(c);
1975                            i += 1;
1976                            continue;
1977                        }
1978                        // Detect nested fn-def pattern at depth 0:
1979                        // `name() {...}` or `name () {...}` (and
1980                        // optional `function `-keyword form). Only
1981                        // when in_sq/in_dq == false and brace/paren
1982                        // depth == 0.
1983                        if !in_sq && !in_dq && brace_depth == 0 && paren_depth == 0 {
1984                            // c:Src/text.c gettext2 WC_CASE arm (~520) —
1985                            // C re-emits case statements from wordcode as
1986                            //   case W in
1987                            //           (p | q) body ;;
1988                            //   esac
1989                            // with `;;`/`;&`/`;|` per WC_CASE_TYPE. The
1990                            // generic `;`-break below ATE the `;;` (it
1991                            // skips runs of `;`), so `functions f`
1992                            // displayed case bodies without terminators
1993                            // and with `;&` mangled to `&`. Re-render the
1994                            // whole case..esac region case-aware.
1995                            if out.is_empty() || out.ends_with('\n') || out.ends_with('\t') {
1996                                if let Some((next_i, txt)) = try_render_case(&chars, i, depth) {
1997                                    out.push_str(&txt);
1998                                    i = next_i;
1999                                    // Consume trailing `;` + ws; emit a
2000                                    // statement break if more follows.
2001                                    while i < chars.len()
2002                                        && (chars[i] == ' '
2003                                            || chars[i] == '\t'
2004                                            || chars[i] == '\n'
2005                                            || chars[i] == ';')
2006                                    {
2007                                        i += 1;
2008                                    }
2009                                    if i < chars.len() {
2010                                        out.push('\n');
2011                                        out.push_str(&stmt_indent);
2012                                    }
2013                                    continue;
2014                                }
2015                            }
2016                            // Try to match `<ident>\s*\(\s*\)\s*\{` at
2017                            // current position, OR `function\s+<ident>...{`.
2018                            let fn_start = try_match_fn_def(&chars, i);
2019                            if let Some((header_end, name_str)) = fn_start {
2020                                // Find matching `}` for the body.
2021                                let body_open = header_end; // index just after `{`
2022                                let body_close = find_matching_brace(&chars, body_open - 1);
2023                                if let Some(close_idx) = body_close {
2024                                    let body_src: String =
2025                                        chars[body_open..close_idx].iter().collect();
2026                                    let body_trim = body_src
2027                                        .trim_start_matches(|c: char| c.is_whitespace())
2028                                        .trim_end_matches(|c: char| c.is_whitespace() || c == ';')
2029                                        .to_string();
2030                                    out.push_str(&name_str);
2031                                    out.push_str(" () {\n");
2032                                    out.push_str(&fmt_body(&body_trim, depth + 1, true));
2033                                    out.push('\n');
2034                                    out.push_str(&stmt_indent);
2035                                    out.push('}');
2036                                    i = close_idx + 1;
2037                                    // Consume trailing `;` and ws.
2038                                    let saved = i;
2039                                    while i < chars.len()
2040                                        && (chars[i] == ' ' || chars[i] == '\t' || chars[i] == ';')
2041                                    {
2042                                        i += 1;
2043                                    }
2044                                    if saved != i || i < chars.len() {
2045                                        // More content follows — emit
2046                                        // statement break.
2047                                        if i < chars.len() {
2048                                            out.push('\n');
2049                                            out.push_str(&stmt_indent);
2050                                        }
2051                                    }
2052                                    continue;
2053                                }
2054                            }
2055                        }
2056                        if !in_sq && !in_dq {
2057                            match c {
2058                                '{' => brace_depth += 1,
2059                                '}' => brace_depth = (brace_depth - 1).max(0),
2060                                '(' => paren_depth += 1,
2061                                ')' => paren_depth = (paren_depth - 1).max(0),
2062                                _ => {}
2063                            }
2064                        }
2065                        if !in_sq && !in_dq && brace_depth == 0 && paren_depth == 0 && c == ';' {
2066                            i += 1;
2067                            while i < chars.len()
2068                                && (chars[i] == ' ' || chars[i] == '\t' || chars[i] == ';')
2069                            {
2070                                i += 1;
2071                            }
2072                            if i < chars.len() {
2073                                out.push('\n');
2074                                out.push_str(&stmt_indent);
2075                            }
2076                            continue;
2077                        }
2078                        out.push(c);
2079                        i += 1;
2080                    }
2081                    out
2082                }
2083                fn is_ident_byte(b: u8) -> bool {
2084                    b == b'_' || b.is_ascii_alphanumeric()
2085                }
2086                fn try_match_fn_def(chars: &[char], start: usize) -> Option<(usize, String)> {
2087                    // Skip leading `function ` keyword (optional).
2088                    let mut i = start;
2089                    let _function_prefix = {
2090                        let rest: String = chars[i..].iter().collect();
2091                        if rest.starts_with("function ") || rest.starts_with("function\t") {
2092                            i += "function".len();
2093                            while i < chars.len() && (chars[i] == ' ' || chars[i] == '\t') {
2094                                i += 1;
2095                            }
2096                            true
2097                        } else {
2098                            false
2099                        }
2100                    };
2101                    // Match identifier.
2102                    let name_start = i;
2103                    while i < chars.len() && is_ident_byte(chars[i] as u8) {
2104                        i += 1;
2105                    }
2106                    if i == name_start {
2107                        return None;
2108                    }
2109                    let name: String = chars[name_start..i].iter().collect();
2110                    // Skip optional whitespace.
2111                    while i < chars.len() && (chars[i] == ' ' || chars[i] == '\t') {
2112                        i += 1;
2113                    }
2114                    // Require `()` for the non-`function`-keyword form;
2115                    // C zsh accepts `function name { ... }` without parens.
2116                    if i < chars.len() && chars[i] == '(' {
2117                        i += 1;
2118                        while i < chars.len() && (chars[i] == ' ' || chars[i] == '\t') {
2119                            i += 1;
2120                        }
2121                        if i >= chars.len() || chars[i] != ')' {
2122                            return None;
2123                        }
2124                        i += 1;
2125                    } else if !_function_prefix {
2126                        return None;
2127                    }
2128                    // Skip ws + `{`.
2129                    while i < chars.len() && (chars[i] == ' ' || chars[i] == '\t') {
2130                        i += 1;
2131                    }
2132                    if i >= chars.len() || chars[i] != '{' {
2133                        return None;
2134                    }
2135                    Some((i + 1, name))
2136                }
2137                /// Keyword match at `i` with word boundaries on both
2138                /// sides (start/ws/`;` before; end/ws/`;` after).
2139                fn matches_kw(chars: &[char], i: usize, kw: &str) -> bool {
2140                    let kl = kw.len();
2141                    if i + kl > chars.len() {
2142                        return false;
2143                    }
2144                    if !chars[i..i + kl].iter().copied().eq(kw.chars()) {
2145                        return false;
2146                    }
2147                    let before_ok = i == 0 || chars[i - 1].is_whitespace() || chars[i - 1] == ';';
2148                    let after_ok = i + kl == chars.len()
2149                        || chars[i + kl].is_whitespace()
2150                        || chars[i + kl] == ';';
2151                    before_ok && after_ok
2152                }
2153                /// Split a case-pattern alternation on top-level `|`
2154                /// (quote/paren aware), trimming each alternative —
2155                /// `a|b` → ["a", "b"], rendered `(a | b)` like
2156                /// C:Src/text.c gettext2's `taddstr(" | ")` walk.
2157                fn split_top_bar(pat: &str) -> Vec<String> {
2158                    let chars: Vec<char> = pat.chars().collect();
2159                    let mut alts = Vec::new();
2160                    let mut cur = String::new();
2161                    let (mut in_sq, mut in_dq) = (false, false);
2162                    let mut pd = 0i32;
2163                    let mut i = 0;
2164                    while i < chars.len() {
2165                        let c = chars[i];
2166                        if !in_sq && !in_dq && c == '\\' && i + 1 < chars.len() {
2167                            cur.push(c);
2168                            cur.push(chars[i + 1]);
2169                            i += 2;
2170                            continue;
2171                        }
2172                        if !in_dq && c == '\'' {
2173                            in_sq = !in_sq;
2174                        } else if !in_sq && c == '"' {
2175                            in_dq = !in_dq;
2176                        } else if !in_sq && !in_dq {
2177                            match c {
2178                                '(' => pd += 1,
2179                                ')' => pd = (pd - 1).max(0),
2180                                '|' if pd == 0 => {
2181                                    alts.push(cur.trim().to_string());
2182                                    cur.clear();
2183                                    i += 1;
2184                                    continue;
2185                                }
2186                                _ => {}
2187                            }
2188                        }
2189                        cur.push(c);
2190                        i += 1;
2191                    }
2192                    alts.push(cur.trim().to_string());
2193                    alts
2194                }
2195                /// Case-aware re-render of one `case W in … esac`
2196                /// region starting at `start` (which must point at the
2197                /// `case` keyword). Returns (index-after-`esac`,
2198                /// rendered text) or None when the region doesn't
2199                /// parse (caller falls back to generic emission).
2200                /// Output shape mirrors C:Src/text.c gettext2 WC_CASE:
2201                ///   case W in
2202                ///   \t(p | q) body ;;
2203                ///   esac
2204                /// with arm bodies recursively formatted at depth+2
2205                /// (continuation statements land one level deeper than
2206                /// the arm line, matching zsh 5.9 output).
2207                fn try_render_case(
2208                    chars: &[char],
2209                    start: usize,
2210                    depth: usize,
2211                ) -> Option<(usize, String)> {
2212                    let n = chars.len();
2213                    let mut i = start;
2214                    if !matches_kw(chars, i, "case") {
2215                        return None;
2216                    }
2217                    i += 4;
2218                    if i >= n || !chars[i].is_whitespace() {
2219                        return None;
2220                    }
2221                    while i < n && chars[i].is_whitespace() {
2222                        i += 1;
2223                    }
2224                    // Scrutinee word: scan to unquoted whitespace.
2225                    let word_start = i;
2226                    let (mut in_sq, mut in_dq) = (false, false);
2227                    while i < n {
2228                        let c = chars[i];
2229                        if !in_sq && !in_dq && c == '\\' && i + 1 < n {
2230                            i += 2;
2231                            continue;
2232                        }
2233                        if !in_dq && c == '\'' {
2234                            in_sq = !in_sq;
2235                        } else if !in_sq && c == '"' {
2236                            in_dq = !in_dq;
2237                        } else if !in_sq && !in_dq && c.is_whitespace() {
2238                            break;
2239                        }
2240                        i += 1;
2241                    }
2242                    if i == word_start || in_sq || in_dq {
2243                        return None;
2244                    }
2245                    let word: String = chars[word_start..i].iter().collect();
2246                    while i < n && chars[i].is_whitespace() {
2247                        i += 1;
2248                    }
2249                    if !matches_kw(chars, i, "in") {
2250                        return None;
2251                    }
2252                    i += 2;
2253                    let indent_case = "\t".repeat(depth);
2254                    let indent_arm = "\t".repeat(depth + 1);
2255                    let mut rendered = format!("case {} in", word);
2256                    loop {
2257                        while i < n && chars[i].is_whitespace() {
2258                            i += 1;
2259                        }
2260                        if i >= n {
2261                            return None; // unterminated — bail out
2262                        }
2263                        if matches_kw(chars, i, "esac") {
2264                            i += 4;
2265                            break;
2266                        }
2267                        // Pattern: optional leading `(`, alts to `)`.
2268                        if chars[i] == '(' {
2269                            i += 1;
2270                        }
2271                        let pat_start = i;
2272                        let (mut in_sq, mut in_dq) = (false, false);
2273                        let mut pd = 0i32;
2274                        while i < n {
2275                            let c = chars[i];
2276                            if !in_sq && !in_dq && c == '\\' && i + 1 < n {
2277                                i += 2;
2278                                continue;
2279                            }
2280                            if !in_dq && c == '\'' {
2281                                in_sq = !in_sq;
2282                            } else if !in_sq && c == '"' {
2283                                in_dq = !in_dq;
2284                            } else if !in_sq && !in_dq {
2285                                if c == '(' {
2286                                    pd += 1;
2287                                } else if c == ')' {
2288                                    if pd == 0 {
2289                                        break;
2290                                    }
2291                                    pd -= 1;
2292                                }
2293                            }
2294                            i += 1;
2295                        }
2296                        if i >= n || chars[i] != ')' {
2297                            return None;
2298                        }
2299                        let pat_src: String = chars[pat_start..i].iter().collect();
2300                        i += 1; // consume `)`
2301                        while i < n && (chars[i] == ' ' || chars[i] == '\t') {
2302                            i += 1;
2303                        }
2304                        // Body: scan to `;;`/`;&`/`;|` or this case's
2305                        // `esac` (last arm without terminator), quote/
2306                        // paren/brace aware, nested case…esac tracked.
2307                        let body_start = i;
2308                        let mut body_end = i;
2309                        let mut term: Option<&str> = None;
2310                        let (mut in_sq, mut in_dq) = (false, false);
2311                        let (mut bd, mut pd) = (0i32, 0i32);
2312                        let mut nested_case = 0i32;
2313                        loop {
2314                            if i >= n {
2315                                return None; // unterminated — bail out
2316                            }
2317                            let c = chars[i];
2318                            if !in_sq && !in_dq && c == '\\' && i + 1 < n {
2319                                i += 2;
2320                                continue;
2321                            }
2322                            if !in_dq && c == '\'' {
2323                                in_sq = !in_sq;
2324                                i += 1;
2325                                continue;
2326                            }
2327                            if !in_sq && c == '"' {
2328                                in_dq = !in_dq;
2329                                i += 1;
2330                                continue;
2331                            }
2332                            if in_sq || in_dq {
2333                                i += 1;
2334                                continue;
2335                            }
2336                            match c {
2337                                '{' => bd += 1,
2338                                '}' => bd = (bd - 1).max(0),
2339                                '(' => pd += 1,
2340                                ')' => pd = (pd - 1).max(0),
2341                                _ => {}
2342                            }
2343                            if bd == 0 && pd == 0 {
2344                                if matches_kw(chars, i, "case") {
2345                                    nested_case += 1;
2346                                    i += 4;
2347                                    continue;
2348                                }
2349                                if matches_kw(chars, i, "esac") {
2350                                    if nested_case == 0 {
2351                                        body_end = i;
2352                                        break;
2353                                    }
2354                                    nested_case -= 1;
2355                                    i += 4;
2356                                    continue;
2357                                }
2358                                if nested_case == 0
2359                                    && c == ';'
2360                                    && i + 1 < n
2361                                    && matches!(chars[i + 1], ';' | '&' | '|')
2362                                {
2363                                    body_end = i;
2364                                    term = Some(match chars[i + 1] {
2365                                        ';' => ";;",
2366                                        '&' => ";&",
2367                                        _ => ";|",
2368                                    });
2369                                    i += 2;
2370                                    break;
2371                                }
2372                            }
2373                            i += 1;
2374                        }
2375                        let body_src: String = chars[body_start..body_end].iter().collect();
2376                        let body_trim = body_src.trim().trim_end_matches(';').trim_end();
2377                        rendered.push('\n');
2378                        rendered.push_str(&indent_arm);
2379                        rendered.push('(');
2380                        rendered.push_str(&split_top_bar(&pat_src).join(" | "));
2381                        rendered.push_str(") ");
2382                        rendered.push_str(&fmt_body(body_trim, depth + 2, false));
2383                        if let Some(t) = term {
2384                            rendered.push(' ');
2385                            rendered.push_str(t);
2386                        }
2387                    }
2388                    rendered.push('\n');
2389                    rendered.push_str(&indent_case);
2390                    rendered.push_str("esac");
2391                    Some((i, rendered))
2392                }
2393                fn find_matching_brace(chars: &[char], open: usize) -> Option<usize> {
2394                    let mut depth = 1i32;
2395                    let mut in_sq = false;
2396                    let mut in_dq = false;
2397                    let mut j = open + 1;
2398                    while j < chars.len() {
2399                        let c = chars[j];
2400                        if !in_sq && !in_dq && c == '\\' && j + 1 < chars.len() {
2401                            j += 2;
2402                            continue;
2403                        }
2404                        if !in_dq && c == '\'' {
2405                            in_sq = !in_sq;
2406                        } else if !in_sq && c == '"' {
2407                            in_dq = !in_dq;
2408                        } else if !in_sq && !in_dq {
2409                            if c == '{' {
2410                                depth += 1;
2411                            } else if c == '}' {
2412                                depth -= 1;
2413                                if depth == 0 {
2414                                    return Some(j);
2415                                }
2416                            }
2417                        }
2418                        j += 1;
2419                    }
2420                    None
2421                }
2422                let mut s = source.trim().to_string();
2423                if s.starts_with('{') {
2424                    s = s[1..].trim_start().to_string();
2425                }
2426                if s.ends_with('}') {
2427                    s.pop();
2428                    s = s.trim_end().to_string();
2429                }
2430                if s.ends_with(';') {
2431                    s.pop();
2432                    s = s.trim_end().to_string();
2433                }
2434                fmt_body(&s, 1, false)
2435            };
2436            // c:954 — `t = getpermtext(fd, NULL, 1);`. C holds the body as
2437            // compiled wordcode and renders it back to source with
2438            // getpermtext, which is what produces zsh's canonical layout:
2439            // `do`/`then` on their own line with the body indented under
2440            // them, `(` and `)` broken onto separate lines, an `always`
2441            // block re-emitted as `{ … } always { … }`, and a trailing
2442            // space after every assignment (taddassign, c:203-204).
2443            //
2444            // zshrs only has an Eprog for zwc-loaded functions (the
2445            // `hn.funcdef` branch above); shell-defined ones keep their raw
2446            // source, which is why this branch existed. Re-parse that source
2447            // and render it through the SAME deparser rather than
2448            // re-deriving getpermtext's formatting rules by hand —
2449            // canonicalize_body reproduced the flat cases but not the
2450            // indenting ones, and mangled `always` blocks into
2451            // `print x } always { print y`.
2452            //
2453            // The source is parsed as-is. hn.body arrives with the framing
2454            // `{ }` of `name() { … }` ALREADY stripped, so removing a
2455            // leading `{` here would eat the braces of a body whose first
2456            // command is itself a brace group (`f() { { print x } }`) and
2457            // would leave an always-block body unbalanced. That
2458            // unconditional strip is exactly why canonicalize_body below
2459            // rendered `f() { { print x } always { print y } }` as
2460            // `print x } always { print y`.
2461            //
2462            // parse_string is the wordcode parser, whose coverage is
2463            // narrower than the AST parser that executes these bodies, so
2464            // fall back to canonicalize_body when it can't take the source
2465            // rather than losing the listing entirely.
2466            //
2467            // That re-parse is a SECOND lex of text C never lexes twice, so
2468            // every lexer-time option the deparse depends on has to be put
2469            // back the way it was when the function was defined, or the same
2470            // function prints two different ways across a `setopt`. RCQUOTES
2471            // is the one that does it: an adjacent quote pair inside a quoted
2472            // word lexes as one literal quote (c:Src/lex.c:1328) rather than
2473            // as two delimiters, so `f() { local v='it''s' }` came back as
2474            // `local v='it's'` once RCQUOTES was set — docs/BUGS.md #1105.
2475            // `funcdef_lex_pin` restores the value recorded when the
2476            // definition installed, and pins nothing when this body has no
2477            // record.
2478            let deparse_body = |source: &str| -> String {
2479                let _pin = crate::vm_helper::funcdef_lex_pin(&hn.node.nam, source);
2480                match crate::ported::exec::parse_string(source.trim(), 1) {
2481                    Some(p) => crate::ported::text::getpermtext(Box::new(p), None, 1), // c:954
2482                    None => canonicalize_body(source),
2483                }
2484            };
2485            t = hn.body.clone().map(|s| deparse_body(&s));
2486        }
2487        // c:955-958 — PM_TAGGED | PM_TAGGED_LOCAL → `# traced` marker.
2488        if (hn.node.flags & (PM_TAGGED | PM_TAGGED_LOCAL) as i32) != 0 {
2489            println!("{} traced", hashchar.load(Ordering::Relaxed) as u8 as char); // c:956
2490            let _ = zoutputtab(&mut io::stdout()); // c:957
2491        }
2492        // c:959-983 — no funcdef text → autoload stub; else emit text.
2493        if t.is_none() {
2494            // c:959
2495            // c:960-964 — `fopt = "UtTkzc"; flgs[] = { PM_UNALIASED, PM_TAGGED,
2496            //               PM_TAGGED_LOCAL, PM_KSHSTORED, PM_ZSHSTORED, PM_CUR_FPATH, 0 };`
2497            let fopt: &[u8] = b"UtTkzc"; // c:960
2498            let flgs: [u32; 6] = [
2499                // c:961-964
2500                PM_UNALIASED,
2501                PM_TAGGED,
2502                PM_TAGGED_LOCAL,
2503                PM_KSHSTORED,
2504                PM_ZSHSTORED,
2505                PM_CUR_FPATH,
2506            ];
2507            let mut so = io::stdout();
2508            let _ = zputs("builtin autoload -X", &mut so); // c:967
2509                                                           // c:968-969 — emit each fopt char whose flag is set.
2510            for fl in 0..fopt.len() {
2511                // c:968
2512                if (hn.node.flags & flgs[fl] as i32) != 0 {
2513                    // c:969
2514                    print!("{}", fopt[fl] as char); // c:969
2515                }
2516            }
2517            // c:970-973 — PM_LOADDIR with filename → ' ' + zputs(filename).
2518            if let Some(filename) = &hn.filename {
2519                if (hn.node.flags & PM_LOADDIR as i32) != 0 {
2520                    // c:970
2521                    print!(" "); // c:971
2522                    let _ = zputs(filename, &mut so); // c:972
2523                }
2524            }
2525        } else {
2526            // c:974
2527            // c:975 — `zputs(t, stdout);`
2528            let body = t.take().unwrap();
2529            let mut so = io::stdout();
2530            let _ = zputs(&body, &mut so); // c:975
2531                                           // c:977-982 — funcdef.flags & EF_RUN → run-time suffix.
2532            let ef_run = hn
2533                .funcdef
2534                .as_ref()
2535                .map(|fd| (fd.flags & EF_RUN) != 0)
2536                .unwrap_or(false);
2537            if ef_run {
2538                // c:977
2539                println!(); // c:978
2540                let _ = zoutputtab(&mut io::stdout()); // c:979
2541                let _ = io::Write::write_all(&mut io::stdout(), &unmetafy_str(&quotedzputs(&hn.node.nam))); // c:980
2542                print!(" \"$@\""); // c:981
2543            }
2544        }
2545        print!("\n}}"); // c:984
2546    } else {
2547        // c:985
2548        print!(" () {{ }}"); // c:986
2549    }
2550    // c:988-994 — redir present → emit its text.
2551    if let Some(redir) = &hn.redir {
2552        // c:988
2553        let t = getpermtext(redir.clone(), None, 1); // c:989
2554        if !t.is_empty() {
2555            // c:990
2556            let mut so = io::stdout();
2557            let _ = zputs(&t, &mut so); // c:991
2558        }
2559    } else if let Some(t) = &hn.redir_text {
2560        // RUST-ONLY twin of the branch above, exactly parallel to the
2561        // `body`-instead-of-`funcdef` fallback at c:947: the fusevm
2562        // definition path registers the redirection list as already-rendered
2563        // text rather than a second Eprog, so there is nothing for
2564        // `getpermtext` to walk. Without this, `functions f` / `which f`
2565        // dropped the `} > out` tail that C prints at c:991.
2566        if !t.is_empty() {
2567            let mut so = io::stdout();
2568            let _ = zputs(t, &mut so); // c:991
2569        }
2570    }
2571
2572    println!(); // c:996
2573}
2574
2575/// Port of `scanmatchshfunc(Patprog pprog, int sorted, int flags1, int flags2, ScanFunc scanfunc, int scanflags, int expand)` from `Src/hashtable.c:1013`.
2576///
2577/// C body iterates `shfunctab` and calls `func(node)` on every
2578/// entry whose name matches the compiled pattern `pprog`. Rust
2579/// port walks the singleton with a closure callback.
2580///
2581/// Returns the count of matched entries (mirrors C's int return).
2582/// WARNING: param names don't match C — Rust=(pattern, func) vs C=(pprog, sorted, flags1, flags2, scanfunc, scanflags, expand)
2583pub fn scanmatchshfunc<F>(pattern: Option<&str>, mut func: F) -> i32
2584where
2585    F: FnMut(&str, &shfunc),
2586{
2587    let tab = shfunctab_lock().read().expect("shfunctab poisoned");
2588    let mut count = 0;
2589    // c:Src/hashtable.c:1031 scanshfunc(sorted=1, …) — the `sorted`
2590    // flag is set on every internal caller (bin_functions's no-arg
2591    // listing, etc.), so scan walks entries in sorted order via
2592    // hnamcmp (byte-wise ASCII compare). The HashMap iter order is
2593    // arbitrary; collect + sort for parity.
2594    let mut entries: Vec<_> = tab.iter().collect();
2595    entries.sort_by(|(a, _), (b, _)| a.cmp(b));
2596    for (name, entry) in entries {
2597        let matches = match pattern {
2598            None => true,
2599            Some(p) => simple_glob_match(p, name),
2600        };
2601        if matches {
2602            func(name, entry);
2603            count += 1;
2604        }
2605    }
2606    count
2607}
2608
2609/// Port of `scanshfunc(int sorted, int flags1, int flags2, ScanFunc scanfunc, int scanflags, int expand)` from `Src/hashtable.c:1031`.
2610///
2611/// C body walks every `shfunctab` entry calling `func(node, flags)`.
2612/// Rust port delegates to scanmatchshfunc with no pattern.
2613/// WARNING: param names don't match C — Rust=(func) vs C=(sorted, flags1, flags2, scanfunc, scanflags, expand)
2614pub fn scanshfunc<F>(func: F) -> i32
2615where
2616    F: FnMut(&str, &shfunc),
2617{
2618    scanmatchshfunc(None, func)
2619}
2620
2621/// Port of `printshfuncexpand(HashNode hn, int printflags, int expand)` from `Src/hashtable.c:1042`.
2622///
2623/// C body:
2624/// ```c
2625/// int save_expand;
2626/// save_expand = text_expand_tabs;
2627/// text_expand_tabs = expand;
2628/// shfunctab->printnode(hn, printflags);
2629/// text_expand_tabs = save_expand;
2630/// ```
2631///
2632/// Briefly toggles `text_expand_tabs` around the printnode call so
2633/// the body indentation comes out either tab- or space-formatted
2634/// per the caller's `expand` arg.
2635pub fn printshfuncexpand(hn: &shfunc, printflags: i32, expand: i32) {
2636    // c:1044 — `int save_expand;`
2637    let save_expand: i32; // c:1044
2638                          // c:1046 — `save_expand = text_expand_tabs;`
2639    save_expand = crate::text::TEXT_EXPAND_TABS.load(Ordering::Relaxed); // c:1046
2640                                                                         // c:1047 — `text_expand_tabs = expand;`
2641    crate::text::TEXT_EXPAND_TABS.store(expand, Ordering::Relaxed); // c:1047
2642                                                                    // c:1048 — `shfunctab->printnode(hn, printflags);`
2643    printshfuncnode(hn, printflags); // c:1048
2644                                     // c:1049 — `text_expand_tabs = save_expand;`
2645    crate::text::TEXT_EXPAND_TABS.store(save_expand, Ordering::Relaxed); // c:1049
2646}
2647
2648/// Port of `getshfuncfile(shfunc shf)` from `Src/hashtable.c:1059`.
2649///
2650/// C body (verbatim):
2651///   if (shf->node.flags & PM_LOADDIR) {
2652///       return zhtricat(shf->filename, "/", shf->node.nam);
2653///   } else if (shf->filename) {
2654///       return dupstring(shf->filename);
2655///   } else {
2656///       return NULL;
2657///   }
2658///
2659/// PM_LOADDIR is set when zsh loaded the function via fpath
2660/// directory autoload (the common `autoload -Uz` path): in that
2661/// case `filename` is the DIRECTORY and we must append `/name` to
2662/// produce the actual source file. Prior Rust port skipped the
2663/// PM_LOADDIR branch, so `${functions_source[my_autoload]}`
2664/// returned the fpath dir (e.g. `/usr/share/zsh/5.9/functions`)
2665/// instead of the real path (`.../functions/my_autoload`).
2666/// NOTE ON THE SIGNATURE: C takes the resolved node —
2667/// `getshfuncfile(Shfunc shf)` — so callers that already hold it (c:549
2668/// `pm->u.str = getshfuncfile(shf)`, c:589 `pm.u.str =
2669/// getshfuncfile((Shfunc)hn)`) reach the filename with no lookup at all.
2670/// This port is keyed by NAME, so those callers pay a second shfunctab
2671/// lock + hash. Callers on a whole-map path therefore inline the c:1061-1063
2672/// body against the node they already resolved rather than calling here.
2673pub fn getshfuncfile(shf: &str) -> Option<String> {
2674    let tab = shfunctab_lock().read().expect("shfunctab poisoned");
2675    let f = tab.get_including_disabled(shf)?;
2676    let filename = f.filename.as_ref()?;
2677    // c:1061 — PM_LOADDIR: `zhtricat(shf->filename, "/", shf->node.nam)`
2678    if (f.node.flags as u32 & crate::ported::zsh_h::PM_LOADDIR) != 0 {
2679        Some(format!("{}/{}", filename, f.node.nam))
2680    } else {
2681        // c:1063 — `dupstring(shf->filename)`
2682        Some(filename.clone())
2683    }
2684}
2685
2686/// Port of `createreswdtable()` from `Src/hashtable.c:1120`.
2687///
2688/// C body wires up the reswdtab GSU vtable then iterates the
2689/// static `reswds` array calling `addnode` for each. Rust port:
2690/// touches the singleton (which seeds the table from the static
2691/// word list in `reswd_table::new`).
2692pub fn createreswdtable() {
2693    let _ = reswdtab_lock();
2694}
2695
2696/// Port of `printreswdnode(HashNode hn, int printflags)` from `Src/hashtable.c:1147`.
2697///
2698/// C body:
2699/// ```c
2700/// Reswd rw = (Reswd) hn;
2701/// if (printflags & PRINT_WHENCE_WORD) {
2702///     printf("%s: reserved\n", rw->node.nam);
2703///     return;
2704/// }
2705/// if (printflags & PRINT_WHENCE_CSH) {
2706///     printf("%s: shell reserved word\n", rw->node.nam);
2707///     return;
2708/// }
2709/// if (printflags & PRINT_WHENCE_VERBOSE) {
2710///     printf("%s is a reserved word\n", rw->node.nam);
2711///     return;
2712/// }
2713/// /* default is name only */
2714/// printf("%s\n", rw->node.nam);
2715/// ```
2716pub fn printreswdnode(hn: &reswd, printflags: i32) {
2717    // c:1149 — `Reswd rw = (Reswd) hn;` — Rust types already give us reswd.
2718    // c:1151-1154 — PRINT_WHENCE_WORD branch.
2719    if (printflags & PRINT_WHENCE_WORD) != 0 {
2720        println!("{}: reserved", hn.node.nam); // c:1152
2721        return; // c:1153
2722    }
2723    // c:1156-1159 — PRINT_WHENCE_CSH branch.
2724    if (printflags & PRINT_WHENCE_CSH) != 0 {
2725        println!("{}: shell reserved word", hn.node.nam); // c:1157
2726        return; // c:1158
2727    }
2728    // c:1161-1164 — PRINT_WHENCE_VERBOSE branch.
2729    if (printflags & PRINT_WHENCE_VERBOSE) != 0 {
2730        println!("{} is a reserved word", hn.node.nam); // c:1162
2731        return; // c:1163
2732    }
2733    // c:1166-1167 — default: name only.
2734    println!("{}", hn.node.nam); // c:1167
2735}
2736
2737/// Port of `void createaliastable(HashTable ht)` from `Src/hashtable.c:1186`.
2738/// ```c
2739/// void
2740/// createaliastable(HashTable ht)
2741/// {
2742///     ht->hash        = hasher;
2743///     ht->emptytable  = NULL;
2744///     ht->filltable   = NULL;
2745///     ht->cmpnodes    = strcmp;
2746///     ht->addnode     = addhashnode;
2747///     ht->getnode     = gethashnode;
2748///     ht->getnode2    = gethashnode2;
2749///     ht->removenode  = removehashnode;
2750///     ht->disablenode = disablehashnode;
2751///     ht->enablenode  = enablehashnode;
2752///     ht->freenode    = freealiasnode;
2753///     ht->printnode   = printaliasnode;
2754/// }
2755/// ```
2756/// The Rust `hashtable.addnode/.getnode/.removenode/.disablenode/.enablenode/
2757/// .freenode/.printnode` function-pointer types take untyped HashNode
2758/// arguments. The generic Rust helpers (`addhashnode<T>`/`gethashnode<T>`/
2759/// etc.) take typed `&mut HashMap<String, T>` so they can't directly
2760/// satisfy the untyped slot signature; downstream consumers of `aliastab`
2761/// dispatch through `aliastab_lock()` (the typed wrapper) instead of
2762/// the C-style slot. Mirror the C structure verbatim: assign every slot
2763/// either to the matching adapter or `None`, with each line citing the
2764/// matching c:NNN.
2765pub fn createaliastable(ht: &mut hashtable) {
2766    // c:1188
2767    fn cmpnodes_strcmp(a: &str, b: &str) -> i32 {
2768        // c:1193 strcmp
2769        a.cmp(b) as i32
2770    }
2771    ht.hash = Some(hasher); // c:1190
2772    ht.emptytable = None; // c:1191
2773    ht.filltable = None; // c:1192
2774    ht.cmpnodes = Some(cmpnodes_strcmp); // c:1193
2775                                         // c:1194-1201 — addnode/getnode/getnode2/removenode/disablenode/
2776                                         // enablenode/freenode/printnode: their C signatures are `void(*)(
2777                                         // HashTable, char *, void *)` / `HashNode(*)(HashTable, char *)` /
2778                                         // ... — they take untyped `void *`. The typed Rust helpers
2779                                         // (`addhashnode<T>(ht: &mut HashMap<String, T>, ...)`) can't be
2780                                         // coerced through the `fn(&mut hashtable, String, usize)` slot
2781                                         // shape without per-value-type trampoline closures. Leave the
2782                                         // slots `None`; the typed dispatch through `aliastab_lock` is the
2783                                         // canonical Rust path for this table.
2784    ht.addnode = None; // c:1194 addhashnode
2785    ht.getnode = None; // c:1195 gethashnode
2786    ht.getnode2 = None; // c:1196 gethashnode2
2787    ht.removenode = None; // c:1197 removehashnode
2788    ht.disablenode = None; // c:1198 disablehashnode
2789    ht.enablenode = None; // c:1199 enablehashnode
2790    ht.freenode = None; // c:1200 freealiasnode
2791    ht.printnode = None; // c:1201 printaliasnode
2792}
2793
2794/// Trait exposing the DISABLED flag on a hash-node value.
2795///
2796/// Implemented for the per-table value types so the generic ops
2797/// (`gethashnode`/`disablehashnode`/etc.) can filter / mutate
2798/// without per-table dispatch. Mirrors C's `HashNode->flags`
2799/// field which every node struct embeds via the `struct hashnode`
2800/// header.
2801pub trait HashNodeFlags {
2802    fn flags(&self) -> u32;
2803    fn set_disabled(&mut self, disabled: bool);
2804    fn is_disabled(&self) -> bool {
2805        self.flags() & (DISABLED as u32) != 0
2806    }
2807}
2808
2809impl HashNodeFlags for alias {
2810    fn flags(&self) -> u32 {
2811        self.node.flags as u32
2812    }
2813    fn set_disabled(&mut self, disabled: bool) {
2814        if disabled {
2815            self.node.flags |= DISABLED as i32;
2816        } else {
2817            self.node.flags &= !(DISABLED as i32);
2818        }
2819    }
2820}
2821
2822impl HashNodeFlags for shfunc {
2823    fn flags(&self) -> u32 {
2824        self.node.flags as u32
2825    }
2826    fn set_disabled(&mut self, disabled: bool) {
2827        if disabled {
2828            self.node.flags |= DISABLED as i32;
2829        } else {
2830            self.node.flags &= !(DISABLED as i32);
2831        }
2832    }
2833}
2834
2835impl HashNodeFlags for cmdnam {
2836    fn flags(&self) -> u32 {
2837        self.node.flags as u32
2838    }
2839    fn set_disabled(&mut self, disabled: bool) {
2840        if disabled {
2841            self.node.flags |= DISABLED as i32;
2842        } else {
2843            self.node.flags &= !(DISABLED as i32);
2844        }
2845    }
2846}
2847
2848impl HashNodeFlags for reswd {
2849    fn flags(&self) -> u32 {
2850        self.node.flags as u32
2851    }
2852    fn set_disabled(&mut self, disabled: bool) {
2853        if disabled {
2854            self.node.flags |= DISABLED as i32;
2855        } else {
2856            self.node.flags &= !(DISABLED as i32);
2857        }
2858    }
2859}
2860
2861/// Port of `createaliastables()` from `Src/hashtable.c:1206`.
2862///
2863/// C body (lines 1206-1224):
2864/// ```c
2865/// aliastab = newhashtable(23, "aliastab", NULL);
2866/// createaliastable(aliastab);
2867/// aliastab->addnode(aliastab, ztrdup("run-help"), createaliasnode(ztrdup("man"), 0));
2868/// aliastab->addnode(aliastab, ztrdup("which-command"), createaliasnode(ztrdup("whence"), 0));
2869/// sufaliastab = newhashtable(11, "sufaliastab", NULL);
2870/// createaliastable(sufaliastab);
2871/// ```
2872///
2873/// The OnceLock-backed `aliastab_lock()` / `sufaliastab_lock()`
2874/// stand in for `newhashtable(...)` + `createaliastable(...)` — they
2875/// lazy-init the underlying maps on first access.
2876pub fn createaliastables() {
2877    // c:1206 — newhashtable(23, "aliastab", NULL)
2878    // c:1212 — createaliastable(aliastab)
2879    let mut tab = aliastab_lock().write().expect("aliastab poisoned");
2880    // ZSHRS-ONLY. Same gate as `with_defaults`: a drop-in gets ITS
2881    // shell's default aliases, not zsh's two. This function runs during
2882    // init, AFTER the binary has selected the personality, so without the
2883    // gate it re-added `run-help` / `which-command` on top of the set
2884    // installed at table-creation time — `--bash` listed two aliases bash
2885    // does not have, and `--ksh` listed 21 where ksh has 19.
2886    if let Some(defaults) = crate::extensions::emulation_startup::default_aliases() {
2887        tab.clear();
2888        for (name, text) in defaults {
2889            tab.add(createaliasnode(name, text, 0));
2890        }
2891        drop(tab);
2892        let _ = sufaliastab_lock();
2893        return;
2894    }
2895    // c:1215 — `aliastab->addnode(aliastab, ztrdup("run-help"),
2896    //                              createaliasnode(ztrdup("man"), 0));`
2897    tab.add(createaliasnode("run-help", "man", 0)); // c:1215
2898                                                    // c:1216 — `aliastab->addnode(aliastab, ztrdup("which-command"),
2899                                                    //                              createaliasnode(ztrdup("whence"), 0));`
2900    tab.add(createaliasnode("which-command", "whence", 0)); // c:1216
2901    drop(tab);
2902    // c:1221 — newhashtable(11, "sufaliastab", NULL)
2903    // c:1223 — createaliastable(sufaliastab)
2904    let _ = sufaliastab_lock();
2905}
2906// c:1253
2907
2908/// Build an alias node with the canonical `alias` shape.
2909/// Mirrors C `addaliasnode(aliastab, name, createaliasnode(text, flags))`
2910/// at hashtable.c:1230 — caller-side bundle for the
2911/// hashnode+text+flags inline-build.
2912pub fn createaliasnode(name: &str, text: &str, flags: u32) -> alias {
2913    // c:1230
2914    alias {
2915        node: hashnode {
2916            next: None,
2917            nam: name.to_string(),
2918            flags: flags as i32,
2919        },
2920        text: text.to_string(),
2921        inuse: std::sync::atomic::AtomicI32::new(0),
2922    }
2923}
2924
2925/// Port of `createaliasnode(char *txt, int flags)` from `Src/hashtable.c:1230`.
2926///
2927/// C body:
2928/// ```c
2929/// al = zshcalloc(sizeof *al);
2930/// al->node.flags = flags;
2931/// al->text = txt;
2932/// al->inuse = 0;
2933/// return al;
2934/// ```
2935// Duplicate `createaliasnode` removed — canonical port is at the
2936// earlier definition (matches C hashtable.c:1230).
2937
2938/// Port of `freealiasnode(HashNode hn)` from `Src/hashtable.c:1243`.
2939///
2940/// C body frees the name + text strings + alias struct. Rust
2941/// port: drop runs the same when the crate::ported::zsh_h::alias is removed from its
2942/// table. This helper triggers the drop.
2943pub fn freealiasnode(hn: &str) {
2944    let mut tab = aliastab_lock().write().expect("aliastab poisoned");
2945    tab.remove(hn);
2946}
2947
2948/// Port of `printaliasnode(HashNode hn, int printflags)` from `Src/hashtable.c:1256`.
2949///
2950/// Emits `whence`-style output for one alias with PRINT_NAMEONLY /
2951/// PRINT_WHENCE_WORD / PRINT_WHENCE_SIMPLE / PRINT_WHENCE_CSH /
2952/// PRINT_WHENCE_VERBOSE / PRINT_LIST flag dispatch. PRINT_LIST falls
2953/// through to the tail `quotedzputs(nam) '=' quotedzputs(text) '\n'`;
2954/// every other branch returns early.
2955pub fn printaliasnode(hn: &alias, printflags: i32) {
2956    // c:1258 — `Alias a = (Alias) hn;` — Rust types already give us alias.
2957
2958    // c:1260-1264 — PRINT_NAMEONLY branch.
2959    if (printflags & PRINT_NAMEONLY) != 0 {
2960        let mut so = io::stdout();
2961        let _ = zputs(&hn.node.nam, &mut so); // c:1261
2962        println!(); // c:1262
2963        return; // c:1263
2964    }
2965
2966    // c:1266-1274 — PRINT_WHENCE_WORD branch.
2967    if (printflags & PRINT_WHENCE_WORD) != 0 {
2968        if (hn.node.flags & ALIAS_SUFFIX as i32) != 0 {
2969            println!("{}: suffix alias", hn.node.nam); // c:1268
2970        } else if (hn.node.flags & ALIAS_GLOBAL as i32) != 0 {
2971            println!("{}: global alias", hn.node.nam); // c:1270
2972        } else {
2973            println!("{}: alias", hn.node.nam); // c:1272
2974        }
2975        return; // c:1273
2976    }
2977
2978    // c:1276-1280 — PRINT_WHENCE_SIMPLE branch.
2979    if (printflags & PRINT_WHENCE_SIMPLE) != 0 {
2980        let mut so = io::stdout();
2981        let _ = zputs(&hn.text, &mut so); // c:1277
2982        println!(); // c:1278
2983        return; // c:1279
2984    }
2985
2986    // c:1282-1293 — PRINT_WHENCE_CSH branch.
2987    if (printflags & PRINT_WHENCE_CSH) != 0 {
2988        let mut so = io::stdout();
2989        let _ = nicezputs(&hn.node.nam, &mut so); // c:1283
2990        print!(": "); // c:1284
2991        if (hn.node.flags & ALIAS_SUFFIX as i32) != 0 {
2992            print!("suffix "); // c:1286
2993        } else if (hn.node.flags & ALIAS_GLOBAL as i32) != 0 {
2994            print!("globally "); // c:1288
2995        }
2996        print!("aliased to "); // c:1289
2997        let _ = nicezputs(&hn.text, &mut so); // c:1290
2998        println!(); // c:1291
2999        return; // c:1292
3000    }
3001
3002    // c:1295-1308 — PRINT_WHENCE_VERBOSE branch.
3003    if (printflags & PRINT_WHENCE_VERBOSE) != 0 {
3004        let mut so = io::stdout();
3005        let _ = nicezputs(&hn.node.nam, &mut so); // c:1296
3006        print!(" is a"); // c:1297
3007        if (hn.node.flags & ALIAS_SUFFIX as i32) != 0 {
3008            print!(" suffix"); // c:1299
3009        } else if (hn.node.flags & ALIAS_GLOBAL as i32) != 0 {
3010            print!(" global"); // c:1301
3011        } else {
3012            print!("n"); // c:1303
3013        }
3014        print!(" alias for "); // c:1304
3015        let _ = nicezputs(&hn.text, &mut so); // c:1305
3016        println!(); // c:1306
3017        return; // c:1307
3018    }
3019
3020    // ZSHRS-ONLY, no C counterpart. bash's `alias` listing always emits
3021    // the reusable `alias NAME='value'` form with the value single-quoted
3022    // — `alias a=1` lists as `alias a='1'` — where zsh's plain listing is
3023    // bare `a=1` and only `alias -L` adds the prefix. So a `--bash` shell
3024    // printed `ll='ls -alF'` where bash prints `alias ll='ls -alF'`.
3025    // Applies to the plain listing only; the whence/nameonly forms above
3026    // have already returned.
3027    if crate::extensions::dash_mode::bash_mode() {
3028        println!(
3029            "alias {}={}",
3030            hn.node.nam,
3031            crate::extensions::emulation_startup::single_quote(&hn.text)
3032        );
3033        return;
3034    }
3035    // dash single-quotes the value ALWAYS (`alias yy=1` lists as
3036    // `yy='1'`), where ksh, mksh and zsh quote only when the value needs
3037    // it. No `alias ` prefix, unlike bash.
3038    if crate::extensions::emulation_output::alias_always_quotes() {
3039        println!(
3040            "{}={}",
3041            hn.node.nam,
3042            crate::extensions::emulation_startup::single_quote(&hn.text)
3043        );
3044        return;
3045    }
3046
3047    // c:1310-1330 — PRINT_LIST prefix block (falls through to the
3048    // tail quotedzputs body below; default-no-flags also reaches the
3049    // tail by skipping this block).
3050    if (printflags & PRINT_LIST) != 0 {
3051        // c:1312-1316 — Fast fail on `=` in name (unrepresentable
3052        // `alias name=...` round-trip).
3053        if hn.node.nam.contains('=') {
3054            // c:1313
3055            zwarn(&format!(
3056                "invalid alias '{}' encountered while printing aliases",
3057                hn.node.nam
3058            ));
3059            return; // c:1316
3060        }
3061        print!("alias "); // c:1320
3062        if (hn.node.flags & ALIAS_SUFFIX as i32) != 0 {
3063            // c:1321
3064            print!("-s "); // c:1322
3065        } else if (hn.node.flags & ALIAS_GLOBAL as i32) != 0 {
3066            // c:1323
3067            print!("-g "); // c:1324
3068        }
3069        // c:1326-1329 — `-- ` so a name starting with `-`/`+` isn't
3070        // interpreted as an option when the listing is re-executed.
3071        if hn.node.nam.starts_with('-') || hn.node.nam.starts_with('+') {
3072            // c:1328
3073            print!("-- "); // c:1329
3074        }
3075    }
3076
3077    // c:1332-1336 — common tail: quotedzputs(nam) '=' quotedzputs(text) '\n'.
3078    let _ = io::Write::write_all(&mut io::stdout(), &unmetafy_str(&quotedzputs(&hn.node.nam))); // c:1332
3079    print!("="); // c:1333
3080    let _ = io::Write::write_all(&mut io::stdout(), &unmetafy_str(&quotedzputs(&hn.text))); // c:1334
3081    println!(); // c:1336
3082}
3083
3084/// Port of `createhisttable()` from `Src/hashtable.c:1345`.
3085///
3086/// C body wires up the histtab GSU vtable with `histhasher` /
3087/// `histstrcmp` / `addhistnode` etc. Rust port: touches the
3088/// singleton to initialise. The HashMap-keyed-by-string model
3089/// is much simpler than C's per-bucket chain; the entries hold
3090/// (history event-id) values keyed by command-text.
3091pub fn createhisttable() {
3092    let _ = histtab_lock();
3093}
3094
3095/// History-specific hash function (normalizes whitespace).
3096/// Port of `histhasher(const char *str)` from `Src/hashtable.c:1365`.
3097///
3098/// C body uses `inblank(*str)` (canonical typtab predicate at
3099/// `Src/ztype.h:50` — NARROW blank: space/tab ONLY, not newline,
3100/// definitely NOT broad Unicode whitespace). The Rust port previously
3101/// used `c.is_whitespace()` which is the Unicode-broad set including
3102/// CR/FF/VT/NBSP — every line of zsh history containing one of those
3103/// bytes hashed to a different bucket than C would have.
3104///
3105/// Faithful: matches `inblank` exactly (`c:50` — `space + tab`).
3106pub fn histhasher(s: &str) -> u32 {
3107    // c:1365
3108    // c:50 — `inblank(c)` = `c == ' ' || c == '\t'`. NOT `\n`, NOT broad.
3109    #[inline]
3110    fn is_inblank_narrow(c: char) -> bool {
3111        c == ' ' || c == '\t'
3112    }
3113
3114    let mut hashval: u32 = 0;
3115    let mut chars = s.chars().peekable();
3116
3117    // c:1369 — `while (inblank(*str)) str++;` skip leading blanks.
3118    while let Some(&c) = chars.peek() {
3119        if is_inblank_narrow(c) {
3120            chars.next();
3121        } else {
3122            break;
3123        }
3124    }
3125
3126    // c:1371 — main mix loop.
3127    while let Some(c) = chars.next() {
3128        if is_inblank_narrow(c) {
3129            // c:1373 — `do str++; while (inblank(*str));` collapse runs.
3130            while let Some(&next) = chars.peek() {
3131                if is_inblank_narrow(next) {
3132                    chars.next();
3133                } else {
3134                    break;
3135                }
3136            }
3137            // c:1374-1375 — `if (*str) hashval += (hashval << 5) + ' ';`
3138            if chars.peek().is_some() {
3139                hashval = hashval.wrapping_add(hashval.wrapping_shl(5).wrapping_add(' ' as u32));
3140            }
3141        } else {
3142            // c:1377 — `hashval += (hashval << 5) + *(unsigned char *)str++;`
3143            hashval = hashval.wrapping_add(hashval.wrapping_shl(5).wrapping_add(c as u32));
3144        }
3145    }
3146    hashval
3147}
3148
3149/// Port of `emptyhisttable(HashTable ht)` from `Src/hashtable.c:1385`.
3150///
3151/// C body:
3152/// ```c
3153/// emptyhashtable(ht);
3154/// if (hist_ring) histremovedups();
3155/// ```
3156/// WARNING: param names don't match C — Rust=() vs C=(ht)
3157pub fn emptyhisttable() {
3158    // c:1385 — `emptyhashtable(ht)` — clear the lookup table.
3159    histtab_lock().write().expect("histtab poisoned").clear();
3160    // c:1386 — `if (hist_ring) histremovedups();` — prune dup-flagged
3161    // entries from the history ring.
3162    let has_ring = !hist_ring.lock().unwrap().is_empty();
3163    if has_ring {
3164        histremovedups(); // c:1386
3165    }
3166}
3167
3168/// Compare strings with normalized whitespace (for history).
3169/// Port of `histstrcmp(const char *str1, const char *str2)` from
3170/// `Src/hashtable.c:1396`.
3171///
3172/// C body uses `inblank(*str)` everywhere (`Src/ztype.h:50` — NARROW
3173/// space/tab only). The previous Rust port used `c.is_whitespace()`
3174/// (broad Unicode set including CR/FF/VT/NBSP), which would silently
3175/// fold history lines that C considers distinct (e.g. lines that
3176/// contain NBSP would dedupe against lines with no NBSP).
3177///
3178/// C signature is 2-arg: it reads `isset(HISTREDUCEBLANKS)` directly.
3179/// Rust port passes `reduce_blanks` as an explicit 3rd arg to keep
3180/// the option read out of this leaf fn (call sites at hist.c thread
3181/// the option from the parent scope).
3182pub fn histstrcmp(s1: &str, s2: &str, reduce_blanks: bool) -> std::cmp::Ordering {
3183    // c:1396
3184    // c:50 — `inblank(c)` = `c == ' ' || c == '\t'`. NOT newline, NOT broad.
3185    #[inline]
3186    fn is_inblank_narrow(c: char) -> bool {
3187        c == ' ' || c == '\t'
3188    }
3189
3190    // c:1398-1399 — skip leading inblank in both strings.
3191    let s1 = s1.trim_start_matches(is_inblank_narrow);
3192    let s2 = s2.trim_start_matches(is_inblank_narrow);
3193
3194    // c:1405 — HISTREDUCEBLANKS short-circuit to raw strcmp.
3195    if reduce_blanks {
3196        return s1.cmp(s2);
3197    }
3198
3199    let mut c1 = s1.chars().peekable();
3200    let mut c2 = s2.chars().peekable();
3201
3202    // c:1408 — `while (*str1 && *str2) { ... }` then `return *str1 - *str2;`.
3203    loop {
3204        let ch1 = c1.peek().copied();
3205        let ch2 = c2.peek().copied();
3206
3207        match (ch1, ch2) {
3208            (None, None) => return std::cmp::Ordering::Equal, // c:1421 — both NUL
3209            (None, Some(c)) => {
3210                // c:1421 — *str1=0 - *str2; left shorter (Less) unless str2
3211                // is all-inblank residue.
3212                if is_inblank_narrow(c) {
3213                    while c2.peek().copied().map(is_inblank_narrow).unwrap_or(false) {
3214                        c2.next();
3215                    }
3216                    if c2.peek().is_none() {
3217                        return std::cmp::Ordering::Equal;
3218                    }
3219                }
3220                return std::cmp::Ordering::Less;
3221            }
3222            (Some(c), None) => {
3223                if is_inblank_narrow(c) {
3224                    while c1.peek().copied().map(is_inblank_narrow).unwrap_or(false) {
3225                        c1.next();
3226                    }
3227                    if c1.peek().is_none() {
3228                        return std::cmp::Ordering::Equal;
3229                    }
3230                }
3231                return std::cmp::Ordering::Greater;
3232            }
3233            (Some(ch1), Some(ch2)) => {
3234                let ws1 = is_inblank_narrow(ch1);
3235                let ws2 = is_inblank_narrow(ch2);
3236
3237                if ws1 && ws2 {
3238                    // c:1411-1413 — collapse both runs.
3239                    while c1.peek().copied().map(is_inblank_narrow).unwrap_or(false) {
3240                        c1.next();
3241                    }
3242                    while c2.peek().copied().map(is_inblank_narrow).unwrap_or(false) {
3243                        c2.next();
3244                    }
3245                } else if ws1 {
3246                    // c:1410 — `if (!inblank(*str2)) break;` → mismatch.
3247                    while c1.peek().copied().map(is_inblank_narrow).unwrap_or(false) {
3248                        c1.next();
3249                    }
3250                    if c1.peek().is_none() {
3251                        return std::cmp::Ordering::Less;
3252                    }
3253                    return std::cmp::Ordering::Less;
3254                } else if ws2 {
3255                    while c2.peek().copied().map(is_inblank_narrow).unwrap_or(false) {
3256                        c2.next();
3257                    }
3258                    if c2.peek().is_none() {
3259                        return std::cmp::Ordering::Greater;
3260                    }
3261                    return std::cmp::Ordering::Greater;
3262                } else if ch1 != ch2 {
3263                    return ch1.cmp(&ch2); // c:1417 — *str1 - *str2
3264                } else {
3265                    c1.next();
3266                    c2.next();
3267                }
3268            }
3269        }
3270    }
3271}
3272
3273/// Port of `addhistnode(HashTable ht, char *nam, void *nodeptr)` from `Src/hashtable.c:1427`.
3274///
3275/// C body:
3276/// ```c
3277/// HashNode oldnode = addhashnode2(ht, nam, nodeptr);
3278/// Histent he = (Histent)nodeptr;
3279/// if (oldnode && oldnode != (HashNode)nodeptr) {
3280///     if (he->node.flags & HIST_MAKEUNIQUE
3281///      || (he->node.flags & HIST_FOREIGN && (Histent)oldnode == he->up)) {
3282///         (void) addhashnode2(ht, oldnode->nam, oldnode); /* restore hash */
3283///         he->node.flags |= HIST_DUP;
3284///         he->node.flags &= ~HIST_MAKEUNIQUE;
3285///     } else {
3286///         oldnode->flags |= HIST_DUP;
3287///         if (hist_ignore_all_dups)
3288///             freehistnode(oldnode); /* Remove the old dup */
3289///     }
3290/// } else
3291///     he->node.flags &= ~HIST_MAKEUNIQUE;
3292/// ```
3293///
3294/// The Rust `histtab` is keyed by command text → event id, so
3295/// `addhashnode2` maps to `HashMap::insert` (returns the displaced
3296/// event). The new node `he` and the displaced `oldnode` are located
3297/// in `hist_ring` by their `histnum`; their `node.flags` are the same
3298/// `HIST_*` fields the C node carries.
3299///
3300/// NOTE: the caller must NOT hold the `hist_ring` lock across this
3301/// call — `addhistnode` re-locks the ring to read/mutate node flags.
3302/// WARNING: param names don't match C — Rust=(nam, event_id) vs C=(ht, nam, nodeptr)
3303pub fn addhistnode(nam: &str, event_id: i32) -> Option<i32> {
3304    // c:1429 — `HashNode oldnode = addhashnode2(ht, nam, nodeptr);`
3305    let oldnode = histtab_lock()
3306        .write()
3307        .expect("histtab poisoned")
3308        .insert(nam.to_string(), event_id);
3309
3310    // c:1431 — `if (oldnode && oldnode != (HashNode)nodeptr)`
3311    if let Some(old_event) = oldnode {
3312        if old_event != event_id {
3313            // `he->node.flags` — flags of the newly inserted node. C reads
3314            // `he->node.flags` directly off the pointer; the Rust ring is a
3315            // `Vec` keyed by `histnum`, so locate the entry by event id.
3316            let he_flags = hist_ring
3317                .lock()
3318                .unwrap()
3319                .iter()
3320                .find(|h| h.histnum == event_id as i64)
3321                .map(|h| h.node.flags)
3322                .unwrap_or(0);
3323            // c:1433 — `(Histent)oldnode == he->up` (the entry directly
3324            // above `he` in the ring is the one being displaced).
3325            let up_is_old = up_histent(event_id as i64) == Some(old_event as i64);
3326            if (he_flags & HIST_MAKEUNIQUE as i32) != 0
3327                || ((he_flags & HIST_FOREIGN as i32) != 0 && up_is_old)
3328            {
3329                // c:1434 — `addhashnode2(ht, oldnode->nam, oldnode);`
3330                // Restore the hash so `nam` maps back to the old event
3331                // (same command text, so the key is unchanged).
3332                histtab_lock()
3333                    .write()
3334                    .expect("histtab poisoned")
3335                    .insert(nam.to_string(), old_event);
3336                // c:1435-1436 — mark `he` a dup, clear make-unique.
3337                if let Some(h) = hist_ring
3338                    .lock()
3339                    .unwrap()
3340                    .iter_mut()
3341                    .find(|h| h.histnum == event_id as i64)
3342                {
3343                    h.node.flags = (h.node.flags | HIST_DUP as i32) & !(HIST_MAKEUNIQUE as i32);
3344                }
3345            } else {
3346                // c:1439 — `oldnode->flags |= HIST_DUP;`
3347                if let Some(h) = hist_ring
3348                    .lock()
3349                    .unwrap()
3350                    .iter_mut()
3351                    .find(|h| h.histnum == old_event as i64)
3352                {
3353                    h.node.flags |= HIST_DUP as i32;
3354                }
3355                // c:1440-1441 — `if (hist_ignore_all_dups) freehistnode(oldnode);`
3356                // C's `freehistnode` == `freehistdata(oldnode, 1); zfree(oldnode)`;
3357                // the ported `freehistdata(idx, 1)` unlinks the old node from
3358                // the ring (the Rust equivalent of `zfree`) and — because the
3359                // node is now HIST_DUP-flagged — skips removing the hash entry
3360                // that already points at the new node (c:1466 guard).
3361                if hist_ignore_all_dups.load(Ordering::SeqCst) != 0 {
3362                    let idx = hist_ring
3363                        .lock()
3364                        .unwrap()
3365                        .iter()
3366                        .position(|h| h.histnum == old_event as i64);
3367                    if let Some(idx) = idx {
3368                        freehistdata(idx, 1);
3369                    }
3370                }
3371            }
3372            return oldnode;
3373        }
3374    }
3375    // c:1445 — `he->node.flags &= ~HIST_MAKEUNIQUE;`
3376    if let Some(h) = hist_ring
3377        .lock()
3378        .unwrap()
3379        .iter_mut()
3380        .find(|h| h.histnum == event_id as i64)
3381    {
3382        h.node.flags &= !(HIST_MAKEUNIQUE as i32);
3383    }
3384    oldnode
3385}
3386
3387/// Port of `freehistnode(HashNode nodeptr)` from `Src/hashtable.c:1450`.
3388///
3389/// C body: `freehistdata((Histent)nodeptr, 1); zfree(nodeptr, ...);`
3390/// Rust port: removes from the lookup table — drop runs the
3391/// equivalent of zfree.
3392pub fn freehistnode(nodeptr: &str) {
3393    histtab_lock()
3394        .write()
3395        .expect("histtab poisoned")
3396        .remove(nodeptr);
3397}
3398
3399/// Port of `freehistdata(Histent he, int unlink)` from `Src/hashtable.c:1458`.
3400///
3401/// C body: removes the named entry from `histtab` (unless flagged
3402/// HIST_DUP/HIST_TMPSTORE), frees the command + word-array fields,
3403/// and if `unlink` re-links the ring around `he` and decrements
3404/// `histlinect`. Rust port indexes into `hist_ring` (Vec replaces C's
3405/// doubly-linked list); the up/down relink collapses to `Vec::remove`.
3406/// WARNING: param names don't match C — Rust=(idx, unlink) vs C=(he, unlink)
3407pub fn freehistdata(idx: usize, unlink: i32) {
3408    // c:1458
3409    let mut ring = hist_ring.lock().unwrap();
3410    let he = match ring.get(idx) {
3411        Some(h) => h,
3412        None => return,
3413    }; // c:1461 if (!he) return
3414    let nam = he.node.nam.clone();
3415    let flags = he.node.flags as u32;
3416    let histnum = he.histnum as i32;
3417    if (flags & (HIST_DUP | HIST_TMPSTORE)) == 0 {
3418        // c:1467
3419        let mut tab = histtab_lock().write().expect("histtab poisoned"); // c:1468 removehashnode(histtab, ...)
3420        // !!! WARNING: RUST-ONLY — NO C COUNTERPART !!!
3421        // C removes by name: every non-DUP entry is the hashed node for its
3422        // text. readhistfile here does not hash file entries, so an evicted
3423        // file entry can share its text with a newer interactive entry that
3424        // owns the key. Remove the key only when it points at `he`.
3425        if tab.get(&nam) == Some(&histnum) {
3426            tab.remove(&nam);
3427        }
3428    }
3429    // c:1471-1473 — `zsfree(name); if (nwords) zfree(words, ...)`. Rust
3430    // String/Vec drop handles both; only the unlink step needs explicit
3431    // ring mutation.
3432    if unlink != 0 {
3433        // c:1475
3434        ring.remove(idx); // c:1477-1483 unlink up/down
3435        let new_ct = ring.len() as i64;
3436        drop(ring);
3437        histlinect.store(new_ct, Ordering::SeqCst);
3438        // c:1477 --histlinect
3439    }
3440}
3441
3442/// Port of `dircache_set(char **name, char *value)` from `Src/hashtable.c:1537`.
3443///
3444/// C body manages a refcounted directory-name cache:
3445///   - `value == NULL` → decrement refs on `*name`, free if zero,
3446///     set `*name = NULL`.
3447///   - `value != NULL` → search for an existing entry, bump refs,
3448///     else allocate a new slot.
3449///
3450/// Rust port: routes through dircache_lock() with refcount-by-
3451/// HashMap-value (i32). Add/remove via the (name, value) pair.
3452pub fn dircache_set(name: &mut Option<String>, value: Option<&str>) {
3453    // c:1537
3454    let mut cache = dircache_lock().lock().expect("dircache poisoned");
3455
3456    if value.is_none() {
3457        // c:1541
3458        // c:1542-1543 — `if (!*name) return;`
3459        let key = match name.as_deref() {
3460            None => return, // c:1543
3461            Some(s) => s.to_string(),
3462        };
3463        // c:1544-1548 — `if (!dircache_size) { zsfree(*name); *name = NULL; return; }`
3464        if cache.is_empty() {
3465            // c:1544
3466            *name = None; // c:1546
3467            return; // c:1547
3468        }
3469        // c:1550-1582 — scan cache, decrement matching entry's refs;
3470        // on refs==0, drop the entry. Rust keys by string equality
3471        // since we don't share the C pointer-identity used at c:1553.
3472        if let Some(idx) = cache.iter().position(|e| e.name == key) {
3473            // c:1550
3474            cache[idx].refs -= 1; // c:1555
3475            if cache[idx].refs == 0 {
3476                // c:1556
3477                cache.remove(idx); // c:1558-1577 collapsed
3478                DIRCACHE_LASTENTRY.store(usize::MAX, Ordering::SeqCst); // c:1564/1577
3479            }
3480            *name = None; // c:1579
3481            return; // c:1580
3482        }
3483        // c:1583-1584 — `zsfree(*name); *name = NULL;`
3484        *name = None; // c:1584
3485    } else {
3486        // c:1585
3487        let mut v = value.unwrap().to_string();
3488        // c:1590-1594 — absolute-path normalization for relative input.
3489        if !v.starts_with('/') {
3490            // c:1590
3491            let cwd = zgetcwd(); // c:1591 zgetcwd
3492            v = format!("{}/{}", cwd, v); // c:1591 zhtricat
3493            if let Some(resolved) = xsymlink(&v) {
3494                // c:1593 xsymlink(..., 1)
3495                v = resolved; // c:1593
3496            } // c:1593
3497        }
3498        // c:1602-1606 — `dircache_lastentry` fast-path: same path as last.
3499        let last_idx = DIRCACHE_LASTENTRY.load(Ordering::SeqCst);
3500        if last_idx != usize::MAX && last_idx < cache.len() && cache[last_idx].name == v {
3501            *name = Some(cache[last_idx].name.clone()); // c:1604
3502            cache[last_idx].refs += 1; // c:1605
3503            return; // c:1606
3504        }
3505        // c:1607-1610 — empty-cache: allocate first entry.
3506        if cache.is_empty() {
3507            // c:1607
3508            cache.push(dircache_entry {
3509                name: v.clone(),
3510                refs: 1,
3511            }); // c:1609-1610
3512            DIRCACHE_LASTENTRY.store(0usize, Ordering::SeqCst);
3513            *name = Some(v);
3514            return;
3515        }
3516        // c:1611-1619 — scan for existing entry, bump refs.
3517        if let Some(idx) = cache.iter().position(|e| e.name == v) {
3518            // c:1612-1614
3519            *name = Some(cache[idx].name.clone()); // c:1615
3520            cache[idx].refs += 1; // c:1616
3521            DIRCACHE_LASTENTRY.store(idx, Ordering::SeqCst);
3522            return;
3523        }
3524        // c:1620+ — push new entry.
3525        cache.push(dircache_entry {
3526            name: v.clone(),
3527            refs: 1,
3528        });
3529        let new_idx = cache.len() - 1;
3530        DIRCACHE_LASTENTRY.store(new_idx, Ordering::SeqCst);
3531        *name = Some(v);
3532    }
3533}
3534
3535// `DIRCACHE_LASTENTRY` already declared below at hashtable.rs:1849
3536// as `AtomicUsize` (`usize::MAX` sentinel). Reuse that — the new
3537// body above adapts via i32 cast.
3538
3539// `SuffixAliasTable` type alias deleted — Rust-only convenience.
3540// C has no `SuffixAliasTable`; the same generic `HashTable` powers
3541// both `aliastab` and `sufaliastab` (declared identically at
3542// hashtable.c:1177-1182). Callers can use `alias_table` directly
3543// for both. (When the canonical HashTable substrate is wired,
3544// both will share the same generic type.)
3545
3546/// Port of `struct dircache_entry` from `Src/hashtable.c:1503-1509`.
3547///
3548/// C body:
3549/// ```c
3550/// struct dircache_entry {
3551///     char *name;   /* Name of directory in cache */
3552///     int   refs;   /* Number of references to it */
3553/// };
3554/// ```
3555#[allow(non_camel_case_types)]
3556#[derive(Debug, Clone)]
3557pub struct dircache_entry {
3558    // c:1503
3559    pub name: String, // c:1506
3560    pub refs: i32,    // c:1508
3561}
3562
3563/// Command name hash table
3564// hash table containing external commands                                  // c:587
3565#[derive(Debug, Clone)]
3566/// `$cmdtab` table of cached executable lookups.
3567/// Port of `cmdnamtab` from Src/hashtable.c — `createcmdnamtable()`
3568/// (line 601), `emptycmdnamtable()` (line 623), and `hashdir()`
3569/// (line 634) drive populate/clear/fill cycles.
3570/// **NOT C-FAITHFUL — Rust-only typed wrapper around HashMap.**
3571/// C uses the generic `HashTable` struct (zsh.h:1530 / zsh_h.rs:535)
3572/// with per-table GSU callback fn pointers (`hash`/`addnode`/
3573/// `getnode`/`removenode`/`freenode`/`printnode`/`scantab`). Each
3574/// per-table accessor (`cmdnamtab_lock`, `shfunctab_lock`, etc.)
3575/// returns a `Mutex<HashTable>` instance with the appropriate
3576/// callbacks wired. When the generic-HashTable substrate lands,
3577/// cmdnam_table/shfunc_table/reswd_table/alias_table get deleted
3578/// in favor of typed views over the shared `HashTable` storage.
3579pub struct cmdnam_table {
3580    /// `table` field — C's `cmdnamtab` bucket array, `hsize = 201`
3581    /// (`Src/hashtable.c:603`). Was a `std::collections::HashMap`,
3582    /// whose per-process-seeded order made `${(k)commands)` /
3583    /// `compadd -k commands` differ on every run.
3584    ///
3585    /// COPY-ON-WRITE, like `alias_table::table`: `$( … )` snapshots it on
3586    /// every substitution (see `snapshot`), and a filled table holds an
3587    /// entry per executable on `$PATH` — thousands.
3588    table: crate::cow_map::CowArc<hashtable_nodes<cmdnam>>,
3589    /// `path_checked_index` field.
3590    path_checked_index: usize,
3591    /// `path` field.
3592    path: Vec<String>,
3593    /// `hash_executables_only` field.
3594    hash_executables_only: bool,
3595}
3596
3597// `impl shfunc` deleted — methods replaced with inline flag checks
3598// (`(shf.node.flags & FLAG as i32) != 0`) at callers, mirroring
3599// C's idiom. Constructors `shfunc_with_body` / `shfunc_autoload`
3600// above replace `shfunc::with_body` / `::autoload` / `::new`.
3601
3602/// Shell function hash table
3603// hash table containing the shell functions                                // c:805
3604#[derive(Debug, Clone)]
3605/// `$shfunctab` shell function table.
3606/// Port of the `shfunctab` HashTable Src/hashtable.c builds —
3607/// `printshfuncnode` / `freeshfuncnode` (Src/builtin.c) hang off
3608/// the same shape.
3609/// Faithful port of C's `HashTable shfunctab` (Src/zsh.h, declared
3610/// `mod_export HashTable shfunctab`). Stores `Box<shfunc>` so that
3611/// raw `*mut shfunc` handed to C-style call sites stays stable
3612/// across map rehashes — mirrors C's `HashNode` semantics where
3613/// the table owns the heap allocation and hands out pointers.
3614/// Owned-value accessors (`add`, `get`, `get_mut`) coexist with
3615/// C-faithful pointer accessors (`addnode`, `getnode`) so both
3616/// the Rust-idiomatic bytecode function-def path
3617/// (`fusevm_bridge.rs:8378`) and the C-style `bin_functions`
3618/// port (`builtin.rs:3689+`) write to the same canonical table.
3619pub struct shfunc_table {
3620    /// `table` field — the `nodes`/`hsize`/`ct` bucket array C's
3621    /// `shfunctab` is, created at `hsize = 7` (`Src/hashtable.c:814`
3622    /// `shfunctab = newhashtable(7, "shfunctab", NULL)`).
3623    ///
3624    /// It used to be a `std::collections::HashMap`, whose iteration
3625    /// order is neither C's bucket walk nor even stable across runs
3626    /// (`RandomState` re-seeds per process). `${(k)functions}` and
3627    /// `compadd -k functions` read this order straight out
3628    /// (`Src/Modules/parameter.c:480-481`), so both were random.
3629    ///
3630    /// COPY-ON-WRITE. The bucket array sits behind an `Arc` so that
3631    /// `snapshot()` — which `$( … )` and `( … )` run on EVERY
3632    /// substitution — is a refcount bump instead of a deep clone of
3633    /// every `Box<shfunc>` in the table. On a shell with ~47k loaded
3634    /// functions the deep clone was ~47% of the cost of entering a
3635    /// command substitution.
3636    ///
3637    /// Every `&mut self` method below goes through `Arc::make_mut`, so
3638    /// the FIRST write after a snapshot pays the clone and the parent's
3639    /// snapshot keeps the pre-write bucket array. That is exactly the
3640    /// old semantics — a subshell that only READS functions (the
3641    /// overwhelmingly common case) now pays nothing.
3642    table: std::sync::Arc<hashtable_nodes<Box<shfunc>>>,
3643}
3644
3645/// Reserved word hash table
3646#[derive(Debug)]
3647/// `$reswdtab` reserved-word table.
3648// hash table containing the reserved words                                 // c:1111
3649/// Port of the `reswdtab` HashTable from Src/hashtable.c — used
3650/// by Src/lex.c to recognize keywords like `if`/`while`/`do`.
3651/// **NOT C-FAITHFUL — Rust-only typed wrapper.** See WARNING on
3652/// `cmdnam_table` for the canonical-port direction.
3653pub struct reswd_table {
3654    /// `table` field — C's `reswdtab` bucket array, `hsize = 23`
3655    /// (`Src/hashtable.c:1124` `reswdtab = newhashtable(23, "reswdtab",
3656    /// NULL)`).
3657    ///
3658    /// Was a `std::collections::HashMap`, whose per-process-seeded
3659    /// order made `$reswords` / `$dis_reswords` random. `getreswords`
3660    /// (`Src/Modules/parameter.c:871-886`) walks the raw bucket array
3661    /// (`for (i = 0; i < reswdtab->hsize; i++) for (hn =
3662    /// reswdtab->nodes[i]; hn; hn = hn->next)`), so those parameters
3663    /// expose C's chain order directly.
3664    table: hashtable_nodes<reswd>,
3665}
3666
3667/// crate::ported::zsh_h::alias hash table
3668#[derive(Debug)]
3669/// `$aliastab` alias hash.
3670/// Port of the `aliastab` HashTable from Src/hashtable.c —
3671// hash table containing the aliases                                        // c:1174
3672/// `bin_alias()` (Src/builtin.c) drives every mutation. Suffix
3673/// aliases live in a separate `sufaliastab` instance.
3674/// **NOT C-FAITHFUL — Rust-only typed wrapper.** See WARNING on
3675/// `cmdnam_table` for the canonical-port direction.
3676pub struct alias_table {
3677    /// `table` field — C's `aliastab` bucket array, `hsize = 23`
3678    /// (`Src/hashtable.c:1210` `aliastab = newhashtable(23, "aliastab",
3679    /// NULL)`); the `sufaliastab` instance is built with `hsize = 11`
3680    /// (`Src/hashtable.c:1221`).
3681    ///
3682    /// Was an `indexmap::IndexMap`, chosen on the theory that
3683    /// insertion order was "closer to zsh's observed behavior". That
3684    /// reasoning was wrong: zsh emits alias keys in the `scanhashtable`
3685    /// bucket walk (`Src/hashtable.c:420-434`) that `scanpmraliases` /
3686    /// `scanpmgaliases` / `scanpmsaliases` drive
3687    /// (`Src/Modules/parameter.c:2005-2047`), i.e. bucket
3688    /// `hasher(nam) % hsize` ascending, each chain walked head→tail
3689    /// with the most recently added key at the head (`c:214-215`).
3690    /// That is neither insertion order nor sorted order, so
3691    /// `${(k)aliases}` diverged from zsh for every alias set.
3692    ///
3693    /// COPY-ON-WRITE, for the same reason as `shfunc_table::table`:
3694    /// `$( … )` snapshots both alias tables on every substitution (see
3695    /// `snapshot`), and a user table runs to thousands of aliases.
3696    table: crate::cow_map::CowArc<hashtable_nodes<alias>>,
3697}
3698
3699// Mirrors C's file-statics at hashtable.c:1517:
3700//   `static struct dircache_entry *dircache, *dircache_lastentry;`
3701//   `static int dircache_size;`
3702// Rust port keeps the cache as a `Mutex<Vec<dircache_entry>>` plus
3703// a lastentry index. dircache_size is implicit (Vec::len()).
3704static DIRCACHE_INNER: std::sync::OnceLock<std::sync::Mutex<Vec<dircache_entry>>> =
3705    std::sync::OnceLock::new();
3706static DIRCACHE_LASTENTRY: std::sync::atomic::AtomicUsize = // c:1517
3707    std::sync::atomic::AtomicUsize::new(usize::MAX); // sentinel "no last"
3708
3709/// Build a hashed `cmdnam` carrying a resolved path. Mirrors C's
3710/// inline `cn->u.cmd = ztrdup(path); cn->node.flags = HASHED;` at
3711/// hashtable.c:704.
3712pub fn cmdnam_hashed(name: &str, path: &str) -> cmdnam {
3713    // c:704 idiom
3714    cmdnam {
3715        node: hashnode {
3716            next: None,
3717            nam: name.to_string(),
3718            flags: HASHED as i32,
3719        },
3720        name: None,
3721        cmd: Some(path.to_string()),
3722    }
3723}
3724
3725/// Build an unhashed `cmdnam` whose lookup will scan
3726/// `path_segments`. Mirrors C's `cn->u.name = pathchecked;
3727/// cn->node.flags = 0;` at hashtable.c:712.
3728pub fn cmdnam_unhashed(name: &str, path_segments: Vec<String>) -> cmdnam {
3729    // c:712 idiom
3730    cmdnam {
3731        node: hashnode {
3732            next: None,
3733            nam: name.to_string(),
3734            flags: 0,
3735        },
3736        name: Some(path_segments),
3737        cmd: None,
3738    }
3739}
3740
3741/// Build a `shfunc` for the lazy-compile path with body source text.
3742/// Mirrors C's `shfunctab->addnode(shfunctab, ztrdup(name), shf)`
3743/// after callers populate `shf->funcdef = parse_subst_string(body)`.
3744pub fn shfunc_with_body(name: &str, body: &str) -> shfunc {
3745    // c:824 idiom
3746    // c:Src/exec.c:5383 — `ztrdup(scriptfilename)`. zsh tags every
3747    // shfunc with the script it was defined in so `whence -v fn`
3748    // and `type fn` can print `is a shell function from <script>`.
3749    // For `-c '...'` invocations zsh sets scriptfilename to "zsh".
3750    // Without this seed, fusevm-compiled functions all had
3751    // filename=None and `type fn` lost the "from <script>" suffix.
3752    shfunc {
3753        node: hashnode {
3754            next: None,
3755            nam: name.to_string(),
3756            flags: 0,
3757        },
3758        filename: scriptfilename_get(),
3759        lineno: 0,
3760        funcdef: None,
3761        redir: None,
3762        sticky: None,
3763        body: Some(body.to_string()),
3764        redir_text: None,
3765    }
3766}
3767
3768/// Build an autoload-marker `shfunc`. Mirrors C's
3769/// `createshfunc(name); shf->node.flags = PM_UNDEFINED;` at
3770/// hashtable.c:829.
3771pub fn shfunc_autoload(name: &str) -> shfunc {
3772    // c:829 idiom
3773    shfunc {
3774        node: hashnode {
3775            next: None,
3776            nam: name.to_string(),
3777            flags: PM_UNDEFINED as i32,
3778        },
3779        filename: None,
3780        lineno: 0,
3781        funcdef: None,
3782        redir: None,
3783        sticky: None,
3784        body: None,
3785        redir_text: None,
3786    }
3787}
3788
3789// -----------------------------------------------------------
3790// cmdnamtab / aliastab / sufaliastab / reswdtab / histtab
3791// global singletons. Match C's `mod_export HashTable cmdnamtab;`
3792// (hashtable.c:594) and friends. Each is lazily initialised on
3793// first access.
3794// -----------------------------------------------------------
3795
3796// hash table containing external commands                                  // c:587
3797/// Singleton accessor for the global `cmdnamtab`.
3798/// Mirrors C's `mod_export HashTable cmdnamtab` (hashtable.c:594).
3799/// Per PORT_PLAN.md Phase 3 (bucket-2, read-mostly): the PATH cache
3800/// is read on every command resolution but mutated only by `hash`,
3801/// `rehash`, or `path` reassignment. `RwLock` lets parallel command
3802/// lookups proceed without serialising on a single mutex. Holder
3803/// accessor keeps the `_lock` suffix for source-stability (call
3804/// sites use `.read()`/`.write()` directly).
3805pub fn cmdnamtab_lock() -> &'static std::sync::RwLock<cmdnam_table> {
3806    // c:594
3807    static CMDNAMTAB: std::sync::OnceLock<std::sync::RwLock<cmdnam_table>> =
3808        std::sync::OnceLock::new();
3809    CMDNAMTAB.get_or_init(|| std::sync::RwLock::new(cmdnam_table::new()))
3810}
3811
3812/// Port of `mod_export char **pathchecked;` from `Src/hashtable.c:595`.
3813///
3814/// Cursor into the `$path` array tracking how far the PATH-hash-on-
3815/// first-use machinery has walked. Bumped by `hashcmd` (exec.c:1042)
3816/// after each successful lookup so subsequent `hashdir` calls only
3817/// scan entries we haven't already cached.
3818///
3819/// C uses `char **pathchecked` (pointer into the `path[]` array); the
3820/// Rust port stores an index since `$path` lives in paramtab and is
3821/// re-fetched on each access. Reset to 0 by `path` reassignment per
3822/// `Src/hashtable.c:618`.
3823pub static pathchecked: std::sync::atomic::AtomicUsize = // c:595
3824    std::sync::atomic::AtomicUsize::new(0);
3825
3826// hash table containing the aliases                                        // c:1174
3827/// Singleton accessor for the global `aliastab`.
3828/// Mirrors C's `mod_export HashTable aliastab` (hashtable.c:1186).
3829/// Bucket-2 read-mostly: aliases are looked up on every command word,
3830/// mutated only by `alias`/`unalias`. `RwLock` per PORT_PLAN.md.
3831pub fn aliastab_lock() -> &'static std::sync::RwLock<alias_table> {
3832    // c:1186
3833    static ALIASTAB: std::sync::OnceLock<std::sync::RwLock<alias_table>> =
3834        std::sync::OnceLock::new();
3835    ALIASTAB.get_or_init(|| std::sync::RwLock::new(alias_table::with_defaults()))
3836}
3837
3838/// Singleton accessor for the global `sufaliastab`.
3839/// Mirrors C's `mod_export HashTable sufaliastab` (hashtable.c:1187).
3840/// Bucket-2 read-mostly: same rationale as `aliastab`.
3841pub fn sufaliastab_lock() -> &'static std::sync::RwLock<alias_table> {
3842    static SUFALIASTAB: std::sync::OnceLock<std::sync::RwLock<alias_table>> =
3843        std::sync::OnceLock::new();
3844    // c:1221 — `sufaliastab = newhashtable(11, "sufaliastab", NULL);`
3845    // "Table for suffix aliases --- make this smaller" (c:1219). The
3846    // smaller `hsize` gives a DIFFERENT bucket walk than `aliastab`'s
3847    // 23, so `${(k)saliases}` order depends on getting this exact
3848    // number. Built inline rather than via `alias_table::new()` (which
3849    // is `aliastab`'s 23) because a second named constructor would be
3850    // a Rust-only fn with no C counterpart.
3851    SUFALIASTAB.get_or_init(|| {
3852        std::sync::RwLock::new(alias_table {
3853            table: crate::cow_map::CowArc::new(hashtable_nodes::newhashtable(11)), // c:1221
3854        })
3855    })
3856}
3857
3858// hash table containing the reserved words                                 // c:1111
3859/// Singleton accessor for the global `reswdtab`.
3860/// Mirrors C's `HashTable reswdtab` (hashtable.c, file-scope).
3861/// Bucket-2 read-mostly (effectively read-only post-init): every
3862/// command word is checked against reserved words; the table is
3863/// populated once at startup. `RwLock` per PORT_PLAN.md.
3864pub fn reswdtab_lock() -> &'static std::sync::RwLock<reswd_table> {
3865    // c:1115
3866    static reswdTAB: std::sync::OnceLock<std::sync::RwLock<reswd_table>> =
3867        std::sync::OnceLock::new();
3868    reswdTAB.get_or_init(|| std::sync::RwLock::new(reswd_table::new()))
3869}
3870
3871/// Singleton accessor for the global `histtab` (history events).
3872/// Mirrors C's `HashTable histtab` (hashtable.c:1340).
3873pub fn histtab_lock() -> &'static std::sync::RwLock<HashMap<String, i32>> {
3874    static HISTTAB: std::sync::OnceLock<std::sync::RwLock<HashMap<String, i32>>> =
3875        std::sync::OnceLock::new();
3876    HISTTAB.get_or_init(|| std::sync::RwLock::new(HashMap::new()))
3877}
3878
3879// ===========================================================
3880// shfunctab — the global shell-function table.
3881//
3882// Port of `mod_export HashTable shfunctab` from
3883// `Src/hashtable.c:808` and the GSU callbacks built around it
3884// (`createshfunctable` and the `*shfuncnode` family).
3885//
3886// C zsh dispatches every `function f() { … }` definition,
3887// `unfunction`, `disable -f`, `enable -f`, `whence`, and trap-
3888// function lookup through `shfunctab`. zshrs uses a singleton
3889// `OnceLock<Mutex<shfunc_table>>` exposed via `shfunctab_lock()`
3890// so the GSU-style C names below can mutate it without taking a
3891// `ShellExecutor` parameter (matching the C signatures, where
3892// the table is global).
3893// ===========================================================
3894
3895/// Singleton accessor for the global `shfunctab`.
3896/// Mirrors C's `mod_export HashTable shfunctab` (hashtable.c:808).
3897/// Lazily initialised on first access. Bucket-2 read-mostly: shell
3898/// functions are looked up on every function-call dispatch, mutated
3899/// only by `function f()` / `unfunction` / `autoload`. `RwLock`
3900/// per PORT_PLAN.md.
3901pub fn shfunctab_lock() -> &'static std::sync::RwLock<shfunc_table> {
3902    // c:808
3903    static shfuncTAB: std::sync::OnceLock<std::sync::RwLock<shfunc_table>> =
3904        std::sync::OnceLock::new();
3905    shfuncTAB.get_or_init(|| std::sync::RwLock::new(shfunc_table::new()))
3906}
3907
3908/// Glob-style match for hashtable scan callers. Direct port of C's
3909/// `pattry(pprog, hn->nam)` at `Src/hashtable.c:412` / `c:431` —
3910/// `scanmatchtable` compiles the caller's pattern once into a
3911/// `Patprog` and tests every node's name against it. zshrs's
3912/// `patmatch(pattern, text)` (pattern.rs:1561) does the
3913/// `patcompile + pattry` pair in one call, so we route through
3914/// it directly.
3915///
3916/// Previously this was an ad-hoc 30-line recursive matcher that
3917/// only handled `*` and `?` — char classes (`[abc]`), numeric
3918/// ranges (`<1-9>`), recursive globs, and the rest of zsh's
3919/// extended-glob set silently fell through. Now uses the
3920/// canonical engine.
3921fn simple_glob_match(pattern: &str, name: &str) -> bool {
3922    // c:hashtable.c:412 — `scanmatchtable` callers pass a compiled
3923    // `Patprog`; this helper inlines the compile+match since callers
3924    // here have only the raw pattern string.
3925    patcompile(
3926        &{
3927            let mut __pat_tok = (pattern).to_string();
3928            crate::ported::glob::tokenize(&mut __pat_tok);
3929            __pat_tok
3930        },
3931        PAT_HEAPDUP as i32,
3932        None,
3933    )
3934    .map_or(false, |p| pattry(&p, name))
3935}
3936
3937// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
3938// ─── RUST-ONLY ACCESSORS ───
3939//
3940// Singleton accessor ported for `OnceLock<Mutex<T>>` / `OnceLock<
3941// RwLock<T>>` globals declared above. C zsh uses direct global
3942// access; Rust needs these wrappers because `OnceLock::get_or_init`
3943// is the only way to lazily construct shared state. These ported sit
3944// here so the body of this file reads in C source order without
3945// the accessor wrappers interleaved between real port ported.
3946// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
3947
3948// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
3949// ─── RUST-ONLY ACCESSORS ───
3950//
3951// Singleton accessor ported for `OnceLock<Mutex<T>>` / `OnceLock<
3952// RwLock<T>>` globals declared above. C zsh uses direct global
3953// access; Rust needs these wrappers because `OnceLock::get_or_init`
3954// is the only way to lazily construct shared state. These ported sit
3955// here so the body of this file reads in C source order without
3956// the accessor wrappers interleaved between real port ported.
3957// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
3958
3959/// Singleton accessor for the `dircache` file-static at
3960/// `Src/hashtable.c:1517`.
3961pub fn dircache_lock() -> &'static std::sync::Mutex<Vec<dircache_entry>> {
3962    DIRCACHE_INNER.get_or_init(|| std::sync::Mutex::new(Vec::new()))
3963}
3964
3965#[cfg(test)]
3966mod tests {
3967    use std::cmp::Ordering;
3968
3969    use super::*;
3970
3971    /// Complexity pin for the `$( … )` alias snapshot. Taking it must not
3972    /// copy the table, and EXPANDING an alias inside the body — which
3973    /// flips `inuse` on every use (`Src/lex.c:1929`, `Src/input.c:773`) —
3974    /// must not copy it either. Only a real definition pays, once, and
3975    /// `restore` then drops the body's table whole.
3976    #[test]
3977    fn alias_snapshot_is_shared_until_a_definition_splits_it() {
3978        let mut live = alias_table::new();
3979        for i in 0..2000 {
3980            live.add(createaliasnode(&format!("a{i}"), "print", 0));
3981        }
3982        let snap = live.snapshot();
3983        assert!(crate::cow_map::CowArc::ptr_eq(&live.table, &snap.table));
3984
3985        // What the lexer does for every expansion.
3986        let a = live.get("a7").unwrap();
3987        a.inuse.store(1, std::sync::atomic::Ordering::Relaxed);
3988        a.inuse.store(0, std::sync::atomic::Ordering::Relaxed);
3989        assert!(
3990            crate::cow_map::CowArc::ptr_eq(&live.table, &snap.table),
3991            "expanding an alias split the table"
3992        );
3993
3994        live.add(createaliasnode("inner", "echo", 0));
3995        live.remove("a1");
3996        assert!(!crate::cow_map::CowArc::ptr_eq(&live.table, &snap.table));
3997        assert!(snap.get("inner").is_none() && snap.get("a1").is_some());
3998
3999        live.restore(snap);
4000        assert!(live.get("inner").is_none());
4001        assert!(live.get("a1").is_some());
4002        assert_eq!(live.len(), 2000);
4003    }
4004
4005    /// Same pin for `cmdnamtab`: a snapshot shares the (possibly
4006    /// thousands-strong, after a `$PATH` fill) bucket array.
4007    #[test]
4008    fn cmdnam_snapshot_is_shared_until_a_hash_splits_it() {
4009        let mut live = cmdnam_table::new();
4010        live.add(cmdnam_hashed("ls", "/bin/ls"));
4011        let snap = live.snapshot();
4012        assert!(crate::cow_map::CowArc::ptr_eq(&live.table, &snap.table));
4013        assert!(live.get("ls").is_some());
4014        assert!(crate::cow_map::CowArc::ptr_eq(&live.table, &snap.table));
4015
4016        live.add(cmdnam_hashed("zq", "/bin/echo"));
4017        assert!(snap.get("zq").is_none());
4018        live.restore(snap);
4019        assert!(live.get("zq").is_none() && live.get("ls").is_some());
4020    }
4021
4022    #[test]
4023    fn test_hasher() {
4024        let _g = crate::test_util::global_state_lock();
4025        assert_eq!(hasher(""), 0);
4026        assert_ne!(hasher("test"), 0);
4027        assert_eq!(hasher("test"), hasher("test"));
4028        assert_ne!(hasher("test"), hasher("Test"));
4029    }
4030
4031    /// Pin `hnamcmp` to its canonical C body at `Src/hashtable.c:341-346`:
4032    /// must route through `ztrcmp` (META-AWARE compare), not naive
4033    /// `str::cmp`. The previous Rust port used byte-wise cmp which
4034    /// sorts Meta-encoded keys incorrectly.
4035    #[test]
4036    fn hnamcmp_uses_ztrcmp_meta_aware_compare() {
4037        let _g = crate::test_util::global_state_lock();
4038        // Plain ASCII: same as str::cmp.
4039        assert_eq!(hnamcmp("apple", "banana"), Ordering::Less);
4040        assert_eq!(hnamcmp("banana", "apple"), Ordering::Greater);
4041        assert_eq!(hnamcmp("equal", "equal"), Ordering::Equal);
4042
4043        // Empty string sorts before non-empty.
4044        assert_eq!(hnamcmp("", "a"), Ordering::Less);
4045        assert_eq!(hnamcmp("a", ""), Ordering::Greater);
4046
4047        // Meta-encoded byte: 0x83 0x41 → real 0x61 ('a'). The
4048        // Meta-aware ztrcmp treats `\x83\x41` as 'a' for compare
4049        // purposes; naive str::cmp would compare 0x83 vs 0x61 (so
4050        // "\x83\x41" would sort AFTER 'a'). Verify the Meta-aware
4051        // path: the encoded "a" should compare equal-ish to "a".
4052        // Construct via unsafe bytes since 0x83 isn't valid UTF-8
4053        // alone — Rust ztrcmp operates on bytes.
4054        let meta_a_bytes: Vec<u8> = vec![0x83, 0x41]; // Meta + 'A'^32 = 'a'
4055        let meta_a = unsafe { std::str::from_utf8_unchecked(&meta_a_bytes) };
4056        // Real "a" (0x61) vs encoded "a" (0x83 0x41): ztrcmp resolves
4057        // both to 0x61 at the first position → Equal. But ztrcmp also
4058        // takes into account end-of-string, so encoded "a" is longer
4059        // by one byte unstripped. The C ztrcmp loop skips matching
4060        // prefix; here the first bytes differ (0x61 vs 0x83), so it
4061        // resolves c1=0x61, c2=(0x41^32)=0x61 → Equal. Verify.
4062        assert_eq!(
4063            hnamcmp("a", meta_a),
4064            Ordering::Equal,
4065            "c:345 — Meta-encoded 'a' (0x83 0x41) compares equal to real 'a'"
4066        );
4067    }
4068
4069    #[test]
4070    fn test_histhasher() {
4071        let _g = crate::test_util::global_state_lock();
4072        assert_eq!(histhasher("  hello  world  "), histhasher("hello world"));
4073        assert_ne!(histhasher("hello world"), histhasher("helloworld"));
4074    }
4075
4076    /// `Src/hashtable.c:1365-1380` — `histhasher` uses `inblank(*str)`
4077    /// per `Src/ztype.h:50`: NARROW space/tab only. The previous Rust
4078    /// port used `c.is_whitespace()` (broad Unicode) which would have
4079    /// silently rehashed any history line containing CR/FF/VT/NBSP.
4080    /// Pin the narrow-inblank semantics:
4081    ///   * Multi-space/tab runs collapse to a single ' ' bucket-mix.
4082    ///   * Newlines are NOT collapsed (newline is not inblank per c:50).
4083    ///   * NBSP / CR are NOT treated as inblank.
4084    #[test]
4085    fn histhasher_inblank_is_narrow_space_tab_only() {
4086        let _g = crate::test_util::global_state_lock();
4087        // c:1369 — leading inblank stripped; multiple equivalent forms hash same.
4088        assert_eq!(
4089            histhasher("\t  hello"),
4090            histhasher("hello"),
4091            "c:1369 — leading space+tab stripped before mixing"
4092        );
4093        // c:1373 — runs of inblank collapse to a single ' '.
4094        assert_eq!(
4095            histhasher("a \t  b"),
4096            histhasher("a b"),
4097            "c:1373 — interior inblank runs collapse to single space"
4098        );
4099
4100        // Newline is NOT inblank per c:50; it must hash as itself, not collapse.
4101        assert_ne!(
4102            histhasher("a\nb"),
4103            histhasher("a b"),
4104            "c:50 — newline is NOT inblank; hashes as its own char"
4105        );
4106        // CR is NOT inblank.
4107        assert_ne!(
4108            histhasher("a\rb"),
4109            histhasher("ab"),
4110            "CR not in inblank; must mix as a character, not collapse"
4111        );
4112        // NBSP (0xA0) is NOT inblank (it's broad Unicode whitespace
4113        // but NOT in C's narrow typtab class).
4114        assert_ne!(
4115            histhasher("a\u{00A0}b"),
4116            histhasher("ab"),
4117            "NBSP not in inblank; must mix as a character, not collapse"
4118        );
4119    }
4120
4121    #[test]
4122    fn test_histstrcmp() {
4123        let _g = crate::test_util::global_state_lock();
4124        assert_eq!(
4125            histstrcmp("  hello  world  ", "hello world", false),
4126            Ordering::Equal
4127        );
4128        assert_eq!(
4129            histstrcmp("hello world", "hello world", true),
4130            Ordering::Equal
4131        );
4132    }
4133
4134    /// `Src/hashtable.c:1396-1421` — `histstrcmp` uses `inblank(*str)`
4135    /// (NARROW space/tab only per `Src/ztype.h:50`). The previous Rust
4136    /// port used `c.is_whitespace()` (broad Unicode) which silently
4137    /// folded history lines that C considers distinct.
4138    /// Pin narrow-inblank semantics.
4139    #[test]
4140    fn histstrcmp_inblank_is_narrow_space_tab_only() {
4141        let _g = crate::test_util::global_state_lock();
4142        // c:1411-1413 — runs of inblank collapse to a single boundary.
4143        assert_eq!(
4144            histstrcmp("hello\tworld", "hello world", false),
4145            Ordering::Equal,
4146            "c:1411-1413 — tab and space both inblank; mixed runs equal"
4147        );
4148        // Newline is NOT inblank per c:50 → string mismatch.
4149        assert_ne!(
4150            histstrcmp("hello\nworld", "hello world", false),
4151            Ordering::Equal,
4152            "c:50 — newline is NOT inblank; must be treated as ordinary char"
4153        );
4154        // CR is NOT inblank.
4155        assert_ne!(
4156            histstrcmp("hello\rworld", "hello world", false),
4157            Ordering::Equal,
4158            "CR not in inblank; not collapsed with space"
4159        );
4160        // NBSP is NOT inblank (broad Unicode whitespace, NOT typtab).
4161        assert_ne!(
4162            histstrcmp("hello\u{00A0}world", "hello world", false),
4163            Ordering::Equal,
4164            "NBSP not in inblank; not collapsed"
4165        );
4166        // c:1405 — HISTREDUCEBLANKS short-circuits to raw cmp.
4167        // With reduce_blanks=true the multi-space form is NOT collapsed.
4168        assert_ne!(
4169            histstrcmp("hello  world", "hello world", true),
4170            Ordering::Equal,
4171            "c:1405 — HISTREDUCEBLANKS=true → strcmp; runs do NOT collapse"
4172        );
4173    }
4174
4175    /// `Src/hashtable.c:1398-1399` — leading inblank is stripped from
4176    /// both sides BEFORE comparison. So `"  cmd"` and `"\tcmd"` are
4177    /// equal. Trailing inblank (per the loop behavior, c:1421
4178    /// `*str1 - *str2` reaches 0 when one side runs out) is also
4179    /// folded: trailing run on one side vs end on the other returns
4180    /// Equal via the (Some, None) inblank-collapse branch.
4181    #[test]
4182    fn histstrcmp_strips_leading_and_trailing_inblank() {
4183        let _g = crate::test_util::global_state_lock();
4184        assert_eq!(
4185            histstrcmp("  cmd", "\tcmd", false),
4186            Ordering::Equal,
4187            "c:1398-1399 — leading inblank skipped (both kinds)"
4188        );
4189        assert_eq!(
4190            histstrcmp("cmd  ", "cmd", false),
4191            Ordering::Equal,
4192            "c:1421 — trailing inblank on left collapses to end-equal"
4193        );
4194        assert_eq!(
4195            histstrcmp("cmd", "cmd\t\t", false),
4196            Ordering::Equal,
4197            "c:1421 — trailing inblank on right collapses to end-equal"
4198        );
4199    }
4200
4201    #[test]
4202    fn test_cmdnam_table() {
4203        let _g = crate::test_util::global_state_lock();
4204        let mut table = cmdnam_table::new();
4205        table.add(cmdnam_hashed("ls", "/bin/ls"));
4206
4207        assert!(table.get("ls").is_some());
4208        assert!(table.get("nonexistent").is_none());
4209
4210        let ls = table.get("ls").unwrap();
4211        assert_ne!((ls.node.flags & HASHED as i32), 0);
4212        assert_eq!((ls.node.flags & DISABLED as i32), 0);
4213    }
4214
4215    #[test]
4216    fn test_shfunc_table() {
4217        let _g = crate::test_util::global_state_lock();
4218        let mut table = shfunc_table::new();
4219        table.add(shfunc_with_body("myfunc", "echo hello"));
4220        table.add(shfunc_autoload("lazy"));
4221
4222        assert!(table.get("myfunc").is_some());
4223        assert_eq!(
4224            (table.get("myfunc").unwrap().node.flags & PM_UNDEFINED as i32),
4225            0
4226        );
4227        assert_ne!(
4228            (table.get("lazy").unwrap().node.flags & PM_UNDEFINED as i32),
4229            0
4230        );
4231
4232        table.disable("myfunc");
4233        assert!(table.get("myfunc").is_none());
4234        assert!(table.get_including_disabled("myfunc").is_some());
4235
4236        table.enable("myfunc");
4237        assert!(table.get("myfunc").is_some());
4238    }
4239
4240    #[test]
4241    fn test_reswd_table() {
4242        let _g = crate::test_util::global_state_lock();
4243        let table = reswd_table::new();
4244
4245        assert!(table.is_reserved("if"));
4246        assert!(table.is_reserved("while"));
4247        assert!(table.is_reserved("[["));
4248        assert!(!table.is_reserved("notreserved"));
4249
4250        let if_rw = table.get("if").unwrap();
4251        assert_eq!(if_rw.token, IF);
4252    }
4253
4254    #[test]
4255    fn test_alias_table() {
4256        let _g = crate::test_util::global_state_lock();
4257        let mut table = alias_table::with_defaults();
4258
4259        assert!(table.get("run-help").is_some());
4260        assert_eq!(table.get("run-help").unwrap().text, "man");
4261
4262        table.add(createaliasnode("G", "| grep", ALIAS_GLOBAL as u32));
4263        let g = table.get("G").unwrap();
4264        assert_ne!((g.node.flags & ALIAS_GLOBAL as i32), 0);
4265
4266        table.add(createaliasnode("pdf", "zathura", ALIAS_SUFFIX as u32));
4267        let p = table.get("pdf").unwrap();
4268        assert_ne!((p.node.flags & ALIAS_SUFFIX as i32), 0);
4269
4270        table.disable("G");
4271        assert!(table.get("G").is_none());
4272    }
4273
4274    #[test]
4275    fn test_dir_cache() {
4276        let _g = crate::test_util::global_state_lock();
4277        // Smoke-test the canonical `dircache` file-static at
4278        // hashtable.c:1517 — the cache lives in a global Mutex
4279        // matching C semantics. Each test gets a fresh slice via
4280        // a unique-name marker so parallel tests don't collide.
4281        let cache = dircache_lock();
4282        {
4283            let mut g = cache.lock().unwrap();
4284            g.clear();
4285            g.push(dircache_entry {
4286                name: "/usr/share/zsh".into(),
4287                refs: 1,
4288            });
4289            g.push(dircache_entry {
4290                name: "/usr/share/zsh".into(),
4291                refs: 1,
4292            });
4293            // Dedupe-by-refs is the C semantic: get_or_insert bumps
4294            // refs on an existing entry. Verify the data shape.
4295            assert_eq!(g.len(), 2);
4296            assert_eq!(g[0].refs, 1);
4297        }
4298    }
4299
4300    // -------------------------------------------------------------
4301    // Tests for the global shfunctab singleton & GSU callbacks.
4302    //
4303    // Tests are serialised via shfuncTAB_TEST_LOCK because they
4304    // mutate the process-wide singleton.
4305    // -------------------------------------------------------------
4306
4307    static shfuncTAB_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
4308
4309    fn fresh_shfunctab() {
4310        let mut tab = shfunctab_lock().write().expect("shfunctab poisoned");
4311        tab.clear();
4312    }
4313
4314    #[test]
4315    fn test_createshfunctable_idempotent() {
4316        let _g = crate::test_util::global_state_lock();
4317        let _g = shfuncTAB_TEST_LOCK.lock();
4318        createshfunctable();
4319        createshfunctable();
4320        // Singleton handle stable across calls.
4321        let h1 = shfunctab_lock() as *const _;
4322        let h2 = shfunctab_lock() as *const _;
4323        assert_eq!(h1, h2);
4324    }
4325
4326    #[test]
4327    fn test_shfunctab_add_get_remove() {
4328        let _g = crate::test_util::global_state_lock();
4329        let _g = shfuncTAB_TEST_LOCK.lock();
4330        fresh_shfunctab();
4331        {
4332            let mut tab = shfunctab_lock().write().unwrap();
4333            tab.add(shfunc_with_body("greet", "echo hello"));
4334        }
4335        {
4336            let tab = shfunctab_lock().read().unwrap();
4337            assert!(tab.get("greet").is_some());
4338            assert_eq!(
4339                tab.get("greet").unwrap().body.as_deref(),
4340                Some("echo hello")
4341            );
4342        }
4343        let removed = removeshfuncnode("greet");
4344        assert!(removed.is_some());
4345        assert!(shfunctab_lock().read().unwrap().get("greet").is_none());
4346    }
4347
4348    #[test]
4349    fn test_shfunctab_disable_enable() {
4350        let _g = crate::test_util::global_state_lock();
4351        let _g = shfuncTAB_TEST_LOCK.lock();
4352        fresh_shfunctab();
4353        {
4354            let mut tab = shfunctab_lock().write().unwrap();
4355            tab.add(shfunc_with_body("f", "true"));
4356        }
4357        disableshfuncnode("f");
4358        // get() filters disabled; get_including_disabled doesn't.
4359        {
4360            let tab = shfunctab_lock().read().unwrap();
4361            assert!(tab.get("f").is_none());
4362            assert!(tab.get_including_disabled("f").is_some());
4363        }
4364        enableshfuncnode("f");
4365        assert!(shfunctab_lock().read().unwrap().get("f").is_some());
4366        removeshfuncnode("f");
4367    }
4368
4369    #[test]
4370    fn test_simple_glob_match() {
4371        let _g = crate::test_util::global_state_lock();
4372        assert!(simple_glob_match("foo", "foo"));
4373        assert!(!simple_glob_match("foo", "bar"));
4374        assert!(simple_glob_match("f*", "foo"));
4375        assert!(simple_glob_match("f*", "f"));
4376        assert!(simple_glob_match("*o", "foo"));
4377        assert!(simple_glob_match("*", ""));
4378        assert!(simple_glob_match("?oo", "foo"));
4379        assert!(!simple_glob_match("?oo", "fo"));
4380        assert!(simple_glob_match("f*o", "frogspawn-suo"));
4381    }
4382
4383    #[test]
4384    fn test_scanmatchshfunc_matches_pattern() {
4385        let _g = crate::test_util::global_state_lock();
4386        let _g = shfuncTAB_TEST_LOCK.lock();
4387        fresh_shfunctab();
4388        {
4389            let mut tab = shfunctab_lock().write().unwrap();
4390            tab.add(shfunc_with_body("foo", "echo a"));
4391            tab.add(shfunc_with_body("foobar", "echo b"));
4392            tab.add(shfunc_with_body("baz", "echo c"));
4393        }
4394        let mut matched: Vec<String> = Vec::new();
4395        let count = scanmatchshfunc(Some("foo*"), |name, _| matched.push(name.to_string()));
4396        assert_eq!(count, 2);
4397        matched.sort();
4398        assert_eq!(matched, vec!["foo".to_string(), "foobar".to_string()]);
4399        // No-pattern walks all.
4400        let total = scanshfunc(|_, _| {});
4401        assert_eq!(total, 3);
4402        fresh_shfunctab();
4403    }
4404
4405    #[test]
4406    fn test_getshfuncfile_returns_filename() {
4407        let _g = crate::test_util::global_state_lock();
4408        let _g = shfuncTAB_TEST_LOCK.lock();
4409        fresh_shfunctab();
4410        {
4411            let mut tab = shfunctab_lock().write().unwrap();
4412            let mut f = shfunc_with_body("f", "true");
4413            f.filename = Some("/tmp/zshrs-ported/f".to_string());
4414            tab.add(f);
4415        }
4416        assert_eq!(getshfuncfile("f"), Some("/tmp/zshrs-ported/f".to_string()));
4417        assert_eq!(getshfuncfile("nonexistent"), None);
4418        fresh_shfunctab();
4419    }
4420
4421    // -------------------------------------------------------------
4422    // Generic hashtable ops + per-table singletons.
4423    // -------------------------------------------------------------
4424
4425    #[test]
4426    fn test_generic_addhashnode_displaces_old() {
4427        let _g = crate::test_util::global_state_lock();
4428        let mut ht: HashMap<String, alias> = HashMap::new();
4429        addhashnode(&mut ht, "x", createaliasnode("x", "echo a", 0));
4430        let old = addhashnode2(&mut ht, "x", createaliasnode("x", "echo b", 0));
4431        assert!(old.is_some());
4432        assert_eq!(old.unwrap().text, "echo a");
4433        assert_eq!(gethashnode2(&ht, "x").unwrap().text, "echo b");
4434    }
4435
4436    #[test]
4437    fn test_generic_disable_filters_get() {
4438        let _g = crate::test_util::global_state_lock();
4439        let mut ht: HashMap<String, alias> = HashMap::new();
4440        ht.insert("a".to_string(), createaliasnode("a", "1", 0));
4441        assert!(gethashnode(&ht, "a").is_some());
4442        disablehashnode(&mut ht, "a");
4443        // gethashnode filters disabled, gethashnode2 doesn't.
4444        assert!(gethashnode(&ht, "a").is_none());
4445        assert!(gethashnode2(&ht, "a").is_some());
4446        enablehashnode(&mut ht, "a");
4447        assert!(gethashnode(&ht, "a").is_some());
4448    }
4449
4450    #[test]
4451    fn test_scanmatchtable_pattern_and_count() {
4452        let _g = crate::test_util::global_state_lock();
4453        let mut ht: HashMap<String, alias> = HashMap::new();
4454        ht.insert("foo".to_string(), createaliasnode("foo", "1", 0));
4455        ht.insert("foobar".to_string(), createaliasnode("foobar", "2", 0));
4456        ht.insert("baz".to_string(), createaliasnode("baz", "3", 0));
4457        let mut hits: Vec<String> = Vec::new();
4458        let count = scanmatchtable(&ht, Some("foo*"), true, 0, 0, |n, _| {
4459            hits.push(n.to_string())
4460        });
4461        assert_eq!(count, 2);
4462        // Sorted output guaranteed when sorted=true.
4463        assert_eq!(hits, vec!["foo".to_string(), "foobar".to_string()]);
4464    }
4465
4466    #[test]
4467    fn test_emptyhashtable_clears() {
4468        let _g = crate::test_util::global_state_lock();
4469        let mut ht: HashMap<String, alias> = HashMap::new();
4470        ht.insert("a".to_string(), createaliasnode("a", "1", 0));
4471        ht.insert("b".to_string(), createaliasnode("b", "2", 0));
4472        assert_eq!(ht.len(), 2);
4473        emptyhashtable(&mut ht);
4474        assert_eq!(ht.len(), 0);
4475    }
4476
4477    #[test]
4478    fn test_resizehashtable_reserves_capacity() {
4479        let _g = crate::test_util::global_state_lock();
4480        let mut ht: HashMap<String, i32> = HashMap::new();
4481        let initial_cap = ht.capacity();
4482        resizehashtable(&mut ht, 200);
4483        assert!(ht.capacity() >= 200);
4484        assert!(ht.capacity() >= initial_cap);
4485    }
4486
4487    #[test]
4488    fn test_aliastab_singleton_has_defaults() {
4489        let _g = crate::test_util::global_state_lock();
4490        let tab = aliastab_lock().read().unwrap();
4491        // createaliastables seeds run-help and which-command.
4492        assert!(tab.get_including_disabled("run-help").is_some());
4493        assert!(tab.get_including_disabled("which-command").is_some());
4494    }
4495
4496    #[test]
4497    fn test_createaliasnode_sets_flags() {
4498        let _g = crate::test_util::global_state_lock();
4499        let a = createaliasnode("foo", "echo bar", ALIAS_GLOBAL as u32);
4500        assert_eq!(a.node.nam, "foo");
4501        assert_eq!(a.text, "echo bar");
4502        assert_ne!((a.node.flags & ALIAS_GLOBAL as i32), 0);
4503    }
4504
4505    #[test]
4506    fn test_printaliasnode_smoke() {
4507        // printaliasnode writes directly to stdout (matches C's void
4508        // return / writes-to-stdout signature). The behavioural parity
4509        // assertions live in `tests/builtin_c_parity.rs::alias_builtin`,
4510        // which compares against `/bin/zsh -fc 'alias gst'` byte-for-byte.
4511        // This unit test just exercises every flag branch to make sure
4512        // none panics / borrows incorrectly.
4513        let _g = crate::test_util::global_state_lock();
4514        let a = createaliasnode("ll", "ls -la", 0);
4515        printaliasnode(&a, PRINT_NAMEONLY);
4516        printaliasnode(&a, PRINT_WHENCE_WORD);
4517        printaliasnode(&a, PRINT_WHENCE_SIMPLE);
4518        printaliasnode(&a, PRINT_WHENCE_CSH);
4519        printaliasnode(&a, PRINT_WHENCE_VERBOSE);
4520        printaliasnode(&a, PRINT_LIST);
4521        printaliasnode(&a, 0);
4522    }
4523
4524    #[test]
4525    fn test_printreswdnode_smoke() {
4526        // printreswdnode writes directly to stdout (matches C's void
4527        // return / write-to-stdout signature at hashtable.c:1147).
4528        // Smoke-test every flag branch to make sure none panics.
4529        let _g = crate::test_util::global_state_lock();
4530        let table = reswd_table::new();
4531        let if_rw = table.get("if").unwrap();
4532        printreswdnode(if_rw, PRINT_WHENCE_WORD);
4533        printreswdnode(if_rw, PRINT_WHENCE_CSH);
4534        printreswdnode(if_rw, PRINT_WHENCE_VERBOSE);
4535        printreswdnode(if_rw, 0);
4536    }
4537
4538    #[test]
4539    fn test_addhistnode_displaces_old() {
4540        let _g = crate::test_util::global_state_lock();
4541        emptyhisttable();
4542        assert_eq!(addhistnode("ls -la", 1), None);
4543        let old = addhistnode("ls -la", 5);
4544        assert_eq!(old, Some(1));
4545        emptyhisttable();
4546    }
4547
4548    #[test]
4549    fn test_freecmdnamnode_removes() {
4550        let _g = crate::test_util::global_state_lock();
4551        emptycmdnamtable();
4552        {
4553            let mut tab = cmdnamtab_lock().write().unwrap();
4554            tab.add(cmdnam_unhashed("ls", vec!["/bin".to_string()]));
4555        }
4556        assert!(cmdnamtab_lock().read().unwrap().get("ls").is_some());
4557        freecmdnamnode("ls");
4558        assert!(cmdnamtab_lock().read().unwrap().get("ls").is_none());
4559    }
4560
4561    #[test]
4562    fn test_dircache_set_refcounts() {
4563        let _g = crate::test_util::global_state_lock();
4564        // Refcount add → entries grow.
4565        let mut k: Option<String> = None;
4566        dircache_set(&mut k, Some("/usr/bin"));
4567        let mut k2: Option<String> = None;
4568        dircache_set(&mut k2, Some("/usr/bin"));
4569        let cache_size = dircache_lock().lock().unwrap().len();
4570        assert!(cache_size >= 1);
4571    }
4572
4573    /// c:1230 — `createaliasnode(name, text, flags)` builds an crate::ported::zsh_h::alias
4574    /// with the text field populated. Regression that drops `text`
4575    /// would silently install aliases that expand to nothing.
4576    #[test]
4577    fn createaliasnode_round_trips_name_and_text() {
4578        let _g = crate::test_util::global_state_lock();
4579        let a = createaliasnode("ls-color", "ls --color=auto", 0);
4580        assert_eq!(a.text, "ls --color=auto");
4581        assert_eq!(a.node.nam, "ls-color");
4582    }
4583
4584    // ─── alias-creation zsh-corpus pins ────────────────────────────
4585
4586    /// `createaliasnode` round-trips name+text+flags=0 (regular alias).
4587    #[test]
4588    fn alias_corpus_create_regular_alias() {
4589        let _g = crate::test_util::global_state_lock();
4590        let a = createaliasnode("ll", "ls -la", 0);
4591        assert_eq!(a.node.nam, "ll");
4592        assert_eq!(a.text, "ls -la");
4593        // Regular alias = no GLOBAL/SUFFIX flags.
4594        let f = a.node.flags as i32;
4595        assert_eq!(
4596            f & (ALIAS_GLOBAL | ALIAS_SUFFIX),
4597            0,
4598            "regular alias has no GLOBAL/SUFFIX bits"
4599        );
4600    }
4601
4602    /// `createaliasnode` with ALIAS_GLOBAL flag sets the global bit.
4603    #[test]
4604    fn alias_corpus_create_global_alias_carries_flag() {
4605        let _g = crate::test_util::global_state_lock();
4606        let a = createaliasnode("G", "global text", ALIAS_GLOBAL as u32);
4607        let f = a.node.flags as i32;
4608        assert_ne!(f & ALIAS_GLOBAL, 0, "ALIAS_GLOBAL set");
4609    }
4610
4611    /// `createaliasnode` with ALIAS_SUFFIX flag sets the suffix bit.
4612    #[test]
4613    fn alias_corpus_create_suffix_alias_carries_flag() {
4614        let _g = crate::test_util::global_state_lock();
4615        let a = createaliasnode("S", "suffix text", ALIAS_SUFFIX as u32);
4616        let f = a.node.flags as i32;
4617        assert_ne!(f & ALIAS_SUFFIX, 0, "ALIAS_SUFFIX set");
4618    }
4619
4620    /// Empty text is preserved (zsh allows zero-length alias expansion).
4621    #[test]
4622    fn alias_corpus_create_empty_text_preserved() {
4623        let _g = crate::test_util::global_state_lock();
4624        let a = createaliasnode("noop", "", 0);
4625        assert_eq!(a.text, "");
4626    }
4627
4628    /// Alias text may contain spaces — preserved as-is.
4629    #[test]
4630    fn alias_corpus_create_multi_word_text_preserved() {
4631        let _g = crate::test_util::global_state_lock();
4632        let a = createaliasnode("rmf", "rm -rf --no-preserve-root", 0);
4633        assert_eq!(a.text, "rm -rf --no-preserve-root");
4634    }
4635
4636    /// `aliastab_lock` initialises with the two default aliases
4637    /// `run-help` and `which-command` per hashtable.c:1215-1216.
4638    /// A regression here breaks zsh's documented default behaviour
4639    /// where `run-help` resolves to `man` after `autoload -U run-help`.
4640    #[test]
4641    fn aliastab_seeds_run_help_and_which_command_defaults() {
4642        let _g = crate::test_util::global_state_lock();
4643        createaliastables();
4644        let tab = aliastab_lock().read().expect("aliastab poisoned");
4645        assert!(tab.get("run-help").is_some(), "run-help default missing");
4646        assert!(
4647            tab.get("which-command").is_some(),
4648            "which-command default missing"
4649        );
4650    }
4651
4652    /// c:86 — `hasher` is the canonical zsh string hash. Same input
4653    /// MUST produce same output (basic determinism); different inputs
4654    /// SHOULD produce different outputs (no pathological collisions
4655    /// for single-char-different strings). The wrapping_add chain in
4656    /// the impl makes this a Bernstein-style hash; verify it's stable.
4657    #[test]
4658    fn hasher_is_deterministic_across_calls() {
4659        let _g = crate::test_util::global_state_lock();
4660        assert_eq!(hasher("foo"), hasher("foo"));
4661        assert_eq!(hasher(""), hasher(""));
4662        // Common shell names should not collide trivially.
4663        assert_ne!(hasher("ls"), hasher("cd"));
4664        assert_ne!(hasher("foo"), hasher("bar"));
4665    }
4666
4667    /// c:86 — empty input hashes to 0 (the seed value). A regression
4668    /// changing the seed would invalidate every persisted hash + cause
4669    /// silent rebuild storms in the cache layer.
4670    #[test]
4671    fn hasher_empty_string_hashes_to_zero() {
4672        let _g = crate::test_util::global_state_lock();
4673        assert_eq!(hasher(""), 0);
4674    }
4675
4676    /// c:86 — single-byte input `c` hashes to `c as u32` exactly
4677    /// (the loop runs once: hashval = 0 + 0<<5 + c = c). Pins the
4678    /// canonical first-iteration formula.
4679    #[test]
4680    fn hasher_single_byte_equals_byte_value() {
4681        let _g = crate::test_util::global_state_lock();
4682        assert_eq!(hasher("a"), b'a' as u32);
4683        assert_eq!(hasher("Z"), b'Z' as u32);
4684        assert_eq!(hasher("0"), b'0' as u32);
4685    }
4686
4687    /// `Src/hashtable.c:90-91` — `hashval += (hashval << 5) + c`
4688    /// simplifies to `hashval = hashval*33 + c` (the Bernstein
4689    /// hash variant). Pin the exact two-byte formula so a refactor
4690    /// to a different polynomial (e.g. FNV / djb2 / siphash) fails
4691    /// loudly. Regression here invalidates every cached fpath/hash
4692    /// digest stored on disk.
4693    #[test]
4694    fn hasher_two_byte_matches_bernstein_polynomial() {
4695        let _g = crate::test_util::global_state_lock();
4696        // For "ab": h0=0; h1 = 0 + (0<<5) + 'a' = 97; h2 = 97 + (97<<5) + 'b' = 97 + 3104 + 98 = 3299.
4697        assert_eq!(
4698            hasher("ab"),
4699            97u32
4700                .wrapping_add(97u32.wrapping_shl(5))
4701                .wrapping_add(b'b' as u32)
4702        );
4703        assert_eq!(hasher("ab"), 3299);
4704        // Pin the exact value for "ls" — a name we'll lookup billions of times.
4705        let ls_expected = {
4706            let mut h: u32 = 0;
4707            for &c in b"ls" {
4708                h = h.wrapping_add(h.wrapping_shl(5)).wrapping_add(c as u32);
4709            }
4710            h
4711        };
4712        assert_eq!(hasher("ls"), ls_expected);
4713    }
4714
4715    /// c:86 — hasher must NOT mix in encoding/locale state — the
4716    /// algorithm is byte-by-byte. Multi-byte UTF-8 like 'é' (0xC3 0xA9)
4717    /// hashes the two bytes independently. Pin so a regression that
4718    /// uses chars instead of bytes (which would aggregate the two
4719    /// bytes into one codepoint) fails.
4720    #[test]
4721    fn hasher_processes_utf8_bytes_not_codepoints() {
4722        let _g = crate::test_util::global_state_lock();
4723        // 'é' UTF-8 = 0xC3 0xA9 — two bytes.
4724        let expected = {
4725            let mut h: u32 = 0;
4726            for &c in &[0xC3u8, 0xA9u8] {
4727                h = h.wrapping_add(h.wrapping_shl(5)).wrapping_add(c as u32);
4728            }
4729            h
4730        };
4731        assert_eq!(
4732            hasher("é"),
4733            expected,
4734            "c:90 — `*(unsigned char *) str++` reads BYTES, not codepoints"
4735        );
4736    }
4737
4738    /// c:157 — `addhashnode` inserts; `gethashnode2` reads back.
4739    /// Round-trip MUST yield the value just inserted. Regression
4740    /// returning None on a present key would break every command-
4741    /// table lookup.
4742    #[test]
4743    fn addhashnode_then_gethashnode2_round_trips() {
4744        let _g = crate::test_util::global_state_lock();
4745        let mut h: HashMap<String, i32> = HashMap::new();
4746        addhashnode(&mut h, "key1", 42);
4747        assert_eq!(gethashnode2(&h, "key1"), Some(&42));
4748        assert_eq!(gethashnode2(&h, "missing"), None);
4749    }
4750
4751    /// c:275 — `removehashnode` returns Some(value) when present and
4752    /// drops the entry. Subsequent lookup MUST miss. Regression
4753    /// returning Some without removing would let callers think they
4754    /// removed when they actually didn't.
4755    #[test]
4756    fn removehashnode_returns_value_and_drops_entry() {
4757        let _g = crate::test_util::global_state_lock();
4758        let mut h: HashMap<String, String> = HashMap::new();
4759        addhashnode(&mut h, "key1", "val".to_string());
4760        let removed = removehashnode(&mut h, "key1");
4761        assert_eq!(removed.as_deref(), Some("val"));
4762        assert!(
4763            gethashnode2(&h, "key1").is_none(),
4764            "after removehashnode, lookup must miss"
4765        );
4766    }
4767
4768    /// c:275 — `removehashnode` on a missing key returns None and
4769    /// doesn't mutate the table. A regression where it errors or
4770    /// inserts a sentinel would break `unalias missing` (which is
4771    /// supposed to fail-soft).
4772    #[test]
4773    fn removehashnode_missing_key_returns_none() {
4774        let _g = crate::test_util::global_state_lock();
4775        let mut h: HashMap<String, i32> = HashMap::new();
4776        addhashnode(&mut h, "k1", 1);
4777        let len_before = h.len();
4778        assert!(removehashnode(&mut h, "missing").is_none());
4779        assert_eq!(h.len(), len_before, "missing-key remove must not mutate");
4780    }
4781
4782    // ─── zsh-corpus pins: hashtable add/get/remove ──────────────────
4783
4784    /// `addhashnode2` returns None on first insert.
4785    #[test]
4786    fn hashtable_corpus_add_new_returns_none() {
4787        let mut h: HashMap<String, i32> = HashMap::new();
4788        assert!(addhashnode2(&mut h, "fresh", 7).is_none());
4789        assert_eq!(gethashnode2(&h, "fresh"), Some(&7));
4790    }
4791
4792    /// `addhashnode2` on an existing key returns the OLD value.
4793    #[test]
4794    fn hashtable_corpus_add_existing_returns_previous_value() {
4795        let mut h: HashMap<String, i32> = HashMap::new();
4796        addhashnode2(&mut h, "k", 1);
4797        let prev = addhashnode2(&mut h, "k", 2);
4798        assert_eq!(prev, Some(1), "old value returned on replace");
4799        assert_eq!(gethashnode2(&h, "k"), Some(&2), "new value installed");
4800    }
4801
4802    /// `gethashnode2` on missing key returns None.
4803    #[test]
4804    fn hashtable_corpus_get_missing_returns_none() {
4805        let h: HashMap<String, i32> = HashMap::new();
4806        assert!(gethashnode2(&h, "anything").is_none());
4807    }
4808
4809    /// `newhashtable` returns (name, size); name preserved.
4810    #[test]
4811    fn hashtable_corpus_newhashtable_preserves_name() {
4812        let (name, sz) = newhashtable(64, "myht");
4813        assert_eq!(name, "myht");
4814        assert!(sz > 0, "size positive, got {sz}");
4815    }
4816
4817    /// Round-trip with many distinct keys.
4818    #[test]
4819    fn hashtable_corpus_many_keys_round_trip() {
4820        let mut h: HashMap<String, i32> = HashMap::new();
4821        for i in 0..100 {
4822            addhashnode(&mut h, &format!("k{i}"), i);
4823        }
4824        for i in 0..100 {
4825            assert_eq!(gethashnode2(&h, &format!("k{i}")), Some(&i));
4826        }
4827        assert_eq!(h.len(), 100);
4828    }
4829
4830    /// `removehashnode` followed by `gethashnode2` shows missing.
4831    #[test]
4832    fn hashtable_corpus_remove_then_get_is_none() {
4833        let mut h: HashMap<String, String> = HashMap::new();
4834        addhashnode(&mut h, "x", "value".into());
4835        let _ = removehashnode(&mut h, "x");
4836        assert!(gethashnode2(&h, "x").is_none());
4837    }
4838
4839    // ═══════════════════════════════════════════════════════════════════
4840    // Additional C-parity tests for Src/hashtable.c hasher + hnamcmp +
4841    // generic add/remove primitives.
4842    // ═══════════════════════════════════════════════════════════════════
4843
4844    /// c:86 — `hasher` of empty string returns 0 (no bytes contribute).
4845    #[test]
4846    fn hasher_empty_string_returns_zero() {
4847        assert_eq!(hasher(""), 0, "no bytes → hash 0");
4848    }
4849
4850    /// c:86 — `hasher` is deterministic: same input → same output.
4851    #[test]
4852    fn hasher_is_deterministic() {
4853        let h1 = hasher("test_string");
4854        let h2 = hasher("test_string");
4855        assert_eq!(h1, h2, "hasher must be deterministic");
4856    }
4857
4858    /// c:86 — `hasher` differentiates between different strings
4859    /// (no trivial collisions on common inputs).
4860    #[test]
4861    fn hasher_distinguishes_common_strings() {
4862        assert_ne!(hasher("foo"), hasher("bar"));
4863        assert_ne!(hasher("a"), hasher("b"));
4864        assert_ne!(hasher("test"), hasher("Test"), "case-sensitive");
4865    }
4866
4867    /// c:86 — hash of single char "a" matches the formula
4868    /// `0 + (0<<5) + 'a' = 0x61` (verifies inline formula).
4869    #[test]
4870    fn hasher_single_char_matches_formula() {
4871        let h = hasher("a");
4872        assert_eq!(h, b'a' as u32, "single char 'a' → 0x61");
4873        let h = hasher("0");
4874        assert_eq!(h, b'0' as u32, "single char '0' → 0x30");
4875    }
4876
4877    /// c:86 — hasher of "ab": h=0 → h=0+(0<<5)+'a'=0x61
4878    /// → h=0x61+(0x61<<5)+'b' = 0x61 + 0xC20 + 0x62 = 0xCE3.
4879    #[test]
4880    fn hasher_two_char_matches_formula() {
4881        let h = hasher("ab");
4882        let expected: u32 = 0u32
4883            .wrapping_add(0u32.wrapping_shl(5))
4884            .wrapping_add(b'a' as u32);
4885        let expected = expected
4886            .wrapping_add(expected.wrapping_shl(5))
4887            .wrapping_add(b'b' as u32);
4888        assert_eq!(h, expected, "two-char formula must match");
4889    }
4890
4891    /// c:86 — uses wrapping arithmetic so long strings don't panic.
4892    #[test]
4893    fn hasher_long_string_does_not_panic() {
4894        let s = "a".repeat(10_000);
4895        let _ = hasher(&s);
4896    }
4897
4898    /// c:345 — `hnamcmp("abc", "abc")` returns Equal.
4899    #[test]
4900    fn hnamcmp_equal_strings_return_equal() {
4901        assert_eq!(hnamcmp("abc", "abc"), std::cmp::Ordering::Equal);
4902        assert_eq!(hnamcmp("", ""), std::cmp::Ordering::Equal);
4903    }
4904
4905    /// c:345 — `hnamcmp` orders lexicographically.
4906    #[test]
4907    fn hnamcmp_lex_order() {
4908        assert_eq!(hnamcmp("abc", "abd"), std::cmp::Ordering::Less);
4909        assert_eq!(hnamcmp("abd", "abc"), std::cmp::Ordering::Greater);
4910    }
4911
4912    /// c:345 — empty string sorts before any non-empty string.
4913    #[test]
4914    fn hnamcmp_empty_sorts_first() {
4915        assert_eq!(hnamcmp("", "x"), std::cmp::Ordering::Less);
4916        assert_eq!(hnamcmp("x", ""), std::cmp::Ordering::Greater);
4917    }
4918
4919    /// `emptyhashtable` drops all entries.
4920    #[test]
4921    fn emptyhashtable_clears_all_entries() {
4922        let mut h: HashMap<String, i32> = HashMap::new();
4923        h.insert("a".to_string(), 1);
4924        h.insert("b".to_string(), 2);
4925        h.insert("c".to_string(), 3);
4926        emptyhashtable(&mut h);
4927        assert!(h.is_empty(), "all entries dropped after emptyhashtable");
4928    }
4929
4930    /// `deletehashtable` clears the map (Rust semantics).
4931    #[test]
4932    fn deletehashtable_clears_all_entries() {
4933        let mut h: HashMap<String, i32> = HashMap::new();
4934        h.insert("x".to_string(), 42);
4935        deletehashtable(&mut h);
4936        assert!(h.is_empty());
4937    }
4938
4939    /// `removehashnode` on missing key returns None (no panic).
4940    #[test]
4941    fn removehashnode_missing_returns_none() {
4942        let mut h: HashMap<String, i32> = HashMap::new();
4943        let prev = removehashnode(&mut h, "never_there");
4944        assert!(prev.is_none(), "remove of missing key → None");
4945    }
4946
4947    /// `addhashnode` overwriting existing key drops old value silently.
4948    #[test]
4949    fn addhashnode_overwrite_does_not_panic() {
4950        let mut h: HashMap<String, String> = HashMap::new();
4951        addhashnode(&mut h, "k", "first".into());
4952        addhashnode(&mut h, "k", "second".into());
4953        assert_eq!(gethashnode2(&h, "k"), Some(&"second".to_string()));
4954    }
4955
4956    // ═══════════════════════════════════════════════════════════════════
4957    // Additional C-parity tests for Src/hashtable.c
4958    // c:55 hasher / c:85 newhashtable / c:97 deletehashtable /
4959    // c:150 addhashnode2 / c:343 gethashnode2 / c:355 removehashnode /
4960    // c:715 hnamcmp / c:876 expandhashtable / c:887 resizehashtable /
4961    // c:916 printhashtabinfo
4962    // ═══════════════════════════════════════════════════════════════════
4963
4964    /// c:55 — `hasher("")` empty string returns u32 (type pin).
4965    #[test]
4966    fn hasher_empty_returns_u32_type() {
4967        let _: u32 = hasher("");
4968    }
4969
4970    /// c:55 — `hasher` is pure.
4971    #[test]
4972    fn hasher_is_pure_full_sweep() {
4973        for s in ["", "a", "abc", "hello world", "日本"] {
4974            let first = hasher(s);
4975            for _ in 0..5 {
4976                assert_eq!(hasher(s), first, "hasher({:?}) must be pure", s);
4977            }
4978        }
4979    }
4980
4981    /// c:85 — `newhashtable(0, "")` returns (String, i32) tuple type pin.
4982    #[test]
4983    fn newhashtable_returns_string_i32_tuple_type() {
4984        let _: (String, i32) = newhashtable(0, "");
4985    }
4986
4987    /// c:97 — `deletehashtable` on empty table is safe no-op.
4988    #[test]
4989    fn deletehashtable_empty_no_panic() {
4990        let mut empty: HashMap<String, String> = HashMap::new();
4991        deletehashtable(&mut empty);
4992        assert!(empty.is_empty(), "still empty after delete");
4993    }
4994
4995    /// c:150 — `addhashnode2` returns Option<T> (replaced value).
4996    #[test]
4997    fn addhashnode2_returns_option_type() {
4998        let mut h: HashMap<String, i32> = HashMap::new();
4999        let _: Option<i32> = addhashnode2(&mut h, "k", 1);
5000    }
5001
5002    /// c:150 — `addhashnode2` first insert returns None.
5003    #[test]
5004    fn addhashnode2_first_insert_returns_none() {
5005        let mut h: HashMap<String, i32> = HashMap::new();
5006        let r = addhashnode2(&mut h, "k", 42);
5007        assert!(r.is_none(), "first insert → None (no replacement)");
5008    }
5009
5010    /// c:150 — `addhashnode2` overwrite returns Some(old).
5011    #[test]
5012    fn addhashnode2_overwrite_returns_some_old() {
5013        let mut h: HashMap<String, i32> = HashMap::new();
5014        addhashnode2(&mut h, "k", 1);
5015        let r = addhashnode2(&mut h, "k", 2);
5016        assert_eq!(r, Some(1), "overwrite returns previous value");
5017    }
5018
5019    /// c:343 — `gethashnode2(empty, _)` returns None.
5020    #[test]
5021    fn gethashnode2_empty_table_returns_none() {
5022        let h: HashMap<String, String> = HashMap::new();
5023        assert!(gethashnode2(&h, "anything").is_none());
5024    }
5025
5026    /// c:355 — `removehashnode(empty, _)` returns None.
5027    #[test]
5028    fn removehashnode_empty_table_returns_none() {
5029        let mut h: HashMap<String, String> = HashMap::new();
5030        assert!(removehashnode(&mut h, "anything").is_none());
5031    }
5032
5033    /// c:715 — `hnamcmp` is antisymmetric.
5034    #[test]
5035    fn hnamcmp_antisymmetric() {
5036        use std::cmp::Ordering;
5037        for (a, b) in [("a", "b"), ("abc", "xyz"), ("", "x")] {
5038            let ab = hnamcmp(a, b);
5039            let ba = hnamcmp(b, a);
5040            assert_eq!(
5041                ab.reverse(),
5042                ba,
5043                "hnamcmp must be antisymmetric for ({:?}, {:?})",
5044                a,
5045                b
5046            );
5047            // ab cannot be Equal AND ba Equal unless both Equal
5048            if ab == Ordering::Equal {
5049                assert_eq!(ba, Ordering::Equal);
5050            }
5051        }
5052    }
5053
5054    /// c:887 — `resizehashtable` with same size is no-op.
5055    #[test]
5056    fn resizehashtable_same_size_no_panic() {
5057        let mut h: HashMap<String, i32> = HashMap::new();
5058        h.insert("a".to_string(), 1);
5059        h.insert("b".to_string(), 2);
5060        resizehashtable(&mut h, 2);
5061        assert_eq!(h.len(), 2, "entries preserved after same-size resize");
5062    }
5063
5064    /// c:876 — `expandhashtable` is idempotent.
5065    #[test]
5066    fn expandhashtable_idempotent() {
5067        let mut h: HashMap<String, i32> = HashMap::new();
5068        h.insert("a".to_string(), 1);
5069        for _ in 0..5 {
5070            expandhashtable(&mut h);
5071        }
5072        assert_eq!(h.get("a"), Some(&1), "value preserved across expansions");
5073    }
5074
5075    /// c:916 — `printhashtabinfo("", empty)` returns String type.
5076    #[test]
5077    fn printhashtabinfo_returns_string_type() {
5078        let empty: HashMap<String, String> = HashMap::new();
5079        let _: String = printhashtabinfo("test", &empty);
5080    }
5081
5082    // ═══════════════════════════════════════════════════════════════════
5083    // Additional C-parity tests for Src/hashtable.c
5084    // c:55 hasher / c:139 addhashnode / c:343 gethashnode2 / c:355 removehashnode /
5085    // c:715 hnamcmp / c:900 emptyhashtable / c:916 printhashtabinfo
5086    // ═══════════════════════════════════════════════════════════════════
5087
5088    /// c:55 — `hasher` returns u32 (compile-time pin).
5089    #[test]
5090    fn hasher_returns_u32_type() {
5091        let _: u32 = hasher("anything");
5092    }
5093
5094    /// c:55 — `hasher` is deterministic (same input → same hash, alt).
5095    #[test]
5096    fn hasher_is_deterministic_alt() {
5097        for s in ["", "x", "abc", "longer input", "日本"] {
5098            let first = hasher(s);
5099            for _ in 0..5 {
5100                assert_eq!(hasher(s), first, "hasher({:?}) must be pure", s);
5101            }
5102        }
5103    }
5104
5105    /// c:55 — `hasher` distinguishes simple distinct inputs (sanity:
5106    /// not a constant hash).
5107    #[test]
5108    fn hasher_distinguishes_distinct_inputs() {
5109        let h_a = hasher("a");
5110        let h_b = hasher("b");
5111        let h_z = hasher("z");
5112        // At least two of three must differ (proves non-constant).
5113        let distinct = (h_a != h_b) || (h_b != h_z) || (h_a != h_z);
5114        assert!(
5115            distinct,
5116            "hasher must distinguish distinct inputs; got {} {} {}",
5117            h_a, h_b, h_z
5118        );
5119    }
5120
5121    /// c:139 — `addhashnode` followed by gethashnode2 retrieves entry.
5122    #[test]
5123    fn addhashnode_then_gethashnode2_retrieves_entry() {
5124        let mut h: HashMap<String, String> = HashMap::new();
5125        addhashnode(&mut h, "key", "value".to_string());
5126        let v = gethashnode2(&h, "key");
5127        assert_eq!(
5128            v,
5129            Some(&"value".to_string()),
5130            "add then get must round-trip"
5131        );
5132    }
5133
5134    /// c:355 — `removehashnode` after add returns Some(value).
5135    #[test]
5136    fn removehashnode_after_add_returns_some() {
5137        let mut h: HashMap<String, String> = HashMap::new();
5138        addhashnode(&mut h, "k", "v".to_string());
5139        let removed = removehashnode(&mut h, "k");
5140        assert_eq!(
5141            removed,
5142            Some("v".to_string()),
5143            "remove returns the removed value"
5144        );
5145        assert!(h.is_empty(), "table empty after remove");
5146    }
5147
5148    /// c:355 — `removehashnode` twice returns Some then None.
5149    #[test]
5150    fn removehashnode_twice_returns_some_then_none() {
5151        let mut h: HashMap<String, i32> = HashMap::new();
5152        addhashnode(&mut h, "k", 42);
5153        let first = removehashnode(&mut h, "k");
5154        let second = removehashnode(&mut h, "k");
5155        assert!(first.is_some());
5156        assert!(second.is_none(), "second remove of same key returns None");
5157    }
5158
5159    /// c:715 — `hnamcmp(x, x)` returns Equal (reflexive).
5160    #[test]
5161    fn hnamcmp_reflexive() {
5162        use std::cmp::Ordering;
5163        for s in ["", "a", "hello", "long string here"] {
5164            assert_eq!(
5165                hnamcmp(s, s),
5166                Ordering::Equal,
5167                "hnamcmp({:?}, {:?}) must be Equal",
5168                s,
5169                s
5170            );
5171        }
5172    }
5173
5174    /// c:900 — `emptyhashtable` actually drops all entries.
5175    #[test]
5176    fn emptyhashtable_drops_all_entries() {
5177        let mut h: HashMap<String, i32> = HashMap::new();
5178        for i in 0..10 {
5179            addhashnode(&mut h, &format!("k_{}", i), i);
5180        }
5181        assert_eq!(h.len(), 10);
5182        emptyhashtable(&mut h);
5183        assert_eq!(h.len(), 0, "empty must clear all entries");
5184    }
5185
5186    /// c:916 — `printhashtabinfo` for empty table returns non-empty
5187    /// String (must contain at least the table name).
5188    #[test]
5189    fn printhashtabinfo_empty_table_non_empty_output() {
5190        let empty: HashMap<String, String> = HashMap::new();
5191        let r = printhashtabinfo("my_table_name", &empty);
5192        assert!(
5193            !r.is_empty(),
5194            "printhashtabinfo must produce non-empty output even for empty table"
5195        );
5196    }
5197
5198    /// c:97 — `deletehashtable` empties + safe.
5199    #[test]
5200    fn deletehashtable_empties_table() {
5201        let mut h: HashMap<String, i32> = HashMap::new();
5202        addhashnode(&mut h, "k", 1);
5203        deletehashtable(&mut h);
5204        assert!(h.is_empty(), "delete must empty the table");
5205    }
5206
5207    /// c:85 — `newhashtable` returns (String, i32) tuple (compile-time pin).
5208    #[test]
5209    fn newhashtable_returns_tuple_type() {
5210        let _: (String, i32) = newhashtable(0, "test");
5211    }
5212
5213    /// c:954 — printshfuncnode renders a function body with
5214    /// `getpermtext(fd, NULL, 1)`, so `functions f` prints CANONICAL text
5215    /// rather than the source as typed. zshrs keeps raw source for
5216    /// shell-defined functions, so its listing path re-parses and renders
5217    /// through that same deparser; these are the shapes where the layout is
5218    /// an actual decision rather than a passthrough.
5219    ///
5220    /// The `always` case is the one that matters most: the previous
5221    /// hand-rolled canonicalization emitted `print x } always { print y`,
5222    /// which is not merely mis-indented, it no longer parses — and
5223    /// `functions` output is meant to be re-readable by the shell.
5224    ///
5225    /// Pins the deparse itself rather than printshfuncnode's stdout, so it
5226    /// doesn't depend on capturing print! output. Indent 1 matches C, which
5227    /// writes one tab via zoutputtab (c:949) before calling getpermtext.
5228    #[test]
5229    fn function_body_deparses_to_canonical_layout() {
5230        let _g = crate::test_util::global_state_lock();
5231        for (body, want) in [
5232            // `do` gets its own line; the body indents beneath it.
5233            (
5234                "for i in 1 2; do print $i; done",
5235                "for i in 1 2\n\tdo\n\t\tprint $i\n\tdone",
5236            ),
5237            // `(` and `)` break onto their own lines.
5238            ("(print s)", "(\n\t\tprint s\n\t)"),
5239            // taddassign appends a trailing space after the value
5240            // (c:Src/text.c:203-204) and nothing backs it off.
5241            ("g=inner", "g=inner "),
5242            // The shape the emulation broke.
5243            (
5244                "{ print x } always { print y }",
5245                "{\n\t\tprint x\n\t} always {\n\t\tprint y\n\t}",
5246            ),
5247        ] {
5248            let prog = crate::ported::exec::parse_string(body, 1)
5249                .unwrap_or_else(|| panic!("body must parse for the listing path: {body:?}"));
5250            let got = crate::ported::text::getpermtext(Box::new(prog), None, 1);
5251            assert_eq!(got, want, "c:954 deparse of {body:?}");
5252        }
5253    }
5254}