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 = {
3227        // c:Src/glob.c:3919 — `execode(prog, 1, 0, "globqual");`. execode
3228        // (c:Src/exec.c:1245-1266) APPENDS its context for the duration, so
3229        // code inside an `(e:'…':)` / `(+cmd)` glob qualifier sees
3230        // `cmdarg:globqual`. Popped on every return path. Bug #1069.
3231        let sync_eval_ctx = |stack: &[String]| {
3232            let joined = stack.join(":");
3233            if let Ok(mut tab) = crate::ported::params::paramtab().write() {
3234                if let Some(pm) = tab.get_mut("zsh_eval_context") {
3235                    pm.u_arr = Some(stack.to_vec());
3236                    pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
3237                }
3238                if let Some(pm) = tab.get_mut("ZSH_EVAL_CONTEXT") {
3239                    pm.u_str = Some(joined);
3240                    pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
3241                }
3242            }
3243        };
3244        if let Ok(mut ctx) = crate::ported::exec::zsh_eval_context.lock() {
3245            ctx.push("globqual".to_string());
3246            sync_eval_ctx(&ctx);
3247        }
3248        struct GlobQualCtxGuard<F: Fn(&[String])>(F);
3249        impl<F: Fn(&[String])> Drop for GlobQualCtxGuard<F> {
3250            fn drop(&mut self) {
3251                if let Ok(mut ctx) = crate::ported::exec::zsh_eval_context.lock() {
3252                    ctx.pop();
3253                    (self.0)(&ctx);
3254                }
3255            }
3256        }
3257        let _ctx_guard = GlobQualCtxGuard(sync_eval_ctx);
3258        crate::ported::exec::execute_script(expr).unwrap_or(1) // c:3919
3259    };
3260    let ret = LASTVAL.load(Ordering::Relaxed); // c:3921 ret = lastval
3261    let _ = rc;
3262    // c:3924 — `errflag = ef | (errflag & ERRFLAG_INT);`. Restore
3263    // pre-call errflag plus any interrupt bit set during eval.
3264    let post_errflag = errflag.load(Ordering::Relaxed);
3265    errflag.store(
3266        saved_errflag | (post_errflag & ERRFLAG_INT),
3267        Ordering::Relaxed,
3268    ); // c:3924
3269       // c:3925 — `lastval = lv;`. Restore pre-call lastval.
3270    LASTVAL.store(saved_lastval, Ordering::Relaxed); // c:3925
3271                                                     // c:3927-3937 — `inserts = getaparam("reply") || gethparam("reply")`,
3272                                                     // else the `reply`/`REPLY` SCALAR as a one-element list. The eval can
3273                                                     // thus REPLACE the matched name with zero-or-more names (`reply=(…)`)
3274                                                     // or rename it (`REPLY=…`). REPLY was seeded to `filename` at c:3916,
3275                                                     // so this is always Some — at minimum the (possibly modified) REPLY.
3276    let inserts: Vec<String> = match crate::ported::params::getaparam("reply") {
3277        Some(arr) => arr, // c:3927
3278        None => match crate::ported::params::getsparam("reply") // c:3931
3279            .or_else(|| crate::ported::params::getsparam("REPLY"))
3280        {
3281            Some(scalar) => vec![scalar], // c:3934-3936
3282            None => Vec::new(),
3283        },
3284    };
3285    *INSERTS.lock().unwrap() = Some(inserts);
3286    // c:3941 — `return !ret;` — qualifier passes iff lastval is 0.
3287    i32::from(ret == 0)
3288}
3289
3290/// Port of `qualnonemptydir(char *name, struct stat *buf, UNUSED(off_t days), UNUSED(char *str))` from Src/glob.c:3948.
3291/// C: opendir(name) and check if any non-`.`/`..` entries exist.
3292pub fn qualnonemptydir(name: &str, buf: &libc::stat, days: i64, str: &str) -> i32 {
3293    // c:3948
3294    // c:3948 — `if (!S_ISDIR(buf->st_mode)) return 0;`
3295    if (buf.st_mode as u32 & libc::S_IFMT as u32) != libc::S_IFDIR as u32 {
3296        // c:3950
3297        return 0;
3298    }
3299    // c:3953-3964 — opendir + readdir loop, skip "." and ".." entries.
3300    match fs::read_dir(name) {
3301        Ok(entries) => entries.filter_map(|e| e.ok()).any(|e| {
3302            let n = e.file_name();
3303            let s = n.to_string_lossy();
3304            s != "." && s != ".."
3305        }) as i32,
3306        Err(_) => 0,
3307    }
3308}
3309
3310// =====================================================================
3311// Thread-local glob option snapshot — race fix.
3312//
3313// `Src/glob.c` reads `isset(NULLGLOB)` / `isset(BAREGLOBQUAL)` / etc.
3314// inline at each callsite during a glob. In zsh those reads hit the
3315// thread-local C `opts[]` array (zsh is single-threaded). In zshrs
3316// they hit `OPTS_LIVE` — a process-wide `RwLock<HashMap>`.
3317//
3318// Embedders that snapshot/restore options around individual glob
3319// invocations on multiple threads (e.g. stryke's `StrykeGlobOptsGuard`
3320// + `glob_par` rayon parallelism) race: thread A sets bareglobqual=1,
3321// calls glob; while A is mid-parse, thread B's matching guard restores
3322// bareglobqual=0; A's qualifier-parse step sees bareglobqual=0 and
3323// drops the trailing `(N)` as a literal substring, producing false
3324// matches.
3325//
3326// Fix: snapshot every glob-relevant option ONCE at glob() entry into
3327// a thread-local cache, then read all isset() inside glob from the
3328// snapshot. The snapshot is dropped on glob exit via an RAII guard.
3329// Each thread's in-flight glob() sees a coherent set of options
3330// regardless of concurrent setopt/unsetopt on other threads.
3331// =====================================================================
3332
3333/// Snapshot of glob-relevant zsh options taken at glob entry. Holds
3334/// the live `isset(...)` value for each option that the glob engine
3335/// or its helpers (`parse_qualifiers`, `scanner`,
3336/// `sort_matches`, `glob_emit_path`) consult.
3337#[derive(Debug, Clone, Copy, Default)]
3338pub struct GlobOptSnapshot {
3339    pub bareglobqual: bool,
3340    pub braceccl: bool,
3341    pub caseglob: bool,
3342    pub extendedglob: bool,
3343    pub globdots: bool,
3344    pub globstarshort: bool,
3345    pub listtypes: bool,
3346    pub markdirs: bool,
3347    pub nullglob: bool,
3348    pub numericglobsort: bool,
3349}
3350
3351/// RAII guard that drops the thread-local snapshot on scope exit.
3352/// Constructed by `enter_glob_scope()`. Nested glob calls on the
3353/// same thread reuse the outer snapshot (the guard's `populated`
3354/// field tracks whether we installed or just observed).
3355pub struct GlobOptsGuard {
3356    populated: bool,
3357}
3358
3359/// Sort specifier flags
3360// `GlobSort` / `SortOrder` / `SortSpec` deleted — C uses bit-flag
3361// `int tp` in `struct globsort { int tp; char *exec; }` (Src/glob.c:155):
3362//   tp & ~GS_DESC selects the sort key (GS_NAME/GS_DEPTH/GS_EXEC/…),
3363//   tp & GS_DESC reverses direction (`O` vs `o` qualifier),
3364//   tp << GS_SHIFT carries the follow-link variants.
3365// All those bits already exist as i32 constants at glob.rs:33+
3366// (GS_NAME / GS_DEPTH / GS_SIZE / … / GS_DESC / GS_NONE / GS__SIZE /
3367// …). The enum + Ascending/Descending + struct triple-wrapper was a
3368// Rust-only convenience with no C counterpart; callers now operate
3369// on the raw i32 the same way `gmatchcmp()` at glob.c:936 does.
3370
3371// `TimeUnit` / `SizeUnit` / `RangeOp` enums deleted — Rust-only
3372// wrappers around constants/chars that exist as raw values in C:
3373//   `TimeUnit::Seconds` → TT_SECONDS i32 (glob.c:126 → glob.rs:99)
3374//   `TimeUnit::Minutes` → TT_MINS (c:123)
3375//   `TimeUnit::Hours`   → TT_HOURS (c:122)
3376//   `TimeUnit::Days`    → TT_DAYS (c:121)
3377//   `TimeUnit::Weeks`   → TT_WEEKS (c:124)
3378//   `TimeUnit::Months`  → TT_MONTHS (c:125)
3379//   `SizeUnit::Bytes`   → TT_BYTES (c:128)
3380//   `SizeUnit::PosixBlocks` → TT_POSIX_BLOCKS (c:129)
3381//   `SizeUnit::Kilobytes`   → TT_KILOBYTES (c:130)
3382//   `SizeUnit::Megabytes`   → TT_MEGABYTES (c:131)
3383//   `SizeUnit::Gigabytes`   → TT_GIGABYTES (c:132)
3384//   `SizeUnit::Terabytes`   → TT_TERABYTES (c:133)
3385//   `RangeOp::Less`/`Equal`/`Greater` → raw chars `<` `=` `>`
3386//      (zsh's qgetnum at glob.c:827 returns the raw operator char
3387//      and the qualifier handlers switch on it inline).
3388
3389/// Port of `TestMatchFunc` — the `int (*)(char *, struct stat *, off_t,
3390/// char *)` function-pointer type held by `struct qual.func` (Src/glob.c).
3391/// Every glob-qualifier test (qualuid, qualisreg, qualiscom, qualsize, …)
3392/// has this signature so `insert()` can call them uniformly through the
3393/// `func` slot. `name` is the metafied path, `buf` the stat, `data` the
3394/// qualifier argument (uid, mode bits, day count, …), `sdata` the
3395/// expression text (only `qualsheval` uses it; others ignore it).
3396pub type TestMatchFunc = fn(name: &str, buf: &libc::stat, data: i64, sdata: &str) -> i32;
3397
3398/// Port of `struct qual` from `Src/glob.c:138`. A node in the linked list
3399/// of glob qualifiers parsed from a `(…)` suffix. `insert()` (glob.c:346)
3400/// walks this list per candidate file, calling `func` on each node and
3401/// accepting/rejecting the file by `sense`. The terminating node carries
3402/// `func == None` (C tests `qn && qn->func` to stop).
3403///
3404/// REPRESENTATION: C's `next`/`or` are `struct qual *` pointers into a
3405/// graph that `parsepat` builds and relinks through aliased cursors (the
3406/// OR-distribution at glob.c:1797-1820 aliases one node from several
3407/// chains at once). Rust single-ownership `Box` cannot express that, so
3408/// the port uses an arena ([`QualArena`]): nodes live in a `Vec<qual>`
3409/// and `next`/`or` are `Option<usize>` indices. C pointer assignments map
3410/// to index assignments, so the aliased-cursor algorithm translates
3411/// almost verbatim.
3412#[allow(non_camel_case_types)]
3413#[derive(Clone, Debug)]
3414pub struct qual {
3415    /// c:139 — `struct qual *next;` next qualifier in the AND-chain.
3416    pub next: Option<usize>,
3417    /// c:140 — `struct qual *or;` head of an alternative OR-chain.
3418    pub or: Option<usize>,
3419    /// c:141 — `TestMatchFunc func;` the test to call; `None` terminates.
3420    pub func: Option<TestMatchFunc>,
3421    /// c:142 — `off_t data;` argument passed to `func`.
3422    pub data: i64,
3423    /// c:143 — `int sense;` bit0: 0=assert / 1=negate; bit1: follow links.
3424    pub sense: i32,
3425    /// c:144 — `int amc;` which timestamp to test (0=a, 1=m, 2=c).
3426    pub amc: i32,
3427    /// c:145 — `int range;` test `<` / `>` / `=` (signum of comparison).
3428    pub range: i32,
3429    /// c:146 — `int units;` multiplier for time (TT_DAYS…) or size.
3430    pub units: i32,
3431    /// c:147 — `char *sdata;` expression to eval (currently qualsheval only).
3432    pub sdata: Option<String>,
3433}
3434
3435impl qual {
3436    /// A fresh node with the C zero-initialised defaults (`hcalloc`).
3437    pub fn new() -> qual {
3438        qual {
3439            next: None,
3440            or: None,
3441            func: None,
3442            data: 0,
3443            sense: 0,
3444            amc: 0,
3445            range: 0,
3446            units: 0,
3447            sdata: None,
3448        }
3449    }
3450}
3451
3452impl Default for qual {
3453    fn default() -> qual {
3454        qual::new()
3455    }
3456}
3457
3458/// Arena backing the [`qual`] graph. C allocates each `struct qual` with
3459/// `hcalloc` and links them by pointer; the Rust port allocates them in
3460/// this `Vec` and links them by index. `alloc()` mirrors a single
3461/// `hcalloc(sizeof *qn)` and returns the new node's index; `head` holds
3462/// the index of `quals` (the list `insert()` walks), `None` when empty.
3463#[derive(Clone, Default, Debug)]
3464pub struct QualArena {
3465    pub nodes: Vec<qual>,
3466    pub head: Option<usize>,
3467}
3468
3469impl QualArena {
3470    pub fn new() -> QualArena {
3471        QualArena {
3472            nodes: Vec::new(),
3473            head: None,
3474        }
3475    }
3476
3477    /// c:`qn = (struct qual *)hcalloc(sizeof *qn);` — allocate a fresh
3478    /// zeroed node and return its arena index.
3479    pub fn alloc(&mut self) -> usize {
3480        self.nodes.push(qual::new());
3481        self.nodes.len() - 1
3482    }
3483}
3484
3485/// A glob qualifier function
3486#[derive(Debug, Clone)]
3487/// One glob qualifier — Rust-extension sum type. C uses a linked
3488/// list of `struct qual` (`Src/zsh.h:140-152`) with function-pointer
3489/// `func` per node; each variant here maps to one of C's `q*` test
3490/// ported (`qisreg`, `qisdir`, `qowner`, `qtime`, ...) at
3491/// `Src/glob.c:1080-1340`. The full `struct qual` port + per-test fn
3492/// dispatch lives in a later phase; this enum keeps the parsed-form
3493/// the per-match filter inside `scanner()` (line 500) needs.
3494#[allow(non_camel_case_types)]
3495pub enum qualifier {
3496    /// File type qualifiers
3497    IsRegular,
3498    IsDirectory,
3499    IsSymlink,
3500    IsSocket,
3501    IsFifo,
3502    IsBlockDev,
3503    IsCharDev,
3504    IsDevice,
3505    IsExecutable,
3506
3507    /// Permission qualifiers
3508    Readable,
3509    Writable,
3510    Executable,
3511    WorldReadable,
3512    WorldWritable,
3513    WorldExecutable,
3514    GroupReadable,
3515    GroupWritable,
3516    GroupExecutable,
3517    Setuid,
3518    Setgid,
3519    Sticky,
3520
3521    /// Ownership qualifiers
3522    OwnedByEuid,
3523    OwnedByEgid,
3524    OwnedByUid(u32),
3525    OwnedByGid(u32),
3526
3527    /// Numeric qualifiers with range. `unit` is a `TT_*` i32
3528    /// (glob.rs:99-113 / glob.c:121-133); `op` is the raw range
3529    /// operator char (`<`, `=`, `>`) — mirrors C's qgetnum which
3530    /// returns the operator char and the handler switches inline.
3531    Size {
3532        value: u64,
3533        unit: i32,
3534        op: char,
3535    },
3536    Links {
3537        value: u64,
3538        op: char,
3539    },
3540    Atime {
3541        value: i64,
3542        unit: i32,
3543        op: char,
3544    },
3545    Mtime {
3546        value: i64,
3547        unit: i32,
3548        op: char,
3549    },
3550    Ctime {
3551        value: i64,
3552        unit: i32,
3553        op: char,
3554    },
3555
3556    /// Mode specification
3557    Mode {
3558        yes: u32,
3559        no: u32,
3560    },
3561
3562    /// Device number
3563    Device(u64),
3564
3565    /// Non-empty directory
3566    NonEmptyDir,
3567
3568    /// Shell evaluation
3569    Eval(String),
3570
3571    /// `^` — toggle the running match sense for SUBSEQUENT qualifiers in
3572    /// this list. C uses a running `sense ^= 1` (Src/glob.c:1346) stored
3573    /// per `struct qual.sense`; the enum can't carry per-node sense, so a
3574    /// marker in the list reproduces it: `check_qualifier_list` flips a
3575    /// running sense bit on each marker and XORs it into later tests.
3576    /// Fixes `*(/^@)` (dir AND not-symlink), `*(.^x)`, etc.
3577    ToggleSense,
3578}
3579
3580// Misnamed `gmatchcmp(&str, &str)` deleted — was a Rust-only
3581// locale-aware string compare claiming to be the C qsort
3582// comparator. The real C `gmatchcmp(Gmatch, Gmatch)` at glob.c:936
3583// is now ported as `gmatchcmp(&GlobMatch, &GlobMatch, &[i32], bool)`
3584// below. The string-compare case the old name claimed routes
3585// through canonical `zstrcmp` (sort.c:191).
3586
3587// `GlobOptions` struct deleted — Rust-only Bag-of-options with no
3588// C counterpart. C reads each option directly from the global
3589// `opts[]` array via `isset(NULL_GLOB)` / `isset(EXTENDED_GLOB)`
3590// / etc. (Src/options.c). The Rust port uses the canonical
3591// `opt_state_get(name) -> Option<bool>`
3592// which reads from the same global store. Inlined at each
3593// callsite as `opt_state_get("name").unwrap_or(default)`.
3594
3595/// Parsed glob qualifier set
3596#[derive(Debug, Clone, Default)]
3597/// Compiled qualifier list for one glob.
3598/// Mirrors the `struct qual *` linked list `parsepat()`
3599/// (Src/glob.c:791) builds — every `(qual)` after a glob pattern
3600/// adds to it.
3601#[allow(non_camel_case_types)]
3602pub struct qualifier_set {
3603    // c:138
3604    pub qualifiers: Vec<qualifier>,
3605    pub alternatives: Vec<Vec<qualifier>>,
3606    pub follow_links: bool,
3607    /// Packed sort-spec flags, one per `o`/`O` qualifier in the pattern.
3608    /// Each entry is the C `struct globsort.tp` field — `GS_NAME` /
3609    /// `GS_DEPTH` / `GS_EXEC` / `GS_SIZE` / `GS_ATIME` / `GS_MTIME` /
3610    /// `GS_CTIME` / `GS_LINKS` (or their `<< GS_SHIFT` follow-link
3611    /// variants), OR'd with `GS_DESC` for reverse direction.
3612    pub sorts: Vec<i32>, // c:155 struct globsort.tp[]
3613    /// c:74 `struct globsort.exec` — the eval code for each `GS_EXEC`
3614    /// (`oe:…:` / `o+…`) sort spec, indexed by the `idx` packed into the
3615    /// sort entry's high bits (`GS_EXEC | (idx << 16)`). Evaluated per
3616    /// match with `$REPLY` = the match name; the resulting `$REPLY` is
3617    /// the sort key (the original name is still emitted).
3618    pub sort_exec: Vec<String>, // c:74 globsort.exec
3619    /// The faithful glob.c `struct qual` representation of `alternatives`,
3620    /// built at parse end (additive — the enum is kept for now). AND-chain
3621    /// via `next`, alternatives via `or`, per-node `sense`/`range`/`amc`/
3622    /// `units`/`sdata`. The convergence target: `check_qualifiers` walks
3623    /// THIS via `insert()` semantics (glob.c:392-419), and the enum is
3624    /// eventually deleted.
3625    pub quals: QualArena, // c:206 curglobdata.gd_quals
3626    pub first: Option<i32>,
3627    pub last: Option<i32>,
3628    pub colon_mods: Option<String>,
3629    pub pre_words: Vec<String>,
3630    pub post_words: Vec<String>,
3631    /// `(M)` qualifier — append `/` to directory entries in output.
3632    /// Direct port of zsh/Src/glob.c:1557-1561 (`case 'M'`):
3633    ///   `gf_markdirs = !(sense & 1)` — set when the qualifier appears
3634    ///   without a `^` toggle. Stored per-qualifier-set rather than per
3635    ///   GlobOptions so a single glob call's qualifier picks it up.
3636    pub mark_dirs: bool,
3637    /// `(T)` qualifier — append type-char (ls -F style) to every entry.
3638    /// Direct port of zsh/Src/glob.c:1562-1566 (`case 'T'`).
3639    pub list_types: bool,
3640    /// `(N)` qualifier — per-glob nullglob: empty result on no-match,
3641    /// no error. Direct port of zsh/Src/glob.c:1567-1569 (`case 'N'`):
3642    ///   `gf_nullglob = !(sense & 1)` — set when `(N)` appears without
3643    ///   the `^` toggle.
3644    pub nullglob: bool,
3645    /// `(D)` qualifier — include dotfiles in the glob result (override
3646    /// the global `globdots`/`dotglob` option for this glob alone).
3647    /// Direct port of zsh/Src/glob.c case 'D' which sets
3648    /// `gf_glob.dots = 1` for the duration of this glob expansion.
3649    pub globdots: bool,
3650    /// `(YN)` qualifier — short-circuit: limit number of matches to
3651    /// at most N. Direct port of zsh/Src/glob.c:1579-1595 (`case 'Y'`)
3652    /// which sets the C `shortcircuit` int. The matcher should stop
3653    /// after collecting N entries; the Rust port truncates after the
3654    /// match walk completes (the optimization gain is the same in
3655    /// the typical short-glob case). Bug #41 in docs/BUGS.md.
3656    pub short_circuit: Option<i32>,
3657    /// `(n)` qualifier — per-glob numeric sort. Direct port of
3658    /// zsh/Src/glob.c:1575-1577 (`case 'n': gf_numsort = !(sense & 1)`).
3659    /// Distinct from the `o`/`O` sort KEY `n` (= GS_NAME, lexical):
3660    /// standalone `(n)` makes the name comparison NUMERIC (so `*(n)`
3661    /// orders `f2` before `f10`), OR'd with the global NUMERIC_GLOB_SORT
3662    /// option at sort time (glob.c:947/983 `gf_numsort ?
3663    /// SORTIT_NUMERICALLY : 0`). `None` = not specified (fall back to
3664    /// the global option); `Some(true)` = `(n)`; `Some(false)` = `(^n)`
3665    /// which OVERRIDES the global option to force lexical.
3666    pub numsort: Option<bool>,
3667}
3668
3669/// Enter a glob scope: snapshot options into TLS if no outer scope
3670/// is active, returning a guard that clears the snapshot on Drop.
3671/// Re-entrant calls (nested globdata_glob via brace expansion etc.)
3672/// return a no-op guard that observes the existing snapshot.
3673pub fn enter_glob_scope() -> GlobOptsGuard {
3674    let already = GLOB_OPTS_TLS.with_borrow(|g| g.is_some());
3675    if already {
3676        return GlobOptsGuard { populated: false };
3677    }
3678    let snap = GlobOptSnapshot::capture();
3679    GLOB_OPTS_TLS.with_borrow_mut(|g| *g = Some(snap));
3680    GlobOptsGuard { populated: true }
3681}
3682
3683/// Read a glob-relevant option through the TLS snapshot when one is
3684/// active; fall back to the live `isset()` otherwise. Use this in
3685/// any glob-engine helper that previously read `isset(OPT)` for one
3686/// of the snapshotted options.
3687#[inline]
3688pub fn glob_isset(opt: i32) -> bool {
3689    GLOB_OPTS_TLS.with_borrow(|g| match g {
3690        Some(snap) => match opt {
3691            x if x == BAREGLOBQUAL => snap.bareglobqual,
3692            x if x == BRACECCL => snap.braceccl,
3693            x if x == CASEGLOB => snap.caseglob,
3694            x if x == EXTENDEDGLOB => snap.extendedglob,
3695            x if x == GLOBDOTS => snap.globdots,
3696            x if x == GLOBSTARSHORT => snap.globstarshort,
3697            x if x == LISTTYPES => snap.listtypes,
3698            x if x == MARKDIRS => snap.markdirs,
3699            x if x == NULLGLOB => snap.nullglob,
3700            x if x == NUMERICGLOBSORT => snap.numericglobsort,
3701            _ => isset(opt),
3702        },
3703        None => isset(opt),
3704    })
3705}
3706
3707// ===========================================================
3708// `impl globdata` block above kept only for the `new()`
3709// constructor (Default-style). All scanner / parser / qualifier
3710// ported below are top-level — C glob.c has them as top-level
3711// statics that mutate the file-static `curglobdata`. Each is
3712// flagged with `// RUST-ONLY` and a comment naming the closest
3713// C equivalent + the proper-port target (typically `scanner`
3714// at glob.c:500 driving `insert` at c:346).
3715// ===========================================================
3716
3717/// Main entry point: expand a glob pattern.
3718///
3719/// Closest C equivalent: `zglob` driver at glob.c:1214 calls
3720/// `parsepat` (c:791) and then `scanner` (c:500). The Rust port
3721/// here orchestrates qualifier parsing, brace expansion (which C
3722/// does separately in `xpandbraces`), then drives the faithful
3723/// `parsecomplist` (c:710) → `complist` → `scanner` (c:500) path.
3724pub fn globdata_glob(state: &mut globdata, pattern: &str) -> Vec<String> {
3725    // RUST-ONLY
3726    // Brace pre-expansion. In zsh, `xpandbraces` (zsh/Src/glob.c:2275)
3727    // runs during substitution before glob — patterns reaching glob()
3728    // are already brace-free in the production path (vm_helper handles
3729    // it). For direct programmatic callers of glob_with_options, run
3730    // the brace pass here so `GlobOptions.brace_ccl` is actually
3731    // consulted: with brace_ccl set, `{a-mnop}` expands to a..m,n,o,p
3732    // per glob.c:2424 BRACECCL block; without, only `{a,b}` lists and
3733    // `{1..5}`/`{a..e}` ranges expand. Recurse on each variant and
3734    // concatenate matches.
3735    let brace_ccl = glob_isset(BRACECCL);
3736    if hasbraces(pattern, brace_ccl) {
3737        let mut all = Vec::new();
3738        for variant in xpandbraces(pattern, brace_ccl) {
3739            all.extend(globdata_glob(state, &variant));
3740        }
3741        return all;
3742    }
3743
3744    state.matches.clear();
3745    state.pathbuf.clear();
3746    state.pathpos = 0;
3747
3748    // Parse qualifiers first so a bare-qualifier pattern like `dir(/)`
3749    // (no wildcard, just a stat-based filter) still enters the expansion
3750    // path. Without this, `haswilds("dir(/)")` returns false and the
3751    // pattern echoes back unfiltered, which defeats the whole point of
3752    // qualifiers.
3753    let (pat, quals) = parse_qualifiers(pattern);
3754    state.qualifiers = quals;
3755
3756    // c:Src/glob.c — `A~B` exclusion. zsh compiles the ENTIRE glob
3757    // (path components + `~B` exclusion + `**`) into ONE Patprog and
3758    // matches each candidate PATH against it, so the `~B` applies to the
3759    // whole path. zshrs's component scanner can't express a path-level
3760    // exclusion, so split a TOP-LEVEL `~` (extendedglob; depth 0, not in
3761    // `[...]`/`(...)`, not Bnull-escaped) off here into the main pattern
3762    // plus full-path exclusion patterns, glob the main pattern, then drop
3763    // matched paths matching any exclusion below. Without this the `~B`
3764    // (which may contain `/`) was split across path components and either
3765    // excluded everything (`~*/.git/*` → `*.txt~*` matched all) or
3766    // nothing. `~` inside one component still works the same (main globs,
3767    // the post-filter applies the exclusion).
3768    let (pat, glob_exclusions): (String, Vec<String>) =
3769        if glob_isset(EXTENDEDGLOB) && (pat.contains('~') || pat.contains('\u{98}')) {
3770            let cv: Vec<char> = pat.chars().collect();
3771            let mut parts: Vec<String> = Vec::new();
3772            let mut cur = String::new();
3773            let mut bd = 0i32; // `[...]` depth
3774            let mut pd = 0i32; // `(...)` depth
3775            let mut k = 0usize;
3776            while k < cv.len() {
3777                let c = cv[k];
3778                match c {
3779                    '\u{9f}' => {
3780                        // Bnull escape — next char is literal.
3781                        cur.push(c);
3782                        if k + 1 < cv.len() {
3783                            cur.push(cv[k + 1]);
3784                            k += 2;
3785                            continue;
3786                        }
3787                    }
3788                    '[' => {
3789                        bd += 1;
3790                        cur.push(c);
3791                    }
3792                    ']' => {
3793                        if bd > 0 {
3794                            bd -= 1;
3795                        }
3796                        cur.push(c);
3797                    }
3798                    '(' | '\u{88}' => {
3799                        pd += 1;
3800                        cur.push(c);
3801                    }
3802                    ')' | '\u{8a}' => {
3803                        if pd > 0 {
3804                            pd -= 1;
3805                        }
3806                        cur.push(c);
3807                    }
3808                    '~' | '\u{98}' if bd == 0 && pd == 0 => {
3809                        parts.push(std::mem::take(&mut cur));
3810                    }
3811                    _ => cur.push(c),
3812                }
3813                k += 1;
3814            }
3815            parts.push(cur);
3816            // Only split when there's a non-empty main pattern AND at
3817            // least one exclusion (a leading `~` is home-dir, handled by
3818            // filesub before glob — never reaches here as an exclusion).
3819            if parts.len() > 1 && !parts[0].is_empty() {
3820                let main = parts.remove(0);
3821                (main, parts)
3822            } else {
3823                // No usable split — restore the original pattern verbatim
3824                // (the `~` separators dropped during the scan).
3825                (cv.iter().collect(), Vec::new())
3826            }
3827        } else {
3828            (pat, Vec::new())
3829        };
3830
3831    // c:Src/glob.c:1843-1854 — `if (!q || errflag) { ... return; }`. When
3832    // the qualifier parser already emitted a diagnostic (e.g. "number
3833    // expected" from qgetnum at c:832) and set errflag, abort glob
3834    // expansion entirely rather than continuing to scan with a partial
3835    // qualifier set. Without this gate, `?(a)` (where `(a)` is an
3836    // invalid qualifier) error-prints "number expected" then continues
3837    // to expand `?` against the dir, emitting matches alongside the
3838    // error — bug #549.
3839    if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::SeqCst)
3840        & crate::ported::zsh_h::ERRFLAG_ERROR
3841        != 0
3842    {
3843        return Vec::new();
3844    }
3845
3846    // Now check wildcards on the qualifier-stripped pattern. A pure
3847    // literal with a qualifier (`name(.)`) still needs to enter the
3848    // scanner so the qualifier filter can run against the literal name.
3849    // haswilds scans TOKENIZED strings (c:Src/glob.c:1230 runs on the
3850    // lexer-tokenized word); glob_path receives both tokenized (zglob)
3851    // and untokenized (expand_glob fast paths) patterns, so tokenize a
3852    // local copy for the check — existing token chars pass through
3853    // zshtokenize untouched, untokenized metachars gain their tokens.
3854    let mut pat_tok = pat.clone();
3855    tokenize(&mut pat_tok); // c:Src/glob.c:3548
3856    if !haswilds(&pat_tok) && state.qualifiers.is_none() {
3857        return vec![pattern.to_string()];
3858    }
3859
3860    // c:glob() — `gd.gf_noglobdots = !isset(GLOBDOTS)` (plus the `(D)`
3861    // qualifier). parsecomplist reads this from CURGLOBDATA to compile
3862    // PAT_NOGLD into each component, which is what makes pattry reject
3863    // leading-dot files (pattern.rs:2890). Set it before parsing so the
3864    // faithful flag-driven dot filtering replaces the old manual skip.
3865    {
3866        let globdots = glob_isset(GLOBDOTS)
3867            || state
3868                .qualifiers
3869                .as_ref()
3870                .map(|qf| qf.globdots)
3871                .unwrap_or(false);
3872        let mut gd = CURGLOBDATA.lock().unwrap_or_else(|e| e.into_inner());
3873        gd.gf_noglobdots = if globdots { 0 } else { 1 };
3874    }
3875
3876    // c:parsepat (glob.c:809-820) — init pathbuf for absolute vs relative,
3877    // strip the leading `/`, then parse into a `complist` and run the
3878    // faithful `scanner`. parsecomplist consumes TOKENIZED input (it tests
3879    // for the `Star` token and hands segments to patcompile), so feed it
3880    // `pat_tok` (already tokenized above; tokenize is idempotent).
3881    state.pathbufcwd = 0; // c:812 — `DPUTS(pathbufcwd, ...)` invariant
3882    let parse_src = if let Some(rest) = pat_tok.strip_prefix('/') {
3883        // c:813-816 — absolute path.
3884        state.pathbuf.push('/');
3885        state.pathpos = 1;
3886        rest.to_string()
3887    } else {
3888        // c:817-818 — relative to pwd.
3889        pat_tok.clone()
3890    };
3891    if let Some(complist) = parsecomplist(&parse_src) {
3892        scanner(state, Some(&complist), 0, false);
3893    } else {
3894        // c:Src/glob.c:1842-1854 — `q = parsepat(str); if (!q || errflag)`.
3895        // This is the `!q` half: the pattern failed to COMPILE (e.g. an
3896        // unclosed `[` character class — patcomppiece returns -1 at
3897        // pattern.rs:1687). C then either treats the word as a literal
3898        // string (when `unset(BADPATTERN)`) or aborts with
3899        // `zerr("bad pattern: %s", ostr)`. zshrs previously dropped the
3900        // `None` silently, so `echo abc[def` fell through to the
3901        // no-matches / literal-under-NO_NOMATCH path instead of the
3902        // compile diagnostic — and BADPATTERN was ignored entirely.
3903        // (The `errflag` half — a diagnostic already emitted by the
3904        // qualifier parser — is handled by the gate at glob.rs:3770.)
3905        if !isset(crate::ported::zsh_h::BADPATTERN) {
3906            // c:1846-1848 — treat as an ordinary literal string.
3907            return vec![pattern.to_string()];
3908        }
3909        // c:1851-1852 — clear any stale error bit so zerr fires, then
3910        // emit. utils::zerr re-sets ERRFLAG_ERROR, which zglob's gate at
3911        // glob.rs:1541 reads to suppress the redundant "no matches found".
3912        errflag.fetch_and(
3913            !crate::ported::zsh_h::ERRFLAG_ERROR,
3914            std::sync::atomic::Ordering::SeqCst,
3915        );
3916        zerr(&format!("bad pattern: {}", pattern));
3917        return Vec::new();
3918    }
3919
3920    // c:Src/glob.c — apply top-level `~B` exclusions to the full matched
3921    // paths (split off above). A match is dropped if its emitted path
3922    // matches ANY exclusion pattern (`*` matches `/` here, matching zsh's
3923    // flat-string exclusion semantics).
3924    if !glob_exclusions.is_empty() {
3925        let eg = glob_isset(EXTENDEDGLOB);
3926        let cg = glob_isset(CASEGLOB);
3927        state.matches.retain(|m| {
3928            let p = glob_emit_path(&m.path);
3929            !glob_exclusions.iter().any(|ex| matchpat(ex, &p, eg, cg))
3930        });
3931    }
3932
3933    // c:1680 — populate GS_EXEC sort keys before sorting. Evaluate each
3934    // `oe:…:` / `o+…` code per match with $REPLY = the match name, and
3935    // capture the resulting $REPLY as that match's sort string (gmatchcmp
3936    // reads gmatch.sort_strings[idx]). Exec sort changes ORDER only — the
3937    // original name is still what's emitted.
3938    let sort_codes: Vec<String> = state
3939        .qualifiers
3940        .as_ref()
3941        .map(|q| q.sort_exec.clone())
3942        .unwrap_or_default();
3943    if !sort_codes.is_empty() {
3944        for m in state.matches.iter_mut() {
3945            let name = glob_emit_path(&m.path);
3946            for code in &sort_codes {
3947                crate::ported::params::setsparam("REPLY", &name);
3948                let _ = crate::ported::exec::execute_script(code);
3949                let key = crate::ported::params::getsparam("REPLY").unwrap_or_else(|| name.clone());
3950                m.sort_strings.push(key);
3951            }
3952        }
3953    }
3954
3955    // c:Src/glob.c:518 — the `Y` limit belongs to the SCAN, not to the result:
3956    //     scanner(q->next, shortcircuit);
3957    //     if (shortcircuit && shortcircuit == matchct)
3958    //         return;
3959    // The scanner simply stops once N matches exist, so the survivors are the
3960    // first N *found*, and only then does c:1868's sort run over them. Applying
3961    // the limit after sorting (as apply_selection used to) keeps the first N by
3962    // SORT ORDER instead — a different set: `*(.Y2on)` must be the two files
3963    // the scan reached, re-ordered by name, not the two alphabetically-first.
3964    //
3965    // This port collects every match before sorting, so the faithful place for
3966    // the truncation is here — after collection, before sort_matches. The
3967    // `[first,last]` subscript stays in apply_selection: c:1868's sort precedes
3968    // it, so THAT one really is a post-sort selection.
3969    //
3970    // Zero means "no limit" (c:518's leading `shortcircuit &&`), hence `> 0`.
3971    if let Some(n) = state.qualifiers.as_ref().and_then(|q| q.short_circuit) {
3972        if n > 0 {
3973            let n = n as usize;
3974            if state.matches.len() > n {
3975                state.matches.truncate(n); // c:518
3976            }
3977        }
3978    }
3979
3980    // Sort results
3981    sort_matches(state);
3982
3983    // Apply subscript selection
3984    apply_selection(state);
3985
3986    // Extract filenames. Mark-dirs / list-types come from EITHER
3987    // the canonical global option store OR the parsed `(M)`/`(T)`
3988    // qualifier on this glob. Direct port of zsh/Src/glob.c:355,372
3989    // — output marker emission consults the per-glob `gf_markdirs`
3990    // / `gf_listtypes` flags which the qualifier parser at
3991    // glob.c:1557-1566 sets.
3992    let mark_dirs = glob_isset(MARKDIRS)
3993        || state
3994            .qualifiers
3995            .as_ref()
3996            .map(|q| q.mark_dirs)
3997            .unwrap_or(false);
3998    // c:1562-1566 — `gf_listtypes` is set ONLY by the `T` glob
3999    // qualifier (`*(T)`), NOT by the global LISTTYPES option.
4000    // LISTTYPES is the completion-listing option (see man zshoptions
4001    // "LIST_TYPES") and doesn't affect glob output. Including the
4002    // option here mis-decorated every executable-file glob with a
4003    // trailing `*` and every directory with `/` for users with the
4004    // default `setopt listtypes` ON.
4005    let list_types = state
4006        .qualifiers
4007        .as_ref()
4008        .map(|q| q.list_types)
4009        .unwrap_or(false);
4010    let colon_mods = state.qualifiers.as_ref().and_then(|q| q.colon_mods.clone());
4011    // c:Src/glob.c — a pattern ending in `/` ("trailing slash" syntax)
4012    // forces matches to be directories AND preserves the slash in the
4013    // output. zsh's parsepat treats `pat/` as `pat` + an empty trailing
4014    // component; the scanner restricts to directories and the emitter
4015    // appends `/`. The Rust port's parser short-circuits the empty
4016    // trailing component, so we filter + re-add the suffix here at emit
4017    // time. `/` is never a glob metachar so a literal trailing-slash
4018    // check on the raw pattern is sufficient.
4019    // Use the qualifier-stripped `pat` for the trailing-slash check.
4020    // For `*/(N)` the original `pattern` ends with `)`, so the check
4021    // on `pattern` returned false and the trailing-slash directory-
4022    // filter was skipped — `*/(N)` then matched every file, not just
4023    // directories. The qualifier-stripped `pat` is `*/` which still
4024    // carries the trailing `/`.
4025    let trailing_slash = pat.ends_with('/') && pat.len() > 1;
4026    // c:Src/glob.c — preserve user-typed `./` / `../` prefix in
4027    // output. glob_emit_path strips CurDir uniformly so the
4028    // leading `./` would be lost. Detect the source prefix here
4029    // and re-prepend after emit. Same `pat` (post-qualifier) — the
4030    // prefix is on the path part, not the qualifier.
4031    let leading_dot_prefix: &str = if pat.starts_with("../") {
4032        "../"
4033    } else if pat.starts_with("./") {
4034        "./"
4035    } else {
4036        ""
4037    };
4038    let mut results: Vec<String> = state
4039        .matches
4040        .iter()
4041        .filter(|m| !trailing_slash || fs::metadata(&m.path).map(|md| md.is_dir()).unwrap_or(false))
4042        .map(|m| {
4043            let mut s = glob_emit_path(&m.path);
4044            if !leading_dot_prefix.is_empty()
4045                && !s.starts_with(leading_dot_prefix)
4046                && !s.starts_with('/')
4047            {
4048                s = format!("{}{}", leading_dot_prefix, s);
4049            }
4050            if trailing_slash && !s.ends_with('/') {
4051                s.push('/');
4052            }
4053            if mark_dirs || list_types {
4054                if let Ok(meta) = fs::symlink_metadata(&m.path) {
4055                    let ch = file_type(meta.mode());
4056                    if list_types || (mark_dirs && ch == '/') {
4057                        // Don't double-stamp if trailing_slash already added one.
4058                        if !s.ends_with(ch) {
4059                            s.push(ch);
4060                        }
4061                    }
4062                }
4063            }
4064            // Apply colon modifiers AFTER mark/list-type appendage —
4065            // zsh applies them last in glob.c:432 modify() per emitted
4066            // node, so `(M:t)` would mark THEN tail (effectively just
4067            // tail since the slash is gone). Faithful order.
4068            if let Some(ref m) = colon_mods {
4069                // Delegate to canonical `modify()` port in subst.rs
4070                // (Src/subst.c:4531). Local colon-modifier impl was
4071                // an invented duplicate — removed.
4072                s = crate::ported::subst::modify(&s, m);
4073            }
4074            s
4075        })
4076        .collect();
4077
4078    // c:421-426 — zsh applies the colon modifier in insert() (during
4079    // collection) BEFORE the match sort, so the default (name) sort
4080    // orders the MODIFIED names: `*(.:e)` on (apple.z,banana.a,cherry.m)
4081    // sorts the extensions → (a,m,z), not name order (z,a,m). The Rust
4082    // pipeline modifies at emit (after sort_matches), so re-sort the
4083    // modified results here — but ONLY for the default name sort; an
4084    // explicit `o`/`O` keeps its stat-keyed order with modified output.
4085    let explicit_sort = state
4086        .qualifiers
4087        .as_ref()
4088        .map(|q| !q.sorts.is_empty())
4089        .unwrap_or(false);
4090    if colon_mods.is_some() && !explicit_sort {
4091        results.sort();
4092    }
4093
4094    // c:1744-1756 / insert_glob_match c:1430 — `P:word:` (gf_pre_words)
4095    // emits `word` as a separate element BEFORE each match; `^P:word:`
4096    // (gf_post_words) emits it AFTER. Applied last, after sort/modifiers.
4097    let (pre_words, post_words) = state
4098        .qualifiers
4099        .as_ref()
4100        .map(|q| (q.pre_words.clone(), q.post_words.clone()))
4101        .unwrap_or_default();
4102    if !pre_words.is_empty() || !post_words.is_empty() {
4103        let mut expanded =
4104            Vec::with_capacity(results.len() * (1 + pre_words.len() + post_words.len()));
4105        for s in results {
4106            expanded.extend(pre_words.iter().cloned());
4107            expanded.push(s);
4108            expanded.extend(post_words.iter().cloned());
4109        }
4110        results = expanded;
4111    }
4112
4113    // c:Src/glob.c:1872-1888 — no-match path. Return Vec::new() so
4114    // the caller (vm_helper.rs::expand_glob) drives the
4115    // NULLGLOB / NOMATCH / literal-fallback policy. Previously
4116    // this pushed `pattern.to_string()` on no-match, which made
4117    // `expand_glob` see a non-empty result and skip the NOMATCH
4118    // check entirely. zsh's `setopt nomatch` is on by default, so
4119    // `echo /never_real/*` should emit "no matches found" and
4120    // exit 1 — not silently print the literal. Parity bug #13
4121    // recurrence. Per-glob `(N)` qualifier (nullglob_per_qual)
4122    // and global NULLGLOB are now consulted in expand_glob too.
4123    let _ = state.qualifiers.as_ref().map(|q| q.nullglob);
4124    results
4125}
4126
4127// `sort_matches_by_type` deleted — dead code (no callers anywhere
4128// in the tree). The sort path lives in `GlobMatch::compare` which
4129// dispatches off the canonical `GS_*` tp bits exactly like C's
4130// `gmatchcmp()` at glob.c:936.
4131
4132// ============================================================================
4133// Pattern matching with replacement (from glob.c getmatch family)
4134// ============================================================================
4135
4136// `MatchFlags` deleted — Rust-only bool-bag wrapper. C uses bare
4137// `int fl` with `SUB_*` bits from `Src/zsh.h:1981+` (mirrored at
4138// `zsh_h.rs:2463+`). Callers now pass an `i32` and test bits.
4139
4140// Trimmed local `struct imatchdata` deleted — it was a fake 5-field
4141// duplicate (str/pattern/match_start/match_end/replacement) of the
4142// canonical `Src/zsh.h:1740` port that already lives in
4143// `zsh_h.rs::imatchdata` (mstr/mlen/ustr/ulen/flags/replstr/repllist).
4144// `get_match_ret` now uses the canonical struct; see its port above.
4145
4146/// Strip a trailing `(qual)` block from a glob pattern.
4147/// **RUST-ONLY** — C glob.c handles qualifier parsing inline in
4148/// `parsepat` (c:791) as it builds the Complist. Move to that
4149/// shape when porting parsepat for real.
4150pub fn parse_qualifiers(pattern: &str) -> (String, Option<qualifier_set>) {
4151    // RUST-ONLY
4152    if !pattern.ends_with(')') {
4153        return (pattern.to_string(), None);
4154    }
4155
4156    let bytes = pattern.as_bytes();
4157    // Backslash-escaped trailing `)` is literal — no qualifier block
4158    // present. C's glob path handles this via Bnull preservation in
4159    // the lexer; zshrs's path arrives here with `\)` for quoted
4160    // close-parens (e.g. assoc-value `'\)'`). Without the gate, the
4161    // qualifier-extraction walk below treats the literal `)` as the
4162    // qualifier terminator.
4163    let last_paren_escaped = {
4164        let mut bs = 0usize;
4165        let mut j = bytes.len() - 1;
4166        while j > 0 && bytes[j - 1] == b'\\' {
4167            bs += 1;
4168            j -= 1;
4169        }
4170        bs % 2 == 1
4171    };
4172    if last_paren_escaped {
4173        return (pattern.to_string(), None);
4174    }
4175
4176    // Find matching open paren
4177    let mut depth = 0;
4178    let mut qual_start = None;
4179
4180    // c:Src/glob.c haswilds backslash handling — a `\(` / `\)` is a
4181    // literal paren, not a qualifier delimiter. Track which paren
4182    // bytes are escaped by a preceding `\` (counting consecutive
4183    // backslashes — even count means the paren is unescaped, odd
4184    // count means escaped). Without this gate, `\(*\)` is mis-parsed
4185    // as `(*\)` qualifier + empty pattern prefix, sending `*\` to
4186    // the qualifier-letter switch which emits "unknown file
4187    // attribute: \".
4188    let is_escaped = |idx: usize| -> bool {
4189        let mut bs = 0usize;
4190        let mut j = idx;
4191        while j > 0 && bytes[j - 1] == b'\\' {
4192            bs += 1;
4193            j -= 1;
4194        }
4195        bs % 2 == 1
4196    };
4197
4198    for i in (0..bytes.len()).rev() {
4199        match bytes[i] {
4200            b')' => {
4201                if !is_escaped(i) {
4202                    depth += 1;
4203                }
4204            }
4205            b'(' => {
4206                if !is_escaped(i) {
4207                    depth -= 1;
4208                    if depth == 0 {
4209                        qual_start = Some(i);
4210                        break;
4211                    }
4212                }
4213            }
4214            _ => {}
4215        }
4216    }
4217
4218    let start = match qual_start {
4219        Some(s) => s,
4220        None => return (pattern.to_string(), None),
4221    };
4222
4223    // Check for (#q...) explicit qualifier syntax.
4224    let qual_str = &pattern[start + 1..pattern.len() - 1];
4225    // c:Src/glob.c:1192-1197 — `if (isset(EXTENDEDGLOB) &&
4226    // !zpc_disables[ZPC_HASH] && s[1] == Pound) { if (s[2] != 'q')
4227    // return 0; ret = 2; }`. The leading `#` inside `(...)` is ONLY
4228    // special under EXTENDEDGLOB: `(#q...)` is the explicit glob
4229    // qualifier, and `(#X...)` for any other X is an inline pattern flag
4230    // (passed through). Without EXTENDEDGLOB the `#` is not special at
4231    // all — the `(...)` is a bare qualifier group and a leading `#` is an
4232    // (unknown) attribute char, so `*(#q.)` errors `unknown file
4233    // attribute: #` rather than silently applying the `.` qualifier.
4234    let (is_explicit, qual_content) = if glob_isset(EXTENDEDGLOB) && qual_str.starts_with('#') {
4235        if let Some(after) = qual_str.strip_prefix("#q") {
4236            (true, after) // c:1195 ret = 2 — explicit glob qualifier
4237        } else {
4238            // c:1194 `if (s[2] != 'q') return 0;` — inline pattern flag
4239            // (`(#i)`, `(#c1,2)`, `(#a)`, `(#l)`, `(#s)`, `(#e)`, `(#m)`).
4240            return (pattern.to_string(), None);
4241        }
4242    } else if glob_isset(BAREGLOBQUAL) {
4243        (false, qual_str)
4244    } else {
4245        return (pattern.to_string(), None);
4246    };
4247
4248    // Don't parse as qualifiers if it contains | or ~ (alternatives/exclusions)
4249    if !is_explicit && (qual_content.contains('|') || qual_content.contains('~')) {
4250        return (pattern.to_string(), None);
4251    }
4252
4253    // Parse the qualifiers
4254    let qs = parse_qualifier_string(qual_content);
4255    (pattern[..start].to_string(), Some(qs))
4256}
4257
4258/// Parse the body of a `(...)` qualifier block into a qualifier_set.
4259/// **RUST-ONLY** — see header on `parse_qualifiers`.
4260fn parse_qualifier_string(s: &str) -> qualifier_set {
4261    // RUST-ONLY
4262    let mut qs = qualifier_set::default();
4263    let mut chars = s.chars().peekable();
4264    let mut negated = false;
4265    let mut follow = false;
4266
4267    while let Some(c) = chars.next() {
4268        match c {
4269            '^' => {
4270                // c:1346 — `sense ^= 1`. Keep the running `negated` for
4271                // the flag qualifiers (N/D/M/T) that read it, AND emit a
4272                // marker so the per-qualifier sense reaches the list walk.
4273                negated = !negated;
4274                qs.qualifiers.push(qualifier::ToggleSense);
4275            }
4276            '-' => follow = !follow,
4277            ',' => {
4278                // Start new alternative
4279                if !qs.qualifiers.is_empty() {
4280                    qs.alternatives.push(std::mem::take(&mut qs.qualifiers));
4281                }
4282                negated = false;
4283                follow = false;
4284            }
4285            ':' => {
4286                // Colon modifiers - rest of string
4287                let rest: String = chars.collect();
4288                qs.colon_mods = Some(format!(":{}", rest));
4289                break;
4290            }
4291            // File type qualifiers
4292            '/' => qs.qualifiers.push(qualifier::IsDirectory),
4293            '.' => qs.qualifiers.push(qualifier::IsRegular),
4294            '@' => qs.qualifiers.push(qualifier::IsSymlink),
4295            '=' => qs.qualifiers.push(qualifier::IsSocket),
4296            'p' => qs.qualifiers.push(qualifier::IsFifo),
4297            '%' => match chars.peek() {
4298                Some('b') => {
4299                    chars.next();
4300                    qs.qualifiers.push(qualifier::IsBlockDev);
4301                }
4302                Some('c') => {
4303                    chars.next();
4304                    qs.qualifiers.push(qualifier::IsCharDev);
4305                }
4306                _ => qs.qualifiers.push(qualifier::IsDevice),
4307            },
4308            '*' => qs.qualifiers.push(qualifier::IsExecutable),
4309            // Permission qualifiers
4310            'r' => qs.qualifiers.push(qualifier::Readable),
4311            'w' => qs.qualifiers.push(qualifier::Writable),
4312            'x' => qs.qualifiers.push(qualifier::Executable),
4313            'R' => qs.qualifiers.push(qualifier::WorldReadable),
4314            'W' => qs.qualifiers.push(qualifier::WorldWritable),
4315            'X' => qs.qualifiers.push(qualifier::WorldExecutable),
4316            'A' => qs.qualifiers.push(qualifier::GroupReadable),
4317            'I' => qs.qualifiers.push(qualifier::GroupWritable),
4318            'E' => qs.qualifiers.push(qualifier::GroupExecutable),
4319            's' => qs.qualifiers.push(qualifier::Setuid),
4320            'S' => qs.qualifiers.push(qualifier::Setgid),
4321            't' => qs.qualifiers.push(qualifier::Sticky),
4322            // c:Src/glob.c case 'f' — file-mode qualifier accepts
4323            // an octal mode (`f755`) or chmod-style symbolic spec
4324            // (`fu+x`/`fg-w`/`fa=r`) and matches files whose mode
4325            // bits agree. Multiple delimited specs can be chained
4326            // by separator character. Direct port of qgetmodespec
4327            // dispatch + mode-bit accumulator in qgetmodespec.
4328            'f' => {
4329                // c:Src/glob.c:849 — qgetmodespec dispatches on the
4330                // first char: if it's `=`/`+`/`-`/`?` or a digit
4331                // (`(c >= '0' && c <= '7')`) the spec is bare (no
4332                // surrounding delimiter, no who) — `f+x` / `f-w` /
4333                // `f=755` / `f0644`. Otherwise the char is the
4334                // OPENING DELIMITER paired with a matching closer
4335                // (`<` → `>`, `[` → `]`, `{` → `}`, anything else
4336                // → itself, e.g. `f:u+x:` uses `:` on both sides).
4337                // The previous port used `!d.is_alphanumeric()`
4338                // which incorrectly classified `+`, `-`, `=`, `?`
4339                // as delimiters, sending `f+x` down the
4340                // delimiter-string path that then called
4341                // qgetmodespec on body "x" — qgetmodespec rejects
4342                // `x` (no op char) and silently dropped the
4343                // qualifier, so `*(.f+x)` matched ALL files. Bug
4344                // #105 in docs/BUGS.md.
4345                let rest: String = chars.clone().collect();
4346                let trimmed: &str = match rest.chars().next() {
4347                    // c:849-861 — bare spec ONLY when the first char is
4348                    // `=`/`+`/`-`/`?` or an octal digit; ANY other char is
4349                    // the OPENING DELIMITER (`<`→`>`, `[`→`]`, `{`→`}`, else
4350                    // itself). Who-clauses (u/g/o/a) are valid only INSIDE a
4351                    // delimited spec, so bare `fu+x` lands here with `u` as
4352                    // the delimiter — and with no closing `u` it is an
4353                    // unterminated (invalid) spec, exactly as in zsh.
4354                    Some(d) if !matches!(d, '+' | '-' | '=' | '?' | '0'..='7') => {
4355                        chars.next(); // consume opening delim
4356                        let close = match d {
4357                            '<' => '>',
4358                            '[' => ']',
4359                            '{' => '}',
4360                            other => other,
4361                        };
4362                        let mut body = String::new();
4363                        let mut closed = false;
4364                        while let Some(pc) = chars.next() {
4365                            if pc == close {
4366                                closed = true;
4367                                break;
4368                            }
4369                            body.push(pc);
4370                        }
4371                        if !closed {
4372                            // c:884/930 — `zerr("invalid mode specification")`
4373                            // on an unterminated spec; the glob then fails.
4374                            crate::ported::utils::zerr("invalid mode specification");
4375                            crate::ported::utils::errflag.fetch_or(
4376                                crate::ported::utils::ERRFLAG_ERROR,
4377                                std::sync::atomic::Ordering::Relaxed,
4378                            );
4379                            return qs;
4380                        }
4381                        if let Some((_who, op, perm, _r)) = qgetmodespec(&body) {
4382                            let (yes, no) = match op {
4383                                '=' => (perm, 0o7777 & !perm),
4384                                '+' => (perm, 0),
4385                                '-' => (0, perm),
4386                                _ => (perm, 0),
4387                            };
4388                            qs.qualifiers.push(qualifier::Mode { yes, no });
4389                        } else {
4390                            // c:884/930 — qgetmodespec's every failure path is
4391                            // `zerr("invalid mode specification"); return 0;`.
4392                            // rs's qgetmodespec returns None only on those, so
4393                            // None must fail the glob, not silently drop `f`.
4394                            crate::ported::utils::zerr("invalid mode specification");
4395                            crate::ported::utils::errflag.fetch_or(
4396                                crate::ported::utils::ERRFLAG_ERROR,
4397                                std::sync::atomic::Ordering::Relaxed,
4398                            );
4399                            return qs;
4400                        }
4401                        ""
4402                    }
4403                    _ => {
4404                        let parsed = qgetmodespec(&rest);
4405                        if let Some((who, op, perm, r)) = parsed {
4406                            let consumed = rest.len() - r.len();
4407                            for _ in 0..consumed {
4408                                chars.next();
4409                            }
4410                            let _ = who;
4411                            let (yes, no) = match op {
4412                                '=' => (perm, 0o7777 & !perm),
4413                                '+' => (perm, 0),
4414                                '-' => (0, perm),
4415                                _ => (perm, 0),
4416                            };
4417                            qs.qualifiers.push(qualifier::Mode { yes, no });
4418                        } else {
4419                            // c:930 — a bare `f` (empty spec) or otherwise
4420                            // unparseable mode is `zerr("invalid mode
4421                            // specification"); return 0;` in qgetmodespec, which
4422                            // aborts the glob rather than dropping the qualifier.
4423                            crate::ported::utils::zerr("invalid mode specification");
4424                            crate::ported::utils::errflag.fetch_or(
4425                                crate::ported::utils::ERRFLAG_ERROR,
4426                                std::sync::atomic::Ordering::Relaxed,
4427                            );
4428                            return qs;
4429                        }
4430                        ""
4431                    }
4432                };
4433                let _ = trimmed;
4434            }
4435            // Ownership
4436            'U' => qs.qualifiers.push(qualifier::OwnedByEuid),
4437            'G' => qs.qualifiers.push(qualifier::OwnedByEgid),
4438            'u' => {
4439                let uid = parse_uid_gid(&mut chars, false);
4440                qs.qualifiers.push(qualifier::OwnedByUid(uid));
4441            }
4442            'g' => {
4443                let gid = parse_uid_gid(&mut chars, true);
4444                qs.qualifiers.push(qualifier::OwnedByGid(gid));
4445            }
4446            // Size
4447            'L' => {
4448                let (unit, op, val) = parse_size_spec(&mut chars);
4449                qs.qualifiers.push(qualifier::Size {
4450                    value: val,
4451                    unit,
4452                    op,
4453                });
4454            }
4455            // Link count
4456            'l' => {
4457                let (op, val) = parse_range_spec(&mut chars);
4458                qs.qualifiers.push(qualifier::Links { value: val, op });
4459            }
4460            // c:Src/glob.c:1445-1449 — `case 'd': func = qualdev;
4461            // data = qgetnum(&s);`. Matches files by device number
4462            // (`qualdev`, c:3688, is `buf->st_dev == dv`). Both the
4463            // `qualifier::Device` variant and the `qualdev` matcher were
4464            // already ported and wired at glob.rs:4894 — only this parser arm
4465            // was missing, so `*(d5)` never reached them and a bare `*(d)`
4466            // reported "unknown file attribute: d" where zsh says
4467            // "number expected".
4468            //
4469            // c:826-834 qgetnum takes PLAIN digits — deliberately NOT
4470            // `parse_range_spec`, which would also accept the `+`/`-` range
4471            // operators that `l`/`L`/`m` use and that C does not allow here.
4472            'd' => {
4473                let mut num = String::new();
4474                while let Some(&pc) = chars.peek() {
4475                    if pc.is_ascii_digit() {
4476                        num.push(pc);
4477                        chars.next();
4478                    } else {
4479                        break;
4480                    }
4481                }
4482                if num.is_empty() {
4483                    // c:830-833 — `if (!idigit(**s)) { zerr("number expected");
4484                    // return 0; }`. Matches the sibling error arms in this
4485                    // parser (`Y`, `f`): report, set errflag, stop.
4486                    crate::ported::utils::zerr("number expected"); // c:832
4487                    crate::ported::utils::errflag.fetch_or(
4488                        crate::ported::utils::ERRFLAG_ERROR,
4489                        std::sync::atomic::Ordering::Relaxed,
4490                    );
4491                    return qs;
4492                }
4493                qs.qualifiers
4494                    .push(qualifier::Device(num.parse().unwrap_or(0))); // c:1448
4495            }
4496            // Times
4497            'a' => {
4498                let (unit, op, val) = schedgetfn(&mut chars);
4499                qs.qualifiers.push(qualifier::Atime {
4500                    value: val as i64,
4501                    unit,
4502                    op,
4503                });
4504            }
4505            'm' => {
4506                let (unit, op, val) = schedgetfn(&mut chars);
4507                qs.qualifiers.push(qualifier::Mtime {
4508                    value: val as i64,
4509                    unit,
4510                    op,
4511                });
4512            }
4513            'c' => {
4514                let (unit, op, val) = schedgetfn(&mut chars);
4515                qs.qualifiers.push(qualifier::Ctime {
4516                    value: val as i64,
4517                    unit,
4518                    op,
4519                });
4520            }
4521            // Sort qualifier — `o<key>` ascending / `O<key>`
4522            // descending. Mirrors the parser arm in zsh's glob.c
4523            // that builds `struct globsort` entries (tp = GS_* OR
4524            // GS_DESC, optionally shifted by GS_SHIFT for the
4525            // follow-links variant).
4526            'o' | 'O' => {
4527                let desc = c == 'O';
4528                if let Some(&sc) = chars.peek() {
4529                    let key: i32 = match sc {
4530                        'n' => {
4531                            chars.next();
4532                            GS_NAME
4533                        }
4534                        'L' => {
4535                            chars.next();
4536                            GS_SIZE
4537                        }
4538                        'l' => {
4539                            chars.next();
4540                            GS_LINKS
4541                        }
4542                        'a' => {
4543                            chars.next();
4544                            GS_ATIME
4545                        }
4546                        'm' => {
4547                            chars.next();
4548                            GS_MTIME
4549                        }
4550                        'c' => {
4551                            chars.next();
4552                            GS_CTIME
4553                        }
4554                        'd' => {
4555                            chars.next();
4556                            GS_DEPTH
4557                        }
4558                        'N' => {
4559                            chars.next();
4560                            GS_NONE
4561                        }
4562                        'e' | '+' => {
4563                            // c:1672-1687 — GS_EXEC sort: `glob_exec_string`
4564                            // parses the eval code (delimited `e:code:` or
4565                            // identifier `+name`); store it indexed and pack
4566                            // the index into the sort entry. The code is
4567                            // consumed HERE (not left for the main loop), so
4568                            // `oe:…:` is a SORT (eval = key, original name
4569                            // emitted), not a separate eval qualifier.
4570                            let is_plus = sc == '+';
4571                            chars.next(); // consume 'e' / '+'
4572                            let rest: String = chars.clone().collect();
4573                            match glob_exec_string(&rest, is_plus) {
4574                                Some((code, consumed)) => {
4575                                    for _ in 0..consumed {
4576                                        chars.next();
4577                                    }
4578                                    let idx = qs.sort_exec.len();
4579                                    qs.sort_exec.push(code);
4580                                    GS_EXEC | ((idx as i32) << 16)
4581                                }
4582                                None => {
4583                                    // glob_exec_string already emitted the
4584                                    // diagnostic + set errflag; bail.
4585                                    return qs;
4586                                }
4587                            }
4588                        }
4589                        _ => {
4590                            // c:1689 — `default: zerr("unknown sort
4591                            // specifier"); restore_globstate(saved);
4592                            // return;`. Was `_ => GS_NAME`, which silently
4593                            // accepted invalid keys (and reparsed them as
4594                            // separate qualifiers, e.g. `oD` → o + D-dotglob
4595                            // instead of erroring like zsh).
4596                            crate::ported::utils::zerr("unknown sort specifier");
4597                            crate::ported::utils::errflag.fetch_or(
4598                                crate::ported::utils::ERRFLAG_ERROR,
4599                                std::sync::atomic::Ordering::Relaxed,
4600                            );
4601                            return qs;
4602                        }
4603                    };
4604                    let shifted = if follow && (key & GS_NORMAL) != 0 {
4605                        key << GS_SHIFT // c:1692-1694
4606                    } else {
4607                        key
4608                    };
4609                    // c:1695-1702 — a sort key may not be repeated:
4610                    //     if (t != GS_EXEC) {
4611                    //         if (gf_sorts & t) {
4612                    //             zerr("doubled sort specifier");
4613                    //             restore_globstate(saved);
4614                    //             return;
4615                    //         }
4616                    //     }
4617                    //     gf_sorts |= t;
4618                    // GS_EXEC is exempt — `oe:…:` may appear repeatedly, each
4619                    // with its own code. This check was missing entirely, so
4620                    // `*(.onon)` and `*(.onOn)` silently sorted where zsh
4621                    // fails. Note the test is on the SHIFTED key, so `oL` and
4622                    // `OL` collide (same key, different direction) while `oL`
4623                    // and `oLm` do not (the follow variant shifts).
4624                    if (shifted & GS_EXEC) == 0 && qs.sorts.iter().any(|s| (s & !GS_DESC) == shifted)
4625                    {
4626                        crate::ported::utils::zerr("doubled sort specifier"); // c:1697
4627                        crate::ported::utils::errflag.fetch_or(
4628                            crate::ported::utils::ERRFLAG_ERROR,
4629                            std::sync::atomic::Ordering::Relaxed,
4630                        );
4631                        return qs; // c:1699
4632                    }
4633                    // c:1658-1662 — `if (gf_nsorts == MAX_SORTS) { zerr("too
4634                    // many glob sort specifiers"); ... return; }`. MAX_SORTS is
4635                    // 12 (c:164). Unreachable in practice now that the doubled
4636                    // check above rejects repeats, but it is C's bound and the
4637                    // list is otherwise unbounded.
4638                    if qs.sorts.len() == MAX_SORTS {
4639                        crate::ported::utils::zerr("too many glob sort specifiers"); // c:1659
4640                        crate::ported::utils::errflag.fetch_or(
4641                            crate::ported::utils::ERRFLAG_ERROR,
4642                            std::sync::atomic::Ordering::Relaxed,
4643                        );
4644                        return qs; // c:1661
4645                    }
4646                    let tp = shifted | (if desc { GS_DESC } else { 0 });
4647                    qs.sorts.push(tp);
4648                } else {
4649                    // c:1666-1690 — C does not guard on "is there a next
4650                    // character": it reads `switch (*s)` unconditionally, so an
4651                    // `o`/`O` at the very end of the qualifier list sees the
4652                    // terminating `)` and lands on `default: zerr("unknown sort
4653                    // specifier")`. This port skipped the whole block when the
4654                    // iterator was exhausted, so a trailing `o` was silently
4655                    // ignored and `*(No)` listed every match where zsh fails.
4656                    // A NON-trailing bad spec (`*(ozN)`) already errored — it
4657                    // reaches the `_` arm above — which is why only the
4658                    // end-of-list form survived.
4659                    crate::ported::utils::zerr("unknown sort specifier"); // c:1688
4660                    crate::ported::utils::errflag.fetch_or(
4661                        crate::ported::utils::ERRFLAG_ERROR,
4662                        std::sync::atomic::Ordering::Relaxed,
4663                    );
4664                    return qs; // c:1690
4665                }
4666            }
4667            // Flags
4668            // c:1567-1569 — `case 'N': gf_nullglob = !(sense & 1);`.
4669            // Per-glob nullglob: empty result on no-match, no error.
4670            'N' => qs.nullglob = !negated,
4671            'D' => qs.globdots = !negated,
4672            // c:1575-1577 — `case 'n': gf_numsort = !(sense & 1)`.
4673            // Standalone `(n)` enables numeric sort for this glob (the
4674            // `^n` toggle disables it). Consumed in sort_matches.
4675            'n' => qs.numsort = Some(!negated),
4676            // (M) / (T) — set per-qualifier-set flags. glob.c:1557-1566:
4677            //   case 'M': gf_markdirs = !(sense & 1);  break;
4678            //   case 'T': gf_listtypes = !(sense & 1); break;
4679            // `sense & 1` = the `^`-toggle bit. zshrs's parser tracks
4680            // `negated`; mirror by `!negated`. Read at output-emit
4681            // time to mark dirs / list types like coreutils ls -F.
4682            'M' => qs.mark_dirs = !negated,
4683            'T' => qs.list_types = !negated,
4684            'F' => qs.qualifiers.push(qualifier::NonEmptyDir),
4685            // c:1744-1756 — `P:word:` adds `word` to gf_pre_words (or
4686            // gf_post_words under `^`), emitted as a separate element
4687            // before/after each match. `glob_exec_string` parses the
4688            // delimited word.
4689            'P' => {
4690                let rest: String = chars.clone().collect();
4691                if let Some((word, consumed)) = glob_exec_string(&rest, false) {
4692                    for _ in 0..consumed {
4693                        chars.next();
4694                    }
4695                    if negated {
4696                        qs.post_words.push(word); // c:1750 gf_post_words
4697                    } else {
4698                        qs.pre_words.push(word); // c:1750 gf_pre_words
4699                    }
4700                }
4701            }
4702            // Subscript
4703            '[' => {
4704                let (first, last) = parse_subscript(&mut chars);
4705                qs.first = first;
4706                qs.last = last;
4707            }
4708            // c:Src/glob.c:1579-1595 `case 'Y'` — short-circuit:
4709            // limit matches to at most N. Reads a numeric argument
4710            // immediately following the `Y`. Bug #41 in docs/BUGS.md.
4711            'Y' => {
4712                // c:1579-1594:
4713                //     const char *s_saved = s;
4714                //     shortcircuit = !(sense & 1);
4715                //     if (shortcircuit) {
4716                //         data = qgetnum(&s);
4717                //         if ((shortcircuit = data) != data) {
4718                //             /* Integer overflow */
4719                //             zerr("value too big: Y%s", s_saved);
4720                //             restore_globstate(saved);
4721                //             return;
4722                //         }
4723                //     }
4724                // `data` is a zlong and `shortcircuit` an int, so the
4725                // assignment-then-compare is a 64→32 TRUNCATION test: a limit
4726                // that does not survive the narrowing is fatal. This parsed
4727                // straight into i32 and silently dropped the qualifier when
4728                // that failed, so `*(Y2147483648)` listed every match where
4729                // zsh errors — the limit was simply ignored.
4730                //
4731                // s_saved is the text AFTER the `Y`, running to the end of the
4732                // qualifier list, which is why the message carries any trailing
4733                // qualifiers too: `*(.NY99…)` reports `value too big: Y99…`
4734                // exactly as spelled.
4735                let s_saved: String = chars.clone().collect(); // c:1582
4736                let mut num_str = String::new();
4737                while let Some(&pc) = chars.peek() {
4738                    if pc.is_ascii_digit() {
4739                        num_str.push(pc);
4740                        chars.next();
4741                    } else {
4742                        break;
4743                    }
4744                }
4745                // c:1586 — qgetnum is `while (idigit(**s)) v = v * 10 + …`,
4746                // with no overflow check of its own; it simply wraps. Mirror
4747                // that rather than failing the parse, so the truncation test
4748                // below is what decides, exactly as in C.
4749                let mut data: i64 = 0;
4750                for ch in num_str.chars() {
4751                    data = data
4752                        .wrapping_mul(10)
4753                        .wrapping_add((ch as i64) - ('0' as i64));
4754                }
4755                let sc = data as i32; // c:1587 `shortcircuit = data`
4756                if sc as i64 != data {
4757                    // c:1587 `!= data`
4758                    crate::ported::utils::zerr(&format!("value too big: Y{s_saved}")); // c:1589
4759                    crate::ported::utils::errflag.fetch_or(
4760                        crate::ported::utils::ERRFLAG_ERROR,
4761                        std::sync::atomic::Ordering::Relaxed,
4762                    );
4763                    return qs; // c:1591
4764                }
4765                qs.short_circuit = Some(sc);
4766            }
4767            // c:Src/glob.c:1599-1620 `case 'e'` — `(e:CODE:)` shell-
4768            // eval qualifier. The body is delimited by the char
4769            // immediately following 'e' (matched at both ends).
4770            // Common forms in zsh scripts: `(e:'[[ -L $REPLY ]]':)`
4771            // for symlink filter, etc. The body runs against each
4772            // candidate path with $REPLY set; the file is kept iff
4773            // the body returns 0. zshrs's qualifier::Eval variant
4774            // was defined but had no parser arm — was rejected as
4775            // "unknown file attribute: e". Bug #469.
4776            'e' => {
4777                // c:1712-1719 — `e` routes through glob_exec_string →
4778                // get_strarg, which requires a delimiter char after `e` AND a
4779                // matching closer. A bare `e` (nothing after, delim would be the
4780                // `)`) or an unterminated body is `zerr("missing end of
4781                // string"); return NULL;` (c:1102), aborting the glob.
4782                let delim = match chars.next() {
4783                    Some(d) => d,
4784                    None => {
4785                        crate::ported::utils::zerr("missing end of string");
4786                        crate::ported::utils::errflag.fetch_or(
4787                            crate::ported::utils::ERRFLAG_ERROR,
4788                            std::sync::atomic::Ordering::Relaxed,
4789                        );
4790                        return qs;
4791                    }
4792                };
4793                let mut body = String::new();
4794                let mut closed = false;
4795                while let Some(&pc) = chars.peek() {
4796                    if pc == delim {
4797                        chars.next();
4798                        closed = true;
4799                        break;
4800                    }
4801                    body.push(pc);
4802                    chars.next();
4803                }
4804                if !closed {
4805                    crate::ported::utils::zerr("missing end of string");
4806                    crate::ported::utils::errflag.fetch_or(
4807                        crate::ported::utils::ERRFLAG_ERROR,
4808                        std::sync::atomic::Ordering::Relaxed,
4809                    );
4810                    return qs;
4811                }
4812                if negated {
4813                    // (^e:CODE:) inverts; we route through Eval and
4814                    // flip by wrapping the body. Simpler: just store
4815                    // and let the negated flag at the qualifier_set
4816                    // level handle inversion.
4817                }
4818                qs.qualifiers.push(qualifier::Eval(body));
4819            }
4820            // c:Src/glob.c:1708-1722 `case '+':` — `(+FUNC)` invokes
4821            // shell function FUNC on each candidate; keep file iff
4822            // function returns 0. C's glob_exec_string (c:1085) reads
4823            // the identifier name via `itype_end(s, IIDENT, 0)` when
4824            // the qualifier letter was '+'. The body wraps the call as
4825            // a shell expression `FUNC` that qualsheval (c:4769 zshrs
4826            // qualifier::Eval) runs as a one-shot. Bug #N — this arm
4827            // was missing entirely, so `*(+func)` and `*(s+0)`-style
4828            // mixed-qualifier strings errored "unknown file attribute:
4829            // +" instead of routing through the Eval path that would
4830            // either match files or fall through to "no matches found".
4831            '+' => {
4832                // c:1090-1097 — `tt = itype_end(s, IIDENT, 0); if
4833                // (tt == s) zerr("missing identifier after `+'")`.
4834                // Read identifier chars greedily.
4835                let mut ident = String::new();
4836                while let Some(&pc) = chars.peek() {
4837                    if pc.is_ascii_alphanumeric() || pc == '_' {
4838                        ident.push(pc);
4839                        chars.next();
4840                    } else {
4841                        break;
4842                    }
4843                }
4844                if ident.is_empty() {
4845                    crate::ported::utils::zerr("missing identifier after `+'"); // c:1095
4846                    crate::ported::utils::errflag.fetch_or(
4847                        crate::ported::utils::ERRFLAG_ERROR,
4848                        std::sync::atomic::Ordering::Relaxed,
4849                    );
4850                    return qs;
4851                }
4852                // c:1109-1117 — the identifier IS the body that
4853                // qualsheval will run. Push as Eval so the same
4854                // per-file evaluator at c:4769 fires.
4855                qs.qualifiers.push(qualifier::Eval(ident));
4856            }
4857            // c:Src/glob.c:1758-1762 — `default: zerr("unknown file
4858            // attribute: %c", *s); restore_globstate(saved); return;`.
4859            // Bug #583: zshrs's `_ => {}` arm silently accepted any
4860            // unknown qualifier letter, so `*(z)` returned all files
4861            // rc=0 instead of erroring "unknown file attribute: z".
4862            // Mirror C's strict behavior — but skip ASCII whitespace
4863            // / `\0` (parser leftovers) and chars below 0x21 since
4864            // C's qualifier loop stops on those naturally via its
4865            // `case ')'` arm before reaching default.
4866            ch if !ch.is_whitespace() && ch != '\0' && ch != ')' => {
4867                crate::ported::utils::zerr(&format!("unknown file attribute: {}", ch));
4868                crate::ported::utils::errflag.fetch_or(
4869                    crate::ported::utils::ERRFLAG_ERROR,
4870                    std::sync::atomic::Ordering::Relaxed,
4871                );
4872                return qs;
4873            }
4874            _ => {}
4875        }
4876    }
4877
4878    if !qs.qualifiers.is_empty() {
4879        qs.alternatives.push(std::mem::take(&mut qs.qualifiers));
4880    }
4881
4882    qs.follow_links = follow;
4883
4884    // Build the faithful glob.c `struct qual` arena from `alternatives`
4885    // (additive: matching still uses the enum). Each alternative is an
4886    // AND-chain (`next` links); alternatives chain via `or`. ToggleSense
4887    // markers toggle a running per-node `sense` (c:1346). Each variant
4888    // maps to its leaf TestMatchFunc + data, mirroring check_single_
4889    // qualifier's dispatch (Src/glob.c:1343-1620 case → func/data).
4890    {
4891        let op_range = |o: char| -> i32 {
4892            match o {
4893                '<' => -1,
4894                '>' => 1,
4895                _ => 0,
4896            }
4897        };
4898        let mut arena = QualArena::new();
4899        let mut prev_alt_head: Option<usize> = None;
4900        for alt in &qs.alternatives {
4901            let mut sense = 0i32;
4902            let mut chain_tail: Option<usize> = None;
4903            let mut chain_head: Option<usize> = None;
4904            for q in alt {
4905                // (func, data, range, amc, units, sdata) — None = no node.
4906                let fields: Option<(TestMatchFunc, i64, i32, i32, i32, Option<String>)> = match q {
4907                    qualifier::ToggleSense => {
4908                        sense ^= 1; // c:1346
4909                        None
4910                    }
4911                    qualifier::IsRegular => Some((qualisreg, 0, 0, 0, 0, None)),
4912                    qualifier::IsDirectory => Some((qualisdir, 0, 0, 0, 0, None)),
4913                    qualifier::IsSymlink => Some((qualislnk, 0, 0, 0, 0, None)),
4914                    qualifier::IsSocket => Some((qualissock, 0, 0, 0, 0, None)),
4915                    qualifier::IsFifo => Some((qualisfifo, 0, 0, 0, 0, None)),
4916                    qualifier::IsBlockDev => Some((qualisblk, 0, 0, 0, 0, None)),
4917                    qualifier::IsCharDev => Some((qualischr, 0, 0, 0, 0, None)),
4918                    qualifier::IsDevice => Some((qualisdev, 0, 0, 0, 0, None)),
4919                    qualifier::IsExecutable => Some((qualiscom, 0, 0, 0, 0, None)),
4920                    qualifier::Readable => Some((qualflags, 0o400, 0, 0, 0, None)),
4921                    qualifier::Writable => Some((qualflags, 0o200, 0, 0, 0, None)),
4922                    qualifier::Executable => Some((qualflags, 0o100, 0, 0, 0, None)),
4923                    qualifier::WorldReadable => Some((qualflags, 0o004, 0, 0, 0, None)),
4924                    qualifier::WorldWritable => Some((qualflags, 0o002, 0, 0, 0, None)),
4925                    qualifier::WorldExecutable => Some((qualflags, 0o001, 0, 0, 0, None)),
4926                    qualifier::GroupReadable => Some((qualflags, 0o040, 0, 0, 0, None)),
4927                    qualifier::GroupWritable => Some((qualflags, 0o020, 0, 0, 0, None)),
4928                    qualifier::GroupExecutable => Some((qualflags, 0o010, 0, 0, 0, None)),
4929                    qualifier::Setuid => Some((qualflags, 0o4000, 0, 0, 0, None)),
4930                    qualifier::Setgid => Some((qualflags, 0o2000, 0, 0, 0, None)),
4931                    qualifier::Sticky => Some((qualflags, 0o1000, 0, 0, 0, None)),
4932                    qualifier::OwnedByEuid => {
4933                        Some((qualuid, unsafe { libc::geteuid() } as i64, 0, 0, 0, None))
4934                    }
4935                    qualifier::OwnedByEgid => {
4936                        Some((qualgid, unsafe { libc::getegid() } as i64, 0, 0, 0, None))
4937                    }
4938                    qualifier::OwnedByUid(u) => Some((qualuid, *u as i64, 0, 0, 0, None)),
4939                    qualifier::OwnedByGid(g) => Some((qualgid, *g as i64, 0, 0, 0, None)),
4940                    qualifier::Size { value, unit, op } => {
4941                        Some((qualsize, *value as i64, op_range(*op), -1, *unit, None))
4942                    }
4943                    qualifier::Links { value, op } => {
4944                        Some((qualnlink, *value as i64, op_range(*op), 0, 0, None))
4945                    }
4946                    qualifier::Atime { value, unit, op } => {
4947                        Some((qualtime, *value, op_range(*op), 0, *unit, None))
4948                    }
4949                    qualifier::Mtime { value, unit, op } => {
4950                        Some((qualtime, *value, op_range(*op), 1, *unit, None))
4951                    }
4952                    qualifier::Ctime { value, unit, op } => {
4953                        Some((qualtime, *value, op_range(*op), 2, *unit, None))
4954                    }
4955                    qualifier::Mode { yes, no } => Some((
4956                        qualmodeflags,
4957                        (*yes as i64) | ((*no as i64) << 12),
4958                        0,
4959                        0,
4960                        0,
4961                        None,
4962                    )),
4963                    qualifier::Device(d) => Some((qualdev, *d as i64, 0, 0, 0, None)),
4964                    qualifier::NonEmptyDir => Some((qualnonemptydir, 0, 0, 0, 0, None)),
4965                    qualifier::Eval(code) => Some((qualsheval, 0, 0, 0, 0, Some(code.clone()))),
4966                };
4967                let Some((func, data, range, amc, units, sdata)) = fields else {
4968                    continue;
4969                };
4970                let idx = arena.alloc(); // c:1768 hcalloc
4971                {
4972                    let node = &mut arena.nodes[idx];
4973                    node.func = Some(func); // c:1774
4974                    node.data = data; // c:1776
4975                    node.sense = sense; // c:1775
4976                    node.range = range; // c:1778
4977                    node.units = units; // c:1779
4978                    node.amc = amc; // c:1780
4979                    node.sdata = sdata; // c:1777
4980                }
4981                if let Some(t) = chain_tail {
4982                    arena.nodes[t].next = Some(idx); // c:1770
4983                }
4984                chain_tail = Some(idx);
4985                if chain_head.is_none() {
4986                    chain_head = Some(idx);
4987                }
4988            }
4989            // Link this alternative's head into the or-chain (c:1324).
4990            if let Some(h) = chain_head {
4991                if let Some(p) = prev_alt_head {
4992                    arena.nodes[p].or = Some(h);
4993                }
4994                prev_alt_head = Some(h);
4995                if arena.head.is_none() {
4996                    arena.head = Some(h);
4997                }
4998            }
4999        }
5000        qs.quals = arena;
5001    }
5002
5003    qs
5004}
5005
5006/// Parse a numeric uid/gid or a delimited user/group name from a `u`/`g`
5007/// qualifier char stream. Port of the name-resolution arms of the `u`/`g`
5008/// qualifier cases at `Src/glob.c:1468-1535`:
5009/// `if (idigit(*s)) data = qgetnum(&s);` else `get_strarg`-delimited name
5010/// resolved through `getpwnam(3)` / `getgrnam(3)`. On an unknown name it
5011/// emits the C diagnostic (`zerr` sets errflag) and returns 0, matching
5012/// C's `data = 0;` arm. `is_group` selects getgrnam over getpwnam.
5013fn parse_uid_gid(chars: &mut std::iter::Peekable<std::str::Chars>, is_group: bool) -> u32 {
5014    // c:1468/1511 — `if (idigit(*s)) data = qgetnum(&s);`
5015    if chars.peek().map(|c| c.is_ascii_digit()).unwrap_or(false) {
5016        let mut num = String::new();
5017        while let Some(&c) = chars.peek() {
5018            if c.is_ascii_digit() {
5019                num.push(c);
5020                chars.next();
5021            } else {
5022                break;
5023            }
5024        }
5025        return num.parse().unwrap_or(0);
5026    }
5027    // c:1471-1505 — `tt = get_strarg(s, &arglen);` the char right after the
5028    // qualifier is the delimiter; the name runs to the next delimiter.
5029    let delim = match chars.next() {
5030        Some(d) => d,
5031        None => {
5032            // c:1481/1524 — `zerr("missing delimiter for 'u'/'g' glob qualifier")`.
5033            zerr(if is_group {
5034                "missing delimiter for 'g' glob qualifier"
5035            } else {
5036                "missing delimiter for 'u' glob qualifier"
5037            });
5038            return 0;
5039        }
5040    };
5041    let mut name = String::new();
5042    for c in chars.by_ref() {
5043        if c == delim {
5044            break;
5045        }
5046        name.push(c);
5047    }
5048    // c:1488/1530 — resolve via getpwnam(3) / getgrnam(3).
5049    let cstr = match std::ffi::CString::new(name.as_bytes()) {
5050        Ok(c) => c,
5051        Err(_) => return 0,
5052    };
5053    unsafe {
5054        if is_group {
5055            let gr = libc::getgrnam(cstr.as_ptr()); // c:1530
5056            if !gr.is_null() {
5057                return (*gr).gr_gid; // c:1531
5058            }
5059            zerr("unknown group"); // c:1533
5060        } else {
5061            let pw = libc::getpwnam(cstr.as_ptr()); // c:1488
5062            if !pw.is_null() {
5063                return (*pw).pw_uid; // c:1489
5064            }
5065            // c:1491 — `zerr("unknown username '%s'", s + arglen);`
5066            zerr(&format!("unknown username '{}'", name));
5067        }
5068    }
5069    // c:1493/1535 — `data = 0;` after the unknown-name diagnostic.
5070    0
5071}
5072
5073/// Parse the unit/op/value tail of an `(L...)` size qualifier.
5074/// **RUST-ONLY** — C parses inline in parsepat; the size-unit
5075/// constants are `TT_BYTES`/`TT_KILOBYTES`/… at glob.c:128-133.
5076fn parse_size_spec(
5077    // RUST-ONLY
5078    chars: &mut std::iter::Peekable<std::str::Chars>,
5079) -> (i32, char, u64) {
5080    let unit: i32 = match chars.peek() {
5081        Some('p') | Some('P') => {
5082            chars.next();
5083            TT_POSIX_BLOCKS
5084        }
5085        Some('k') | Some('K') => {
5086            chars.next();
5087            TT_KILOBYTES
5088        }
5089        Some('m') | Some('M') => {
5090            chars.next();
5091            TT_MEGABYTES
5092        }
5093        Some('g') | Some('G') => {
5094            chars.next();
5095            TT_GIGABYTES
5096        }
5097        Some('t') | Some('T') => {
5098            chars.next();
5099            TT_TERABYTES
5100        }
5101        _ => TT_BYTES,
5102    };
5103    let (op, val) = parse_range_spec(chars);
5104    (unit, op, val)
5105}
5106
5107/// Parse the unit/op/value tail of an `(a/m/c...)` time qualifier.
5108/// **RUST-ONLY** — C parses inline in parsepat; time-unit constants
5109/// are `TT_SECONDS`/`TT_MINS`/… at glob.c:121-126.
5110fn schedgetfn(
5111    // RUST-ONLY (clashes with sched.c:341 name, unrelated fn)
5112    chars: &mut std::iter::Peekable<std::str::Chars>,
5113) -> (i32, char, u64) {
5114    let unit: i32 = match chars.peek() {
5115        Some('s') => {
5116            chars.next();
5117            TT_SECONDS
5118        }
5119        Some('m') => {
5120            chars.next();
5121            TT_MINS
5122        }
5123        Some('h') => {
5124            chars.next();
5125            TT_HOURS
5126        }
5127        Some('d') => {
5128            chars.next();
5129            TT_DAYS
5130        }
5131        Some('w') => {
5132            chars.next();
5133            TT_WEEKS
5134        }
5135        Some('M') => {
5136            chars.next();
5137            TT_MONTHS
5138        }
5139        _ => TT_DAYS,
5140    };
5141    let (op, val) = parse_range_spec(chars);
5142    (unit, op, val)
5143}
5144
5145/// Parse `[+-]?NUMBER` operator+value tail. Mirrors C qgetnum
5146/// (glob.c:827) which returns the int value; here we return the
5147/// operator char along with the value since callers need both.
5148fn parse_range_spec(chars: &mut std::iter::Peekable<std::str::Chars>) -> (char, u64) {
5149    // RUST-ONLY
5150    // C's qgetnum at glob.c:827 returns the operator char inline.
5151    // `+N` = greater, `-N` = less, bare digits = equal.
5152    let op: char = match chars.peek() {
5153        Some('+') => {
5154            chars.next();
5155            '>'
5156        }
5157        Some('-') => {
5158            chars.next();
5159            '<'
5160        }
5161        _ => '=',
5162    };
5163    let mut num = String::new();
5164    while let Some(&c) = chars.peek() {
5165        if c.is_ascii_digit() {
5166            num.push(c);
5167            chars.next();
5168        } else {
5169            break;
5170        }
5171    }
5172    // c:Src/glob.c:826-834 — `qgetnum` errors with "number expected"
5173    // when not followed by `idigit(**s)`. Previously the Rust parser
5174    // silently treated the missing number as 0 and let the outer
5175    // qualifier loop continue consuming bytes as fresh qualifier
5176    // letters — `*(Lr)` parsed as `L0 + r` (readable, size=0) and
5177    // dropped to the no-matches path. Setting errflag here propagates
5178    // the canonical error message and gates the "no matches found"
5179    // emission in zglob (c:1843-1854 `if (errflag) ... return`).
5180    if num.is_empty() {
5181        crate::ported::utils::zerr("number expected"); // c:832
5182        crate::ported::utils::errflag.fetch_or(
5183            crate::ported::zsh_h::ERRFLAG_ERROR,
5184            std::sync::atomic::Ordering::SeqCst,
5185        );
5186        return (op, 0); // c:833
5187    }
5188    let val = num.parse().unwrap_or(0);
5189    (op, val)
5190}
5191
5192/// Parse `[FIRST,LAST]` subscript range. **RUST-ONLY**.
5193fn parse_subscript(
5194    // RUST-ONLY
5195    chars: &mut std::iter::Peekable<std::str::Chars>,
5196) -> (Option<i32>, Option<i32>) {
5197    let mut first_str = String::new();
5198    let mut last_str = String::new();
5199    let mut in_last = false;
5200
5201    while let Some(&c) = chars.peek() {
5202        chars.next();
5203        if c == ']' {
5204            break;
5205        } else if c == ',' {
5206            in_last = true;
5207        } else if in_last {
5208            last_str.push(c);
5209        } else {
5210            first_str.push(c);
5211        }
5212    }
5213
5214    let first = first_str.parse().ok();
5215    let last = if in_last {
5216        last_str.parse().ok()
5217    } else {
5218        first
5219    };
5220    (first, last)
5221}
5222
5223/// Port of `static void insert(char *s, int checked)` from `Src/glob.c:346`.
5224/// Records one matched path into the glob result list: runs the qualifier
5225/// walk (`check_qualifiers` = the c:381-419 `struct qual` walk over the
5226/// arena) and, if accepted, builds the `gmatch` entry from the file's
5227/// stat and appends it (c:421-490) — honoring any `$reply`/`$REPLY`
5228/// replacement (INSERTS, c:428) set by an `(e:…:)` eval qualifier.
5229/// Called by the scanner per matched name, mirroring glob.c:576/668.
5230pub fn insert(state: &mut globdata, s: &Path, checked: i32) {
5231    use std::os::unix::fs::MetadataExt;
5232    let _ = checked; // c:347 already-stat'd hint; check_qualifiers re-stats.
5233                     // c:381-419 — qualifier walk; reject the file if it fails.
5234    if !check_qualifiers(state, s) {
5235        return;
5236    }
5237    // c:421-490 — build + append the match entry. Symlink targets get
5238    // their own stat for the GS_LINKED (follow-link) sort variants.
5239    let Ok(meta) = fs::symlink_metadata(s) else {
5240        return;
5241    };
5242    let name = s
5243        .file_name()
5244        .map(|n| n.to_string_lossy().to_string())
5245        .unwrap_or_default();
5246    let (tsize, tatime, tmtime, tctime, tlinks, tansec, tmnsec, tcnsec) =
5247        if meta.file_type().is_symlink() {
5248            if let Ok(tm) = fs::metadata(s) {
5249                (
5250                    tm.size(),
5251                    tm.atime(),
5252                    tm.mtime(),
5253                    tm.ctime(),
5254                    tm.nlink(),
5255                    tm.atime_nsec(),
5256                    tm.mtime_nsec(),
5257                    tm.ctime_nsec(),
5258                )
5259            } else {
5260                (
5261                    meta.size(),
5262                    meta.atime(),
5263                    meta.mtime(),
5264                    meta.ctime(),
5265                    meta.nlink(),
5266                    meta.atime_nsec(),
5267                    meta.mtime_nsec(),
5268                    meta.ctime_nsec(),
5269                )
5270            }
5271        } else {
5272            (
5273                meta.size(),
5274                meta.atime(),
5275                meta.mtime(),
5276                meta.ctime(),
5277                meta.nlink(),
5278                meta.atime_nsec(),
5279                meta.mtime_nsec(),
5280                meta.ctime_nsec(),
5281            )
5282        };
5283    // c:428 — `while (!inserts || (news = *inserts++))`: an (e:…:) eval
5284    // may have set $reply/$REPLY (INSERTS), replacing this match with
5285    // zero-or-more names; otherwise emit the original name once. The
5286    // synthetic path makes glob_emit_path yield the replacement string;
5287    // the stat fields stay those of the matched file (C keeps
5288    // matchptr->size etc. from buf).
5289    let emit: Vec<(String, PathBuf)> = match INSERTS.lock().unwrap().take() {
5290        Some(list) => list
5291            .into_iter()
5292            .map(|n| (n.clone(), PathBuf::from(&n)))
5293            .collect(),
5294        None => vec![(name.clone(), s.to_path_buf())],
5295    };
5296    for (nm, pth) in emit {
5297        state.matches.push(gmatch {
5298            name: nm,
5299            path: pth,
5300            size: meta.size(),
5301            atime: meta.atime(),
5302            mtime: meta.mtime(),
5303            ctime: meta.ctime(),
5304            links: meta.nlink(),
5305            mode: meta.mode(),
5306            uid: meta.uid(),
5307            gid: meta.gid(),
5308            dev: meta.dev(),
5309            ino: meta.ino(),
5310            target_size: tsize,
5311            target_atime: tatime,
5312            target_mtime: tmtime,
5313            target_ctime: tctime,
5314            target_links: tlinks,
5315            ansec: meta.atime_nsec(),
5316            mnsec: meta.mtime_nsec(),
5317            cnsec: meta.ctime_nsec(),
5318            target_ansec: tansec,
5319            target_mnsec: tmnsec,
5320            target_cnsec: tcnsec,
5321            sort_strings: Vec::new(),
5322        });
5323        state.matchct += 1; // c:479 matchptr++
5324    }
5325}
5326
5327/// Drive the OR-of-AND qualifier filter against `path`. **RUST-ONLY**
5328/// — C glob.c does qualifier eval inline inside `insert()` (c:381+).
5329fn check_qualifiers(state: &globdata, path: &Path) -> bool {
5330    use std::os::unix::fs::MetadataExt;
5331    use std::sync::atomic::Ordering;
5332    // c:353 — `inserts = NULL;` at insert() entry: clear any reply/REPLY
5333    // replacement left by a previous file's (e:…:) eval before this walk.
5334    *INSERTS.lock().unwrap() = None;
5335    let qs = match &state.qualifiers {
5336        Some(q) => q,
5337        None => return true,
5338    };
5339    // No test qualifiers (only flags/sort/words) → no filtering.
5340    let arena = &qs.quals;
5341    if arena.head.is_none() {
5342        return true;
5343    }
5344
5345    let meta = match if qs.follow_links {
5346        // c:399-402 — `if (!S_ISLNK(buf.st_mode) || statfullpath(s,&buf2,0))
5347        //                  memcpy(&buf2, &buf, sizeof(buf));`
5348        // Follow the link, but fall back to the lstat buffer when the
5349        // target stat fails (a broken symlink) so `*(-@)` / `*(-^/)`
5350        // still see the dangling link rather than dropping it.
5351        fs::metadata(path).or_else(|_| fs::symlink_metadata(path))
5352    } else {
5353        fs::symlink_metadata(path)
5354    } {
5355        Ok(m) => m,
5356        Err(_) => return false,
5357    };
5358    // Bridge meta → libc::stat for the leaf test fns (no extra syscall).
5359    let mut st: libc::stat = unsafe { std::mem::zeroed() };
5360    st.st_mode = meta.mode() as _;
5361    st.st_uid = meta.uid() as _;
5362    st.st_gid = meta.gid() as _;
5363    st.st_dev = meta.dev() as _;
5364    st.st_nlink = meta.nlink() as _;
5365    st.st_size = meta.size() as _;
5366    st.st_atime = meta.atime() as _;
5367    st.st_mtime = meta.mtime() as _;
5368    st.st_ctime = meta.ctime() as _;
5369    // qualsheval / qualnonemptydir need the name (REPLY / opendir).
5370    let name = glob_emit_path(path);
5371
5372    // c:387-419 — insert()'s qual-walk over the `struct qual` arena: AND
5373    // via `next`, OR (alternatives) via `or`, per-node `sense`. `qo` is
5374    // the current alternative head; a rejected node advances to `qo->or`
5375    // (next alternative) or, if none, rejects the file. Falling off the
5376    // `next` chain (or hitting a func-less terminator) accepts.
5377    let mut qo = arena.head;
5378    let mut qn = qo;
5379    loop {
5380        let Some(qn_i) = qn else { return true }; // c:419 — chain matched
5381        let (func, data, sense, range, amc, units, sdata, next) = {
5382            let n = &arena.nodes[qn_i];
5383            (
5384                n.func,
5385                n.data,
5386                n.sense,
5387                n.range,
5388                n.amc,
5389                n.units,
5390                n.sdata.clone(),
5391                n.next,
5392            )
5393        };
5394        let func = match func {
5395            Some(f) => f,
5396            None => return true, // c:392 — `qn && qn->func` terminator
5397        };
5398        // c:393-395 — set the scratch the range/time/size leaf fns read.
5399        g_range.store(range, Ordering::SeqCst);
5400        g_amc.store(amc, Ordering::SeqCst);
5401        g_units.store(units, Ordering::SeqCst);
5402        let r = func(&name, &st, data, sdata.as_deref().unwrap_or(""));
5403        // c:407-409 — reject if `(!(func()) ^ sense) & 1`.
5404        let reject = ((if r == 0 { 1 } else { 0 }) ^ (sense & 1)) & 1 == 1;
5405        if reject {
5406            // c:411-415 — try the next alternative, else reject the file.
5407            match arena.nodes[qo.unwrap()].or {
5408                Some(or_i) => {
5409                    qo = Some(or_i);
5410                    qn = Some(or_i);
5411                }
5412                None => return false,
5413            }
5414        } else {
5415            qn = next; // c:417
5416        }
5417    }
5418}
5419
5420/// Sort the per-state matches per the qualifier `o`/`O` keys.
5421/// **RUST-ONLY** — C does this inline at the end of `scanner()`
5422/// (glob.c:1700ish, qsort with `gmatchcmp` comparator).
5423fn sort_matches(state: &mut globdata) {
5424    // RUST-ONLY
5425    // Default sort is GS_NAME ascending (c:204 gf_sortlist
5426    // initial setup). Per-qualifier `o<key>` / `O<key>` overrides.
5427    // c:1855-1857 — when gf_nsorts == 0 (no explicit o/O qualifier),
5428    // glob.c falls back to GS_NAME (or GS_NONE under shortcircuit).
5429    // Mirror that here so a qualifier set carrying ONLY non-sort
5430    // qualifiers (`Lk+0`, etc.) still sorts alphabetically.
5431    // c:1855-1857 is a CONDITIONAL default, not a fixed one:
5432    //     if (!gf_nsorts) {
5433    //         gf_sortlist[0].tp = gf_sorts = (shortcircuit ? GS_NONE : GS_NAME);
5434    //         gf_nsorts = 1;
5435    //     }
5436    // A `Y` limit means "give me the first N the scanner finds", so with no
5437    // explicit o/O spec the default becomes GS_NONE and the results stay in
5438    // SCAN order — sorting them would defeat the short-circuit, since the first
5439    // N by name are not the first N found. This always chose GS_NAME, so a
5440    // limited glob returned the alphabetically-first N instead of the
5441    // scan-order-first N, and even `*(Y99)` (a limit larger than the match
5442    // count, which discards nothing) came back sorted where zsh leaves it in
5443    // readdir order.
5444    //
5445    // `Y0` is the counter-case and must still sort: c:1583 parses the argument
5446    // over `shortcircuit`, so 0 lands there legitimately and c:518's
5447    // `if (shortcircuit && …)` reads it as "no limit" — hence the `> 0` test
5448    // rather than `.is_some()`.
5449    let limited = state
5450        .qualifiers
5451        .as_ref()
5452        .and_then(|q| q.short_circuit)
5453        .is_some_and(|n| n > 0); // c:1856 `shortcircuit ?`
5454    let default_spec = if limited { GS_NONE } else { GS_NAME }; // c:1856
5455    let mut specs: Vec<i32> = state
5456        .qualifiers
5457        .as_ref()
5458        .map(|q| {
5459            if q.sorts.is_empty() {
5460                vec![default_spec] // c:1856
5461            } else {
5462                q.sorts.clone()
5463            }
5464        })
5465        .unwrap_or_else(|| vec![GS_NAME]);
5466
5467    // GS_NONE marker — caller wants no sort at all.
5468    if specs.iter().any(|&tp| (tp & GS_NONE) != 0) {
5469        return;
5470    }
5471
5472    // c:936 gmatchcmp returns 0 when every sort key ties. In C the final
5473    // order of equal-key matches is then whatever the libc `qsort` does with
5474    // all-equal elements (glob.c:1976) — NON-stable on macOS/BSD (arbitrary,
5475    // input-dependent: `*(oL)` on three 0-byte files a/b/d prints `d a b`),
5476    // and on glibc it depends on readdir order. So zsh has NO portable,
5477    // defined tie-break here. zshrs is a cross-architecture shell and must be
5478    // DETERMINISTIC, so we impose an explicit GS_NAME final tie-breaker:
5479    // equal-key matches order by name ascending, identical on every platform.
5480    // This is an intentional determinism guarantee, NOT a bug-for-bug match of
5481    // zsh's qsort-defined order (which can't be reproduced portably). Skip
5482    // when the last key is already GS_NAME (the no-qualifier default, or an
5483    // explicit trailing `on`) so we don't double-compare.
5484    if specs.last().map_or(true, |&last| (last & !GS_DESC) != GS_NAME) {
5485        specs.push(GS_NAME); // deterministic name-ascending tie-break
5486    }
5487
5488    // c:1258/1575 — gf_numsort starts at the global NUMERIC_GLOB_SORT
5489    // option, then a per-glob `(n)`/`(^n)` qualifier OVERRIDES it
5490    // absolutely (so `(^n)` forces lexical even when the option is on).
5491    let numeric = state
5492        .qualifiers
5493        .as_ref()
5494        .and_then(|q| q.numsort)
5495        .unwrap_or_else(|| glob_isset(NUMERICGLOBSORT));
5496    state
5497        .matches
5498        .sort_by(|a, b| gmatchcmp(a, b, &specs, numeric));
5499}
5500
5501/// Apply `[FIRST,LAST]` qualifier subscript on the match list.
5502/// **RUST-ONLY** — C uses `gd_pre_first` / `gd_first` index tracking
5503/// during scanner emit; here we slice after the full walk.
5504fn apply_selection(state: &mut globdata) {
5505    // RUST-ONLY
5506    let (first, last, short_circuit) = match &state.qualifiers {
5507        Some(q) => (q.first, q.last, q.short_circuit),
5508        None => return,
5509    };
5510
5511    let len = state.matches.len() as i32;
5512    if len == 0 {
5513        return;
5514    }
5515
5516    let start = match first {
5517        Some(f) if f < 0 => (len + f).max(0) as usize,
5518        Some(f) => (f - 1).max(0) as usize,
5519        None => 0,
5520    };
5521
5522    let end = match last {
5523        Some(l) if l < 0 => (len + l + 1).max(0) as usize,
5524        Some(l) => l.min(len) as usize,
5525        None => len as usize,
5526    };
5527
5528    if start < end && start < state.matches.len() {
5529        state.matches = state.matches[start..end.min(state.matches.len())].to_vec();
5530    } else {
5531        state.matches.clear();
5532    }
5533
5534    // c:Src/glob.c:1579-1595 `case 'Y'` — apply the short-circuit
5535    // limit after the [first,last] slice (so `(Y3[2,4])` would yield
5536    // up to 3 from the [2,4] subscript range). C truncates DURING
5537    // the scanner walk via `if (shortcircuit == matchct) break;` —
5538    // here we slice post-walk which produces the same set on the
5539    // common short-glob case where the scanner doesn't recurse
5540    // unboundedly. Bug #41 in docs/BUGS.md.
5541    // NOTE: the `Y` short-circuit is deliberately NOT applied here. It bounds
5542    // the SCAN (c:Src/glob.c:518), so it must run BEFORE the sort — see the
5543    // truncation at the sort_matches call site. Only the `[first,last]`
5544    // subscript belongs in this post-sort selection.
5545    let _ = short_circuit;
5546}
5547
5548// `haswilds()` is defined in `Src/pattern.c:4306`, not glob.c — the
5549// canonical Rust port lives at `crate::ported::pattern::haswilds`.
5550// This file previously carried a divergent re-implementation tracking
5551// bracket/escape state instead of the token-aware `zpc_disables[]` /
5552// SHGLOB / KSHGLOB / EXTENDEDGLOB checks the C source actually does.
5553// Drift deleted; glob.rs callers route through the canonical port.
5554
5555#[cfg(test)]
5556mod gs_tt_tests {
5557    use super::*;
5558
5559    #[test]
5560    fn gs_size_offset_matches_c() {
5561        let _g = crate::test_util::global_state_lock();
5562        // c:83 — GS_SIZE = GS_SHIFT_BASE = 8.
5563        assert_eq!(GS_SIZE, 8);
5564        // c:84 — GS_ATIME = GS_SHIFT_BASE << 1 = 16.
5565        assert_eq!(GS_ATIME, 16);
5566        // c:87 — GS_LINKS = GS_SHIFT_BASE << 4 = 128.
5567        assert_eq!(GS_LINKS, 128);
5568    }
5569
5570    #[test]
5571    fn gs_normal_covers_all_size_keys() {
5572        let _g = crate::test_util::global_state_lock();
5573        // c:99 — GS_NORMAL = SIZE | ATIME | MTIME | CTIME | LINKS.
5574        assert_ne!(GS_NORMAL & GS_SIZE, 0);
5575        assert_ne!(GS_NORMAL & GS_ATIME, 0);
5576        assert_ne!(GS_NORMAL & GS_MTIME, 0);
5577        assert_ne!(GS_NORMAL & GS_CTIME, 0);
5578        assert_ne!(GS_NORMAL & GS_LINKS, 0);
5579    }
5580
5581    #[test]
5582    fn tt_namespaces_share_indices() {
5583        let _g = crate::test_util::global_state_lock();
5584        // c:121-126 vs c:128-133 — TT_DAYS == TT_BYTES == 0, etc.
5585        assert_eq!(TT_DAYS, TT_BYTES);
5586        assert_eq!(TT_HOURS, TT_POSIX_BLOCKS);
5587        assert_eq!(TT_MINS, TT_KILOBYTES);
5588        assert_eq!(TT_WEEKS, TT_MEGABYTES);
5589        assert_eq!(TT_MONTHS, TT_GIGABYTES);
5590        assert_eq!(TT_SECONDS, TT_TERABYTES);
5591    }
5592
5593    #[test]
5594    fn max_sorts_is_12() {
5595        let _g = crate::test_util::global_state_lock();
5596        assert_eq!(MAX_SORTS, 12);
5597    }
5598}
5599
5600fn expand_range(
5601    prefix: &str,
5602    content: &str,
5603    dotdot_pos: usize,
5604    suffix: &str,
5605) -> Option<Vec<String>> {
5606    // c:Src/glob.c::xpandbraces parses brace-range endpoints via
5607    // zstrtol, which is TOKEN-aware and treats Dash TOKEN (\u{9b})
5608    // as ASCII `-`. Rust's i64::parse only accepts ASCII, so
5609    // untokenize the content here before splitting/parsing — matches
5610    // C semantics without weakening the strict-TOKEN gates above.
5611    let owned: String;
5612    let content: &str = if content.chars().any(|c| {
5613        let cu = c as u32;
5614        (0x84..=0xa1).contains(&cu) && c != '\u{8f}' && c != '\u{90}' && c != '\u{9a}'
5615    }) {
5616        owned = crate::lex::untokenize(content);
5617        &owned
5618    } else {
5619        content
5620    };
5621    // `dotdot_pos` is a CHAR index (xpandbraces counts it over a char vector,
5622    // `i - start - 1`), but the slicing below is byte-based. For ASCII content
5623    // they coincide; for content holding multibyte/metafied chars (e.g.
5624    // `{$'\x80'..$'\x81'}`, where the bytes are Meta-escaped) a raw byte slice
5625    // at the char index lands mid-char and panics. Convert to a byte offset.
5626    let dotdot_byte = content
5627        .char_indices()
5628        .nth(dotdot_pos)
5629        .map(|(b, _)| b)
5630        .unwrap_or(content.len());
5631    let left = &content[..dotdot_byte];
5632    let right_start = dotdot_byte + 2; // ".." is two ASCII bytes
5633
5634    // Check for second `..` for `{N..M..S}` step form. Step may be
5635    // signed: negative-step REVERSES the natural direction sequence
5636    // per zsh's brace expansion (Src/glob.c::xpandbraces recursive
5637    // iteration with sign tracking). Examples:
5638    //   {1..32..3}   →  1,4,7,…,31 (natural ascending)
5639    //   {1..32..-3}  → 31,28,…,1   (same set, reversed)
5640    //   {32..1..3}   → 32,29,…,2   (natural descending)
5641    //   {32..1..-3}  →  2,5,…,32   (same set, reversed)
5642    //
5643    // c:Src/glob.c:2365-2369 — `if (dotdot == 2 && *p == '.' &&`
5644    //                         `    p[1] == '.') {`
5645    //                         `    rincr = zstrtol(p+2, &p, 10);`
5646    //                         `    wid3 = p - dots2 - 2;`
5647    //                         `    if (p != str2 || !rincr)`
5648    //                         `        err++;`
5649    // The `!rincr` test (c:2368) rejects a zero step: when err > 0,
5650    // the C code falls past the c:2374 `if (!err)` block, so the
5651    // brace expansion is abandoned and the literal pattern is
5652    // preserved. Parity bug #15: previously the Rust port silently
5653    // clamped step to 1 via `.max(1)`, producing `1 2 3 4 5` for
5654    // `{1..5..0}` instead of zsh's literal `1..5..0`.
5655    let (right, incr_abs, incr_sign_negative, step_text) =
5656        if let Some(pos) = content[right_start..].find("..") {
5657            let r = &content[right_start..right_start + pos];
5658            let s_text = &content[right_start + pos + 2..];
5659            let raw: i64 = s_text.parse().unwrap_or(1);
5660            if raw == 0 {
5661                // c:2368 — `!rincr` → err++ → no expansion. Return
5662                // None so xpandbraces falls back to literal.
5663                return None;
5664            }
5665            (r, raw.unsigned_abs(), raw < 0, s_text)
5666        } else {
5667            (&content[right_start..], 1u64, false, "")
5668        };
5669
5670    // Try numeric range
5671    if let (Ok(start), Ok(end)) = (left.parse::<i64>(), right.parse::<i64>()) {
5672        let mut results = Vec::new();
5673
5674        // Iterate from `start` toward `end` with abs(step). Sign of
5675        // start/end relative to each other determines natural
5676        // direction; step is always |step|.
5677        let step = incr_abs.max(1) as i64;
5678        let mut vals: Vec<i64> = Vec::new();
5679        if start <= end {
5680            let mut v = start;
5681            while v <= end {
5682                vals.push(v);
5683                v += step;
5684            }
5685        } else {
5686            let mut v = start;
5687            while v >= end {
5688                vals.push(v);
5689                v -= step;
5690            }
5691        }
5692        if incr_sign_negative {
5693            vals.reverse();
5694        }
5695
5696        // Padding: zsh pads with leading zeros when ANY of the three
5697        // textual fields (left endpoint, right endpoint, step) has a
5698        // leading zero after stripping the optional sign. Width is
5699        // the max textual width across left/right/step. For negative
5700        // values, the sign prefix counts toward width — we emit `-`
5701        // then zero-pad the remaining digits (`-02`, not `0-2`).
5702        // Direct port of Src/lex.c::dobrace_pad logic which detects
5703        // pad mode per the textual form of all three fields.
5704        // zsh's pad-detection: pad fires when any of the three
5705        // textual fields (left / right / step) is a multi-digit
5706        // form with a leading zero (e.g. "01", "00", "003"), with
5707        // ONE exception: when the LEFT endpoint is exactly "0"
5708        // (bare single zero, possibly signed), pad is suppressed
5709        // regardless of right's form. Empirical zsh:
5710        //   {0..-5}    → 0 -1 -2 -3 -4 -5    (left=0 → no pad)
5711        //   {0..03}    → 0 1 2 3              (left=0 → no pad)
5712        //   {00..-5}   → 00 -1 -2 -3 -4 -5    (left=00 → pad w=2)
5713        //   {1..03}    → 01 02 03             (right=03 → pad w=2)
5714        //   {1..-03}   → 001 000 -01 -02 -03  (right=-03 → pad w=3)
5715        let lstrip = left.trim_start_matches(['+', '-']);
5716        let rstrip = right.trim_start_matches(['+', '-']);
5717        let sstrip = step_text.trim_start_matches(['+', '-']);
5718        let is_padded_field = |stripped: &str| stripped.len() >= 2 && stripped.starts_with('0');
5719        // Suppress pad ONLY when LEFT is exactly the single-char "0"
5720        // (no sign, no extra digits). "-0" or "00" both pad.
5721        let left_is_bare_zero = left == "0";
5722        let pad = !left_is_bare_zero
5723            && (is_padded_field(lstrip)
5724                || is_padded_field(rstrip)
5725                || (!step_text.is_empty() && is_padded_field(sstrip)));
5726        let width = left.len().max(right.len()).max(step_text.len());
5727
5728        for v in vals {
5729            let formatted = if pad {
5730                if v < 0 {
5731                    let abs = (-v).to_string();
5732                    let inner_w = width.saturating_sub(1);
5733                    format!("-{:0>w$}", abs, w = inner_w)
5734                } else {
5735                    format!("{:0>w$}", v, w = width)
5736                }
5737            } else {
5738                v.to_string()
5739            };
5740            results.push(format!("{}{}{}", prefix, formatted, suffix));
5741        }
5742        return Some(results);
5743    }
5744
5745    // Try character range
5746    // c:Src/lex.c::dobrace alpha-range path — zsh only handles bare a..z,
5747    // NOT a..z..N (it emits literal `{a..z..3}` because the step form is
5748    // numeric-only). bash DOES support an alpha step (`{a..e..2}` → a c e),
5749    // so honor `step_text` only under --bash; --zsh keeps refusing it so the
5750    // literal survives (verified: real zsh emits `{a..e..2}` unchanged).
5751    // bash ignores the step's SIGN for alpha (`{a..e..-2}` == `{a..e..2}`),
5752    // so use abs(step) and take direction purely from start vs end.
5753    let alpha_step_ok = step_text.is_empty() || crate::dash_mode::bash_mode();
5754    if left.len() == 1 && right.len() == 1 && alpha_step_ok {
5755        let start = left.chars().next()?;
5756        let end = right.chars().next()?;
5757        let step = incr_abs.max(1) as u32;
5758        let (lo, hi, reverse) = if start <= end {
5759            (start, end, false)
5760        } else {
5761            (end, start, true)
5762        };
5763
5764        let mut results = Vec::new();
5765        let mut chars: Vec<char> = Vec::new();
5766        let mut v = lo as u32;
5767        while v <= hi as u32 {
5768            if let Some(c) = char::from_u32(v) {
5769                chars.push(c);
5770            }
5771            v += step;
5772        }
5773        if reverse {
5774            chars.reverse();
5775        }
5776
5777        for c in chars {
5778            results.push(format!("{}{}{}", prefix, c, suffix));
5779        }
5780        return Some(results);
5781    }
5782
5783    None
5784}
5785
5786fn expand_comma(
5787    prefix: &str,
5788    content: &str,
5789    positions: &[usize],
5790    suffix: &str,
5791) -> Option<Vec<String>> {
5792    // positions are CHAR indices into `content` (per the xpandbraces
5793    // walker above that builds them from `chars: Vec<char>`). Slice
5794    // by char-index, not byte-index — `&content[last..pos]` would
5795    // panic on multi-byte token chars like Comma TOKEN (`\u{9a}`,
5796    // 2 UTF-8 bytes) and Inbrace (`\u{8f}`, 2 bytes).
5797    let chars: Vec<char> = content.chars().collect();
5798    let mut results = Vec::new();
5799    let mut last: usize = 0;
5800    for &pos in positions {
5801        let part: String = chars[last..pos].iter().collect();
5802        results.push(format!("{}{}{}", prefix, part, suffix));
5803        last = pos + 1;
5804    }
5805    let tail: String = chars[last..].iter().collect();
5806    results.push(format!("{}{}{}", prefix, tail, suffix));
5807    Some(results)
5808}
5809
5810fn expand_ccl(prefix: &str, content: &str, suffix: &str) -> Option<Vec<String>> {
5811    // c:Src/glob.c:expand_ccl — char-class range expansion for the
5812    // `setopt braceccl` brace shape `{m-o}` → m,n,o. Accept both
5813    // ASCII `-` and Dash TOKEN (\u{9b}) as the range separator —
5814    // the bridge passthru path delivers `{m-o}` with Dash TOKEN
5815    // since the lexer tokenizes `-` to Dash inside word context.
5816    let mut chars_set = HashSet::new();
5817    let chars: Vec<char> = content.chars().collect();
5818    let mut i = 0;
5819
5820    while i < chars.len() {
5821        let is_range = i + 2 < chars.len() && (chars[i + 1] == '-' || chars[i + 1] == '\u{9b}');
5822        if is_range {
5823            let start = chars[i];
5824            let end = chars[i + 2];
5825            for c in start..=end {
5826                chars_set.insert(c);
5827            }
5828            i += 3;
5829        } else {
5830            chars_set.insert(chars[i]);
5831            i += 1;
5832        }
5833    }
5834
5835    let mut results: Vec<String> = chars_set
5836        .iter()
5837        .map(|c| format!("{}{}{}", prefix, c, suffix))
5838        .collect();
5839    results.sort();
5840    Some(results)
5841}
5842
5843// ============================================================================
5844// Convenience functions
5845// ============================================================================
5846
5847/// Split a pattern at its trailing zsh-style qualifier suffix. Returns
5848/// `(pattern_without_qualifier, qualifier_inner)` — the inner is the bytes
5849/// between the matching parens, without the surrounding `()` and without
5850/// any leading `#q`. Returns `(pattern, None)` when there is no qualifier
5851/// suffix. Useful for callers that want to use the pattern half with the
5852/// runtime [`matchpat`] (which has no qualifier semantics) while
5853/// reporting or applying the qualifier separately.
5854/// Split a glob pattern into (path-pattern, qualifier-string).
5855/// Port of the qualifier-detection step in `parsepat()`
5856/// (Src/glob.c:791).
5857pub fn split_qualifier(pattern: &str) -> (&str, Option<&str>) {
5858    if !pattern.ends_with(')') {
5859        return (pattern, None);
5860    }
5861    let bytes = pattern.as_bytes();
5862    let mut depth = 0;
5863    for i in (0..bytes.len()).rev() {
5864        match bytes[i] {
5865            b')' => depth += 1,
5866            b'(' => {
5867                depth -= 1;
5868                if depth == 0 {
5869                    let inner = &pattern[i + 1..pattern.len() - 1];
5870                    let inner = inner.strip_prefix("#q").unwrap_or(inner);
5871                    return (&pattern[..i], Some(inner));
5872                }
5873            }
5874            _ => {}
5875        }
5876    }
5877    (pattern, None)
5878}
5879
5880/// Strip redundant `.` / `CurDir` segments from relative match paths for
5881/// output. Rust's `read_dir(".")` yields `entry.path()` like `./foo` while
5882/// `read_dir("foo")` yields `foo/bar` — zsh prints the latter shape for both.
5883fn glob_emit_path(path: &Path) -> String {
5884    match path.components().next() {
5885        Some(Component::Prefix(_) | Component::RootDir) => path.to_string_lossy().to_string(),
5886        None => ".".to_string(),
5887        _ => {
5888            let mut out = PathBuf::new();
5889            for c in path.components() {
5890                match c {
5891                    Component::CurDir => {}
5892                    Component::ParentDir => out.push(".."),
5893                    Component::Normal(s) => out.push(s),
5894                    Component::Prefix(_) | Component::RootDir => {}
5895                }
5896            }
5897            if out.as_os_str().is_empty() {
5898                ".".to_string()
5899            } else {
5900                out.to_string_lossy().to_string()
5901            }
5902        }
5903    }
5904}
5905
5906/// Check if path is a symlink
5907/// Check whether a glob match is a symlink.
5908/// Port of the `S_ISLNK(lstat.st_mode)` test in Src/glob.c.
5909pub fn is_symlink(path: &str) -> bool {
5910    fs::symlink_metadata(path)
5911        .map(|m| m.file_type().is_symlink())
5912        .unwrap_or(false)
5913}
5914
5915// ===========================================================
5916// Methods moved verbatim from src/ported/vm_helper because their
5917// C counterpart's source file maps 1:1 to this Rust module.
5918// Phase: glob
5919// ===========================================================
5920
5921// BEGIN moved-from-exec-rs
5922// (impl ShellExecutor block moved to src/exec_shims.rs — see file marker)
5923
5924// END moved-from-exec-rs
5925
5926// ===========================================================
5927// Methods moved verbatim from src/ported/vm_helper because their
5928// C counterpart's source file maps 1:1 to this Rust module.
5929// Phase: drift
5930// ===========================================================
5931
5932// BEGIN moved-from-exec-rs
5933// (impl ShellExecutor block moved to src/exec_shims.rs — see file marker)
5934
5935// END moved-from-exec-rs
5936
5937/// !!! RUST-ONLY adapter — NO DIRECT C COUNTERPART !!!
5938///
5939/// Thin "pattern string in, match list out" entry over the glob engine
5940/// for call sites holding a single pattern that want its matches as a
5941/// `Vec<String>`, rather than going through C's in-place
5942/// `zglob(LinkList, LinkNode, int)` list mutation. Snapshots the
5943/// glob-relevant options into TLS (`enter_glob_scope` — required because
5944/// zshrs runs glob on multiple threads; see that fn's doc) and drives
5945/// `globdata_glob`, the RUST-ONLY read_dir-based adaptation of C's
5946/// `scanner()` (Src/glob.c:500 — C descends with `lchdir`, which is
5947/// process-global and unsafe under zshrs's threading, so the port walks
5948/// absolute paths instead). ALL real glob semantics — brace expansion,
5949/// `(a|b)` alternation, `^`/`~` exclusion, qualifiers, `**`, sorting —
5950/// live in `globdata_glob`/`scanner`/`patcompile`, not here. (The 130
5951/// lines of ad-hoc `^`/`~`/alternation read_dir+matchpat code that used
5952/// to live here were verified redundant with `globdata_glob` and
5953/// removed.) The faithful C entry is `zglob` (c:1214).
5954pub fn glob_path(pattern: &str) -> Vec<String> {
5955    let _glob_scope = enter_glob_scope();
5956    let mut state = globdata::new();
5957    globdata_glob(&mut state, pattern)
5958}
5959
5960// The qualifier-comparison direction static is `g_range` (Src/glob.c, ported at
5961// glob.rs ~2799). The duplicate `G_RANGE` that used to live here was a Rust-only
5962// erroneous second copy — never written by the qual-eval, so qualnlink (its only
5963// reader) was stuck on `==`. Removed; qualnlink now reads `g_range`.
5964
5965// `mode_to_octal` lives at `crate::ported::utils::mode_to_octal`
5966// (port of `Src/utils.c:7634` — 12 bit-by-bit POSIX permission
5967// mappings). Local alias kept for the call sites at c:1610/1618
5968// which used the masked identity before the canonical port landed.
5969fn mode_to_octal(mode: u32) -> u32 {
5970    crate::ported::utils::mode_to_octal(mode) as u32
5971}
5972
5973#[cfg(test)]
5974mod tests {
5975    use super::*;
5976    use std::fs::{self, File};
5977    use tempfile::TempDir;
5978
5979    use crate::ported::options::{opt_state_set, opt_state_unset};
5980    use crate::ported::zsh_h::{redir, ERRFLAG_ERROR, REDIR_WRITE};
5981
5982    /// Convert ASCII brace-expansion source to the lexer-tokenized
5983    /// form `hasbraces` / `xpandbraces` consume per c:Src/glob.c —
5984    /// ASCII `{` → Inbrace (\u{8f}), `}` → Outbrace (\u{90}), `,` →
5985    /// Comma (\u{9a}). Backslash-escaped variants (`\{`, `\}`, `\,`)
5986    /// emit Bnull + literal so the canonical "escape via Bnull"
5987    /// distinction reaches the brace walker. Used by every test in
5988    /// this module that wants to drive the canonical TOKEN-strict
5989    /// path with readable ASCII source.
5990    fn tok(s: &str) -> String {
5991        let mut out = String::with_capacity(s.len());
5992        let mut chars = s.chars().peekable();
5993        while let Some(c) = chars.next() {
5994            match c {
5995                '\\' => match chars.peek() {
5996                    Some('{') | Some('}') | Some(',') => {
5997                        out.push('\u{9f}');
5998                        out.push(chars.next().unwrap());
5999                    }
6000                    _ => out.push(c),
6001                },
6002                '{' => out.push('\u{8f}'),
6003                '}' => out.push('\u{90}'),
6004                ',' => out.push('\u{9a}'),
6005                _ => out.push(c),
6006            }
6007        }
6008        out
6009    }
6010
6011    fn setup_test_dir() -> TempDir {
6012        let dir = TempDir::new().unwrap();
6013        let base = dir.path();
6014
6015        // Create test files
6016        File::create(base.join("file1.txt")).unwrap();
6017        File::create(base.join("file2.txt")).unwrap();
6018        File::create(base.join("file3.rs")).unwrap();
6019        File::create(base.join(".hidden")).unwrap();
6020
6021        // Create subdirectory
6022        fs::create_dir(base.join("subdir")).unwrap();
6023        File::create(base.join("subdir/nested.txt")).unwrap();
6024
6025        dir
6026    }
6027
6028    #[test]
6029    fn test_haswilds() {
6030        let _g = crate::test_util::global_state_lock();
6031        // c:Src/glob.c:3548 — full glob tokenize (shadows the
6032        // brace-only `fn tok` test helper above on purpose:
6033        // haswilds consumes glob tokens, not just braces).
6034        let tok = |s: &str| {
6035            let mut t = s.to_string();
6036            tokenize(&mut t);
6037            t
6038        };
6039        assert!(haswilds(&tok("*.txt")));
6040        assert!(haswilds(&tok("file?.txt")));
6041        assert!(haswilds(&tok("file[12].txt")));
6042        assert!(!haswilds(&tok("file.txt")));
6043        assert!(!haswilds(&tok("path/to/file.txt")));
6044    }
6045
6046    #[test]
6047    fn test_pattern_match() {
6048        let _g = crate::test_util::global_state_lock();
6049        assert!(matchpat("*.txt", "file.txt", false, true));
6050        assert!(matchpat("file?.txt", "file1.txt", false, true));
6051        assert!(!matchpat("*.txt", "file.rs", false, true));
6052        assert!(matchpat("file[12].txt", "file1.txt", false, true));
6053        assert!(!matchpat("file[12].txt", "file3.txt", false, true));
6054    }
6055
6056    #[test]
6057    fn test_brace_expansion() {
6058        let _g = crate::test_util::global_state_lock();
6059        let result = xpandbraces(&tok("{a,b,c}"), false);
6060        assert_eq!(result, vec!["a", "b", "c"]);
6061
6062        let result = xpandbraces(&tok("file{1,2,3}.txt"), false);
6063        assert_eq!(result, vec!["file1.txt", "file2.txt", "file3.txt"]);
6064
6065        let result = xpandbraces(&tok("{1..5}"), false);
6066        assert_eq!(result, vec!["1", "2", "3", "4", "5"]);
6067
6068        let result = xpandbraces(&tok("{a..e}"), false);
6069        assert_eq!(result, vec!["a", "b", "c", "d", "e"]);
6070    }
6071
6072    #[test]
6073    fn test_glob_simple() {
6074        let _g = crate::test_util::global_state_lock();
6075        let dir = setup_test_dir();
6076        let pattern = format!("{}/*.txt", dir.path().display());
6077
6078        let mut state = globdata::new();
6079        let results = globdata_glob(&mut state, &pattern);
6080
6081        assert_eq!(results.len(), 2);
6082        assert!(results.iter().any(|s| s.ends_with("file1.txt")));
6083        assert!(results.iter().any(|s| s.ends_with("file2.txt")));
6084    }
6085
6086    #[test]
6087    fn test_glob_hidden() {
6088        let _g = crate::test_util::global_state_lock();
6089        let dir = setup_test_dir();
6090        let pattern = format!("{}/*", dir.path().display());
6091
6092        // Default (globdots off) → hidden files skipped. `dotglob`
6093        // is the bash alias for zsh's canonical `globdots` option;
6094        // the C-faithful read goes through `isset(GLOBDOTS)` which
6095        // resolves the canonical name.
6096        opt_state_set("globdots", false);
6097        let mut state = globdata::new();
6098        let results = globdata_glob(&mut state, &pattern);
6099        assert!(!results.iter().any(|s| s.contains(".hidden")));
6100
6101        // setopt globdots → hidden files included.
6102        opt_state_set("globdots", true);
6103        let mut state = globdata::new();
6104        let results = globdata_glob(&mut state, &pattern);
6105        assert!(results.iter().any(|s| s.contains(".hidden")));
6106        opt_state_set("globdots", false); // reset for other tests
6107    }
6108
6109    #[test]
6110    fn test_glob_emit_path_strips_read_dir_dot_slash() {
6111        let _g = crate::test_util::global_state_lock();
6112        assert_eq!(glob_emit_path(Path::new("./sub")), "sub");
6113        assert_eq!(glob_emit_path(Path::new("sub/deeper")), "sub/deeper");
6114        assert_eq!(glob_emit_path(Path::new("././x")), "x");
6115        assert_eq!(glob_emit_path(Path::new("../up")), "../up");
6116    }
6117
6118    #[test]
6119    fn test_file_type_char() {
6120        let _g = crate::test_util::global_state_lock();
6121        assert_eq!(file_type(libc::S_IFDIR as u32), '/');
6122        assert_eq!(file_type(libc::S_IFREG as u32), ' ');
6123        assert_eq!(file_type(libc::S_IFREG as u32 | 0o111), '*');
6124        assert_eq!(file_type(libc::S_IFLNK as u32), '@');
6125    }
6126
6127    /// `Src/glob.c:2018-2036` — `file_type(mode_t filemode)` returns a
6128    /// single-char marker keyed off S_ISBLK/CHR/DIR/FIFO/LNK/REG/SOCK,
6129    /// with `S_IXUGO` (== 0o111 = S_IXUSR|S_IXGRP|S_IXOTH) distinguishing
6130    /// executable regular files (`*`) from non-executable (` `). The
6131    /// catch-all (e.g. door, port, unknown types) returns `?`.
6132    /// Pin every branch by position.
6133    #[test]
6134    fn file_type_every_branch_matches_c_dispatch() {
6135        let _g = crate::test_util::global_state_lock();
6136        assert_eq!(
6137            file_type(libc::S_IFBLK as u32),
6138            '#',
6139            "c:2020 — S_ISBLK → '#'"
6140        );
6141        assert_eq!(
6142            file_type(libc::S_IFCHR as u32),
6143            '%',
6144            "c:2022 — S_ISCHR → '%'"
6145        );
6146        assert_eq!(
6147            file_type(libc::S_IFDIR as u32),
6148            '/',
6149            "c:2024 — S_ISDIR → '/'"
6150        );
6151        assert_eq!(
6152            file_type(libc::S_IFIFO as u32),
6153            '|',
6154            "c:2026 — S_ISFIFO → '|'"
6155        );
6156        assert_eq!(
6157            file_type(libc::S_IFLNK as u32),
6158            '@',
6159            "c:2028 — S_ISLNK → '@'"
6160        );
6161        // Regular file: ' ' if not executable, '*' if executable (any bit in S_IXUGO).
6162        assert_eq!(
6163            file_type(libc::S_IFREG as u32),
6164            ' ',
6165            "c:2030 — non-executable regular file → ' '"
6166        );
6167        // Each individual exec bit triggers '*'.
6168        assert_eq!(
6169            file_type(libc::S_IFREG as u32 | 0o100),
6170            '*',
6171            "c:2030 — S_IXUSR alone is enough"
6172        );
6173        assert_eq!(
6174            file_type(libc::S_IFREG as u32 | 0o010),
6175            '*',
6176            "c:2030 — S_IXGRP alone is enough"
6177        );
6178        assert_eq!(
6179            file_type(libc::S_IFREG as u32 | 0o001),
6180            '*',
6181            "c:2030 — S_IXOTH alone is enough"
6182        );
6183        assert_eq!(
6184            file_type(libc::S_IFSOCK as u32),
6185            '=',
6186            "c:2033 — S_ISSOCK → '='"
6187        );
6188        // Catch-all for unknown S_IFMT — bare 0 returns '?'.
6189        assert_eq!(file_type(0), '?', "c:2035 — unknown st_mode → '?'");
6190    }
6191
6192    #[test]
6193    fn test_zstrcmp_numeric() {
6194        let _g = crate::test_util::global_state_lock();
6195        let n = crate::zsh_h::SORTIT_NUMERICALLY as u32;
6196        assert_eq!(zstrcmp("file1", "file2", n), std::cmp::Ordering::Less);
6197        assert_eq!(zstrcmp("file10", "file2", n), std::cmp::Ordering::Greater);
6198        assert_eq!(zstrcmp("file10", "file10", n), std::cmp::Ordering::Equal);
6199    }
6200
6201    /// Race-fix verification: snapshot pins bareglobqual for the
6202    /// duration of a glob scope even when the live store flips
6203    /// underneath. Mimics the stryke pattern in
6204    /// MenkeTechnologies/strykelang#3 — one thread is mid-glob with
6205    /// bareglobqual=1 snapshot when another flips it off.
6206    #[test]
6207    fn glob_opts_snapshot_isolates_concurrent_setopt() {
6208        let _g = crate::test_util::global_state_lock();
6209
6210        // Preserve the existing live state so the test is hermetic.
6211        let saved = opt_state_get("bareglobqual");
6212
6213        // Live store says bareglobqual=true; enter scope captures that.
6214        opt_state_set("bareglobqual", true);
6215        let _scope = enter_glob_scope();
6216        assert!(
6217            glob_isset(BAREGLOBQUAL),
6218            "TLS snapshot reads bareglobqual=true at scope entry"
6219        );
6220
6221        // Simulate a concurrent thread flipping the live store.
6222        opt_state_set("bareglobqual", false);
6223        assert!(
6224            glob_isset(BAREGLOBQUAL),
6225            "TLS snapshot must still report bareglobqual=true \
6226             even though live store now reads false"
6227        );
6228
6229        // Restore.
6230        match saved {
6231            Some(v) => opt_state_set("bareglobqual", v),
6232            None => opt_state_unset("bareglobqual"),
6233        }
6234    }
6235
6236    /// After the scope guard drops, reads fall back to the live store.
6237    #[test]
6238    fn glob_opts_snapshot_clears_on_drop() {
6239        let _g = crate::test_util::global_state_lock();
6240
6241        let saved = opt_state_get("nullglob");
6242        opt_state_set("nullglob", true);
6243        {
6244            let _scope = enter_glob_scope();
6245            assert!(glob_isset(NULLGLOB), "snapshot live=true → true inside");
6246        }
6247        // Outside scope: flip live store, read should follow live.
6248        opt_state_set("nullglob", false);
6249        assert!(
6250            !glob_isset(NULLGLOB),
6251            "post-scope: glob_isset falls back to live store"
6252        );
6253
6254        match saved {
6255            Some(v) => opt_state_set("nullglob", v),
6256            None => opt_state_unset("nullglob"),
6257        }
6258    }
6259
6260    /// Nested glob scopes share the outer snapshot — inner doesn't
6261    /// re-capture or clear on its own Drop.
6262    #[test]
6263    fn glob_opts_snapshot_nested_is_noop() {
6264        let _g = crate::test_util::global_state_lock();
6265
6266        let saved = opt_state_get("extendedglob");
6267        opt_state_set("extendedglob", true);
6268        let _outer = enter_glob_scope();
6269        assert!(glob_isset(EXTENDEDGLOB));
6270        {
6271            let _inner = enter_glob_scope();
6272            // Live store flip while nested.
6273            opt_state_set("extendedglob", false);
6274            assert!(glob_isset(EXTENDEDGLOB), "inner observes outer snapshot");
6275        } // inner drops — outer snapshot still active.
6276        assert!(
6277            glob_isset(EXTENDEDGLOB),
6278            "outer snapshot survives inner drop"
6279        );
6280
6281        match saved {
6282            Some(v) => opt_state_set("extendedglob", v),
6283            None => opt_state_unset("extendedglob"),
6284        }
6285    }
6286
6287    /// c:2150 (xpandredir port) — when the redir name has no wildcard
6288    /// AND no `$var`/`!hist`/`{a,b}` to expand, prefork+globlist leave
6289    /// the name unchanged. The single-match path at c:2171 must
6290    /// rewrite `fn.name` to the same string and return 0 (no multi-fan).
6291    /// Regression that returns 1 would trigger MULTIOS dispatch on a
6292    /// single literal filename — wrong shell semantics.
6293    #[test]
6294    fn xpandredir_single_literal_filename_returns_zero() {
6295        let _g = crate::test_util::global_state_lock();
6296        let mut fn_ = redir {
6297            typ: REDIR_WRITE,
6298            flags: 0,
6299            fd1: 1,
6300            fd2: -1,
6301            name: Some("/tmp/zshrs_test_out".to_string()),
6302            varid: None,
6303            here_terminator: None,
6304            munged_here_terminator: None,
6305        };
6306        let mut tab: Vec<redir> = Vec::new();
6307        let r = xpandredir(&mut fn_, &mut tab);
6308        assert_eq!(r, 0, "literal filename → single match → ret=0");
6309        assert_eq!(
6310            fn_.name.as_deref(),
6311            Some("/tmp/zshrs_test_out"),
6312            "literal name must round-trip through prefork unchanged"
6313        );
6314        assert!(tab.is_empty(), "no multi-match → redirtab not appended");
6315    }
6316
6317    /// c:2176-2177 — `>&-` (REDIR_MERGEOUT with name "-") collapses to
6318    /// REDIR_CLOSE per the `IS_DASH(s[0]) && !s[1]` branch. Regression
6319    /// where this fails leaves `>&-` as a literal merge-fd attempt,
6320    /// which the executor would interpret as "merge with fd -1".
6321    #[test]
6322    fn xpandredir_dash_merge_collapses_to_close() {
6323        let _g = crate::test_util::global_state_lock();
6324        let mut fn_ = redir {
6325            typ: REDIR_MERGEOUT,
6326            flags: 0,
6327            fd1: 1,
6328            fd2: -1,
6329            name: Some("-".to_string()),
6330            varid: None,
6331            here_terminator: None,
6332            munged_here_terminator: None,
6333        };
6334        let mut tab: Vec<redir> = Vec::new();
6335        let _ = xpandredir(&mut fn_, &mut tab);
6336        assert_eq!(
6337            fn_.typ, REDIR_CLOSE,
6338            "`>&-` must rewrite typ to REDIR_CLOSE"
6339        );
6340    }
6341
6342    /// c:2150 — empty `fn.name` should return 0 cleanly. Catches a
6343    /// regression that panics on `.as_deref().unwrap()` for absent name.
6344    #[test]
6345    fn xpandredir_with_no_name_returns_zero_no_panic() {
6346        let _g = crate::test_util::global_state_lock();
6347        let mut fn_ = redir {
6348            typ: REDIR_WRITE,
6349            flags: 0,
6350            fd1: 1,
6351            fd2: -1,
6352            name: None,
6353            varid: None,
6354            here_terminator: None,
6355            munged_here_terminator: None,
6356        };
6357        let mut tab: Vec<redir> = Vec::new();
6358        assert_eq!(xpandredir(&mut fn_, &mut tab), 0);
6359    }
6360
6361    /// `IN_EXPANDREDIR` static defaults to 0 — set transiently inside
6362    /// xpandredir per c:2165/2167. After a normal call it must restore
6363    /// to 0. Regression that leaks the flag would skew unrelated glob
6364    /// expansions outside redirections.
6365    #[test]
6366    fn in_expandredir_flag_is_zero_at_rest() {
6367        let _g = crate::test_util::global_state_lock();
6368        assert_eq!(IN_EXPANDREDIR.load(Ordering::SeqCst), 0);
6369    }
6370
6371    /// c:4306 — `haswilds` honours backslash-escapes: `\*` is a literal
6372    /// star, NOT a wildcard. Regression that ignores the escape would
6373    /// trigger globbing on `printf '%s\n' \*`, breaking shell scripts
6374    /// that quote literal stars.
6375    #[test]
6376    fn haswilds_respects_backslash_escape() {
6377        let _g = crate::test_util::global_state_lock();
6378        // c:Src/glob.c:3548 — full glob tokenize (shadows the
6379        // brace-only `fn tok` test helper above on purpose:
6380        // haswilds consumes glob tokens, not just braces).
6381        let tok = |s: &str| {
6382            let mut t = s.to_string();
6383            tokenize(&mut t);
6384            t
6385        };
6386        assert!(haswilds(&tok("*.txt")), "bare * is wild");
6387        assert!(
6388            !haswilds(&tok(r"\*.txt")),
6389            "escaped \\* is literal — NOT wild"
6390        );
6391        assert!(
6392            !haswilds(&tok(r"\?.txt")),
6393            "escaped \\? is literal — NOT wild"
6394        );
6395    }
6396
6397    /// c:4306 — `[` immediately enters bracket mode AND counts as a
6398    /// wildcard. The early return on `[` is critical — even
6399    /// unterminated brackets must be flagged so `cd [` doesn't try
6400    /// to chdir to a literal `[`.
6401    #[test]
6402    fn haswilds_open_bracket_alone_is_a_wildcard() {
6403        let _g = crate::test_util::global_state_lock();
6404        // c:Src/glob.c:3548 — full glob tokenize (shadows the
6405        // brace-only `fn tok` test helper above on purpose:
6406        // haswilds consumes glob tokens, not just braces).
6407        let tok = |s: &str| {
6408            let mut t = s.to_string();
6409            tokenize(&mut t);
6410            t
6411        };
6412        assert!(haswilds(&tok("[abc]")), "char-class is wild");
6413        assert!(haswilds(&tok("foo[")), "even unterminated [ is wild");
6414    }
6415
6416    /// c:4306 — wildcard chars `*` `?` inside an OPEN bracket-context
6417    /// are NOT additional wildcards (the bracket already is one).
6418    /// Regression that double-counts would make haswilds report `[*]`
6419    /// as wildcard-twice — cosmetic, but confuses any caller using
6420    /// the bool as a "should I glob?" gate that AND-tests with another
6421    /// flag.
6422    #[test]
6423    fn haswilds_extglob_chars_inside_bracket_dont_double_count() {
6424        let _g = crate::test_util::global_state_lock();
6425        // c:Src/glob.c:3548 — full glob tokenize (shadows the
6426        // brace-only `fn tok` test helper above on purpose:
6427        // haswilds consumes glob tokens, not just braces).
6428        let tok = |s: &str| {
6429            let mut t = s.to_string();
6430            tokenize(&mut t);
6431            t
6432        };
6433        // Once `[` is seen, function returns true immediately, so the
6434        // post-bracket chars don't matter. But this docs the contract.
6435        assert!(haswilds(&tok("[*]")));
6436    }
6437
6438    /// c:4306 — plain text returns false. Catches a regression where
6439    /// any non-empty input is flagged as wild (would break `cd /tmp`
6440    /// by triggering glob expansion on a literal path).
6441    #[test]
6442    fn haswilds_plain_text_not_wild() {
6443        let _g = crate::test_util::global_state_lock();
6444        // c:Src/glob.c:3548 — full glob tokenize (shadows the
6445        // brace-only `fn tok` test helper above on purpose:
6446        // haswilds consumes glob tokens, not just braces).
6447        let tok = |s: &str| {
6448            let mut t = s.to_string();
6449            tokenize(&mut t);
6450            t
6451        };
6452        assert!(!haswilds(&tok("plain")));
6453        assert!(!haswilds(&tok("")));
6454        assert!(!haswilds(&tok("/usr/local/bin")));
6455        assert!(!haswilds(&tok("file.txt")));
6456    }
6457
6458    /// c:4363-4371 — `#` and `^` are recognised as wildcards by
6459    /// `haswilds` **only when `EXTENDEDGLOB` is set**, matching the C
6460    /// source's `isset(EXTENDEDGLOB)` gate at pattern.c:4364/4369. `~`
6461    /// is **not** a filename-generation wildcard (zsh handles it as
6462    /// tilde expansion in a separate pipeline stage); a prior
6463    /// glob.rs-local `haswilds` impl returned true for `~`, which
6464    /// caused zglob to mis-classify tilde-prefixed args as needing
6465    /// filename generation. Pin the corrected behavior.
6466    #[test]
6467    fn haswilds_extended_glob_chars_recognised() {
6468        let _g = crate::test_util::global_state_lock();
6469        // c:Src/glob.c:3548 — full glob tokenize (shadows the
6470        // brace-only `fn tok` test helper above on purpose:
6471        // haswilds consumes glob tokens, not just braces).
6472        let tok = |s: &str| {
6473            let mut t = s.to_string();
6474            tokenize(&mut t);
6475            t
6476        };
6477        // EXTENDEDGLOB off → `#` and `^` are not wild.
6478        crate::ported::options::opt_state_set("extendedglob", false);
6479        assert!(
6480            !haswilds(&tok("foo#bar")),
6481            "# not wild without EXTENDEDGLOB"
6482        );
6483        assert!(
6484            !haswilds(&tok("foo^bar")),
6485            "^ not wild without EXTENDEDGLOB"
6486        );
6487        // EXTENDEDGLOB on → `#` and `^` are wild (c:4364, c:4369).
6488        crate::ported::options::opt_state_set("extendedglob", true);
6489        assert!(haswilds(&tok("foo#bar")), "# is extglob wild");
6490        assert!(haswilds(&tok("foo^bar")), "^ is extglob wild");
6491        crate::ported::options::opt_state_set("extendedglob", false);
6492        // `~` is NOT in haswilds' switch (c:4324-4373) — tilde expansion
6493        // is a separate pipeline stage.
6494        assert!(
6495            !haswilds(&tok("~/file")),
6496            "~ is NOT a filename-generation wildcard"
6497        );
6498    }
6499
6500    /// c:2514 — `matchpat` returns true for exact match. Sanity check
6501    /// the simplest dispatch path (the matcher recipe builds a
6502    /// trivial pattern, runs it).
6503    #[test]
6504    fn matchpat_exact_literal_matches() {
6505        let _g = crate::test_util::global_state_lock();
6506        assert!(matchpat("hello", "hello", false, true));
6507        assert!(!matchpat("hello", "world", false, true));
6508    }
6509
6510    /// c:2514 — `matchpat` with case_sensitive=false MUST treat upper
6511    /// and lower as equal (`HELLO` matches `hello`). Regression keeping
6512    /// case-sensitive when flag is false would silently break every
6513    /// `[[ "$x" = (#i)foo ]]`-style match.
6514    #[test]
6515    fn matchpat_case_insensitive_when_flag_clear() {
6516        let _g = crate::test_util::global_state_lock();
6517        assert!(
6518            matchpat("hello", "HELLO", false, false),
6519            "case-insensitive match must succeed across cases"
6520        );
6521        assert!(matchpat("FoO", "foo", false, false));
6522    }
6523
6524    /// c:2514 — case-sensitive (default) MUST reject case-different
6525    /// inputs. Pinning the contract.
6526    #[test]
6527    fn matchpat_case_sensitive_rejects_case_different() {
6528        let _g = crate::test_util::global_state_lock();
6529        assert!(
6530            !matchpat("hello", "HELLO", false, true),
6531            "case-sensitive default must reject HELLO != hello"
6532        );
6533    }
6534
6535    /// c:2780 — `set_pat_start(p, offs)` sets `PAT_NOTSTART` when offs is
6536    /// nonzero (matched substring starts past the real start, so `(#s)`
6537    /// must fail) and clears it when offs is 0. A regression here would
6538    /// let `(#s)` anchors fire mid-string during substring globbing.
6539    #[test]
6540    fn set_pat_start_toggles_pat_notstart_flag() {
6541        let _g = crate::test_util::global_state_lock();
6542        let mut p = mk_test_patprog();
6543        set_pat_start(&mut p, 2);
6544        assert!(
6545            p.0.flags & PAT_NOTSTART != 0,
6546            "offs!=0 must set PAT_NOTSTART"
6547        );
6548        set_pat_start(&mut p, 0);
6549        assert!(
6550            p.0.flags & PAT_NOTSTART == 0,
6551            "offs==0 must clear PAT_NOTSTART"
6552        );
6553    }
6554
6555    /// c:2796 — `set_pat_end(p, null_me)` sets `PAT_NOTEND` when the char
6556    /// being zapped is non-NUL (string shortened at tail, so `(#e)` must
6557    /// fail) and clears it when that char is already NUL. A regression
6558    /// would let `(#e)` anchors fire before the real end.
6559    #[test]
6560    fn set_pat_end_toggles_pat_notend_flag() {
6561        let _g = crate::test_util::global_state_lock();
6562        let mut p = mk_test_patprog();
6563        set_pat_end(&mut p, b'x');
6564        assert!(
6565            p.0.flags & PAT_NOTEND != 0,
6566            "non-NUL null_me must set PAT_NOTEND"
6567        );
6568        set_pat_end(&mut p, 0);
6569        assert!(
6570            p.0.flags & PAT_NOTEND == 0,
6571            "NUL null_me must clear PAT_NOTEND"
6572        );
6573    }
6574
6575    /// Build a zeroed `Patprog` for flag-toggle tests.
6576    fn mk_test_patprog() -> Patprog {
6577        Box::new((
6578            crate::ported::zsh_h::patprog {
6579                startoff: 0,
6580                size: 0,
6581                mustoff: 0,
6582                patmlen: 0,
6583                globflags: 0,
6584                globend: 0,
6585                flags: 0,
6586                patnpar: 0,
6587                patstartch: 0,
6588            },
6589            Vec::new(),
6590        ))
6591    }
6592
6593    /// c:2773 — `freematchlist(None)` is a no-op (matches C's
6594    /// `if (repllist) freelinklist(...)`). Regression panicking on
6595    /// None would crash every glob-replace path with no matches.
6596    #[test]
6597    fn freematchlist_handles_none_safely() {
6598        let _g = crate::test_util::global_state_lock();
6599        freematchlist(None);
6600        // No assertion — survival is the test.
6601    }
6602
6603    /// c:2773 — `freematchlist(Some(&mut Vec))` clears the list.
6604    /// Regression that drops a stale entry would leak match positions
6605    /// across calls.
6606    #[test]
6607    fn freematchlist_clears_provided_vec() {
6608        let _g = crate::test_util::global_state_lock();
6609        let mut v = vec![
6610            repldata {
6611                b: 0,
6612                e: 5,
6613                replstr: None,
6614            },
6615            repldata {
6616                b: 10,
6617                e: 15,
6618                replstr: Some("x".to_string()),
6619            },
6620        ];
6621        freematchlist(Some(&mut v));
6622        assert!(v.is_empty(), "freematchlist must clear the input vec");
6623    }
6624
6625    /// `Src/glob.c:2042-2142` — `hasbraces(str)` returns true when
6626    /// `str` contains a brace-expansion candidate. A bare lbrace/rbrace
6627    /// is NOT a brace expansion (it's a literal). Detection requires a
6628    /// matched pair containing either a comma or a dotdot range. Pin
6629    /// the canonical contract.
6630    #[test]
6631    fn hasbraces_matched_pair_with_comma_or_dotdot() {
6632        let _g = crate::test_util::global_state_lock();
6633        // Matched + comma → true.
6634        assert!(
6635            hasbraces(&tok("a{b,c}d"), false),
6636            "c:2127 — lbrace + comma + rbrace is a brace expansion"
6637        );
6638        // Matched + dotdot → true.
6639        assert!(
6640            hasbraces(&tok("file{1..3}.txt"), false),
6641            "c:2082 — N..M range is a brace expansion"
6642        );
6643        // Matched WITHOUT comma/dotdot → false (it's a literal).
6644        assert!(
6645            !hasbraces(&tok("{abc}"), false),
6646            "literal braces are NOT a brace expansion without comma or dotdot"
6647        );
6648        // Unmatched → false.
6649        assert!(
6650            !hasbraces(&tok("{abc"), false),
6651            "lone lbrace without matching rbrace is not brace expansion"
6652        );
6653        assert!(
6654            !hasbraces(&tok("abc}"), false),
6655            "lone rbrace is not brace expansion"
6656        );
6657        // No braces → false.
6658        assert!(!hasbraces(&tok("plain"), false));
6659        assert!(!hasbraces(&tok(""), false));
6660    }
6661
6662    /// `Src/glob.c:2049-2063` — BRACECCL branch: when `isset(BRACECCL)`
6663    /// (the `brace_ccl` parameter), any non-empty matched braces
6664    /// become a character-class set, regardless of whether the body
6665    /// contains a comma. Empty pair is still not.
6666    #[test]
6667    fn hasbraces_brace_ccl_makes_any_pair_match() {
6668        let _g = crate::test_util::global_state_lock();
6669        // c:2049 — BRACECCL: non-empty pair is enough.
6670        assert!(
6671            hasbraces(&tok("{abc}"), true),
6672            "c:2049 — BRACECCL: non-empty pair is char-class set"
6673        );
6674        assert!(
6675            hasbraces(&tok("x{q}y"), true),
6676            "c:2049 — single-char pair counts under BRACECCL"
6677        );
6678        // Empty pair shouldn't trigger.
6679        assert!(
6680            !hasbraces(&tok("{}"), true),
6681            "empty pair still not a brace expansion even under BRACECCL"
6682        );
6683        // Without BRACECCL, plain literal-letter pair is NOT a brace expansion.
6684        assert!(
6685            !hasbraces(&tok("{abc}"), false),
6686            "c:2049 — BRACECCL off → plain literal pair stays literal"
6687        );
6688    }
6689
6690    /// `Src/glob.c:2042-2142` — depth tracking: comma/dotdot count
6691    /// only at depth==1 in our port. So nested inner comma doesn't
6692    /// qualify the outer pair, but TWO independent top-level pairs
6693    /// (each with comma) DO trigger detection.
6694    #[test]
6695    fn hasbraces_depth_1_check_for_comma_dotdot() {
6696        let _g = crate::test_util::global_state_lock();
6697        assert!(
6698            hasbraces(&tok("a{1,2}b{3,4}c"), false),
6699            "two independent top-level pairs, first one matches at depth 1"
6700        );
6701    }
6702
6703    /// Pin: `remnulargs` per `Src/glob.c:3649-3679`:
6704    ///   - Strings with NO inull bytes are unchanged.
6705    ///   - Strings with ONLY Bnullkeep: keep as-is in scan phase
6706    ///     (Bnullkeep is "active backslash" that hasn't triggered
6707    ///     copy phase yet).
6708    ///   - After encountering any other inull: switch to copy phase:
6709    ///     * Bnullkeep → '\\' (literal backslash).
6710    ///     * Other inulls (Snull/Dnull/Bnull/Nularg) → stripped.
6711    ///     * Non-inull → kept.
6712    ///   - Empty post-strip → replaced with single Nularg.
6713    #[test]
6714    fn remnulargs_matches_c_inull_handling() {
6715        let _g = crate::test_util::global_state_lock();
6716
6717        // Plain ASCII unchanged (no inulls).
6718        let mut s = "hello".to_string();
6719        remnulargs(&mut s);
6720        assert_eq!(
6721            s, "hello",
6722            "c:3654 — no inull bytes leaves string unchanged"
6723        );
6724
6725        // Snull-triggered copy: Snull stripped, rest kept.
6726        let mut s = format!("ab{}cd", Snull);
6727        remnulargs(&mut s);
6728        assert_eq!(
6729            s, "abcd",
6730            "c:3663 — Snull triggers copy; itself stripped, rest kept"
6731        );
6732
6733        // Bnullkeep AFTER Snull trigger → '\\' (active backslash).
6734        let mut s = format!("ab{}c{}d", Snull, Bnullkeep);
6735        remnulargs(&mut s);
6736        assert_eq!(
6737            s, "abc\\d",
6738            "c:3666 — Bnullkeep in copy phase becomes literal '\\\\'"
6739        );
6740
6741        // Other inulls (Bnull, Dnull) stripped in copy phase.
6742        let mut s = format!("a{}b{}c{}d", Snull, Bnull, Dnull);
6743        remnulargs(&mut s);
6744        assert_eq!(s, "abcd", "c:3668 — Bnull/Dnull stripped in copy phase");
6745
6746        // Empty post-strip → Nularg.
6747        let mut s = format!("{}", Snull);
6748        remnulargs(&mut s);
6749        assert_eq!(
6750            s,
6751            format!("{}", Nularg),
6752            "c:3674 — empty result replaced by Nularg sentinel"
6753        );
6754    }
6755
6756    /// `Src/glob.c:1085-1117` — `glob_exec_string` is a PARSER, not
6757    /// an executor. Extracts the qualifier inner text from `*sp`
6758    /// (advancing past delimiters). Previous Rust port forked
6759    /// `/bin/sh` to execute the cmd — that's entirely the wrong
6760    /// layer; execution happens at the call sites via qualsheval.
6761    /// Pin: plus-form extracts identifier; delimited form extracts
6762    /// content + advances.
6763    #[test]
6764    fn glob_exec_string_parses_qualifier_text() {
6765        let _g = crate::test_util::global_state_lock();
6766        // Plus form: identifier ends at first non-ident char.
6767        // C: `(+myfunc:rest)` — `s` points past `+`, plus_form=true.
6768        let r = glob_exec_string("myfunc rest", true);
6769        assert!(r.is_some(), "c:1092 — identifier parse should succeed");
6770        let (ident, _adv) = r.unwrap();
6771        assert_eq!(
6772            ident, "myfunc",
6773            "c:1092 — itype_end stops at first non-IIDENT char"
6774        );
6775
6776        // Plus form with no identifier (immediate non-ident) — error.
6777        let r = glob_exec_string(" leading-space", true);
6778        assert!(
6779            r.is_none(),
6780            "c:1093-1096 — empty identifier emits zerr + returns None"
6781        );
6782    }
6783
6784    /// `Src/glob.c:3907-3943` — `qualsheval(name, _, _, expr)` sets
6785    /// `$REPLY` via paramtab, evaluates `expr` IN THE CURRENT SHELL,
6786    /// then restores errflag+lastval. Previous Rust port spawned
6787    /// `/bin/sh -c expr` which:
6788    ///   1. Couldn't access shell locals / functions / aliases.
6789    ///   2. Ran `sh` (POSIX), not `zsh` — every zsh-feature qualifier
6790    ///      silently failed.
6791    ///
6792    /// Pin: after qualsheval, errflag is restored (mod ERRFLAG_INT),
6793    /// lastval is restored, and `$REPLY` is set to the filename.
6794    /// Easiest direct pin: errflag/lastval restore around an `expr`
6795    /// that mutates them.
6796    #[test]
6797    fn qualsheval_restores_errflag_and_lastval() {
6798        let _g = crate::test_util::global_state_lock();
6799        // Seed errflag and lastval with distinctive values.
6800        errflag.store(0, Ordering::Relaxed);
6801        LASTVAL.store(42, Ordering::Relaxed);
6802        // Even if the expr mutates these via the executor, c:3924-3925
6803        // restore them after.
6804        let st0: libc::stat = unsafe { std::mem::zeroed() };
6805        let _ = qualsheval("/tmp/file", &st0, 0, ":"); // no-op expr
6806                                                       // c:3924 — errflag restored.
6807        assert_eq!(
6808            errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR,
6809            0,
6810            "c:3924 — qualsheval must restore errflag (no ERRFLAG_ERROR leak)"
6811        );
6812        // c:3925 — lastval restored.
6813        assert_eq!(
6814            LASTVAL.load(Ordering::Relaxed),
6815            42,
6816            "c:3925 — qualsheval must restore lastval to pre-call value"
6817        );
6818        // c:3916 — $REPLY visible in paramtab.
6819        assert_eq!(
6820            crate::ported::params::getsparam("REPLY"),
6821            Some("/tmp/file".to_string()),
6822            "c:3916 — qualsheval must set $REPLY to filename"
6823        );
6824    }
6825
6826    /// `Src/glob.c:2164` — `if (!errflag && isset(MULTIOS))` is the
6827    /// logical zero-check guarding globlist recursion. Previous Rust
6828    /// port used `!errflag.load(...) != 0` which is BITWISE NOT
6829    /// (`^-1`), evaluating to `errflag != -1` — almost-always-true.
6830    ///
6831    /// Pin: a `matchpat` smoke that DOES NOT mutate errflag, then a
6832    /// direct check that the fix sense is correct (errflag==0 enters
6833    /// the branch, errflag!=0 skips). Can't easily exercise the full
6834    /// xpandredir path without a redir struct + IN_EXPANDREDIR
6835    /// global, so this is a sense-of-the-condition pin.
6836    #[test]
6837    fn xpandredir_errflag_check_uses_logical_zero() {
6838        let _g = crate::test_util::global_state_lock();
6839        // Mirror the in-port logic with the canonical zero-check.
6840        let saved = errflag.load(Ordering::Relaxed);
6841        // c:2164 — errflag==0 path: branch should fire.
6842        errflag.store(0, Ordering::Relaxed);
6843        let zero_enters = errflag.load(Ordering::Relaxed) == 0;
6844        assert!(
6845            zero_enters,
6846            "c:2164 — errflag==0 enters the branch (the canonical sense)"
6847        );
6848        // c:2164 — errflag!=0 path: branch should skip.
6849        errflag.store(1, Ordering::Relaxed);
6850        let nonzero_skips = errflag.load(Ordering::Relaxed) == 0;
6851        assert!(
6852            !nonzero_skips,
6853            "c:2164 — errflag!=0 skips the branch (regression of bitwise-NOT bug fix)"
6854        );
6855        errflag.store(saved, Ordering::Relaxed);
6856    }
6857
6858    // ═══════════════════════════════════════════════════════════════════
6859    // Additional unit coverage — pure pattern/brace/glob behaviour that
6860    // doesn't need a real filesystem. Uses `matchpat` for high-level
6861    // pattern checks (extended=false, case_sensitive=true is the most
6862    // common path) and `xpandbraces` for brace expansion.
6863    // ═══════════════════════════════════════════════════════════════════
6864
6865    // ── matchpat: full-string pattern matching ───────────────────────
6866    #[test]
6867    fn matchpat_literal_no_wildcards() {
6868        let _g = crate::test_util::global_state_lock();
6869        assert!(matchpat("foo", "foo", false, true));
6870        assert!(!matchpat("foo", "bar", false, true));
6871        assert!(!matchpat("foo", "foobar", false, true));
6872    }
6873
6874    #[test]
6875    fn matchpat_star_consumes_substring() {
6876        let _g = crate::test_util::global_state_lock();
6877        assert!(matchpat("a*b", "ab", false, true));
6878        assert!(matchpat("a*b", "ahellob", false, true));
6879        assert!(!matchpat("a*b", "abc", false, true));
6880    }
6881
6882    #[test]
6883    fn matchpat_question_is_single_char() {
6884        let _g = crate::test_util::global_state_lock();
6885        assert!(matchpat("a?c", "abc", false, true));
6886        assert!(matchpat("a?c", "axc", false, true));
6887        assert!(!matchpat("a?c", "ac", false, true));
6888        assert!(!matchpat("a?c", "abbc", false, true));
6889    }
6890
6891    #[test]
6892    fn matchpat_bracket_class_inline() {
6893        let _g = crate::test_util::global_state_lock();
6894        assert!(matchpat("file[abc].txt", "filea.txt", false, true));
6895        assert!(matchpat("file[abc].txt", "fileb.txt", false, true));
6896        assert!(!matchpat("file[abc].txt", "filed.txt", false, true));
6897    }
6898
6899    #[test]
6900    fn matchpat_case_sensitive_strict() {
6901        let _g = crate::test_util::global_state_lock();
6902        assert!(matchpat("Foo", "Foo", false, true));
6903        assert!(!matchpat("Foo", "foo", false, true));
6904        assert!(!matchpat("Foo", "FOO", false, true));
6905    }
6906
6907    #[test]
6908    fn matchpat_empty_pattern_matches_only_empty() {
6909        let _g = crate::test_util::global_state_lock();
6910        assert!(matchpat("", "", false, true));
6911        assert!(!matchpat("", "x", false, true));
6912    }
6913
6914    #[test]
6915    fn matchpat_star_alone_matches_anything() {
6916        let _g = crate::test_util::global_state_lock();
6917        assert!(matchpat("*", "", false, true));
6918        assert!(matchpat("*", "a", false, true));
6919        assert!(matchpat("*", "abcdef", false, true));
6920    }
6921
6922    #[test]
6923    fn matchpat_question_alone_one_char() {
6924        let _g = crate::test_util::global_state_lock();
6925        assert!(!matchpat("?", "", false, true));
6926        assert!(matchpat("?", "a", false, true));
6927        assert!(!matchpat("?", "ab", false, true));
6928    }
6929
6930    // ── haswilds: meta-char detection ───────────────────────────────
6931    #[test]
6932    fn haswilds_each_glob_meta() {
6933        let _g = crate::test_util::global_state_lock();
6934        // c:Src/glob.c:3548 — full glob tokenize (shadows the
6935        // brace-only `fn tok` test helper above on purpose:
6936        // haswilds consumes glob tokens, not just braces).
6937        let tok = |s: &str| {
6938            let mut t = s.to_string();
6939            tokenize(&mut t);
6940            t
6941        };
6942        assert!(haswilds(&tok("*")));
6943        assert!(haswilds(&tok("?")));
6944        assert!(haswilds(&tok("[abc]")));
6945        assert!(haswilds(&tok("a*b")));
6946        assert!(haswilds(&tok("a?b")));
6947    }
6948
6949    #[test]
6950    fn haswilds_plain_strings_have_no_wildcards() {
6951        let _g = crate::test_util::global_state_lock();
6952        // c:Src/glob.c:3548 — full glob tokenize (shadows the
6953        // brace-only `fn tok` test helper above on purpose:
6954        // haswilds consumes glob tokens, not just braces).
6955        let tok = |s: &str| {
6956            let mut t = s.to_string();
6957            tokenize(&mut t);
6958            t
6959        };
6960        assert!(!haswilds(&tok("")));
6961        assert!(!haswilds(&tok("plain.txt")));
6962        assert!(!haswilds(&tok("/abs/path/file.rs")));
6963        assert!(!haswilds(&tok("./rel/file")));
6964    }
6965
6966    // ── xpandbraces: brace expansion (zsh extension, not POSIX) ─────
6967    #[test]
6968    fn xpandbraces_three_alternatives() {
6969        let _g = crate::test_util::global_state_lock();
6970        assert_eq!(xpandbraces(&tok("{a,b,c}"), false), vec!["a", "b", "c"]);
6971    }
6972
6973    #[test]
6974    fn xpandbraces_with_prefix_and_suffix() {
6975        let _g = crate::test_util::global_state_lock();
6976        assert_eq!(
6977            xpandbraces(&tok("pre{x,y,z}post"), false),
6978            vec!["prexpost", "preypost", "prezpost"]
6979        );
6980    }
6981
6982    #[test]
6983    fn xpandbraces_numeric_range_ascending() {
6984        let _g = crate::test_util::global_state_lock();
6985        assert_eq!(
6986            xpandbraces(&tok("{1..5}"), false),
6987            vec!["1", "2", "3", "4", "5"]
6988        );
6989    }
6990
6991    #[test]
6992    fn xpandbraces_alpha_range_ascending() {
6993        let _g = crate::test_util::global_state_lock();
6994        assert_eq!(
6995            xpandbraces(&tok("{a..e}"), false),
6996            vec!["a", "b", "c", "d", "e"]
6997        );
6998    }
6999
7000    #[test]
7001    fn xpandbraces_single_alternative_passes_through_literal() {
7002        // Real zsh 5.9 (`print -l {a}`) outputs `{a}` verbatim — no
7003        // expansion because there is no comma or range inside the
7004        // braces. xpandbraces operates on TOKEN form throughout
7005        // (c:Src/glob.c::xpandbraces); the eventual ASCII conversion
7006        // happens later in untokenize. So unit-level pass-through
7007        // preserves TOKEN bytes, not ASCII braces.
7008        let _g = crate::test_util::global_state_lock();
7009        let out = xpandbraces(&tok("{a}"), false);
7010        assert_eq!(
7011            out,
7012            vec![tok("{a}")],
7013            "zsh 5.9 returns the input verbatim (TOKEN form preserved at xpandbraces layer)"
7014        );
7015    }
7016
7017    #[test]
7018    fn xpandbraces_no_braces_returns_input() {
7019        let _g = crate::test_util::global_state_lock();
7020        // Pure literal has nothing to expand — should yield the input
7021        // as a single element (or empty for empty input).
7022        let out = xpandbraces(&tok("plain"), false);
7023        assert_eq!(out, vec!["plain"]);
7024    }
7025
7026    // ── file_type: mode → marker char (used by `ls -F`-style output) ─
7027    #[test]
7028    fn file_type_dir_marker_is_slash() {
7029        let _g = crate::test_util::global_state_lock();
7030        assert_eq!(file_type(libc::S_IFDIR as u32), '/');
7031    }
7032
7033    #[test]
7034    fn file_type_regular_plain_is_space() {
7035        let _g = crate::test_util::global_state_lock();
7036        assert_eq!(file_type(libc::S_IFREG as u32), ' ');
7037    }
7038
7039    #[test]
7040    fn file_type_regular_executable_is_star() {
7041        let _g = crate::test_util::global_state_lock();
7042        // Any of the three executable bits should switch the marker.
7043        for x in [0o100, 0o010, 0o001] {
7044            assert_eq!(
7045                file_type(libc::S_IFREG as u32 | x),
7046                '*',
7047                "exec bit 0o{x:o} should produce '*'"
7048            );
7049        }
7050    }
7051
7052    #[test]
7053    fn file_type_symlink_is_at() {
7054        let _g = crate::test_util::global_state_lock();
7055        assert_eq!(file_type(libc::S_IFLNK as u32), '@');
7056    }
7057
7058    #[test]
7059    fn file_type_fifo_is_pipe() {
7060        let _g = crate::test_util::global_state_lock();
7061        assert_eq!(file_type(libc::S_IFIFO as u32), '|');
7062    }
7063
7064    #[test]
7065    fn file_type_socket_is_equal() {
7066        let _g = crate::test_util::global_state_lock();
7067        assert_eq!(file_type(libc::S_IFSOCK as u32), '=');
7068    }
7069
7070    // ═══════════════════════════════════════════════════════════════════
7071    // xpandbraces edge cases — anchored to `print -l <pattern>` in zsh 5.9.
7072    // Where zshrs diverges, the test FAILS to expose the bug.
7073    // ═══════════════════════════════════════════════════════════════════
7074
7075    /// `{1..10..2}` → 1 3 5 7 9 (step range). zsh: 5 elements.
7076    #[test]
7077    fn xpandbraces_numeric_step_two() {
7078        let _g = crate::test_util::global_state_lock();
7079        assert_eq!(
7080            xpandbraces(&tok("{1..10..2}"), false),
7081            vec!["1", "3", "5", "7", "9"]
7082        );
7083    }
7084
7085    /// `{10..1..2}` → 10 8 6 4 2 (descending step).
7086    #[test]
7087    fn xpandbraces_numeric_descending_step_two() {
7088        let _g = crate::test_util::global_state_lock();
7089        assert_eq!(
7090            xpandbraces(&tok("{10..1..2}"), false),
7091            vec!["10", "8", "6", "4", "2"]
7092        );
7093    }
7094
7095    /// `{5..1}` → 5 4 3 2 1 (descending range, no step).
7096    #[test]
7097    fn xpandbraces_numeric_descending_range() {
7098        let _g = crate::test_util::global_state_lock();
7099        assert_eq!(
7100            xpandbraces(&tok("{5..1}"), false),
7101            vec!["5", "4", "3", "2", "1"]
7102        );
7103    }
7104
7105    /// `{01..10}` → 01 02 03 ... 10 (zero-padded; pad preserved).
7106    #[test]
7107    fn xpandbraces_zero_padded_numeric_range() {
7108        let _g = crate::test_util::global_state_lock();
7109        assert_eq!(
7110            xpandbraces(&tok("{01..10}"), false),
7111            vec!["01", "02", "03", "04", "05", "06", "07", "08", "09", "10"]
7112        );
7113    }
7114
7115    /// `{001..010}` → 001 002 ... 010 (3-digit pad preserved).
7116    #[test]
7117    fn xpandbraces_three_digit_pad_preserved() {
7118        let _g = crate::test_util::global_state_lock();
7119        assert_eq!(
7120            xpandbraces(&tok("{001..010}"), false),
7121            vec!["001", "002", "003", "004", "005", "006", "007", "008", "009", "010"]
7122        );
7123    }
7124
7125    /// `\{a,b,c\}` → no expansion (escaped braces aren't brace
7126    /// delimiters). xpandbraces returns input unchanged; the lexer
7127    /// upstream strips the backslashes before this layer sees them.
7128    /// Anchored test: zsh `print -r -- \{a,b,c\}` → `{a,b,c}` because
7129    /// the lexer tokenizes `\{`/`\}` to `Bnull{`/`Bnull}` before
7130    /// xpandbraces runs, then untokenize drops the Bnulls. At the
7131    /// xpandbraces-unit level (raw backslashes), the only invariant
7132    /// to verify is "no expansion fires" — the brace literal survives.
7133    #[test]
7134    fn xpandbraces_escaped_braces_remain_literal_anchored_to_zsh() {
7135        let _g = crate::test_util::global_state_lock();
7136        // tok() emits Bnull+ASCII `{` for `\{` and Bnull+ASCII `}` for
7137        // `\}`, with Comma TOKEN for the unescaped commas inside.
7138        // xpandbraces scans for Inbrace TOKEN only (per
7139        // c:Src/glob.c::xpandbraces) — finds none here, so the
7140        // string passes through unchanged at this layer. The Bnulls
7141        // are stripped later by remnulargs in prefork.
7142        assert_eq!(
7143            xpandbraces(&tok("\\{a,b,c\\}"), false),
7144            vec![tok("\\{a,b,c\\}")],
7145            "xpandbraces unit: escaped braces survive without expansion (no per-element splat)"
7146        );
7147    }
7148
7149    /// `{a,b}{c,d}` → ac ad bc bd (Cartesian product).
7150    /// 2 * 2 = 4 elements, in row-major order.
7151    #[test]
7152    fn xpandbraces_cartesian_product_two_by_two() {
7153        let _g = crate::test_util::global_state_lock();
7154        assert_eq!(
7155            xpandbraces(&tok("{a,b}{c,d}"), false),
7156            vec!["ac", "ad", "bc", "bd"]
7157        );
7158    }
7159
7160    /// `{a,{b,c}}` → a b c (nested flattened).
7161    #[test]
7162    fn xpandbraces_nested_braces_flatten() {
7163        let _g = crate::test_util::global_state_lock();
7164        assert_eq!(xpandbraces(&tok("{a,{b,c}}"), false), vec!["a", "b", "c"]);
7165    }
7166
7167    /// `{}` → `{}` literal (empty brace doesn't expand). xpandbraces
7168    /// preserves TOKEN form at this layer (c:Src/glob.c::xpandbraces);
7169    /// ASCII conversion happens later in untokenize.
7170    #[test]
7171    fn xpandbraces_empty_braces_remain_literal() {
7172        let _g = crate::test_util::global_state_lock();
7173        assert_eq!(xpandbraces(&tok("{}"), false), vec![tok("{}")]);
7174    }
7175
7176    /// `a{b,c}d{e,f}` → abde abdf acde acdf (cartesian with surrounding text).
7177    #[test]
7178    fn xpandbraces_cartesian_with_surrounding_text() {
7179        let _g = crate::test_util::global_state_lock();
7180        assert_eq!(
7181            xpandbraces(&tok("a{b,c}d{e,f}"), false),
7182            vec!["abde", "abdf", "acde", "acdf"]
7183        );
7184    }
7185
7186    /// `{a..z..3}` → `{a..z..3}` literal (alpha range with step NOT expanded
7187    /// by zsh — surprising but verified). xpandbraces preserves TOKEN form
7188    /// at this layer; ASCII conversion happens later in untokenize.
7189    #[test]
7190    fn xpandbraces_alpha_step_unsupported_anchored_to_zsh() {
7191        let _g = crate::test_util::global_state_lock();
7192        assert_eq!(
7193            xpandbraces(&tok("{a..z..3}"), false),
7194            vec![tok("{a..z..3}")],
7195            "zsh: alpha range with step → literal (TOKEN form preserved at xpandbraces layer)"
7196        );
7197    }
7198
7199    // ═══════════════════════════════════════════════════════════════════
7200    // zsh test-corpus pins — direct anchors to Test/D09brace.ztst.
7201    // Tests verify brace expansion matches zsh's documented output.
7202    // ═══════════════════════════════════════════════════════════════════
7203
7204    /// `Test/D09brace.ztst:10-12` — basic brace expansion with
7205    /// nested range: `X{1,2,{3..6},7,8}Y` expands all 8 values.
7206    #[test]
7207    fn zsh_corpus_basic_brace_with_nested_range() {
7208        let _g = crate::test_util::global_state_lock();
7209        assert_eq!(
7210            xpandbraces(&tok("X{1,2,{3..6},7,8}Y"), false),
7211            vec!["X1Y", "X2Y", "X3Y", "X4Y", "X5Y", "X6Y", "X7Y", "X8Y"],
7212            "ztst:11 — basic brace expansion with nested range",
7213        );
7214    }
7215
7216    /// `Test/D09brace.ztst:30-32` — numeric range with leading zero
7217    /// padding: `X{01..4}Y` → all 4 values 0-padded to 2 digits.
7218    #[test]
7219    fn zsh_corpus_numeric_range_zero_padding() {
7220        let _g = crate::test_util::global_state_lock();
7221        assert_eq!(
7222            xpandbraces(&tok("X{01..4}Y"), false),
7223            vec!["X01Y", "X02Y", "X03Y", "X04Y"],
7224            "ztst:32 — leading-zero padding propagates to all values",
7225        );
7226    }
7227
7228    /// `Test/D09brace.ztst:34-36` — padding comes from RHS too:
7229    /// `X{1..04}Y` → 2-digit zero-padded.
7230    #[test]
7231    fn zsh_corpus_numeric_range_padding_from_rhs() {
7232        let _g = crate::test_util::global_state_lock();
7233        assert_eq!(
7234            xpandbraces(&tok("X{1..04}Y"), false),
7235            vec!["X01Y", "X02Y", "X03Y", "X04Y"],
7236            "ztst:36 — RHS padding `04` propagates",
7237        );
7238    }
7239
7240    /// `Test/D09brace.ztst:38-40` — unpadded range >9: `X{7..12}Y`
7241    /// numbers stay unpadded.
7242    #[test]
7243    fn zsh_corpus_numeric_range_no_padding_when_unspecified() {
7244        let _g = crate::test_util::global_state_lock();
7245        assert_eq!(
7246            xpandbraces(&tok("X{7..12}Y"), false),
7247            vec!["X7Y", "X8Y", "X9Y", "X10Y", "X11Y", "X12Y"],
7248            "ztst:40 — no padding when neither end has leading zero",
7249        );
7250    }
7251
7252    /// `Test/D09brace.ztst:42-44` — `X{07..12}Y` → 2-digit padded.
7253    #[test]
7254    fn zsh_corpus_numeric_range_lhs_padding_propagates() {
7255        let _g = crate::test_util::global_state_lock();
7256        assert_eq!(
7257            xpandbraces(&tok("X{07..12}Y"), false),
7258            vec!["X07Y", "X08Y", "X09Y", "X10Y", "X11Y", "X12Y"],
7259            "ztst:44 — LHS padding `07` propagates",
7260        );
7261    }
7262
7263    /// `Test/D09brace.ztst:46-48` — wider RHS padding overrides:
7264    /// `X{7..012}Y` → 3-digit padded.
7265    #[test]
7266    fn zsh_corpus_numeric_range_max_padding_width_wins() {
7267        let _g = crate::test_util::global_state_lock();
7268        assert_eq!(
7269            xpandbraces(&tok("X{7..012}Y"), false),
7270            vec!["X007Y", "X008Y", "X009Y", "X010Y", "X011Y", "X012Y"],
7271            "ztst:48 — widest padding width wins",
7272        );
7273    }
7274
7275    /// `Test/D09brace.ztst:50-52` — decreasing range: `X{4..1}Y` →
7276    /// 4,3,2,1 (reversed).
7277    #[test]
7278    fn zsh_corpus_numeric_range_decreasing() {
7279        let _g = crate::test_util::global_state_lock();
7280        assert_eq!(
7281            xpandbraces(&tok("X{4..1}Y"), false),
7282            vec!["X4Y", "X3Y", "X2Y", "X1Y"],
7283            "ztst:52 — decreasing range emits in reverse",
7284        );
7285    }
7286
7287    /// `Test/D09brace.ztst:54-56` — combined braces: `X{1..4}{1..4}Y`
7288    /// → 16-element cross product.
7289    #[test]
7290    fn zsh_corpus_combined_braces_cross_product() {
7291        let _g = crate::test_util::global_state_lock();
7292        assert_eq!(
7293            xpandbraces(&tok("X{1..4}{1..4}Y"), false),
7294            vec![
7295                "X11Y", "X12Y", "X13Y", "X14Y", "X21Y", "X22Y", "X23Y", "X24Y", "X31Y", "X32Y",
7296                "X33Y", "X34Y", "X41Y", "X42Y", "X43Y", "X44Y",
7297            ],
7298            "ztst:56 — combined-brace cross product",
7299        );
7300    }
7301
7302    /// `Test/D09brace.ztst:58-60` — negative numbers in range:
7303    /// `X{-4..4}Y` → `-4`..`4` (9 values including 0).
7304    #[test]
7305    fn zsh_corpus_negative_numbers_in_range() {
7306        let _g = crate::test_util::global_state_lock();
7307        assert_eq!(
7308            xpandbraces(&tok("X{-4..4}Y"), false),
7309            vec!["X-4Y", "X-3Y", "X-2Y", "X-1Y", "X0Y", "X1Y", "X2Y", "X3Y", "X4Y",],
7310            "ztst:60 — negative-to-positive range",
7311        );
7312    }
7313
7314    /// `Test/D09brace.ztst:62-64` — reverse-direction numeric range:
7315    /// `X{4..-4}Y` walks from 4 to -4 descending.
7316    #[test]
7317    fn zsh_corpus_brace_descending_range_from_positive_to_negative() {
7318        let _g = crate::test_util::global_state_lock();
7319        assert_eq!(
7320            xpandbraces(&tok("X{4..-4}Y"), false),
7321            vec!["X4Y", "X3Y", "X2Y", "X1Y", "X0Y", "X-1Y", "X-2Y", "X-3Y", "X-4Y",],
7322            "ztst:64 — descending 4..-4 produces 9 values",
7323        );
7324    }
7325
7326    /// `Test/D09brace.ztst:66-68` — stepped padded range:
7327    /// `X{004..-4..2}Y` = X004Y X002Y X000Y X-02Y X-04Y.
7328    /// Padding width 3 from LHS; step is +2 (sign of step is ignored,
7329    /// direction is from start to end).
7330    #[test]
7331    fn zsh_corpus_brace_stepped_padded_descending() {
7332        let _g = crate::test_util::global_state_lock();
7333        assert_eq!(
7334            xpandbraces(&tok("X{004..-4..2}Y"), false),
7335            vec!["X004Y", "X002Y", "X000Y", "X-02Y", "X-04Y"],
7336            "ztst:68 — stepped+padded descending",
7337        );
7338    }
7339
7340    /// `Test/D09brace.ztst:74-76` — step alignment 1: `X{1..32..3}Y`
7341    /// = X1Y X4Y X7Y X10Y X13Y X16Y X19Y X22Y X25Y X28Y X31Y.
7342    /// Step 3 starting from 1, capped at 32.
7343    #[test]
7344    fn zsh_corpus_brace_step_alignment_1_to_32_step_3() {
7345        let _g = crate::test_util::global_state_lock();
7346        assert_eq!(
7347            xpandbraces(&tok("X{1..32..3}Y"), false),
7348            vec![
7349                "X1Y", "X4Y", "X7Y", "X10Y", "X13Y", "X16Y", "X19Y", "X22Y", "X25Y", "X28Y",
7350                "X31Y",
7351            ],
7352            "ztst:76 — {{1..32..3}} step alignment",
7353        );
7354    }
7355
7356    /// `Test/D09brace.ztst:100-102` — `hey{a..j}there` — char range.
7357    #[test]
7358    fn zsh_corpus_brace_char_range_simple() {
7359        let _g = crate::test_util::global_state_lock();
7360        assert_eq!(
7361            xpandbraces(&tok("hey{a..j}there"), false),
7362            vec![
7363                "heyathere",
7364                "heybthere",
7365                "heycthere",
7366                "heydthere",
7367                "heyethere",
7368                "heyfthere",
7369                "heygthere",
7370                "heyhthere",
7371                "heyithere",
7372                "heyjthere",
7373            ],
7374            "ztst:102 — char range a..j",
7375        );
7376    }
7377
7378    /// `Test/D09brace.ztst:108-110` — reverse char range:
7379    /// `crumbs{y..p}ooh` walks down y → p (10 values).
7380    #[test]
7381    fn zsh_corpus_brace_char_range_reverse() {
7382        let _g = crate::test_util::global_state_lock();
7383        assert_eq!(
7384            xpandbraces(&tok("crumbs{y..p}ooh"), false),
7385            vec![
7386                "crumbsyooh",
7387                "crumbsxooh",
7388                "crumbswooh",
7389                "crumbsvooh",
7390                "crumbsuooh",
7391                "crumbstooh",
7392                "crumbssooh",
7393                "crumbsrooh",
7394                "crumbsqooh",
7395                "crumbspooh",
7396            ],
7397            "ztst:110 — char range y..p reverse",
7398        );
7399    }
7400
7401    /// `Test/D09brace.ztst:104-106` — nested brace: `gosh{1,{Z..a},2}cripes`
7402    /// — inner `{Z..a}` is char range Z to a in ASCII order (10 chars).
7403    /// Full output: gosh1cripes goshZcripes gosh[cripes gosh\cripes
7404    /// gosh]cripes gosh^cripes gosh_cripes gosh`cripes goshacripes
7405    /// gosh2cripes.
7406    #[test]
7407    fn zsh_corpus_brace_nested_with_char_range_ascii() {
7408        let _g = crate::test_util::global_state_lock();
7409        assert_eq!(
7410            xpandbraces(&tok("gosh{1,{Z..a},2}cripes"), false),
7411            vec![
7412                "gosh1cripes",
7413                "goshZcripes",
7414                "gosh[cripes",
7415                "gosh\\cripes",
7416                "gosh]cripes",
7417                "gosh^cripes",
7418                "gosh_cripes",
7419                "gosh`cripes",
7420                "goshacripes",
7421                "gosh2cripes",
7422            ],
7423            "ztst:106 — nested brace with ASCII char range",
7424        );
7425    }
7426
7427    /// `Test/D09brace.ztst:116-118` — unmatched closing brace after
7428    /// matched braces stays literal: `{1..10}{..` → `1{.. 2{.. ...`.
7429    /// `xpandbraces` itself preserves TOKEN form (Inbrace=`\u{8f}`,
7430    /// Outbrace=`\u{90}`) per the convention pinned by
7431    /// `xpandbraces_alpha_step_unsupported_anchored_to_zsh` — the
7432    /// later untokenize pass in the user-output pipeline turns
7433    /// surviving brace tokens into literal `{` / `}`. This test
7434    /// asserts the tokenized intermediate form.
7435    #[test]
7436    fn zsh_corpus_brace_unmatched_after_matched_left_literal() {
7437        let _g = crate::test_util::global_state_lock();
7438        let tokb = '\u{8f}'; // Inbrace
7439        let expected: Vec<String> = (1..=10).map(|n| format!("{}{}..", n, tokb)).collect();
7440        assert_eq!(
7441            xpandbraces(&tok("{1..10}{.."), false),
7442            expected,
7443            "ztst:118 — unmatched trailing {{.. preserved as tokenized Inbrace at xpandbraces layer",
7444        );
7445    }
7446
7447    // ── split_qualifier: parse trailing (qual) block ─────────────────
7448    /// `*.txt` (no trailing parens) → (input, None).
7449    #[test]
7450    fn split_qualifier_no_parens_returns_input_and_none() {
7451        let _g = crate::test_util::global_state_lock();
7452        let (head, qual) = split_qualifier("*.txt");
7453        assert_eq!(head, "*.txt");
7454        assert_eq!(qual, None);
7455    }
7456
7457    /// `*(N)` → ("*", Some("N")) — null-glob qualifier.
7458    #[test]
7459    fn split_qualifier_star_paren_N_extracts_null_glob_qual() {
7460        let _g = crate::test_util::global_state_lock();
7461        let (head, qual) = split_qualifier("*(N)");
7462        assert_eq!(head, "*");
7463        assert_eq!(qual, Some("N"));
7464    }
7465
7466    /// `*(.)` → ("*", Some(".")) — regular-file qualifier.
7467    #[test]
7468    fn split_qualifier_star_paren_dot_extracts_regular_file_qual() {
7469        let _g = crate::test_util::global_state_lock();
7470        let (head, qual) = split_qualifier("*(.)");
7471        assert_eq!(head, "*");
7472        assert_eq!(qual, Some("."));
7473    }
7474
7475    /// `*.rs(.)` → ("*.rs", Some(".")) — qualifier on glob pattern.
7476    #[test]
7477    fn split_qualifier_pattern_with_qual_extracts_correctly() {
7478        let _g = crate::test_util::global_state_lock();
7479        let (head, qual) = split_qualifier("*.rs(.)");
7480        assert_eq!(head, "*.rs");
7481        assert_eq!(qual, Some("."));
7482    }
7483
7484    /// `*(#qN)` → ("*", Some("N")) — `#q` prefix stripped per zsh syntax.
7485    #[test]
7486    fn split_qualifier_hash_q_prefix_stripped() {
7487        let _g = crate::test_util::global_state_lock();
7488        let (head, qual) = split_qualifier("*(#qN)");
7489        assert_eq!(head, "*");
7490        assert_eq!(qual, Some("N"));
7491    }
7492
7493    /// `*(om[1])` → ("*", Some("om[1]")) — sort-by-mtime keep first.
7494    #[test]
7495    fn split_qualifier_multichar_qual_with_brackets() {
7496        let _g = crate::test_util::global_state_lock();
7497        let (head, qual) = split_qualifier("*(om[1])");
7498        assert_eq!(head, "*");
7499        assert_eq!(qual, Some("om[1]"));
7500    }
7501
7502    /// `(a)(b)` — multiple paren groups: split takes the OUTERMOST trailing.
7503    #[test]
7504    fn split_qualifier_multiple_groups_takes_outermost_trailing() {
7505        let _g = crate::test_util::global_state_lock();
7506        let (head, qual) = split_qualifier("(a)(b)");
7507        assert_eq!(head, "(a)");
7508        assert_eq!(qual, Some("b"));
7509    }
7510
7511    // ═══════════════════════════════════════════════════════════════════
7512    // parse_qualifier_string — file-type / permission / sort qualifiers.
7513    // Tests call the private fn directly (same crate, same file).
7514    // Each qualifier letter maps to a `qualifier::*` variant per the
7515    // zsh glob.c arm at c:1495+. Pin the parse, not the match — the
7516    // match path needs a real filesystem.
7517    //
7518    // IMPORTANT: at end of parse_qualifier_string (c:3433-3435 in this
7519    // file), if `qualifiers` is non-empty it gets moved into
7520    // `alternatives[]`. So tests read the qualifier from `alternatives[0]`
7521    // — qualifiers itself is empty after the move.
7522    // ═══════════════════════════════════════════════════════════════════
7523
7524    /// Helper: read the only qualifier from a single-letter parse result.
7525    fn first_qual(qs: &qualifier_set) -> &qualifier {
7526        // After the post-loop move, single-letter input lands in
7527        // alternatives[0][0]. Fall back to qualifiers[0] for safety in
7528        // case the move ever changes.
7529        if !qs.alternatives.is_empty() && !qs.alternatives[0].is_empty() {
7530            &qs.alternatives[0][0]
7531        } else {
7532            &qs.qualifiers[0]
7533        }
7534    }
7535
7536    /// Empty qualifier body → no alternatives, no qualifiers.
7537    #[test]
7538    fn parse_qualifier_string_empty_returns_empty_set() {
7539        let _g = crate::test_util::global_state_lock();
7540        let qs = parse_qualifier_string("");
7541        assert!(qs.qualifiers.is_empty());
7542        assert!(qs.alternatives.is_empty());
7543        assert!(!qs.nullglob);
7544    }
7545
7546    /// `/` → IsDirectory (in alternatives[0]).
7547    #[test]
7548    fn parse_qualifier_string_slash_is_directory() {
7549        let _g = crate::test_util::global_state_lock();
7550        let qs = parse_qualifier_string("/");
7551        assert!(matches!(first_qual(&qs), qualifier::IsDirectory));
7552    }
7553
7554    /// `.` → IsRegular.
7555    #[test]
7556    fn parse_qualifier_string_dot_is_regular() {
7557        let _g = crate::test_util::global_state_lock();
7558        let qs = parse_qualifier_string(".");
7559        assert!(matches!(first_qual(&qs), qualifier::IsRegular));
7560    }
7561
7562    /// `parsecomplist` splits a path into per-component `complist` nodes,
7563    /// each literal section a PAT_PURES Patprog (relies on patcompile's
7564    /// PAT_FILE PURES segment-stop at `/`, pattern.c:584-610).
7565    #[test]
7566    fn parsecomplist_splits_path_components() {
7567        let _g = crate::test_util::global_state_lock();
7568        fn pure(p: &crate::ported::pattern::Patprog) -> Option<String> {
7569            if (p.0.flags & crate::ported::zsh_h::PAT_PURES as i32) != 0 {
7570                let off = p.0.startoff as usize;
7571                let l = p.0.patmlen as usize;
7572                p.1.get(off..off + l)
7573                    .map(|b| String::from_utf8_lossy(b).into_owned())
7574            } else {
7575                None
7576            }
7577        }
7578        fn sections(mut q: &complist) -> Vec<String> {
7579            let mut out = Vec::new();
7580            loop {
7581                out.push(pure(&q.pat).unwrap_or_else(|| "<pat>".into()));
7582                match &q.next {
7583                    Some(n) => q = n,
7584                    None => break,
7585                }
7586            }
7587            out
7588        }
7589        // Literal path → split into per-component PAT_PURES sections.
7590        let mut t = "a/b/c.txt".to_string();
7591        tokenize(&mut t);
7592        let cl = parsecomplist(&t).expect("parsecomplist None");
7593        assert_eq!(sections(&cl), vec!["a", "b", "c.txt"]);
7594
7595        // Literal prefix before a pattern still splits the literal dirs;
7596        // the trailing glob section is a real pattern (not PAT_PURES).
7597        let mut t2 = "a/b/*.txt".to_string();
7598        tokenize(&mut t2);
7599        let cl2 = parsecomplist(&t2).expect("parsecomplist None");
7600        let s2 = sections(&cl2);
7601        assert_eq!(s2.len(), 3, "a/b/*.txt → 3 sections, got {s2:?}");
7602        assert_eq!(&s2[..2], &["a".to_string(), "b".to_string()]);
7603        assert_eq!(s2[2], "<pat>", "trailing *.txt must be a pattern section");
7604
7605        // Pattern-FIRST then literal dir: */foo → [<pat>, foo].
7606        let mut t3 = "*/foo".to_string();
7607        tokenize(&mut t3);
7608        let cl3 = parsecomplist(&t3).expect("parsecomplist None for */foo");
7609        let s3 = sections(&cl3);
7610        assert_eq!(s3.len(), 2, "*/foo → 2 sections, got {s3:?}");
7611        assert_eq!(s3[0], "<pat>", "leading * must be a pattern section");
7612        assert_eq!(s3[1], "foo");
7613    }
7614
7615    /// The `struct qual` arena is built from the parse alongside the enum:
7616    /// AND via `next`, alternatives via `or`, per-node `sense` from `^`.
7617    #[test]
7618    fn parse_qualifier_string_builds_qual_arena() {
7619        let _g = crate::test_util::global_state_lock();
7620        // single qualifier → one node, sense 0, head set, func present.
7621        let q = parse_qualifier_string(".");
7622        assert_eq!(q.quals.nodes.len(), 1);
7623        assert_eq!(q.quals.head, Some(0));
7624        assert_eq!(q.quals.nodes[0].sense, 0);
7625        assert!(q.quals.nodes[0].func.is_some());
7626        assert_eq!(q.quals.nodes[0].next, None);
7627
7628        // leading `^` → per-node sense flipped (c:1346).
7629        let q = parse_qualifier_string("^.");
7630        assert_eq!(q.quals.nodes.len(), 1);
7631        assert_eq!(q.quals.nodes[0].sense, 1);
7632
7633        // `/^@` → AND-chain of 2: dir(sense 0) -next-> symlink(sense 1).
7634        let q = parse_qualifier_string("/^@");
7635        assert_eq!(q.quals.nodes.len(), 2);
7636        assert_eq!(q.quals.nodes[0].sense, 0);
7637        assert_eq!(q.quals.nodes[0].next, Some(1));
7638        assert_eq!(q.quals.nodes[1].sense, 1);
7639        assert_eq!(q.quals.nodes[1].next, None);
7640
7641        // `.,/` → two alternatives: node0 -or-> node1, neither chained.
7642        let q = parse_qualifier_string(".,/");
7643        assert_eq!(q.quals.nodes.len(), 2);
7644        assert_eq!(q.quals.nodes[0].or, Some(1));
7645        assert_eq!(q.quals.nodes[0].next, None);
7646        assert_eq!(q.quals.head, Some(0));
7647    }
7648
7649    /// `@` → IsSymlink.
7650    #[test]
7651    fn parse_qualifier_string_at_is_symlink() {
7652        let _g = crate::test_util::global_state_lock();
7653        let qs = parse_qualifier_string("@");
7654        assert!(matches!(first_qual(&qs), qualifier::IsSymlink));
7655    }
7656
7657    /// `=` → IsSocket.
7658    #[test]
7659    fn parse_qualifier_string_equals_is_socket() {
7660        let _g = crate::test_util::global_state_lock();
7661        let qs = parse_qualifier_string("=");
7662        assert!(matches!(first_qual(&qs), qualifier::IsSocket));
7663    }
7664
7665    /// `p` → IsFifo.
7666    #[test]
7667    fn parse_qualifier_string_p_is_fifo() {
7668        let _g = crate::test_util::global_state_lock();
7669        let qs = parse_qualifier_string("p");
7670        assert!(matches!(first_qual(&qs), qualifier::IsFifo));
7671    }
7672
7673    /// `*` → IsExecutable.
7674    #[test]
7675    fn parse_qualifier_string_star_is_executable() {
7676        let _g = crate::test_util::global_state_lock();
7677        let qs = parse_qualifier_string("*");
7678        assert!(matches!(first_qual(&qs), qualifier::IsExecutable));
7679    }
7680
7681    /// `%b` → IsBlockDev.
7682    #[test]
7683    fn parse_qualifier_string_pct_b_is_block_device() {
7684        let _g = crate::test_util::global_state_lock();
7685        let qs = parse_qualifier_string("%b");
7686        assert!(matches!(first_qual(&qs), qualifier::IsBlockDev));
7687    }
7688
7689    /// `%c` → IsCharDev.
7690    #[test]
7691    fn parse_qualifier_string_pct_c_is_char_device() {
7692        let _g = crate::test_util::global_state_lock();
7693        let qs = parse_qualifier_string("%c");
7694        assert!(matches!(first_qual(&qs), qualifier::IsCharDev));
7695    }
7696
7697    /// `r` / `w` / `x` → Readable / Writable / Executable.
7698    #[test]
7699    fn parse_qualifier_string_perm_letters_map_to_perm_qualifiers() {
7700        let _g = crate::test_util::global_state_lock();
7701        for (letter, expected) in [
7702            ("r", qualifier::Readable),
7703            ("w", qualifier::Writable),
7704            ("x", qualifier::Executable),
7705        ] {
7706            let qs = parse_qualifier_string(letter);
7707            assert_eq!(
7708                std::mem::discriminant(first_qual(&qs)),
7709                std::mem::discriminant(&expected),
7710                "letter {letter:?} should map to {expected:?}"
7711            );
7712        }
7713    }
7714
7715    /// `U` → OwnedByEuid (process EUID).
7716    #[test]
7717    fn parse_qualifier_string_capital_U_is_owned_by_euid() {
7718        let _g = crate::test_util::global_state_lock();
7719        let qs = parse_qualifier_string("U");
7720        assert!(matches!(first_qual(&qs), qualifier::OwnedByEuid));
7721    }
7722
7723    /// `G` → OwnedByEgid.
7724    #[test]
7725    fn parse_qualifier_string_capital_G_is_owned_by_egid() {
7726        let _g = crate::test_util::global_state_lock();
7727        let qs = parse_qualifier_string("G");
7728        assert!(matches!(first_qual(&qs), qualifier::OwnedByEgid));
7729    }
7730
7731    /// Multiple file-type qualifiers stack — `./` → IsRegular then IsDirectory
7732    /// both end up in alternatives[0].
7733    #[test]
7734    fn parse_qualifier_string_multiple_letters_stack_in_one_alternative() {
7735        let _g = crate::test_util::global_state_lock();
7736        let qs = parse_qualifier_string("./");
7737        assert_eq!(qs.alternatives.len(), 1);
7738        assert_eq!(qs.alternatives[0].len(), 2);
7739        assert!(matches!(qs.alternatives[0][0], qualifier::IsRegular));
7740        assert!(matches!(qs.alternatives[0][1], qualifier::IsDirectory));
7741    }
7742
7743    /// `,` separates alternatives — `/,.` → two alternatives, each with one
7744    /// qualifier (IsDirectory and IsRegular respectively).
7745    #[test]
7746    fn parse_qualifier_string_comma_creates_two_alternatives() {
7747        let _g = crate::test_util::global_state_lock();
7748        let qs = parse_qualifier_string("/,.");
7749        assert_eq!(qs.alternatives.len(), 2, "two alternatives expected");
7750        assert!(matches!(qs.alternatives[0][0], qualifier::IsDirectory));
7751        assert!(matches!(qs.alternatives[1][0], qualifier::IsRegular));
7752    }
7753
7754    /// `:` introduces colon-modifiers; rest captured into colon_mods.
7755    #[test]
7756    fn parse_qualifier_string_colon_captures_modifiers_in_field() {
7757        let _g = crate::test_util::global_state_lock();
7758        let qs = parse_qualifier_string(":h");
7759        assert_eq!(qs.colon_mods.as_deref(), Some(":h"));
7760    }
7761
7762    /// `:` after a qualifier captures BOTH.
7763    #[test]
7764    fn parse_qualifier_string_qualifier_then_colon_mod() {
7765        let _g = crate::test_util::global_state_lock();
7766        let qs = parse_qualifier_string("/:h");
7767        assert!(matches!(first_qual(&qs), qualifier::IsDirectory));
7768        assert_eq!(qs.colon_mods.as_deref(), Some(":h"));
7769    }
7770
7771    /// `N` flag → nullglob set, no qualifier pushed.
7772    #[test]
7773    fn parse_qualifier_string_capital_N_sets_nullglob_flag() {
7774        let _g = crate::test_util::global_state_lock();
7775        let qs = parse_qualifier_string("N");
7776        assert!(qs.nullglob, "(N) must set nullglob");
7777        assert!(qs.alternatives.is_empty(), "N doesn't push to qualifiers");
7778    }
7779
7780    /// `M` flag → mark_dirs.
7781    #[test]
7782    fn parse_qualifier_string_capital_M_sets_mark_dirs_flag() {
7783        let _g = crate::test_util::global_state_lock();
7784        let qs = parse_qualifier_string("M");
7785        assert!(qs.mark_dirs, "(M) must set mark_dirs");
7786    }
7787
7788    /// `T` flag → list_types.
7789    #[test]
7790    fn parse_qualifier_string_capital_T_sets_list_types_flag() {
7791        let _g = crate::test_util::global_state_lock();
7792        let qs = parse_qualifier_string("T");
7793        assert!(qs.list_types, "(T) must set list_types");
7794    }
7795
7796    // ── split_qualifier integration with parse_qualifier_string ──────
7797    /// `*(.)` — split yields ("*", Some(".")); parse yields IsRegular.
7798    #[test]
7799    fn split_then_parse_dot_qualifier_yields_is_regular() {
7800        let _g = crate::test_util::global_state_lock();
7801        let (head, qual) = split_qualifier("*(.)");
7802        assert_eq!(head, "*");
7803        let qs = parse_qualifier_string(qual.unwrap());
7804        assert!(matches!(first_qual(&qs), qualifier::IsRegular));
7805    }
7806
7807    /// `*(/)` — directory-only glob.
7808    #[test]
7809    fn split_then_parse_slash_qualifier_yields_is_directory() {
7810        let _g = crate::test_util::global_state_lock();
7811        let (_, qual) = split_qualifier("*(/)");
7812        let qs = parse_qualifier_string(qual.unwrap());
7813        assert!(matches!(first_qual(&qs), qualifier::IsDirectory));
7814    }
7815
7816    // ═══════════════════════════════════════════════════════════════════
7817    // Sort qualifiers — `o<key>` ascending, `O<key>` descending.
7818    // ═══════════════════════════════════════════════════════════════════
7819
7820    /// `oN` — sort by NONE (no sorting key requested).
7821    #[test]
7822    fn parse_qualifier_string_oN_pushes_gs_none() {
7823        let _g = crate::test_util::global_state_lock();
7824        let qs = parse_qualifier_string("oN");
7825        assert_eq!(qs.sorts.len(), 1);
7826        assert_ne!(qs.sorts[0] & GS_NONE, 0, "oN must set GS_NONE bit");
7827    }
7828
7829    /// `on` — sort by name ascending.
7830    #[test]
7831    fn parse_qualifier_string_on_pushes_gs_name_ascending() {
7832        let _g = crate::test_util::global_state_lock();
7833        let qs = parse_qualifier_string("on");
7834        assert_eq!(qs.sorts.len(), 1);
7835        assert_eq!(qs.sorts[0] & GS_NAME, GS_NAME);
7836        assert_eq!(qs.sorts[0] & GS_DESC, 0);
7837    }
7838
7839    /// `On` — sort by name descending.
7840    #[test]
7841    fn parse_qualifier_string_On_pushes_gs_name_descending() {
7842        let _g = crate::test_util::global_state_lock();
7843        let qs = parse_qualifier_string("On");
7844        assert_eq!(qs.sorts.len(), 1);
7845        assert_eq!(qs.sorts[0] & GS_NAME, GS_NAME);
7846        assert_eq!(qs.sorts[0] & GS_DESC, GS_DESC);
7847    }
7848
7849    /// Multiple sort keys stack.
7850    #[test]
7851    fn parse_qualifier_string_chained_sort_keys_stack() {
7852        let _g = crate::test_util::global_state_lock();
7853        let qs = parse_qualifier_string("onOL");
7854        assert_eq!(qs.sorts.len(), 2);
7855    }
7856
7857    // ── Size qualifier — `L<unit><op><value>` ───────────────────────
7858    // NOTE: zshrs's parse_range_spec normalises `+` (greater) → '>' and
7859    // `-` (less) → '<' for the internal op char, matching C's qgetnum
7860    // convention (Src/glob.c c:1620-ish).
7861
7862    /// `Lk+1` — size > 1 kilobyte. zsh syntax: `L<unit><op><value>`
7863    /// (unit char BEFORE op).
7864    #[test]
7865    fn parse_qualifier_string_Lk_plus_one_size_kilobyte() {
7866        let _g = crate::test_util::global_state_lock();
7867        let qs = parse_qualifier_string("Lk+1");
7868        match first_qual(&qs) {
7869            qualifier::Size { value, unit, op } => {
7870                assert_eq!(*value, 1);
7871                assert_eq!(*unit, TT_KILOBYTES);
7872                assert_eq!(*op, '>', "+ is stored as '>' (greater than)");
7873            }
7874            other => panic!("expected Size, got {other:?}"),
7875        }
7876    }
7877
7878    /// `L-100` — size < 100 (default unit bytes). Stored op is '<'.
7879    #[test]
7880    fn parse_qualifier_string_L_minus_100_size_bytes() {
7881        let _g = crate::test_util::global_state_lock();
7882        let qs = parse_qualifier_string("L-100");
7883        match first_qual(&qs) {
7884            qualifier::Size { value, unit, op } => {
7885                assert_eq!(*value, 100);
7886                assert_eq!(*unit, TT_BYTES);
7887                assert_eq!(*op, '<', "- is stored as '<' (less than)");
7888            }
7889            other => panic!("expected Size, got {other:?}"),
7890        }
7891    }
7892
7893    /// `Lm+1` — size > 1 megabyte; unit recognised.
7894    #[test]
7895    fn parse_qualifier_string_Lm_megabyte_unit() {
7896        let _g = crate::test_util::global_state_lock();
7897        let qs = parse_qualifier_string("Lm+1");
7898        match first_qual(&qs) {
7899            qualifier::Size { unit, .. } => assert_eq!(*unit, TT_MEGABYTES),
7900            other => panic!("expected Size, got {other:?}"),
7901        }
7902    }
7903
7904    // ── Time qualifier — same op normalisation ───────────────────────
7905    /// `m-1` — modified less than 1 day ago. Stored op '<'.
7906    #[test]
7907    fn parse_qualifier_string_m_minus_1_day_default_unit() {
7908        let _g = crate::test_util::global_state_lock();
7909        let qs = parse_qualifier_string("m-1");
7910        match first_qual(&qs) {
7911            qualifier::Mtime { value, unit, op } => {
7912                assert_eq!(*value, 1);
7913                assert_eq!(*unit, TT_DAYS);
7914                assert_eq!(*op, '<');
7915            }
7916            other => panic!("expected Mtime, got {other:?}"),
7917        }
7918    }
7919
7920    /// `mh+24` — modified more than 24 hours ago. Stored op '>'.
7921    #[test]
7922    fn parse_qualifier_string_mh_plus_24_hours() {
7923        let _g = crate::test_util::global_state_lock();
7924        let qs = parse_qualifier_string("mh+24");
7925        match first_qual(&qs) {
7926            qualifier::Mtime { value, unit, op } => {
7927                assert_eq!(*value, 24);
7928                assert_eq!(*unit, TT_HOURS);
7929                assert_eq!(*op, '>');
7930            }
7931            other => panic!("expected Mtime, got {other:?}"),
7932        }
7933    }
7934
7935    /// `a-7` — accessed less than 7 days ago. Stored op '<'.
7936    #[test]
7937    fn parse_qualifier_string_a_minus_7_days() {
7938        let _g = crate::test_util::global_state_lock();
7939        let qs = parse_qualifier_string("a-7");
7940        match first_qual(&qs) {
7941            qualifier::Atime { value, unit, op } => {
7942                assert_eq!(*value, 7);
7943                assert_eq!(*unit, TT_DAYS);
7944                assert_eq!(*op, '<');
7945            }
7946            other => panic!("expected Atime, got {other:?}"),
7947        }
7948    }
7949
7950    // ── Subscript qualifier — `[N]` keep first ───────────────────────
7951    /// `[1]` — first entry only.
7952    #[test]
7953    fn parse_qualifier_string_bracket_one_sets_first_to_one() {
7954        let _g = crate::test_util::global_state_lock();
7955        let qs = parse_qualifier_string("[1]");
7956        assert_eq!(qs.first, Some(1));
7957    }
7958
7959    /// `om[1]` — sort by mtime, keep first.
7960    #[test]
7961    fn parse_qualifier_string_om_bracket_one_sort_and_subscript() {
7962        let _g = crate::test_util::global_state_lock();
7963        let qs = parse_qualifier_string("om[1]");
7964        assert_eq!(qs.sorts.len(), 1);
7965        assert_eq!(qs.sorts[0] & GS_MTIME, GS_MTIME);
7966        assert_eq!(qs.first, Some(1));
7967    }
7968
7969    // ═══════════════════════════════════════════════════════════════════
7970    // tokenize / remnulargs C-parity tests — pin Src/glob.c:3551 (tokenize)
7971    // and Src/glob.c:3649 (remnulargs). These two functions form the
7972    // input → glob-token → output sandwich every glob pattern walks
7973    // through; regressions silently corrupt every pattern match.
7974    // Tests that capture KNOWN ZSHRS BUGS use #[ignore = "ZSHRS BUG: …"].
7975    // ═══════════════════════════════════════════════════════════════════
7976
7977    /// `tokenize("abc")` is a no-op for plain ASCII — no glob metachars.
7978    /// C glob.c:3551 — `for (; (c = *s); s++) zstrtok(...)` — only
7979    /// recognized glob bytes get replaced with their token form.
7980    #[test]
7981    fn tokenize_pure_ascii_unchanged() {
7982        let _g = crate::test_util::global_state_lock();
7983        let mut s = String::from("abc");
7984        tokenize(&mut s);
7985        assert_eq!(s, "abc");
7986    }
7987
7988    /// `tokenize("")` on empty input returns empty unchanged.
7989    #[test]
7990    fn tokenize_empty_unchanged() {
7991        let _g = crate::test_util::global_state_lock();
7992        let mut s = String::new();
7993        tokenize(&mut s);
7994        assert_eq!(s, "");
7995    }
7996
7997    /// `remnulargs("abc")` is a no-op for pure ASCII (no inull bytes).
7998    /// C glob.c:3649-3680 — only Snull/Dnull/Bnull/Bnullkeep/Nularg
7999    /// are stripped or transformed.
8000    #[test]
8001    fn remnulargs_pure_ascii_unchanged() {
8002        let _g = crate::test_util::global_state_lock();
8003        let mut s = String::from("hello");
8004        remnulargs(&mut s);
8005        assert_eq!(s, "hello");
8006    }
8007
8008    /// `remnulargs("")` returns empty unchanged (c:3653 early exit).
8009    #[test]
8010    fn remnulargs_empty_returns_empty() {
8011        let _g = crate::test_util::global_state_lock();
8012        let mut s = String::new();
8013        remnulargs(&mut s);
8014        assert_eq!(s, "");
8015    }
8016
8017    /// `remnulargs` strips Snull (single-quote scope marker).
8018    /// C glob.c:3656 — inull predicate includes Snull (0x84).
8019    /// Input `"a\u{84}b"` → `"ab"` (strip the Snull byte).
8020    #[test]
8021    fn remnulargs_strips_snull_marker() {
8022        let _g = crate::test_util::global_state_lock();
8023        let mut s = format!("a{}b", crate::ported::zsh_h::Snull);
8024        remnulargs(&mut s);
8025        assert_eq!(s, "ab", "Snull byte should be stripped");
8026    }
8027
8028    /// `remnulargs` strips Dnull (double-quote scope marker).
8029    #[test]
8030    fn remnulargs_strips_dnull_marker() {
8031        let _g = crate::test_util::global_state_lock();
8032        let mut s = format!("a{}b", crate::ported::zsh_h::Dnull);
8033        remnulargs(&mut s);
8034        assert_eq!(s, "ab", "Dnull byte should be stripped");
8035    }
8036
8037    /// `remnulargs` strips Bnull (backslash-quoted marker — c:3658
8038    /// `inull(c)` includes Bnull). The non-Bnullkeep variant of the
8039    /// active-backslash marker.
8040    #[test]
8041    fn remnulargs_strips_bnull_marker() {
8042        let _g = crate::test_util::global_state_lock();
8043        let mut s = format!("a{}b", crate::ported::zsh_h::Bnull);
8044        remnulargs(&mut s);
8045        assert_eq!(s, "ab", "Bnull byte should be stripped");
8046    }
8047
8048    /// `remnulargs` on a single Nularg returns the Nularg sentinel
8049    /// preserved. C glob.c:3690-3692 — if post-strip output is empty
8050    /// AND original had any inull, emit Nularg as the empty-arg marker.
8051    #[test]
8052    fn remnulargs_empty_post_strip_emits_nularg_sentinel() {
8053        let _g = crate::test_util::global_state_lock();
8054        let mut s = format!("{}", crate::ported::zsh_h::Snull);
8055        remnulargs(&mut s);
8056        assert_eq!(
8057            s,
8058            format!("{}", crate::ported::zsh_h::Nularg),
8059            "all-inull input should collapse to single Nularg marker"
8060        );
8061    }
8062
8063    /// `remnulargs` on the Bnullkeep marker: c:3669 — Bnullkeep
8064    /// either (a) preserved as-is when it appears BEFORE any other
8065    /// inull (scan phase), or (b) transformed to literal `\` (copy
8066    /// phase). Pin the simpler case: a lone Bnullkeep is stripped.
8067    #[test]
8068    fn remnulargs_lone_bnullkeep_stripped() {
8069        let _g = crate::test_util::global_state_lock();
8070        let mut s = format!("a{}b", crate::ported::zsh_h::Bnullkeep);
8071        remnulargs(&mut s);
8072        // C behavior depends on whether the byte is in scan or copy
8073        // phase. Faithful port should give either "ab" (scan strip)
8074        // or "a\\b" (copy phase materialize). Either is acceptable;
8075        // pin that it's NOT the raw input.
8076        assert!(
8077            s == "ab" || s == "a\\b",
8078            "Bnullkeep should be stripped or materialized; got {s:?}"
8079        );
8080    }
8081
8082    // ═══════════════════════════════════════════════════════════════════
8083    // Additional C-parity tests for Src/glob.c file_type + hasbraces.
8084    // ═══════════════════════════════════════════════════════════════════
8085
8086    /// c:2018 — file_type for regular file with no exec bits → ' '.
8087    #[test]
8088    #[cfg(unix)]
8089    fn file_type_regular_non_exec_returns_space() {
8090        let r = file_type(libc::S_IFREG as u32 | 0o644);
8091        assert_eq!(r, ' ');
8092    }
8093
8094    /// c:2018 — file_type for regular file WITH exec bit → '*'.
8095    #[test]
8096    #[cfg(unix)]
8097    fn file_type_regular_exec_returns_star() {
8098        let r = file_type(libc::S_IFREG as u32 | 0o755);
8099        assert_eq!(r, '*');
8100    }
8101
8102    /// c:2018 — file_type for directory → '/'.
8103    #[test]
8104    #[cfg(unix)]
8105    fn file_type_directory_returns_slash() {
8106        let r = file_type(libc::S_IFDIR as u32 | 0o755);
8107        assert_eq!(r, '/');
8108    }
8109
8110    /// c:2018 — file_type for symlink → '@'.
8111    #[test]
8112    #[cfg(unix)]
8113    fn file_type_symlink_returns_at() {
8114        let r = file_type(libc::S_IFLNK as u32 | 0o777);
8115        assert_eq!(r, '@');
8116    }
8117
8118    /// c:2018 — file_type for FIFO → '|'.
8119    #[test]
8120    #[cfg(unix)]
8121    fn file_type_fifo_returns_pipe() {
8122        let r = file_type(libc::S_IFIFO as u32 | 0o644);
8123        assert_eq!(r, '|');
8124    }
8125
8126    /// c:2018 — file_type for socket → '='.
8127    #[test]
8128    #[cfg(unix)]
8129    fn file_type_socket_returns_equals() {
8130        let r = file_type(libc::S_IFSOCK as u32 | 0o777);
8131        assert_eq!(r, '=');
8132    }
8133
8134    /// c:2018 — file_type for char device → '%'.
8135    #[test]
8136    #[cfg(unix)]
8137    fn file_type_char_device_returns_percent() {
8138        let r = file_type(libc::S_IFCHR as u32 | 0o644);
8139        assert_eq!(r, '%');
8140    }
8141
8142    /// c:2018 — file_type for block device → '#'.
8143    #[test]
8144    #[cfg(unix)]
8145    fn file_type_block_device_returns_hash() {
8146        let r = file_type(libc::S_IFBLK as u32 | 0o644);
8147        assert_eq!(r, '#');
8148    }
8149
8150    /// c:2018 — file_type for unknown mode → '?'.
8151    #[test]
8152    fn file_type_unknown_mode_returns_question() {
8153        // Bare 0 mode (none of the S_IF* bits set) → '?'.
8154        assert_eq!(file_type(0), '?');
8155    }
8156
8157    /// c:2018 — file_type is deterministic.
8158    #[test]
8159    #[cfg(unix)]
8160    fn file_type_is_deterministic() {
8161        for mode in [
8162            libc::S_IFREG as u32 | 0o644,
8163            libc::S_IFDIR as u32,
8164            libc::S_IFLNK as u32,
8165            0u32,
8166        ] {
8167            let first = file_type(mode);
8168            for _ in 0..5 {
8169                assert_eq!(file_type(mode), first);
8170            }
8171        }
8172    }
8173
8174    /// c:2042 — hasbraces on plain string returns false.
8175    #[test]
8176    fn hasbraces_no_braces_returns_false() {
8177        let _g = crate::test_util::global_state_lock();
8178        assert!(!hasbraces("hello", false));
8179        assert!(!hasbraces("", false));
8180    }
8181
8182    /// c:2042 — hasbraces on plain ASCII `{a,b}` returns FALSE because
8183    /// the C port checks TOKEN bytes (Inbrace=0x8f / Outbrace=0x90 /
8184    /// Comma=0x9a) not literal ASCII. The lexer pre-tokenizes input
8185    /// before hasbraces runs in the real pipeline. Pin the actual
8186    /// behavior so a regen that adds ASCII fast-path is caught.
8187    #[test]
8188    fn hasbraces_plain_ascii_braces_returns_false_uses_tokens() {
8189        let _g = crate::test_util::global_state_lock();
8190        assert!(
8191            !hasbraces("{a,b}", false),
8192            "plain ASCII not tokenized → false; lexer pre-tokenizes in real pipeline"
8193        );
8194    }
8195
8196    /// c:2042 — hasbraces on tokenized brace expansion returns true.
8197    /// Construct the TOKEN form: Inbrace + 'a' + Comma + 'b' + Outbrace.
8198    #[test]
8199    fn hasbraces_tokenized_brace_expansion_returns_true() {
8200        let _g = crate::test_util::global_state_lock();
8201        let tokenized = format!(
8202            "{}a{}b{}",
8203            '\u{8f}', // Inbrace token
8204            '\u{9a}', // Comma token
8205            '\u{90}'  // Outbrace token
8206        );
8207        assert!(
8208            hasbraces(&tokenized, false),
8209            "Inbrace+a+Comma+b+Outbrace must trigger brace expansion"
8210        );
8211    }
8212
8213    /// c:2042 — hasbraces on unclosed `{` returns false (no matching pair).
8214    #[test]
8215    fn hasbraces_unclosed_returns_false() {
8216        let _g = crate::test_util::global_state_lock();
8217        assert!(!hasbraces("{", false));
8218        assert!(!hasbraces("{abc", false));
8219    }
8220
8221    // ═══════════════════════════════════════════════════════════════════
8222    // Additional C-parity tests for Src/glob.c
8223    // c:950 qgetnum / c:994 qgetmodespec / c:1250 glob_exec_string /
8224    // c:1351 checkglobqual / c:1539 hasbraces / c:1617 bracechardots /
8225    // c:1802 matchpat / c:1899 getmatch / c:1505 file_type
8226    // ═══════════════════════════════════════════════════════════════════
8227
8228    /// c:950 — `qgetnum("")` empty returns None.
8229    #[test]
8230    fn qgetnum_empty_returns_none() {
8231        assert!(qgetnum("").is_none(), "empty → None");
8232    }
8233
8234    /// c:950 — `qgetnum` returns Option<(i64, &str)> (type pin).
8235    #[test]
8236    fn qgetnum_returns_option_i64_str_tuple_type() {
8237        let _: Option<(i64, &str)> = qgetnum("42");
8238    }
8239
8240    /// c:950 — `qgetnum("123")` returns Some((123, "")) (canonical decimal).
8241    #[test]
8242    fn qgetnum_canonical_decimal_parses() {
8243        let r = qgetnum("123");
8244        assert!(r.is_some(), "valid digits → Some");
8245        let (n, rest) = r.unwrap();
8246        assert_eq!(n, 123);
8247        assert_eq!(rest, "");
8248    }
8249
8250    /// c:994 — `qgetmodespec("")` empty returns None.
8251    #[test]
8252    fn qgetmodespec_empty_returns_none() {
8253        assert!(qgetmodespec("").is_none());
8254    }
8255
8256    /// c:1539 — `hasbraces("")` empty returns false.
8257    #[test]
8258    fn hasbraces_empty_returns_false() {
8259        let _g = crate::test_util::global_state_lock();
8260        assert!(!hasbraces("", false));
8261    }
8262
8263    /// c:1539 — `hasbraces` is pure (no side effects).
8264    #[test]
8265    fn hasbraces_is_pure() {
8266        let _g = crate::test_util::global_state_lock();
8267        for s in ["", "abc", "{", "{a,b}", "no braces"] {
8268            let first = hasbraces(s, false);
8269            for _ in 0..3 {
8270                assert_eq!(
8271                    hasbraces(s, false),
8272                    first,
8273                    "hasbraces({:?}, false) must be pure",
8274                    s
8275                );
8276            }
8277        }
8278    }
8279
8280    /// c:1505 — `file_type(0)` returns char (compile-time type pin).
8281    #[test]
8282    fn file_type_returns_char_type() {
8283        let _: char = file_type(0);
8284    }
8285
8286    /// c:1505 — `file_type` is pure for arbitrary mode.
8287    #[test]
8288    fn file_type_is_pure() {
8289        for m in [0u32, 0o100000, 0o040000, 0o120000, 0o140000, 0o160000] {
8290            let first = file_type(m);
8291            for _ in 0..3 {
8292                assert_eq!(file_type(m), first, "file_type({:o}) must be pure", m);
8293            }
8294        }
8295    }
8296
8297    /// c:1802 — `matchpat("", "", false, false)` returns bool (type pin).
8298    #[test]
8299    fn matchpat_returns_bool_type() {
8300        let _g = crate::test_util::global_state_lock();
8301        let _: bool = matchpat("", "", false, false);
8302    }
8303
8304    /// c:1899 — `getmatch("", "", 0, 0, None)` returns String (type pin).
8305    #[test]
8306    fn getmatch_returns_string_type() {
8307        let _g = crate::test_util::global_state_lock();
8308        let _: String = getmatch("", "", 0, 0, None);
8309    }
8310
8311    /// c:1862 — `compgetmatch("")` empty returns Option<(String, i32)>.
8312    #[test]
8313    fn compgetmatch_returns_option_tuple_type() {
8314        let _g = crate::test_util::global_state_lock();
8315        let _: Option<(String, i32)> = compgetmatch("");
8316    }
8317}