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