Skip to main content

zsh/ported/
glob.rs

1//! Filename generation (globbing) for zshrs
2//!
3//! Direct port from zsh/Src/glob.c
4//!
5//! Supports:
6//! - Basic glob patterns (*, ?, [...])
7//! - Extended glob patterns (#, ##, ~, ^)
8//! - Recursive globbing (**/*)
9//! - Glob qualifiers (., /, @, etc.)
10//! - Brace expansion ({a,b,c}, {1..10})
11//! - Sorting and filtering matches
12
13use crate::ported::builtin::LASTVAL;
14use crate::ported::options::opt_state_get;
15use crate::ported::pattern::{haswilds, Patprog};
16use crate::ported::signals::unqueue_signals;
17use crate::ported::sort::zstrcmp;
18use crate::ported::string::dyncat;
19use crate::ported::utils::{errflag, init_dirsav, lchdir, restoredir, zerr};
20// `vm_helper` import removed — ShellExecutor reach-in routed through
21// the `crate::ported::exec` accessor wrappers (see memory
22// feedback_no_exec_script_from_ported).
23use crate::ported::lex::untokenize;
24use crate::ported::mem::dupstring;
25use crate::ported::subst::singsub;
26use crate::ported::subst::LinkList;
27use crate::ported::zsh_h::{imatchdata, repldata};
28use crate::ported::zsh_h::{
29    isset, redir, Bnull, Bnullkeep, Dnull, Inang, Meta, Nularg, Outang, Pound, Snull, BAREGLOBQUAL,
30    BRACECCL, CASEGLOB, ERRFLAG_INT, EXTENDEDGLOB, GLOBDOTS, GLOBSTARSHORT, IS_DASH, LISTTYPES,
31    MARKDIRS, MB_METASTRLEN2END, MULTIOS, NULLGLOB, NUMERICGLOBSORT, PAT_NOTEND, PAT_NOTSTART,
32    PP_UNKWN, PREFORK_SINGLE, REDIR_CLOSE, REDIR_ERRWRITE, REDIR_MERGEIN, REDIR_MERGEOUT, SHGLOB,
33    SUB_ALL, SUB_BIND, SUB_DOSUBST, SUB_EIND, SUB_END, SUB_GLOBAL, SUB_LEN, SUB_LIST, SUB_LONG,
34    SUB_MATCH, SUB_REST, SUB_START, SUB_SUBSTR, ZSHTOK_SHGLOB, ZSHTOK_SUBST,
35};
36use crate::ported::ztype_h::imeta;
37use crate::subst::prefork;
38use std::collections::HashSet;
39use std::fs::{self, Metadata};
40use std::os::unix::fs::{MetadataExt, PermissionsExt};
41use std::path::{Component, Path, PathBuf};
42use std::sync::atomic::Ordering;
43use std::time::{SystemTime, UNIX_EPOCH};
44
45/// A glob match with metadata for sorting
46#[derive(Debug, Clone)]
47/// One glob match result.
48/// Port of `struct gmatch` from Src/glob.c — `gmatchcmp()`
49/// (line 936) sorts arrays of these for the `o`/`O` qualifier.
50pub struct gmatch {
51    /// `name` field.
52    pub name: String,
53    /// `path` field.
54    pub path: PathBuf,
55    /// `size` field.
56    pub size: u64,
57    /// `atime` field.
58    pub atime: i64,
59    /// `mtime` field.
60    pub mtime: i64,
61    /// `ctime` field.
62    pub ctime: i64,
63    /// `links` field.
64    pub links: u64,
65    /// `mode` field.
66    pub mode: u32,
67    /// `uid` field.
68    pub uid: u32,
69    /// `gid` field.
70    pub gid: u32,
71    /// `dev` field.
72    pub dev: u64,
73    /// `ino` field.
74    pub ino: u64,
75    // For symlink targets (when following)
76    /// `target_size` field.
77    pub target_size: u64,
78    /// `target_atime` field.
79    pub target_atime: i64,
80    /// `target_mtime` field.
81    pub target_mtime: i64,
82    /// `target_ctime` field.
83    pub target_ctime: i64,
84    /// `target_links` field.
85    pub target_links: u64,
86    // Sub-second timestamp components — port of `long ansec/mnsec/cnsec` +
87    // `long _ansec/_mnsec/_cnsec` in `struct gmatch` (Src/glob.c:63-74).
88    // gmatchcmp tie-breaks equal-second mtimes/atimes/ctimes by these
89    // nanosecond fields (c:992/999/1006/1019/1026/1033) — without them,
90    // files touched <1s apart sort by name rather than mtime.
91    /// `ansec` field. c:64
92    pub ansec: i64,
93    /// `mnsec` field. c:68
94    pub mnsec: i64,
95    /// `cnsec` field. c:72
96    pub cnsec: i64,
97    /// `_ansec` field. c:65
98    pub target_ansec: i64,
99    /// `_mnsec` field. c:69
100    pub target_mnsec: i64,
101    /// `_cnsec` field. c:73
102    pub target_cnsec: i64,
103    // For exec sort strings
104    /// `sort_strings` field.
105    pub sort_strings: Vec<String>,
106}
107
108impl GlobOptSnapshot {
109    /// Read every glob-relevant option from the canonical live store
110    /// (`opt_state_get`) once. Returns a self-contained snapshot.
111    pub fn capture() -> Self {
112        Self {
113            bareglobqual: isset(BAREGLOBQUAL),
114            braceccl: isset(BRACECCL),
115            caseglob: isset(CASEGLOB),
116            extendedglob: isset(EXTENDEDGLOB),
117            globdots: isset(GLOBDOTS),
118            globstarshort: isset(GLOBSTARSHORT),
119            listtypes: isset(LISTTYPES),
120            markdirs: isset(MARKDIRS),
121            nullglob: isset(NULLGLOB),
122            numericglobsort: isset(NUMERICGLOBSORT),
123        }
124    }
125}
126
127thread_local! {
128    /// Thread-local glob option cache. `Some(snap)` while a glob()
129    /// call is in flight on this thread; `None` otherwise (reads
130    /// fall back to the live `isset()`).
131    static GLOB_OPTS_TLS: std::cell::RefCell<Option<GlobOptSnapshot>> =
132        const { std::cell::RefCell::new(None) };
133}
134
135// =====================================================================
136// GS_* — sort-specifier flag bits — `Src/glob.c:77-94`. The `glob -O`
137// + `glob -o` sort-spec parser stuffs these bits into the per-glob
138// sortspec struct so the qsort comparator picks the right key.
139// =====================================================================
140
141/// Port of `GS_NAME` from `Src/glob.c:77`. Sort by filename.
142pub const GS_NAME: i32 = 1; // c:77
143
144impl Drop for GlobOptsGuard {
145    fn drop(&mut self) {
146        if self.populated {
147            GLOB_OPTS_TLS.with_borrow_mut(|g| *g = None);
148        }
149    }
150}
151/// Port of `GS_DEPTH` from `Src/glob.c:78`. Sort by directory depth.
152pub const GS_DEPTH: i32 = 2; // c:78
153/// Port of `GS_EXEC` from `Src/glob.c:79`. Sort via external function.
154pub const GS_EXEC: i32 = 4; // c:79
155
156/// Port of `GS_SHIFT_BASE` from `Src/glob.c:81`. Bit position where
157/// the size/mtime/atime/ctime/links sort keys live.
158pub const GS_SHIFT_BASE: i32 = 8; // c:81
159
160/// Port of `GS_SIZE` from `Src/glob.c:83`. Sort by file size.
161pub const GS_SIZE: i32 = GS_SHIFT_BASE; // c:83
162/// Port of `GS_ATIME` from `Src/glob.c:84`. Sort by access time.
163pub const GS_ATIME: i32 = GS_SHIFT_BASE << 1; // c:84
164/// Port of `GS_MTIME` from `Src/glob.c:85`. Sort by modification time.
165pub const GS_MTIME: i32 = GS_SHIFT_BASE << 2; // c:85
166/// Port of `GS_CTIME` from `Src/glob.c:86`. Sort by inode-change time.
167pub const GS_CTIME: i32 = GS_SHIFT_BASE << 3; // c:86
168/// Port of `GS_LINKS` from `Src/glob.c:87`. Sort by hard-link count.
169pub const GS_LINKS: i32 = GS_SHIFT_BASE << 4; // c:87
170
171/// Port of `GS_SHIFT` from `Src/glob.c:89`. Bit-shift offset where the
172/// reverse-direction variants of the size/atime/mtime/ctime/links
173/// sort flags live.
174pub const GS_SHIFT: i32 = 5; // c:89
175
176/// Port of `GS__SIZE`  from `Src/glob.c:90` (reverse-sort variant).
177pub const GS__SIZE: i32 = GS_SIZE << GS_SHIFT; // c:90
178/// Port of `GS__ATIME` from `Src/glob.c:91`.
179pub const GS__ATIME: i32 = GS_ATIME << GS_SHIFT; // c:91
180/// Port of `GS__MTIME` from `Src/glob.c:92`.
181pub const GS__MTIME: i32 = GS_MTIME << GS_SHIFT; // c:92
182/// Port of `GS__CTIME` from `Src/glob.c:93`.
183pub const GS__CTIME: i32 = GS_CTIME << GS_SHIFT; // c:93
184/// Port of `GS__LINKS` from `Src/glob.c:94`.
185pub const GS__LINKS: i32 = GS_LINKS << GS_SHIFT; // c:94
186
187/// Port of `GS_DESC` from `Src/glob.c:96`. Descending-order toggle.
188pub const GS_DESC: i32 = GS_SHIFT_BASE << (2 * GS_SHIFT); // c:96
189/// Port of `GS_NONE` from `Src/glob.c:97`. Marker for no-sort spec.
190pub const GS_NONE: i32 = GS_SHIFT_BASE << (2 * GS_SHIFT + 1); // c:97
191
192/// Port of `GS_NORMAL` from `Src/glob.c:99`. Forward-direction sort
193/// keys (excluding NAME/DEPTH/EXEC and the reverse variants).
194pub const GS_NORMAL: i32 = GS_SIZE | GS_ATIME | GS_MTIME | GS_CTIME | GS_LINKS; // c:99
195/// Port of `GS_LINKED` from `Src/glob.c:100`. Reverse-direction
196/// (linked) sort keys.
197pub const GS_LINKED: i32 = GS_NORMAL << GS_SHIFT; // c:100
198
199// =====================================================================
200// TT_* — time + size unit selectors. Two parallel namespaces using
201// the same numeric values.
202// =====================================================================
203
204/// Port of `TT_DAYS` from `Src/glob.c:121`. Time qualifier in days.
205pub const TT_DAYS: i32 = 0; // c:121
206/// Port of `TT_HOURS` from `Src/glob.c:122`. Time qualifier in hours.
207pub const TT_HOURS: i32 = 1; // c:122
208/// Port of `TT_MINS` from `Src/glob.c:123`. Time qualifier in minutes.
209pub const TT_MINS: i32 = 2; // c:123
210/// Port of `TT_WEEKS` from `Src/glob.c:124`. Time qualifier in weeks.
211pub const TT_WEEKS: i32 = 3; // c:124
212/// Port of `TT_MONTHS` from `Src/glob.c:125`. Time qualifier in months.
213pub const TT_MONTHS: i32 = 4; // c:125
214/// Port of `TT_SECONDS` from `Src/glob.c:126`. Time qualifier in seconds.
215pub const TT_SECONDS: i32 = 5; // c:126
216
217/// Port of `TT_BYTES` from `Src/glob.c:128`. Size qualifier in bytes.
218pub const TT_BYTES: i32 = 0; // c:128
219/// Port of `TT_POSIX_BLOCKS` from `Src/glob.c:129`. Size qualifier
220/// in POSIX 512-byte blocks (the `b` glob qualifier suffix).
221pub const TT_POSIX_BLOCKS: i32 = 1; // c:129
222/// Port of `TT_KILOBYTES` from `Src/glob.c:130`.
223pub const TT_KILOBYTES: i32 = 2; // c:130
224/// Port of `TT_MEGABYTES` from `Src/glob.c:131`.
225pub const TT_MEGABYTES: i32 = 3; // c:131
226/// Port of `TT_GIGABYTES` from `Src/glob.c:132`.
227pub const TT_GIGABYTES: i32 = 4; // c:132
228/// Port of `TT_TERABYTES` from `Src/glob.c:133`.
229pub const TT_TERABYTES: i32 = 5; // c:133
230
231/// Port of `MAX_SORTS` from `Src/glob.c:164`. Maximum sort-spec keys
232/// per glob (`glob -O 'reverse(name).size'` style).
233pub const MAX_SORTS: usize = 12; // c:164
234
235/// Main glob state — port of `struct globdata` from Src/glob.c:168.
236/// C zsh has a single file-static `static struct globdata curglobdata;`
237/// (glob.c:196) and accesses fields through a wall of #define macros
238/// (`matchsz`/`matchct`/`pathbuf`/`pathpos`/`quals`/...) that all
239/// resolve to `curglobdata.gd_*`. The Rust port collapses the
240/// `gd_matchsz`/`gd_matchct`/`gd_matchbuf`/`gd_matchptr` quartet into
241/// `matches: Vec<GlobMatch>` (the natural Rust shape) and folds the
242/// `gd_gf_*` glob-flag bag into `options: GlobOptions`, but the
243/// 1:1 correspondence to `struct globdata` is otherwise faithful.
244// struct to easily save/restore current state                              // c:166
245/// `globdata` — see fields for layout.
246#[allow(non_camel_case_types)]
247pub struct globdata {
248    // c:168
249    /// `matches` field.
250    pub matches: Vec<gmatch>,
251    /// `qualifiers` field.
252    pub qualifiers: Option<qualifier_set>,
253    /// c:206 — `quals` (`curglobdata.gd_quals`): the `struct qual` list
254    /// that `insert()` walks per candidate file. Arena-backed port of
255    /// the C pointer list; `None` when the glob has no qualifiers.
256    pub quals: Option<QualArena>,
257    pub pathbuf: String,                    // c:170 gd_pathbuf
258    pub pathpos: usize,                     // c:169 gd_pathpos
259    pub matchct: i32,                       // c:173 gd_matchct
260    pub pathbufsz: usize,                   // c:174 gd_pathbufsz
261    pub pathbufcwd: i32,                    // c:175 gd_pathbufcwd
262    pub gf_nullglob: i32,                   // c:186 gd_gf_nullglob
263    pub gf_markdirs: i32,                   // c:186 gd_gf_markdirs
264    pub gf_noglobdots: i32,                 // c:186 gd_gf_noglobdots
265    pub gf_listtypes: i32,                  // c:186 gd_gf_listtypes
266    pub gf_pre_words: Option<Vec<String>>,  // c:190 gd_gf_pre_words
267    pub gf_post_words: Option<Vec<String>>, // c:190 gd_gf_post_words
268}
269
270// c:197 — `static struct globdata curglobdata;`
271// C's single file-static glob state; macros at c:199-222 redirect
272// `pathbufsz`, `gf_noglobdots`, etc. to `curglobdata.gd_*`. Rust
273// mirror — accessed via lock for thread safety; C is single-threaded.
274/// `CURGLOBDATA` static.
275pub static CURGLOBDATA: std::sync::Mutex<globdata> = std::sync::Mutex::new(globdata {
276    matches: Vec::new(),
277    qualifiers: None,
278    quals: None,
279    pathbuf: String::new(),
280    pathpos: 0,
281    matchct: 0,
282    pathbufsz: 0,
283    pathbufcwd: 0,
284    gf_nullglob: 0,
285    gf_markdirs: 0,
286    gf_noglobdots: 0,
287    gf_listtypes: 0,
288    gf_pre_words: None,
289    gf_post_words: None,
290});
291
292/// Port of `int badcshglob` from `Src/glob.c:103`. Tracks csh-glob
293/// diagnostic state across a single command line: bit 1 = "at
294/// least one expansion failed" (CSHNULLGLOB and no matches),
295/// bit 2 = "at least one expansion produced output". `globlist`
296/// resets to 0 at entry, ORs the bits as each `zglob` runs, then
297/// emits "no match" iff the final value is 1 (some failed, none
298/// succeeded). Used to make `*.nope *.ok` succeed without
299/// diagnostic, but `*.nope alone` error out under CSHNULLGLOB.
300pub static BADCSHGLOB: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0); // c:103
301
302/// Port of `static char **inserts;` from `Src/glob.c:340`. Set by
303/// `qualsheval` (c:3927-3937) from the `$reply` array / `$REPLY` scalar
304/// after an `(e:…:)` / `(+…)` qualifier eval; `insert()` then emits one
305/// match per element instead of the original name (c:428). `None` ==
306/// C's `inserts == NULL` (emit the original name once). Reset to `None`
307/// before each per-file qualifier walk (C: `inserts = NULL` at insert()
308/// entry, c:353).
309pub static INSERTS: std::sync::Mutex<Option<Vec<String>>> = std::sync::Mutex::new(None);
310
311/// Port of `struct complist` from `Src/glob.c:252`.
312/// C body:
313/// ```c
314/// struct complist {
315///     Complist next;
316///     Patprog  pat;
317///     int      closure;  /* 1 if this is a (foo/)# */
318///     int      follow;   /* 1 to go thru symlinks  */
319/// };
320/// ```
321#[allow(non_camel_case_types)]
322pub struct complist {
323    // c:252
324    pub next: Option<Box<complist>>,          // c:253
325    pub pat: crate::ported::pattern::Patprog, // c:254
326    pub closure: i32,                         // c:255
327    pub follow: i32,                          // c:256
328}
329
330/// Add path component (from glob.c addpath lines 263-274)
331/// Append a path component to a glob path buffer.
332/// Port of `addpath(char *s, int l)` from Src/glob.c:265.
333pub fn addpath(s: &mut String, l: &str) {
334    // c:265
335    s.push_str(l);
336    if !s.ends_with('/') {
337        s.push('/');
338    }
339}
340
341/// PARTIAL port of `statfullpath(const char *s, struct stat *st, int l)` from
342/// `Src/glob.c:283` — NOT yet faithful, and currently has NO callers (the live
343/// qualifier matcher inlines `fs::metadata`/`symlink_metadata` on the already-
344/// full path). Two gaps remain, both blocked on the glob path-state flow:
345///   1. C prepends the global `pathbuf[pathbufcwd..pathpos]` (the accumulated
346///      glob directory) to `s`; this port omits it. A faithful version must
347///      take the pathbuf as a parameter — it cannot read the `CURGLOBDATA`
348///      singleton because the matcher already holds that lock (deadlock).
349///   2. C's `st` is the OUTPUT `struct stat *`; the second arg here is a string
350///      suffix, which is not the C contract.
351/// The `l` flag now matches C: `l ? lstat : stat` (l → `symlink_metadata`).
352/// Do not wire this in until gap #1 is resolved.
353pub fn statfullpath(s: &str, st: &str, l: bool) -> Option<Metadata> {
354    // c:283
355    let full = if st.is_empty() {
356        if s.is_empty() {
357            ".".to_string()
358        } else {
359            s.to_string()
360        }
361    } else {
362        format!("{}{}", s, st)
363    };
364
365    // c:308 — `return l ? lstat(buf, st) : stat(buf, st);`
366    if l {
367        fs::symlink_metadata(&full).ok() // l → lstat
368    } else {
369        fs::metadata(&full).ok() // !l → stat (follows symlinks)
370    }
371}
372// END moved-from-exec-rs (free ported)
373
374// ===========================================================
375// Direct ports of static helpers from Src/glob.c not yet covered
376// above. The Rust glob engine (`crate::glob`) re-implements the
377// lexer + matcher; these free-fn entries satisfy ABI/name parity
378// for the drift gate.
379// ===========================================================
380
381/// Port of `scanner(Complist q, int shortcircuit)` from `Src/glob.c:500`.
382///
383/// Walks the `complist` path-component chain built by `parsecomplist`,
384/// descending the directory tree one component per node. `q.closure`
385/// (set for `**` and `(dir/)#` / `(dir/)##`) drives the any-depth match:
386/// the zero-directory case scans `q.next` directly, then each matching
387/// subdirectory is re-scanned against `(q.closure) ? q : q.next` — i.e.
388/// the same node `q` repeats for `**`, matching at every depth.
389///
390/// Deviations from C, all pre-existing in this engine — NOT introduced
391/// by this port:
392///   * directory entries come from `fs::read_dir` + `zreaddir` (the
393///     ported `zreaddir`, glob.c:5217) instead of C `opendir`; same
394///     skip-`.`/`..` behaviour, and names are real (non-metafied) so no
395///     `unmeta` round-trip is needed;
396///   * the approximate-match error-reduction block (`forceerrs`/
397///     `errsfound`, glob.c:619-639) is omitted — the Rust matcher keeps
398///     no per-match error count;
399///   * `glob_pre`/`glob_suf` (ZLE prefix/suffix) filtering is omitted.
400/// Leading-dot files are filtered by `pattry`'s `PAT_NOGLD` check
401/// (pattern.rs:2890); `globdata_glob` sets `gf_noglobdots` so
402/// `parsecomplist` compiles the flag in (faithful to C's `glob()`).
403///
404/// The `in_closure_repeat` parameter is Rust-only: it carries C's
405/// `q->closure = 1` mutation across the recursion. A `(dir/)##` node
406/// (closure==2) requires at least one directory, so the zero-directory
407/// match is skipped only the FIRST time the node is entered; once we have
408/// descended one closure level it behaves like `(dir/)#` (zero-or-more).
409/// `q` is shared (`&complist`), so C's in-place field mutation is modelled
410/// by this argument instead. Callers pass `false`.
411fn scanner(state: &mut globdata, q: Option<&complist>, shortcircuit: i32, in_closure_repeat: bool) {
412    use std::sync::atomic::Ordering;
413    // c:506 — `if (!q || errflag) return;`
414    let Some(q) = q else { return };
415    if errflag.load(Ordering::SeqCst) & crate::ported::zsh_h::ERRFLAG_ERROR != 0 {
416        return;
417    }
418    let pbcwdsav = state.pathbufcwd; // c:503
419    let mut ds = init_dirsav(); // c:508
420    let path_max = crate::ported::zsh_system_h::PATH_MAX;
421
422    // c:510-518 — closure preamble: try zero directories via q.next first
423    // (skipped for `(dir/)##` until at least one directory is consumed).
424    let closure = q.closure;
425    if closure != 0 {
426        let skip_zero = q.closure == 2 && !in_closure_repeat;
427        if !skip_zero {
428            scanner(state, q.next.as_deref(), shortcircuit, false); // c:515
429            if shortcircuit != 0 && shortcircuit == state.matchct {
430                return;
431            }
432        }
433    }
434
435    let p = &q.pat; // c:519
436    if (p.0.flags & crate::ported::zsh_h::PAT_PURES as i32) != 0 {
437        // c:521-565 — pure literal section: append to the path (intermediate)
438        // or emit (final); no directory scan.
439        let start = p.0.startoff as usize;
440        let l = p.0.patmlen as usize;
441        let str_lit = String::from_utf8_lossy(&p.1[start..start + l]).into_owned();
442
443        // c:524-536 — PATH_MAX guard.
444        if l + (l == 0) as usize + state.pathpos - state.pathbufcwd as usize >= path_max {
445            if l >= path_max {
446                return;
447            }
448            let anchor = state
449                .pathbuf
450                .get(state.pathbufcwd as usize..)
451                .unwrap_or("")
452                .to_string();
453            let err = lchdir(&anchor, Some(&mut ds), 0);
454            if err == -1 {
455                return;
456            }
457            if err != 0 {
458                zerr("current directory lost during glob");
459                return;
460            }
461            state.pathbufcwd = state.pathpos as i32;
462        }
463
464        if q.next.is_some() {
465            // c:539-560 — not the last section: add to path, recurse.
466            let oppos = state.pathpos;
467            if errflag.load(Ordering::SeqCst) & crate::ported::zsh_h::ERRFLAG_ERROR == 0 {
468                // c:543-552 — `.`/`..` handling inside a closure walk.
469                let mut add = true;
470                if closure != 0 && !state.pathbuf.is_empty() {
471                    if str_lit == "." {
472                        add = false; // c:546
473                    } else if str_lit == ".." {
474                        // c:547-551 — drop `..` that would escape the root.
475                        use std::os::unix::fs::MetadataExt;
476                        let cur: &str = &state.pathbuf;
477                        add = match (fs::metadata("/"), fs::metadata(cur)) {
478                            (Ok(r), Ok(c)) => r.ino() != c.ino() || r.dev() != c.dev(),
479                            _ => true,
480                        };
481                    }
482                }
483                if add {
484                    addpath(&mut state.pathbuf, &str_lit); // c:553
485                    state.pathpos = state.pathbuf.len();
486                    // c:554 — closure: only recurse when the new path is a
487                    // real directory (`!statfullpath("", NULL, 1)`).
488                    let recurse = closure == 0
489                        || fs::metadata(state.pathbuf.as_str())
490                            .map(|m| m.is_dir())
491                            .unwrap_or(false);
492                    if recurse {
493                        scanner(
494                            state,
495                            if closure != 0 {
496                                Some(q)
497                            } else {
498                                q.next.as_deref()
499                            },
500                            shortcircuit,
501                            closure != 0,
502                        ); // c:555
503                        if shortcircuit != 0 && shortcircuit == state.matchct {
504                            return;
505                        }
506                    }
507                    state.pathbuf.truncate(oppos); // c:558
508                    state.pathpos = oppos;
509                }
510            }
511        } else {
512            // c:561-564 — last section: emit the literal.
513            let full = std::path::Path::new(&state.pathbuf).join(&str_lit);
514            insert(state, &full, 0);
515            if shortcircuit != 0 && shortcircuit == state.matchct {
516                return;
517            }
518        }
519    } else {
520        // c:567-685 — pattern-matched section: scan the directory.
521        let base = {
522            let from_cwd = state.pathbuf.get(state.pathbufcwd as usize..).unwrap_or("");
523            if from_cwd.is_empty() {
524                ".".to_string()
525            } else {
526                from_cwd.to_string()
527            }
528        };
529        let dirs = q.next.is_some(); // c:570
530        let mut rd = match fs::read_dir(&base) {
531            Ok(d) => d,
532            Err(_) => return, // c:573 — opendir == NULL
533        };
534        // c:572-573 — collect matching subdirs, descend AFTER the dir
535        // handle is dropped (C buffers them in `subdirs`). `zreaddir(lock, 1)`
536        // ALWAYS skips `.`/`..` (c:5217); leading-dot files are admitted and
537        // then accepted/rejected by `pattry`'s PAT_NOGLD rule, so dotglob /
538        // `(D)` is handled by the compiled flag, never by re-including
539        // `.`/`..` here (that previously leaked `.`/`..` under dotglob).
540        let mut subdirs: Vec<String> = Vec::new();
541        while let Some(name) = crate::ported::utils::zreaddir(&mut rd, 1) {
542            if errflag.load(Ordering::SeqCst) & crate::ported::zsh_h::ERRFLAG_ERROR != 0 {
543                break;
544            }
545            if !crate::ported::pattern::pattry(p, &name) {
546                continue; // c:583
547            }
548            // c:585-597 — PATH_MAX lchdir bookkeeping.
549            if pbcwdsav == state.pathbufcwd
550                && name.len() + state.pathpos - state.pathbufcwd as usize >= path_max
551            {
552                let anchor = state
553                    .pathbuf
554                    .get(state.pathbufcwd as usize..)
555                    .unwrap_or("")
556                    .to_string();
557                let err = lchdir(&anchor, Some(&mut ds), 0);
558                if err == -1 {
559                    break;
560                }
561                if err != 0 {
562                    zerr("current directory lost during glob");
563                    break;
564                }
565                state.pathbufcwd = state.pathpos as i32;
566            }
567            if dirs {
568                // c:642-655 — closure: only descend real directories.
569                if closure != 0 {
570                    let probe = std::path::Path::new(&base).join(&name);
571                    let md = if q.follow != 0 {
572                        fs::metadata(&probe)
573                    } else {
574                        fs::symlink_metadata(&probe)
575                    };
576                    match md {
577                        Ok(m) if m.is_dir() => {}
578                        _ => continue,
579                    }
580                }
581                subdirs.push(name); // c:657-663
582            } else {
583                // c:665-670 — last component: emit.
584                let full = std::path::Path::new(&base).join(&name);
585                insert(state, &full, 1);
586                if shortcircuit != 0 && shortcircuit == state.matchct {
587                    return;
588                }
589            }
590        }
591        drop(rd); // c:672 — closedir
592                  // c:674-684 — descend into each collected subdir.
593        if !subdirs.is_empty() {
594            let oppos = state.pathpos;
595            for name in subdirs {
596                addpath(&mut state.pathbuf, &name);
597                state.pathpos = state.pathbuf.len();
598                scanner(
599                    state,
600                    if closure != 0 {
601                        Some(q)
602                    } else {
603                        q.next.as_deref()
604                    },
605                    shortcircuit,
606                    closure != 0,
607                ); // c:681
608                if shortcircuit != 0 && shortcircuit == state.matchct {
609                    return;
610                }
611                state.pathbuf.truncate(oppos);
612                state.pathpos = oppos;
613            }
614        }
615    }
616
617    // c:687-694 — restore cwd if we lchdir'd partway through.
618    if pbcwdsav < state.pathbufcwd {
619        if restoredir(&mut ds) != 0 {
620            zerr("current directory lost during glob"); // c:689
621        }
622        state.pathbufcwd = pbcwdsav;
623    }
624}
625
626/* This function tokenizes a zsh glob pattern */
627// c:706
628/// Port of `parsecomplist(char *instr)` from Src/glob.c:710.
629/// Tokenize a zsh glob path pattern into a `Complist` of path
630/// components, recursively. Returns `None` and sets `errflag |=
631/// ERRFLAG_ERROR` on parse failure. Reads `gf_noglobdots` from
632/// `CURGLOBDATA` (the curglobdata singleton at c:197).
633pub fn parsecomplist(instr: &str) -> Option<Box<complist>> {
634    // c:710
635    let p1: crate::ported::pattern::Patprog; // c:712
636    let l1: Box<complist>; // c:713
637                           // c:714 `char *str;` — used as the skipparens cursor (parens branch).
638                           // c:715 — compflags depend on gf_noglobdots from curglobdata.
639    let gf_noglobdots: i32 = CURGLOBDATA
640        .lock()
641        .unwrap_or_else(|e| e.into_inner())
642        .gf_noglobdots; // c:214 macro / c:186 field
643    let compflags: i32 = if gf_noglobdots != 0 {
644        crate::ported::zsh_h::PAT_FILE | crate::ported::zsh_h::PAT_NOGLD
645    } else {
646        crate::ported::zsh_h::PAT_FILE
647    }; // c:715
648
649    let chars: Vec<char> = instr.chars().collect();
650    if chars.len() >= 2
651        && chars[0] == crate::ported::zsh_h::Star
652        && chars[1] == crate::ported::zsh_h::Star
653    {
654        // c:717
655        let mut shortglob: i32 = 0; // c:718
656        let cond_a = chars.get(2) == Some(&'/'); // c:719
657        let cond_b =
658            chars.get(2) == Some(&crate::ported::zsh_h::Star) && chars.get(3) == Some(&'/'); // c:719
659                                                                                             // c:719-720 — `instr[2] == '/' || (instr[2] == Star && instr[3] == '/')
660                                                                                             // || (shortglob = isset(GLOBSTARSHORT))`. C's `||` SHORT-CIRCUITS:
661                                                                                             // the `shortglob = isset(GLOBSTARSHORT)` assignment runs ONLY when
662                                                                                             // `**` is not explicitly followed by `/`. Mirror that with a lazy
663                                                                                             // `||` so cond_a/cond_b keep `shortglob == 0` — an eager
664                                                                                             // `let cond_c = { shortglob = … }` set it unconditionally, making
665                                                                                             // `**/x` advance by 1 (`shortglob ? 1 : 3`) instead of 3, leaving a
666                                                                                             // stray `*` that collapsed `**/` to a single directory level.
667        let enter = cond_a || cond_b || {
668            shortglob = if crate::ported::zsh_h::isset(crate::ported::zsh_h::GLOBSTARSHORT) {
669                1
670            } else {
671                0
672            };
673            shortglob != 0
674        }; // c:720
675        if enter {
676            /* Match any number of directories. */
677            // c:721
678            /* with three stars, follow symbolic links */                    // c:724
679            let follow: i32 = if chars.get(2) == Some(&crate::ported::zsh_h::Star) {
680                1
681            } else {
682                0
683            }; // c:725
684               /*
685                * With GLOBSTARSHORT, leave a star in place for the
686                * pattern inside the directory.
687                */                                                              // c:726-729
688            let advance: usize = (if shortglob != 0 { 1 } else { 3 }) + follow as usize; // c:730
689
690            /* Now get the next path component if there is one. */
691            // c:732
692            let next_instr: String = chars[advance..].iter().collect();
693            // c:733 — `l1 = (Complist) zhalloc(sizeof *l1);`
694            let next_l = parsecomplist(&next_instr); // c:734
695            if next_l.is_none() {
696                crate::ported::utils::errflag.fetch_or(
697                    crate::ported::zsh_h::ERRFLAG_ERROR,
698                    std::sync::atomic::Ordering::Relaxed,
699                ); // c:735
700                return None; // c:736
701            }
702            let pat = crate::ported::pattern::patcompile(
703                "",
704                compflags | crate::ported::zsh_h::PAT_ANY,
705                None,
706            )?; // c:738
707            l1 = Box::new(complist {
708                next: next_l,
709                pat,
710                closure: 1, // c:739
711                follow,     // c:740
712            });
713            return Some(l1); // c:741
714        }
715    }
716
717    /* Parse repeated directories such as (dir/)# and (dir/)## */
718    // c:745
719    let zpc = crate::ported::pattern::zpc_special
720        .lock()
721        .unwrap_or_else(|e| e.into_inner());
722    let inpar_c = zpc[crate::ported::zsh_h::ZPC_INPAR as usize] as char;
723    let hash_c = zpc[crate::ported::zsh_h::ZPC_HASH as usize] as char;
724    drop(zpc);
725
726    // c:746-748 — `if (*(str = instr) == zpc_special[ZPC_INPAR] &&
727    //               !skipparens(Inpar, Outpar, (char **)&str) &&
728    //               *str == zpc_special[ZPC_HASH] && str[-2] == '/')`.
729    // Routed through the canonical `skipparens` port for caller-coverage
730    // parity with C — was previously inlined as a divergent depth-walk
731    // because the old Rust signature returned usize. The C-faithful
732    // `skipparens(open, close, &mut &str)` now matches `int skipparens
733    // (char inpar, char outpar, char **s)` at utils.c:2409.
734    let instr_chars: String = chars.iter().collect();
735    let inpar_byte = crate::ported::zsh_h::Inpar as u32;
736    let outpar_byte = crate::ported::zsh_h::Outpar as u32;
737    let mut cursor: &str = &instr_chars;
738    let skip_level: i32 = crate::ported::utils::skipparens(
739        char::from_u32(inpar_byte).unwrap_or('('),
740        char::from_u32(outpar_byte).unwrap_or(')'),
741        &mut cursor,
742    );
743    let str_after_parens: Option<usize> = if chars.first() == Some(&inpar_c) {
744        Some(instr_chars.chars().count() - cursor.chars().count())
745    } else {
746        None
747    };
748    let parens_balanced = chars.first() == Some(&inpar_c) && skip_level == 0; // c:746-747 `!skipparens(...)`
749    let after_paren_idx = str_after_parens.unwrap_or(0);
750    let str_at_hash = parens_balanced && chars.get(after_paren_idx) == Some(&hash_c); // c:748 `*str == Pound`
751                                                                                      // c:748 `str[-2] == '/'` — `str` is past `)`, so `str[-2]` is char
752                                                                                      // before `)`. In chars, that's `chars[after_paren_idx - 2]`.
753    let preceded_by_slash =
754        parens_balanced && after_paren_idx >= 2 && chars.get(after_paren_idx - 2) == Some(&'/');
755
756    if parens_balanced && str_at_hash && preceded_by_slash {
757        // c:749 — `instr++;`
758        let mut cursor: String = chars[1..].iter().collect();
759        let mut endexp = String::new();
760        let p1_opt = crate::ported::pattern::patcompile(&cursor, compflags, Some(&mut endexp)); // c:750
761        if p1_opt.is_none() {
762            return None; // c:751
763        }
764        let p1_real = p1_opt.unwrap();
765        cursor = endexp; // C: `instr` advanced past compiled pattern
766        let c2: Vec<char> = cursor.chars().collect();
767        if c2.first() == Some(&'/')
768            && c2.get(1) == Some(&crate::ported::zsh_h::Outpar)
769            && c2.get(2) == Some(&crate::ported::zsh_h::Pound)
770        {
771            // c:752
772            let mut pdflag: i32 = 0; // c:753
773            let mut adv = 3; // c:755
774            if c2.get(3) == Some(&crate::ported::zsh_h::Pound) {
775                pdflag = 1; // c:757
776                adv = 4; // c:758
777            }
778            let next_instr: String = c2[adv..].iter().collect();
779            // c:760-761 — `l1 = (Complist) zhalloc(...); l1->pat = p1;`
780            /* special case (/)# to avoid infinite recursion */              // c:762
781            // c:763 — `(*((char *)p1 + p1->startoff)) ? 1 + pdflag : 0`
782            let pat_nonempty = !p1_real.1.is_empty()
783                && p1_real
784                    .1
785                    .get(p1_real.0.startoff as usize)
786                    .copied()
787                    .unwrap_or(0)
788                    != 0;
789            let closure = if pat_nonempty { 1 + pdflag } else { 0 };
790            let next_l = parsecomplist(&next_instr); // c:765
791                                                     // c:766 — `return (l1->pat) ? l1 : NULL;`
792            l1 = Box::new(complist {
793                next: next_l,
794                pat: p1_real,
795                closure,
796                follow: 0, // c:764
797            });
798            return Some(l1);
799        }
800    } else {
801        /* parse single path component */
802        // c:769
803        let mut endexp = String::new();
804        let p1_opt = crate::ported::pattern::patcompile(
805            instr,
806            compflags | crate::ported::zsh_h::PAT_FILET,
807            Some(&mut endexp),
808        ); // c:770
809        if p1_opt.is_none() {
810            return None; // c:771
811        }
812        p1 = p1_opt.unwrap();
813        let cursor: Vec<char> = endexp.chars().collect();
814        /* then do the remaining path components */
815        // c:772
816        let head = cursor.first().copied();
817        if head == Some('/') || head.is_none() {
818            // c:773
819            let ef: i32 = if head == Some('/') { 1 } else { 0 }; // c:774
820            let next_l: Option<Box<complist>> = if ef != 0 {
821                let rest: String = cursor[1..].iter().collect();
822                parsecomplist(&rest) // c:779
823            } else {
824                None
825            };
826            // c:780 — `return (ef && !l1->next) ? NULL : l1;`
827            if ef != 0 && next_l.is_none() {
828                return None;
829            }
830            l1 = Box::new(complist {
831                next: next_l,
832                pat: p1,
833                closure: 0, // c:778
834                follow: 0,
835            });
836            return Some(l1);
837        }
838    }
839    crate::ported::utils::errflag.fetch_or(
840        crate::ported::zsh_h::ERRFLAG_ERROR,
841        std::sync::atomic::Ordering::Relaxed,
842    ); // c:783
843    None // c:784
844}
845
846/* turn a string into a Complist struct:  this has path components */
847// c:787
848/// Port of `parsepat(char *str)` from Src/glob.c:791.
849/// Top-level entry: strip leading `(#...)` flag block via
850/// `patgetglobflags`, then initialise `pathbuf`/`pathpos` per the
851/// pattern's absolute-vs-relative head, then dispatch to
852/// `parsecomplist`. Mutates `CURGLOBDATA.{pathbuf,pathpos,pathbufsz}`.
853pub fn parsepat(s: &str) -> Option<Box<complist>> {
854    // c:791
855    // c:793 `long assert; int ignore;` — captured into patgetglobflags result
856    crate::ported::pattern::patcompstart(); // c:796
857    let chars: Vec<char> = s.chars().collect();
858    let zpc = crate::ported::pattern::zpc_special
859        .lock()
860        .unwrap_or_else(|e| e.into_inner());
861    let inpar_c = zpc[crate::ported::zsh_h::ZPC_INPAR as usize] as char;
862    let hash_c = zpc[crate::ported::zsh_h::ZPC_HASH as usize] as char;
863    let ksh_at_c = zpc[crate::ported::zsh_h::ZPC_KSH_AT as usize] as char;
864    drop(zpc);
865
866    /*
867     * Check for initial globbing flags, so that they don't form
868     * a bogus path component.
869     */
870    // c:797-800
871    let mut cursor: String = s.to_string();
872    let first_is_inpar_hash = chars.first() == Some(&inpar_c) && chars.get(1) == Some(&hash_c); // c:801
873    let first_is_ksh_at_inpar_hash = chars.first() == Some(&ksh_at_c)
874        && chars.get(1) == Some(&crate::ported::zsh_h::Inpar)
875        && chars.get(2) == Some(&hash_c); // c:802-803
876    if first_is_inpar_hash || first_is_ksh_at_inpar_hash {
877        let skip = if chars.first() == Some(&crate::ported::zsh_h::Inpar) {
878            2
879        } else {
880            3
881        }; // c:804
882        cursor = chars[skip..].iter().collect();
883        let flag_result = crate::ported::pattern::patgetglobflags(&cursor); // c:805
884        if flag_result.is_none() {
885            return None; // c:806
886        }
887        let (_bits, _assertp, consumed) = flag_result.unwrap();
888        cursor = cursor[consumed..].to_string();
889    }
890
891    /* Now there is no (#X) in front, we can check the path. */
892    // c:809
893    {
894        let mut gd = CURGLOBDATA.lock().unwrap_or_else(|e| e.into_inner());
895        // c:810-811 — `if (!pathbuf) pathbuf = zalloc(pathbufsz = PATH_MAX+1);`
896        if gd.pathbuf.capacity() == 0 {
897            gd.pathbufsz = libc::PATH_MAX as usize + 1;
898            gd.pathbuf = String::with_capacity(gd.pathbufsz);
899        }
900        // c:812 — `DPUTS(pathbufcwd, "BUG: glob changed directory");`
901        debug_assert!(gd.pathbufcwd == 0, "BUG: glob changed directory");
902        if cursor.starts_with('/') {
903            // c:813
904            /* pattern has absolute path */
905            cursor = cursor[1..].to_string(); // c:814 `str++;`
906            gd.pathbuf.clear();
907            gd.pathbuf.push('/'); // c:815 `pathbuf[0] = '/';`
908            gd.pathpos = 1; // c:816 `pathbuf[pathpos = 1] = '\0';`
909        } else {
910            /* pattern is relative to pwd */
911            // c:817
912            gd.pathbuf.clear(); // c:818 `pathbuf[pathpos = 0] = '\0';`
913            gd.pathpos = 0;
914        }
915    }
916
917    parsecomplist(&cursor) // c:820
918}
919
920/// Parse qualifier (from glob.c qgetnum)
921/// Parse a numeric glob-qualifier argument.
922/// Port of `qgetnum(char **s)` from Src/glob.c:827.
923pub fn qgetnum(s: &str) -> Option<(i64, &str)> {
924    // c:827
925    let end = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len());
926    if end == 0 {
927        return None;
928    }
929    let num = s[..end].parse::<i64>().ok()?;
930    Some((num, &s[end..]))
931}
932
933impl globdata {
934    /// `new` — see implementation.
935    pub fn new() -> Self {
936        globdata {
937            matches: Vec::new(),
938            qualifiers: None,
939            quals: None,
940            pathbuf: String::with_capacity(4096),
941            pathpos: 0,
942            matchct: 0,
943            pathbufsz: 4096,
944            pathbufcwd: 0,
945            gf_nullglob: 0,
946            gf_markdirs: 0,
947            gf_noglobdots: 0,
948            gf_listtypes: 0,
949            gf_pre_words: None,
950            gf_post_words: None,
951        }
952    }
953}
954
955// ============================================================================
956// Mode specification parsing (from glob.c qgetmodespec)
957// ============================================================================
958
959// `ModeSpec` struct deleted — Rust-only helper. C `qgetmodespec`
960// (`Src/glob.c:844`) parses one clause and returns the combined
961// `long` mode-bits directly, mutating the parse cursor via `char**`.
962// The Rust port now returns `(who, op, perm, rest)` as a flat tuple
963// so the canonical "no intermediate struct" pattern is preserved.
964
965/// Parse mode specification like chmod (from glob.c qgetmodespec lines 790-920)
966/// Examples: u+x, go-w, a=r, 755 — returns `(who, op, perm, rest)`.
967/// Port of `qgetmodespec(char **s)` from `Src/glob.c:844`.
968pub fn qgetmodespec(s: &str) -> Option<(u32, char, u32, &str)> {
969    let mut chars = s.chars().peekable();
970    let mut spec_who: u32 = 0;
971    let mut spec_op: char = '\0';
972    let mut spec_perm: u32 = 0;
973
974    // Check for octal mode
975    if chars.peek().map(|c| c.is_ascii_digit()).unwrap_or(false) {
976        let mut mode_str = String::new();
977        while let Some(&c) = chars.peek() {
978            if c.is_ascii_digit() && c < '8' {
979                mode_str.push(c);
980                chars.next();
981            } else {
982                break;
983            }
984        }
985        if let Ok(mode) = u32::from_str_radix(&mode_str, 8) {
986            spec_perm = mode;
987            spec_op = '=';
988            spec_who = 0o7777;
989            let rest_pos = s.len() - chars.collect::<String>().len();
990            return Some((spec_who, spec_op, spec_perm, &s[rest_pos..]));
991        }
992        return None;
993    }
994
995    // Parse symbolic mode
996    // Who: u, g, o, a
997    let mut who = 0u32;
998    while let Some(&c) = chars.peek() {
999        match c {
1000            'u' => {
1001                who |= 0o4700;
1002                chars.next();
1003            }
1004            'g' => {
1005                who |= 0o2070;
1006                chars.next();
1007            }
1008            'o' => {
1009                who |= 0o1007;
1010                chars.next();
1011            }
1012            'a' => {
1013                who |= 0o7777;
1014                chars.next();
1015            }
1016            _ => break,
1017        }
1018    }
1019    if who == 0 {
1020        who = 0o7777; // Default to all
1021    }
1022    spec_who = who;
1023
1024    // Op: +, -, =
1025    spec_op = match chars.next() {
1026        Some('+') => '+',
1027        Some('-') => '-',
1028        Some('=') => '=',
1029        _ => return None,
1030    };
1031
1032    // Perm: r, w, x, X, s, t
1033    let mut perm = 0u32;
1034    while let Some(&c) = chars.peek() {
1035        match c {
1036            'r' => {
1037                perm |= 0o444;
1038                chars.next();
1039            }
1040            'w' => {
1041                perm |= 0o222;
1042                chars.next();
1043            }
1044            'x' => {
1045                perm |= 0o111;
1046                chars.next();
1047            }
1048            'X' => {
1049                perm |= 0o111;
1050                chars.next();
1051            } // Conditional execute
1052            's' => {
1053                perm |= 0o6000;
1054                chars.next();
1055            }
1056            't' => {
1057                perm |= 0o1000;
1058                chars.next();
1059            }
1060            _ => break,
1061        }
1062    }
1063    // c:Src/glob.c:903-913 — `while ((c = *p) == '?' || (c >= '0'
1064    // && c <= '7')) { ... val = (val << 3) | (c - '0'); }`.
1065    // In the C code path, perm letters (`r`/`w`/`x`/`s`/`t`) only
1066    // get parsed when an explicit `who` mask was set (the `if
1067    // (mask)` arm at c:877). When there's NO who (the
1068    // bare-spec form like `f=644`), C falls through to the
1069    // numeric digit loop in the `else if` arm at c:901. The
1070    // Rust port previously accepted letters in either case but
1071    // rejected digits — `f=644` would parse op='=' then break
1072    // on digit '6' and return spec_perm = 0, which the caller
1073    // computed as yes=0, no=0o7777 (essentially "match
1074    // nothing"). After also accepting digits here, `f=644`
1075    // builds spec_perm = 0o644 like C, and the matcher
1076    // resolves `*(.f=644)` correctly. Bug #105 in
1077    // docs/BUGS.md.
1078    if perm == 0 {
1079        let mut val = 0u32;
1080        let mut any_digit = false;
1081        while let Some(&c) = chars.peek() {
1082            if c == '?' {
1083                val <<= 3;
1084                chars.next();
1085                any_digit = true;
1086            } else if c.is_ascii_digit() && c < '8' {
1087                val = (val << 3) | (c as u32 - '0' as u32);
1088                chars.next();
1089                any_digit = true;
1090            } else {
1091                break;
1092            }
1093        }
1094        if any_digit {
1095            perm = val;
1096        }
1097    }
1098    spec_perm = perm & who;
1099
1100    let rest_pos = s.len() - chars.collect::<String>().len();
1101    Some((spec_who, spec_op, spec_perm, &s[rest_pos..]))
1102}
1103
1104// `impl GlobMatch` block deleted — C builds Gmatch entries inline
1105// in `insert()` (glob.c:346) and `gmatchcmp` (glob.c:936) is a
1106// free function taking two Gmatch pointers. The Rust port mirrors
1107// that shape: no methods on the struct; construction inlined at
1108// the scanner call site, comparator as a free fn below.
1109
1110/// Port of `gmatchcmp(Gmatch a, Gmatch b)` from Src/glob.c:936 —
1111/// the qsort comparator the `o`/`O` glob qualifier drives.
1112///
1113/// `specs` is the equivalent of the C `gf_sortlist` array, each
1114/// entry a packed i32 (the C `struct globsort.tp` field):
1115///   bits 0..=4        — primary key (GS_NAME / GS_DEPTH / GS_EXEC /
1116///                       GS_SIZE / GS_ATIME / GS_MTIME / GS_CTIME /
1117///                       GS_LINKS, plus GS_NONE marker)
1118///   bits << GS_SHIFT  — same keys, follow-link variant
1119///                       (GS__SIZE / GS__ATIME / …)
1120///   GS_DESC bit       — reverse direction (`O` qualifier instead of `o`)
1121/// WARNING: param names don't match C — Rust=(b, specs, numeric_sort) vs C=(a, b)
1122pub fn gmatchcmp(
1123    // c:936
1124    a: &gmatch,
1125    b: &gmatch,
1126    specs: &[i32],
1127    numeric_sort: bool,
1128) -> std::cmp::Ordering {
1129    for &tp in specs {
1130        // c:943
1131        let key = tp & !GS_DESC; // c:944 s->tp & ~GS_DESC
1132        let follow = (key & GS_LINKED) != 0;
1133        let key_unshifted = if follow { key >> GS_SHIFT } else { key };
1134        let cmp = if key_unshifted == GS_NAME {
1135            // c:945 — `gmatch->name` in C zsh is the FULL match string
1136            // (Src/glob.c sets it from the accumulated path during the
1137            // scanner walk), so the qsort comparator inherently sorts by
1138            // full path. The Rust port stores the basename in `name`;
1139            // use `path` instead so `**/*` matches the full-path
1140            // lexicographic order zsh produces. Verified vs
1141            // /opt/homebrew/bin/zsh: `echo /tmp/rg/**/*` → `f sub sub/g`
1142            // (sorted by full path, not basename).
1143            let a_cow = a.path.to_string_lossy();
1144            let b_cow = b.path.to_string_lossy();
1145            // c:Src/glob.c:424 — C stores bare-relative match names
1146            // (`dyncat(pathbuf, news)`, no "./"), so its single qsort at
1147            // c:1977 sorts the exact strings it emits. The Rust scanner joins
1148            // depth-0 matches against base "." (glob.rs:527 + :584), giving a
1149            // leading "./" that deeper matches lack and that `glob_emit_path`
1150            // strips at emit. Since '.' (0x2E) sorts before any letter, that
1151            // stray prefix clustered every top-level entry ahead of any nested
1152            // path (`**/*` → `d e m d/z` instead of `d d/z e m`). Strip it here
1153            // so the sort key matches the emit key.
1154            let a_full = a_cow.strip_prefix("./").unwrap_or(&a_cow);
1155            let b_full = b_cow.strip_prefix("./").unwrap_or(&b_cow);
1156            zstrcmp(
1157                a_full,
1158                b_full,
1159                if numeric_sort {
1160                    crate::zsh_h::SORTIT_NUMERICALLY as u32
1161                } else {
1162                    0
1163                },
1164            )
1165        } else if key_unshifted == GS_DEPTH {
1166            // c:949-972 — pairwise: skip the common prefix of the two
1167            // full paths, then `slasha`/`slashb` = does the remainder of
1168            // each (excluding its final char) still contain a `/`, i.e.
1169            // is this path deeper past the divergence point. `r = slasha
1170            // - slashb`; ascending puts the deeper path first.
1171            let an = a.path.to_string_lossy();
1172            let bn = b.path.to_string_lossy();
1173            let ab = an.as_bytes();
1174            let bb = bn.as_bytes();
1175            // c:951 — `while (*aptr && *aptr == *bptr) aptr++,bptr++;`
1176            let mut i = 0;
1177            while i < ab.len() && i < bb.len() && ab[i] == bb[i] {
1178                i += 1;
1179            }
1180            // c:953-954 — if at the end of one and the prev char is `/`,
1181            // back up one so the trailing component is counted.
1182            let (mut ai, mut bi) = (i, i);
1183            if (ai >= ab.len() || bi >= bb.len()) && ai > 0 && ab[ai - 1] == b'/' {
1184                ai -= 1;
1185                bi -= 1;
1186            }
1187            // c:956-964 — slash present in the remainder, excluding last char.
1188            let has_slash = |s: &[u8]| s.len() > 1 && s[..s.len() - 1].contains(&b'/');
1189            let slasha = has_slash(&ab[ai.min(ab.len())..]) as i32;
1190            let slashb = has_slash(&bb[bi.min(bb.len())..]) as i32;
1191            // c:971 — `r = slasha - slashb`; deeper (slash=1) sorts first.
1192            slashb.cmp(&slasha)
1193        } else if key_unshifted == GS_SIZE {
1194            // c:985
1195            if follow {
1196                a.target_size.cmp(&b.target_size)
1197            } else {
1198                a.size.cmp(&b.size)
1199            }
1200        } else if key_unshifted == GS_ATIME {
1201            // c:988-994 — `r = a->atime - b->atime;` then tie-break by
1202            // `a->ansec - b->ansec` (under GET_ST_ATIME_NSEC).
1203            let primary = if follow {
1204                b.target_atime.cmp(&a.target_atime)
1205            } else {
1206                b.atime.cmp(&a.atime)
1207            };
1208            if primary != std::cmp::Ordering::Equal {
1209                primary
1210            } else if follow {
1211                b.target_ansec.cmp(&a.target_ansec) // c:1019
1212            } else {
1213                b.ansec.cmp(&a.ansec) // c:992
1214            }
1215        } else if key_unshifted == GS_MTIME {
1216            // c:995-1001 — `r = a->mtime - b->mtime;` then tie-break by
1217            // `a->mnsec - b->mnsec` (under GET_ST_MTIME_NSEC). Without
1218            // the nsec tie-break, files touched <1s apart sort by name.
1219            let primary = if follow {
1220                b.target_mtime.cmp(&a.target_mtime)
1221            } else {
1222                b.mtime.cmp(&a.mtime)
1223            };
1224            if primary != std::cmp::Ordering::Equal {
1225                primary
1226            } else if follow {
1227                b.target_mnsec.cmp(&a.target_mnsec) // c:1026
1228            } else {
1229                b.mnsec.cmp(&a.mnsec) // c:999
1230            }
1231        } else if key_unshifted == GS_CTIME {
1232            // c:1002-1008 — `r = a->ctime - b->ctime;` then tie-break by
1233            // `a->cnsec - b->cnsec` (under GET_ST_CTIME_NSEC).
1234            let primary = if follow {
1235                b.target_ctime.cmp(&a.target_ctime)
1236            } else {
1237                b.ctime.cmp(&a.ctime)
1238            };
1239            if primary != std::cmp::Ordering::Equal {
1240                primary
1241            } else if follow {
1242                b.target_cnsec.cmp(&a.target_cnsec) // c:1033
1243            } else {
1244                b.cnsec.cmp(&a.cnsec) // c:1006
1245            }
1246        } else if key_unshifted == GS_LINKS {
1247            // c:1010 — `r = b->links - a->links;` — SAME sign convention
1248            // as GS_SIZE (c:985), so ascending `ol` is fewest-links-first.
1249            // (Was reversed vs GS_SIZE, putting most-linked first.)
1250            if follow {
1251                a.target_links.cmp(&b.target_links)
1252            } else {
1253                a.links.cmp(&b.links)
1254            }
1255        } else if key_unshifted == GS_EXEC {
1256            // c:974
1257            let idx = ((key as u32) >> 16) as usize;
1258            let asx = a.sort_strings.get(idx).map(|s| s.as_str()).unwrap_or("");
1259            let bsx = b.sort_strings.get(idx).map(|s| s.as_str()).unwrap_or("");
1260            zstrcmp(
1261                asx,
1262                bsx,
1263                if numeric_sort {
1264                    crate::zsh_h::SORTIT_NUMERICALLY as u32
1265                } else {
1266                    0
1267                },
1268            )
1269        } else {
1270            std::cmp::Ordering::Equal // GS_NONE / unknown
1271        };
1272        if cmp != std::cmp::Ordering::Equal {
1273            return if (tp & GS_DESC) != 0 {
1274                cmp.reverse()
1275            } else {
1276                cmp
1277            };
1278        }
1279    }
1280    std::cmp::Ordering::Equal
1281}
1282
1283// `Redirect` struct + `RedirectType` enum + `xpandredir` fn
1284// DELETED. Both types were Rust-only duplicates of `parse::Redirect`
1285// / `parse::RedirectOp` with no callers. The `xpandredir` impl took
1286// the wrong signature anyway — C's `xpandredir(struct redir *fn,
1287// LinkList redirtab)` at `Src/glob.c:2150` mutates a linked-list
1288// in place and returns int; this Rust version returned `Vec<Redirect>`
1289// and operated on the duplicate Redirect type. Port `xpandredir`
1290// freshly against `parse::Redirect` when an actual caller appears.
1291
1292// ============================================================================
1293// Exec string for sorting (from glob.c glob_exec_string)
1294// ============================================================================
1295
1296/// Port of `static char *glob_exec_string(char **sp)` from
1297/// `Src/glob.c:1085`.
1298///
1299/// **C is a PARSER, not an executor.** It extracts the qualifier
1300/// argument string from `*sp` (advancing the pointer past the
1301/// delimiters) and returns the duplicated text. The actual eval
1302/// happens at the call sites (c:1680/1713/1747) which feed the
1303/// returned string into `qualsheval` (for `e:`/`+:`) or
1304/// `gf_sortlist[].exec` (for sort).
1305///
1306/// Previous Rust port was COMPLETELY MIS-IMPLEMENTED:
1307///   1. **Signature wrong:** Rust took `(cmd, filename)` and returned
1308///      command output. C takes `(**sp)` and returns the parsed
1309///      qualifier string (the `cmd` itself, NOT its output).
1310///   2. **Forked `/bin/sh`:** spawned a separate POSIX shell process
1311///      to run the cmd. That's even more broken than `qualsheval`
1312///      was, because the function isn't supposed to execute anything
1313///      at all at this layer.
1314///
1315/// Now mirrors C's parser body (c:1090-1117): handle the `+` prefix
1316/// (identifier form) vs the `e:` delimited form (get_strarg), set
1317/// `*sp` past the closing delimiter, return the dup'd inner text.
1318///
1319/// Returns `(parsed_string, advance_offset)` so the caller can
1320/// emulate C's `*sp = tt + plus;` pointer advance via slice
1321/// indexing.
1322/// WARNING: param names don't match C — Rust=(s, plus) vs C=(sp)
1323pub fn glob_exec_string(s: &str, plus_form: bool) -> Option<(String, usize)> {
1324    // c:1085
1325    if plus_form {
1326        // c:1090
1327        // c:1092 — `tt = itype_end(s, IIDENT, 0);`
1328        // c:1093-1097 — `if (tt == s) { zerr("missing identifier after `+'"); return NULL; }`
1329        let tt = crate::ported::utils::itype_end(s, crate::ported::ztype_h::IIDENT as u32, false);
1330        if tt == 0 {
1331            // c:1093
1332            zerr("missing identifier after `+'"); // c:1095
1333            return None; // c:1096
1334        }
1335        // c:1109 — `sdata = dupstring(s + plus);` (plus=0 here).
1336        // c:1113 — `*sp = tt + plus;` → advance offset is `tt`.
1337        Some((s[..tt].to_string(), tt))
1338    } else {
1339        // c:1099 — `tt = get_strarg(s, &plus);` — find matching delimiter.
1340        // get_strarg returns delimiter-balanced span; for `e:foo:` it
1341        // walks `s` past the inner expr and returns position of the
1342        // closing `:`.
1343        match crate::ported::subst::get_strarg(s) {
1344            Some((_del, content, rest)) => {
1345                // c:1100-1104 — `if (!*tt) { zerr("missing end of string"); return NULL; }`
1346                if rest.is_empty() && content.is_empty() {
1347                    zerr("missing end of string"); // c:1102
1348                    return None; // c:1103
1349                }
1350                // c:1109-1115 — `sdata = dupstring(s + plus); ... *sp = tt + plus;`.
1351                // Advance offset: bytes consumed of `s` = s.len() - rest.len().
1352                let advance = s.len() - rest.len();
1353                Some((content, advance))
1354            }
1355            None => {
1356                zerr("missing end of string"); // c:1102
1357                None
1358            }
1359        }
1360    }
1361}
1362
1363/*
1364 * Insert a glob match.
1365 * If there were words to prepend given by the P glob qualifier, do so.
1366 */
1367// c:1120-1123
1368/// Port of `insert_glob_match(LinkList list, LinkNode next, char *data)`
1369/// from Src/glob.c:1125. Inserts `data` at `next`, with optional
1370/// `gf_pre_words` prefix and `gf_post_words` suffix injection from
1371/// CURGLOBDATA (the `P:before:after:` glob qualifier).
1372pub fn insert_glob_match(list: &mut Vec<String>, next: usize, data: &str) {
1373    // c:1125
1374    let (pre, post) = {
1375        let gd = CURGLOBDATA.lock().unwrap_or_else(|e| e.into_inner());
1376        (gd.gf_pre_words.clone(), gd.gf_post_words.clone()) // c:1127, c:1136
1377    };
1378    // C `insertlinknode(list, next, data)` inserts AFTER node `next`,
1379    // returning the newly added node (so subsequent inserts append in
1380    // order). Our Vec<String> analog inserts at index `next+1` and
1381    // bumps `next` to point at the just-inserted slot.
1382    let mut cur = next;
1383    let n = list.len();
1384    let mut clamp = |i: usize| -> usize {
1385        if i > n {
1386            n
1387        } else {
1388            i
1389        }
1390    };
1391    if let Some(pre_words) = pre {
1392        // c:1127 `if (gf_pre_words)`
1393        for w in pre_words.iter() {
1394            // c:1129
1395            let pos = clamp(cur + 1);
1396            list.insert(pos, w.clone()); // c:1130 `dupstring(getdata(added))`
1397            cur = pos; // c:1130 — return-value advances `next`
1398        }
1399    }
1400    let pos = clamp(cur + 1);
1401    list.insert(pos, data.to_string()); // c:1134
1402    cur = pos;
1403    if let Some(post_words) = post {
1404        // c:1136 `if (gf_post_words)`
1405        for w in post_words.iter() {
1406            // c:1138
1407            let pos = clamp(cur + 1);
1408            list.insert(pos, w.clone()); // c:1139
1409            cur = pos;
1410        }
1411    }
1412}
1413
1414/// Port of `checkglobqual(char *str, int sl, int nobareglob, char **sp)` from Src/glob.c:1158.
1415/// C: `int checkglobqual(char *str, int sl, int nobareglob, char **sp)` —
1416///   confirm the trailing `(...)` is a glob qualifier (not literal).
1417///   Sets `*sp` to the qualifier start position. Returns 0 if not a
1418///   qualifier, non-zero if it is.
1419/// WARNING: param names don't match C — Rust=(str, sl, _nobareglob) vs C=(str, sl, nobareglob, sp)
1420pub fn checkglobqual(
1421    str: &str,
1422    sl: i32,
1423    _nobareglob: i32, // c:1158
1424    sp: &mut Option<usize>,
1425) -> i32 {
1426    // c:1163-1164 — `if (str[sl-1] != Outpar) return 0;`
1427    let bytes = str.as_bytes();
1428    let sl = sl as usize;
1429    if sl == 0 || bytes[sl - 1] != b')' {
1430        // c:1164
1431        return 0;
1432    }
1433    // c:1167-1212 — walk backwards counting parens to find matching `(`.
1434    let mut paren = 1i32;
1435    let mut i = sl - 1;
1436    while i > 0 {
1437        i -= 1;
1438        match bytes[i] {
1439            b')' => paren += 1,
1440            b'(' => {
1441                paren -= 1;
1442                if paren == 0 {
1443                    *sp = Some(i);
1444                    return 1; // c:1209
1445                }
1446            }
1447            _ => {}
1448        }
1449    }
1450    0 // c:1212
1451}
1452
1453/// Port of `zglob(LinkList list, LinkNode np, int nountok)` from
1454/// Src/glob.c:1214. Top-level glob expansion: gate on GLOBOPT/
1455/// EXECOPT/haswilds (c:1230-1234), remove the placeholder node,
1456/// expand the pattern via `glob_path` (which covers the c:1240-2012
1457/// qualifier+scanner+sort body), then splice the resulting matches
1458/// back at `node` via `insert_glob_match` (c:1995-2007).
1459pub fn zglob(list: &mut Vec<String>, np: usize, nountok: i32) {
1460    // c:1214
1461    if np >= list.len() {
1462        return;
1463    }
1464    // c:1217 — `LinkNode node = prevnode(np);` — the insertion point
1465    // after the placeholder is uremnode'd. Vec analog: insert at np.
1466    let node: usize = np; // c:1217
1467    let ostr = list[np].clone(); // c:1221 `ostr = getdata(np)`
1468                                 // c:1226 — `nobareglob = !isset(BAREGLOBQUAL);` (consumed by qualifier
1469                                 // parser inside glob_path).
1470
1471    // c:1230 — `if (unset(GLOBOPT) || !haswilds(ostr) || unset(EXECOPT))`
1472    if !crate::ported::zsh_h::isset(crate::ported::zsh_h::GLOBOPT)
1473        || !haswilds(&ostr)
1474        || !crate::ported::zsh_h::isset(crate::ported::zsh_h::EXECOPT)
1475    {
1476        if nountok == 0 {
1477            // c:1231
1478            // c:1232 — untokenize in-place; replace list[np] with the
1479            // untokenized form.
1480            list[np] = crate::ported::lex::untokenize(&ostr);
1481        }
1482        return; // c:1233
1483    }
1484
1485    // c:1235 — `save_globstate(saved);`. zshrs snapshots the
1486    // glob-relevant options into TLS (`enter_glob_scope`, required for
1487    // thread-safety) — the RAII guard restores them on return.
1488    let _glob_scope = enter_glob_scope();
1489
1490    // c:1237-1238 — `str = dupstring(ostr); uremnode(list, np);`
1491    list.remove(np); // c:1238
1492
1493    // c:1240-1995 — the scanner walk + match collection. zshrs drives
1494    // it through `globdata_glob`, the RUST-ONLY read_dir adaptation of
1495    // C's `scanner()` (c:500; C's `lchdir` descent is process-global and
1496    // unsafe under zshrs's threading). globdata_glob owns its own
1497    // globdata; call it directly rather than via the glob_path Vec
1498    // convenience.
1499    let matches = {
1500        let mut st = globdata::new();
1501        globdata_glob(&mut st, &ostr)
1502    };
1503
1504    // c:1871-1875 — badcshglob accounting. Each zglob run updates
1505    // the per-command-line counter so globlist's terminal diagnostic
1506    // can distinguish "some failures, no successes" (emit error)
1507    // from "some failures, some successes" (silent).
1508    if !matches.is_empty() {
1509        // c:1872 — `badcshglob |= 2;` (at least one expansion OK).
1510        BADCSHGLOB.fetch_or(2, std::sync::atomic::Ordering::Relaxed);
1511    } else if crate::ported::zsh_h::isset(crate::ported::zsh_h::CSHNULLGLOB) {
1512        // c:1874-1875 — `badcshglob |= 1;` (at least one expansion
1513        // failed) under CSHNULLGLOB.
1514        BADCSHGLOB.fetch_or(1, std::sync::atomic::Ordering::Relaxed);
1515    }
1516
1517    // c:1872-1888 — `Deal with failures to match depending on options`.
1518    // C body verbatim (c:1872-1888):
1519    //   if (matchct)
1520    //       badcshglob |= 2;
1521    //   else if (!gf_nullglob) {
1522    //       if (isset(CSHNULLGLOB)) {
1523    //           badcshglob |= 1;
1524    //       } else if (isset(NOMATCH)) {
1525    //           zerr("no matches found: %s", ostr);
1526    //           zfree(matchbuf, 0);
1527    //           restore_globstate(saved);
1528    //           return;
1529    //       } else {
1530    //           /* treat as an ordinary string */
1531    //           untokenize(matchptr->name = dupstring(ostr));
1532    //           matchptr++;
1533    //           matchct = 1;
1534    //       }
1535    //   }
1536    // gf_nullglob (c:212) is the per-glob nullglob bit toggled by
1537    // qualifier `N`; here we approximate with the global option.
1538    // Parity bug #13: previously this Rust arm fell through to the
1539    // ordinary-literal path (c:1882-1887) unconditionally, making
1540    // `echo /never/*` print the literal glob instead of erroring.
1541    if matches.is_empty() {
1542        let nullglob = isset(crate::ported::zsh_h::NULLGLOB); // c:1873 !gf_nullglob
1543        let csh_nullglob = isset(crate::ported::zsh_h::CSHNULLGLOB); // c:1874
1544                                                                     // c:Src/glob.c:1843-1854 — `if (!q || errflag) { ... zerr(
1545                                                                     // "bad pattern", ostr); return; }`. When the qualifier
1546                                                                     // parser already emitted a diagnostic (e.g. "number expected"
1547                                                                     // from qgetnum at c:832) and set errflag, the no-matches /
1548                                                                     // bad-pattern terminal block runs but the prior zerr is what
1549                                                                     // the user sees first. Skipping the redundant "no matches
1550                                                                     // found" here matches zsh which has already aborted glob
1551                                                                     // expansion via the errflag-gated return at c:1787 / c:1843.
1552        if errflag.load(Ordering::SeqCst) != 0 {
1553            return;
1554        }
1555        // c:1872 — `else if (!gf_nullglob) { ... }`. The ENTIRE no-match
1556        // handling (CSHNULLGLOB, NOMATCH, literal fallback) is gated on
1557        // nullglob being OFF. When nullglob is set the failed word is
1558        // simply dropped: C never enters the block, matchbuf stays empty,
1559        // and glob() removes the word from the list. Hoisting the
1560        // `!nullglob` test to a single outer gate (matching the C
1561        // `else if`) ensures the literal-insert fallback below is skipped
1562        // under nullglob too — previously it fell through unconditionally
1563        // and echoed the literal, diverging from c:1872.
1564        if !nullglob {
1565            if csh_nullglob {
1566                // c:1874-1875 — `if (isset(CSHNULLGLOB)) { badcshglob |= 1; }`
1567                // (already recorded above). The else-if chain means the
1568                // ordinary-string arm is NOT reached: the failed word is
1569                // DROPPED like nullglob, and globlist's terminal check
1570                // (subst.c:505-507, ported at subst.rs:1419) emits the
1571                // csh-style `no match` when NO word on the line matched.
1572                // Falling through to the literal insert here produced the
1573                // NOMATCH-style "no matches found: PAT" instead.
1574                return;
1575            }
1576            if isset(crate::ported::zsh_h::NOMATCH) {
1577                // c:1876-1880 — `else if (isset(NOMATCH)) { zerr; return; }`
1578                crate::ported::utils::zerr(&format!("no matches found: {}", ostr));
1579                // c:1878 — `zfree(matchbuf, 0);` (Rust drop handles it)
1580                // c:1879 — `restore_globstate(saved);` (handled by guard)
1581                return; // c:1880
1582            }
1583            // c:1882-1887 — `treat as an ordinary string`. The matchptr++
1584            // bookkeeping in C maps to our `list.insert`.
1585            let restored = if nountok == 0 {
1586                crate::ported::lex::untokenize(&ostr) // c:1884 untokenize(matchptr->name = dupstring(ostr))
1587            } else {
1588                ostr.clone()
1589            };
1590            let pos = if node > list.len() { list.len() } else { node };
1591            list.insert(pos, restored); // c:1885 matchptr++, c:1886 matchct = 1
1592        }
1593        // nullglob set → fall through with matchbuf empty: the word is
1594        // dropped (c:1872 skips the block entirely). c:1889's
1595        // `else if (in_expandredir)` redirect-failure arm is handled by
1596        // the caller (vm_helper.rs redirect-glob path), not here.
1597        return;
1598    }
1599
1600    // c:1995-2007 — splice matches back via insert_glob_match (honors
1601    // gf_pre_words / gf_post_words).
1602    let mut cur = if node == 0 { 0 } else { node - 1 }; // node is `prevnode(np)` semantics
1603    for m in matches.iter() {
1604        insert_glob_match(list, cur, m); // c:1995
1605        cur += 1;
1606        if let Some(g) = CURGLOBDATA.lock().ok() {
1607            // Advance past any pre_words that insert_glob_match added.
1608            if let Some(p) = &g.gf_pre_words {
1609                cur += p.len();
1610            }
1611            if let Some(p) = &g.gf_post_words {
1612                cur += p.len();
1613            }
1614        }
1615    }
1616}
1617
1618/// File type character for -F style listing
1619/// Render a mode bitmap as the `*` qualifier letter (`d`/`b`/
1620/// `c`/`l`/`s`/`p`/etc.).
1621/// Port of `file_type(mode_t filemode)` from Src/glob.c:2018.
1622pub fn file_type(filemode: u32) -> char {
1623    // c:2018
1624    let fmt = filemode & libc::S_IFMT as u32;
1625    if fmt == libc::S_IFBLK as u32 {
1626        '#'
1627    } else if fmt == libc::S_IFCHR as u32 {
1628        '%'
1629    } else if fmt == libc::S_IFDIR as u32 {
1630        '/'
1631    } else if fmt == libc::S_IFIFO as u32 {
1632        '|'
1633    } else if fmt == libc::S_IFLNK as u32 {
1634        '@'
1635    } else if fmt == libc::S_IFREG as u32 {
1636        if filemode & 0o111 != 0 {
1637            '*'
1638        } else {
1639            ' '
1640        }
1641    } else if fmt == libc::S_IFSOCK as u32 {
1642        '='
1643    } else {
1644        '?'
1645    }
1646}
1647
1648// ============================================================================
1649// Brace expansion
1650// ============================================================================
1651
1652/// Check if string has brace expansion
1653/// Check whether a string has brace-expansion `{a,b}` content.
1654/// Port of `hasbraces(char *str)` from Src/glob.c:2042.
1655/// WARNING: param names don't match C — Rust=(s, brace_ccl) vs C=(str)
1656pub fn hasbraces(s: &str, brace_ccl: bool) -> bool {
1657    // c:2042
1658    let mut depth = 0;
1659    let mut has_comma = false;
1660    let mut has_dotdot = false;
1661    let mut brace_open: Option<usize> = None;
1662
1663    let chars: Vec<char> = s.chars().collect();
1664    let len = chars.len();
1665
1666    let mut i = 0;
1667    while i < len {
1668        // c:Src/lex.c:3587-3600 — backslash escape converts the next
1669        // char into a tokenized literal (Bnull/Bnullkeep). xpandbraces
1670        // sees the tokenized form so `\{` never enters the brace
1671        // walk. Accept both the canonical Bnull (`\u{9f}`,
1672        // Src/zsh.h:195) and ASCII `\` so direct callers (tests,
1673        // utility paths) and pipeline callers (bridge → multsub →
1674        // xpandbraces with Bnull markers from gettokstr) both behave
1675        // the same: skip the escape marker plus the next char.
1676        if (chars[i] == '\\' || chars[i] == '\u{9f}') && i + 1 < len {
1677            i += 2; // c:3591 — skip Bnull/Bnullkeep + escaped char
1678            continue;
1679        }
1680        match chars[i] {
1681            // c:Src/glob.c:hasbraces — Inbrace/Outbrace/Comma TOKEN
1682            // strictly (\u{8f} / \u{90} / \u{9a}). The lexer emits
1683            // TOKEN form for unescaped `{`/`,`/`}`; `\X` produces
1684            // Bnull + ASCII X. After remnulargs strips Bnull, the
1685            // ASCII X doesn't match the TOKEN check so escaped
1686            // braces correctly bypass expansion.
1687            '\u{8f}' => {
1688                if depth == 0 {
1689                    brace_open = Some(i);
1690                }
1691                depth += 1;
1692            }
1693            '\u{90}' if depth > 0 => {
1694                depth -= 1;
1695                if depth == 0 {
1696                    // c:2050-2061 — a comma group always expands.
1697                    if has_comma {
1698                        return true;
1699                    }
1700                    if has_dotdot {
1701                        // c:2071-2096 — a `..` group expands ONLY when it
1702                        // is a valid char range (bracechardots, c:2071) OR
1703                        // a numeric range whose endpoints are immediately
1704                        // bounded by the closing brace: C walks `[-]digits
1705                        // .. [-]digits` and requires the next char to be
1706                        // Outbrace (c:2084) or `..incr..` + Outbrace
1707                        // (c:2087-2095). Trailing garbage (`{0..5%2}`,
1708                        // `{1..3x}`, `{1.2..3}`, `{1..5.}`) fails this, so
1709                        // the group stays LITERAL with braces. Nested
1710                        // groups stay permissive — recursive xpandbraces
1711                        // handles the inner range.
1712                        let content: String =
1713                            chars[brace_open.unwrap_or(0) + 1..i].iter().collect();
1714                        let nested = content.contains('\u{8f}');
1715                        // c:2074-2096 — structural walk: optional `-`,
1716                        // digits (0+), `..`, optional `-`, digits (0+),
1717                        // then MUST end at the brace (Outbrace), or take a
1718                        // `..incr` step then end. C allows EMPTY endpoints
1719                        // (`{1..}`, `{..5}` pass hasbraces, then fail the
1720                        // range parse and get brace-stripped) — what it
1721                        // forbids is trailing non-range chars (`{0..5%2}`,
1722                        // `{1.2..3}`). Closes with c:2085 `idigit(lbr[1])
1723                        // || idigit(str[-1])` — a digit adjacent to `..`.
1724                        // Dash TOKEN (\u{9b}) reads as ASCII `-`.
1725                        let numeric_ok = {
1726                            let norm: String = content
1727                                .chars()
1728                                .map(|c| if c == '\u{9b}' { '-' } else { c })
1729                                .collect();
1730                            let b = norm.as_bytes();
1731                            let n = b.len();
1732                            let mut j = 0;
1733                            let mut shape_ok = false;
1734                            if j < n && b[j] == b'-' {
1735                                j += 1; // c:2074 leading `-`
1736                            }
1737                            while j < n && b[j].is_ascii_digit() {
1738                                j += 1; // c:2076 first number
1739                            }
1740                            if j + 1 < n && b[j] == b'.' && b[j + 1] == b'.' {
1741                                j += 2; // c:2078 `..`
1742                                if j < n && b[j] == b'-' {
1743                                    j += 1; // c:2080
1744                                }
1745                                while j < n && b[j].is_ascii_digit() {
1746                                    j += 1; // c:2082 second number
1747                                }
1748                                if j == n {
1749                                    shape_ok = true; // c:2084 Outbrace
1750                                } else if j + 1 < n && b[j] == b'.' && b[j + 1] == b'.' {
1751                                    j += 2; // c:2087 `..incr`
1752                                    if j < n && b[j] == b'-' {
1753                                        j += 1;
1754                                    }
1755                                    while j < n && b[j].is_ascii_digit() {
1756                                        j += 1; // c:2091
1757                                    }
1758                                    if j == n {
1759                                        shape_ok = true; // c:2093
1760                                    }
1761                                }
1762                            }
1763                            // c:2085/2094 — digit adjacent to `..`.
1764                            shape_ok
1765                                && (b.first().is_some_and(|c| c.is_ascii_digit())
1766                                    || b.last().is_some_and(|c| c.is_ascii_digit()))
1767                        };
1768                        if nested || bracechardots(&content).is_some() || numeric_ok {
1769                            return true;
1770                        }
1771                    }
1772                    if brace_ccl {
1773                        if let Some(open) = brace_open {
1774                            if i > open + 1 {
1775                                return true;
1776                            }
1777                        }
1778                    }
1779                    has_comma = false;
1780                    has_dotdot = false;
1781                    brace_open = None;
1782                }
1783            }
1784            // c:Src/glob.c:hasbraces — accept comma / `..` at ANY depth
1785            // (was `depth == 1` only). Nested groups like `{{1,2}}`
1786            // have the comma at depth 2; without this, hasbraces
1787            // returned false and the outer xpandbraces never ran,
1788            // leaving `{{1,2}}` literal instead of expanding to
1789            // `{1} {2}` per zsh's nested-brace pass.
1790            '\u{9a}' if depth > 0 => has_comma = true,
1791            '.' if depth > 0 && i + 1 < len && chars[i + 1] == '.' => has_dotdot = true,
1792            _ => {}
1793        }
1794        i += 1;
1795    }
1796
1797    false
1798}
1799
1800// ============================================================================
1801// Brace char range parsing (from glob.c bracechardots)
1802// ============================================================================
1803
1804/// Parse character range in braces like {a..z} (from glob.c bracechardots line 2222)
1805/// Port of `bracechardots(char *str, convchar_t *c1p, convchar_t *c2p)` from `Src/glob.c:2222`.
1806/// WARNING: param names don't match C — Rust=(s) vs C=(str, c1p, c2p)
1807pub fn bracechardots(s: &str) -> Option<(char, char, i32)> {
1808    let chars: Vec<char> = s.chars().collect();
1809
1810    // Must be at least "a..b"
1811    if chars.len() < 4 {
1812        return None;
1813    }
1814
1815    // Find ..
1816    let dotdot_pos = s.find("..")?;
1817    if dotdot_pos == 0 {
1818        return None;
1819    }
1820
1821    let left = &s[..dotdot_pos];
1822    let right = &s[dotdot_pos + 2..];
1823
1824    // Check for increment
1825    let (end_str, incr) = if let Some(pos) = right.find("..") {
1826        let end = &right[..pos];
1827        let inc: i32 = right[pos + 2..].parse().unwrap_or(1);
1828        (end, inc)
1829    } else {
1830        (right, 1)
1831    };
1832
1833    // Single character range
1834    if left.chars().count() == 1 && end_str.chars().count() == 1 {
1835        let c1 = left.chars().next()?;
1836        let c2 = end_str.chars().next()?;
1837        return Some((c1, c2, incr));
1838    }
1839
1840    None
1841}
1842
1843/// Expand braces in a string
1844/// Brace-expand a string into a flat list.
1845/// Port of `xpandbraces(LinkList list, LinkNode *np)` from Src/glob.c:2276 — same
1846/// `{a,b}` / `{1..10}` / `{a-z}` handling.
1847/// WARNING: param names don't match C — Rust=(s, brace_ccl) vs C=(list, np)
1848pub fn xpandbraces(s: &str, brace_ccl: bool) -> Vec<String> {
1849    // c:2276
1850    if !hasbraces(s, brace_ccl) {
1851        return vec![s.to_string()];
1852    }
1853
1854    // Inline single-brace expansion — direct port of the per-iteration
1855    // brace-scan inside zsh's xpandbraces (Src/glob.c:2276). Walks the
1856    // string, finds the first `{`...`}` group, classifies as range
1857    // (`a..b`) / comma (`a,b`) / ccl (`[abc]`-style char-class), and
1858    // dispatches to the matching expander. Returns Some(parts) on
1859    // expansion, None if no brace group or unmatched.
1860    // c:Src/glob.c:2276 xpandbraces — advances `lbr` through every
1861    // candidate `{` and tries each. Bug #575: zshrs only tried the
1862    // FIRST `{` and returned None for the whole string when that
1863    // group was not expandable, so `{a-c}{1..3}` left the `{1..3}`
1864    // unexpanded. Mirror C by carrying a `from` offset and walking
1865    // to the next `{` on failure.
1866    //
1867    // Returns (Some(parts), _) on successful expansion. Returns
1868    // (None, Some(next_from)) when the current `{...}` was found but
1869    // didn't expand — outer loop should retry from next_from to scan
1870    // for a later expandable group. Returns (None, None) when no
1871    // `{...}` remains.
1872    let try_expand_from = |s: &str, from: usize| -> (Option<Vec<String>>, Option<usize>) {
1873        let chars: Vec<char> = s.chars().collect();
1874        let len = chars.len();
1875        // c:Src/glob.c:xpandbraces — Inbrace TOKEN strict.
1876        let start = match chars[from..].iter().position(|&c| c == '\u{8f}') {
1877            Some(p) => from + p,
1878            None => return (None, None),
1879        };
1880        let mut depth = 1;
1881        let mut comma_positions = Vec::new();
1882        let mut dotdot_pos = None;
1883        for i in (start + 1)..len {
1884            match chars[i] {
1885                '\u{8f}' => depth += 1,
1886                '\u{90}' => {
1887                    depth -= 1;
1888                    if depth == 0 {
1889                        let next_from = i + 1;
1890                        let prefix: String = chars[..start].iter().collect();
1891                        let suffix: String = chars[i + 1..].iter().collect();
1892                        let content: String = chars[start + 1..i].iter().collect();
1893                        if let Some(dp) = dotdot_pos {
1894                            if comma_positions.is_empty() {
1895                                if let Some(parts) = expand_range(&prefix, &content, dp, &suffix) {
1896                                    return (Some(parts), None);
1897                                }
1898                                // c:Src/glob.c:2476-2506 — when range
1899                                // parsing fails INSIDE C's xpandbraces
1900                                // (err != 0 set at c:2355/2360/2369/2371),
1901                                // the function falls through to the
1902                                // normal-comma-expansion path. With no
1903                                // commas, that loop produces a single
1904                                // `prefix + content + suffix` — i.e.
1905                                // strips the outermost braces only.
1906                                // BUT: C only reaches xpandbraces when
1907                                // `hasbraces()` (c:2042) returned 1,
1908                                // and hasbraces requires DIGITS in the
1909                                // range endpoints (c:2076-2096) for the
1910                                // numeric form. Patterns like
1911                                // `{hello..world}` fail hasbraces and
1912                                // never enter xpandbraces — they stay
1913                                // as literal `{hello..world}`.
1914                                //
1915                                // Rust's hasbraces is more permissive
1916                                // (accepts any `..` inside `{}`), so we
1917                                // gate the strip-braces fallback on the
1918                                // SAME shape C uses: at least one digit
1919                                // must appear in the left or right
1920                                // endpoint, mirroring c:2085 `idigit
1921                                // (lbr[1]) || idigit(str[-1])`.
1922                                // `dp` is a CHAR index; byte-slice via a
1923                                // converted offset so multibyte/metafied
1924                                // content (`{$'\x80'..$'\x81'}`) can't panic.
1925                                let dp_byte = content
1926                                    .char_indices()
1927                                    .nth(dp)
1928                                    .map(|(b, _)| b)
1929                                    .unwrap_or(content.len());
1930                                let left = &content[..dp_byte];
1931                                let right = &content[dp_byte + 2..];
1932                                let strip_end =
1933                                    right.find("..").map(|p| &right[..p]).unwrap_or(right);
1934                                let has_digit = left.chars().any(|c| c.is_ascii_digit())
1935                                    || strip_end.chars().any(|c| c.is_ascii_digit());
1936                                if has_digit {
1937                                    // c:2495-2498 — strip braces.
1938                                    return (
1939                                        Some(vec![format!("{}{}{}", prefix, content, suffix)]),
1940                                        None,
1941                                    );
1942                                }
1943                                // Non-digit `..` content (e.g.
1944                                // `{hello..world}`) — C wouldn't have
1945                                // entered xpandbraces. Preserve the
1946                                // literal pattern intact. Allow outer
1947                                // loop to retry from next_from in case
1948                                // a later brace group is expandable.
1949                                return (None, Some(next_from));
1950                            }
1951                        }
1952                        if !comma_positions.is_empty() {
1953                            return (
1954                                expand_comma(&prefix, &content, &comma_positions, &suffix),
1955                                None,
1956                            );
1957                        }
1958                        if brace_ccl && !content.is_empty() {
1959                            return (expand_ccl(&prefix, &content, &suffix), None);
1960                        }
1961                        // Outer brace has no comma/dotdot at depth 1,
1962                        // but content may contain nested braces (e.g.
1963                        // `{{1,2}}` → `{1} {2}` — outer braces become
1964                        // literal, inner expands).
1965                        if content.contains('\u{8f}') {
1966                            let inner_expanded = xpandbraces(&content, brace_ccl);
1967                            let mut out: Vec<String> = Vec::with_capacity(inner_expanded.len());
1968                            for piece in inner_expanded {
1969                                out.push(format!("{}{{{}}}{}", prefix, piece, suffix));
1970                            }
1971                            return (Some(out), None);
1972                        }
1973                        // Literal-only group: allow outer to retry
1974                        // from next_from for a later expandable group.
1975                        return (None, Some(next_from));
1976                    }
1977                }
1978                '\u{9a}' if depth == 1 => comma_positions.push(i - start - 1),
1979                '.' if depth == 1 && i + 1 < len && chars[i + 1] == '.' && dotdot_pos.is_none() => {
1980                    dotdot_pos = Some(i - start - 1);
1981                }
1982                _ => {}
1983            }
1984        }
1985        (None, None)
1986    };
1987
1988    // Outer loop: try expanding starting from each `{` in turn.
1989    // Returns first successful expansion; None if none found.
1990    let try_expand_one = |s: &str| -> Option<Vec<String>> {
1991        let mut from = 0;
1992        loop {
1993            let (result, next) = try_expand_from(s, from);
1994            if let Some(parts) = result {
1995                return Some(parts);
1996            }
1997            match next {
1998                Some(nf) if nf > from => from = nf,
1999                _ => return None,
2000            }
2001        }
2002    };
2003
2004    let mut results = vec![s.to_string()];
2005    let mut changed = true;
2006    while changed {
2007        changed = false;
2008        let mut new_results = Vec::new();
2009        for item in &results {
2010            if let Some(expanded) = try_expand_one(item) {
2011                new_results.extend(expanded);
2012                changed = true;
2013            } else {
2014                new_results.push(item.clone());
2015            }
2016        }
2017        results = new_results;
2018    }
2019    results
2020}
2021
2022/// Simple glob pattern matching
2023/* check to see if a matches b (b is not a filename pattern) */            // c:2510
2024// !!! WARNING: RUST-ONLY HELPER — extra args + flipped arg order vs C !!!
2025// C signature: `int matchpat(char *a, char *b)` — `a` = text to match,
2026// `b` = pattern. Rust callers (cond.rs, watch.rs, glob.rs internal users)
2027// pass `(pattern, text, extended, case_sensitive)` — pattern-FIRST,
2028// flipped from C, plus two per-call option overrides. The BODY below
2029// is C-faithful for the matching path (patcompile + pattry, no Unicode
2030// case-folding); `extended`/`case_sensitive` drive transient
2031// `opt_state_set` overrides around the patcompile call. The transient
2032// swap is the WRONG mechanism — patcompile is not reentrant under
2033// concurrent option mutation. SOLE acceptable use: this same-process,
2034// single-threaded matchpat call path. Replace with: all callers
2035// migrate to (a) global setopt and (b) canonical C arg order
2036// `matchpat(text, pattern)`.
2037// !!! WARNING: RUST-ONLY HELPER — extra args + flipped arg order vs C !!!
2038/// Port of `matchpat(char *a, char *b)` from `Src/glob.c:2514`.
2039/// CALLER CONVENTION: Rust=(pattern, text, ext, cs) — pattern FIRST
2040/// (FLIPPED from C `matchpat(text=a, pattern=b)`). Body remaps to
2041/// C order internally.
2042pub fn matchpat(pattern_in: &str, text_in: &str, extended: bool, case_sensitive: bool) -> bool {
2043    // Remap to C names (a=text, b=pattern) at the function boundary so
2044    // the body below reads identically to Src/glob.c:2514-2530.
2045    let a = text_in;
2046    let b = pattern_in;
2047    // c:2514
2048    crate::ported::signals_h::queue_signals(); // c:2519
2049                                               // GF_IGNCASE handling: zshrs's pattern matcher DOES honor the
2050                                               // flag in `patmatch_internal` (pattern.rs:2245/2312/2333 — P_EXACTLY,
2051                                               // P_ANYOF, P_ANYBUT all check `glob_flags & (GF_IGNCASE|GF_LCMATCHUC)`
2052                                               // and case-fold appropriately). The pre-fold below is a defensive
2053                                               // fast path for the simple case-insensitive `:#`/`:#:` filter — it
2054                                               // bypasses the matcher's per-arm fold and avoids any stale-cache
2055                                               // edge case in the compile→match handoff. Costs one to_lowercase
2056                                               // call per side; correct under all matcher implementations.
2057    let (a_eff, b_eff) = if case_sensitive {
2058        (a.to_string(), b.to_string())
2059    } else {
2060        (a.to_lowercase(), b.to_lowercase())
2061    };
2062    // Per-call option override (Rust-only): snapshot, set, restore.
2063    let prev_extended = crate::ported::options::opt_state_get("extendedglob");
2064    let prev_caseglob = crate::ported::options::opt_state_get("caseglob");
2065    crate::ported::options::opt_state_set("extendedglob", extended);
2066    crate::ported::options::opt_state_set("caseglob", case_sensitive);
2067    // c:2521 — `if (!(p = patcompile(b, PAT_STATIC, NULL)))`. Rust uses
2068    // PAT_HEAPDUP (=0) — pattern::patmatch's canonical compile path;
2069    // PAT_STATIC's static-buffer path is incomplete in zshrs.
2070    let p_opt = crate::ported::pattern::patcompile(
2071        &{
2072            let mut __pat_tok = (&b_eff).to_string();
2073            crate::ported::glob::tokenize(&mut __pat_tok);
2074            __pat_tok
2075        },
2076        crate::ported::zsh_h::PAT_HEAPDUP,
2077        None,
2078    );
2079    if let Some(v) = prev_extended {
2080        crate::ported::options::opt_state_set("extendedglob", v);
2081    }
2082    if let Some(v) = prev_caseglob {
2083        crate::ported::options::opt_state_set("caseglob", v);
2084    }
2085    let ret = match p_opt {
2086        Some(p) => crate::ported::pattern::pattry(&p, &a_eff), // c:2525
2087        None => {
2088            crate::ported::utils::zerr(&format!("bad pattern: {}", b)); // c:2522
2089            false // c:2523
2090        }
2091    };
2092    crate::ported::signals_h::unqueue_signals(); // c:2527
2093    ret // c:2529
2094}
2095
2096/// Port of `get_match_ret(Imatchdata imd, int b, int e)` from `Src/glob.c:2550`.
2097///
2098/// C returns `char *`: `NULL` when there is nothing to emit, `imd->mstr`
2099/// when the match was recorded into `repllist` (SUB_GLOBAL / SUB_LIST),
2100/// else a freshly built buffer. Rust returns `Option<String>` (`None` ==
2101/// C `NULL`). `b`/`e` arrive as *unmetafied* byte offsets and are first
2102/// re-based to metafied byte offsets, exactly as C does. Takes `&mut`
2103/// because the SUB_GLOBAL/SUB_LIST branch pushes onto `imd->repllist`.
2104pub fn get_match_ret(imd: &mut imatchdata, b: usize, e: usize) -> Option<String> {
2105    let mut buf = String::new(); // c:2552 char buf[80]
2106    let mut ll: i64 = 0; // c:2553 ll = 0
2107    let mut bl: usize = 0; // c:2553 bl = 0
2108    let mut t = false; // c:2553 t = 0
2109    let mut add: usize = 0; // c:2553 add = 0
2110    let fl = imd.flags; // c:2553 fl = imd->flags
2111    let mut replstr: Option<String> = imd.replstr.clone(); // c:2552 *replstr = imd->replstr
2112
2113    let ustr_owned = imd.ustr.clone().unwrap_or_default();
2114    let ustr = ustr_owned.as_bytes();
2115    let mstr_owned = imd.mstr.clone().unwrap_or_default();
2116    let mstr = mstr_owned.as_bytes();
2117    let mlen = imd.mlen as usize; // c:2544 imd->mlen
2118
2119    // c:2555-2564 — account for b and e referring to unmetafied string.
2120    let mut p = 0usize; // c:2556 p = imd->ustr
2121    while p < b && p < ustr.len() {
2122        // c:2556 for (; p < imd->ustr + b; p++)
2123        if imeta(ustr[p]) {
2124            add += 1; // c:2558 add++
2125        }
2126        p += 1;
2127    }
2128    let b = b + add; // c:2559 b += add
2129    while p < e && p < ustr.len() {
2130        // c:2560 for (; p < imd->ustr + e; p++)
2131        if imeta(ustr[p]) {
2132            add += 1; // c:2562 add++
2133        }
2134        p += 1;
2135    }
2136    let e = e + add; // c:2563 e += add
2137
2138    // c:2566 — Everything now refers to metafied lengths.
2139    if replstr.is_some() || (fl & SUB_LIST) != 0 {
2140        // c:2567
2141        if (fl & SUB_DOSUBST) != 0 {
2142            // c:2568
2143            let mut rs = dupstring(replstr.as_deref().unwrap_or("")); // c:2569 dupstring(replstr)
2144            rs = singsub(&rs); // c:2570 singsub(&replstr)
2145            rs = untokenize(&rs); // c:2571 untokenize(replstr)
2146            replstr = Some(rs);
2147        }
2148        if (fl & (SUB_GLOBAL | SUB_LIST)) != 0 && imd.repllist.is_some() {
2149            // c:2573 — replacing the chunk, just add this to the list.
2150            let rd = repldata {
2151                b: b as i32,              // c:2578 rd->b = b
2152                e: e as i32,              // c:2579 rd->e = e
2153                replstr: replstr.clone(), // c:2580 rd->replstr = replstr
2154            };
2155            imd.repllist.as_mut().unwrap().push(rd); // c:2581-2584 z/addlinknode(repllist, rd)
2156            return imd.mstr.clone(); // c:2585 return imd->mstr
2157        }
2158        if let Some(ref r) = replstr {
2159            ll += r.len() as i64; // c:2588 ll += strlen(replstr)
2160        }
2161    }
2162    if (fl & SUB_MATCH) != 0 {
2163        // c:2590 matched portion
2164        ll += 1 + (e as i64 - b as i64); // c:2591 ll += 1 + (e - b)
2165    }
2166    if (fl & SUB_REST) != 0 {
2167        // c:2592 unmatched portion
2168        ll += 1 + (mlen as i64 - (e as i64 - b as i64)); // c:2593 ll += 1 + (mlen - (e - b))
2169    }
2170    if (fl & SUB_BIND) != 0 {
2171        // c:2594 position of start of matched portion
2172        buf = format!("{} ", MB_METASTRLEN2END(mstr_owned.as_str(), false, b) + 1); // c:2596
2173        bl = buf.len(); // c:2597 bl = strlen(buf)
2174        ll += bl as i64; // c:2597 ll += bl
2175    }
2176    if (fl & SUB_EIND) != 0 {
2177        // c:2599 position of end of matched portion
2178        buf.push_str(&format!(
2179            "{} ",
2180            MB_METASTRLEN2END(mstr_owned.as_str(), false, e) + 1
2181        )); // c:2601
2182        bl = buf.len(); // c:2602 bl = strlen(buf)
2183        ll += bl as i64; // c:2602 ll += bl
2184    }
2185    if (fl & SUB_LEN) != 0 {
2186        // c:2603 length of matched portion — MB_METASTRLEN2END(mstr+b, 0, mstr+e)
2187        let sub = if b <= mstr.len() {
2188            &mstr_owned[b..]
2189        } else {
2190            ""
2191        };
2192        buf.push_str(&format!(
2193            "{} ",
2194            MB_METASTRLEN2END(sub, false, e.saturating_sub(b))
2195        )); // c:2605
2196        bl = buf.len(); // c:2606 bl = strlen(buf)
2197        ll += bl as i64; // c:2606 ll += bl
2198    }
2199    if bl != 0 {
2200        buf.pop(); // c:2609 buf[bl - 1] = '\0' — drop the trailing space
2201    }
2202
2203    if ll == 0 {
2204        return None; // c:2614 return NULL
2205    }
2206
2207    // c:2617 — rr = r = hcalloc(ll); build the result buffer.
2208    let mut r = String::new();
2209
2210    if (fl & SUB_MATCH) != 0 {
2211        // c:2619-2623 — copy matched portion to new buffer.
2212        let end = e.min(mstr.len());
2213        let start = b.min(end);
2214        r.push_str(&mstr_owned[start..end]); // c:2621
2215        t = true; // c:2622
2216    }
2217    if (fl & SUB_REST) != 0 {
2218        // c:2624 — copy unmatched portion. If both portions requested,
2219        // put a space in between (why?).
2220        if t {
2221            r.push(' '); // c:2627
2222        }
2223        // c:2629-2630 — unmatched bits before the match.
2224        let pre = b.min(mstr.len());
2225        r.push_str(&mstr_owned[..pre]); // c:2630
2226        if let Some(ref rs) = replstr {
2227            r.push_str(rs); // c:2632-2633 copy replstr
2228        }
2229        // c:2634-2635 — unmatched bits after the match.
2230        let post = e.min(mstr.len());
2231        r.push_str(&mstr_owned[post..]); // c:2635
2232        t = true; // c:2636
2233    }
2234    if bl != 0 {
2235        // c:2639-2643 — append the numeric buffer; space first if needed.
2236        if t {
2237            r.push(' '); // c:2642
2238        }
2239        r.push_str(&buf); // c:2643 strcpy(rr, buf)
2240    }
2241    Some(r) // c:2645 return r
2242}
2243
2244/// Compile pattern and get match info (from glob.c compgetmatch line 2650)
2245/// Port of `compgetmatch(char *pat, int *flp, char **replstrp)` from `Src/glob.c:2650`.
2246/// WARNING: param names don't match C — Rust=(pat) vs C=(pat, flp, replstrp)
2247pub fn compgetmatch(pat: &str) -> Option<(String, i32)> {
2248    // c:1993 — `SUB_START` (anchor at head) / `SUB_END` (anchor at
2249    // tail) / `SUB_LONG` (`##`/`%%` doubled = longest). All three
2250    // imported from the canonical zsh_h.rs port; the previous local
2251    // redeclaration risked the same drift hazard as the HIST_*
2252    // bit-value bug caught earlier.
2253    let mut flags: i32 = 0;
2254    let mut pattern = pat.to_string();
2255
2256    if pattern.starts_with('#') {
2257        flags |= SUB_START;
2258        pattern = pattern[1..].to_string();
2259    }
2260    if pattern.starts_with("##") {
2261        flags |= SUB_START | SUB_LONG;
2262        pattern = pattern[2..].to_string();
2263    }
2264    if pattern.ends_with('%') {
2265        flags |= SUB_END;
2266        pattern.pop();
2267    }
2268    if pattern.ends_with("%%") {
2269        flags |= SUB_END | SUB_LONG;
2270        pattern.truncate(pattern.len().saturating_sub(2));
2271    }
2272
2273    Some((pattern, flags))
2274}
2275
2276/// Port of `getmatch(char **sp, char *pat, int fl, int n, char *replstr)`
2277/// from `Src/glob.c:2710`. C body (4 lines):
2278///   `Patprog p;
2279///    if (!(p = compgetmatch(pat, &fl, &replstr))) return 1;
2280///    return igetmatch(sp, p, fl, n, replstr, NULL);`
2281/// Rust returns the resulting string (callers don't take a `**sp`
2282/// out-pointer); compgetmatch/igetmatch hold the real prepare +
2283/// match-and-replace logic.
2284pub fn getmatch(sp: &str, pat: &str, fl: i32, n: i32, replstr: Option<&str>) -> String {
2285    let (prep_pat, prep_fl) = match compgetmatch(pat) {
2286        // c:2713
2287        Some(t) => t,
2288        None => return sp.to_string(), // c:2713 return 1
2289    };
2290    let mut buf = sp.to_string();
2291    igetmatch(&mut buf, &prep_pat, prep_fl | fl, n, replstr); // c:2715
2292    buf
2293}
2294
2295/// Get match for array elements (from glob.c getmatcharr lines 2690-2750)
2296/// Port of `getmatcharr(char ***ap, char *pat, int fl, int n, char *replstr)` from `Src/glob.c:2727`.
2297pub fn getmatcharr(
2298    ap: &[String],
2299    pat: &str,
2300    fl: i32,
2301    n: i32,
2302    replstr: Option<&str>,
2303) -> Vec<String> {
2304    ap.iter()
2305        .map(|s| getmatch(s, pat, fl, n, replstr))
2306        .collect()
2307}
2308
2309/// Port of `int getmatchlist(char *str, Patprog p, LinkList *repllistp)`
2310/// from `Src/glob.c:2749`.
2311/// ```c
2312/// int
2313/// getmatchlist(char *str, Patprog p, LinkList *repllistp)
2314/// {
2315///     char **sp = &str;
2316///     return igetmatch(sp, p, SUB_LONG|SUB_GLOBAL|SUB_SUBSTR|SUB_LIST,
2317///                      0, NULL, repllistp);
2318/// }
2319/// ```
2320/// 3-line delegation to `igetmatch` with the canonical flag set. The
2321/// `repllistp` out-param is the LinkList that receives the match
2322/// position pairs; the Rust port currently lacks a repllistp out-
2323/// channel on `igetmatch`, so this entry mirrors the C structure
2324/// and returns the igetmatch status.
2325pub fn getmatchlist(sp: &mut String, p: &str) -> i32 {
2326    // c:2749
2327    igetmatch(
2328        sp,
2329        p,
2330        SUB_LONG | SUB_GLOBAL | SUB_SUBSTR | SUB_LIST, // c:2761
2331        0,
2332        None,
2333    ) // c:2762
2334}
2335
2336/// File-static `static int in_expandredir = 0;` from `Src/glob.c:1206`
2337/// — set during the `globlist` call inside `xpandredir` so the glob
2338/// pipeline knows the result feeds a redirection (suppresses the
2339/// "no matches" warning in the caller's normal flow).
2340pub static IN_EXPANDREDIR: std::sync::atomic::AtomicI32 = // c:1206
2341    std::sync::atomic::AtomicI32::new(0);
2342
2343/// Direct port of `int xpandredir(struct redir *fn, LinkList redirtab)`
2344/// from `Src/glob.c:2150`. Expands `>>*.c`-style redirections by
2345/// running `prefork` + (when MULTIOS is set) `globlist` over the redir
2346/// name. Single match: rewrite `fn->name` in place and decode the
2347/// MERGEIN/MERGEOUT special syntax (`-` close, `p` pipe-fd, digits =
2348/// fd number). Multi match: clone the redir for each name and append
2349/// to `redirtab`. Returns 1 if multios produced multiple entries, 0
2350/// otherwise.
2351/// WARNING: param names don't match C — Rust=(fn_, redirtab) vs C=(fn, redirtab)
2352pub fn xpandredir(
2353    fn_: &mut redir, // c:2150
2354    redirtab: &mut Vec<redir>,
2355) -> i32 {
2356    use std::sync::atomic::Ordering::SeqCst;
2357    let mut ret = 0; // c:2156
2358    let name = match fn_.name.as_deref() {
2359        // c:2160 init_list1(fake, fn->name)
2360        Some(n) => n.to_string(),
2361        None => return 0,
2362    };
2363    let mut fake: LinkList = LinkList::new();
2364    fake.push_back(name); // c:2160
2365    let mut rf = 0i32;
2366    prefork(
2367        &mut fake, // c:2162 prefork
2368        if isset(MULTIOS) { 0 } else { PREFORK_SINGLE },
2369        &mut rf,
2370    );
2371    // c:2164 — `if (!errflag && isset(MULTIOS))`. C uses LOGICAL NOT
2372    // on `errflag` (an `int`), so the condition is "errflag is zero".
2373    // The previous Rust port wrote `!errflag.load(...) != 0` which is
2374    // BITWISE NOT in Rust (`!i32` is `^-1`), making the condition
2375    // equivalent to `errflag != -1` — true for every common errflag
2376    // value (0 / ERRFLAG_ERROR=1 / ERRFLAG_INT=2). Result: the
2377    // globlist call ran even when errflag was set, papering over
2378    // the abort path that C uses to bail early on prior errors.
2379    if errflag.load(SeqCst) == 0 && isset(MULTIOS) {
2380        // c:2164
2381        IN_EXPANDREDIR.store(1, SeqCst); // c:2165
2382        crate::ported::subst::globlist(&mut fake, 0); // c:2166
2383        IN_EXPANDREDIR.store(0, SeqCst); // c:2167
2384    }
2385    if errflag.load(SeqCst) != 0 {
2386        return 0;
2387    } // c:2169
2388    let names: Vec<String> = fake.iter().cloned().collect();
2389    if names.len() == 1 {
2390        // c:2171 nonempty(&fake) && !nextnode(firstnode)
2391        let s = crate::lex::untokenize(&names[0]); // c:2174 untokenize(s)
2392        fn_.name = Some(s.clone()); // c:2173 fn->name = s
2393        if fn_.typ == REDIR_MERGEIN || fn_.typ == REDIR_MERGEOUT {
2394            // c:2175
2395            let bytes = s.as_bytes();
2396            if bytes.len() == 1 && IS_DASH(bytes[0] as char) {
2397                // c:2176-2177 IS_DASH(s[0]) && !s[1]
2398                fn_.typ = REDIR_CLOSE;
2399            } else if bytes.len() == 1 && bytes[0] == b'p' {
2400                // c:2178 s[0]=='p' && !s[1]
2401                fn_.fd2 = -2;
2402            } else {
2403                let mut i = 0;
2404                while i < bytes.len() && bytes[i].is_ascii_digit() {
2405                    i += 1;
2406                } // c:2181 idigit
2407                if i == bytes.len() && i > 0 {
2408                    // c:2183 !*s && s > fn->name
2409                    fn_.fd2 = crate::ported::utils::zstrtol(&s, 10).0 as i32; // c:2184 zstrtol(.., 10)
2410                } else if fn_.typ == REDIR_MERGEIN {
2411                    // c:2185
2412                    zerr("file number expected"); // c:2186
2413                } else {
2414                    fn_.typ = REDIR_ERRWRITE; // c:2188
2415                }
2416            }
2417        }
2418    } else if fn_.typ == REDIR_MERGEIN {
2419        // c:2192
2420        zerr("file number expected"); // c:2193
2421    } else {
2422        if fn_.typ == REDIR_MERGEOUT {
2423            fn_.typ = REDIR_ERRWRITE;
2424        } // c:2195
2425        for nam in names {
2426            // c:2196 while ((nam = ugetnode(&fake)))
2427            let mut ff = fn_.clone(); // c:2199-2200 zhalloc + *ff = *fn
2428            ff.name = Some(nam); // c:2201 ff->name = nam
2429            redirtab.push(ff); // c:2202 addlinknode(redirtab, ff)
2430            ret = 1; // c:2203
2431        }
2432    }
2433    ret // c:2206
2434}
2435
2436/// Port of `freerepldata(void *ptr)` from Src/glob.c:2766.
2437/// C: `static void freerepldata(void *ptr)` →
2438///   `zfree(ptr, sizeof(struct repldata));`
2439#[allow(unused_variables)]
2440pub fn freerepldata(ptr: *mut std::ffi::c_void) { // c:2766
2441                                                  // Rust drop covers the equivalent.
2442}
2443
2444/// Port of `freematchlist(LinkList repllist)` from Src/glob.c:2773.
2445/// C: `void freematchlist(LinkList repllist)` →
2446///   `freelinklist(repllist, freerepldata);`
2447///
2448/// The C `repllist` is a `LinkList` of `struct repldata` (the same
2449/// node `get_match_ret` records for SUB_GLOBAL/SUB_LIST). The Rust
2450/// port operates on `Vec<repldata>` — matching the canonical
2451/// `imatchdata.repllist` type — not the prior ad-hoc `Vec<(usize,usize)>`.
2452/// Clearing the Vec drops each `repldata` (Rust's `freerepldata` +
2453/// `freelinklist` equivalent).
2454pub fn freematchlist(repllist: Option<&mut Vec<repldata>>) {
2455    // c:2773
2456    if let Some(l) = repllist {
2457        l.clear(); // c:2776 freelinklist(repllist, freerepldata)
2458    }
2459}
2460
2461/// Port of `set_pat_start(Patprog p, int offs)` from `Src/glob.c:2780`.
2462///
2463/// When we advance up the test string from its start, tell the pattern
2464/// matcher that a start-of-string assertion `(#s)` should fail: set
2465/// `PAT_NOTSTART` when `offs` is nonzero (the real start is past the
2466/// actual start), clear it when `offs == 0`. Mutates `p->flags`.
2467/// Replaces the prior fake that sliced the pattern string and returned
2468/// a substring — unrelated to the C behaviour (the matcher reads
2469/// `PAT_NOTSTART` off `prog.flags`, see pattern.rs:4792).
2470pub fn set_pat_start(p: &mut Patprog, offs: i32) {
2471    // c:2780
2472    if offs != 0 {
2473        p.0.flags |= PAT_NOTSTART; // c:2790 p->flags |= PAT_NOTSTART
2474    } else {
2475        p.0.flags &= !PAT_NOTSTART; // c:2792 p->flags &= ~PAT_NOTSTART
2476    }
2477}
2478
2479/// Port of `set_pat_end(Patprog p, char null_me)` from `Src/glob.c:2796`.
2480///
2481/// When we shorten the string at the tail, tell the pattern matcher
2482/// that an end-of-string assertion `(#e)` should fail: set `PAT_NOTEND`
2483/// when the char `null_me` about to be zapped is non-NUL, clear it when
2484/// it is already NUL. Mutates `p->flags`. Replaces the prior fake that
2485/// sliced the pattern string and returned a prefix (the matcher reads
2486/// `PAT_NOTEND` off `prog.flags`, see pattern.rs:2803).
2487pub fn set_pat_end(p: &mut Patprog, null_me: u8) {
2488    // c:2796
2489    if null_me != 0 {
2490        p.0.flags |= PAT_NOTEND; // c:2806 p->flags |= PAT_NOTEND
2491    } else {
2492        p.0.flags &= !PAT_NOTEND; // c:2808 p->flags &= ~PAT_NOTEND
2493    }
2494}
2495
2496/// Port of `igetmatch(char **sp, Patprog p, int fl, int n, char *replstr, LinkList *repllistp)` from Src/glob.c:2832.
2497/// C: `static int igetmatch(char **sp, Patprog p, int fl, int n,
2498///     char *replstr, LinkList *repllistp)` — pattern-replace inner
2499///     matcher; modifies `*sp` in place, optionally collects match
2500///     positions into `*repllistp`.
2501/// WARNING: param names don't match C — Rust=(sp, p, fl, n, replstr) vs C=(sp, p, fl, n, replstr, repllistp)
2502pub fn igetmatch(
2503    sp: &mut String,
2504    p: &str,
2505    fl: i32,
2506    _n: i32, // c:2832
2507    replstr: Option<&str>,
2508) -> i32 {
2509    // c:2840-3100+ — full SUB_* dispatch: longest/shortest/global/end-
2510    // anchor replacement loop with multibyte tracking. Rust port walks
2511    // chars + `matchpat`; full Patprog substrate (with chunked DFA
2512    // execution) lives in src/ported/pattern.rs. SUB_START imported
2513    // from zsh_h.rs at top-of-file rather than redeclared locally.
2514    let anchored_start = (fl & SUB_START) != 0;
2515    let anchored_end = (fl & SUB_END) != 0;
2516    let substr_mode = (fl & SUB_SUBSTR) != 0;
2517    let shortest = (fl & SUB_LONG) == 0;
2518    let chars: Vec<char> = sp.chars().collect();
2519    let len = chars.len();
2520
2521    // c:2887-2898 — SUB_ALL: entire-string match flag.
2522    if (fl & SUB_ALL) != 0 {
2523        // c:2887
2524        let i = matchpat(p, sp, true, true); // c:2888 pattrylen
2525        if !i {
2526            // c:2889
2527            // c:2890-2893 — no match: clear replstr.
2528            if (fl & SUB_MATCH) != 0 {
2529                // c:2895
2530                *sp = String::new();
2531                return 0; // c:2896
2532            }
2533            return 1; // c:2897
2534        }
2535        if let Some(r) = replstr {
2536            // c:2894 get_match_ret
2537            *sp = r.to_string();
2538        }
2539        if sp.is_empty() && (fl & SUB_REST) != 0 && i {
2540            // c:2895
2541            return 0; // c:2896
2542        }
2543        return 1; // c:2897
2544    }
2545
2546    if len == 0 {
2547        return 1;
2548    }
2549    // c:2998-3041 — SUB_LIST: collect all match offset pairs.
2550    if (fl & SUB_LIST) != 0 {
2551        // c:2998
2552        // Walk all substrings; collect (start, end) pairs the caller
2553        // can read via subsequent `getmatchlist` invocations. Without
2554        // a `repllistp` out-channel through the Rust signature, we
2555        // only walk to validate at-least-one-match; the canonical
2556        // collection point is the caller-supplied LinkList that the
2557        // C body populates at c:3000+. Defer until the out-vec lands.
2558        let mut found = false;
2559        for start in 0..len {
2560            // c:3008
2561            for end in (start + 1)..=len {
2562                // c:3009
2563                let s2: String = chars[start..end].iter().collect();
2564                if matchpat(p, &s2, true, true) {
2565                    // c:3010
2566                    found = true;
2567                    if shortest {
2568                        break;
2569                    } // c:3011 SUB_LONG vs short
2570                }
2571            }
2572            if !substr_mode {
2573                break;
2574            }
2575        }
2576        return if found { 0 } else { 1 };
2577    }
2578
2579    // c:Src/glob.c:2900+ — SUB_MATCH inverts the disposition of the
2580    // anchored-strip operators. Default (no SUB_MATCH): the matched
2581    // portion is REMOVED and the rest is returned (`${var#pat}` →
2582    // tail after the matched prefix). With SUB_MATCH: the matched
2583    // portion is KEPT and the rest is removed (`${(M)var#pat}` → the
2584    // matched prefix only). Previously honoured only in the SUB_ALL
2585    // arm at c:2895; the anchored-prefix / anchored-suffix / substr
2586    // arms below always returned prefix+suffix, so `(M)` was a no-op
2587    // for `#pat` / `%pat`. Mirror the C SUB_MATCH branch by emitting
2588    // just the matched slice.
2589    let match_only = (fl & SUB_MATCH) != 0;
2590    let (match_start, match_end) = if anchored_start && anchored_end {
2591        if matchpat(p, sp, true, true) {
2592            (0, len)
2593        } else {
2594            if match_only {
2595                *sp = String::new();
2596                return 0;
2597            }
2598            return 1;
2599        }
2600    } else if anchored_start {
2601        let mut best_end = 0;
2602        for end in 1..=len {
2603            let substr: String = chars[..end].iter().collect();
2604            if matchpat(p, &substr, true, true) {
2605                if shortest {
2606                    *sp = if match_only {
2607                        chars[..end].iter().collect()
2608                    } else {
2609                        match replstr {
2610                            Some(r) => format!("{}{}", r, chars[end..].iter().collect::<String>()),
2611                            None => chars[end..].iter().collect(),
2612                        }
2613                    };
2614                    return 0;
2615                }
2616                best_end = end;
2617            }
2618        }
2619        if best_end > 0 {
2620            (0, best_end)
2621        } else {
2622            if match_only {
2623                *sp = String::new();
2624                return 0;
2625            }
2626            return 1;
2627        }
2628    } else if anchored_end {
2629        let mut best_start = len;
2630        for start in (0..len).rev() {
2631            let substr: String = chars[start..].iter().collect();
2632            if matchpat(p, &substr, true, true) {
2633                if shortest {
2634                    *sp = if match_only {
2635                        chars[start..].iter().collect()
2636                    } else {
2637                        match replstr {
2638                            Some(r) => {
2639                                format!("{}{}", chars[..start].iter().collect::<String>(), r)
2640                            }
2641                            None => chars[..start].iter().collect(),
2642                        }
2643                    };
2644                    return 0;
2645                }
2646                best_start = start;
2647            }
2648        }
2649        if best_start < len {
2650            (best_start, len)
2651        } else {
2652            if match_only {
2653                *sp = String::new();
2654                return 0;
2655            }
2656            return 1;
2657        }
2658    } else {
2659        for start in 0..len {
2660            for end in (start + 1)..=len {
2661                let substr: String = chars[start..end].iter().collect();
2662                if matchpat(p, &substr, true, true) {
2663                    if match_only {
2664                        *sp = chars[start..end].iter().collect();
2665                        return 0;
2666                    }
2667                    let prefix: String = chars[..start].iter().collect();
2668                    let suffix: String = chars[end..].iter().collect();
2669                    *sp = match replstr {
2670                        Some(r) => format!("{}{}{}", prefix, r, suffix),
2671                        None => format!("{}{}", prefix, suffix),
2672                    };
2673                    return 0;
2674                }
2675            }
2676        }
2677        if match_only {
2678            *sp = String::new();
2679            return 0;
2680        }
2681        return 1;
2682    };
2683    *sp = if match_only {
2684        chars[match_start..match_end].iter().collect()
2685    } else {
2686        let prefix: String = chars[..match_start].iter().collect();
2687        let suffix: String = chars[match_end..].iter().collect();
2688        match replstr {
2689            Some(r) => format!("{}{}{}", prefix, r, suffix),
2690            None => format!("{}{}", prefix, suffix),
2691        }
2692    };
2693    0
2694}
2695
2696// ============================================================================
2697// Tokenization (from glob.c tokenize family)
2698// ============================================================================
2699
2700// `enum GlobToken` deleted — C uses the byte-token constants
2701// (`Star`/`Quest`/`Inpar`/...) from `Src/zsh.h:159-200`, mirrored in
2702// the Rust port at `zsh_h.rs:128-160`. `tokenize()` (`Src/glob.c:3548`
2703// → `zshtokenize`) mutates the input string in place, replacing each
2704// glob-metacharacter with its high-bit byte token; the Rust port now
2705// matches.
2706
2707// `ztokens[]` from `Src/lex.c:38` — the source-char ↔ token-byte
2708// table the C tokenizer indexes with `(t - ztokens) + Pound`. Each
2709// position N in the string maps to high-bit byte `Pound + N`.
2710pub const ZTOKENS: &str = "#$^*(())$=|{}[]`<>>?~`,-!'\"\\\\";
2711
2712/// Tokenize a glob pattern in place — port of `tokenize(char *s)` from
2713/// `Src/glob.c:3548`. One-line C delegation: `zshtokenize(s, 0)`.
2714pub fn tokenize(s: &mut String) {
2715    // c:3548
2716    zshtokenize(s, 0); // c:3552
2717}
2718
2719/// Tokenize for shell — port of `shtokenize(char *s)` from
2720/// `Src/glob.c:3563`. Builds flags from SHGLOB option then delegates
2721/// to `zshtokenize`.
2722pub fn shtokenize(s: &mut String) {
2723    // c:3563
2724    let mut flags = ZSHTOK_SUBST; // c:3567
2725    if isset(SHGLOB) {
2726        // c:3568
2727        flags |= ZSHTOK_SHGLOB; // c:3569
2728    }
2729    zshtokenize(s, flags); // c:3570
2730}
2731
2732/// Port of `zshtokenize(char *s, int flags)` from `Src/glob.c:3575`.
2733/// Walks `s` in place, replacing each glob-metachar with its high-bit
2734/// byte token from the `ZTOKENS` table; respects ZSHTOK_SUBST (use
2735/// Bnullkeep for escaped tokens) and ZSHTOK_SHGLOB (don't tokenize
2736/// `<` / `(` / `|` / `)`).
2737pub fn zshtokenize(s: &mut String, flags: i32) {
2738    // c:3575
2739    let ztokens: Vec<char> = ZTOKENS.chars().collect();
2740    let mut chars: Vec<char> = s.chars().collect();
2741    let mut bslash = false; // c:3578
2742    let mut i = 0;
2743    while i < chars.len() {
2744        // c:3580 for (; *s; s++)
2745        let c = chars[i];
2746        match c {
2747            x if x == Meta as char => {                                              // c:3583 case Meta
2748                i += 2;                                                      // c:3585 skip both Meta and next
2749                bslash = false;
2750                continue;
2751            }
2752            x if x == Bnull || x == Bnullkeep || x == '\\' => {              // c:3587-3589
2753                if bslash {                                                  // c:3590
2754                    chars[i - 1] = if (flags & ZSHTOK_SUBST) != 0 {          // c:3591
2755                        Bnullkeep
2756                    } else {
2757                        Bnull
2758                    };
2759                } else {
2760                    bslash = true;                                           // c:3595
2761                    i += 1;
2762                    continue;                                                // c:3596 (skip bslash=0 reset)
2763                }
2764            }
2765            '<' => {                                                         // c:3598
2766                if (flags & ZSHTOK_SHGLOB) != 0 {                            // c:3599
2767                    // break — no tokenization
2768                } else if bslash {                                           // c:3601
2769                    chars[i - 1] = if (flags & ZSHTOK_SUBST) != 0 {
2770                        Bnullkeep
2771                    } else {
2772                        Bnull
2773                    };
2774                } else {
2775                    // c:3605-3614 — try to parse `<N-N>` redirection.
2776                    let t = i;                                               // c:3605
2777                    let mut j = i + 1;
2778                    while j < chars.len() && chars[j].is_ascii_digit() {     // c:3606 idigit
2779                        j += 1;
2780                    }
2781                    if j < chars.len() && (chars[j] == '-') {                // c:3607 IS_DASH
2782                        let mut k = j + 1;
2783                        while k < chars.len() && chars[k].is_ascii_digit() { // c:3609
2784                            k += 1;
2785                        }
2786                        if k < chars.len() && chars[k] == '>' {              // c:3611
2787                            chars[t] = Inang;                                // c:3613
2788                            chars[k] = Outang;                               // c:3614
2789                            i = k + 1;
2790                            bslash = false;
2791                            continue;
2792                        }
2793                    }
2794                    // c:3608/3611 `goto cont` — re-switch on current *s;
2795                    // since none of the conditions matched, fall through.
2796                }
2797            }
2798            '(' | '|' | ')' if (flags & ZSHTOK_SHGLOB) != 0 => {             // c:3617-3620
2799                // no tokenization under SHGLOB
2800            }
2801            '>' | '^' | '#' | '~' | '[' | ']' | '*' | '?'                    // c:3621-3631
2802            | '=' | '-' | '!' | '(' | '|' | ')' => {
2803                for (n, &t) in ztokens.iter().enumerate() {                  // c:3633
2804                    if t == c {                                              // c:3634
2805                        if bslash {                                          // c:3635
2806                            chars[i - 1] = if (flags & ZSHTOK_SUBST) != 0 {
2807                                Bnullkeep
2808                            } else {
2809                                Bnull
2810                            };
2811                        } else {
2812                            chars[i] = char::from_u32(Pound as u32 + n as u32)
2813                                .unwrap_or(c);                                // c:3638
2814                        }
2815                        break;                                                // c:3639
2816                    }
2817                }
2818            }
2819            _ => {}
2820        }
2821        bslash = false; // c:3646
2822        i += 1;
2823    }
2824    *s = chars.into_iter().collect();
2825}
2826
2827/// Port of `void remnulargs(char *s)` from `Src/glob.c:3649`.
2828///
2829/// C body (c:3651-3676): walks `s` looking for INULL bytes.
2830///   - `Bnullkeep` in SCAN phase: skip (the `continue` at c:3658)
2831///     — don't treat as a null marker.
2832///   - Any other INULL: switch to COPY phase. In copy phase:
2833///       - `Bnullkeep` becomes literal `\` (the "active backslash"
2834///         is re-materialized).
2835///       - Other INULLs: stripped.
2836///       - Non-INULL: kept.
2837///   - If the post-copy string is empty, replace with `Nularg`
2838///     (single-char empty-arg marker).
2839///
2840/// The previous Rust port collapsed the body to
2841/// `s.retain(|c| c != '\0' && c != Bnullkeep)` — a simple
2842/// strip of NUL and Bnullkeep. Three divergences:
2843///   - Stripped Bnullkeep entirely instead of preserving it in
2844///     the scan-only phase (when it appears BEFORE any other
2845///     inull) or converting to `\` in the copy phase.
2846///   - Didn't strip Snull/Dnull/Bnull/Nularg — those inulls
2847///     stayed in the output, polluting downstream processing.
2848///   - Didn't emit Nularg sentinel for empty post-strip strings.
2849pub fn remnulargs(s: &mut String) {
2850    // c:3649
2851    if s.is_empty() {
2852        // c:3654
2853        return;
2854    }
2855    // c:3656 `inull(c)` predicate: Snull / Dnull / Bnull / Bnullkeep / Nularg.
2856    let is_inull =
2857        |c: char| c == Snull || c == Dnull || c == Bnull || c == Bnullkeep || c == Nularg;
2858    let src: Vec<char> = s.chars().collect();
2859    let mut out: Vec<char> = Vec::with_capacity(src.len());
2860    let mut i = 0usize;
2861    // rs-only adaptation of C's byte walk: C strings are byte arrays and
2862    // store an eight-bit char (e.g. `$'\M-\C-a'` = 0x81) RAW, so its byte
2863    // never aliases an inull sentinel. A Rust `String` is UTF-8, so zshrs
2864    // METAFIES that byte — Meta (0x83) + (byte ^ 0x20) — and 0x81 ^ 0x20 =
2865    // 0xA1, which IS the Nularg sentinel (0xBD/0xBE/0xBF/0xBC likewise
2866    // alias Snull/Dnull/Bnull/Bnullkeep). A metafied byte is DATA, not a
2867    // sentinel, so treat `Meta` + next char as an opaque pair on BOTH the
2868    // scan and copy walks — otherwise `${(qq)v}` for v=$'\M-\C-a' dropped
2869    // the 0xA1 and left the lone Meta (rs emitted `'\xc2\x83'` where zsh
2870    // emits `'\x81'`). C's byte walk needs no such guard.
2871    // Meta is a byte constant (0x83); a metafied string stores it as the
2872    // char U+0083, so compare against the char form.
2873    let meta = char::from(crate::ported::zsh_h::Meta);
2874    // c:3656 — SCAN phase: copy chars, skip standalone Bnullkeep.
2875    while i < src.len() {
2876        let c = src[i];
2877        if c == meta && i + 1 < src.len() {
2878            out.push(c);
2879            out.push(src[i + 1]);
2880            i += 2;
2881            continue;
2882        }
2883        if c == Bnullkeep {
2884            // c:3657 continue
2885            i += 1;
2886            continue;
2887        }
2888        if is_inull(c) {
2889            // c:3663 inull(c)
2890            // c:3664+ — COPY phase: walk the rest, fold Bnullkeep
2891            // to `\\`, strip other inulls.
2892            i += 1;
2893            while i < src.len() {
2894                let d = src[i];
2895                if d == meta && i + 1 < src.len() {
2896                    // Metafied data pair — keep verbatim.
2897                    out.push(d);
2898                    out.push(src[i + 1]);
2899                    i += 2;
2900                    continue;
2901                }
2902                if d == Bnullkeep {
2903                    // c:3666
2904                    out.push('\\'); // c:3667
2905                } else if !is_inull(d) {
2906                    // c:3668
2907                    out.push(d); // c:3669
2908                }
2909                i += 1;
2910            }
2911            break;
2912        }
2913        // SCAN phase non-inull: keep verbatim.
2914        out.push(c);
2915        i += 1;
2916    }
2917    // c:3673-3675 — empty result → Nularg sentinel.
2918    if out.is_empty() {
2919        out.push(Nularg);
2920    }
2921    *s = out.into_iter().collect();
2922}
2923
2924/// Port of `qualdev(UNUSED(char *name), struct stat *buf, off_t dv, UNUSED(char *dummy))` from Src/glob.c:3688.
2925/// C: `static int qualdev(UNUSED(char *name), struct stat *buf, off_t dv,
2926///     UNUSED(char *dummy))` → `return (off_t)buf->st_dev == dv;`
2927#[allow(unused_variables)]
2928pub fn qualdev(name: &str, buf: &libc::stat, dv: i64, dummy: &str) -> i32 {
2929    // c:3688
2930    (buf.st_dev as i64 == dv) as i32 // c:3697
2931}
2932
2933/// Port of `qualnlink(UNUSED(char *name), struct stat *buf, off_t ct, UNUSED(char *dummy))` from Src/glob.c:3697.
2934/// C: ternary on `g_range`: < / > / == against `st_nlink`.
2935#[allow(unused_variables)]
2936pub fn qualnlink(name: &str, buf: &libc::stat, ct: i64, dummy: &str) -> i32 {
2937    // c:3697
2938    // c:3699-3701 — `g_range` selects `<` / `>` / `==`. This MUST read the same
2939    // `g_range` static the qual-eval sets (glob.rs ~5020) and that qualsize /
2940    // qualtime read — not the stale Rust-only `G_RANGE` duplicate, which was
2941    // never stored, leaving every `l[+-]N` comparison stuck on `==`.
2942    let g = g_range.load(Ordering::Relaxed);
2943    let nl = buf.st_nlink as i64; // c:3708
2944    if g < 0 {
2945        (nl < ct) as i32
2946    } else if g > 0 {
2947        (nl > ct) as i32
2948    } else {
2949        (nl == ct) as i32
2950    }
2951}
2952
2953/// Port of `qualuid(UNUSED(char *name), struct stat *buf, off_t uid, UNUSED(char *dummy))` from Src/glob.c:3708.
2954/// C: `return buf->st_uid == uid;`
2955#[allow(unused_variables)]
2956pub fn qualuid(name: &str, buf: &libc::stat, uid: i64, dummy: &str) -> i32 {
2957    // c:3708
2958    (buf.st_uid as i64 == uid) as i32 // c:3717
2959}
2960
2961/// Port of `qualgid(UNUSED(char *name), struct stat *buf, off_t gid, UNUSED(char *dummy))` from Src/glob.c:3717.
2962/// C: `return buf->st_gid == gid;`
2963#[allow(unused_variables)]
2964pub fn qualgid(name: &str, buf: &libc::stat, gid: i64, dummy: &str) -> i32 {
2965    // c:3717
2966    (buf.st_gid as i64 == gid) as i32 // c:3726
2967}
2968
2969/// Port of `qualisdev(UNUSED(char *name), struct stat *buf, UNUSED(off_t junk), UNUSED(char *dummy))` from Src/glob.c:3726.
2970/// C: `return S_ISBLK(buf->st_mode) || S_ISCHR(buf->st_mode);`
2971#[allow(unused_variables)]
2972pub fn qualisdev(name: &str, buf: &libc::stat, junk: i64, dummy: &str) -> i32 {
2973    // c:3726
2974    let m = buf.st_mode as u32 & libc::S_IFMT as u32;
2975    ((m == libc::S_IFBLK as u32) || (m == libc::S_IFCHR as u32)) as i32 // c:3735
2976}
2977
2978/// Port of `qualisblk(UNUSED(char *name), struct stat *buf, UNUSED(off_t junk), UNUSED(char *dummy))` from Src/glob.c:3735.
2979/// C: `return S_ISBLK(buf->st_mode);`
2980#[allow(unused_variables)]
2981pub fn qualisblk(name: &str, buf: &libc::stat, junk: i64, dummy: &str) -> i32 {
2982    // c:3735
2983    ((buf.st_mode as u32 & libc::S_IFMT as u32) == libc::S_IFBLK as u32) as i32 // c:3744
2984}
2985
2986/// Port of `qualischr(UNUSED(char *name), struct stat *buf, UNUSED(off_t junk), UNUSED(char *dummy))` from Src/glob.c:3744.
2987/// C: `return S_ISCHR(buf->st_mode);`
2988#[allow(unused_variables)]
2989pub fn qualischr(name: &str, buf: &libc::stat, junk: i64, dummy: &str) -> i32 {
2990    // c:3744
2991    ((buf.st_mode as u32 & libc::S_IFMT as u32) == libc::S_IFCHR as u32) as i32 // c:3753
2992}
2993
2994/// Port of `qualisdir(UNUSED(char *name), struct stat *buf, UNUSED(off_t junk), UNUSED(char *dummy))` from Src/glob.c:3753.
2995/// C: `return S_ISDIR(buf->st_mode);`
2996#[allow(unused_variables)]
2997pub fn qualisdir(name: &str, buf: &libc::stat, junk: i64, dummy: &str) -> i32 {
2998    // c:3753
2999    ((buf.st_mode as u32 & libc::S_IFMT as u32) == libc::S_IFDIR as u32) as i32 // c:3762
3000}
3001
3002/// Port of `qualisfifo(UNUSED(char *name), struct stat *buf, UNUSED(off_t junk), UNUSED(char *dummy))` from Src/glob.c:3762.
3003/// C: `return S_ISFIFO(buf->st_mode);`
3004#[allow(unused_variables)]
3005pub fn qualisfifo(name: &str, buf: &libc::stat, junk: i64, dummy: &str) -> i32 {
3006    // c:3762
3007    ((buf.st_mode as u32 & libc::S_IFMT as u32) == libc::S_IFIFO as u32) as i32 // c:3771
3008}
3009
3010/// Port of `qualislnk(UNUSED(char *name), struct stat *buf, UNUSED(off_t junk), UNUSED(char *dummy))` from Src/glob.c:3771.
3011/// C: `return S_ISLNK(buf->st_mode);`
3012#[allow(unused_variables)]
3013pub fn qualislnk(name: &str, buf: &libc::stat, junk: i64, dummy: &str) -> i32 {
3014    // c:3771
3015    ((buf.st_mode as u32 & libc::S_IFMT as u32) == libc::S_IFLNK as u32) as i32 // c:3780
3016}
3017
3018/// Port of `qualisreg(UNUSED(char *name), struct stat *buf, UNUSED(off_t junk), UNUSED(char *dummy))` from Src/glob.c:3780.
3019/// C: `return S_ISREG(buf->st_mode);`
3020#[allow(unused_variables)]
3021pub fn qualisreg(name: &str, buf: &libc::stat, junk: i64, dummy: &str) -> i32 {
3022    // c:3780
3023    ((buf.st_mode as u32 & libc::S_IFMT as u32) == libc::S_IFREG as u32) as i32 // c:3789
3024}
3025
3026/// Port of `qualissock(UNUSED(char *name), struct stat *buf, UNUSED(off_t junk), UNUSED(char *dummy))` from Src/glob.c:3789.
3027/// C: `return S_ISSOCK(buf->st_mode);`
3028#[allow(unused_variables)]
3029pub fn qualissock(name: &str, buf: &libc::stat, junk: i64, dummy: &str) -> i32 {
3030    // c:3789
3031    ((buf.st_mode as u32 & libc::S_IFMT as u32) == libc::S_IFSOCK as u32) as i32
3032    // c:3798
3033}
3034
3035/// Port of `qualflags(UNUSED(char *name), struct stat *buf, off_t mod, UNUSED(char *dummy))` from Src/glob.c:3798.
3036/// C: `return mode_to_octal(buf->st_mode) & mod;`
3037#[allow(unused_variables)]
3038/// WARNING: param names don't match C — Rust=(name, buf, dummy) vs C=(name, buf, mod, dummy)
3039pub fn qualflags(name: &str, buf: &libc::stat, r#mod: i64, dummy: &str) -> i32 {
3040    // c:3798
3041    (mode_to_octal(buf.st_mode as u32) as i64 & r#mod) as i32 // c:3807
3042}
3043
3044/// Port of `qualmodeflags(UNUSED(char *name), struct stat *buf, off_t mod, UNUSED(char *dummy))` from Src/glob.c:3807.
3045/// C: `((v & y) == y && !(v & n))` where `y = mod & 07777`, `n = mod >> 12`.
3046#[allow(unused_variables)]
3047/// WARNING: param names don't match C — Rust=(name, buf, dummy) vs C=(name, buf, mod, dummy)
3048pub fn qualmodeflags(name: &str, buf: &libc::stat, r#mod: i64, dummy: &str) -> i32 {
3049    // c:3807
3050    let v = mode_to_octal(buf.st_mode as u32) as i64; // c:3818
3051    let y = r#mod & 0o7777;
3052    let n = r#mod >> 12;
3053    (((v & y) == y) && (v & n) == 0) as i32 // c:3818
3054}
3055
3056/// Port of `qualiscom(UNUSED(char *name), struct stat *buf, UNUSED(off_t mod), UNUSED(char *dummy))` from Src/glob.c:3818.
3057/// C: `return S_ISREG(buf->st_mode) && (buf->st_mode & S_IXUGO);`
3058#[allow(unused_variables)]
3059pub fn qualiscom(name: &str, buf: &libc::stat, r#mod: i64, dummy: &str) -> i32 {
3060    // c:3818
3061    let is_reg = (buf.st_mode as u32 & libc::S_IFMT as u32) == libc::S_IFREG as u32;
3062    let s_ixugo: u32 = (libc::S_IXUSR | libc::S_IXGRP | libc::S_IXOTH) as u32;
3063    (is_reg && (buf.st_mode as u32 & s_ixugo) != 0) as i32 // c:3820
3064}
3065
3066/// Port of `static int g_units;` from `Src/glob.c`. Time/size unit
3067/// selector for the qualtime / qualsize qualifier predicates. Values
3068/// come from the TT_* enum (TT_DAYS=0, TT_HOURS=1, TT_MINS=2,
3069/// TT_WEEKS=3, TT_MONTHS=4 for time; TT_BYTES=0, TT_POSIX_BLOCKS=1,
3070/// TT_KILOBYTES=2, etc. for size).
3071pub static g_units: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
3072
3073/// Port of `static int g_range;` from `Src/glob.c`. Comparison
3074/// direction for qualtime / qualsize / qualnlink: -1 = `<`, 0 = `==`,
3075/// +1 = `>`. Set by the glob qualifier parser.
3076pub static g_range: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
3077
3078/// Port of `static int g_amc;` from `Src/glob.c`. Which mtime to read
3079/// for qualtime: 0 = atime, 1 = mtime, 2 = ctime. Set by the `am`,
3080/// `mm`, `cm` qualifier letters.
3081pub static g_amc: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
3082
3083/// Direct port of `static int qualsize(UNUSED(char *name),
3084/// struct stat *buf, off_t size, UNUSED(char *dummy))` from
3085/// `Src/glob.c:3825-3866`. Glob-qualifier predicate: returns true
3086/// when `buf->st_size` (scaled by `g_units`) compares to `size`
3087/// via `g_range`.
3088///
3089/// **Previous fakery:** the file held a same-named helper that was
3090/// actually a unit-conversion *parser* `(s, units) -> Option<(i64,
3091/// &str)>` — wrong shape, wrong semantics, zero callers. The
3092/// canonical C signature is restored here; the qualifier-eval site
3093/// at glob.rs:3914 already inlines the equivalent (uses
3094/// qualifier::Size struct fields) and is the live path.
3095#[allow(non_snake_case)]
3096pub fn qualsize(_name: &str, buf: &libc::stat, size: i64, _dummy: &str) -> i32 {
3097    use std::sync::atomic::Ordering;
3098    // c:3831 `zlong scaled = buf->st_size;`
3099    let mut scaled: i64 = buf.st_size as i64;
3100    // c:3837-3860 — switch (g_units) ceiling-divide by the unit.
3101    match g_units.load(Ordering::SeqCst) {
3102        x if x == TT_POSIX_BLOCKS => {
3103            scaled = (scaled + 511) / 512; // c:3838-3841
3104        }
3105        x if x == TT_KILOBYTES => {
3106            scaled = (scaled + 1023) / 1024; // c:3842-3845
3107        }
3108        x if x == TT_MEGABYTES => {
3109            scaled = (scaled + 1_048_575) / 1_048_576; // c:3846-3849
3110        }
3111        x if x == TT_GIGABYTES => {
3112            scaled = (scaled + 1_073_741_823) / 1_073_741_824; // c:3851-3854
3113        }
3114        x if x == TT_TERABYTES => {
3115            scaled = (scaled + 1_099_511_627_775) / 1_099_511_627_776; // c:3855-3858
3116        }
3117        _ => {} // c:3860 default: bytes — no scaling.
3118    }
3119    // c:3862-3864 — `g_range < 0 ? < : g_range > 0 ? > : ==`.
3120    let r = g_range.load(Ordering::SeqCst);
3121    i32::from(if r < 0 {
3122        scaled < size
3123    } else if r > 0 {
3124        scaled > size
3125    } else {
3126        scaled == size
3127    })
3128}
3129
3130/// Direct port of `static int qualtime(UNUSED(char *name),
3131/// struct stat *buf, off_t days, UNUSED(char *dummy))` from
3132/// `Src/glob.c:3870-3901`. Glob-qualifier predicate: returns true
3133/// when `now - buf->{a,m,c}time` (selected by `g_amc`, scaled by
3134/// `g_units`) compares to `days` via `g_range`.
3135///
3136/// **Previous fakery:** same misshapen parser as the qualsize one
3137/// above. Restored to C signature; the qualifier-eval site at
3138/// glob.rs:3940 already inlines the equivalent (uses qualifier::
3139/// Atime struct fields with the live atime/mtime/ctime read).
3140#[allow(non_snake_case)]
3141pub fn qualtime(_name: &str, buf: &libc::stat, days: i64, _dummy: &str) -> i32 {
3142    use std::sync::atomic::Ordering;
3143    // c:3876-3878 — `time(&now); diff = now - (g_amc == 0 ? st_atime
3144    //                  : g_amc == 1 ? st_mtime : st_ctime);`
3145    let now: i64 = std::time::SystemTime::now()
3146        .duration_since(std::time::UNIX_EPOCH)
3147        .map(|d| d.as_secs() as i64)
3148        .unwrap_or(0);
3149    let amc = g_amc.load(Ordering::SeqCst);
3150    let stamp: i64 = match amc {
3151        0 => buf.st_atime as i64, // c:3877
3152        1 => buf.st_mtime as i64, // c:3877
3153        _ => buf.st_ctime as i64, // c:3878
3154    };
3155    let mut diff: i64 = now - stamp;
3156    // c:3880-3896 — scale by g_units.
3157    match g_units.load(Ordering::SeqCst) {
3158        x if x == TT_DAYS => diff /= 86400,     // c:3881-3883
3159        x if x == TT_HOURS => diff /= 3600,     // c:3884-3886
3160        x if x == TT_MINS => diff /= 60,        // c:3887-3889
3161        x if x == TT_WEEKS => diff /= 604800,   // c:3890-3892
3162        x if x == TT_MONTHS => diff /= 2592000, // c:3893-3895
3163        _ => {}                                 // c:3896 default: seconds.
3164    }
3165    // c:3898-3900 — same range comparison as qualsize.
3166    let r = g_range.load(Ordering::SeqCst);
3167    i32::from(if r < 0 {
3168        diff < days
3169    } else if r > 0 {
3170        diff > days
3171    } else {
3172        diff == days
3173    })
3174}
3175
3176/// Port of `static int qualsheval(char *name, UNUSED(struct stat *buf),
3177/// UNUSED(off_t days), char *str)` from `Src/glob.c:3907`.
3178///
3179/// C body (c:3907-3943):
3180/// ```c
3181/// if ((prog = parse_string(str, 0))) {
3182///     int ef = errflag, lv = lastval;             // c:3912 save
3183///     unsetparam("reply");                        // c:3915
3184///     setsparam("REPLY", ztrdup(name));           // c:3916
3185///     execode(prog, 1, 0, "globqual");            // c:3919
3186///     ret = lastval;                              // c:3921
3187///     errflag = ef | (errflag & ERRFLAG_INT);     // c:3924 restore
3188///     lastval = lv;                               // c:3925
3189///     return !ret;
3190/// }
3191/// return 0;
3192/// ```
3193///
3194/// The `e:EXPR:` glob qualifier runs `EXPR` against each match
3195/// candidate with `$REPLY` pre-set to the filename. The expr is
3196/// evaluated IN THE CURRENT SHELL — it can read shell locals,
3197/// reference shell functions, set $reply etc.
3198///
3199/// The previous Rust port spawned `/bin/sh -c <expr>` via
3200/// `Command::new("sh")`, which:
3201///   1. Loses access to current zsh function/alias/local-var scope.
3202///   2. Runs `sh` (POSIX), not `zsh` — entire `(e:zsh-feature:)`
3203///      class of qualifiers silently failed.
3204///   3. Skipped errflag/lastval save+restore.
3205///
3206/// Fixed: route through the canonical `ShellExecutor` for in-shell
3207/// evaluation. Set `REPLY` via paramtab (C `setsparam`), restore
3208/// `errflag`/`lastval` per c:3924-3925, mask any parse-time
3209/// ERRFLAG_ERROR while preserving ERRFLAG_INT (user interrupt).
3210/// WARNING: param names don't match C — Rust=(filename, expr) vs C=(name, buf, days, str)
3211pub fn qualsheval(filename: &str, _buf: &libc::stat, _data: i64, expr: &str) -> i32 {
3212    // c:3907
3213    // c:3912 — save errflag + lastval.
3214    let saved_errflag = errflag.load(Ordering::Relaxed); // c:3912
3215    let saved_lastval = LASTVAL.load(Ordering::Relaxed); // c:3912
3216                                                         // c:3915 — `unsetparam("reply");`
3217    crate::ported::params::unsetparam("reply"); // c:3915
3218                                                // c:3916 — `setsparam("REPLY", ztrdup(name));`. Set in the
3219                                                // canonical paramtab so the evaluated expression sees `$REPLY`.
3220    crate::ported::params::setsparam("REPLY", filename); // c:3916
3221                                                         // c:3919 — `execode(prog, 1, 0, "globqual");`. Route through the
3222                                                         // executor via the `crate::ported::exec` accessor wrappers
3223                                                         // (resolve the live executor on demand). Direct ShellExecutor
3224                                                         // reach-in from src/ported/ is forbidden — see memory
3225                                                         // feedback_no_exec_script_from_ported.
3226    let rc = crate::ported::exec::execute_script(expr).unwrap_or(1); // c:3919
3227    let ret = LASTVAL.load(Ordering::Relaxed); // c:3921 ret = lastval
3228    let _ = rc;
3229    // c:3924 — `errflag = ef | (errflag & ERRFLAG_INT);`. Restore
3230    // pre-call errflag plus any interrupt bit set during eval.
3231    let post_errflag = errflag.load(Ordering::Relaxed);
3232    errflag.store(
3233        saved_errflag | (post_errflag & ERRFLAG_INT),
3234        Ordering::Relaxed,
3235    ); // c:3924
3236       // c:3925 — `lastval = lv;`. Restore pre-call lastval.
3237    LASTVAL.store(saved_lastval, Ordering::Relaxed); // c:3925
3238                                                     // c:3927-3937 — `inserts = getaparam("reply") || gethparam("reply")`,
3239                                                     // else the `reply`/`REPLY` SCALAR as a one-element list. The eval can
3240                                                     // thus REPLACE the matched name with zero-or-more names (`reply=(…)`)
3241                                                     // or rename it (`REPLY=…`). REPLY was seeded to `filename` at c:3916,
3242                                                     // so this is always Some — at minimum the (possibly modified) REPLY.
3243    let inserts: Vec<String> = match crate::ported::params::getaparam("reply") {
3244        Some(arr) => arr, // c:3927
3245        None => match crate::ported::params::getsparam("reply") // c:3931
3246            .or_else(|| crate::ported::params::getsparam("REPLY"))
3247        {
3248            Some(scalar) => vec![scalar], // c:3934-3936
3249            None => Vec::new(),
3250        },
3251    };
3252    *INSERTS.lock().unwrap() = Some(inserts);
3253    // c:3941 — `return !ret;` — qualifier passes iff lastval is 0.
3254    i32::from(ret == 0)
3255}
3256
3257/// Port of `qualnonemptydir(char *name, struct stat *buf, UNUSED(off_t days), UNUSED(char *str))` from Src/glob.c:3948.
3258/// C: opendir(name) and check if any non-`.`/`..` entries exist.
3259pub fn qualnonemptydir(name: &str, buf: &libc::stat, days: i64, str: &str) -> i32 {
3260    // c:3948
3261    // c:3948 — `if (!S_ISDIR(buf->st_mode)) return 0;`
3262    if (buf.st_mode as u32 & libc::S_IFMT as u32) != libc::S_IFDIR as u32 {
3263        // c:3950
3264        return 0;
3265    }
3266    // c:3953-3964 — opendir + readdir loop, skip "." and ".." entries.
3267    match fs::read_dir(name) {
3268        Ok(entries) => entries.filter_map(|e| e.ok()).any(|e| {
3269            let n = e.file_name();
3270            let s = n.to_string_lossy();
3271            s != "." && s != ".."
3272        }) as i32,
3273        Err(_) => 0,
3274    }
3275}
3276
3277// =====================================================================
3278// Thread-local glob option snapshot — race fix.
3279//
3280// `Src/glob.c` reads `isset(NULLGLOB)` / `isset(BAREGLOBQUAL)` / etc.
3281// inline at each callsite during a glob. In zsh those reads hit the
3282// thread-local C `opts[]` array (zsh is single-threaded). In zshrs
3283// they hit `OPTS_LIVE` — a process-wide `RwLock<HashMap>`.
3284//
3285// Embedders that snapshot/restore options around individual glob
3286// invocations on multiple threads (e.g. stryke's `StrykeGlobOptsGuard`
3287// + `glob_par` rayon parallelism) race: thread A sets bareglobqual=1,
3288// calls glob; while A is mid-parse, thread B's matching guard restores
3289// bareglobqual=0; A's qualifier-parse step sees bareglobqual=0 and
3290// drops the trailing `(N)` as a literal substring, producing false
3291// matches.
3292//
3293// Fix: snapshot every glob-relevant option ONCE at glob() entry into
3294// a thread-local cache, then read all isset() inside glob from the
3295// snapshot. The snapshot is dropped on glob exit via an RAII guard.
3296// Each thread's in-flight glob() sees a coherent set of options
3297// regardless of concurrent setopt/unsetopt on other threads.
3298// =====================================================================
3299
3300/// Snapshot of glob-relevant zsh options taken at glob entry. Holds
3301/// the live `isset(...)` value for each option that the glob engine
3302/// or its helpers (`parse_qualifiers`, `scanner`,
3303/// `sort_matches`, `glob_emit_path`) consult.
3304#[derive(Debug, Clone, Copy, Default)]
3305pub struct GlobOptSnapshot {
3306    pub bareglobqual: bool,
3307    pub braceccl: bool,
3308    pub caseglob: bool,
3309    pub extendedglob: bool,
3310    pub globdots: bool,
3311    pub globstarshort: bool,
3312    pub listtypes: bool,
3313    pub markdirs: bool,
3314    pub nullglob: bool,
3315    pub numericglobsort: bool,
3316}
3317
3318/// RAII guard that drops the thread-local snapshot on scope exit.
3319/// Constructed by `enter_glob_scope()`. Nested glob calls on the
3320/// same thread reuse the outer snapshot (the guard's `populated`
3321/// field tracks whether we installed or just observed).
3322pub struct GlobOptsGuard {
3323    populated: bool,
3324}
3325
3326/// Sort specifier flags
3327// `GlobSort` / `SortOrder` / `SortSpec` deleted — C uses bit-flag
3328// `int tp` in `struct globsort { int tp; char *exec; }` (Src/glob.c:155):
3329//   tp & ~GS_DESC selects the sort key (GS_NAME/GS_DEPTH/GS_EXEC/…),
3330//   tp & GS_DESC reverses direction (`O` vs `o` qualifier),
3331//   tp << GS_SHIFT carries the follow-link variants.
3332// All those bits already exist as i32 constants at glob.rs:33+
3333// (GS_NAME / GS_DEPTH / GS_SIZE / … / GS_DESC / GS_NONE / GS__SIZE /
3334// …). The enum + Ascending/Descending + struct triple-wrapper was a
3335// Rust-only convenience with no C counterpart; callers now operate
3336// on the raw i32 the same way `gmatchcmp()` at glob.c:936 does.
3337
3338// `TimeUnit` / `SizeUnit` / `RangeOp` enums deleted — Rust-only
3339// wrappers around constants/chars that exist as raw values in C:
3340//   `TimeUnit::Seconds` → TT_SECONDS i32 (glob.c:126 → glob.rs:99)
3341//   `TimeUnit::Minutes` → TT_MINS (c:123)
3342//   `TimeUnit::Hours`   → TT_HOURS (c:122)
3343//   `TimeUnit::Days`    → TT_DAYS (c:121)
3344//   `TimeUnit::Weeks`   → TT_WEEKS (c:124)
3345//   `TimeUnit::Months`  → TT_MONTHS (c:125)
3346//   `SizeUnit::Bytes`   → TT_BYTES (c:128)
3347//   `SizeUnit::PosixBlocks` → TT_POSIX_BLOCKS (c:129)
3348//   `SizeUnit::Kilobytes`   → TT_KILOBYTES (c:130)
3349//   `SizeUnit::Megabytes`   → TT_MEGABYTES (c:131)
3350//   `SizeUnit::Gigabytes`   → TT_GIGABYTES (c:132)
3351//   `SizeUnit::Terabytes`   → TT_TERABYTES (c:133)
3352//   `RangeOp::Less`/`Equal`/`Greater` → raw chars `<` `=` `>`
3353//      (zsh's qgetnum at glob.c:827 returns the raw operator char
3354//      and the qualifier handlers switch on it inline).
3355
3356/// Port of `TestMatchFunc` — the `int (*)(char *, struct stat *, off_t,
3357/// char *)` function-pointer type held by `struct qual.func` (Src/glob.c).
3358/// Every glob-qualifier test (qualuid, qualisreg, qualiscom, qualsize, …)
3359/// has this signature so `insert()` can call them uniformly through the
3360/// `func` slot. `name` is the metafied path, `buf` the stat, `data` the
3361/// qualifier argument (uid, mode bits, day count, …), `sdata` the
3362/// expression text (only `qualsheval` uses it; others ignore it).
3363pub type TestMatchFunc = fn(name: &str, buf: &libc::stat, data: i64, sdata: &str) -> i32;
3364
3365/// Port of `struct qual` from `Src/glob.c:138`. A node in the linked list
3366/// of glob qualifiers parsed from a `(…)` suffix. `insert()` (glob.c:346)
3367/// walks this list per candidate file, calling `func` on each node and
3368/// accepting/rejecting the file by `sense`. The terminating node carries
3369/// `func == None` (C tests `qn && qn->func` to stop).
3370///
3371/// REPRESENTATION: C's `next`/`or` are `struct qual *` pointers into a
3372/// graph that `parsepat` builds and relinks through aliased cursors (the
3373/// OR-distribution at glob.c:1797-1820 aliases one node from several
3374/// chains at once). Rust single-ownership `Box` cannot express that, so
3375/// the port uses an arena ([`QualArena`]): nodes live in a `Vec<qual>`
3376/// and `next`/`or` are `Option<usize>` indices. C pointer assignments map
3377/// to index assignments, so the aliased-cursor algorithm translates
3378/// almost verbatim.
3379#[allow(non_camel_case_types)]
3380#[derive(Clone, Debug)]
3381pub struct qual {
3382    /// c:139 — `struct qual *next;` next qualifier in the AND-chain.
3383    pub next: Option<usize>,
3384    /// c:140 — `struct qual *or;` head of an alternative OR-chain.
3385    pub or: Option<usize>,
3386    /// c:141 — `TestMatchFunc func;` the test to call; `None` terminates.
3387    pub func: Option<TestMatchFunc>,
3388    /// c:142 — `off_t data;` argument passed to `func`.
3389    pub data: i64,
3390    /// c:143 — `int sense;` bit0: 0=assert / 1=negate; bit1: follow links.
3391    pub sense: i32,
3392    /// c:144 — `int amc;` which timestamp to test (0=a, 1=m, 2=c).
3393    pub amc: i32,
3394    /// c:145 — `int range;` test `<` / `>` / `=` (signum of comparison).
3395    pub range: i32,
3396    /// c:146 — `int units;` multiplier for time (TT_DAYS…) or size.
3397    pub units: i32,
3398    /// c:147 — `char *sdata;` expression to eval (currently qualsheval only).
3399    pub sdata: Option<String>,
3400}
3401
3402impl qual {
3403    /// A fresh node with the C zero-initialised defaults (`hcalloc`).
3404    pub fn new() -> qual {
3405        qual {
3406            next: None,
3407            or: None,
3408            func: None,
3409            data: 0,
3410            sense: 0,
3411            amc: 0,
3412            range: 0,
3413            units: 0,
3414            sdata: None,
3415        }
3416    }
3417}
3418
3419impl Default for qual {
3420    fn default() -> qual {
3421        qual::new()
3422    }
3423}
3424
3425/// Arena backing the [`qual`] graph. C allocates each `struct qual` with
3426/// `hcalloc` and links them by pointer; the Rust port allocates them in
3427/// this `Vec` and links them by index. `alloc()` mirrors a single
3428/// `hcalloc(sizeof *qn)` and returns the new node's index; `head` holds
3429/// the index of `quals` (the list `insert()` walks), `None` when empty.
3430#[derive(Clone, Default, Debug)]
3431pub struct QualArena {
3432    pub nodes: Vec<qual>,
3433    pub head: Option<usize>,
3434}
3435
3436impl QualArena {
3437    pub fn new() -> QualArena {
3438        QualArena {
3439            nodes: Vec::new(),
3440            head: None,
3441        }
3442    }
3443
3444    /// c:`qn = (struct qual *)hcalloc(sizeof *qn);` — allocate a fresh
3445    /// zeroed node and return its arena index.
3446    pub fn alloc(&mut self) -> usize {
3447        self.nodes.push(qual::new());
3448        self.nodes.len() - 1
3449    }
3450}
3451
3452/// A glob qualifier function
3453#[derive(Debug, Clone)]
3454/// One glob qualifier — Rust-extension sum type. C uses a linked
3455/// list of `struct qual` (`Src/zsh.h:140-152`) with function-pointer
3456/// `func` per node; each variant here maps to one of C's `q*` test
3457/// ported (`qisreg`, `qisdir`, `qowner`, `qtime`, ...) at
3458/// `Src/glob.c:1080-1340`. The full `struct qual` port + per-test fn
3459/// dispatch lives in a later phase; this enum keeps the parsed-form
3460/// the per-match filter inside `scanner()` (line 500) needs.
3461#[allow(non_camel_case_types)]
3462pub enum qualifier {
3463    /// File type qualifiers
3464    IsRegular,
3465    IsDirectory,
3466    IsSymlink,
3467    IsSocket,
3468    IsFifo,
3469    IsBlockDev,
3470    IsCharDev,
3471    IsDevice,
3472    IsExecutable,
3473
3474    /// Permission qualifiers
3475    Readable,
3476    Writable,
3477    Executable,
3478    WorldReadable,
3479    WorldWritable,
3480    WorldExecutable,
3481    GroupReadable,
3482    GroupWritable,
3483    GroupExecutable,
3484    Setuid,
3485    Setgid,
3486    Sticky,
3487
3488    /// Ownership qualifiers
3489    OwnedByEuid,
3490    OwnedByEgid,
3491    OwnedByUid(u32),
3492    OwnedByGid(u32),
3493
3494    /// Numeric qualifiers with range. `unit` is a `TT_*` i32
3495    /// (glob.rs:99-113 / glob.c:121-133); `op` is the raw range
3496    /// operator char (`<`, `=`, `>`) — mirrors C's qgetnum which
3497    /// returns the operator char and the handler switches inline.
3498    Size {
3499        value: u64,
3500        unit: i32,
3501        op: char,
3502    },
3503    Links {
3504        value: u64,
3505        op: char,
3506    },
3507    Atime {
3508        value: i64,
3509        unit: i32,
3510        op: char,
3511    },
3512    Mtime {
3513        value: i64,
3514        unit: i32,
3515        op: char,
3516    },
3517    Ctime {
3518        value: i64,
3519        unit: i32,
3520        op: char,
3521    },
3522
3523    /// Mode specification
3524    Mode {
3525        yes: u32,
3526        no: u32,
3527    },
3528
3529    /// Device number
3530    Device(u64),
3531
3532    /// Non-empty directory
3533    NonEmptyDir,
3534
3535    /// Shell evaluation
3536    Eval(String),
3537
3538    /// `^` — toggle the running match sense for SUBSEQUENT qualifiers in
3539    /// this list. C uses a running `sense ^= 1` (Src/glob.c:1346) stored
3540    /// per `struct qual.sense`; the enum can't carry per-node sense, so a
3541    /// marker in the list reproduces it: `check_qualifier_list` flips a
3542    /// running sense bit on each marker and XORs it into later tests.
3543    /// Fixes `*(/^@)` (dir AND not-symlink), `*(.^x)`, etc.
3544    ToggleSense,
3545}
3546
3547// Misnamed `gmatchcmp(&str, &str)` deleted — was a Rust-only
3548// locale-aware string compare claiming to be the C qsort
3549// comparator. The real C `gmatchcmp(Gmatch, Gmatch)` at glob.c:936
3550// is now ported as `gmatchcmp(&GlobMatch, &GlobMatch, &[i32], bool)`
3551// below. The string-compare case the old name claimed routes
3552// through canonical `zstrcmp` (sort.c:191).
3553
3554// `GlobOptions` struct deleted — Rust-only Bag-of-options with no
3555// C counterpart. C reads each option directly from the global
3556// `opts[]` array via `isset(NULL_GLOB)` / `isset(EXTENDED_GLOB)`
3557// / etc. (Src/options.c). The Rust port uses the canonical
3558// `opt_state_get(name) -> Option<bool>`
3559// which reads from the same global store. Inlined at each
3560// callsite as `opt_state_get("name").unwrap_or(default)`.
3561
3562/// Parsed glob qualifier set
3563#[derive(Debug, Clone, Default)]
3564/// Compiled qualifier list for one glob.
3565/// Mirrors the `struct qual *` linked list `parsepat()`
3566/// (Src/glob.c:791) builds — every `(qual)` after a glob pattern
3567/// adds to it.
3568#[allow(non_camel_case_types)]
3569pub struct qualifier_set {
3570    // c:138
3571    pub qualifiers: Vec<qualifier>,
3572    pub alternatives: Vec<Vec<qualifier>>,
3573    pub follow_links: bool,
3574    /// Packed sort-spec flags, one per `o`/`O` qualifier in the pattern.
3575    /// Each entry is the C `struct globsort.tp` field — `GS_NAME` /
3576    /// `GS_DEPTH` / `GS_EXEC` / `GS_SIZE` / `GS_ATIME` / `GS_MTIME` /
3577    /// `GS_CTIME` / `GS_LINKS` (or their `<< GS_SHIFT` follow-link
3578    /// variants), OR'd with `GS_DESC` for reverse direction.
3579    pub sorts: Vec<i32>, // c:155 struct globsort.tp[]
3580    /// c:74 `struct globsort.exec` — the eval code for each `GS_EXEC`
3581    /// (`oe:…:` / `o+…`) sort spec, indexed by the `idx` packed into the
3582    /// sort entry's high bits (`GS_EXEC | (idx << 16)`). Evaluated per
3583    /// match with `$REPLY` = the match name; the resulting `$REPLY` is
3584    /// the sort key (the original name is still emitted).
3585    pub sort_exec: Vec<String>, // c:74 globsort.exec
3586    /// The faithful glob.c `struct qual` representation of `alternatives`,
3587    /// built at parse end (additive — the enum is kept for now). AND-chain
3588    /// via `next`, alternatives via `or`, per-node `sense`/`range`/`amc`/
3589    /// `units`/`sdata`. The convergence target: `check_qualifiers` walks
3590    /// THIS via `insert()` semantics (glob.c:392-419), and the enum is
3591    /// eventually deleted.
3592    pub quals: QualArena, // c:206 curglobdata.gd_quals
3593    pub first: Option<i32>,
3594    pub last: Option<i32>,
3595    pub colon_mods: Option<String>,
3596    pub pre_words: Vec<String>,
3597    pub post_words: Vec<String>,
3598    /// `(M)` qualifier — append `/` to directory entries in output.
3599    /// Direct port of zsh/Src/glob.c:1557-1561 (`case 'M'`):
3600    ///   `gf_markdirs = !(sense & 1)` — set when the qualifier appears
3601    ///   without a `^` toggle. Stored per-qualifier-set rather than per
3602    ///   GlobOptions so a single glob call's qualifier picks it up.
3603    pub mark_dirs: bool,
3604    /// `(T)` qualifier — append type-char (ls -F style) to every entry.
3605    /// Direct port of zsh/Src/glob.c:1562-1566 (`case 'T'`).
3606    pub list_types: bool,
3607    /// `(N)` qualifier — per-glob nullglob: empty result on no-match,
3608    /// no error. Direct port of zsh/Src/glob.c:1567-1569 (`case 'N'`):
3609    ///   `gf_nullglob = !(sense & 1)` — set when `(N)` appears without
3610    ///   the `^` toggle.
3611    pub nullglob: bool,
3612    /// `(D)` qualifier — include dotfiles in the glob result (override
3613    /// the global `globdots`/`dotglob` option for this glob alone).
3614    /// Direct port of zsh/Src/glob.c case 'D' which sets
3615    /// `gf_glob.dots = 1` for the duration of this glob expansion.
3616    pub globdots: bool,
3617    /// `(YN)` qualifier — short-circuit: limit number of matches to
3618    /// at most N. Direct port of zsh/Src/glob.c:1579-1595 (`case 'Y'`)
3619    /// which sets the C `shortcircuit` int. The matcher should stop
3620    /// after collecting N entries; the Rust port truncates after the
3621    /// match walk completes (the optimization gain is the same in
3622    /// the typical short-glob case). Bug #41 in docs/BUGS.md.
3623    pub short_circuit: Option<i32>,
3624    /// `(n)` qualifier — per-glob numeric sort. Direct port of
3625    /// zsh/Src/glob.c:1575-1577 (`case 'n': gf_numsort = !(sense & 1)`).
3626    /// Distinct from the `o`/`O` sort KEY `n` (= GS_NAME, lexical):
3627    /// standalone `(n)` makes the name comparison NUMERIC (so `*(n)`
3628    /// orders `f2` before `f10`), OR'd with the global NUMERIC_GLOB_SORT
3629    /// option at sort time (glob.c:947/983 `gf_numsort ?
3630    /// SORTIT_NUMERICALLY : 0`). `None` = not specified (fall back to
3631    /// the global option); `Some(true)` = `(n)`; `Some(false)` = `(^n)`
3632    /// which OVERRIDES the global option to force lexical.
3633    pub numsort: Option<bool>,
3634}
3635
3636/// Enter a glob scope: snapshot options into TLS if no outer scope
3637/// is active, returning a guard that clears the snapshot on Drop.
3638/// Re-entrant calls (nested globdata_glob via brace expansion etc.)
3639/// return a no-op guard that observes the existing snapshot.
3640pub fn enter_glob_scope() -> GlobOptsGuard {
3641    let already = GLOB_OPTS_TLS.with_borrow(|g| g.is_some());
3642    if already {
3643        return GlobOptsGuard { populated: false };
3644    }
3645    let snap = GlobOptSnapshot::capture();
3646    GLOB_OPTS_TLS.with_borrow_mut(|g| *g = Some(snap));
3647    GlobOptsGuard { populated: true }
3648}
3649
3650/// Read a glob-relevant option through the TLS snapshot when one is
3651/// active; fall back to the live `isset()` otherwise. Use this in
3652/// any glob-engine helper that previously read `isset(OPT)` for one
3653/// of the snapshotted options.
3654#[inline]
3655pub fn glob_isset(opt: i32) -> bool {
3656    GLOB_OPTS_TLS.with_borrow(|g| match g {
3657        Some(snap) => match opt {
3658            x if x == BAREGLOBQUAL => snap.bareglobqual,
3659            x if x == BRACECCL => snap.braceccl,
3660            x if x == CASEGLOB => snap.caseglob,
3661            x if x == EXTENDEDGLOB => snap.extendedglob,
3662            x if x == GLOBDOTS => snap.globdots,
3663            x if x == GLOBSTARSHORT => snap.globstarshort,
3664            x if x == LISTTYPES => snap.listtypes,
3665            x if x == MARKDIRS => snap.markdirs,
3666            x if x == NULLGLOB => snap.nullglob,
3667            x if x == NUMERICGLOBSORT => snap.numericglobsort,
3668            _ => isset(opt),
3669        },
3670        None => isset(opt),
3671    })
3672}
3673
3674// ===========================================================
3675// `impl globdata` block above kept only for the `new()`
3676// constructor (Default-style). All scanner / parser / qualifier
3677// ported below are top-level — C glob.c has them as top-level
3678// statics that mutate the file-static `curglobdata`. Each is
3679// flagged with `// RUST-ONLY` and a comment naming the closest
3680// C equivalent + the proper-port target (typically `scanner`
3681// at glob.c:500 driving `insert` at c:346).
3682// ===========================================================
3683
3684/// Main entry point: expand a glob pattern.
3685///
3686/// Closest C equivalent: `zglob` driver at glob.c:1214 calls
3687/// `parsepat` (c:791) and then `scanner` (c:500). The Rust port
3688/// here orchestrates qualifier parsing, brace expansion (which C
3689/// does separately in `xpandbraces`), then drives the faithful
3690/// `parsecomplist` (c:710) → `complist` → `scanner` (c:500) path.
3691pub fn globdata_glob(state: &mut globdata, pattern: &str) -> Vec<String> {
3692    // RUST-ONLY
3693    // Brace pre-expansion. In zsh, `xpandbraces` (zsh/Src/glob.c:2275)
3694    // runs during substitution before glob — patterns reaching glob()
3695    // are already brace-free in the production path (vm_helper handles
3696    // it). For direct programmatic callers of glob_with_options, run
3697    // the brace pass here so `GlobOptions.brace_ccl` is actually
3698    // consulted: with brace_ccl set, `{a-mnop}` expands to a..m,n,o,p
3699    // per glob.c:2424 BRACECCL block; without, only `{a,b}` lists and
3700    // `{1..5}`/`{a..e}` ranges expand. Recurse on each variant and
3701    // concatenate matches.
3702    let brace_ccl = glob_isset(BRACECCL);
3703    if hasbraces(pattern, brace_ccl) {
3704        let mut all = Vec::new();
3705        for variant in xpandbraces(pattern, brace_ccl) {
3706            all.extend(globdata_glob(state, &variant));
3707        }
3708        return all;
3709    }
3710
3711    state.matches.clear();
3712    state.pathbuf.clear();
3713    state.pathpos = 0;
3714
3715    // Parse qualifiers first so a bare-qualifier pattern like `dir(/)`
3716    // (no wildcard, just a stat-based filter) still enters the expansion
3717    // path. Without this, `haswilds("dir(/)")` returns false and the
3718    // pattern echoes back unfiltered, which defeats the whole point of
3719    // qualifiers.
3720    let (pat, quals) = parse_qualifiers(pattern);
3721    state.qualifiers = quals;
3722
3723    // c:Src/glob.c — `A~B` exclusion. zsh compiles the ENTIRE glob
3724    // (path components + `~B` exclusion + `**`) into ONE Patprog and
3725    // matches each candidate PATH against it, so the `~B` applies to the
3726    // whole path. zshrs's component scanner can't express a path-level
3727    // exclusion, so split a TOP-LEVEL `~` (extendedglob; depth 0, not in
3728    // `[...]`/`(...)`, not Bnull-escaped) off here into the main pattern
3729    // plus full-path exclusion patterns, glob the main pattern, then drop
3730    // matched paths matching any exclusion below. Without this the `~B`
3731    // (which may contain `/`) was split across path components and either
3732    // excluded everything (`~*/.git/*` → `*.txt~*` matched all) or
3733    // nothing. `~` inside one component still works the same (main globs,
3734    // the post-filter applies the exclusion).
3735    let (pat, glob_exclusions): (String, Vec<String>) =
3736        if glob_isset(EXTENDEDGLOB) && (pat.contains('~') || pat.contains('\u{98}')) {
3737            let cv: Vec<char> = pat.chars().collect();
3738            let mut parts: Vec<String> = Vec::new();
3739            let mut cur = String::new();
3740            let mut bd = 0i32; // `[...]` depth
3741            let mut pd = 0i32; // `(...)` depth
3742            let mut k = 0usize;
3743            while k < cv.len() {
3744                let c = cv[k];
3745                match c {
3746                    '\u{9f}' => {
3747                        // Bnull escape — next char is literal.
3748                        cur.push(c);
3749                        if k + 1 < cv.len() {
3750                            cur.push(cv[k + 1]);
3751                            k += 2;
3752                            continue;
3753                        }
3754                    }
3755                    '[' => {
3756                        bd += 1;
3757                        cur.push(c);
3758                    }
3759                    ']' => {
3760                        if bd > 0 {
3761                            bd -= 1;
3762                        }
3763                        cur.push(c);
3764                    }
3765                    '(' | '\u{88}' => {
3766                        pd += 1;
3767                        cur.push(c);
3768                    }
3769                    ')' | '\u{8a}' => {
3770                        if pd > 0 {
3771                            pd -= 1;
3772                        }
3773                        cur.push(c);
3774                    }
3775                    '~' | '\u{98}' if bd == 0 && pd == 0 => {
3776                        parts.push(std::mem::take(&mut cur));
3777                    }
3778                    _ => cur.push(c),
3779                }
3780                k += 1;
3781            }
3782            parts.push(cur);
3783            // Only split when there's a non-empty main pattern AND at
3784            // least one exclusion (a leading `~` is home-dir, handled by
3785            // filesub before glob — never reaches here as an exclusion).
3786            if parts.len() > 1 && !parts[0].is_empty() {
3787                let main = parts.remove(0);
3788                (main, parts)
3789            } else {
3790                // No usable split — restore the original pattern verbatim
3791                // (the `~` separators dropped during the scan).
3792                (cv.iter().collect(), Vec::new())
3793            }
3794        } else {
3795            (pat, Vec::new())
3796        };
3797
3798    // c:Src/glob.c:1843-1854 — `if (!q || errflag) { ... return; }`. When
3799    // the qualifier parser already emitted a diagnostic (e.g. "number
3800    // expected" from qgetnum at c:832) and set errflag, abort glob
3801    // expansion entirely rather than continuing to scan with a partial
3802    // qualifier set. Without this gate, `?(a)` (where `(a)` is an
3803    // invalid qualifier) error-prints "number expected" then continues
3804    // to expand `?` against the dir, emitting matches alongside the
3805    // error — bug #549.
3806    if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::SeqCst)
3807        & crate::ported::zsh_h::ERRFLAG_ERROR
3808        != 0
3809    {
3810        return Vec::new();
3811    }
3812
3813    // Now check wildcards on the qualifier-stripped pattern. A pure
3814    // literal with a qualifier (`name(.)`) still needs to enter the
3815    // scanner so the qualifier filter can run against the literal name.
3816    // haswilds scans TOKENIZED strings (c:Src/glob.c:1230 runs on the
3817    // lexer-tokenized word); glob_path receives both tokenized (zglob)
3818    // and untokenized (expand_glob fast paths) patterns, so tokenize a
3819    // local copy for the check — existing token chars pass through
3820    // zshtokenize untouched, untokenized metachars gain their tokens.
3821    let mut pat_tok = pat.clone();
3822    tokenize(&mut pat_tok); // c:Src/glob.c:3548
3823    if !haswilds(&pat_tok) && state.qualifiers.is_none() {
3824        return vec![pattern.to_string()];
3825    }
3826
3827    // c:glob() — `gd.gf_noglobdots = !isset(GLOBDOTS)` (plus the `(D)`
3828    // qualifier). parsecomplist reads this from CURGLOBDATA to compile
3829    // PAT_NOGLD into each component, which is what makes pattry reject
3830    // leading-dot files (pattern.rs:2890). Set it before parsing so the
3831    // faithful flag-driven dot filtering replaces the old manual skip.
3832    {
3833        let globdots = glob_isset(GLOBDOTS)
3834            || state
3835                .qualifiers
3836                .as_ref()
3837                .map(|qf| qf.globdots)
3838                .unwrap_or(false);
3839        let mut gd = CURGLOBDATA.lock().unwrap_or_else(|e| e.into_inner());
3840        gd.gf_noglobdots = if globdots { 0 } else { 1 };
3841    }
3842
3843    // c:parsepat (glob.c:809-820) — init pathbuf for absolute vs relative,
3844    // strip the leading `/`, then parse into a `complist` and run the
3845    // faithful `scanner`. parsecomplist consumes TOKENIZED input (it tests
3846    // for the `Star` token and hands segments to patcompile), so feed it
3847    // `pat_tok` (already tokenized above; tokenize is idempotent).
3848    state.pathbufcwd = 0; // c:812 — `DPUTS(pathbufcwd, ...)` invariant
3849    let parse_src = if let Some(rest) = pat_tok.strip_prefix('/') {
3850        // c:813-816 — absolute path.
3851        state.pathbuf.push('/');
3852        state.pathpos = 1;
3853        rest.to_string()
3854    } else {
3855        // c:817-818 — relative to pwd.
3856        pat_tok.clone()
3857    };
3858    if let Some(complist) = parsecomplist(&parse_src) {
3859        scanner(state, Some(&complist), 0, false);
3860    } else {
3861        // c:Src/glob.c:1842-1854 — `q = parsepat(str); if (!q || errflag)`.
3862        // This is the `!q` half: the pattern failed to COMPILE (e.g. an
3863        // unclosed `[` character class — patcomppiece returns -1 at
3864        // pattern.rs:1687). C then either treats the word as a literal
3865        // string (when `unset(BADPATTERN)`) or aborts with
3866        // `zerr("bad pattern: %s", ostr)`. zshrs previously dropped the
3867        // `None` silently, so `echo abc[def` fell through to the
3868        // no-matches / literal-under-NO_NOMATCH path instead of the
3869        // compile diagnostic — and BADPATTERN was ignored entirely.
3870        // (The `errflag` half — a diagnostic already emitted by the
3871        // qualifier parser — is handled by the gate at glob.rs:3770.)
3872        if !isset(crate::ported::zsh_h::BADPATTERN) {
3873            // c:1846-1848 — treat as an ordinary literal string.
3874            return vec![pattern.to_string()];
3875        }
3876        // c:1851-1852 — clear any stale error bit so zerr fires, then
3877        // emit. utils::zerr re-sets ERRFLAG_ERROR, which zglob's gate at
3878        // glob.rs:1541 reads to suppress the redundant "no matches found".
3879        errflag.fetch_and(
3880            !crate::ported::zsh_h::ERRFLAG_ERROR,
3881            std::sync::atomic::Ordering::SeqCst,
3882        );
3883        zerr(&format!("bad pattern: {}", pattern));
3884        return Vec::new();
3885    }
3886
3887    // c:Src/glob.c — apply top-level `~B` exclusions to the full matched
3888    // paths (split off above). A match is dropped if its emitted path
3889    // matches ANY exclusion pattern (`*` matches `/` here, matching zsh's
3890    // flat-string exclusion semantics).
3891    if !glob_exclusions.is_empty() {
3892        let eg = glob_isset(EXTENDEDGLOB);
3893        let cg = glob_isset(CASEGLOB);
3894        state.matches.retain(|m| {
3895            let p = glob_emit_path(&m.path);
3896            !glob_exclusions.iter().any(|ex| matchpat(ex, &p, eg, cg))
3897        });
3898    }
3899
3900    // c:1680 — populate GS_EXEC sort keys before sorting. Evaluate each
3901    // `oe:…:` / `o+…` code per match with $REPLY = the match name, and
3902    // capture the resulting $REPLY as that match's sort string (gmatchcmp
3903    // reads gmatch.sort_strings[idx]). Exec sort changes ORDER only — the
3904    // original name is still what's emitted.
3905    let sort_codes: Vec<String> = state
3906        .qualifiers
3907        .as_ref()
3908        .map(|q| q.sort_exec.clone())
3909        .unwrap_or_default();
3910    if !sort_codes.is_empty() {
3911        for m in state.matches.iter_mut() {
3912            let name = glob_emit_path(&m.path);
3913            for code in &sort_codes {
3914                crate::ported::params::setsparam("REPLY", &name);
3915                let _ = crate::ported::exec::execute_script(code);
3916                let key = crate::ported::params::getsparam("REPLY").unwrap_or_else(|| name.clone());
3917                m.sort_strings.push(key);
3918            }
3919        }
3920    }
3921
3922    // c:Src/glob.c:518 — the `Y` limit belongs to the SCAN, not to the result:
3923    //     scanner(q->next, shortcircuit);
3924    //     if (shortcircuit && shortcircuit == matchct)
3925    //         return;
3926    // The scanner simply stops once N matches exist, so the survivors are the
3927    // first N *found*, and only then does c:1868's sort run over them. Applying
3928    // the limit after sorting (as apply_selection used to) keeps the first N by
3929    // SORT ORDER instead — a different set: `*(.Y2on)` must be the two files
3930    // the scan reached, re-ordered by name, not the two alphabetically-first.
3931    //
3932    // This port collects every match before sorting, so the faithful place for
3933    // the truncation is here — after collection, before sort_matches. The
3934    // `[first,last]` subscript stays in apply_selection: c:1868's sort precedes
3935    // it, so THAT one really is a post-sort selection.
3936    //
3937    // Zero means "no limit" (c:518's leading `shortcircuit &&`), hence `> 0`.
3938    if let Some(n) = state.qualifiers.as_ref().and_then(|q| q.short_circuit) {
3939        if n > 0 {
3940            let n = n as usize;
3941            if state.matches.len() > n {
3942                state.matches.truncate(n); // c:518
3943            }
3944        }
3945    }
3946
3947    // Sort results
3948    sort_matches(state);
3949
3950    // Apply subscript selection
3951    apply_selection(state);
3952
3953    // Extract filenames. Mark-dirs / list-types come from EITHER
3954    // the canonical global option store OR the parsed `(M)`/`(T)`
3955    // qualifier on this glob. Direct port of zsh/Src/glob.c:355,372
3956    // — output marker emission consults the per-glob `gf_markdirs`
3957    // / `gf_listtypes` flags which the qualifier parser at
3958    // glob.c:1557-1566 sets.
3959    let mark_dirs = glob_isset(MARKDIRS)
3960        || state
3961            .qualifiers
3962            .as_ref()
3963            .map(|q| q.mark_dirs)
3964            .unwrap_or(false);
3965    // c:1562-1566 — `gf_listtypes` is set ONLY by the `T` glob
3966    // qualifier (`*(T)`), NOT by the global LISTTYPES option.
3967    // LISTTYPES is the completion-listing option (see man zshoptions
3968    // "LIST_TYPES") and doesn't affect glob output. Including the
3969    // option here mis-decorated every executable-file glob with a
3970    // trailing `*` and every directory with `/` for users with the
3971    // default `setopt listtypes` ON.
3972    let list_types = state
3973        .qualifiers
3974        .as_ref()
3975        .map(|q| q.list_types)
3976        .unwrap_or(false);
3977    let colon_mods = state.qualifiers.as_ref().and_then(|q| q.colon_mods.clone());
3978    // c:Src/glob.c — a pattern ending in `/` ("trailing slash" syntax)
3979    // forces matches to be directories AND preserves the slash in the
3980    // output. zsh's parsepat treats `pat/` as `pat` + an empty trailing
3981    // component; the scanner restricts to directories and the emitter
3982    // appends `/`. The Rust port's parser short-circuits the empty
3983    // trailing component, so we filter + re-add the suffix here at emit
3984    // time. `/` is never a glob metachar so a literal trailing-slash
3985    // check on the raw pattern is sufficient.
3986    // Use the qualifier-stripped `pat` for the trailing-slash check.
3987    // For `*/(N)` the original `pattern` ends with `)`, so the check
3988    // on `pattern` returned false and the trailing-slash directory-
3989    // filter was skipped — `*/(N)` then matched every file, not just
3990    // directories. The qualifier-stripped `pat` is `*/` which still
3991    // carries the trailing `/`.
3992    let trailing_slash = pat.ends_with('/') && pat.len() > 1;
3993    // c:Src/glob.c — preserve user-typed `./` / `../` prefix in
3994    // output. glob_emit_path strips CurDir uniformly so the
3995    // leading `./` would be lost. Detect the source prefix here
3996    // and re-prepend after emit. Same `pat` (post-qualifier) — the
3997    // prefix is on the path part, not the qualifier.
3998    let leading_dot_prefix: &str = if pat.starts_with("../") {
3999        "../"
4000    } else if pat.starts_with("./") {
4001        "./"
4002    } else {
4003        ""
4004    };
4005    let mut results: Vec<String> = state
4006        .matches
4007        .iter()
4008        .filter(|m| !trailing_slash || fs::metadata(&m.path).map(|md| md.is_dir()).unwrap_or(false))
4009        .map(|m| {
4010            let mut s = glob_emit_path(&m.path);
4011            if !leading_dot_prefix.is_empty()
4012                && !s.starts_with(leading_dot_prefix)
4013                && !s.starts_with('/')
4014            {
4015                s = format!("{}{}", leading_dot_prefix, s);
4016            }
4017            if trailing_slash && !s.ends_with('/') {
4018                s.push('/');
4019            }
4020            if mark_dirs || list_types {
4021                if let Ok(meta) = fs::symlink_metadata(&m.path) {
4022                    let ch = file_type(meta.mode());
4023                    if list_types || (mark_dirs && ch == '/') {
4024                        // Don't double-stamp if trailing_slash already added one.
4025                        if !s.ends_with(ch) {
4026                            s.push(ch);
4027                        }
4028                    }
4029                }
4030            }
4031            // Apply colon modifiers AFTER mark/list-type appendage —
4032            // zsh applies them last in glob.c:432 modify() per emitted
4033            // node, so `(M:t)` would mark THEN tail (effectively just
4034            // tail since the slash is gone). Faithful order.
4035            if let Some(ref m) = colon_mods {
4036                // Delegate to canonical `modify()` port in subst.rs
4037                // (Src/subst.c:4531). Local colon-modifier impl was
4038                // an invented duplicate — removed.
4039                s = crate::ported::subst::modify(&s, m);
4040            }
4041            s
4042        })
4043        .collect();
4044
4045    // c:421-426 — zsh applies the colon modifier in insert() (during
4046    // collection) BEFORE the match sort, so the default (name) sort
4047    // orders the MODIFIED names: `*(.:e)` on (apple.z,banana.a,cherry.m)
4048    // sorts the extensions → (a,m,z), not name order (z,a,m). The Rust
4049    // pipeline modifies at emit (after sort_matches), so re-sort the
4050    // modified results here — but ONLY for the default name sort; an
4051    // explicit `o`/`O` keeps its stat-keyed order with modified output.
4052    let explicit_sort = state
4053        .qualifiers
4054        .as_ref()
4055        .map(|q| !q.sorts.is_empty())
4056        .unwrap_or(false);
4057    if colon_mods.is_some() && !explicit_sort {
4058        results.sort();
4059    }
4060
4061    // c:1744-1756 / insert_glob_match c:1430 — `P:word:` (gf_pre_words)
4062    // emits `word` as a separate element BEFORE each match; `^P:word:`
4063    // (gf_post_words) emits it AFTER. Applied last, after sort/modifiers.
4064    let (pre_words, post_words) = state
4065        .qualifiers
4066        .as_ref()
4067        .map(|q| (q.pre_words.clone(), q.post_words.clone()))
4068        .unwrap_or_default();
4069    if !pre_words.is_empty() || !post_words.is_empty() {
4070        let mut expanded =
4071            Vec::with_capacity(results.len() * (1 + pre_words.len() + post_words.len()));
4072        for s in results {
4073            expanded.extend(pre_words.iter().cloned());
4074            expanded.push(s);
4075            expanded.extend(post_words.iter().cloned());
4076        }
4077        results = expanded;
4078    }
4079
4080    // c:Src/glob.c:1872-1888 — no-match path. Return Vec::new() so
4081    // the caller (vm_helper.rs::expand_glob) drives the
4082    // NULLGLOB / NOMATCH / literal-fallback policy. Previously
4083    // this pushed `pattern.to_string()` on no-match, which made
4084    // `expand_glob` see a non-empty result and skip the NOMATCH
4085    // check entirely. zsh's `setopt nomatch` is on by default, so
4086    // `echo /never_real/*` should emit "no matches found" and
4087    // exit 1 — not silently print the literal. Parity bug #13
4088    // recurrence. Per-glob `(N)` qualifier (nullglob_per_qual)
4089    // and global NULLGLOB are now consulted in expand_glob too.
4090    let _ = state.qualifiers.as_ref().map(|q| q.nullglob);
4091    results
4092}
4093
4094// `sort_matches_by_type` deleted — dead code (no callers anywhere
4095// in the tree). The sort path lives in `GlobMatch::compare` which
4096// dispatches off the canonical `GS_*` tp bits exactly like C's
4097// `gmatchcmp()` at glob.c:936.
4098
4099// ============================================================================
4100// Pattern matching with replacement (from glob.c getmatch family)
4101// ============================================================================
4102
4103// `MatchFlags` deleted — Rust-only bool-bag wrapper. C uses bare
4104// `int fl` with `SUB_*` bits from `Src/zsh.h:1981+` (mirrored at
4105// `zsh_h.rs:2463+`). Callers now pass an `i32` and test bits.
4106
4107// Trimmed local `struct imatchdata` deleted — it was a fake 5-field
4108// duplicate (str/pattern/match_start/match_end/replacement) of the
4109// canonical `Src/zsh.h:1740` port that already lives in
4110// `zsh_h.rs::imatchdata` (mstr/mlen/ustr/ulen/flags/replstr/repllist).
4111// `get_match_ret` now uses the canonical struct; see its port above.
4112
4113/// Strip a trailing `(qual)` block from a glob pattern.
4114/// **RUST-ONLY** — C glob.c handles qualifier parsing inline in
4115/// `parsepat` (c:791) as it builds the Complist. Move to that
4116/// shape when porting parsepat for real.
4117pub fn parse_qualifiers(pattern: &str) -> (String, Option<qualifier_set>) {
4118    // RUST-ONLY
4119    if !pattern.ends_with(')') {
4120        return (pattern.to_string(), None);
4121    }
4122
4123    let bytes = pattern.as_bytes();
4124    // Backslash-escaped trailing `)` is literal — no qualifier block
4125    // present. C's glob path handles this via Bnull preservation in
4126    // the lexer; zshrs's path arrives here with `\)` for quoted
4127    // close-parens (e.g. assoc-value `'\)'`). Without the gate, the
4128    // qualifier-extraction walk below treats the literal `)` as the
4129    // qualifier terminator.
4130    let last_paren_escaped = {
4131        let mut bs = 0usize;
4132        let mut j = bytes.len() - 1;
4133        while j > 0 && bytes[j - 1] == b'\\' {
4134            bs += 1;
4135            j -= 1;
4136        }
4137        bs % 2 == 1
4138    };
4139    if last_paren_escaped {
4140        return (pattern.to_string(), None);
4141    }
4142
4143    // Find matching open paren
4144    let mut depth = 0;
4145    let mut qual_start = None;
4146
4147    // c:Src/glob.c haswilds backslash handling — a `\(` / `\)` is a
4148    // literal paren, not a qualifier delimiter. Track which paren
4149    // bytes are escaped by a preceding `\` (counting consecutive
4150    // backslashes — even count means the paren is unescaped, odd
4151    // count means escaped). Without this gate, `\(*\)` is mis-parsed
4152    // as `(*\)` qualifier + empty pattern prefix, sending `*\` to
4153    // the qualifier-letter switch which emits "unknown file
4154    // attribute: \".
4155    let is_escaped = |idx: usize| -> bool {
4156        let mut bs = 0usize;
4157        let mut j = idx;
4158        while j > 0 && bytes[j - 1] == b'\\' {
4159            bs += 1;
4160            j -= 1;
4161        }
4162        bs % 2 == 1
4163    };
4164
4165    for i in (0..bytes.len()).rev() {
4166        match bytes[i] {
4167            b')' => {
4168                if !is_escaped(i) {
4169                    depth += 1;
4170                }
4171            }
4172            b'(' => {
4173                if !is_escaped(i) {
4174                    depth -= 1;
4175                    if depth == 0 {
4176                        qual_start = Some(i);
4177                        break;
4178                    }
4179                }
4180            }
4181            _ => {}
4182        }
4183    }
4184
4185    let start = match qual_start {
4186        Some(s) => s,
4187        None => return (pattern.to_string(), None),
4188    };
4189
4190    // Check for (#q...) explicit qualifier syntax.
4191    let qual_str = &pattern[start + 1..pattern.len() - 1];
4192    // c:Src/glob.c:1192-1197 — `if (isset(EXTENDEDGLOB) &&
4193    // !zpc_disables[ZPC_HASH] && s[1] == Pound) { if (s[2] != 'q')
4194    // return 0; ret = 2; }`. The leading `#` inside `(...)` is ONLY
4195    // special under EXTENDEDGLOB: `(#q...)` is the explicit glob
4196    // qualifier, and `(#X...)` for any other X is an inline pattern flag
4197    // (passed through). Without EXTENDEDGLOB the `#` is not special at
4198    // all — the `(...)` is a bare qualifier group and a leading `#` is an
4199    // (unknown) attribute char, so `*(#q.)` errors `unknown file
4200    // attribute: #` rather than silently applying the `.` qualifier.
4201    let (is_explicit, qual_content) = if glob_isset(EXTENDEDGLOB) && qual_str.starts_with('#') {
4202        if let Some(after) = qual_str.strip_prefix("#q") {
4203            (true, after) // c:1195 ret = 2 — explicit glob qualifier
4204        } else {
4205            // c:1194 `if (s[2] != 'q') return 0;` — inline pattern flag
4206            // (`(#i)`, `(#c1,2)`, `(#a)`, `(#l)`, `(#s)`, `(#e)`, `(#m)`).
4207            return (pattern.to_string(), None);
4208        }
4209    } else if glob_isset(BAREGLOBQUAL) {
4210        (false, qual_str)
4211    } else {
4212        return (pattern.to_string(), None);
4213    };
4214
4215    // Don't parse as qualifiers if it contains | or ~ (alternatives/exclusions)
4216    if !is_explicit && (qual_content.contains('|') || qual_content.contains('~')) {
4217        return (pattern.to_string(), None);
4218    }
4219
4220    // Parse the qualifiers
4221    let qs = parse_qualifier_string(qual_content);
4222    (pattern[..start].to_string(), Some(qs))
4223}
4224
4225/// Parse the body of a `(...)` qualifier block into a qualifier_set.
4226/// **RUST-ONLY** — see header on `parse_qualifiers`.
4227fn parse_qualifier_string(s: &str) -> qualifier_set {
4228    // RUST-ONLY
4229    let mut qs = qualifier_set::default();
4230    let mut chars = s.chars().peekable();
4231    let mut negated = false;
4232    let mut follow = false;
4233
4234    while let Some(c) = chars.next() {
4235        match c {
4236            '^' => {
4237                // c:1346 — `sense ^= 1`. Keep the running `negated` for
4238                // the flag qualifiers (N/D/M/T) that read it, AND emit a
4239                // marker so the per-qualifier sense reaches the list walk.
4240                negated = !negated;
4241                qs.qualifiers.push(qualifier::ToggleSense);
4242            }
4243            '-' => follow = !follow,
4244            ',' => {
4245                // Start new alternative
4246                if !qs.qualifiers.is_empty() {
4247                    qs.alternatives.push(std::mem::take(&mut qs.qualifiers));
4248                }
4249                negated = false;
4250                follow = false;
4251            }
4252            ':' => {
4253                // Colon modifiers - rest of string
4254                let rest: String = chars.collect();
4255                qs.colon_mods = Some(format!(":{}", rest));
4256                break;
4257            }
4258            // File type qualifiers
4259            '/' => qs.qualifiers.push(qualifier::IsDirectory),
4260            '.' => qs.qualifiers.push(qualifier::IsRegular),
4261            '@' => qs.qualifiers.push(qualifier::IsSymlink),
4262            '=' => qs.qualifiers.push(qualifier::IsSocket),
4263            'p' => qs.qualifiers.push(qualifier::IsFifo),
4264            '%' => match chars.peek() {
4265                Some('b') => {
4266                    chars.next();
4267                    qs.qualifiers.push(qualifier::IsBlockDev);
4268                }
4269                Some('c') => {
4270                    chars.next();
4271                    qs.qualifiers.push(qualifier::IsCharDev);
4272                }
4273                _ => qs.qualifiers.push(qualifier::IsDevice),
4274            },
4275            '*' => qs.qualifiers.push(qualifier::IsExecutable),
4276            // Permission qualifiers
4277            'r' => qs.qualifiers.push(qualifier::Readable),
4278            'w' => qs.qualifiers.push(qualifier::Writable),
4279            'x' => qs.qualifiers.push(qualifier::Executable),
4280            'R' => qs.qualifiers.push(qualifier::WorldReadable),
4281            'W' => qs.qualifiers.push(qualifier::WorldWritable),
4282            'X' => qs.qualifiers.push(qualifier::WorldExecutable),
4283            'A' => qs.qualifiers.push(qualifier::GroupReadable),
4284            'I' => qs.qualifiers.push(qualifier::GroupWritable),
4285            'E' => qs.qualifiers.push(qualifier::GroupExecutable),
4286            's' => qs.qualifiers.push(qualifier::Setuid),
4287            'S' => qs.qualifiers.push(qualifier::Setgid),
4288            't' => qs.qualifiers.push(qualifier::Sticky),
4289            // c:Src/glob.c case 'f' — file-mode qualifier accepts
4290            // an octal mode (`f755`) or chmod-style symbolic spec
4291            // (`fu+x`/`fg-w`/`fa=r`) and matches files whose mode
4292            // bits agree. Multiple delimited specs can be chained
4293            // by separator character. Direct port of qgetmodespec
4294            // dispatch + mode-bit accumulator in qgetmodespec.
4295            'f' => {
4296                // c:Src/glob.c:849 — qgetmodespec dispatches on the
4297                // first char: if it's `=`/`+`/`-`/`?` or a digit
4298                // (`(c >= '0' && c <= '7')`) the spec is bare (no
4299                // surrounding delimiter, no who) — `f+x` / `f-w` /
4300                // `f=755` / `f0644`. Otherwise the char is the
4301                // OPENING DELIMITER paired with a matching closer
4302                // (`<` → `>`, `[` → `]`, `{` → `}`, anything else
4303                // → itself, e.g. `f:u+x:` uses `:` on both sides).
4304                // The previous port used `!d.is_alphanumeric()`
4305                // which incorrectly classified `+`, `-`, `=`, `?`
4306                // as delimiters, sending `f+x` down the
4307                // delimiter-string path that then called
4308                // qgetmodespec on body "x" — qgetmodespec rejects
4309                // `x` (no op char) and silently dropped the
4310                // qualifier, so `*(.f+x)` matched ALL files. Bug
4311                // #105 in docs/BUGS.md.
4312                let rest: String = chars.clone().collect();
4313                let trimmed: &str = match rest.chars().next() {
4314                    // c:849-861 — bare spec ONLY when the first char is
4315                    // `=`/`+`/`-`/`?` or an octal digit; ANY other char is
4316                    // the OPENING DELIMITER (`<`→`>`, `[`→`]`, `{`→`}`, else
4317                    // itself). Who-clauses (u/g/o/a) are valid only INSIDE a
4318                    // delimited spec, so bare `fu+x` lands here with `u` as
4319                    // the delimiter — and with no closing `u` it is an
4320                    // unterminated (invalid) spec, exactly as in zsh.
4321                    Some(d) if !matches!(d, '+' | '-' | '=' | '?' | '0'..='7') => {
4322                        chars.next(); // consume opening delim
4323                        let close = match d {
4324                            '<' => '>',
4325                            '[' => ']',
4326                            '{' => '}',
4327                            other => other,
4328                        };
4329                        let mut body = String::new();
4330                        let mut closed = false;
4331                        while let Some(pc) = chars.next() {
4332                            if pc == close {
4333                                closed = true;
4334                                break;
4335                            }
4336                            body.push(pc);
4337                        }
4338                        if !closed {
4339                            // c:884/930 — `zerr("invalid mode specification")`
4340                            // on an unterminated spec; the glob then fails.
4341                            crate::ported::utils::errflag.fetch_or(
4342                                crate::ported::utils::ERRFLAG_ERROR,
4343                                std::sync::atomic::Ordering::Relaxed,
4344                            );
4345                            return qs;
4346                        }
4347                        if let Some((_who, op, perm, _r)) = qgetmodespec(&body) {
4348                            let (yes, no) = match op {
4349                                '=' => (perm, 0o7777 & !perm),
4350                                '+' => (perm, 0),
4351                                '-' => (0, perm),
4352                                _ => (perm, 0),
4353                            };
4354                            qs.qualifiers.push(qualifier::Mode { yes, no });
4355                        }
4356                        ""
4357                    }
4358                    _ => {
4359                        let parsed = qgetmodespec(&rest);
4360                        if let Some((who, op, perm, r)) = parsed {
4361                            let consumed = rest.len() - r.len();
4362                            for _ in 0..consumed {
4363                                chars.next();
4364                            }
4365                            let _ = who;
4366                            let (yes, no) = match op {
4367                                '=' => (perm, 0o7777 & !perm),
4368                                '+' => (perm, 0),
4369                                '-' => (0, perm),
4370                                _ => (perm, 0),
4371                            };
4372                            qs.qualifiers.push(qualifier::Mode { yes, no });
4373                        }
4374                        ""
4375                    }
4376                };
4377                let _ = trimmed;
4378            }
4379            // Ownership
4380            'U' => qs.qualifiers.push(qualifier::OwnedByEuid),
4381            'G' => qs.qualifiers.push(qualifier::OwnedByEgid),
4382            'u' => {
4383                let uid = parse_uid_gid(&mut chars, false);
4384                qs.qualifiers.push(qualifier::OwnedByUid(uid));
4385            }
4386            'g' => {
4387                let gid = parse_uid_gid(&mut chars, true);
4388                qs.qualifiers.push(qualifier::OwnedByGid(gid));
4389            }
4390            // Size
4391            'L' => {
4392                let (unit, op, val) = parse_size_spec(&mut chars);
4393                qs.qualifiers.push(qualifier::Size {
4394                    value: val,
4395                    unit,
4396                    op,
4397                });
4398            }
4399            // Link count
4400            'l' => {
4401                let (op, val) = parse_range_spec(&mut chars);
4402                qs.qualifiers.push(qualifier::Links { value: val, op });
4403            }
4404            // Times
4405            'a' => {
4406                let (unit, op, val) = schedgetfn(&mut chars);
4407                qs.qualifiers.push(qualifier::Atime {
4408                    value: val as i64,
4409                    unit,
4410                    op,
4411                });
4412            }
4413            'm' => {
4414                let (unit, op, val) = schedgetfn(&mut chars);
4415                qs.qualifiers.push(qualifier::Mtime {
4416                    value: val as i64,
4417                    unit,
4418                    op,
4419                });
4420            }
4421            'c' => {
4422                let (unit, op, val) = schedgetfn(&mut chars);
4423                qs.qualifiers.push(qualifier::Ctime {
4424                    value: val as i64,
4425                    unit,
4426                    op,
4427                });
4428            }
4429            // Sort qualifier — `o<key>` ascending / `O<key>`
4430            // descending. Mirrors the parser arm in zsh's glob.c
4431            // that builds `struct globsort` entries (tp = GS_* OR
4432            // GS_DESC, optionally shifted by GS_SHIFT for the
4433            // follow-links variant).
4434            'o' | 'O' => {
4435                let desc = c == 'O';
4436                if let Some(&sc) = chars.peek() {
4437                    let key: i32 = match sc {
4438                        'n' => {
4439                            chars.next();
4440                            GS_NAME
4441                        }
4442                        'L' => {
4443                            chars.next();
4444                            GS_SIZE
4445                        }
4446                        'l' => {
4447                            chars.next();
4448                            GS_LINKS
4449                        }
4450                        'a' => {
4451                            chars.next();
4452                            GS_ATIME
4453                        }
4454                        'm' => {
4455                            chars.next();
4456                            GS_MTIME
4457                        }
4458                        'c' => {
4459                            chars.next();
4460                            GS_CTIME
4461                        }
4462                        'd' => {
4463                            chars.next();
4464                            GS_DEPTH
4465                        }
4466                        'N' => {
4467                            chars.next();
4468                            GS_NONE
4469                        }
4470                        'e' | '+' => {
4471                            // c:1672-1687 — GS_EXEC sort: `glob_exec_string`
4472                            // parses the eval code (delimited `e:code:` or
4473                            // identifier `+name`); store it indexed and pack
4474                            // the index into the sort entry. The code is
4475                            // consumed HERE (not left for the main loop), so
4476                            // `oe:…:` is a SORT (eval = key, original name
4477                            // emitted), not a separate eval qualifier.
4478                            let is_plus = sc == '+';
4479                            chars.next(); // consume 'e' / '+'
4480                            let rest: String = chars.clone().collect();
4481                            match glob_exec_string(&rest, is_plus) {
4482                                Some((code, consumed)) => {
4483                                    for _ in 0..consumed {
4484                                        chars.next();
4485                                    }
4486                                    let idx = qs.sort_exec.len();
4487                                    qs.sort_exec.push(code);
4488                                    GS_EXEC | ((idx as i32) << 16)
4489                                }
4490                                None => {
4491                                    // glob_exec_string already emitted the
4492                                    // diagnostic + set errflag; bail.
4493                                    return qs;
4494                                }
4495                            }
4496                        }
4497                        _ => {
4498                            // c:1689 — `default: zerr("unknown sort
4499                            // specifier"); restore_globstate(saved);
4500                            // return;`. Was `_ => GS_NAME`, which silently
4501                            // accepted invalid keys (and reparsed them as
4502                            // separate qualifiers, e.g. `oD` → o + D-dotglob
4503                            // instead of erroring like zsh).
4504                            crate::ported::utils::zerr("unknown sort specifier");
4505                            crate::ported::utils::errflag.fetch_or(
4506                                crate::ported::utils::ERRFLAG_ERROR,
4507                                std::sync::atomic::Ordering::Relaxed,
4508                            );
4509                            return qs;
4510                        }
4511                    };
4512                    let shifted = if follow && (key & GS_NORMAL) != 0 {
4513                        key << GS_SHIFT // c:1692-1694
4514                    } else {
4515                        key
4516                    };
4517                    // c:1695-1702 — a sort key may not be repeated:
4518                    //     if (t != GS_EXEC) {
4519                    //         if (gf_sorts & t) {
4520                    //             zerr("doubled sort specifier");
4521                    //             restore_globstate(saved);
4522                    //             return;
4523                    //         }
4524                    //     }
4525                    //     gf_sorts |= t;
4526                    // GS_EXEC is exempt — `oe:…:` may appear repeatedly, each
4527                    // with its own code. This check was missing entirely, so
4528                    // `*(.onon)` and `*(.onOn)` silently sorted where zsh
4529                    // fails. Note the test is on the SHIFTED key, so `oL` and
4530                    // `OL` collide (same key, different direction) while `oL`
4531                    // and `oLm` do not (the follow variant shifts).
4532                    if (shifted & GS_EXEC) == 0 && qs.sorts.iter().any(|s| (s & !GS_DESC) == shifted)
4533                    {
4534                        crate::ported::utils::zerr("doubled sort specifier"); // c:1697
4535                        crate::ported::utils::errflag.fetch_or(
4536                            crate::ported::utils::ERRFLAG_ERROR,
4537                            std::sync::atomic::Ordering::Relaxed,
4538                        );
4539                        return qs; // c:1699
4540                    }
4541                    // c:1658-1662 — `if (gf_nsorts == MAX_SORTS) { zerr("too
4542                    // many glob sort specifiers"); ... return; }`. MAX_SORTS is
4543                    // 12 (c:164). Unreachable in practice now that the doubled
4544                    // check above rejects repeats, but it is C's bound and the
4545                    // list is otherwise unbounded.
4546                    if qs.sorts.len() == MAX_SORTS {
4547                        crate::ported::utils::zerr("too many glob sort specifiers"); // c:1659
4548                        crate::ported::utils::errflag.fetch_or(
4549                            crate::ported::utils::ERRFLAG_ERROR,
4550                            std::sync::atomic::Ordering::Relaxed,
4551                        );
4552                        return qs; // c:1661
4553                    }
4554                    let tp = shifted | (if desc { GS_DESC } else { 0 });
4555                    qs.sorts.push(tp);
4556                } else {
4557                    // c:1666-1690 — C does not guard on "is there a next
4558                    // character": it reads `switch (*s)` unconditionally, so an
4559                    // `o`/`O` at the very end of the qualifier list sees the
4560                    // terminating `)` and lands on `default: zerr("unknown sort
4561                    // specifier")`. This port skipped the whole block when the
4562                    // iterator was exhausted, so a trailing `o` was silently
4563                    // ignored and `*(No)` listed every match where zsh fails.
4564                    // A NON-trailing bad spec (`*(ozN)`) already errored — it
4565                    // reaches the `_` arm above — which is why only the
4566                    // end-of-list form survived.
4567                    crate::ported::utils::zerr("unknown sort specifier"); // c:1688
4568                    crate::ported::utils::errflag.fetch_or(
4569                        crate::ported::utils::ERRFLAG_ERROR,
4570                        std::sync::atomic::Ordering::Relaxed,
4571                    );
4572                    return qs; // c:1690
4573                }
4574            }
4575            // Flags
4576            // c:1567-1569 — `case 'N': gf_nullglob = !(sense & 1);`.
4577            // Per-glob nullglob: empty result on no-match, no error.
4578            'N' => qs.nullglob = !negated,
4579            'D' => qs.globdots = !negated,
4580            // c:1575-1577 — `case 'n': gf_numsort = !(sense & 1)`.
4581            // Standalone `(n)` enables numeric sort for this glob (the
4582            // `^n` toggle disables it). Consumed in sort_matches.
4583            'n' => qs.numsort = Some(!negated),
4584            // (M) / (T) — set per-qualifier-set flags. glob.c:1557-1566:
4585            //   case 'M': gf_markdirs = !(sense & 1);  break;
4586            //   case 'T': gf_listtypes = !(sense & 1); break;
4587            // `sense & 1` = the `^`-toggle bit. zshrs's parser tracks
4588            // `negated`; mirror by `!negated`. Read at output-emit
4589            // time to mark dirs / list types like coreutils ls -F.
4590            'M' => qs.mark_dirs = !negated,
4591            'T' => qs.list_types = !negated,
4592            'F' => qs.qualifiers.push(qualifier::NonEmptyDir),
4593            // c:1744-1756 — `P:word:` adds `word` to gf_pre_words (or
4594            // gf_post_words under `^`), emitted as a separate element
4595            // before/after each match. `glob_exec_string` parses the
4596            // delimited word.
4597            'P' => {
4598                let rest: String = chars.clone().collect();
4599                if let Some((word, consumed)) = glob_exec_string(&rest, false) {
4600                    for _ in 0..consumed {
4601                        chars.next();
4602                    }
4603                    if negated {
4604                        qs.post_words.push(word); // c:1750 gf_post_words
4605                    } else {
4606                        qs.pre_words.push(word); // c:1750 gf_pre_words
4607                    }
4608                }
4609            }
4610            // Subscript
4611            '[' => {
4612                let (first, last) = parse_subscript(&mut chars);
4613                qs.first = first;
4614                qs.last = last;
4615            }
4616            // c:Src/glob.c:1579-1595 `case 'Y'` — short-circuit:
4617            // limit matches to at most N. Reads a numeric argument
4618            // immediately following the `Y`. Bug #41 in docs/BUGS.md.
4619            'Y' => {
4620                // c:1579-1594:
4621                //     const char *s_saved = s;
4622                //     shortcircuit = !(sense & 1);
4623                //     if (shortcircuit) {
4624                //         data = qgetnum(&s);
4625                //         if ((shortcircuit = data) != data) {
4626                //             /* Integer overflow */
4627                //             zerr("value too big: Y%s", s_saved);
4628                //             restore_globstate(saved);
4629                //             return;
4630                //         }
4631                //     }
4632                // `data` is a zlong and `shortcircuit` an int, so the
4633                // assignment-then-compare is a 64→32 TRUNCATION test: a limit
4634                // that does not survive the narrowing is fatal. This parsed
4635                // straight into i32 and silently dropped the qualifier when
4636                // that failed, so `*(Y2147483648)` listed every match where
4637                // zsh errors — the limit was simply ignored.
4638                //
4639                // s_saved is the text AFTER the `Y`, running to the end of the
4640                // qualifier list, which is why the message carries any trailing
4641                // qualifiers too: `*(.NY99…)` reports `value too big: Y99…`
4642                // exactly as spelled.
4643                let s_saved: String = chars.clone().collect(); // c:1582
4644                let mut num_str = String::new();
4645                while let Some(&pc) = chars.peek() {
4646                    if pc.is_ascii_digit() {
4647                        num_str.push(pc);
4648                        chars.next();
4649                    } else {
4650                        break;
4651                    }
4652                }
4653                // c:1586 — qgetnum is `while (idigit(**s)) v = v * 10 + …`,
4654                // with no overflow check of its own; it simply wraps. Mirror
4655                // that rather than failing the parse, so the truncation test
4656                // below is what decides, exactly as in C.
4657                let mut data: i64 = 0;
4658                for ch in num_str.chars() {
4659                    data = data
4660                        .wrapping_mul(10)
4661                        .wrapping_add((ch as i64) - ('0' as i64));
4662                }
4663                let sc = data as i32; // c:1587 `shortcircuit = data`
4664                if sc as i64 != data {
4665                    // c:1587 `!= data`
4666                    crate::ported::utils::zerr(&format!("value too big: Y{s_saved}")); // c:1589
4667                    crate::ported::utils::errflag.fetch_or(
4668                        crate::ported::utils::ERRFLAG_ERROR,
4669                        std::sync::atomic::Ordering::Relaxed,
4670                    );
4671                    return qs; // c:1591
4672                }
4673                qs.short_circuit = Some(sc);
4674            }
4675            // c:Src/glob.c:1599-1620 `case 'e'` — `(e:CODE:)` shell-
4676            // eval qualifier. The body is delimited by the char
4677            // immediately following 'e' (matched at both ends).
4678            // Common forms in zsh scripts: `(e:'[[ -L $REPLY ]]':)`
4679            // for symlink filter, etc. The body runs against each
4680            // candidate path with $REPLY set; the file is kept iff
4681            // the body returns 0. zshrs's qualifier::Eval variant
4682            // was defined but had no parser arm — was rejected as
4683            // "unknown file attribute: e". Bug #469.
4684            'e' => {
4685                let delim = match chars.next() {
4686                    Some(d) => d,
4687                    None => break,
4688                };
4689                let mut body = String::new();
4690                while let Some(&pc) = chars.peek() {
4691                    if pc == delim {
4692                        chars.next();
4693                        break;
4694                    }
4695                    body.push(pc);
4696                    chars.next();
4697                }
4698                if negated {
4699                    // (^e:CODE:) inverts; we route through Eval and
4700                    // flip by wrapping the body. Simpler: just store
4701                    // and let the negated flag at the qualifier_set
4702                    // level handle inversion.
4703                }
4704                qs.qualifiers.push(qualifier::Eval(body));
4705            }
4706            // c:Src/glob.c:1708-1722 `case '+':` — `(+FUNC)` invokes
4707            // shell function FUNC on each candidate; keep file iff
4708            // function returns 0. C's glob_exec_string (c:1085) reads
4709            // the identifier name via `itype_end(s, IIDENT, 0)` when
4710            // the qualifier letter was '+'. The body wraps the call as
4711            // a shell expression `FUNC` that qualsheval (c:4769 zshrs
4712            // qualifier::Eval) runs as a one-shot. Bug #N — this arm
4713            // was missing entirely, so `*(+func)` and `*(s+0)`-style
4714            // mixed-qualifier strings errored "unknown file attribute:
4715            // +" instead of routing through the Eval path that would
4716            // either match files or fall through to "no matches found".
4717            '+' => {
4718                // c:1090-1097 — `tt = itype_end(s, IIDENT, 0); if
4719                // (tt == s) zerr("missing identifier after `+'")`.
4720                // Read identifier chars greedily.
4721                let mut ident = String::new();
4722                while let Some(&pc) = chars.peek() {
4723                    if pc.is_ascii_alphanumeric() || pc == '_' {
4724                        ident.push(pc);
4725                        chars.next();
4726                    } else {
4727                        break;
4728                    }
4729                }
4730                if ident.is_empty() {
4731                    crate::ported::utils::zerr("missing identifier after `+'"); // c:1095
4732                    crate::ported::utils::errflag.fetch_or(
4733                        crate::ported::utils::ERRFLAG_ERROR,
4734                        std::sync::atomic::Ordering::Relaxed,
4735                    );
4736                    return qs;
4737                }
4738                // c:1109-1117 — the identifier IS the body that
4739                // qualsheval will run. Push as Eval so the same
4740                // per-file evaluator at c:4769 fires.
4741                qs.qualifiers.push(qualifier::Eval(ident));
4742            }
4743            // c:Src/glob.c:1758-1762 — `default: zerr("unknown file
4744            // attribute: %c", *s); restore_globstate(saved); return;`.
4745            // Bug #583: zshrs's `_ => {}` arm silently accepted any
4746            // unknown qualifier letter, so `*(z)` returned all files
4747            // rc=0 instead of erroring "unknown file attribute: z".
4748            // Mirror C's strict behavior — but skip ASCII whitespace
4749            // / `\0` (parser leftovers) and chars below 0x21 since
4750            // C's qualifier loop stops on those naturally via its
4751            // `case ')'` arm before reaching default.
4752            ch if !ch.is_whitespace() && ch != '\0' && ch != ')' => {
4753                crate::ported::utils::zerr(&format!("unknown file attribute: {}", ch));
4754                crate::ported::utils::errflag.fetch_or(
4755                    crate::ported::utils::ERRFLAG_ERROR,
4756                    std::sync::atomic::Ordering::Relaxed,
4757                );
4758                return qs;
4759            }
4760            _ => {}
4761        }
4762    }
4763
4764    if !qs.qualifiers.is_empty() {
4765        qs.alternatives.push(std::mem::take(&mut qs.qualifiers));
4766    }
4767
4768    qs.follow_links = follow;
4769
4770    // Build the faithful glob.c `struct qual` arena from `alternatives`
4771    // (additive: matching still uses the enum). Each alternative is an
4772    // AND-chain (`next` links); alternatives chain via `or`. ToggleSense
4773    // markers toggle a running per-node `sense` (c:1346). Each variant
4774    // maps to its leaf TestMatchFunc + data, mirroring check_single_
4775    // qualifier's dispatch (Src/glob.c:1343-1620 case → func/data).
4776    {
4777        let op_range = |o: char| -> i32 {
4778            match o {
4779                '<' => -1,
4780                '>' => 1,
4781                _ => 0,
4782            }
4783        };
4784        let mut arena = QualArena::new();
4785        let mut prev_alt_head: Option<usize> = None;
4786        for alt in &qs.alternatives {
4787            let mut sense = 0i32;
4788            let mut chain_tail: Option<usize> = None;
4789            let mut chain_head: Option<usize> = None;
4790            for q in alt {
4791                // (func, data, range, amc, units, sdata) — None = no node.
4792                let fields: Option<(TestMatchFunc, i64, i32, i32, i32, Option<String>)> = match q {
4793                    qualifier::ToggleSense => {
4794                        sense ^= 1; // c:1346
4795                        None
4796                    }
4797                    qualifier::IsRegular => Some((qualisreg, 0, 0, 0, 0, None)),
4798                    qualifier::IsDirectory => Some((qualisdir, 0, 0, 0, 0, None)),
4799                    qualifier::IsSymlink => Some((qualislnk, 0, 0, 0, 0, None)),
4800                    qualifier::IsSocket => Some((qualissock, 0, 0, 0, 0, None)),
4801                    qualifier::IsFifo => Some((qualisfifo, 0, 0, 0, 0, None)),
4802                    qualifier::IsBlockDev => Some((qualisblk, 0, 0, 0, 0, None)),
4803                    qualifier::IsCharDev => Some((qualischr, 0, 0, 0, 0, None)),
4804                    qualifier::IsDevice => Some((qualisdev, 0, 0, 0, 0, None)),
4805                    qualifier::IsExecutable => Some((qualiscom, 0, 0, 0, 0, None)),
4806                    qualifier::Readable => Some((qualflags, 0o400, 0, 0, 0, None)),
4807                    qualifier::Writable => Some((qualflags, 0o200, 0, 0, 0, None)),
4808                    qualifier::Executable => Some((qualflags, 0o100, 0, 0, 0, None)),
4809                    qualifier::WorldReadable => Some((qualflags, 0o004, 0, 0, 0, None)),
4810                    qualifier::WorldWritable => Some((qualflags, 0o002, 0, 0, 0, None)),
4811                    qualifier::WorldExecutable => Some((qualflags, 0o001, 0, 0, 0, None)),
4812                    qualifier::GroupReadable => Some((qualflags, 0o040, 0, 0, 0, None)),
4813                    qualifier::GroupWritable => Some((qualflags, 0o020, 0, 0, 0, None)),
4814                    qualifier::GroupExecutable => Some((qualflags, 0o010, 0, 0, 0, None)),
4815                    qualifier::Setuid => Some((qualflags, 0o4000, 0, 0, 0, None)),
4816                    qualifier::Setgid => Some((qualflags, 0o2000, 0, 0, 0, None)),
4817                    qualifier::Sticky => Some((qualflags, 0o1000, 0, 0, 0, None)),
4818                    qualifier::OwnedByEuid => {
4819                        Some((qualuid, unsafe { libc::geteuid() } as i64, 0, 0, 0, None))
4820                    }
4821                    qualifier::OwnedByEgid => {
4822                        Some((qualgid, unsafe { libc::getegid() } as i64, 0, 0, 0, None))
4823                    }
4824                    qualifier::OwnedByUid(u) => Some((qualuid, *u as i64, 0, 0, 0, None)),
4825                    qualifier::OwnedByGid(g) => Some((qualgid, *g as i64, 0, 0, 0, None)),
4826                    qualifier::Size { value, unit, op } => {
4827                        Some((qualsize, *value as i64, op_range(*op), -1, *unit, None))
4828                    }
4829                    qualifier::Links { value, op } => {
4830                        Some((qualnlink, *value as i64, op_range(*op), 0, 0, None))
4831                    }
4832                    qualifier::Atime { value, unit, op } => {
4833                        Some((qualtime, *value, op_range(*op), 0, *unit, None))
4834                    }
4835                    qualifier::Mtime { value, unit, op } => {
4836                        Some((qualtime, *value, op_range(*op), 1, *unit, None))
4837                    }
4838                    qualifier::Ctime { value, unit, op } => {
4839                        Some((qualtime, *value, op_range(*op), 2, *unit, None))
4840                    }
4841                    qualifier::Mode { yes, no } => Some((
4842                        qualmodeflags,
4843                        (*yes as i64) | ((*no as i64) << 12),
4844                        0,
4845                        0,
4846                        0,
4847                        None,
4848                    )),
4849                    qualifier::Device(d) => Some((qualdev, *d as i64, 0, 0, 0, None)),
4850                    qualifier::NonEmptyDir => Some((qualnonemptydir, 0, 0, 0, 0, None)),
4851                    qualifier::Eval(code) => Some((qualsheval, 0, 0, 0, 0, Some(code.clone()))),
4852                };
4853                let Some((func, data, range, amc, units, sdata)) = fields else {
4854                    continue;
4855                };
4856                let idx = arena.alloc(); // c:1768 hcalloc
4857                {
4858                    let node = &mut arena.nodes[idx];
4859                    node.func = Some(func); // c:1774
4860                    node.data = data; // c:1776
4861                    node.sense = sense; // c:1775
4862                    node.range = range; // c:1778
4863                    node.units = units; // c:1779
4864                    node.amc = amc; // c:1780
4865                    node.sdata = sdata; // c:1777
4866                }
4867                if let Some(t) = chain_tail {
4868                    arena.nodes[t].next = Some(idx); // c:1770
4869                }
4870                chain_tail = Some(idx);
4871                if chain_head.is_none() {
4872                    chain_head = Some(idx);
4873                }
4874            }
4875            // Link this alternative's head into the or-chain (c:1324).
4876            if let Some(h) = chain_head {
4877                if let Some(p) = prev_alt_head {
4878                    arena.nodes[p].or = Some(h);
4879                }
4880                prev_alt_head = Some(h);
4881                if arena.head.is_none() {
4882                    arena.head = Some(h);
4883                }
4884            }
4885        }
4886        qs.quals = arena;
4887    }
4888
4889    qs
4890}
4891
4892/// Parse a numeric uid/gid or a delimited user/group name from a `u`/`g`
4893/// qualifier char stream. Port of the name-resolution arms of the `u`/`g`
4894/// qualifier cases at `Src/glob.c:1468-1535`:
4895/// `if (idigit(*s)) data = qgetnum(&s);` else `get_strarg`-delimited name
4896/// resolved through `getpwnam(3)` / `getgrnam(3)`. On an unknown name it
4897/// emits the C diagnostic (`zerr` sets errflag) and returns 0, matching
4898/// C's `data = 0;` arm. `is_group` selects getgrnam over getpwnam.
4899fn parse_uid_gid(chars: &mut std::iter::Peekable<std::str::Chars>, is_group: bool) -> u32 {
4900    // c:1468/1511 — `if (idigit(*s)) data = qgetnum(&s);`
4901    if chars.peek().map(|c| c.is_ascii_digit()).unwrap_or(false) {
4902        let mut num = String::new();
4903        while let Some(&c) = chars.peek() {
4904            if c.is_ascii_digit() {
4905                num.push(c);
4906                chars.next();
4907            } else {
4908                break;
4909            }
4910        }
4911        return num.parse().unwrap_or(0);
4912    }
4913    // c:1471-1505 — `tt = get_strarg(s, &arglen);` the char right after the
4914    // qualifier is the delimiter; the name runs to the next delimiter.
4915    let delim = match chars.next() {
4916        Some(d) => d,
4917        None => {
4918            // c:1481/1524 — `zerr("missing delimiter for 'u'/'g' glob qualifier")`.
4919            zerr(if is_group {
4920                "missing delimiter for 'g' glob qualifier"
4921            } else {
4922                "missing delimiter for 'u' glob qualifier"
4923            });
4924            return 0;
4925        }
4926    };
4927    let mut name = String::new();
4928    for c in chars.by_ref() {
4929        if c == delim {
4930            break;
4931        }
4932        name.push(c);
4933    }
4934    // c:1488/1530 — resolve via getpwnam(3) / getgrnam(3).
4935    let cstr = match std::ffi::CString::new(name.as_bytes()) {
4936        Ok(c) => c,
4937        Err(_) => return 0,
4938    };
4939    unsafe {
4940        if is_group {
4941            let gr = libc::getgrnam(cstr.as_ptr()); // c:1530
4942            if !gr.is_null() {
4943                return (*gr).gr_gid; // c:1531
4944            }
4945            zerr("unknown group"); // c:1533
4946        } else {
4947            let pw = libc::getpwnam(cstr.as_ptr()); // c:1488
4948            if !pw.is_null() {
4949                return (*pw).pw_uid; // c:1489
4950            }
4951            // c:1491 — `zerr("unknown username '%s'", s + arglen);`
4952            zerr(&format!("unknown username '{}'", name));
4953        }
4954    }
4955    // c:1493/1535 — `data = 0;` after the unknown-name diagnostic.
4956    0
4957}
4958
4959/// Parse the unit/op/value tail of an `(L...)` size qualifier.
4960/// **RUST-ONLY** — C parses inline in parsepat; the size-unit
4961/// constants are `TT_BYTES`/`TT_KILOBYTES`/… at glob.c:128-133.
4962fn parse_size_spec(
4963    // RUST-ONLY
4964    chars: &mut std::iter::Peekable<std::str::Chars>,
4965) -> (i32, char, u64) {
4966    let unit: i32 = match chars.peek() {
4967        Some('p') | Some('P') => {
4968            chars.next();
4969            TT_POSIX_BLOCKS
4970        }
4971        Some('k') | Some('K') => {
4972            chars.next();
4973            TT_KILOBYTES
4974        }
4975        Some('m') | Some('M') => {
4976            chars.next();
4977            TT_MEGABYTES
4978        }
4979        Some('g') | Some('G') => {
4980            chars.next();
4981            TT_GIGABYTES
4982        }
4983        Some('t') | Some('T') => {
4984            chars.next();
4985            TT_TERABYTES
4986        }
4987        _ => TT_BYTES,
4988    };
4989    let (op, val) = parse_range_spec(chars);
4990    (unit, op, val)
4991}
4992
4993/// Parse the unit/op/value tail of an `(a/m/c...)` time qualifier.
4994/// **RUST-ONLY** — C parses inline in parsepat; time-unit constants
4995/// are `TT_SECONDS`/`TT_MINS`/… at glob.c:121-126.
4996fn schedgetfn(
4997    // RUST-ONLY (clashes with sched.c:341 name, unrelated fn)
4998    chars: &mut std::iter::Peekable<std::str::Chars>,
4999) -> (i32, char, u64) {
5000    let unit: i32 = match chars.peek() {
5001        Some('s') => {
5002            chars.next();
5003            TT_SECONDS
5004        }
5005        Some('m') => {
5006            chars.next();
5007            TT_MINS
5008        }
5009        Some('h') => {
5010            chars.next();
5011            TT_HOURS
5012        }
5013        Some('d') => {
5014            chars.next();
5015            TT_DAYS
5016        }
5017        Some('w') => {
5018            chars.next();
5019            TT_WEEKS
5020        }
5021        Some('M') => {
5022            chars.next();
5023            TT_MONTHS
5024        }
5025        _ => TT_DAYS,
5026    };
5027    let (op, val) = parse_range_spec(chars);
5028    (unit, op, val)
5029}
5030
5031/// Parse `[+-]?NUMBER` operator+value tail. Mirrors C qgetnum
5032/// (glob.c:827) which returns the int value; here we return the
5033/// operator char along with the value since callers need both.
5034fn parse_range_spec(chars: &mut std::iter::Peekable<std::str::Chars>) -> (char, u64) {
5035    // RUST-ONLY
5036    // C's qgetnum at glob.c:827 returns the operator char inline.
5037    // `+N` = greater, `-N` = less, bare digits = equal.
5038    let op: char = match chars.peek() {
5039        Some('+') => {
5040            chars.next();
5041            '>'
5042        }
5043        Some('-') => {
5044            chars.next();
5045            '<'
5046        }
5047        _ => '=',
5048    };
5049    let mut num = String::new();
5050    while let Some(&c) = chars.peek() {
5051        if c.is_ascii_digit() {
5052            num.push(c);
5053            chars.next();
5054        } else {
5055            break;
5056        }
5057    }
5058    // c:Src/glob.c:826-834 — `qgetnum` errors with "number expected"
5059    // when not followed by `idigit(**s)`. Previously the Rust parser
5060    // silently treated the missing number as 0 and let the outer
5061    // qualifier loop continue consuming bytes as fresh qualifier
5062    // letters — `*(Lr)` parsed as `L0 + r` (readable, size=0) and
5063    // dropped to the no-matches path. Setting errflag here propagates
5064    // the canonical error message and gates the "no matches found"
5065    // emission in zglob (c:1843-1854 `if (errflag) ... return`).
5066    if num.is_empty() {
5067        crate::ported::utils::zerr("number expected"); // c:832
5068        crate::ported::utils::errflag.fetch_or(
5069            crate::ported::zsh_h::ERRFLAG_ERROR,
5070            std::sync::atomic::Ordering::SeqCst,
5071        );
5072        return (op, 0); // c:833
5073    }
5074    let val = num.parse().unwrap_or(0);
5075    (op, val)
5076}
5077
5078/// Parse `[FIRST,LAST]` subscript range. **RUST-ONLY**.
5079fn parse_subscript(
5080    // RUST-ONLY
5081    chars: &mut std::iter::Peekable<std::str::Chars>,
5082) -> (Option<i32>, Option<i32>) {
5083    let mut first_str = String::new();
5084    let mut last_str = String::new();
5085    let mut in_last = false;
5086
5087    while let Some(&c) = chars.peek() {
5088        chars.next();
5089        if c == ']' {
5090            break;
5091        } else if c == ',' {
5092            in_last = true;
5093        } else if in_last {
5094            last_str.push(c);
5095        } else {
5096            first_str.push(c);
5097        }
5098    }
5099
5100    let first = first_str.parse().ok();
5101    let last = if in_last {
5102        last_str.parse().ok()
5103    } else {
5104        first
5105    };
5106    (first, last)
5107}
5108
5109/// Port of `static void insert(char *s, int checked)` from `Src/glob.c:346`.
5110/// Records one matched path into the glob result list: runs the qualifier
5111/// walk (`check_qualifiers` = the c:381-419 `struct qual` walk over the
5112/// arena) and, if accepted, builds the `gmatch` entry from the file's
5113/// stat and appends it (c:421-490) — honoring any `$reply`/`$REPLY`
5114/// replacement (INSERTS, c:428) set by an `(e:…:)` eval qualifier.
5115/// Called by the scanner per matched name, mirroring glob.c:576/668.
5116pub fn insert(state: &mut globdata, s: &Path, checked: i32) {
5117    use std::os::unix::fs::MetadataExt;
5118    let _ = checked; // c:347 already-stat'd hint; check_qualifiers re-stats.
5119                     // c:381-419 — qualifier walk; reject the file if it fails.
5120    if !check_qualifiers(state, s) {
5121        return;
5122    }
5123    // c:421-490 — build + append the match entry. Symlink targets get
5124    // their own stat for the GS_LINKED (follow-link) sort variants.
5125    let Ok(meta) = fs::symlink_metadata(s) else {
5126        return;
5127    };
5128    let name = s
5129        .file_name()
5130        .map(|n| n.to_string_lossy().to_string())
5131        .unwrap_or_default();
5132    let (tsize, tatime, tmtime, tctime, tlinks, tansec, tmnsec, tcnsec) =
5133        if meta.file_type().is_symlink() {
5134            if let Ok(tm) = fs::metadata(s) {
5135                (
5136                    tm.size(),
5137                    tm.atime(),
5138                    tm.mtime(),
5139                    tm.ctime(),
5140                    tm.nlink(),
5141                    tm.atime_nsec(),
5142                    tm.mtime_nsec(),
5143                    tm.ctime_nsec(),
5144                )
5145            } else {
5146                (
5147                    meta.size(),
5148                    meta.atime(),
5149                    meta.mtime(),
5150                    meta.ctime(),
5151                    meta.nlink(),
5152                    meta.atime_nsec(),
5153                    meta.mtime_nsec(),
5154                    meta.ctime_nsec(),
5155                )
5156            }
5157        } else {
5158            (
5159                meta.size(),
5160                meta.atime(),
5161                meta.mtime(),
5162                meta.ctime(),
5163                meta.nlink(),
5164                meta.atime_nsec(),
5165                meta.mtime_nsec(),
5166                meta.ctime_nsec(),
5167            )
5168        };
5169    // c:428 — `while (!inserts || (news = *inserts++))`: an (e:…:) eval
5170    // may have set $reply/$REPLY (INSERTS), replacing this match with
5171    // zero-or-more names; otherwise emit the original name once. The
5172    // synthetic path makes glob_emit_path yield the replacement string;
5173    // the stat fields stay those of the matched file (C keeps
5174    // matchptr->size etc. from buf).
5175    let emit: Vec<(String, PathBuf)> = match INSERTS.lock().unwrap().take() {
5176        Some(list) => list
5177            .into_iter()
5178            .map(|n| (n.clone(), PathBuf::from(&n)))
5179            .collect(),
5180        None => vec![(name.clone(), s.to_path_buf())],
5181    };
5182    for (nm, pth) in emit {
5183        state.matches.push(gmatch {
5184            name: nm,
5185            path: pth,
5186            size: meta.size(),
5187            atime: meta.atime(),
5188            mtime: meta.mtime(),
5189            ctime: meta.ctime(),
5190            links: meta.nlink(),
5191            mode: meta.mode(),
5192            uid: meta.uid(),
5193            gid: meta.gid(),
5194            dev: meta.dev(),
5195            ino: meta.ino(),
5196            target_size: tsize,
5197            target_atime: tatime,
5198            target_mtime: tmtime,
5199            target_ctime: tctime,
5200            target_links: tlinks,
5201            ansec: meta.atime_nsec(),
5202            mnsec: meta.mtime_nsec(),
5203            cnsec: meta.ctime_nsec(),
5204            target_ansec: tansec,
5205            target_mnsec: tmnsec,
5206            target_cnsec: tcnsec,
5207            sort_strings: Vec::new(),
5208        });
5209        state.matchct += 1; // c:479 matchptr++
5210    }
5211}
5212
5213/// Drive the OR-of-AND qualifier filter against `path`. **RUST-ONLY**
5214/// — C glob.c does qualifier eval inline inside `insert()` (c:381+).
5215fn check_qualifiers(state: &globdata, path: &Path) -> bool {
5216    use std::os::unix::fs::MetadataExt;
5217    use std::sync::atomic::Ordering;
5218    // c:353 — `inserts = NULL;` at insert() entry: clear any reply/REPLY
5219    // replacement left by a previous file's (e:…:) eval before this walk.
5220    *INSERTS.lock().unwrap() = None;
5221    let qs = match &state.qualifiers {
5222        Some(q) => q,
5223        None => return true,
5224    };
5225    // No test qualifiers (only flags/sort/words) → no filtering.
5226    let arena = &qs.quals;
5227    if arena.head.is_none() {
5228        return true;
5229    }
5230
5231    let meta = match if qs.follow_links {
5232        // c:399-402 — `if (!S_ISLNK(buf.st_mode) || statfullpath(s,&buf2,0))
5233        //                  memcpy(&buf2, &buf, sizeof(buf));`
5234        // Follow the link, but fall back to the lstat buffer when the
5235        // target stat fails (a broken symlink) so `*(-@)` / `*(-^/)`
5236        // still see the dangling link rather than dropping it.
5237        fs::metadata(path).or_else(|_| fs::symlink_metadata(path))
5238    } else {
5239        fs::symlink_metadata(path)
5240    } {
5241        Ok(m) => m,
5242        Err(_) => return false,
5243    };
5244    // Bridge meta → libc::stat for the leaf test fns (no extra syscall).
5245    let mut st: libc::stat = unsafe { std::mem::zeroed() };
5246    st.st_mode = meta.mode() as _;
5247    st.st_uid = meta.uid() as _;
5248    st.st_gid = meta.gid() as _;
5249    st.st_dev = meta.dev() as _;
5250    st.st_nlink = meta.nlink() as _;
5251    st.st_size = meta.size() as _;
5252    st.st_atime = meta.atime() as _;
5253    st.st_mtime = meta.mtime() as _;
5254    st.st_ctime = meta.ctime() as _;
5255    // qualsheval / qualnonemptydir need the name (REPLY / opendir).
5256    let name = glob_emit_path(path);
5257
5258    // c:387-419 — insert()'s qual-walk over the `struct qual` arena: AND
5259    // via `next`, OR (alternatives) via `or`, per-node `sense`. `qo` is
5260    // the current alternative head; a rejected node advances to `qo->or`
5261    // (next alternative) or, if none, rejects the file. Falling off the
5262    // `next` chain (or hitting a func-less terminator) accepts.
5263    let mut qo = arena.head;
5264    let mut qn = qo;
5265    loop {
5266        let Some(qn_i) = qn else { return true }; // c:419 — chain matched
5267        let (func, data, sense, range, amc, units, sdata, next) = {
5268            let n = &arena.nodes[qn_i];
5269            (
5270                n.func,
5271                n.data,
5272                n.sense,
5273                n.range,
5274                n.amc,
5275                n.units,
5276                n.sdata.clone(),
5277                n.next,
5278            )
5279        };
5280        let func = match func {
5281            Some(f) => f,
5282            None => return true, // c:392 — `qn && qn->func` terminator
5283        };
5284        // c:393-395 — set the scratch the range/time/size leaf fns read.
5285        g_range.store(range, Ordering::SeqCst);
5286        g_amc.store(amc, Ordering::SeqCst);
5287        g_units.store(units, Ordering::SeqCst);
5288        let r = func(&name, &st, data, sdata.as_deref().unwrap_or(""));
5289        // c:407-409 — reject if `(!(func()) ^ sense) & 1`.
5290        let reject = ((if r == 0 { 1 } else { 0 }) ^ (sense & 1)) & 1 == 1;
5291        if reject {
5292            // c:411-415 — try the next alternative, else reject the file.
5293            match arena.nodes[qo.unwrap()].or {
5294                Some(or_i) => {
5295                    qo = Some(or_i);
5296                    qn = Some(or_i);
5297                }
5298                None => return false,
5299            }
5300        } else {
5301            qn = next; // c:417
5302        }
5303    }
5304}
5305
5306/// Sort the per-state matches per the qualifier `o`/`O` keys.
5307/// **RUST-ONLY** — C does this inline at the end of `scanner()`
5308/// (glob.c:1700ish, qsort with `gmatchcmp` comparator).
5309fn sort_matches(state: &mut globdata) {
5310    // RUST-ONLY
5311    // Default sort is GS_NAME ascending (c:204 gf_sortlist
5312    // initial setup). Per-qualifier `o<key>` / `O<key>` overrides.
5313    // c:1855-1857 — when gf_nsorts == 0 (no explicit o/O qualifier),
5314    // glob.c falls back to GS_NAME (or GS_NONE under shortcircuit).
5315    // Mirror that here so a qualifier set carrying ONLY non-sort
5316    // qualifiers (`Lk+0`, etc.) still sorts alphabetically.
5317    // c:1855-1857 is a CONDITIONAL default, not a fixed one:
5318    //     if (!gf_nsorts) {
5319    //         gf_sortlist[0].tp = gf_sorts = (shortcircuit ? GS_NONE : GS_NAME);
5320    //         gf_nsorts = 1;
5321    //     }
5322    // A `Y` limit means "give me the first N the scanner finds", so with no
5323    // explicit o/O spec the default becomes GS_NONE and the results stay in
5324    // SCAN order — sorting them would defeat the short-circuit, since the first
5325    // N by name are not the first N found. This always chose GS_NAME, so a
5326    // limited glob returned the alphabetically-first N instead of the
5327    // scan-order-first N, and even `*(Y99)` (a limit larger than the match
5328    // count, which discards nothing) came back sorted where zsh leaves it in
5329    // readdir order.
5330    //
5331    // `Y0` is the counter-case and must still sort: c:1583 parses the argument
5332    // over `shortcircuit`, so 0 lands there legitimately and c:518's
5333    // `if (shortcircuit && …)` reads it as "no limit" — hence the `> 0` test
5334    // rather than `.is_some()`.
5335    let limited = state
5336        .qualifiers
5337        .as_ref()
5338        .and_then(|q| q.short_circuit)
5339        .is_some_and(|n| n > 0); // c:1856 `shortcircuit ?`
5340    let default_spec = if limited { GS_NONE } else { GS_NAME }; // c:1856
5341    let mut specs: Vec<i32> = state
5342        .qualifiers
5343        .as_ref()
5344        .map(|q| {
5345            if q.sorts.is_empty() {
5346                vec![default_spec] // c:1856
5347            } else {
5348                q.sorts.clone()
5349            }
5350        })
5351        .unwrap_or_else(|| vec![GS_NAME]);
5352
5353    // GS_NONE marker — caller wants no sort at all.
5354    if specs.iter().any(|&tp| (tp & GS_NONE) != 0) {
5355        return;
5356    }
5357
5358    // c:936 gmatchcmp returns 0 when every sort key ties. In C the final
5359    // order of equal-key matches is then whatever the libc `qsort` does with
5360    // all-equal elements (glob.c:1976) — NON-stable on macOS/BSD (arbitrary,
5361    // input-dependent: `*(oL)` on three 0-byte files a/b/d prints `d a b`),
5362    // and on glibc it depends on readdir order. So zsh has NO portable,
5363    // defined tie-break here. zshrs is a cross-architecture shell and must be
5364    // DETERMINISTIC, so we impose an explicit GS_NAME final tie-breaker:
5365    // equal-key matches order by name ascending, identical on every platform.
5366    // This is an intentional determinism guarantee, NOT a bug-for-bug match of
5367    // zsh's qsort-defined order (which can't be reproduced portably). Skip
5368    // when the last key is already GS_NAME (the no-qualifier default, or an
5369    // explicit trailing `on`) so we don't double-compare.
5370    if specs.last().map_or(true, |&last| (last & !GS_DESC) != GS_NAME) {
5371        specs.push(GS_NAME); // deterministic name-ascending tie-break
5372    }
5373
5374    // c:1258/1575 — gf_numsort starts at the global NUMERIC_GLOB_SORT
5375    // option, then a per-glob `(n)`/`(^n)` qualifier OVERRIDES it
5376    // absolutely (so `(^n)` forces lexical even when the option is on).
5377    let numeric = state
5378        .qualifiers
5379        .as_ref()
5380        .and_then(|q| q.numsort)
5381        .unwrap_or_else(|| glob_isset(NUMERICGLOBSORT));
5382    state
5383        .matches
5384        .sort_by(|a, b| gmatchcmp(a, b, &specs, numeric));
5385}
5386
5387/// Apply `[FIRST,LAST]` qualifier subscript on the match list.
5388/// **RUST-ONLY** — C uses `gd_pre_first` / `gd_first` index tracking
5389/// during scanner emit; here we slice after the full walk.
5390fn apply_selection(state: &mut globdata) {
5391    // RUST-ONLY
5392    let (first, last, short_circuit) = match &state.qualifiers {
5393        Some(q) => (q.first, q.last, q.short_circuit),
5394        None => return,
5395    };
5396
5397    let len = state.matches.len() as i32;
5398    if len == 0 {
5399        return;
5400    }
5401
5402    let start = match first {
5403        Some(f) if f < 0 => (len + f).max(0) as usize,
5404        Some(f) => (f - 1).max(0) as usize,
5405        None => 0,
5406    };
5407
5408    let end = match last {
5409        Some(l) if l < 0 => (len + l + 1).max(0) as usize,
5410        Some(l) => l.min(len) as usize,
5411        None => len as usize,
5412    };
5413
5414    if start < end && start < state.matches.len() {
5415        state.matches = state.matches[start..end.min(state.matches.len())].to_vec();
5416    } else {
5417        state.matches.clear();
5418    }
5419
5420    // c:Src/glob.c:1579-1595 `case 'Y'` — apply the short-circuit
5421    // limit after the [first,last] slice (so `(Y3[2,4])` would yield
5422    // up to 3 from the [2,4] subscript range). C truncates DURING
5423    // the scanner walk via `if (shortcircuit == matchct) break;` —
5424    // here we slice post-walk which produces the same set on the
5425    // common short-glob case where the scanner doesn't recurse
5426    // unboundedly. Bug #41 in docs/BUGS.md.
5427    // NOTE: the `Y` short-circuit is deliberately NOT applied here. It bounds
5428    // the SCAN (c:Src/glob.c:518), so it must run BEFORE the sort — see the
5429    // truncation at the sort_matches call site. Only the `[first,last]`
5430    // subscript belongs in this post-sort selection.
5431    let _ = short_circuit;
5432}
5433
5434// `haswilds()` is defined in `Src/pattern.c:4306`, not glob.c — the
5435// canonical Rust port lives at `crate::ported::pattern::haswilds`.
5436// This file previously carried a divergent re-implementation tracking
5437// bracket/escape state instead of the token-aware `zpc_disables[]` /
5438// SHGLOB / KSHGLOB / EXTENDEDGLOB checks the C source actually does.
5439// Drift deleted; glob.rs callers route through the canonical port.
5440
5441#[cfg(test)]
5442mod gs_tt_tests {
5443    use super::*;
5444
5445    #[test]
5446    fn gs_size_offset_matches_c() {
5447        let _g = crate::test_util::global_state_lock();
5448        // c:83 — GS_SIZE = GS_SHIFT_BASE = 8.
5449        assert_eq!(GS_SIZE, 8);
5450        // c:84 — GS_ATIME = GS_SHIFT_BASE << 1 = 16.
5451        assert_eq!(GS_ATIME, 16);
5452        // c:87 — GS_LINKS = GS_SHIFT_BASE << 4 = 128.
5453        assert_eq!(GS_LINKS, 128);
5454    }
5455
5456    #[test]
5457    fn gs_normal_covers_all_size_keys() {
5458        let _g = crate::test_util::global_state_lock();
5459        // c:99 — GS_NORMAL = SIZE | ATIME | MTIME | CTIME | LINKS.
5460        assert_ne!(GS_NORMAL & GS_SIZE, 0);
5461        assert_ne!(GS_NORMAL & GS_ATIME, 0);
5462        assert_ne!(GS_NORMAL & GS_MTIME, 0);
5463        assert_ne!(GS_NORMAL & GS_CTIME, 0);
5464        assert_ne!(GS_NORMAL & GS_LINKS, 0);
5465    }
5466
5467    #[test]
5468    fn tt_namespaces_share_indices() {
5469        let _g = crate::test_util::global_state_lock();
5470        // c:121-126 vs c:128-133 — TT_DAYS == TT_BYTES == 0, etc.
5471        assert_eq!(TT_DAYS, TT_BYTES);
5472        assert_eq!(TT_HOURS, TT_POSIX_BLOCKS);
5473        assert_eq!(TT_MINS, TT_KILOBYTES);
5474        assert_eq!(TT_WEEKS, TT_MEGABYTES);
5475        assert_eq!(TT_MONTHS, TT_GIGABYTES);
5476        assert_eq!(TT_SECONDS, TT_TERABYTES);
5477    }
5478
5479    #[test]
5480    fn max_sorts_is_12() {
5481        let _g = crate::test_util::global_state_lock();
5482        assert_eq!(MAX_SORTS, 12);
5483    }
5484}
5485
5486fn expand_range(
5487    prefix: &str,
5488    content: &str,
5489    dotdot_pos: usize,
5490    suffix: &str,
5491) -> Option<Vec<String>> {
5492    // c:Src/glob.c::xpandbraces parses brace-range endpoints via
5493    // zstrtol, which is TOKEN-aware and treats Dash TOKEN (\u{9b})
5494    // as ASCII `-`. Rust's i64::parse only accepts ASCII, so
5495    // untokenize the content here before splitting/parsing — matches
5496    // C semantics without weakening the strict-TOKEN gates above.
5497    let owned: String;
5498    let content: &str = if content.chars().any(|c| {
5499        let cu = c as u32;
5500        (0x84..=0xa1).contains(&cu) && c != '\u{8f}' && c != '\u{90}' && c != '\u{9a}'
5501    }) {
5502        owned = crate::lex::untokenize(content);
5503        &owned
5504    } else {
5505        content
5506    };
5507    // `dotdot_pos` is a CHAR index (xpandbraces counts it over a char vector,
5508    // `i - start - 1`), but the slicing below is byte-based. For ASCII content
5509    // they coincide; for content holding multibyte/metafied chars (e.g.
5510    // `{$'\x80'..$'\x81'}`, where the bytes are Meta-escaped) a raw byte slice
5511    // at the char index lands mid-char and panics. Convert to a byte offset.
5512    let dotdot_byte = content
5513        .char_indices()
5514        .nth(dotdot_pos)
5515        .map(|(b, _)| b)
5516        .unwrap_or(content.len());
5517    let left = &content[..dotdot_byte];
5518    let right_start = dotdot_byte + 2; // ".." is two ASCII bytes
5519
5520    // Check for second `..` for `{N..M..S}` step form. Step may be
5521    // signed: negative-step REVERSES the natural direction sequence
5522    // per zsh's brace expansion (Src/glob.c::xpandbraces recursive
5523    // iteration with sign tracking). Examples:
5524    //   {1..32..3}   →  1,4,7,…,31 (natural ascending)
5525    //   {1..32..-3}  → 31,28,…,1   (same set, reversed)
5526    //   {32..1..3}   → 32,29,…,2   (natural descending)
5527    //   {32..1..-3}  →  2,5,…,32   (same set, reversed)
5528    //
5529    // c:Src/glob.c:2365-2369 — `if (dotdot == 2 && *p == '.' &&`
5530    //                         `    p[1] == '.') {`
5531    //                         `    rincr = zstrtol(p+2, &p, 10);`
5532    //                         `    wid3 = p - dots2 - 2;`
5533    //                         `    if (p != str2 || !rincr)`
5534    //                         `        err++;`
5535    // The `!rincr` test (c:2368) rejects a zero step: when err > 0,
5536    // the C code falls past the c:2374 `if (!err)` block, so the
5537    // brace expansion is abandoned and the literal pattern is
5538    // preserved. Parity bug #15: previously the Rust port silently
5539    // clamped step to 1 via `.max(1)`, producing `1 2 3 4 5` for
5540    // `{1..5..0}` instead of zsh's literal `1..5..0`.
5541    let (right, incr_abs, incr_sign_negative, step_text) =
5542        if let Some(pos) = content[right_start..].find("..") {
5543            let r = &content[right_start..right_start + pos];
5544            let s_text = &content[right_start + pos + 2..];
5545            let raw: i64 = s_text.parse().unwrap_or(1);
5546            if raw == 0 {
5547                // c:2368 — `!rincr` → err++ → no expansion. Return
5548                // None so xpandbraces falls back to literal.
5549                return None;
5550            }
5551            (r, raw.unsigned_abs(), raw < 0, s_text)
5552        } else {
5553            (&content[right_start..], 1u64, false, "")
5554        };
5555
5556    // Try numeric range
5557    if let (Ok(start), Ok(end)) = (left.parse::<i64>(), right.parse::<i64>()) {
5558        let mut results = Vec::new();
5559
5560        // Iterate from `start` toward `end` with abs(step). Sign of
5561        // start/end relative to each other determines natural
5562        // direction; step is always |step|.
5563        let step = incr_abs.max(1) as i64;
5564        let mut vals: Vec<i64> = Vec::new();
5565        if start <= end {
5566            let mut v = start;
5567            while v <= end {
5568                vals.push(v);
5569                v += step;
5570            }
5571        } else {
5572            let mut v = start;
5573            while v >= end {
5574                vals.push(v);
5575                v -= step;
5576            }
5577        }
5578        if incr_sign_negative {
5579            vals.reverse();
5580        }
5581
5582        // Padding: zsh pads with leading zeros when ANY of the three
5583        // textual fields (left endpoint, right endpoint, step) has a
5584        // leading zero after stripping the optional sign. Width is
5585        // the max textual width across left/right/step. For negative
5586        // values, the sign prefix counts toward width — we emit `-`
5587        // then zero-pad the remaining digits (`-02`, not `0-2`).
5588        // Direct port of Src/lex.c::dobrace_pad logic which detects
5589        // pad mode per the textual form of all three fields.
5590        // zsh's pad-detection: pad fires when any of the three
5591        // textual fields (left / right / step) is a multi-digit
5592        // form with a leading zero (e.g. "01", "00", "003"), with
5593        // ONE exception: when the LEFT endpoint is exactly "0"
5594        // (bare single zero, possibly signed), pad is suppressed
5595        // regardless of right's form. Empirical zsh:
5596        //   {0..-5}    → 0 -1 -2 -3 -4 -5    (left=0 → no pad)
5597        //   {0..03}    → 0 1 2 3              (left=0 → no pad)
5598        //   {00..-5}   → 00 -1 -2 -3 -4 -5    (left=00 → pad w=2)
5599        //   {1..03}    → 01 02 03             (right=03 → pad w=2)
5600        //   {1..-03}   → 001 000 -01 -02 -03  (right=-03 → pad w=3)
5601        let lstrip = left.trim_start_matches(['+', '-']);
5602        let rstrip = right.trim_start_matches(['+', '-']);
5603        let sstrip = step_text.trim_start_matches(['+', '-']);
5604        let is_padded_field = |stripped: &str| stripped.len() >= 2 && stripped.starts_with('0');
5605        // Suppress pad ONLY when LEFT is exactly the single-char "0"
5606        // (no sign, no extra digits). "-0" or "00" both pad.
5607        let left_is_bare_zero = left == "0";
5608        let pad = !left_is_bare_zero
5609            && (is_padded_field(lstrip)
5610                || is_padded_field(rstrip)
5611                || (!step_text.is_empty() && is_padded_field(sstrip)));
5612        let width = left.len().max(right.len()).max(step_text.len());
5613
5614        for v in vals {
5615            let formatted = if pad {
5616                if v < 0 {
5617                    let abs = (-v).to_string();
5618                    let inner_w = width.saturating_sub(1);
5619                    format!("-{:0>w$}", abs, w = inner_w)
5620                } else {
5621                    format!("{:0>w$}", v, w = width)
5622                }
5623            } else {
5624                v.to_string()
5625            };
5626            results.push(format!("{}{}{}", prefix, formatted, suffix));
5627        }
5628        return Some(results);
5629    }
5630
5631    // Try character range
5632    // c:Src/lex.c::dobrace alpha-range path — only handles bare a..z,
5633    // not a..z..N. zsh emits literal `{a..z..3}` because the step
5634    // form is numeric-only. Detect step_text presence on the char-
5635    // range arm and refuse to expand so the literal survives.
5636    if left.len() == 1 && right.len() == 1 && step_text.is_empty() {
5637        let start = left.chars().next()?;
5638        let end = right.chars().next()?;
5639        let (start, end, reverse) = if start <= end {
5640            (start, end, false)
5641        } else {
5642            (end, start, true)
5643        };
5644
5645        let mut results = Vec::new();
5646        let mut chars: Vec<char> = (start..=end).collect();
5647        if reverse {
5648            chars.reverse();
5649        }
5650
5651        for c in chars {
5652            results.push(format!("{}{}{}", prefix, c, suffix));
5653        }
5654        return Some(results);
5655    }
5656
5657    None
5658}
5659
5660fn expand_comma(
5661    prefix: &str,
5662    content: &str,
5663    positions: &[usize],
5664    suffix: &str,
5665) -> Option<Vec<String>> {
5666    // positions are CHAR indices into `content` (per the xpandbraces
5667    // walker above that builds them from `chars: Vec<char>`). Slice
5668    // by char-index, not byte-index — `&content[last..pos]` would
5669    // panic on multi-byte token chars like Comma TOKEN (`\u{9a}`,
5670    // 2 UTF-8 bytes) and Inbrace (`\u{8f}`, 2 bytes).
5671    let chars: Vec<char> = content.chars().collect();
5672    let mut results = Vec::new();
5673    let mut last: usize = 0;
5674    for &pos in positions {
5675        let part: String = chars[last..pos].iter().collect();
5676        results.push(format!("{}{}{}", prefix, part, suffix));
5677        last = pos + 1;
5678    }
5679    let tail: String = chars[last..].iter().collect();
5680    results.push(format!("{}{}{}", prefix, tail, suffix));
5681    Some(results)
5682}
5683
5684fn expand_ccl(prefix: &str, content: &str, suffix: &str) -> Option<Vec<String>> {
5685    // c:Src/glob.c:expand_ccl — char-class range expansion for the
5686    // `setopt braceccl` brace shape `{m-o}` → m,n,o. Accept both
5687    // ASCII `-` and Dash TOKEN (\u{9b}) as the range separator —
5688    // the bridge passthru path delivers `{m-o}` with Dash TOKEN
5689    // since the lexer tokenizes `-` to Dash inside word context.
5690    let mut chars_set = HashSet::new();
5691    let chars: Vec<char> = content.chars().collect();
5692    let mut i = 0;
5693
5694    while i < chars.len() {
5695        let is_range = i + 2 < chars.len() && (chars[i + 1] == '-' || chars[i + 1] == '\u{9b}');
5696        if is_range {
5697            let start = chars[i];
5698            let end = chars[i + 2];
5699            for c in start..=end {
5700                chars_set.insert(c);
5701            }
5702            i += 3;
5703        } else {
5704            chars_set.insert(chars[i]);
5705            i += 1;
5706        }
5707    }
5708
5709    let mut results: Vec<String> = chars_set
5710        .iter()
5711        .map(|c| format!("{}{}{}", prefix, c, suffix))
5712        .collect();
5713    results.sort();
5714    Some(results)
5715}
5716
5717// ============================================================================
5718// Convenience functions
5719// ============================================================================
5720
5721/// Split a pattern at its trailing zsh-style qualifier suffix. Returns
5722/// `(pattern_without_qualifier, qualifier_inner)` — the inner is the bytes
5723/// between the matching parens, without the surrounding `()` and without
5724/// any leading `#q`. Returns `(pattern, None)` when there is no qualifier
5725/// suffix. Useful for callers that want to use the pattern half with the
5726/// runtime [`matchpat`] (which has no qualifier semantics) while
5727/// reporting or applying the qualifier separately.
5728/// Split a glob pattern into (path-pattern, qualifier-string).
5729/// Port of the qualifier-detection step in `parsepat()`
5730/// (Src/glob.c:791).
5731pub fn split_qualifier(pattern: &str) -> (&str, Option<&str>) {
5732    if !pattern.ends_with(')') {
5733        return (pattern, None);
5734    }
5735    let bytes = pattern.as_bytes();
5736    let mut depth = 0;
5737    for i in (0..bytes.len()).rev() {
5738        match bytes[i] {
5739            b')' => depth += 1,
5740            b'(' => {
5741                depth -= 1;
5742                if depth == 0 {
5743                    let inner = &pattern[i + 1..pattern.len() - 1];
5744                    let inner = inner.strip_prefix("#q").unwrap_or(inner);
5745                    return (&pattern[..i], Some(inner));
5746                }
5747            }
5748            _ => {}
5749        }
5750    }
5751    (pattern, None)
5752}
5753
5754/// Strip redundant `.` / `CurDir` segments from relative match paths for
5755/// output. Rust's `read_dir(".")` yields `entry.path()` like `./foo` while
5756/// `read_dir("foo")` yields `foo/bar` — zsh prints the latter shape for both.
5757fn glob_emit_path(path: &Path) -> String {
5758    match path.components().next() {
5759        Some(Component::Prefix(_) | Component::RootDir) => path.to_string_lossy().to_string(),
5760        None => ".".to_string(),
5761        _ => {
5762            let mut out = PathBuf::new();
5763            for c in path.components() {
5764                match c {
5765                    Component::CurDir => {}
5766                    Component::ParentDir => out.push(".."),
5767                    Component::Normal(s) => out.push(s),
5768                    Component::Prefix(_) | Component::RootDir => {}
5769                }
5770            }
5771            if out.as_os_str().is_empty() {
5772                ".".to_string()
5773            } else {
5774                out.to_string_lossy().to_string()
5775            }
5776        }
5777    }
5778}
5779
5780/// Check if path is a symlink
5781/// Check whether a glob match is a symlink.
5782/// Port of the `S_ISLNK(lstat.st_mode)` test in Src/glob.c.
5783pub fn is_symlink(path: &str) -> bool {
5784    fs::symlink_metadata(path)
5785        .map(|m| m.file_type().is_symlink())
5786        .unwrap_or(false)
5787}
5788
5789// ===========================================================
5790// Methods moved verbatim from src/ported/vm_helper because their
5791// C counterpart's source file maps 1:1 to this Rust module.
5792// Phase: glob
5793// ===========================================================
5794
5795// BEGIN moved-from-exec-rs
5796// (impl ShellExecutor block moved to src/exec_shims.rs — see file marker)
5797
5798// END moved-from-exec-rs
5799
5800// ===========================================================
5801// Methods moved verbatim from src/ported/vm_helper because their
5802// C counterpart's source file maps 1:1 to this Rust module.
5803// Phase: drift
5804// ===========================================================
5805
5806// BEGIN moved-from-exec-rs
5807// (impl ShellExecutor block moved to src/exec_shims.rs — see file marker)
5808
5809// END moved-from-exec-rs
5810
5811/// !!! RUST-ONLY adapter — NO DIRECT C COUNTERPART !!!
5812///
5813/// Thin "pattern string in, match list out" entry over the glob engine
5814/// for call sites holding a single pattern that want its matches as a
5815/// `Vec<String>`, rather than going through C's in-place
5816/// `zglob(LinkList, LinkNode, int)` list mutation. Snapshots the
5817/// glob-relevant options into TLS (`enter_glob_scope` — required because
5818/// zshrs runs glob on multiple threads; see that fn's doc) and drives
5819/// `globdata_glob`, the RUST-ONLY read_dir-based adaptation of C's
5820/// `scanner()` (Src/glob.c:500 — C descends with `lchdir`, which is
5821/// process-global and unsafe under zshrs's threading, so the port walks
5822/// absolute paths instead). ALL real glob semantics — brace expansion,
5823/// `(a|b)` alternation, `^`/`~` exclusion, qualifiers, `**`, sorting —
5824/// live in `globdata_glob`/`scanner`/`patcompile`, not here. (The 130
5825/// lines of ad-hoc `^`/`~`/alternation read_dir+matchpat code that used
5826/// to live here were verified redundant with `globdata_glob` and
5827/// removed.) The faithful C entry is `zglob` (c:1214).
5828pub fn glob_path(pattern: &str) -> Vec<String> {
5829    let _glob_scope = enter_glob_scope();
5830    let mut state = globdata::new();
5831    globdata_glob(&mut state, pattern)
5832}
5833
5834// The qualifier-comparison direction static is `g_range` (Src/glob.c, ported at
5835// glob.rs ~2799). The duplicate `G_RANGE` that used to live here was a Rust-only
5836// erroneous second copy — never written by the qual-eval, so qualnlink (its only
5837// reader) was stuck on `==`. Removed; qualnlink now reads `g_range`.
5838
5839// `mode_to_octal` lives at `crate::ported::utils::mode_to_octal`
5840// (port of `Src/utils.c:7634` — 12 bit-by-bit POSIX permission
5841// mappings). Local alias kept for the call sites at c:1610/1618
5842// which used the masked identity before the canonical port landed.
5843fn mode_to_octal(mode: u32) -> u32 {
5844    crate::ported::utils::mode_to_octal(mode) as u32
5845}
5846
5847#[cfg(test)]
5848mod tests {
5849    use super::*;
5850    use std::fs::{self, File};
5851    use tempfile::TempDir;
5852
5853    use crate::ported::options::{opt_state_set, opt_state_unset};
5854    use crate::ported::zsh_h::{redir, ERRFLAG_ERROR, REDIR_WRITE};
5855
5856    /// Convert ASCII brace-expansion source to the lexer-tokenized
5857    /// form `hasbraces` / `xpandbraces` consume per c:Src/glob.c —
5858    /// ASCII `{` → Inbrace (\u{8f}), `}` → Outbrace (\u{90}), `,` →
5859    /// Comma (\u{9a}). Backslash-escaped variants (`\{`, `\}`, `\,`)
5860    /// emit Bnull + literal so the canonical "escape via Bnull"
5861    /// distinction reaches the brace walker. Used by every test in
5862    /// this module that wants to drive the canonical TOKEN-strict
5863    /// path with readable ASCII source.
5864    fn tok(s: &str) -> String {
5865        let mut out = String::with_capacity(s.len());
5866        let mut chars = s.chars().peekable();
5867        while let Some(c) = chars.next() {
5868            match c {
5869                '\\' => match chars.peek() {
5870                    Some('{') | Some('}') | Some(',') => {
5871                        out.push('\u{9f}');
5872                        out.push(chars.next().unwrap());
5873                    }
5874                    _ => out.push(c),
5875                },
5876                '{' => out.push('\u{8f}'),
5877                '}' => out.push('\u{90}'),
5878                ',' => out.push('\u{9a}'),
5879                _ => out.push(c),
5880            }
5881        }
5882        out
5883    }
5884
5885    fn setup_test_dir() -> TempDir {
5886        let dir = TempDir::new().unwrap();
5887        let base = dir.path();
5888
5889        // Create test files
5890        File::create(base.join("file1.txt")).unwrap();
5891        File::create(base.join("file2.txt")).unwrap();
5892        File::create(base.join("file3.rs")).unwrap();
5893        File::create(base.join(".hidden")).unwrap();
5894
5895        // Create subdirectory
5896        fs::create_dir(base.join("subdir")).unwrap();
5897        File::create(base.join("subdir/nested.txt")).unwrap();
5898
5899        dir
5900    }
5901
5902    #[test]
5903    fn test_haswilds() {
5904        let _g = crate::test_util::global_state_lock();
5905        // c:Src/glob.c:3548 — full glob tokenize (shadows the
5906        // brace-only `fn tok` test helper above on purpose:
5907        // haswilds consumes glob tokens, not just braces).
5908        let tok = |s: &str| {
5909            let mut t = s.to_string();
5910            tokenize(&mut t);
5911            t
5912        };
5913        assert!(haswilds(&tok("*.txt")));
5914        assert!(haswilds(&tok("file?.txt")));
5915        assert!(haswilds(&tok("file[12].txt")));
5916        assert!(!haswilds(&tok("file.txt")));
5917        assert!(!haswilds(&tok("path/to/file.txt")));
5918    }
5919
5920    #[test]
5921    fn test_pattern_match() {
5922        let _g = crate::test_util::global_state_lock();
5923        assert!(matchpat("*.txt", "file.txt", false, true));
5924        assert!(matchpat("file?.txt", "file1.txt", false, true));
5925        assert!(!matchpat("*.txt", "file.rs", false, true));
5926        assert!(matchpat("file[12].txt", "file1.txt", false, true));
5927        assert!(!matchpat("file[12].txt", "file3.txt", false, true));
5928    }
5929
5930    #[test]
5931    fn test_brace_expansion() {
5932        let _g = crate::test_util::global_state_lock();
5933        let result = xpandbraces(&tok("{a,b,c}"), false);
5934        assert_eq!(result, vec!["a", "b", "c"]);
5935
5936        let result = xpandbraces(&tok("file{1,2,3}.txt"), false);
5937        assert_eq!(result, vec!["file1.txt", "file2.txt", "file3.txt"]);
5938
5939        let result = xpandbraces(&tok("{1..5}"), false);
5940        assert_eq!(result, vec!["1", "2", "3", "4", "5"]);
5941
5942        let result = xpandbraces(&tok("{a..e}"), false);
5943        assert_eq!(result, vec!["a", "b", "c", "d", "e"]);
5944    }
5945
5946    #[test]
5947    fn test_glob_simple() {
5948        let _g = crate::test_util::global_state_lock();
5949        let dir = setup_test_dir();
5950        let pattern = format!("{}/*.txt", dir.path().display());
5951
5952        let mut state = globdata::new();
5953        let results = globdata_glob(&mut state, &pattern);
5954
5955        assert_eq!(results.len(), 2);
5956        assert!(results.iter().any(|s| s.ends_with("file1.txt")));
5957        assert!(results.iter().any(|s| s.ends_with("file2.txt")));
5958    }
5959
5960    #[test]
5961    fn test_glob_hidden() {
5962        let _g = crate::test_util::global_state_lock();
5963        let dir = setup_test_dir();
5964        let pattern = format!("{}/*", dir.path().display());
5965
5966        // Default (globdots off) → hidden files skipped. `dotglob`
5967        // is the bash alias for zsh's canonical `globdots` option;
5968        // the C-faithful read goes through `isset(GLOBDOTS)` which
5969        // resolves the canonical name.
5970        opt_state_set("globdots", false);
5971        let mut state = globdata::new();
5972        let results = globdata_glob(&mut state, &pattern);
5973        assert!(!results.iter().any(|s| s.contains(".hidden")));
5974
5975        // setopt globdots → hidden files included.
5976        opt_state_set("globdots", true);
5977        let mut state = globdata::new();
5978        let results = globdata_glob(&mut state, &pattern);
5979        assert!(results.iter().any(|s| s.contains(".hidden")));
5980        opt_state_set("globdots", false); // reset for other tests
5981    }
5982
5983    #[test]
5984    fn test_glob_emit_path_strips_read_dir_dot_slash() {
5985        let _g = crate::test_util::global_state_lock();
5986        assert_eq!(glob_emit_path(Path::new("./sub")), "sub");
5987        assert_eq!(glob_emit_path(Path::new("sub/deeper")), "sub/deeper");
5988        assert_eq!(glob_emit_path(Path::new("././x")), "x");
5989        assert_eq!(glob_emit_path(Path::new("../up")), "../up");
5990    }
5991
5992    #[test]
5993    fn test_file_type_char() {
5994        let _g = crate::test_util::global_state_lock();
5995        assert_eq!(file_type(libc::S_IFDIR as u32), '/');
5996        assert_eq!(file_type(libc::S_IFREG as u32), ' ');
5997        assert_eq!(file_type(libc::S_IFREG as u32 | 0o111), '*');
5998        assert_eq!(file_type(libc::S_IFLNK as u32), '@');
5999    }
6000
6001    /// `Src/glob.c:2018-2036` — `file_type(mode_t filemode)` returns a
6002    /// single-char marker keyed off S_ISBLK/CHR/DIR/FIFO/LNK/REG/SOCK,
6003    /// with `S_IXUGO` (== 0o111 = S_IXUSR|S_IXGRP|S_IXOTH) distinguishing
6004    /// executable regular files (`*`) from non-executable (` `). The
6005    /// catch-all (e.g. door, port, unknown types) returns `?`.
6006    /// Pin every branch by position.
6007    #[test]
6008    fn file_type_every_branch_matches_c_dispatch() {
6009        let _g = crate::test_util::global_state_lock();
6010        assert_eq!(
6011            file_type(libc::S_IFBLK as u32),
6012            '#',
6013            "c:2020 — S_ISBLK → '#'"
6014        );
6015        assert_eq!(
6016            file_type(libc::S_IFCHR as u32),
6017            '%',
6018            "c:2022 — S_ISCHR → '%'"
6019        );
6020        assert_eq!(
6021            file_type(libc::S_IFDIR as u32),
6022            '/',
6023            "c:2024 — S_ISDIR → '/'"
6024        );
6025        assert_eq!(
6026            file_type(libc::S_IFIFO as u32),
6027            '|',
6028            "c:2026 — S_ISFIFO → '|'"
6029        );
6030        assert_eq!(
6031            file_type(libc::S_IFLNK as u32),
6032            '@',
6033            "c:2028 — S_ISLNK → '@'"
6034        );
6035        // Regular file: ' ' if not executable, '*' if executable (any bit in S_IXUGO).
6036        assert_eq!(
6037            file_type(libc::S_IFREG as u32),
6038            ' ',
6039            "c:2030 — non-executable regular file → ' '"
6040        );
6041        // Each individual exec bit triggers '*'.
6042        assert_eq!(
6043            file_type(libc::S_IFREG as u32 | 0o100),
6044            '*',
6045            "c:2030 — S_IXUSR alone is enough"
6046        );
6047        assert_eq!(
6048            file_type(libc::S_IFREG as u32 | 0o010),
6049            '*',
6050            "c:2030 — S_IXGRP alone is enough"
6051        );
6052        assert_eq!(
6053            file_type(libc::S_IFREG as u32 | 0o001),
6054            '*',
6055            "c:2030 — S_IXOTH alone is enough"
6056        );
6057        assert_eq!(
6058            file_type(libc::S_IFSOCK as u32),
6059            '=',
6060            "c:2033 — S_ISSOCK → '='"
6061        );
6062        // Catch-all for unknown S_IFMT — bare 0 returns '?'.
6063        assert_eq!(file_type(0), '?', "c:2035 — unknown st_mode → '?'");
6064    }
6065
6066    #[test]
6067    fn test_zstrcmp_numeric() {
6068        let _g = crate::test_util::global_state_lock();
6069        let n = crate::zsh_h::SORTIT_NUMERICALLY as u32;
6070        assert_eq!(zstrcmp("file1", "file2", n), std::cmp::Ordering::Less);
6071        assert_eq!(zstrcmp("file10", "file2", n), std::cmp::Ordering::Greater);
6072        assert_eq!(zstrcmp("file10", "file10", n), std::cmp::Ordering::Equal);
6073    }
6074
6075    /// Race-fix verification: snapshot pins bareglobqual for the
6076    /// duration of a glob scope even when the live store flips
6077    /// underneath. Mimics the stryke pattern in
6078    /// MenkeTechnologies/strykelang#3 — one thread is mid-glob with
6079    /// bareglobqual=1 snapshot when another flips it off.
6080    #[test]
6081    fn glob_opts_snapshot_isolates_concurrent_setopt() {
6082        let _g = crate::test_util::global_state_lock();
6083
6084        // Preserve the existing live state so the test is hermetic.
6085        let saved = opt_state_get("bareglobqual");
6086
6087        // Live store says bareglobqual=true; enter scope captures that.
6088        opt_state_set("bareglobqual", true);
6089        let _scope = enter_glob_scope();
6090        assert!(
6091            glob_isset(BAREGLOBQUAL),
6092            "TLS snapshot reads bareglobqual=true at scope entry"
6093        );
6094
6095        // Simulate a concurrent thread flipping the live store.
6096        opt_state_set("bareglobqual", false);
6097        assert!(
6098            glob_isset(BAREGLOBQUAL),
6099            "TLS snapshot must still report bareglobqual=true \
6100             even though live store now reads false"
6101        );
6102
6103        // Restore.
6104        match saved {
6105            Some(v) => opt_state_set("bareglobqual", v),
6106            None => opt_state_unset("bareglobqual"),
6107        }
6108    }
6109
6110    /// After the scope guard drops, reads fall back to the live store.
6111    #[test]
6112    fn glob_opts_snapshot_clears_on_drop() {
6113        let _g = crate::test_util::global_state_lock();
6114
6115        let saved = opt_state_get("nullglob");
6116        opt_state_set("nullglob", true);
6117        {
6118            let _scope = enter_glob_scope();
6119            assert!(glob_isset(NULLGLOB), "snapshot live=true → true inside");
6120        }
6121        // Outside scope: flip live store, read should follow live.
6122        opt_state_set("nullglob", false);
6123        assert!(
6124            !glob_isset(NULLGLOB),
6125            "post-scope: glob_isset falls back to live store"
6126        );
6127
6128        match saved {
6129            Some(v) => opt_state_set("nullglob", v),
6130            None => opt_state_unset("nullglob"),
6131        }
6132    }
6133
6134    /// Nested glob scopes share the outer snapshot — inner doesn't
6135    /// re-capture or clear on its own Drop.
6136    #[test]
6137    fn glob_opts_snapshot_nested_is_noop() {
6138        let _g = crate::test_util::global_state_lock();
6139
6140        let saved = opt_state_get("extendedglob");
6141        opt_state_set("extendedglob", true);
6142        let _outer = enter_glob_scope();
6143        assert!(glob_isset(EXTENDEDGLOB));
6144        {
6145            let _inner = enter_glob_scope();
6146            // Live store flip while nested.
6147            opt_state_set("extendedglob", false);
6148            assert!(glob_isset(EXTENDEDGLOB), "inner observes outer snapshot");
6149        } // inner drops — outer snapshot still active.
6150        assert!(
6151            glob_isset(EXTENDEDGLOB),
6152            "outer snapshot survives inner drop"
6153        );
6154
6155        match saved {
6156            Some(v) => opt_state_set("extendedglob", v),
6157            None => opt_state_unset("extendedglob"),
6158        }
6159    }
6160
6161    /// c:2150 (xpandredir port) — when the redir name has no wildcard
6162    /// AND no `$var`/`!hist`/`{a,b}` to expand, prefork+globlist leave
6163    /// the name unchanged. The single-match path at c:2171 must
6164    /// rewrite `fn.name` to the same string and return 0 (no multi-fan).
6165    /// Regression that returns 1 would trigger MULTIOS dispatch on a
6166    /// single literal filename — wrong shell semantics.
6167    #[test]
6168    fn xpandredir_single_literal_filename_returns_zero() {
6169        let _g = crate::test_util::global_state_lock();
6170        let mut fn_ = redir {
6171            typ: REDIR_WRITE,
6172            flags: 0,
6173            fd1: 1,
6174            fd2: -1,
6175            name: Some("/tmp/zshrs_test_out".to_string()),
6176            varid: None,
6177            here_terminator: None,
6178            munged_here_terminator: None,
6179        };
6180        let mut tab: Vec<redir> = Vec::new();
6181        let r = xpandredir(&mut fn_, &mut tab);
6182        assert_eq!(r, 0, "literal filename → single match → ret=0");
6183        assert_eq!(
6184            fn_.name.as_deref(),
6185            Some("/tmp/zshrs_test_out"),
6186            "literal name must round-trip through prefork unchanged"
6187        );
6188        assert!(tab.is_empty(), "no multi-match → redirtab not appended");
6189    }
6190
6191    /// c:2176-2177 — `>&-` (REDIR_MERGEOUT with name "-") collapses to
6192    /// REDIR_CLOSE per the `IS_DASH(s[0]) && !s[1]` branch. Regression
6193    /// where this fails leaves `>&-` as a literal merge-fd attempt,
6194    /// which the executor would interpret as "merge with fd -1".
6195    #[test]
6196    fn xpandredir_dash_merge_collapses_to_close() {
6197        let _g = crate::test_util::global_state_lock();
6198        let mut fn_ = redir {
6199            typ: REDIR_MERGEOUT,
6200            flags: 0,
6201            fd1: 1,
6202            fd2: -1,
6203            name: Some("-".to_string()),
6204            varid: None,
6205            here_terminator: None,
6206            munged_here_terminator: None,
6207        };
6208        let mut tab: Vec<redir> = Vec::new();
6209        let _ = xpandredir(&mut fn_, &mut tab);
6210        assert_eq!(
6211            fn_.typ, REDIR_CLOSE,
6212            "`>&-` must rewrite typ to REDIR_CLOSE"
6213        );
6214    }
6215
6216    /// c:2150 — empty `fn.name` should return 0 cleanly. Catches a
6217    /// regression that panics on `.as_deref().unwrap()` for absent name.
6218    #[test]
6219    fn xpandredir_with_no_name_returns_zero_no_panic() {
6220        let _g = crate::test_util::global_state_lock();
6221        let mut fn_ = redir {
6222            typ: REDIR_WRITE,
6223            flags: 0,
6224            fd1: 1,
6225            fd2: -1,
6226            name: None,
6227            varid: None,
6228            here_terminator: None,
6229            munged_here_terminator: None,
6230        };
6231        let mut tab: Vec<redir> = Vec::new();
6232        assert_eq!(xpandredir(&mut fn_, &mut tab), 0);
6233    }
6234
6235    /// `IN_EXPANDREDIR` static defaults to 0 — set transiently inside
6236    /// xpandredir per c:2165/2167. After a normal call it must restore
6237    /// to 0. Regression that leaks the flag would skew unrelated glob
6238    /// expansions outside redirections.
6239    #[test]
6240    fn in_expandredir_flag_is_zero_at_rest() {
6241        let _g = crate::test_util::global_state_lock();
6242        assert_eq!(IN_EXPANDREDIR.load(Ordering::SeqCst), 0);
6243    }
6244
6245    /// c:4306 — `haswilds` honours backslash-escapes: `\*` is a literal
6246    /// star, NOT a wildcard. Regression that ignores the escape would
6247    /// trigger globbing on `printf '%s\n' \*`, breaking shell scripts
6248    /// that quote literal stars.
6249    #[test]
6250    fn haswilds_respects_backslash_escape() {
6251        let _g = crate::test_util::global_state_lock();
6252        // c:Src/glob.c:3548 — full glob tokenize (shadows the
6253        // brace-only `fn tok` test helper above on purpose:
6254        // haswilds consumes glob tokens, not just braces).
6255        let tok = |s: &str| {
6256            let mut t = s.to_string();
6257            tokenize(&mut t);
6258            t
6259        };
6260        assert!(haswilds(&tok("*.txt")), "bare * is wild");
6261        assert!(
6262            !haswilds(&tok(r"\*.txt")),
6263            "escaped \\* is literal — NOT wild"
6264        );
6265        assert!(
6266            !haswilds(&tok(r"\?.txt")),
6267            "escaped \\? is literal — NOT wild"
6268        );
6269    }
6270
6271    /// c:4306 — `[` immediately enters bracket mode AND counts as a
6272    /// wildcard. The early return on `[` is critical — even
6273    /// unterminated brackets must be flagged so `cd [` doesn't try
6274    /// to chdir to a literal `[`.
6275    #[test]
6276    fn haswilds_open_bracket_alone_is_a_wildcard() {
6277        let _g = crate::test_util::global_state_lock();
6278        // c:Src/glob.c:3548 — full glob tokenize (shadows the
6279        // brace-only `fn tok` test helper above on purpose:
6280        // haswilds consumes glob tokens, not just braces).
6281        let tok = |s: &str| {
6282            let mut t = s.to_string();
6283            tokenize(&mut t);
6284            t
6285        };
6286        assert!(haswilds(&tok("[abc]")), "char-class is wild");
6287        assert!(haswilds(&tok("foo[")), "even unterminated [ is wild");
6288    }
6289
6290    /// c:4306 — wildcard chars `*` `?` inside an OPEN bracket-context
6291    /// are NOT additional wildcards (the bracket already is one).
6292    /// Regression that double-counts would make haswilds report `[*]`
6293    /// as wildcard-twice — cosmetic, but confuses any caller using
6294    /// the bool as a "should I glob?" gate that AND-tests with another
6295    /// flag.
6296    #[test]
6297    fn haswilds_extglob_chars_inside_bracket_dont_double_count() {
6298        let _g = crate::test_util::global_state_lock();
6299        // c:Src/glob.c:3548 — full glob tokenize (shadows the
6300        // brace-only `fn tok` test helper above on purpose:
6301        // haswilds consumes glob tokens, not just braces).
6302        let tok = |s: &str| {
6303            let mut t = s.to_string();
6304            tokenize(&mut t);
6305            t
6306        };
6307        // Once `[` is seen, function returns true immediately, so the
6308        // post-bracket chars don't matter. But this docs the contract.
6309        assert!(haswilds(&tok("[*]")));
6310    }
6311
6312    /// c:4306 — plain text returns false. Catches a regression where
6313    /// any non-empty input is flagged as wild (would break `cd /tmp`
6314    /// by triggering glob expansion on a literal path).
6315    #[test]
6316    fn haswilds_plain_text_not_wild() {
6317        let _g = crate::test_util::global_state_lock();
6318        // c:Src/glob.c:3548 — full glob tokenize (shadows the
6319        // brace-only `fn tok` test helper above on purpose:
6320        // haswilds consumes glob tokens, not just braces).
6321        let tok = |s: &str| {
6322            let mut t = s.to_string();
6323            tokenize(&mut t);
6324            t
6325        };
6326        assert!(!haswilds(&tok("plain")));
6327        assert!(!haswilds(&tok("")));
6328        assert!(!haswilds(&tok("/usr/local/bin")));
6329        assert!(!haswilds(&tok("file.txt")));
6330    }
6331
6332    /// c:4363-4371 — `#` and `^` are recognised as wildcards by
6333    /// `haswilds` **only when `EXTENDEDGLOB` is set**, matching the C
6334    /// source's `isset(EXTENDEDGLOB)` gate at pattern.c:4364/4369. `~`
6335    /// is **not** a filename-generation wildcard (zsh handles it as
6336    /// tilde expansion in a separate pipeline stage); a prior
6337    /// glob.rs-local `haswilds` impl returned true for `~`, which
6338    /// caused zglob to mis-classify tilde-prefixed args as needing
6339    /// filename generation. Pin the corrected behavior.
6340    #[test]
6341    fn haswilds_extended_glob_chars_recognised() {
6342        let _g = crate::test_util::global_state_lock();
6343        // c:Src/glob.c:3548 — full glob tokenize (shadows the
6344        // brace-only `fn tok` test helper above on purpose:
6345        // haswilds consumes glob tokens, not just braces).
6346        let tok = |s: &str| {
6347            let mut t = s.to_string();
6348            tokenize(&mut t);
6349            t
6350        };
6351        // EXTENDEDGLOB off → `#` and `^` are not wild.
6352        crate::ported::options::opt_state_set("extendedglob", false);
6353        assert!(
6354            !haswilds(&tok("foo#bar")),
6355            "# not wild without EXTENDEDGLOB"
6356        );
6357        assert!(
6358            !haswilds(&tok("foo^bar")),
6359            "^ not wild without EXTENDEDGLOB"
6360        );
6361        // EXTENDEDGLOB on → `#` and `^` are wild (c:4364, c:4369).
6362        crate::ported::options::opt_state_set("extendedglob", true);
6363        assert!(haswilds(&tok("foo#bar")), "# is extglob wild");
6364        assert!(haswilds(&tok("foo^bar")), "^ is extglob wild");
6365        crate::ported::options::opt_state_set("extendedglob", false);
6366        // `~` is NOT in haswilds' switch (c:4324-4373) — tilde expansion
6367        // is a separate pipeline stage.
6368        assert!(
6369            !haswilds(&tok("~/file")),
6370            "~ is NOT a filename-generation wildcard"
6371        );
6372    }
6373
6374    /// c:2514 — `matchpat` returns true for exact match. Sanity check
6375    /// the simplest dispatch path (the matcher recipe builds a
6376    /// trivial pattern, runs it).
6377    #[test]
6378    fn matchpat_exact_literal_matches() {
6379        let _g = crate::test_util::global_state_lock();
6380        assert!(matchpat("hello", "hello", false, true));
6381        assert!(!matchpat("hello", "world", false, true));
6382    }
6383
6384    /// c:2514 — `matchpat` with case_sensitive=false MUST treat upper
6385    /// and lower as equal (`HELLO` matches `hello`). Regression keeping
6386    /// case-sensitive when flag is false would silently break every
6387    /// `[[ "$x" = (#i)foo ]]`-style match.
6388    #[test]
6389    fn matchpat_case_insensitive_when_flag_clear() {
6390        let _g = crate::test_util::global_state_lock();
6391        assert!(
6392            matchpat("hello", "HELLO", false, false),
6393            "case-insensitive match must succeed across cases"
6394        );
6395        assert!(matchpat("FoO", "foo", false, false));
6396    }
6397
6398    /// c:2514 — case-sensitive (default) MUST reject case-different
6399    /// inputs. Pinning the contract.
6400    #[test]
6401    fn matchpat_case_sensitive_rejects_case_different() {
6402        let _g = crate::test_util::global_state_lock();
6403        assert!(
6404            !matchpat("hello", "HELLO", false, true),
6405            "case-sensitive default must reject HELLO != hello"
6406        );
6407    }
6408
6409    /// c:2780 — `set_pat_start(p, offs)` sets `PAT_NOTSTART` when offs is
6410    /// nonzero (matched substring starts past the real start, so `(#s)`
6411    /// must fail) and clears it when offs is 0. A regression here would
6412    /// let `(#s)` anchors fire mid-string during substring globbing.
6413    #[test]
6414    fn set_pat_start_toggles_pat_notstart_flag() {
6415        let _g = crate::test_util::global_state_lock();
6416        let mut p = mk_test_patprog();
6417        set_pat_start(&mut p, 2);
6418        assert!(
6419            p.0.flags & PAT_NOTSTART != 0,
6420            "offs!=0 must set PAT_NOTSTART"
6421        );
6422        set_pat_start(&mut p, 0);
6423        assert!(
6424            p.0.flags & PAT_NOTSTART == 0,
6425            "offs==0 must clear PAT_NOTSTART"
6426        );
6427    }
6428
6429    /// c:2796 — `set_pat_end(p, null_me)` sets `PAT_NOTEND` when the char
6430    /// being zapped is non-NUL (string shortened at tail, so `(#e)` must
6431    /// fail) and clears it when that char is already NUL. A regression
6432    /// would let `(#e)` anchors fire before the real end.
6433    #[test]
6434    fn set_pat_end_toggles_pat_notend_flag() {
6435        let _g = crate::test_util::global_state_lock();
6436        let mut p = mk_test_patprog();
6437        set_pat_end(&mut p, b'x');
6438        assert!(
6439            p.0.flags & PAT_NOTEND != 0,
6440            "non-NUL null_me must set PAT_NOTEND"
6441        );
6442        set_pat_end(&mut p, 0);
6443        assert!(
6444            p.0.flags & PAT_NOTEND == 0,
6445            "NUL null_me must clear PAT_NOTEND"
6446        );
6447    }
6448
6449    /// Build a zeroed `Patprog` for flag-toggle tests.
6450    fn mk_test_patprog() -> Patprog {
6451        Box::new((
6452            crate::ported::zsh_h::patprog {
6453                startoff: 0,
6454                size: 0,
6455                mustoff: 0,
6456                patmlen: 0,
6457                globflags: 0,
6458                globend: 0,
6459                flags: 0,
6460                patnpar: 0,
6461                patstartch: 0,
6462            },
6463            Vec::new(),
6464        ))
6465    }
6466
6467    /// c:2773 — `freematchlist(None)` is a no-op (matches C's
6468    /// `if (repllist) freelinklist(...)`). Regression panicking on
6469    /// None would crash every glob-replace path with no matches.
6470    #[test]
6471    fn freematchlist_handles_none_safely() {
6472        let _g = crate::test_util::global_state_lock();
6473        freematchlist(None);
6474        // No assertion — survival is the test.
6475    }
6476
6477    /// c:2773 — `freematchlist(Some(&mut Vec))` clears the list.
6478    /// Regression that drops a stale entry would leak match positions
6479    /// across calls.
6480    #[test]
6481    fn freematchlist_clears_provided_vec() {
6482        let _g = crate::test_util::global_state_lock();
6483        let mut v = vec![
6484            repldata {
6485                b: 0,
6486                e: 5,
6487                replstr: None,
6488            },
6489            repldata {
6490                b: 10,
6491                e: 15,
6492                replstr: Some("x".to_string()),
6493            },
6494        ];
6495        freematchlist(Some(&mut v));
6496        assert!(v.is_empty(), "freematchlist must clear the input vec");
6497    }
6498
6499    /// `Src/glob.c:2042-2142` — `hasbraces(str)` returns true when
6500    /// `str` contains a brace-expansion candidate. A bare lbrace/rbrace
6501    /// is NOT a brace expansion (it's a literal). Detection requires a
6502    /// matched pair containing either a comma or a dotdot range. Pin
6503    /// the canonical contract.
6504    #[test]
6505    fn hasbraces_matched_pair_with_comma_or_dotdot() {
6506        let _g = crate::test_util::global_state_lock();
6507        // Matched + comma → true.
6508        assert!(
6509            hasbraces(&tok("a{b,c}d"), false),
6510            "c:2127 — lbrace + comma + rbrace is a brace expansion"
6511        );
6512        // Matched + dotdot → true.
6513        assert!(
6514            hasbraces(&tok("file{1..3}.txt"), false),
6515            "c:2082 — N..M range is a brace expansion"
6516        );
6517        // Matched WITHOUT comma/dotdot → false (it's a literal).
6518        assert!(
6519            !hasbraces(&tok("{abc}"), false),
6520            "literal braces are NOT a brace expansion without comma or dotdot"
6521        );
6522        // Unmatched → false.
6523        assert!(
6524            !hasbraces(&tok("{abc"), false),
6525            "lone lbrace without matching rbrace is not brace expansion"
6526        );
6527        assert!(
6528            !hasbraces(&tok("abc}"), false),
6529            "lone rbrace is not brace expansion"
6530        );
6531        // No braces → false.
6532        assert!(!hasbraces(&tok("plain"), false));
6533        assert!(!hasbraces(&tok(""), false));
6534    }
6535
6536    /// `Src/glob.c:2049-2063` — BRACECCL branch: when `isset(BRACECCL)`
6537    /// (the `brace_ccl` parameter), any non-empty matched braces
6538    /// become a character-class set, regardless of whether the body
6539    /// contains a comma. Empty pair is still not.
6540    #[test]
6541    fn hasbraces_brace_ccl_makes_any_pair_match() {
6542        let _g = crate::test_util::global_state_lock();
6543        // c:2049 — BRACECCL: non-empty pair is enough.
6544        assert!(
6545            hasbraces(&tok("{abc}"), true),
6546            "c:2049 — BRACECCL: non-empty pair is char-class set"
6547        );
6548        assert!(
6549            hasbraces(&tok("x{q}y"), true),
6550            "c:2049 — single-char pair counts under BRACECCL"
6551        );
6552        // Empty pair shouldn't trigger.
6553        assert!(
6554            !hasbraces(&tok("{}"), true),
6555            "empty pair still not a brace expansion even under BRACECCL"
6556        );
6557        // Without BRACECCL, plain literal-letter pair is NOT a brace expansion.
6558        assert!(
6559            !hasbraces(&tok("{abc}"), false),
6560            "c:2049 — BRACECCL off → plain literal pair stays literal"
6561        );
6562    }
6563
6564    /// `Src/glob.c:2042-2142` — depth tracking: comma/dotdot count
6565    /// only at depth==1 in our port. So nested inner comma doesn't
6566    /// qualify the outer pair, but TWO independent top-level pairs
6567    /// (each with comma) DO trigger detection.
6568    #[test]
6569    fn hasbraces_depth_1_check_for_comma_dotdot() {
6570        let _g = crate::test_util::global_state_lock();
6571        assert!(
6572            hasbraces(&tok("a{1,2}b{3,4}c"), false),
6573            "two independent top-level pairs, first one matches at depth 1"
6574        );
6575    }
6576
6577    /// Pin: `remnulargs` per `Src/glob.c:3649-3679`:
6578    ///   - Strings with NO inull bytes are unchanged.
6579    ///   - Strings with ONLY Bnullkeep: keep as-is in scan phase
6580    ///     (Bnullkeep is "active backslash" that hasn't triggered
6581    ///     copy phase yet).
6582    ///   - After encountering any other inull: switch to copy phase:
6583    ///     * Bnullkeep → '\\' (literal backslash).
6584    ///     * Other inulls (Snull/Dnull/Bnull/Nularg) → stripped.
6585    ///     * Non-inull → kept.
6586    ///   - Empty post-strip → replaced with single Nularg.
6587    #[test]
6588    fn remnulargs_matches_c_inull_handling() {
6589        let _g = crate::test_util::global_state_lock();
6590
6591        // Plain ASCII unchanged (no inulls).
6592        let mut s = "hello".to_string();
6593        remnulargs(&mut s);
6594        assert_eq!(
6595            s, "hello",
6596            "c:3654 — no inull bytes leaves string unchanged"
6597        );
6598
6599        // Snull-triggered copy: Snull stripped, rest kept.
6600        let mut s = format!("ab{}cd", Snull);
6601        remnulargs(&mut s);
6602        assert_eq!(
6603            s, "abcd",
6604            "c:3663 — Snull triggers copy; itself stripped, rest kept"
6605        );
6606
6607        // Bnullkeep AFTER Snull trigger → '\\' (active backslash).
6608        let mut s = format!("ab{}c{}d", Snull, Bnullkeep);
6609        remnulargs(&mut s);
6610        assert_eq!(
6611            s, "abc\\d",
6612            "c:3666 — Bnullkeep in copy phase becomes literal '\\\\'"
6613        );
6614
6615        // Other inulls (Bnull, Dnull) stripped in copy phase.
6616        let mut s = format!("a{}b{}c{}d", Snull, Bnull, Dnull);
6617        remnulargs(&mut s);
6618        assert_eq!(s, "abcd", "c:3668 — Bnull/Dnull stripped in copy phase");
6619
6620        // Empty post-strip → Nularg.
6621        let mut s = format!("{}", Snull);
6622        remnulargs(&mut s);
6623        assert_eq!(
6624            s,
6625            format!("{}", Nularg),
6626            "c:3674 — empty result replaced by Nularg sentinel"
6627        );
6628    }
6629
6630    /// `Src/glob.c:1085-1117` — `glob_exec_string` is a PARSER, not
6631    /// an executor. Extracts the qualifier inner text from `*sp`
6632    /// (advancing past delimiters). Previous Rust port forked
6633    /// `/bin/sh` to execute the cmd — that's entirely the wrong
6634    /// layer; execution happens at the call sites via qualsheval.
6635    /// Pin: plus-form extracts identifier; delimited form extracts
6636    /// content + advances.
6637    #[test]
6638    fn glob_exec_string_parses_qualifier_text() {
6639        let _g = crate::test_util::global_state_lock();
6640        // Plus form: identifier ends at first non-ident char.
6641        // C: `(+myfunc:rest)` — `s` points past `+`, plus_form=true.
6642        let r = glob_exec_string("myfunc rest", true);
6643        assert!(r.is_some(), "c:1092 — identifier parse should succeed");
6644        let (ident, _adv) = r.unwrap();
6645        assert_eq!(
6646            ident, "myfunc",
6647            "c:1092 — itype_end stops at first non-IIDENT char"
6648        );
6649
6650        // Plus form with no identifier (immediate non-ident) — error.
6651        let r = glob_exec_string(" leading-space", true);
6652        assert!(
6653            r.is_none(),
6654            "c:1093-1096 — empty identifier emits zerr + returns None"
6655        );
6656    }
6657
6658    /// `Src/glob.c:3907-3943` — `qualsheval(name, _, _, expr)` sets
6659    /// `$REPLY` via paramtab, evaluates `expr` IN THE CURRENT SHELL,
6660    /// then restores errflag+lastval. Previous Rust port spawned
6661    /// `/bin/sh -c expr` which:
6662    ///   1. Couldn't access shell locals / functions / aliases.
6663    ///   2. Ran `sh` (POSIX), not `zsh` — every zsh-feature qualifier
6664    ///      silently failed.
6665    ///
6666    /// Pin: after qualsheval, errflag is restored (mod ERRFLAG_INT),
6667    /// lastval is restored, and `$REPLY` is set to the filename.
6668    /// Easiest direct pin: errflag/lastval restore around an `expr`
6669    /// that mutates them.
6670    #[test]
6671    fn qualsheval_restores_errflag_and_lastval() {
6672        let _g = crate::test_util::global_state_lock();
6673        // Seed errflag and lastval with distinctive values.
6674        errflag.store(0, Ordering::Relaxed);
6675        LASTVAL.store(42, Ordering::Relaxed);
6676        // Even if the expr mutates these via the executor, c:3924-3925
6677        // restore them after.
6678        let st0: libc::stat = unsafe { std::mem::zeroed() };
6679        let _ = qualsheval("/tmp/file", &st0, 0, ":"); // no-op expr
6680                                                       // c:3924 — errflag restored.
6681        assert_eq!(
6682            errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR,
6683            0,
6684            "c:3924 — qualsheval must restore errflag (no ERRFLAG_ERROR leak)"
6685        );
6686        // c:3925 — lastval restored.
6687        assert_eq!(
6688            LASTVAL.load(Ordering::Relaxed),
6689            42,
6690            "c:3925 — qualsheval must restore lastval to pre-call value"
6691        );
6692        // c:3916 — $REPLY visible in paramtab.
6693        assert_eq!(
6694            crate::ported::params::getsparam("REPLY"),
6695            Some("/tmp/file".to_string()),
6696            "c:3916 — qualsheval must set $REPLY to filename"
6697        );
6698    }
6699
6700    /// `Src/glob.c:2164` — `if (!errflag && isset(MULTIOS))` is the
6701    /// logical zero-check guarding globlist recursion. Previous Rust
6702    /// port used `!errflag.load(...) != 0` which is BITWISE NOT
6703    /// (`^-1`), evaluating to `errflag != -1` — almost-always-true.
6704    ///
6705    /// Pin: a `matchpat` smoke that DOES NOT mutate errflag, then a
6706    /// direct check that the fix sense is correct (errflag==0 enters
6707    /// the branch, errflag!=0 skips). Can't easily exercise the full
6708    /// xpandredir path without a redir struct + IN_EXPANDREDIR
6709    /// global, so this is a sense-of-the-condition pin.
6710    #[test]
6711    fn xpandredir_errflag_check_uses_logical_zero() {
6712        let _g = crate::test_util::global_state_lock();
6713        // Mirror the in-port logic with the canonical zero-check.
6714        let saved = errflag.load(Ordering::Relaxed);
6715        // c:2164 — errflag==0 path: branch should fire.
6716        errflag.store(0, Ordering::Relaxed);
6717        let zero_enters = errflag.load(Ordering::Relaxed) == 0;
6718        assert!(
6719            zero_enters,
6720            "c:2164 — errflag==0 enters the branch (the canonical sense)"
6721        );
6722        // c:2164 — errflag!=0 path: branch should skip.
6723        errflag.store(1, Ordering::Relaxed);
6724        let nonzero_skips = errflag.load(Ordering::Relaxed) == 0;
6725        assert!(
6726            !nonzero_skips,
6727            "c:2164 — errflag!=0 skips the branch (regression of bitwise-NOT bug fix)"
6728        );
6729        errflag.store(saved, Ordering::Relaxed);
6730    }
6731
6732    // ═══════════════════════════════════════════════════════════════════
6733    // Additional unit coverage — pure pattern/brace/glob behaviour that
6734    // doesn't need a real filesystem. Uses `matchpat` for high-level
6735    // pattern checks (extended=false, case_sensitive=true is the most
6736    // common path) and `xpandbraces` for brace expansion.
6737    // ═══════════════════════════════════════════════════════════════════
6738
6739    // ── matchpat: full-string pattern matching ───────────────────────
6740    #[test]
6741    fn matchpat_literal_no_wildcards() {
6742        let _g = crate::test_util::global_state_lock();
6743        assert!(matchpat("foo", "foo", false, true));
6744        assert!(!matchpat("foo", "bar", false, true));
6745        assert!(!matchpat("foo", "foobar", false, true));
6746    }
6747
6748    #[test]
6749    fn matchpat_star_consumes_substring() {
6750        let _g = crate::test_util::global_state_lock();
6751        assert!(matchpat("a*b", "ab", false, true));
6752        assert!(matchpat("a*b", "ahellob", false, true));
6753        assert!(!matchpat("a*b", "abc", false, true));
6754    }
6755
6756    #[test]
6757    fn matchpat_question_is_single_char() {
6758        let _g = crate::test_util::global_state_lock();
6759        assert!(matchpat("a?c", "abc", false, true));
6760        assert!(matchpat("a?c", "axc", false, true));
6761        assert!(!matchpat("a?c", "ac", false, true));
6762        assert!(!matchpat("a?c", "abbc", false, true));
6763    }
6764
6765    #[test]
6766    fn matchpat_bracket_class_inline() {
6767        let _g = crate::test_util::global_state_lock();
6768        assert!(matchpat("file[abc].txt", "filea.txt", false, true));
6769        assert!(matchpat("file[abc].txt", "fileb.txt", false, true));
6770        assert!(!matchpat("file[abc].txt", "filed.txt", false, true));
6771    }
6772
6773    #[test]
6774    fn matchpat_case_sensitive_strict() {
6775        let _g = crate::test_util::global_state_lock();
6776        assert!(matchpat("Foo", "Foo", false, true));
6777        assert!(!matchpat("Foo", "foo", false, true));
6778        assert!(!matchpat("Foo", "FOO", false, true));
6779    }
6780
6781    #[test]
6782    fn matchpat_empty_pattern_matches_only_empty() {
6783        let _g = crate::test_util::global_state_lock();
6784        assert!(matchpat("", "", false, true));
6785        assert!(!matchpat("", "x", false, true));
6786    }
6787
6788    #[test]
6789    fn matchpat_star_alone_matches_anything() {
6790        let _g = crate::test_util::global_state_lock();
6791        assert!(matchpat("*", "", false, true));
6792        assert!(matchpat("*", "a", false, true));
6793        assert!(matchpat("*", "abcdef", false, true));
6794    }
6795
6796    #[test]
6797    fn matchpat_question_alone_one_char() {
6798        let _g = crate::test_util::global_state_lock();
6799        assert!(!matchpat("?", "", false, true));
6800        assert!(matchpat("?", "a", false, true));
6801        assert!(!matchpat("?", "ab", false, true));
6802    }
6803
6804    // ── haswilds: meta-char detection ───────────────────────────────
6805    #[test]
6806    fn haswilds_each_glob_meta() {
6807        let _g = crate::test_util::global_state_lock();
6808        // c:Src/glob.c:3548 — full glob tokenize (shadows the
6809        // brace-only `fn tok` test helper above on purpose:
6810        // haswilds consumes glob tokens, not just braces).
6811        let tok = |s: &str| {
6812            let mut t = s.to_string();
6813            tokenize(&mut t);
6814            t
6815        };
6816        assert!(haswilds(&tok("*")));
6817        assert!(haswilds(&tok("?")));
6818        assert!(haswilds(&tok("[abc]")));
6819        assert!(haswilds(&tok("a*b")));
6820        assert!(haswilds(&tok("a?b")));
6821    }
6822
6823    #[test]
6824    fn haswilds_plain_strings_have_no_wildcards() {
6825        let _g = crate::test_util::global_state_lock();
6826        // c:Src/glob.c:3548 — full glob tokenize (shadows the
6827        // brace-only `fn tok` test helper above on purpose:
6828        // haswilds consumes glob tokens, not just braces).
6829        let tok = |s: &str| {
6830            let mut t = s.to_string();
6831            tokenize(&mut t);
6832            t
6833        };
6834        assert!(!haswilds(&tok("")));
6835        assert!(!haswilds(&tok("plain.txt")));
6836        assert!(!haswilds(&tok("/abs/path/file.rs")));
6837        assert!(!haswilds(&tok("./rel/file")));
6838    }
6839
6840    // ── xpandbraces: brace expansion (zsh extension, not POSIX) ─────
6841    #[test]
6842    fn xpandbraces_three_alternatives() {
6843        let _g = crate::test_util::global_state_lock();
6844        assert_eq!(xpandbraces(&tok("{a,b,c}"), false), vec!["a", "b", "c"]);
6845    }
6846
6847    #[test]
6848    fn xpandbraces_with_prefix_and_suffix() {
6849        let _g = crate::test_util::global_state_lock();
6850        assert_eq!(
6851            xpandbraces(&tok("pre{x,y,z}post"), false),
6852            vec!["prexpost", "preypost", "prezpost"]
6853        );
6854    }
6855
6856    #[test]
6857    fn xpandbraces_numeric_range_ascending() {
6858        let _g = crate::test_util::global_state_lock();
6859        assert_eq!(
6860            xpandbraces(&tok("{1..5}"), false),
6861            vec!["1", "2", "3", "4", "5"]
6862        );
6863    }
6864
6865    #[test]
6866    fn xpandbraces_alpha_range_ascending() {
6867        let _g = crate::test_util::global_state_lock();
6868        assert_eq!(
6869            xpandbraces(&tok("{a..e}"), false),
6870            vec!["a", "b", "c", "d", "e"]
6871        );
6872    }
6873
6874    #[test]
6875    fn xpandbraces_single_alternative_passes_through_literal() {
6876        // Real zsh 5.9 (`print -l {a}`) outputs `{a}` verbatim — no
6877        // expansion because there is no comma or range inside the
6878        // braces. xpandbraces operates on TOKEN form throughout
6879        // (c:Src/glob.c::xpandbraces); the eventual ASCII conversion
6880        // happens later in untokenize. So unit-level pass-through
6881        // preserves TOKEN bytes, not ASCII braces.
6882        let _g = crate::test_util::global_state_lock();
6883        let out = xpandbraces(&tok("{a}"), false);
6884        assert_eq!(
6885            out,
6886            vec![tok("{a}")],
6887            "zsh 5.9 returns the input verbatim (TOKEN form preserved at xpandbraces layer)"
6888        );
6889    }
6890
6891    #[test]
6892    fn xpandbraces_no_braces_returns_input() {
6893        let _g = crate::test_util::global_state_lock();
6894        // Pure literal has nothing to expand — should yield the input
6895        // as a single element (or empty for empty input).
6896        let out = xpandbraces(&tok("plain"), false);
6897        assert_eq!(out, vec!["plain"]);
6898    }
6899
6900    // ── file_type: mode → marker char (used by `ls -F`-style output) ─
6901    #[test]
6902    fn file_type_dir_marker_is_slash() {
6903        let _g = crate::test_util::global_state_lock();
6904        assert_eq!(file_type(libc::S_IFDIR as u32), '/');
6905    }
6906
6907    #[test]
6908    fn file_type_regular_plain_is_space() {
6909        let _g = crate::test_util::global_state_lock();
6910        assert_eq!(file_type(libc::S_IFREG as u32), ' ');
6911    }
6912
6913    #[test]
6914    fn file_type_regular_executable_is_star() {
6915        let _g = crate::test_util::global_state_lock();
6916        // Any of the three executable bits should switch the marker.
6917        for x in [0o100, 0o010, 0o001] {
6918            assert_eq!(
6919                file_type(libc::S_IFREG as u32 | x),
6920                '*',
6921                "exec bit 0o{x:o} should produce '*'"
6922            );
6923        }
6924    }
6925
6926    #[test]
6927    fn file_type_symlink_is_at() {
6928        let _g = crate::test_util::global_state_lock();
6929        assert_eq!(file_type(libc::S_IFLNK as u32), '@');
6930    }
6931
6932    #[test]
6933    fn file_type_fifo_is_pipe() {
6934        let _g = crate::test_util::global_state_lock();
6935        assert_eq!(file_type(libc::S_IFIFO as u32), '|');
6936    }
6937
6938    #[test]
6939    fn file_type_socket_is_equal() {
6940        let _g = crate::test_util::global_state_lock();
6941        assert_eq!(file_type(libc::S_IFSOCK as u32), '=');
6942    }
6943
6944    // ═══════════════════════════════════════════════════════════════════
6945    // xpandbraces edge cases — anchored to `print -l <pattern>` in zsh 5.9.
6946    // Where zshrs diverges, the test FAILS to expose the bug.
6947    // ═══════════════════════════════════════════════════════════════════
6948
6949    /// `{1..10..2}` → 1 3 5 7 9 (step range). zsh: 5 elements.
6950    #[test]
6951    fn xpandbraces_numeric_step_two() {
6952        let _g = crate::test_util::global_state_lock();
6953        assert_eq!(
6954            xpandbraces(&tok("{1..10..2}"), false),
6955            vec!["1", "3", "5", "7", "9"]
6956        );
6957    }
6958
6959    /// `{10..1..2}` → 10 8 6 4 2 (descending step).
6960    #[test]
6961    fn xpandbraces_numeric_descending_step_two() {
6962        let _g = crate::test_util::global_state_lock();
6963        assert_eq!(
6964            xpandbraces(&tok("{10..1..2}"), false),
6965            vec!["10", "8", "6", "4", "2"]
6966        );
6967    }
6968
6969    /// `{5..1}` → 5 4 3 2 1 (descending range, no step).
6970    #[test]
6971    fn xpandbraces_numeric_descending_range() {
6972        let _g = crate::test_util::global_state_lock();
6973        assert_eq!(
6974            xpandbraces(&tok("{5..1}"), false),
6975            vec!["5", "4", "3", "2", "1"]
6976        );
6977    }
6978
6979    /// `{01..10}` → 01 02 03 ... 10 (zero-padded; pad preserved).
6980    #[test]
6981    fn xpandbraces_zero_padded_numeric_range() {
6982        let _g = crate::test_util::global_state_lock();
6983        assert_eq!(
6984            xpandbraces(&tok("{01..10}"), false),
6985            vec!["01", "02", "03", "04", "05", "06", "07", "08", "09", "10"]
6986        );
6987    }
6988
6989    /// `{001..010}` → 001 002 ... 010 (3-digit pad preserved).
6990    #[test]
6991    fn xpandbraces_three_digit_pad_preserved() {
6992        let _g = crate::test_util::global_state_lock();
6993        assert_eq!(
6994            xpandbraces(&tok("{001..010}"), false),
6995            vec!["001", "002", "003", "004", "005", "006", "007", "008", "009", "010"]
6996        );
6997    }
6998
6999    /// `\{a,b,c\}` → no expansion (escaped braces aren't brace
7000    /// delimiters). xpandbraces returns input unchanged; the lexer
7001    /// upstream strips the backslashes before this layer sees them.
7002    /// Anchored test: zsh `print -r -- \{a,b,c\}` → `{a,b,c}` because
7003    /// the lexer tokenizes `\{`/`\}` to `Bnull{`/`Bnull}` before
7004    /// xpandbraces runs, then untokenize drops the Bnulls. At the
7005    /// xpandbraces-unit level (raw backslashes), the only invariant
7006    /// to verify is "no expansion fires" — the brace literal survives.
7007    #[test]
7008    fn xpandbraces_escaped_braces_remain_literal_anchored_to_zsh() {
7009        let _g = crate::test_util::global_state_lock();
7010        // tok() emits Bnull+ASCII `{` for `\{` and Bnull+ASCII `}` for
7011        // `\}`, with Comma TOKEN for the unescaped commas inside.
7012        // xpandbraces scans for Inbrace TOKEN only (per
7013        // c:Src/glob.c::xpandbraces) — finds none here, so the
7014        // string passes through unchanged at this layer. The Bnulls
7015        // are stripped later by remnulargs in prefork.
7016        assert_eq!(
7017            xpandbraces(&tok("\\{a,b,c\\}"), false),
7018            vec![tok("\\{a,b,c\\}")],
7019            "xpandbraces unit: escaped braces survive without expansion (no per-element splat)"
7020        );
7021    }
7022
7023    /// `{a,b}{c,d}` → ac ad bc bd (Cartesian product).
7024    /// 2 * 2 = 4 elements, in row-major order.
7025    #[test]
7026    fn xpandbraces_cartesian_product_two_by_two() {
7027        let _g = crate::test_util::global_state_lock();
7028        assert_eq!(
7029            xpandbraces(&tok("{a,b}{c,d}"), false),
7030            vec!["ac", "ad", "bc", "bd"]
7031        );
7032    }
7033
7034    /// `{a,{b,c}}` → a b c (nested flattened).
7035    #[test]
7036    fn xpandbraces_nested_braces_flatten() {
7037        let _g = crate::test_util::global_state_lock();
7038        assert_eq!(xpandbraces(&tok("{a,{b,c}}"), false), vec!["a", "b", "c"]);
7039    }
7040
7041    /// `{}` → `{}` literal (empty brace doesn't expand). xpandbraces
7042    /// preserves TOKEN form at this layer (c:Src/glob.c::xpandbraces);
7043    /// ASCII conversion happens later in untokenize.
7044    #[test]
7045    fn xpandbraces_empty_braces_remain_literal() {
7046        let _g = crate::test_util::global_state_lock();
7047        assert_eq!(xpandbraces(&tok("{}"), false), vec![tok("{}")]);
7048    }
7049
7050    /// `a{b,c}d{e,f}` → abde abdf acde acdf (cartesian with surrounding text).
7051    #[test]
7052    fn xpandbraces_cartesian_with_surrounding_text() {
7053        let _g = crate::test_util::global_state_lock();
7054        assert_eq!(
7055            xpandbraces(&tok("a{b,c}d{e,f}"), false),
7056            vec!["abde", "abdf", "acde", "acdf"]
7057        );
7058    }
7059
7060    /// `{a..z..3}` → `{a..z..3}` literal (alpha range with step NOT expanded
7061    /// by zsh — surprising but verified). xpandbraces preserves TOKEN form
7062    /// at this layer; ASCII conversion happens later in untokenize.
7063    #[test]
7064    fn xpandbraces_alpha_step_unsupported_anchored_to_zsh() {
7065        let _g = crate::test_util::global_state_lock();
7066        assert_eq!(
7067            xpandbraces(&tok("{a..z..3}"), false),
7068            vec![tok("{a..z..3}")],
7069            "zsh: alpha range with step → literal (TOKEN form preserved at xpandbraces layer)"
7070        );
7071    }
7072
7073    // ═══════════════════════════════════════════════════════════════════
7074    // zsh test-corpus pins — direct anchors to Test/D09brace.ztst.
7075    // Tests verify brace expansion matches zsh's documented output.
7076    // ═══════════════════════════════════════════════════════════════════
7077
7078    /// `Test/D09brace.ztst:10-12` — basic brace expansion with
7079    /// nested range: `X{1,2,{3..6},7,8}Y` expands all 8 values.
7080    #[test]
7081    fn zsh_corpus_basic_brace_with_nested_range() {
7082        let _g = crate::test_util::global_state_lock();
7083        assert_eq!(
7084            xpandbraces(&tok("X{1,2,{3..6},7,8}Y"), false),
7085            vec!["X1Y", "X2Y", "X3Y", "X4Y", "X5Y", "X6Y", "X7Y", "X8Y"],
7086            "ztst:11 — basic brace expansion with nested range",
7087        );
7088    }
7089
7090    /// `Test/D09brace.ztst:30-32` — numeric range with leading zero
7091    /// padding: `X{01..4}Y` → all 4 values 0-padded to 2 digits.
7092    #[test]
7093    fn zsh_corpus_numeric_range_zero_padding() {
7094        let _g = crate::test_util::global_state_lock();
7095        assert_eq!(
7096            xpandbraces(&tok("X{01..4}Y"), false),
7097            vec!["X01Y", "X02Y", "X03Y", "X04Y"],
7098            "ztst:32 — leading-zero padding propagates to all values",
7099        );
7100    }
7101
7102    /// `Test/D09brace.ztst:34-36` — padding comes from RHS too:
7103    /// `X{1..04}Y` → 2-digit zero-padded.
7104    #[test]
7105    fn zsh_corpus_numeric_range_padding_from_rhs() {
7106        let _g = crate::test_util::global_state_lock();
7107        assert_eq!(
7108            xpandbraces(&tok("X{1..04}Y"), false),
7109            vec!["X01Y", "X02Y", "X03Y", "X04Y"],
7110            "ztst:36 — RHS padding `04` propagates",
7111        );
7112    }
7113
7114    /// `Test/D09brace.ztst:38-40` — unpadded range >9: `X{7..12}Y`
7115    /// numbers stay unpadded.
7116    #[test]
7117    fn zsh_corpus_numeric_range_no_padding_when_unspecified() {
7118        let _g = crate::test_util::global_state_lock();
7119        assert_eq!(
7120            xpandbraces(&tok("X{7..12}Y"), false),
7121            vec!["X7Y", "X8Y", "X9Y", "X10Y", "X11Y", "X12Y"],
7122            "ztst:40 — no padding when neither end has leading zero",
7123        );
7124    }
7125
7126    /// `Test/D09brace.ztst:42-44` — `X{07..12}Y` → 2-digit padded.
7127    #[test]
7128    fn zsh_corpus_numeric_range_lhs_padding_propagates() {
7129        let _g = crate::test_util::global_state_lock();
7130        assert_eq!(
7131            xpandbraces(&tok("X{07..12}Y"), false),
7132            vec!["X07Y", "X08Y", "X09Y", "X10Y", "X11Y", "X12Y"],
7133            "ztst:44 — LHS padding `07` propagates",
7134        );
7135    }
7136
7137    /// `Test/D09brace.ztst:46-48` — wider RHS padding overrides:
7138    /// `X{7..012}Y` → 3-digit padded.
7139    #[test]
7140    fn zsh_corpus_numeric_range_max_padding_width_wins() {
7141        let _g = crate::test_util::global_state_lock();
7142        assert_eq!(
7143            xpandbraces(&tok("X{7..012}Y"), false),
7144            vec!["X007Y", "X008Y", "X009Y", "X010Y", "X011Y", "X012Y"],
7145            "ztst:48 — widest padding width wins",
7146        );
7147    }
7148
7149    /// `Test/D09brace.ztst:50-52` — decreasing range: `X{4..1}Y` →
7150    /// 4,3,2,1 (reversed).
7151    #[test]
7152    fn zsh_corpus_numeric_range_decreasing() {
7153        let _g = crate::test_util::global_state_lock();
7154        assert_eq!(
7155            xpandbraces(&tok("X{4..1}Y"), false),
7156            vec!["X4Y", "X3Y", "X2Y", "X1Y"],
7157            "ztst:52 — decreasing range emits in reverse",
7158        );
7159    }
7160
7161    /// `Test/D09brace.ztst:54-56` — combined braces: `X{1..4}{1..4}Y`
7162    /// → 16-element cross product.
7163    #[test]
7164    fn zsh_corpus_combined_braces_cross_product() {
7165        let _g = crate::test_util::global_state_lock();
7166        assert_eq!(
7167            xpandbraces(&tok("X{1..4}{1..4}Y"), false),
7168            vec![
7169                "X11Y", "X12Y", "X13Y", "X14Y", "X21Y", "X22Y", "X23Y", "X24Y", "X31Y", "X32Y",
7170                "X33Y", "X34Y", "X41Y", "X42Y", "X43Y", "X44Y",
7171            ],
7172            "ztst:56 — combined-brace cross product",
7173        );
7174    }
7175
7176    /// `Test/D09brace.ztst:58-60` — negative numbers in range:
7177    /// `X{-4..4}Y` → `-4`..`4` (9 values including 0).
7178    #[test]
7179    fn zsh_corpus_negative_numbers_in_range() {
7180        let _g = crate::test_util::global_state_lock();
7181        assert_eq!(
7182            xpandbraces(&tok("X{-4..4}Y"), false),
7183            vec!["X-4Y", "X-3Y", "X-2Y", "X-1Y", "X0Y", "X1Y", "X2Y", "X3Y", "X4Y",],
7184            "ztst:60 — negative-to-positive range",
7185        );
7186    }
7187
7188    /// `Test/D09brace.ztst:62-64` — reverse-direction numeric range:
7189    /// `X{4..-4}Y` walks from 4 to -4 descending.
7190    #[test]
7191    fn zsh_corpus_brace_descending_range_from_positive_to_negative() {
7192        let _g = crate::test_util::global_state_lock();
7193        assert_eq!(
7194            xpandbraces(&tok("X{4..-4}Y"), false),
7195            vec!["X4Y", "X3Y", "X2Y", "X1Y", "X0Y", "X-1Y", "X-2Y", "X-3Y", "X-4Y",],
7196            "ztst:64 — descending 4..-4 produces 9 values",
7197        );
7198    }
7199
7200    /// `Test/D09brace.ztst:66-68` — stepped padded range:
7201    /// `X{004..-4..2}Y` = X004Y X002Y X000Y X-02Y X-04Y.
7202    /// Padding width 3 from LHS; step is +2 (sign of step is ignored,
7203    /// direction is from start to end).
7204    #[test]
7205    fn zsh_corpus_brace_stepped_padded_descending() {
7206        let _g = crate::test_util::global_state_lock();
7207        assert_eq!(
7208            xpandbraces(&tok("X{004..-4..2}Y"), false),
7209            vec!["X004Y", "X002Y", "X000Y", "X-02Y", "X-04Y"],
7210            "ztst:68 — stepped+padded descending",
7211        );
7212    }
7213
7214    /// `Test/D09brace.ztst:74-76` — step alignment 1: `X{1..32..3}Y`
7215    /// = X1Y X4Y X7Y X10Y X13Y X16Y X19Y X22Y X25Y X28Y X31Y.
7216    /// Step 3 starting from 1, capped at 32.
7217    #[test]
7218    fn zsh_corpus_brace_step_alignment_1_to_32_step_3() {
7219        let _g = crate::test_util::global_state_lock();
7220        assert_eq!(
7221            xpandbraces(&tok("X{1..32..3}Y"), false),
7222            vec![
7223                "X1Y", "X4Y", "X7Y", "X10Y", "X13Y", "X16Y", "X19Y", "X22Y", "X25Y", "X28Y",
7224                "X31Y",
7225            ],
7226            "ztst:76 — {{1..32..3}} step alignment",
7227        );
7228    }
7229
7230    /// `Test/D09brace.ztst:100-102` — `hey{a..j}there` — char range.
7231    #[test]
7232    fn zsh_corpus_brace_char_range_simple() {
7233        let _g = crate::test_util::global_state_lock();
7234        assert_eq!(
7235            xpandbraces(&tok("hey{a..j}there"), false),
7236            vec![
7237                "heyathere",
7238                "heybthere",
7239                "heycthere",
7240                "heydthere",
7241                "heyethere",
7242                "heyfthere",
7243                "heygthere",
7244                "heyhthere",
7245                "heyithere",
7246                "heyjthere",
7247            ],
7248            "ztst:102 — char range a..j",
7249        );
7250    }
7251
7252    /// `Test/D09brace.ztst:108-110` — reverse char range:
7253    /// `crumbs{y..p}ooh` walks down y → p (10 values).
7254    #[test]
7255    fn zsh_corpus_brace_char_range_reverse() {
7256        let _g = crate::test_util::global_state_lock();
7257        assert_eq!(
7258            xpandbraces(&tok("crumbs{y..p}ooh"), false),
7259            vec![
7260                "crumbsyooh",
7261                "crumbsxooh",
7262                "crumbswooh",
7263                "crumbsvooh",
7264                "crumbsuooh",
7265                "crumbstooh",
7266                "crumbssooh",
7267                "crumbsrooh",
7268                "crumbsqooh",
7269                "crumbspooh",
7270            ],
7271            "ztst:110 — char range y..p reverse",
7272        );
7273    }
7274
7275    /// `Test/D09brace.ztst:104-106` — nested brace: `gosh{1,{Z..a},2}cripes`
7276    /// — inner `{Z..a}` is char range Z to a in ASCII order (10 chars).
7277    /// Full output: gosh1cripes goshZcripes gosh[cripes gosh\cripes
7278    /// gosh]cripes gosh^cripes gosh_cripes gosh`cripes goshacripes
7279    /// gosh2cripes.
7280    #[test]
7281    fn zsh_corpus_brace_nested_with_char_range_ascii() {
7282        let _g = crate::test_util::global_state_lock();
7283        assert_eq!(
7284            xpandbraces(&tok("gosh{1,{Z..a},2}cripes"), false),
7285            vec![
7286                "gosh1cripes",
7287                "goshZcripes",
7288                "gosh[cripes",
7289                "gosh\\cripes",
7290                "gosh]cripes",
7291                "gosh^cripes",
7292                "gosh_cripes",
7293                "gosh`cripes",
7294                "goshacripes",
7295                "gosh2cripes",
7296            ],
7297            "ztst:106 — nested brace with ASCII char range",
7298        );
7299    }
7300
7301    /// `Test/D09brace.ztst:116-118` — unmatched closing brace after
7302    /// matched braces stays literal: `{1..10}{..` → `1{.. 2{.. ...`.
7303    /// `xpandbraces` itself preserves TOKEN form (Inbrace=`\u{8f}`,
7304    /// Outbrace=`\u{90}`) per the convention pinned by
7305    /// `xpandbraces_alpha_step_unsupported_anchored_to_zsh` — the
7306    /// later untokenize pass in the user-output pipeline turns
7307    /// surviving brace tokens into literal `{` / `}`. This test
7308    /// asserts the tokenized intermediate form.
7309    #[test]
7310    fn zsh_corpus_brace_unmatched_after_matched_left_literal() {
7311        let _g = crate::test_util::global_state_lock();
7312        let tokb = '\u{8f}'; // Inbrace
7313        let expected: Vec<String> = (1..=10).map(|n| format!("{}{}..", n, tokb)).collect();
7314        assert_eq!(
7315            xpandbraces(&tok("{1..10}{.."), false),
7316            expected,
7317            "ztst:118 — unmatched trailing {{.. preserved as tokenized Inbrace at xpandbraces layer",
7318        );
7319    }
7320
7321    // ── split_qualifier: parse trailing (qual) block ─────────────────
7322    /// `*.txt` (no trailing parens) → (input, None).
7323    #[test]
7324    fn split_qualifier_no_parens_returns_input_and_none() {
7325        let _g = crate::test_util::global_state_lock();
7326        let (head, qual) = split_qualifier("*.txt");
7327        assert_eq!(head, "*.txt");
7328        assert_eq!(qual, None);
7329    }
7330
7331    /// `*(N)` → ("*", Some("N")) — null-glob qualifier.
7332    #[test]
7333    fn split_qualifier_star_paren_N_extracts_null_glob_qual() {
7334        let _g = crate::test_util::global_state_lock();
7335        let (head, qual) = split_qualifier("*(N)");
7336        assert_eq!(head, "*");
7337        assert_eq!(qual, Some("N"));
7338    }
7339
7340    /// `*(.)` → ("*", Some(".")) — regular-file qualifier.
7341    #[test]
7342    fn split_qualifier_star_paren_dot_extracts_regular_file_qual() {
7343        let _g = crate::test_util::global_state_lock();
7344        let (head, qual) = split_qualifier("*(.)");
7345        assert_eq!(head, "*");
7346        assert_eq!(qual, Some("."));
7347    }
7348
7349    /// `*.rs(.)` → ("*.rs", Some(".")) — qualifier on glob pattern.
7350    #[test]
7351    fn split_qualifier_pattern_with_qual_extracts_correctly() {
7352        let _g = crate::test_util::global_state_lock();
7353        let (head, qual) = split_qualifier("*.rs(.)");
7354        assert_eq!(head, "*.rs");
7355        assert_eq!(qual, Some("."));
7356    }
7357
7358    /// `*(#qN)` → ("*", Some("N")) — `#q` prefix stripped per zsh syntax.
7359    #[test]
7360    fn split_qualifier_hash_q_prefix_stripped() {
7361        let _g = crate::test_util::global_state_lock();
7362        let (head, qual) = split_qualifier("*(#qN)");
7363        assert_eq!(head, "*");
7364        assert_eq!(qual, Some("N"));
7365    }
7366
7367    /// `*(om[1])` → ("*", Some("om[1]")) — sort-by-mtime keep first.
7368    #[test]
7369    fn split_qualifier_multichar_qual_with_brackets() {
7370        let _g = crate::test_util::global_state_lock();
7371        let (head, qual) = split_qualifier("*(om[1])");
7372        assert_eq!(head, "*");
7373        assert_eq!(qual, Some("om[1]"));
7374    }
7375
7376    /// `(a)(b)` — multiple paren groups: split takes the OUTERMOST trailing.
7377    #[test]
7378    fn split_qualifier_multiple_groups_takes_outermost_trailing() {
7379        let _g = crate::test_util::global_state_lock();
7380        let (head, qual) = split_qualifier("(a)(b)");
7381        assert_eq!(head, "(a)");
7382        assert_eq!(qual, Some("b"));
7383    }
7384
7385    // ═══════════════════════════════════════════════════════════════════
7386    // parse_qualifier_string — file-type / permission / sort qualifiers.
7387    // Tests call the private fn directly (same crate, same file).
7388    // Each qualifier letter maps to a `qualifier::*` variant per the
7389    // zsh glob.c arm at c:1495+. Pin the parse, not the match — the
7390    // match path needs a real filesystem.
7391    //
7392    // IMPORTANT: at end of parse_qualifier_string (c:3433-3435 in this
7393    // file), if `qualifiers` is non-empty it gets moved into
7394    // `alternatives[]`. So tests read the qualifier from `alternatives[0]`
7395    // — qualifiers itself is empty after the move.
7396    // ═══════════════════════════════════════════════════════════════════
7397
7398    /// Helper: read the only qualifier from a single-letter parse result.
7399    fn first_qual(qs: &qualifier_set) -> &qualifier {
7400        // After the post-loop move, single-letter input lands in
7401        // alternatives[0][0]. Fall back to qualifiers[0] for safety in
7402        // case the move ever changes.
7403        if !qs.alternatives.is_empty() && !qs.alternatives[0].is_empty() {
7404            &qs.alternatives[0][0]
7405        } else {
7406            &qs.qualifiers[0]
7407        }
7408    }
7409
7410    /// Empty qualifier body → no alternatives, no qualifiers.
7411    #[test]
7412    fn parse_qualifier_string_empty_returns_empty_set() {
7413        let _g = crate::test_util::global_state_lock();
7414        let qs = parse_qualifier_string("");
7415        assert!(qs.qualifiers.is_empty());
7416        assert!(qs.alternatives.is_empty());
7417        assert!(!qs.nullglob);
7418    }
7419
7420    /// `/` → IsDirectory (in alternatives[0]).
7421    #[test]
7422    fn parse_qualifier_string_slash_is_directory() {
7423        let _g = crate::test_util::global_state_lock();
7424        let qs = parse_qualifier_string("/");
7425        assert!(matches!(first_qual(&qs), qualifier::IsDirectory));
7426    }
7427
7428    /// `.` → IsRegular.
7429    #[test]
7430    fn parse_qualifier_string_dot_is_regular() {
7431        let _g = crate::test_util::global_state_lock();
7432        let qs = parse_qualifier_string(".");
7433        assert!(matches!(first_qual(&qs), qualifier::IsRegular));
7434    }
7435
7436    /// `parsecomplist` splits a path into per-component `complist` nodes,
7437    /// each literal section a PAT_PURES Patprog (relies on patcompile's
7438    /// PAT_FILE PURES segment-stop at `/`, pattern.c:584-610).
7439    #[test]
7440    fn parsecomplist_splits_path_components() {
7441        let _g = crate::test_util::global_state_lock();
7442        fn pure(p: &crate::ported::pattern::Patprog) -> Option<String> {
7443            if (p.0.flags & crate::ported::zsh_h::PAT_PURES as i32) != 0 {
7444                let off = p.0.startoff as usize;
7445                let l = p.0.patmlen as usize;
7446                p.1.get(off..off + l)
7447                    .map(|b| String::from_utf8_lossy(b).into_owned())
7448            } else {
7449                None
7450            }
7451        }
7452        fn sections(mut q: &complist) -> Vec<String> {
7453            let mut out = Vec::new();
7454            loop {
7455                out.push(pure(&q.pat).unwrap_or_else(|| "<pat>".into()));
7456                match &q.next {
7457                    Some(n) => q = n,
7458                    None => break,
7459                }
7460            }
7461            out
7462        }
7463        // Literal path → split into per-component PAT_PURES sections.
7464        let mut t = "a/b/c.txt".to_string();
7465        tokenize(&mut t);
7466        let cl = parsecomplist(&t).expect("parsecomplist None");
7467        assert_eq!(sections(&cl), vec!["a", "b", "c.txt"]);
7468
7469        // Literal prefix before a pattern still splits the literal dirs;
7470        // the trailing glob section is a real pattern (not PAT_PURES).
7471        let mut t2 = "a/b/*.txt".to_string();
7472        tokenize(&mut t2);
7473        let cl2 = parsecomplist(&t2).expect("parsecomplist None");
7474        let s2 = sections(&cl2);
7475        assert_eq!(s2.len(), 3, "a/b/*.txt → 3 sections, got {s2:?}");
7476        assert_eq!(&s2[..2], &["a".to_string(), "b".to_string()]);
7477        assert_eq!(s2[2], "<pat>", "trailing *.txt must be a pattern section");
7478
7479        // Pattern-FIRST then literal dir: */foo → [<pat>, foo].
7480        let mut t3 = "*/foo".to_string();
7481        tokenize(&mut t3);
7482        let cl3 = parsecomplist(&t3).expect("parsecomplist None for */foo");
7483        let s3 = sections(&cl3);
7484        assert_eq!(s3.len(), 2, "*/foo → 2 sections, got {s3:?}");
7485        assert_eq!(s3[0], "<pat>", "leading * must be a pattern section");
7486        assert_eq!(s3[1], "foo");
7487    }
7488
7489    /// The `struct qual` arena is built from the parse alongside the enum:
7490    /// AND via `next`, alternatives via `or`, per-node `sense` from `^`.
7491    #[test]
7492    fn parse_qualifier_string_builds_qual_arena() {
7493        let _g = crate::test_util::global_state_lock();
7494        // single qualifier → one node, sense 0, head set, func present.
7495        let q = parse_qualifier_string(".");
7496        assert_eq!(q.quals.nodes.len(), 1);
7497        assert_eq!(q.quals.head, Some(0));
7498        assert_eq!(q.quals.nodes[0].sense, 0);
7499        assert!(q.quals.nodes[0].func.is_some());
7500        assert_eq!(q.quals.nodes[0].next, None);
7501
7502        // leading `^` → per-node sense flipped (c:1346).
7503        let q = parse_qualifier_string("^.");
7504        assert_eq!(q.quals.nodes.len(), 1);
7505        assert_eq!(q.quals.nodes[0].sense, 1);
7506
7507        // `/^@` → AND-chain of 2: dir(sense 0) -next-> symlink(sense 1).
7508        let q = parse_qualifier_string("/^@");
7509        assert_eq!(q.quals.nodes.len(), 2);
7510        assert_eq!(q.quals.nodes[0].sense, 0);
7511        assert_eq!(q.quals.nodes[0].next, Some(1));
7512        assert_eq!(q.quals.nodes[1].sense, 1);
7513        assert_eq!(q.quals.nodes[1].next, None);
7514
7515        // `.,/` → two alternatives: node0 -or-> node1, neither chained.
7516        let q = parse_qualifier_string(".,/");
7517        assert_eq!(q.quals.nodes.len(), 2);
7518        assert_eq!(q.quals.nodes[0].or, Some(1));
7519        assert_eq!(q.quals.nodes[0].next, None);
7520        assert_eq!(q.quals.head, Some(0));
7521    }
7522
7523    /// `@` → IsSymlink.
7524    #[test]
7525    fn parse_qualifier_string_at_is_symlink() {
7526        let _g = crate::test_util::global_state_lock();
7527        let qs = parse_qualifier_string("@");
7528        assert!(matches!(first_qual(&qs), qualifier::IsSymlink));
7529    }
7530
7531    /// `=` → IsSocket.
7532    #[test]
7533    fn parse_qualifier_string_equals_is_socket() {
7534        let _g = crate::test_util::global_state_lock();
7535        let qs = parse_qualifier_string("=");
7536        assert!(matches!(first_qual(&qs), qualifier::IsSocket));
7537    }
7538
7539    /// `p` → IsFifo.
7540    #[test]
7541    fn parse_qualifier_string_p_is_fifo() {
7542        let _g = crate::test_util::global_state_lock();
7543        let qs = parse_qualifier_string("p");
7544        assert!(matches!(first_qual(&qs), qualifier::IsFifo));
7545    }
7546
7547    /// `*` → IsExecutable.
7548    #[test]
7549    fn parse_qualifier_string_star_is_executable() {
7550        let _g = crate::test_util::global_state_lock();
7551        let qs = parse_qualifier_string("*");
7552        assert!(matches!(first_qual(&qs), qualifier::IsExecutable));
7553    }
7554
7555    /// `%b` → IsBlockDev.
7556    #[test]
7557    fn parse_qualifier_string_pct_b_is_block_device() {
7558        let _g = crate::test_util::global_state_lock();
7559        let qs = parse_qualifier_string("%b");
7560        assert!(matches!(first_qual(&qs), qualifier::IsBlockDev));
7561    }
7562
7563    /// `%c` → IsCharDev.
7564    #[test]
7565    fn parse_qualifier_string_pct_c_is_char_device() {
7566        let _g = crate::test_util::global_state_lock();
7567        let qs = parse_qualifier_string("%c");
7568        assert!(matches!(first_qual(&qs), qualifier::IsCharDev));
7569    }
7570
7571    /// `r` / `w` / `x` → Readable / Writable / Executable.
7572    #[test]
7573    fn parse_qualifier_string_perm_letters_map_to_perm_qualifiers() {
7574        let _g = crate::test_util::global_state_lock();
7575        for (letter, expected) in [
7576            ("r", qualifier::Readable),
7577            ("w", qualifier::Writable),
7578            ("x", qualifier::Executable),
7579        ] {
7580            let qs = parse_qualifier_string(letter);
7581            assert_eq!(
7582                std::mem::discriminant(first_qual(&qs)),
7583                std::mem::discriminant(&expected),
7584                "letter {letter:?} should map to {expected:?}"
7585            );
7586        }
7587    }
7588
7589    /// `U` → OwnedByEuid (process EUID).
7590    #[test]
7591    fn parse_qualifier_string_capital_U_is_owned_by_euid() {
7592        let _g = crate::test_util::global_state_lock();
7593        let qs = parse_qualifier_string("U");
7594        assert!(matches!(first_qual(&qs), qualifier::OwnedByEuid));
7595    }
7596
7597    /// `G` → OwnedByEgid.
7598    #[test]
7599    fn parse_qualifier_string_capital_G_is_owned_by_egid() {
7600        let _g = crate::test_util::global_state_lock();
7601        let qs = parse_qualifier_string("G");
7602        assert!(matches!(first_qual(&qs), qualifier::OwnedByEgid));
7603    }
7604
7605    /// Multiple file-type qualifiers stack — `./` → IsRegular then IsDirectory
7606    /// both end up in alternatives[0].
7607    #[test]
7608    fn parse_qualifier_string_multiple_letters_stack_in_one_alternative() {
7609        let _g = crate::test_util::global_state_lock();
7610        let qs = parse_qualifier_string("./");
7611        assert_eq!(qs.alternatives.len(), 1);
7612        assert_eq!(qs.alternatives[0].len(), 2);
7613        assert!(matches!(qs.alternatives[0][0], qualifier::IsRegular));
7614        assert!(matches!(qs.alternatives[0][1], qualifier::IsDirectory));
7615    }
7616
7617    /// `,` separates alternatives — `/,.` → two alternatives, each with one
7618    /// qualifier (IsDirectory and IsRegular respectively).
7619    #[test]
7620    fn parse_qualifier_string_comma_creates_two_alternatives() {
7621        let _g = crate::test_util::global_state_lock();
7622        let qs = parse_qualifier_string("/,.");
7623        assert_eq!(qs.alternatives.len(), 2, "two alternatives expected");
7624        assert!(matches!(qs.alternatives[0][0], qualifier::IsDirectory));
7625        assert!(matches!(qs.alternatives[1][0], qualifier::IsRegular));
7626    }
7627
7628    /// `:` introduces colon-modifiers; rest captured into colon_mods.
7629    #[test]
7630    fn parse_qualifier_string_colon_captures_modifiers_in_field() {
7631        let _g = crate::test_util::global_state_lock();
7632        let qs = parse_qualifier_string(":h");
7633        assert_eq!(qs.colon_mods.as_deref(), Some(":h"));
7634    }
7635
7636    /// `:` after a qualifier captures BOTH.
7637    #[test]
7638    fn parse_qualifier_string_qualifier_then_colon_mod() {
7639        let _g = crate::test_util::global_state_lock();
7640        let qs = parse_qualifier_string("/:h");
7641        assert!(matches!(first_qual(&qs), qualifier::IsDirectory));
7642        assert_eq!(qs.colon_mods.as_deref(), Some(":h"));
7643    }
7644
7645    /// `N` flag → nullglob set, no qualifier pushed.
7646    #[test]
7647    fn parse_qualifier_string_capital_N_sets_nullglob_flag() {
7648        let _g = crate::test_util::global_state_lock();
7649        let qs = parse_qualifier_string("N");
7650        assert!(qs.nullglob, "(N) must set nullglob");
7651        assert!(qs.alternatives.is_empty(), "N doesn't push to qualifiers");
7652    }
7653
7654    /// `M` flag → mark_dirs.
7655    #[test]
7656    fn parse_qualifier_string_capital_M_sets_mark_dirs_flag() {
7657        let _g = crate::test_util::global_state_lock();
7658        let qs = parse_qualifier_string("M");
7659        assert!(qs.mark_dirs, "(M) must set mark_dirs");
7660    }
7661
7662    /// `T` flag → list_types.
7663    #[test]
7664    fn parse_qualifier_string_capital_T_sets_list_types_flag() {
7665        let _g = crate::test_util::global_state_lock();
7666        let qs = parse_qualifier_string("T");
7667        assert!(qs.list_types, "(T) must set list_types");
7668    }
7669
7670    // ── split_qualifier integration with parse_qualifier_string ──────
7671    /// `*(.)` — split yields ("*", Some(".")); parse yields IsRegular.
7672    #[test]
7673    fn split_then_parse_dot_qualifier_yields_is_regular() {
7674        let _g = crate::test_util::global_state_lock();
7675        let (head, qual) = split_qualifier("*(.)");
7676        assert_eq!(head, "*");
7677        let qs = parse_qualifier_string(qual.unwrap());
7678        assert!(matches!(first_qual(&qs), qualifier::IsRegular));
7679    }
7680
7681    /// `*(/)` — directory-only glob.
7682    #[test]
7683    fn split_then_parse_slash_qualifier_yields_is_directory() {
7684        let _g = crate::test_util::global_state_lock();
7685        let (_, qual) = split_qualifier("*(/)");
7686        let qs = parse_qualifier_string(qual.unwrap());
7687        assert!(matches!(first_qual(&qs), qualifier::IsDirectory));
7688    }
7689
7690    // ═══════════════════════════════════════════════════════════════════
7691    // Sort qualifiers — `o<key>` ascending, `O<key>` descending.
7692    // ═══════════════════════════════════════════════════════════════════
7693
7694    /// `oN` — sort by NONE (no sorting key requested).
7695    #[test]
7696    fn parse_qualifier_string_oN_pushes_gs_none() {
7697        let _g = crate::test_util::global_state_lock();
7698        let qs = parse_qualifier_string("oN");
7699        assert_eq!(qs.sorts.len(), 1);
7700        assert_ne!(qs.sorts[0] & GS_NONE, 0, "oN must set GS_NONE bit");
7701    }
7702
7703    /// `on` — sort by name ascending.
7704    #[test]
7705    fn parse_qualifier_string_on_pushes_gs_name_ascending() {
7706        let _g = crate::test_util::global_state_lock();
7707        let qs = parse_qualifier_string("on");
7708        assert_eq!(qs.sorts.len(), 1);
7709        assert_eq!(qs.sorts[0] & GS_NAME, GS_NAME);
7710        assert_eq!(qs.sorts[0] & GS_DESC, 0);
7711    }
7712
7713    /// `On` — sort by name descending.
7714    #[test]
7715    fn parse_qualifier_string_On_pushes_gs_name_descending() {
7716        let _g = crate::test_util::global_state_lock();
7717        let qs = parse_qualifier_string("On");
7718        assert_eq!(qs.sorts.len(), 1);
7719        assert_eq!(qs.sorts[0] & GS_NAME, GS_NAME);
7720        assert_eq!(qs.sorts[0] & GS_DESC, GS_DESC);
7721    }
7722
7723    /// Multiple sort keys stack.
7724    #[test]
7725    fn parse_qualifier_string_chained_sort_keys_stack() {
7726        let _g = crate::test_util::global_state_lock();
7727        let qs = parse_qualifier_string("onOL");
7728        assert_eq!(qs.sorts.len(), 2);
7729    }
7730
7731    // ── Size qualifier — `L<unit><op><value>` ───────────────────────
7732    // NOTE: zshrs's parse_range_spec normalises `+` (greater) → '>' and
7733    // `-` (less) → '<' for the internal op char, matching C's qgetnum
7734    // convention (Src/glob.c c:1620-ish).
7735
7736    /// `Lk+1` — size > 1 kilobyte. zsh syntax: `L<unit><op><value>`
7737    /// (unit char BEFORE op).
7738    #[test]
7739    fn parse_qualifier_string_Lk_plus_one_size_kilobyte() {
7740        let _g = crate::test_util::global_state_lock();
7741        let qs = parse_qualifier_string("Lk+1");
7742        match first_qual(&qs) {
7743            qualifier::Size { value, unit, op } => {
7744                assert_eq!(*value, 1);
7745                assert_eq!(*unit, TT_KILOBYTES);
7746                assert_eq!(*op, '>', "+ is stored as '>' (greater than)");
7747            }
7748            other => panic!("expected Size, got {other:?}"),
7749        }
7750    }
7751
7752    /// `L-100` — size < 100 (default unit bytes). Stored op is '<'.
7753    #[test]
7754    fn parse_qualifier_string_L_minus_100_size_bytes() {
7755        let _g = crate::test_util::global_state_lock();
7756        let qs = parse_qualifier_string("L-100");
7757        match first_qual(&qs) {
7758            qualifier::Size { value, unit, op } => {
7759                assert_eq!(*value, 100);
7760                assert_eq!(*unit, TT_BYTES);
7761                assert_eq!(*op, '<', "- is stored as '<' (less than)");
7762            }
7763            other => panic!("expected Size, got {other:?}"),
7764        }
7765    }
7766
7767    /// `Lm+1` — size > 1 megabyte; unit recognised.
7768    #[test]
7769    fn parse_qualifier_string_Lm_megabyte_unit() {
7770        let _g = crate::test_util::global_state_lock();
7771        let qs = parse_qualifier_string("Lm+1");
7772        match first_qual(&qs) {
7773            qualifier::Size { unit, .. } => assert_eq!(*unit, TT_MEGABYTES),
7774            other => panic!("expected Size, got {other:?}"),
7775        }
7776    }
7777
7778    // ── Time qualifier — same op normalisation ───────────────────────
7779    /// `m-1` — modified less than 1 day ago. Stored op '<'.
7780    #[test]
7781    fn parse_qualifier_string_m_minus_1_day_default_unit() {
7782        let _g = crate::test_util::global_state_lock();
7783        let qs = parse_qualifier_string("m-1");
7784        match first_qual(&qs) {
7785            qualifier::Mtime { value, unit, op } => {
7786                assert_eq!(*value, 1);
7787                assert_eq!(*unit, TT_DAYS);
7788                assert_eq!(*op, '<');
7789            }
7790            other => panic!("expected Mtime, got {other:?}"),
7791        }
7792    }
7793
7794    /// `mh+24` — modified more than 24 hours ago. Stored op '>'.
7795    #[test]
7796    fn parse_qualifier_string_mh_plus_24_hours() {
7797        let _g = crate::test_util::global_state_lock();
7798        let qs = parse_qualifier_string("mh+24");
7799        match first_qual(&qs) {
7800            qualifier::Mtime { value, unit, op } => {
7801                assert_eq!(*value, 24);
7802                assert_eq!(*unit, TT_HOURS);
7803                assert_eq!(*op, '>');
7804            }
7805            other => panic!("expected Mtime, got {other:?}"),
7806        }
7807    }
7808
7809    /// `a-7` — accessed less than 7 days ago. Stored op '<'.
7810    #[test]
7811    fn parse_qualifier_string_a_minus_7_days() {
7812        let _g = crate::test_util::global_state_lock();
7813        let qs = parse_qualifier_string("a-7");
7814        match first_qual(&qs) {
7815            qualifier::Atime { value, unit, op } => {
7816                assert_eq!(*value, 7);
7817                assert_eq!(*unit, TT_DAYS);
7818                assert_eq!(*op, '<');
7819            }
7820            other => panic!("expected Atime, got {other:?}"),
7821        }
7822    }
7823
7824    // ── Subscript qualifier — `[N]` keep first ───────────────────────
7825    /// `[1]` — first entry only.
7826    #[test]
7827    fn parse_qualifier_string_bracket_one_sets_first_to_one() {
7828        let _g = crate::test_util::global_state_lock();
7829        let qs = parse_qualifier_string("[1]");
7830        assert_eq!(qs.first, Some(1));
7831    }
7832
7833    /// `om[1]` — sort by mtime, keep first.
7834    #[test]
7835    fn parse_qualifier_string_om_bracket_one_sort_and_subscript() {
7836        let _g = crate::test_util::global_state_lock();
7837        let qs = parse_qualifier_string("om[1]");
7838        assert_eq!(qs.sorts.len(), 1);
7839        assert_eq!(qs.sorts[0] & GS_MTIME, GS_MTIME);
7840        assert_eq!(qs.first, Some(1));
7841    }
7842
7843    // ═══════════════════════════════════════════════════════════════════
7844    // tokenize / remnulargs C-parity tests — pin Src/glob.c:3551 (tokenize)
7845    // and Src/glob.c:3649 (remnulargs). These two functions form the
7846    // input → glob-token → output sandwich every glob pattern walks
7847    // through; regressions silently corrupt every pattern match.
7848    // Tests that capture KNOWN ZSHRS BUGS use #[ignore = "ZSHRS BUG: …"].
7849    // ═══════════════════════════════════════════════════════════════════
7850
7851    /// `tokenize("abc")` is a no-op for plain ASCII — no glob metachars.
7852    /// C glob.c:3551 — `for (; (c = *s); s++) zstrtok(...)` — only
7853    /// recognized glob bytes get replaced with their token form.
7854    #[test]
7855    fn tokenize_pure_ascii_unchanged() {
7856        let _g = crate::test_util::global_state_lock();
7857        let mut s = String::from("abc");
7858        tokenize(&mut s);
7859        assert_eq!(s, "abc");
7860    }
7861
7862    /// `tokenize("")` on empty input returns empty unchanged.
7863    #[test]
7864    fn tokenize_empty_unchanged() {
7865        let _g = crate::test_util::global_state_lock();
7866        let mut s = String::new();
7867        tokenize(&mut s);
7868        assert_eq!(s, "");
7869    }
7870
7871    /// `remnulargs("abc")` is a no-op for pure ASCII (no inull bytes).
7872    /// C glob.c:3649-3680 — only Snull/Dnull/Bnull/Bnullkeep/Nularg
7873    /// are stripped or transformed.
7874    #[test]
7875    fn remnulargs_pure_ascii_unchanged() {
7876        let _g = crate::test_util::global_state_lock();
7877        let mut s = String::from("hello");
7878        remnulargs(&mut s);
7879        assert_eq!(s, "hello");
7880    }
7881
7882    /// `remnulargs("")` returns empty unchanged (c:3653 early exit).
7883    #[test]
7884    fn remnulargs_empty_returns_empty() {
7885        let _g = crate::test_util::global_state_lock();
7886        let mut s = String::new();
7887        remnulargs(&mut s);
7888        assert_eq!(s, "");
7889    }
7890
7891    /// `remnulargs` strips Snull (single-quote scope marker).
7892    /// C glob.c:3656 — inull predicate includes Snull (0x84).
7893    /// Input `"a\u{84}b"` → `"ab"` (strip the Snull byte).
7894    #[test]
7895    fn remnulargs_strips_snull_marker() {
7896        let _g = crate::test_util::global_state_lock();
7897        let mut s = format!("a{}b", crate::ported::zsh_h::Snull);
7898        remnulargs(&mut s);
7899        assert_eq!(s, "ab", "Snull byte should be stripped");
7900    }
7901
7902    /// `remnulargs` strips Dnull (double-quote scope marker).
7903    #[test]
7904    fn remnulargs_strips_dnull_marker() {
7905        let _g = crate::test_util::global_state_lock();
7906        let mut s = format!("a{}b", crate::ported::zsh_h::Dnull);
7907        remnulargs(&mut s);
7908        assert_eq!(s, "ab", "Dnull byte should be stripped");
7909    }
7910
7911    /// `remnulargs` strips Bnull (backslash-quoted marker — c:3658
7912    /// `inull(c)` includes Bnull). The non-Bnullkeep variant of the
7913    /// active-backslash marker.
7914    #[test]
7915    fn remnulargs_strips_bnull_marker() {
7916        let _g = crate::test_util::global_state_lock();
7917        let mut s = format!("a{}b", crate::ported::zsh_h::Bnull);
7918        remnulargs(&mut s);
7919        assert_eq!(s, "ab", "Bnull byte should be stripped");
7920    }
7921
7922    /// `remnulargs` on a single Nularg returns the Nularg sentinel
7923    /// preserved. C glob.c:3690-3692 — if post-strip output is empty
7924    /// AND original had any inull, emit Nularg as the empty-arg marker.
7925    #[test]
7926    fn remnulargs_empty_post_strip_emits_nularg_sentinel() {
7927        let _g = crate::test_util::global_state_lock();
7928        let mut s = format!("{}", crate::ported::zsh_h::Snull);
7929        remnulargs(&mut s);
7930        assert_eq!(
7931            s,
7932            format!("{}", crate::ported::zsh_h::Nularg),
7933            "all-inull input should collapse to single Nularg marker"
7934        );
7935    }
7936
7937    /// `remnulargs` on the Bnullkeep marker: c:3669 — Bnullkeep
7938    /// either (a) preserved as-is when it appears BEFORE any other
7939    /// inull (scan phase), or (b) transformed to literal `\` (copy
7940    /// phase). Pin the simpler case: a lone Bnullkeep is stripped.
7941    #[test]
7942    fn remnulargs_lone_bnullkeep_stripped() {
7943        let _g = crate::test_util::global_state_lock();
7944        let mut s = format!("a{}b", crate::ported::zsh_h::Bnullkeep);
7945        remnulargs(&mut s);
7946        // C behavior depends on whether the byte is in scan or copy
7947        // phase. Faithful port should give either "ab" (scan strip)
7948        // or "a\\b" (copy phase materialize). Either is acceptable;
7949        // pin that it's NOT the raw input.
7950        assert!(
7951            s == "ab" || s == "a\\b",
7952            "Bnullkeep should be stripped or materialized; got {s:?}"
7953        );
7954    }
7955
7956    // ═══════════════════════════════════════════════════════════════════
7957    // Additional C-parity tests for Src/glob.c file_type + hasbraces.
7958    // ═══════════════════════════════════════════════════════════════════
7959
7960    /// c:2018 — file_type for regular file with no exec bits → ' '.
7961    #[test]
7962    #[cfg(unix)]
7963    fn file_type_regular_non_exec_returns_space() {
7964        let r = file_type(libc::S_IFREG as u32 | 0o644);
7965        assert_eq!(r, ' ');
7966    }
7967
7968    /// c:2018 — file_type for regular file WITH exec bit → '*'.
7969    #[test]
7970    #[cfg(unix)]
7971    fn file_type_regular_exec_returns_star() {
7972        let r = file_type(libc::S_IFREG as u32 | 0o755);
7973        assert_eq!(r, '*');
7974    }
7975
7976    /// c:2018 — file_type for directory → '/'.
7977    #[test]
7978    #[cfg(unix)]
7979    fn file_type_directory_returns_slash() {
7980        let r = file_type(libc::S_IFDIR as u32 | 0o755);
7981        assert_eq!(r, '/');
7982    }
7983
7984    /// c:2018 — file_type for symlink → '@'.
7985    #[test]
7986    #[cfg(unix)]
7987    fn file_type_symlink_returns_at() {
7988        let r = file_type(libc::S_IFLNK as u32 | 0o777);
7989        assert_eq!(r, '@');
7990    }
7991
7992    /// c:2018 — file_type for FIFO → '|'.
7993    #[test]
7994    #[cfg(unix)]
7995    fn file_type_fifo_returns_pipe() {
7996        let r = file_type(libc::S_IFIFO as u32 | 0o644);
7997        assert_eq!(r, '|');
7998    }
7999
8000    /// c:2018 — file_type for socket → '='.
8001    #[test]
8002    #[cfg(unix)]
8003    fn file_type_socket_returns_equals() {
8004        let r = file_type(libc::S_IFSOCK as u32 | 0o777);
8005        assert_eq!(r, '=');
8006    }
8007
8008    /// c:2018 — file_type for char device → '%'.
8009    #[test]
8010    #[cfg(unix)]
8011    fn file_type_char_device_returns_percent() {
8012        let r = file_type(libc::S_IFCHR as u32 | 0o644);
8013        assert_eq!(r, '%');
8014    }
8015
8016    /// c:2018 — file_type for block device → '#'.
8017    #[test]
8018    #[cfg(unix)]
8019    fn file_type_block_device_returns_hash() {
8020        let r = file_type(libc::S_IFBLK as u32 | 0o644);
8021        assert_eq!(r, '#');
8022    }
8023
8024    /// c:2018 — file_type for unknown mode → '?'.
8025    #[test]
8026    fn file_type_unknown_mode_returns_question() {
8027        // Bare 0 mode (none of the S_IF* bits set) → '?'.
8028        assert_eq!(file_type(0), '?');
8029    }
8030
8031    /// c:2018 — file_type is deterministic.
8032    #[test]
8033    #[cfg(unix)]
8034    fn file_type_is_deterministic() {
8035        for mode in [
8036            libc::S_IFREG as u32 | 0o644,
8037            libc::S_IFDIR as u32,
8038            libc::S_IFLNK as u32,
8039            0u32,
8040        ] {
8041            let first = file_type(mode);
8042            for _ in 0..5 {
8043                assert_eq!(file_type(mode), first);
8044            }
8045        }
8046    }
8047
8048    /// c:2042 — hasbraces on plain string returns false.
8049    #[test]
8050    fn hasbraces_no_braces_returns_false() {
8051        let _g = crate::test_util::global_state_lock();
8052        assert!(!hasbraces("hello", false));
8053        assert!(!hasbraces("", false));
8054    }
8055
8056    /// c:2042 — hasbraces on plain ASCII `{a,b}` returns FALSE because
8057    /// the C port checks TOKEN bytes (Inbrace=0x8f / Outbrace=0x90 /
8058    /// Comma=0x9a) not literal ASCII. The lexer pre-tokenizes input
8059    /// before hasbraces runs in the real pipeline. Pin the actual
8060    /// behavior so a regen that adds ASCII fast-path is caught.
8061    #[test]
8062    fn hasbraces_plain_ascii_braces_returns_false_uses_tokens() {
8063        let _g = crate::test_util::global_state_lock();
8064        assert!(
8065            !hasbraces("{a,b}", false),
8066            "plain ASCII not tokenized → false; lexer pre-tokenizes in real pipeline"
8067        );
8068    }
8069
8070    /// c:2042 — hasbraces on tokenized brace expansion returns true.
8071    /// Construct the TOKEN form: Inbrace + 'a' + Comma + 'b' + Outbrace.
8072    #[test]
8073    fn hasbraces_tokenized_brace_expansion_returns_true() {
8074        let _g = crate::test_util::global_state_lock();
8075        let tokenized = format!(
8076            "{}a{}b{}",
8077            '\u{8f}', // Inbrace token
8078            '\u{9a}', // Comma token
8079            '\u{90}'  // Outbrace token
8080        );
8081        assert!(
8082            hasbraces(&tokenized, false),
8083            "Inbrace+a+Comma+b+Outbrace must trigger brace expansion"
8084        );
8085    }
8086
8087    /// c:2042 — hasbraces on unclosed `{` returns false (no matching pair).
8088    #[test]
8089    fn hasbraces_unclosed_returns_false() {
8090        let _g = crate::test_util::global_state_lock();
8091        assert!(!hasbraces("{", false));
8092        assert!(!hasbraces("{abc", false));
8093    }
8094
8095    // ═══════════════════════════════════════════════════════════════════
8096    // Additional C-parity tests for Src/glob.c
8097    // c:950 qgetnum / c:994 qgetmodespec / c:1250 glob_exec_string /
8098    // c:1351 checkglobqual / c:1539 hasbraces / c:1617 bracechardots /
8099    // c:1802 matchpat / c:1899 getmatch / c:1505 file_type
8100    // ═══════════════════════════════════════════════════════════════════
8101
8102    /// c:950 — `qgetnum("")` empty returns None.
8103    #[test]
8104    fn qgetnum_empty_returns_none() {
8105        assert!(qgetnum("").is_none(), "empty → None");
8106    }
8107
8108    /// c:950 — `qgetnum` returns Option<(i64, &str)> (type pin).
8109    #[test]
8110    fn qgetnum_returns_option_i64_str_tuple_type() {
8111        let _: Option<(i64, &str)> = qgetnum("42");
8112    }
8113
8114    /// c:950 — `qgetnum("123")` returns Some((123, "")) (canonical decimal).
8115    #[test]
8116    fn qgetnum_canonical_decimal_parses() {
8117        let r = qgetnum("123");
8118        assert!(r.is_some(), "valid digits → Some");
8119        let (n, rest) = r.unwrap();
8120        assert_eq!(n, 123);
8121        assert_eq!(rest, "");
8122    }
8123
8124    /// c:994 — `qgetmodespec("")` empty returns None.
8125    #[test]
8126    fn qgetmodespec_empty_returns_none() {
8127        assert!(qgetmodespec("").is_none());
8128    }
8129
8130    /// c:1539 — `hasbraces("")` empty returns false.
8131    #[test]
8132    fn hasbraces_empty_returns_false() {
8133        let _g = crate::test_util::global_state_lock();
8134        assert!(!hasbraces("", false));
8135    }
8136
8137    /// c:1539 — `hasbraces` is pure (no side effects).
8138    #[test]
8139    fn hasbraces_is_pure() {
8140        let _g = crate::test_util::global_state_lock();
8141        for s in ["", "abc", "{", "{a,b}", "no braces"] {
8142            let first = hasbraces(s, false);
8143            for _ in 0..3 {
8144                assert_eq!(
8145                    hasbraces(s, false),
8146                    first,
8147                    "hasbraces({:?}, false) must be pure",
8148                    s
8149                );
8150            }
8151        }
8152    }
8153
8154    /// c:1505 — `file_type(0)` returns char (compile-time type pin).
8155    #[test]
8156    fn file_type_returns_char_type() {
8157        let _: char = file_type(0);
8158    }
8159
8160    /// c:1505 — `file_type` is pure for arbitrary mode.
8161    #[test]
8162    fn file_type_is_pure() {
8163        for m in [0u32, 0o100000, 0o040000, 0o120000, 0o140000, 0o160000] {
8164            let first = file_type(m);
8165            for _ in 0..3 {
8166                assert_eq!(file_type(m), first, "file_type({:o}) must be pure", m);
8167            }
8168        }
8169    }
8170
8171    /// c:1802 — `matchpat("", "", false, false)` returns bool (type pin).
8172    #[test]
8173    fn matchpat_returns_bool_type() {
8174        let _g = crate::test_util::global_state_lock();
8175        let _: bool = matchpat("", "", false, false);
8176    }
8177
8178    /// c:1899 — `getmatch("", "", 0, 0, None)` returns String (type pin).
8179    #[test]
8180    fn getmatch_returns_string_type() {
8181        let _g = crate::test_util::global_state_lock();
8182        let _: String = getmatch("", "", 0, 0, None);
8183    }
8184
8185    /// c:1862 — `compgetmatch("")` empty returns Option<(String, i32)>.
8186    #[test]
8187    fn compgetmatch_returns_option_tuple_type() {
8188        let _g = crate::test_util::global_state_lock();
8189        let _: Option<(String, i32)> = compgetmatch("");
8190    }
8191}