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