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