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_RETFAIL, 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::{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    /// c:47 `char *uname` — "Unmetafied file name; embedded nulls can't
54    /// occur in file names". The string `gmatchcmp`'s `GS_NAME` arm
55    /// collates (c:945). C fills it once per match, immediately before
56    /// the qsort (c:1963-1973); the port does the same in `sort_matches`
57    /// so the comparator never re-derives it. Empty until then.
58    pub uname: String,
59    /// `path` field.
60    pub path: PathBuf,
61    /// `size` field.
62    pub size: u64,
63    /// `atime` field.
64    pub atime: i64,
65    /// `mtime` field.
66    pub mtime: i64,
67    /// `ctime` field.
68    pub ctime: i64,
69    /// `links` field.
70    pub links: u64,
71    /// `mode` field.
72    pub mode: u32,
73    /// `uid` field.
74    pub uid: u32,
75    /// `gid` field.
76    pub gid: u32,
77    /// `dev` field.
78    pub dev: u64,
79    /// `ino` field.
80    pub ino: u64,
81    // For symlink targets (when following)
82    /// `target_size` field.
83    pub target_size: u64,
84    /// `target_atime` field.
85    pub target_atime: i64,
86    /// `target_mtime` field.
87    pub target_mtime: i64,
88    /// `target_ctime` field.
89    pub target_ctime: i64,
90    /// `target_links` field.
91    pub target_links: u64,
92    // Sub-second timestamp components — port of `long ansec/mnsec/cnsec` +
93    // `long _ansec/_mnsec/_cnsec` in `struct gmatch` (Src/glob.c:63-74).
94    // gmatchcmp tie-breaks equal-second mtimes/atimes/ctimes by these
95    // nanosecond fields (c:992/999/1006/1019/1026/1033) — without them,
96    // files touched <1s apart sort by name rather than mtime.
97    /// `ansec` field. c:64
98    pub ansec: i64,
99    /// `mnsec` field. c:68
100    pub mnsec: i64,
101    /// `cnsec` field. c:72
102    pub cnsec: i64,
103    /// `_ansec` field. c:65
104    pub target_ansec: i64,
105    /// `_mnsec` field. c:69
106    pub target_mnsec: i64,
107    /// `_cnsec` field. c:73
108    pub target_cnsec: i64,
109    // For exec sort strings
110    /// `sort_strings` field.
111    pub sort_strings: Vec<String>,
112}
113
114impl GlobOptSnapshot {
115    /// Read every glob-relevant option from the canonical live store
116    /// (`opt_state_get`) once. Returns a self-contained snapshot.
117    pub fn capture() -> Self {
118        Self {
119            bareglobqual: isset(BAREGLOBQUAL),
120            braceccl: isset(BRACECCL),
121            caseglob: isset(CASEGLOB),
122            extendedglob: isset(EXTENDEDGLOB),
123            globdots: isset(GLOBDOTS),
124            globstarshort: isset(GLOBSTARSHORT),
125            listtypes: isset(LISTTYPES),
126            markdirs: isset(MARKDIRS),
127            nullglob: isset(NULLGLOB),
128            numericglobsort: isset(NUMERICGLOBSORT),
129        }
130    }
131}
132
133thread_local! {
134    /// Thread-local glob option cache. `Some(snap)` while a glob()
135    /// call is in flight on this thread; `None` otherwise (reads
136    /// fall back to the live `isset()`).
137    static GLOB_OPTS_TLS: std::cell::RefCell<Option<GlobOptSnapshot>> =
138        const { std::cell::RefCell::new(None) };
139}
140
141// =====================================================================
142// GS_* — sort-specifier flag bits — `Src/glob.c:77-94`. The `glob -O`
143// + `glob -o` sort-spec parser stuffs these bits into the per-glob
144// sortspec struct so the qsort comparator picks the right key.
145// =====================================================================
146
147/// Port of `GS_NAME` from `Src/glob.c:77`. Sort by filename.
148pub const GS_NAME: i32 = 1; // c:77
149
150impl Drop for GlobOptsGuard {
151    fn drop(&mut self) {
152        if self.populated {
153            GLOB_OPTS_TLS.with_borrow_mut(|g| *g = None);
154        }
155    }
156}
157/// Port of `GS_DEPTH` from `Src/glob.c:78`. Sort by directory depth.
158pub const GS_DEPTH: i32 = 2; // c:78
159/// Port of `GS_EXEC` from `Src/glob.c:79`. Sort via external function.
160pub const GS_EXEC: i32 = 4; // c:79
161
162/// Port of `GS_SHIFT_BASE` from `Src/glob.c:81`. Bit position where
163/// the size/mtime/atime/ctime/links sort keys live.
164pub const GS_SHIFT_BASE: i32 = 8; // c:81
165
166/// Port of `GS_SIZE` from `Src/glob.c:83`. Sort by file size.
167pub const GS_SIZE: i32 = GS_SHIFT_BASE; // c:83
168/// Port of `GS_ATIME` from `Src/glob.c:84`. Sort by access time.
169pub const GS_ATIME: i32 = GS_SHIFT_BASE << 1; // c:84
170/// Port of `GS_MTIME` from `Src/glob.c:85`. Sort by modification time.
171pub const GS_MTIME: i32 = GS_SHIFT_BASE << 2; // c:85
172/// Port of `GS_CTIME` from `Src/glob.c:86`. Sort by inode-change time.
173pub const GS_CTIME: i32 = GS_SHIFT_BASE << 3; // c:86
174/// Port of `GS_LINKS` from `Src/glob.c:87`. Sort by hard-link count.
175pub const GS_LINKS: i32 = GS_SHIFT_BASE << 4; // c:87
176
177/// Port of `GS_SHIFT` from `Src/glob.c:89`. Bit-shift offset where the
178/// reverse-direction variants of the size/atime/mtime/ctime/links
179/// sort flags live.
180pub const GS_SHIFT: i32 = 5; // c:89
181
182/// Port of `GS__SIZE`  from `Src/glob.c:90` (reverse-sort variant).
183pub const GS__SIZE: i32 = GS_SIZE << GS_SHIFT; // c:90
184/// Port of `GS__ATIME` from `Src/glob.c:91`.
185pub const GS__ATIME: i32 = GS_ATIME << GS_SHIFT; // c:91
186/// Port of `GS__MTIME` from `Src/glob.c:92`.
187pub const GS__MTIME: i32 = GS_MTIME << GS_SHIFT; // c:92
188/// Port of `GS__CTIME` from `Src/glob.c:93`.
189pub const GS__CTIME: i32 = GS_CTIME << GS_SHIFT; // c:93
190/// Port of `GS__LINKS` from `Src/glob.c:94`.
191pub const GS__LINKS: i32 = GS_LINKS << GS_SHIFT; // c:94
192
193/// Port of `GS_DESC` from `Src/glob.c:96`. Descending-order toggle.
194pub const GS_DESC: i32 = GS_SHIFT_BASE << (2 * GS_SHIFT); // c:96
195/// Port of `GS_NONE` from `Src/glob.c:97`. Marker for no-sort spec.
196pub const GS_NONE: i32 = GS_SHIFT_BASE << (2 * GS_SHIFT + 1); // c:97
197
198/// Port of `GS_NORMAL` from `Src/glob.c:99`. Forward-direction sort
199/// keys (excluding NAME/DEPTH/EXEC and the reverse variants).
200pub const GS_NORMAL: i32 = GS_SIZE | GS_ATIME | GS_MTIME | GS_CTIME | GS_LINKS; // c:99
201/// Port of `GS_LINKED` from `Src/glob.c:100`. Reverse-direction
202/// (linked) sort keys.
203pub const GS_LINKED: i32 = GS_NORMAL << GS_SHIFT; // c:100
204
205// =====================================================================
206// TT_* — time + size unit selectors. Two parallel namespaces using
207// the same numeric values.
208// =====================================================================
209
210/// Port of `TT_DAYS` from `Src/glob.c:121`. Time qualifier in days.
211pub const TT_DAYS: i32 = 0; // c:121
212/// Port of `TT_HOURS` from `Src/glob.c:122`. Time qualifier in hours.
213pub const TT_HOURS: i32 = 1; // c:122
214/// Port of `TT_MINS` from `Src/glob.c:123`. Time qualifier in minutes.
215pub const TT_MINS: i32 = 2; // c:123
216/// Port of `TT_WEEKS` from `Src/glob.c:124`. Time qualifier in weeks.
217pub const TT_WEEKS: i32 = 3; // c:124
218/// Port of `TT_MONTHS` from `Src/glob.c:125`. Time qualifier in months.
219pub const TT_MONTHS: i32 = 4; // c:125
220/// Port of `TT_SECONDS` from `Src/glob.c:126`. Time qualifier in seconds.
221pub const TT_SECONDS: i32 = 5; // c:126
222
223/// Port of `TT_BYTES` from `Src/glob.c:128`. Size qualifier in bytes.
224pub const TT_BYTES: i32 = 0; // c:128
225/// Port of `TT_POSIX_BLOCKS` from `Src/glob.c:129`. Size qualifier
226/// in POSIX 512-byte blocks (the `b` glob qualifier suffix).
227pub const TT_POSIX_BLOCKS: i32 = 1; // c:129
228/// Port of `TT_KILOBYTES` from `Src/glob.c:130`.
229pub const TT_KILOBYTES: i32 = 2; // c:130
230/// Port of `TT_MEGABYTES` from `Src/glob.c:131`.
231pub const TT_MEGABYTES: i32 = 3; // c:131
232/// Port of `TT_GIGABYTES` from `Src/glob.c:132`.
233pub const TT_GIGABYTES: i32 = 4; // c:132
234/// Port of `TT_TERABYTES` from `Src/glob.c:133`.
235pub const TT_TERABYTES: i32 = 5; // c:133
236
237/// Port of `MAX_SORTS` from `Src/glob.c:164`. Maximum sort-spec keys
238/// per glob (`glob -O 'reverse(name).size'` style).
239pub const MAX_SORTS: usize = 12; // c:164
240
241/// Main glob state — port of `struct globdata` from Src/glob.c:168.
242/// C zsh has a single file-static `static struct globdata curglobdata;`
243/// (glob.c:196) and accesses fields through a wall of #define macros
244/// (`matchsz`/`matchct`/`pathbuf`/`pathpos`/`quals`/...) that all
245/// resolve to `curglobdata.gd_*`. The Rust port collapses the
246/// `gd_matchsz`/`gd_matchct`/`gd_matchbuf`/`gd_matchptr` quartet into
247/// `matches: Vec<GlobMatch>` (the natural Rust shape) and folds the
248/// `gd_gf_*` glob-flag bag into `options: GlobOptions`, but the
249/// 1:1 correspondence to `struct globdata` is otherwise faithful.
250// struct to easily save/restore current state                              // c:166
251/// `globdata` — see fields for layout.
252#[allow(non_camel_case_types)]
253pub struct globdata {
254    // c:168
255    /// `matches` field.
256    pub matches: Vec<gmatch>,
257    /// `qualifiers` field.
258    pub qualifiers: Option<qualifier_set>,
259    /// c:206 — `quals` (`curglobdata.gd_quals`): the `struct qual` list
260    /// that `insert()` walks per candidate file. Arena-backed port of
261    /// the C pointer list; `None` when the glob has no qualifiers.
262    pub quals: Option<QualArena>,
263    pub pathbuf: String,                    // c:170 gd_pathbuf
264    pub pathpos: usize,                     // c:169 gd_pathpos
265    pub matchct: i32,                       // c:173 gd_matchct
266    pub pathbufsz: usize,                   // c:174 gd_pathbufsz
267    pub pathbufcwd: i32,                    // c:175 gd_pathbufcwd
268    pub gf_nullglob: i32,                   // c:186 gd_gf_nullglob
269    pub gf_markdirs: i32,                   // c:186 gd_gf_markdirs
270    pub gf_noglobdots: i32,                 // c:186 gd_gf_noglobdots
271    pub gf_listtypes: i32,                  // c:186 gd_gf_listtypes
272    pub gf_pre_words: Option<Vec<String>>,  // c:190 gd_gf_pre_words
273    pub gf_post_words: Option<Vec<String>>, // c:190 gd_gf_post_words
274    /// The pattern's path text starts with a literal `./`, so every match
275    /// path is the pathbuf text verbatim (c:424 `dyncat(pathbuf, news)`) and
276    /// carries no scanner-added `./` for `glob_emit_path` to strip.
277    pub dot_prefix_verbatim: bool,
278}
279
280// c:197 — `static struct globdata curglobdata;`
281// C's single file-static glob state; macros at c:199-222 redirect
282// `pathbufsz`, `gf_noglobdots`, etc. to `curglobdata.gd_*`. Rust
283// mirror — accessed via lock for thread safety; C is single-threaded.
284/// `CURGLOBDATA` static.
285pub static CURGLOBDATA: std::sync::Mutex<globdata> = std::sync::Mutex::new(globdata {
286    matches: Vec::new(),
287    qualifiers: None,
288    quals: None,
289    pathbuf: String::new(),
290    pathpos: 0,
291    matchct: 0,
292    pathbufsz: 0,
293    pathbufcwd: 0,
294    gf_nullglob: 0,
295    gf_markdirs: 0,
296    gf_noglobdots: 0,
297    gf_listtypes: 0,
298    gf_pre_words: None,
299    gf_post_words: None,
300    dot_prefix_verbatim: false,
301});
302
303/// Port of `int badcshglob` from `Src/glob.c:103`. Tracks csh-glob
304/// diagnostic state across a single command line: bit 1 = "at
305/// least one expansion failed" (CSHNULLGLOB and no matches),
306/// bit 2 = "at least one expansion produced output". `globlist`
307/// resets to 0 at entry, ORs the bits as each `zglob` runs, then
308/// emits "no match" iff the final value is 1 (some failed, none
309/// succeeded). Used to make `*.nope *.ok` succeed without
310/// diagnostic, but `*.nope alone` error out under CSHNULLGLOB.
311pub static BADCSHGLOB: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0); // c:103
312
313/// Port of `static char **inserts;` from `Src/glob.c:340`. Set by
314/// `qualsheval` (c:3927-3937) from the `$reply` array / `$REPLY` scalar
315/// after an `(e:…:)` / `(+…)` qualifier eval; `insert()` then emits one
316/// match per element instead of the original name (c:428). `None` ==
317/// C's `inserts == NULL` (emit the original name once). Reset to `None`
318/// before each per-file qualifier walk (C: `inserts = NULL` at insert()
319/// entry, c:353).
320pub static INSERTS: std::sync::Mutex<Option<Vec<String>>> = std::sync::Mutex::new(None);
321
322/// Port of `struct complist` from `Src/glob.c:252`.
323/// C body:
324/// ```c
325/// struct complist {
326///     Complist next;
327///     Patprog  pat;
328///     int      closure;  /* 1 if this is a (foo/)# */
329///     int      follow;   /* 1 to go thru symlinks  */
330/// };
331/// ```
332#[allow(non_camel_case_types)]
333pub struct complist {
334    // c:252
335    pub next: Option<Box<complist>>,          // c:253
336    pub pat: crate::ported::pattern::Patprog, // c:254
337    pub closure: i32,                         // c:255
338    pub follow: i32,                          // c:256
339}
340
341/// Add path component (from glob.c addpath lines 263-274)
342/// Append a path component to a glob path buffer.
343/// Port of `addpath(char *s, int l)` from Src/glob.c:265.
344///
345/// C body (c:270-273):
346/// ```c
347///     while (l--)
348///         pathbuf[pathpos++] = *s++;
349///     pathbuf[pathpos++] = '/';
350///     pathbuf[pathpos] = '\0';
351/// ```
352/// The `/` is appended UNCONDITIONALLY. That is load-bearing for EMPTY
353/// path components: `patcompile(..., PAT_FILET)` cuts a pure section at
354/// the first `/` (pattern.rs:891), so `//usr//b*` parses into the
355/// component chain `"" , "usr", "", "b*"` and each empty component must
356/// still contribute one `/` to `pathbuf` for the glob results to carry
357/// the literal `//` the user typed. A previous `if !s.ends_with('/')`
358/// guard here squeezed those empty components away (`//usr//bin` →
359/// `/usr/bin`), which broke `_path_files`: it anchors matches on the
360/// on-line path text (`${(@)tmp1#$prepath$realpath$testpath}`), so a
361/// squeezed result no longer shares the `//usr//` prefix and EVERY match
362/// was discarded — completion after `ls //usr//` produced nothing at all.
363/// A non-empty component can never end in `/` (patcompile cut it there),
364/// so removing the guard changes behaviour ONLY in the empty case.
365pub fn addpath(s: &mut String, l: &str) {
366    // c:265
367    s.push_str(l); // c:270-271
368    s.push('/'); // c:272 — unconditional, see doc above
369}
370
371/// PARTIAL port of `statfullpath(const char *s, struct stat *st, int l)` from
372/// `Src/glob.c:283` — NOT yet faithful, and currently has NO callers (the live
373/// qualifier matcher inlines `fs::metadata`/`symlink_metadata` on the already-
374/// full path). Two gaps remain, both blocked on the glob path-state flow:
375///   1. C prepends the global `pathbuf[pathbufcwd..pathpos]` (the accumulated
376///      glob directory) to `s`; this port omits it. A faithful version must
377///      take the pathbuf as a parameter — it cannot read the `CURGLOBDATA`
378///      singleton because the matcher already holds that lock (deadlock).
379///   2. C's `st` is the OUTPUT `struct stat *`; the second arg here is a string
380///      suffix, which is not the C contract.
381/// The `l` flag now matches C: `l ? lstat : stat` (l → `symlink_metadata`).
382/// Do not wire this in until gap #1 is resolved.
383pub fn statfullpath(s: &str, st: &str, l: bool) -> Option<Metadata> {
384    // c:283
385    let full = if st.is_empty() {
386        if s.is_empty() {
387            ".".to_string()
388        } else {
389            s.to_string()
390        }
391    } else {
392        format!("{}{}", s, st)
393    };
394
395    // c:308 — `return l ? lstat(buf, st) : stat(buf, st);`
396    if l {
397        fs::symlink_metadata(&full).ok() // l → lstat
398    } else {
399        fs::metadata(&full).ok() // !l → stat (follows symlinks)
400    }
401}
402// END moved-from-exec-rs (free ported)
403
404// ===========================================================
405// Direct ports of static helpers from Src/glob.c not yet covered
406// above. The Rust glob engine (`crate::glob`) re-implements the
407// lexer + matcher; these free-fn entries satisfy ABI/name parity
408// for the drift gate.
409// ===========================================================
410
411/// Port of `scanner(Complist q, int shortcircuit)` from `Src/glob.c:500`.
412///
413/// Walks the `complist` path-component chain built by `parsecomplist`,
414/// descending the directory tree one component per node. `q.closure`
415/// (set for `**` and `(dir/)#` / `(dir/)##`) drives the any-depth match:
416/// the zero-directory case scans `q.next` directly, then each matching
417/// subdirectory is re-scanned against `(q.closure) ? q : q.next` — i.e.
418/// the same node `q` repeats for `**`, matching at every depth.
419///
420/// Deviations from C, all pre-existing in this engine — NOT introduced
421/// by this port:
422///   * directory entries come from `fs::read_dir` + `zreaddir` (the
423///     ported `zreaddir`, glob.c:5217) instead of C `opendir`; same
424///     skip-`.`/`..` behaviour, and names are real (non-metafied) so no
425///     `unmeta` round-trip is needed;
426///   * the approximate-match error-reduction block (`forceerrs`/
427///     `errsfound`, glob.c:619-639) is omitted — the Rust matcher keeps
428///     no per-match error count;
429///   * `glob_pre`/`glob_suf` (ZLE prefix/suffix) filtering is omitted.
430/// Leading-dot files are filtered by `pattry`'s `PAT_NOGLD` check
431/// (pattern.rs:2890); `globdata_glob` sets `gf_noglobdots` so
432/// `parsecomplist` compiles the flag in (faithful to C's `glob()`).
433///
434/// The `in_closure_repeat` parameter is Rust-only: it carries C's
435/// `q->closure = 1` mutation across the recursion. A `(dir/)##` node
436/// (closure==2) requires at least one directory, so the zero-directory
437/// match is skipped only the FIRST time the node is entered; once we have
438/// descended one closure level it behaves like `(dir/)#` (zero-or-more).
439/// `q` is shared (`&complist`), so C's in-place field mutation is modelled
440/// by this argument instead. Callers pass `false`.
441fn scanner(state: &mut globdata, q: Option<&complist>, shortcircuit: i32, in_closure_repeat: bool) {
442    use std::sync::atomic::Ordering;
443    // c:506 — `if (!q || errflag) return;`
444    let Some(q) = q else { return };
445    if errflag.load(Ordering::SeqCst) & crate::ported::zsh_h::ERRFLAG_ERROR != 0 {
446        return;
447    }
448    let pbcwdsav = state.pathbufcwd; // c:503
449    let mut ds = init_dirsav(); // c:508
450    let path_max = crate::ported::zsh_system_h::PATH_MAX;
451
452    // c:510-518 — closure preamble: try zero directories via q.next first
453    // (skipped for `(dir/)##` until at least one directory is consumed).
454    let closure = q.closure;
455    if closure != 0 {
456        let skip_zero = q.closure == 2 && !in_closure_repeat;
457        if !skip_zero {
458            scanner(state, q.next.as_deref(), shortcircuit, false); // c:515
459            if shortcircuit != 0 && shortcircuit == state.matchct {
460                return;
461            }
462        }
463    }
464
465    let p = &q.pat; // c:519
466    if (p.0.flags & crate::ported::zsh_h::PAT_PURES as i32) != 0 {
467        // c:521-565 — pure literal section: append to the path (intermediate)
468        // or emit (final); no directory scan.
469        let start = p.0.startoff as usize;
470        let l = p.0.patmlen as usize;
471        let str_lit = String::from_utf8_lossy(&p.1[start..start + l]).into_owned();
472
473        // c:524-536 — PATH_MAX guard.
474        if l + (l == 0) as usize + state.pathpos - state.pathbufcwd as usize >= path_max {
475            if l >= path_max {
476                return;
477            }
478            let anchor = state
479                .pathbuf
480                .get(state.pathbufcwd as usize..)
481                .unwrap_or("")
482                .to_string();
483            let err = lchdir(&anchor, Some(&mut ds), 0);
484            if err == -1 {
485                return;
486            }
487            if err != 0 {
488                zerr("current directory lost during glob");
489                return;
490            }
491            state.pathbufcwd = state.pathpos as i32;
492        }
493
494        if q.next.is_some() {
495            // c:539-560 — not the last section: add to path, recurse.
496            let oppos = state.pathpos;
497            if errflag.load(Ordering::SeqCst) & crate::ported::zsh_h::ERRFLAG_ERROR == 0 {
498                // c:543-552 — `.`/`..` handling inside a closure walk.
499                let mut add = true;
500                if closure != 0 && !state.pathbuf.is_empty() {
501                    if str_lit == "." {
502                        add = false; // c:546
503                    } else if str_lit == ".." {
504                        // c:547-551 — drop `..` that would escape the root.
505                        use std::os::unix::fs::MetadataExt;
506                        let cur: &str = &state.pathbuf;
507                        add = match (fs::metadata("/"), fs::metadata(cur)) {
508                            (Ok(r), Ok(c)) => r.ino() != c.ino() || r.dev() != c.dev(),
509                            _ => true,
510                        };
511                    }
512                }
513                if add {
514                    addpath(&mut state.pathbuf, &str_lit); // c:553
515                    state.pathpos = state.pathbuf.len();
516                    // c:554 — closure: only recurse when the new path is a
517                    // real directory (`!statfullpath("", NULL, 1)`).
518                    let recurse = closure == 0
519                        || fs::metadata(state.pathbuf.as_str())
520                            .map(|m| m.is_dir())
521                            .unwrap_or(false);
522                    if recurse {
523                        scanner(
524                            state,
525                            if closure != 0 {
526                                Some(q)
527                            } else {
528                                q.next.as_deref()
529                            },
530                            shortcircuit,
531                            closure != 0,
532                        ); // c:555
533                        if shortcircuit != 0 && shortcircuit == state.matchct {
534                            return;
535                        }
536                    }
537                    state.pathbuf.truncate(oppos); // c:558
538                    state.pathpos = oppos;
539                }
540            }
541        } else {
542            // c:561-564 — last section: emit the literal.
543            let full = std::path::Path::new(&state.pathbuf).join(&str_lit);
544            insert(state, &full, 0);
545            if shortcircuit != 0 && shortcircuit == state.matchct {
546                return;
547            }
548        }
549    } else {
550        // c:567-685 — pattern-matched section: scan the directory.
551        let base = {
552            let from_cwd = state.pathbuf.get(state.pathbufcwd as usize..).unwrap_or("");
553            if from_cwd.is_empty() {
554                ".".to_string()
555            } else {
556                from_cwd.to_string()
557            }
558        };
559        let dirs = q.next.is_some(); // c:570
560        let mut rd = match fs::read_dir(&base) {
561            Ok(d) => d,
562            Err(_) => return, // c:573 — opendir == NULL
563        };
564        // c:572-573 — collect matching subdirs, descend AFTER the dir
565        // handle is dropped (C buffers them in `subdirs`). `zreaddir(lock, 1)`
566        // ALWAYS skips `.`/`..` (c:5217); leading-dot files are admitted and
567        // then accepted/rejected by `pattry`'s PAT_NOGLD rule, so dotglob /
568        // `(D)` is handled by the compiled flag, never by re-including
569        // `.`/`..` here (that previously leaked `.`/`..` under dotglob).
570        let mut subdirs: Vec<String> = Vec::new();
571        while let Some(name) = crate::ported::utils::zreaddir(&mut rd, 1) {
572            if errflag.load(Ordering::SeqCst) & crate::ported::zsh_h::ERRFLAG_ERROR != 0 {
573                break;
574            }
575            if !crate::ported::pattern::pattry(p, &name) {
576                continue; // c:583
577            }
578            // c:585-597 — PATH_MAX lchdir bookkeeping.
579            if pbcwdsav == state.pathbufcwd
580                && name.len() + state.pathpos - state.pathbufcwd as usize >= path_max
581            {
582                let anchor = state
583                    .pathbuf
584                    .get(state.pathbufcwd as usize..)
585                    .unwrap_or("")
586                    .to_string();
587                let err = lchdir(&anchor, Some(&mut ds), 0);
588                if err == -1 {
589                    break;
590                }
591                if err != 0 {
592                    zerr("current directory lost during glob");
593                    break;
594                }
595                state.pathbufcwd = state.pathpos as i32;
596            }
597            if dirs {
598                // c:642-655 — closure: only descend real directories.
599                if closure != 0 {
600                    let probe = std::path::Path::new(&base).join(&name);
601                    let md = if q.follow != 0 {
602                        fs::metadata(&probe)
603                    } else {
604                        fs::symlink_metadata(&probe)
605                    };
606                    match md {
607                        Ok(m) if m.is_dir() => {}
608                        _ => continue,
609                    }
610                }
611                subdirs.push(name); // c:657-663
612            } else {
613                // c:665-670 — last component: emit.
614                let full = std::path::Path::new(&base).join(&name);
615                insert(state, &full, 1);
616                if shortcircuit != 0 && shortcircuit == state.matchct {
617                    return;
618                }
619            }
620        }
621        drop(rd); // c:672 — closedir
622                  // c:674-684 — descend into each collected subdir.
623        if !subdirs.is_empty() {
624            let oppos = state.pathpos;
625            // RUST-ONLY — fan the sibling subdirs of a `**` closure level out
626            // over the rayon pool instead of the serial `for` below, appending
627            // each sibling's matches in `subdirs` order so the result equals the
628            // serial descent (including `oN` unsorted globs). Each worker runs
629            // `scanner` on its own `globdata`; nested levels fan out again
630            // through the same pool. Stays serial when `[glob] recursive_parallel
631            // = false`, with fewer than two siblings, or with an `(e:…:)` /
632            // `(+…)` qualifier (`qualsheval` runs shell code and publishes
633            // `$reply` through the process-global `INSERTS`). The qualifier
634            // arena `insert` walks is `state.qualifiers.quals` (check_qualifiers);
635            // the guard only looked at `state.quals`, so `**/*(e:…:)` fanned out
636            // anyway and the workers raced on `$REPLY` / `INSERTS`. The test is
637            // structural: `sdata` is set only by the eval qualifier.
638            if closure != 0
639                && shortcircuit == 0
640                && subdirs.len() >= 2
641                && crate::extensions::config::current().glob.recursive_parallel
642                && !state
643                    .quals
644                    .as_ref()
645                    .into_iter()
646                    .chain(state.qualifiers.as_ref().map(|q| &q.quals))
647                    .any(|arena| arena.nodes.iter().any(|n| n.sdata.is_some()))
648            {
649                use rayon::prelude::*;
650                // Workers are pool threads with their own TLS; hand each the
651                // option snapshot this glob started with.
652                let snap = GLOB_OPTS_TLS.with_borrow(|g| *g);
653                let shared: &globdata = state;
654                // The process's own pool, not rayon's global one: a forked child
655                // (`$(...)`, a pipeline stage) inherits the global pool's handles
656                // without its threads, and `par_iter` there never returns.
657                let results: Option<Vec<(Vec<gmatch>, i32)>> = crate::fork_safe_pool::pool().map(|pool| pool.install(|| subdirs
658                    .par_iter()
659                    .map(|name| {
660                        let mut child = globdata::new();
661                        child.qualifiers = shared.qualifiers.clone();
662                        child.quals = shared.quals.clone();
663                        child.gf_nullglob = shared.gf_nullglob;
664                        child.gf_markdirs = shared.gf_markdirs;
665                        child.gf_noglobdots = shared.gf_noglobdots;
666                        child.gf_listtypes = shared.gf_listtypes;
667                        child.gf_pre_words = shared.gf_pre_words.clone();
668                        child.gf_post_words = shared.gf_post_words.clone();
669                        child.dot_prefix_verbatim = shared.dot_prefix_verbatim;
670                        child.pathbuf = shared.pathbuf.clone();
671                        child.pathbufcwd = shared.pathbufcwd;
672                        addpath(&mut child.pathbuf, name);
673                        child.pathpos = child.pathbuf.len();
674                        let prev = GLOB_OPTS_TLS.with_borrow_mut(|g| std::mem::replace(g, snap));
675                        scanner(&mut child, Some(q), 0, true); // c:681
676                        GLOB_OPTS_TLS.with_borrow_mut(|g| *g = prev);
677                        (child.matches, child.matchct)
678                    })
679                    .collect()));
680                if let Some(results) = results {
681                    for (matches, matchct) in results {
682                        state.matches.extend(matches);
683                        state.matchct += matchct;
684                    }
685                    subdirs.clear();
686                }
687            }
688            for name in subdirs {
689                addpath(&mut state.pathbuf, &name);
690                state.pathpos = state.pathbuf.len();
691                scanner(
692                    state,
693                    if closure != 0 {
694                        Some(q)
695                    } else {
696                        q.next.as_deref()
697                    },
698                    shortcircuit,
699                    closure != 0,
700                ); // c:681
701                if shortcircuit != 0 && shortcircuit == state.matchct {
702                    return;
703                }
704                state.pathbuf.truncate(oppos);
705                state.pathpos = oppos;
706            }
707        }
708    }
709
710    // c:687-694 — restore cwd if we lchdir'd partway through.
711    if pbcwdsav < state.pathbufcwd {
712        if restoredir(&mut ds) != 0 {
713            zerr("current directory lost during glob"); // c:689
714        }
715        state.pathbufcwd = pbcwdsav;
716    }
717}
718
719/* This function tokenizes a zsh glob pattern */
720// c:706
721/// Port of `parsecomplist(char *instr)` from Src/glob.c:710.
722/// Tokenize a zsh glob path pattern into a `Complist` of path
723/// components, recursively. Returns `None` and sets `errflag |=
724/// ERRFLAG_ERROR` on parse failure. Reads `gf_noglobdots` from
725/// `CURGLOBDATA` (the curglobdata singleton at c:197).
726pub fn parsecomplist(instr: &str) -> Option<Box<complist>> {
727    // c:710
728    let p1: crate::ported::pattern::Patprog; // c:712
729    let l1: Box<complist>; // c:713
730                           // c:714 `char *str;` — used as the skipparens cursor (parens branch).
731                           // c:715 — compflags depend on gf_noglobdots from curglobdata.
732    let gf_noglobdots: i32 = CURGLOBDATA
733        .lock()
734        .unwrap_or_else(|e| e.into_inner())
735        .gf_noglobdots; // c:214 macro / c:186 field
736    let compflags: i32 = if gf_noglobdots != 0 {
737        crate::ported::zsh_h::PAT_FILE | crate::ported::zsh_h::PAT_NOGLD
738    } else {
739        crate::ported::zsh_h::PAT_FILE
740    }; // c:715
741
742    let chars: Vec<char> = instr.chars().collect();
743    if chars.len() >= 2
744        && chars[0] == crate::ported::zsh_h::Star
745        && chars[1] == crate::ported::zsh_h::Star
746    {
747        // c:717
748        let mut shortglob: i32 = 0; // c:718
749        let cond_a = chars.get(2) == Some(&'/'); // c:719
750        let cond_b =
751            chars.get(2) == Some(&crate::ported::zsh_h::Star) && chars.get(3) == Some(&'/'); // c:719
752                                                                                             // c:719-720 — `instr[2] == '/' || (instr[2] == Star && instr[3] == '/')
753                                                                                             // || (shortglob = isset(GLOBSTARSHORT))`. C's `||` SHORT-CIRCUITS:
754                                                                                             // the `shortglob = isset(GLOBSTARSHORT)` assignment runs ONLY when
755                                                                                             // `**` is not explicitly followed by `/`. Mirror that with a lazy
756                                                                                             // `||` so cond_a/cond_b keep `shortglob == 0` — an eager
757                                                                                             // `let cond_c = { shortglob = … }` set it unconditionally, making
758                                                                                             // `**/x` advance by 1 (`shortglob ? 1 : 3`) instead of 3, leaving a
759                                                                                             // stray `*` that collapsed `**/` to a single directory level.
760        let enter = cond_a || cond_b || {
761            shortglob = if crate::ported::zsh_h::isset(crate::ported::zsh_h::GLOBSTARSHORT) {
762                1
763            } else {
764                0
765            };
766            shortglob != 0
767        }; // c:720
768        if enter {
769            /* Match any number of directories. */
770            // c:721
771            /* with three stars, follow symbolic links */                    // c:724
772            let follow: i32 = if chars.get(2) == Some(&crate::ported::zsh_h::Star) {
773                1
774            } else {
775                0
776            }; // c:725
777               /*
778                * With GLOBSTARSHORT, leave a star in place for the
779                * pattern inside the directory.
780                */                                                              // c:726-729
781            let advance: usize = (if shortglob != 0 { 1 } else { 3 }) + follow as usize; // c:730
782
783            /* Now get the next path component if there is one. */
784            // c:732
785            let next_instr: String = chars[advance..].iter().collect();
786            // c:733 — `l1 = (Complist) zhalloc(sizeof *l1);`
787            let next_l = parsecomplist(&next_instr); // c:734
788            if next_l.is_none() {
789                crate::ported::utils::errflag.fetch_or(
790                    crate::ported::zsh_h::ERRFLAG_ERROR,
791                    std::sync::atomic::Ordering::Relaxed,
792                ); // c:735
793                return None; // c:736
794            }
795            let pat = crate::ported::pattern::patcompile(
796                "",
797                compflags | crate::ported::zsh_h::PAT_ANY,
798                None,
799            )?; // c:738
800            l1 = Box::new(complist {
801                next: next_l,
802                pat,
803                closure: 1, // c:739
804                follow,     // c:740
805            });
806            return Some(l1); // c:741
807        }
808    }
809
810    /* Parse repeated directories such as (dir/)# and (dir/)## */
811    // c:745
812    let zpc = crate::ported::pattern::zpc_special
813        .lock()
814        .unwrap_or_else(|e| e.into_inner());
815    let inpar_c = zpc[crate::ported::zsh_h::ZPC_INPAR as usize] as char;
816    let hash_c = zpc[crate::ported::zsh_h::ZPC_HASH as usize] as char;
817    drop(zpc);
818
819    // c:746-748 tests `*instr == zpc_special[ZPC_INPAR]` and `*str ==
820    // zpc_special[ZPC_HASH]`, and C's `zpc_chars` (Src/pattern.c:248)
821    // stores the TOKENS there — `Inpar` and `Pound`, not `(` and `#` —
822    // because everything reaching patcompile in C is lexer-tokenized.
823    // zshrs's `patcompcharsset` deliberately stores the RAW spelling
824    // instead (see its note there: patcompile is reachable with
825    // untokenized patterns and ~80 tests depend on a raw `|`
826    // alternating), so a straight comparison against the table never
827    // matches the tokenized input `parsecomplist` actually receives
828    // from `globdata_glob`.
829    //
830    // Accept either spelling, but keep the option masking: when a slot
831    // is disabled (EXTENDEDGLOB off, SHGLOB on, `disable -p`)
832    // `patcompcharsset` overwrites it with `Marker`, and then NEITHER
833    // spelling may match — which is what keeps `setopt noextendedglob;
834    // (sub/)#end` a literal.
835    // The slots hold the Marker TRUNCATED to a byte (patcompcharsset stores
836    // `Marker as u8`), so compare against that truncation, not the full char.
837    let marker_c = (crate::ported::zsh_h::Marker as u32 as u8) as char;
838    let is_inpar =
839        |c: char| c == inpar_c || (c == crate::ported::zsh_h::Inpar && inpar_c != marker_c);
840    let is_hash = |c: char| c == hash_c || (c == crate::ported::zsh_h::Pound && hash_c != marker_c);
841
842    // c:746-748 — `if (*(str = instr) == zpc_special[ZPC_INPAR] &&
843    //               !skipparens(Inpar, Outpar, (char **)&str) &&
844    //               *str == zpc_special[ZPC_HASH] && str[-2] == '/')`.
845    // Routed through the canonical `skipparens` port for caller-coverage
846    // parity with C — was previously inlined as a divergent depth-walk
847    // because the old Rust signature returned usize. The C-faithful
848    // `skipparens(open, close, &mut &str)` now matches `int skipparens
849    // (char inpar, char outpar, char **s)` at utils.c:2409.
850    let instr_chars: String = chars.iter().collect();
851    let inpar_byte = crate::ported::zsh_h::Inpar as u32;
852    let outpar_byte = crate::ported::zsh_h::Outpar as u32;
853    let mut cursor: &str = &instr_chars;
854    let skip_level: i32 = crate::ported::utils::skipparens(
855        char::from_u32(inpar_byte).unwrap_or('('),
856        char::from_u32(outpar_byte).unwrap_or(')'),
857        &mut cursor,
858    );
859    let str_after_parens: Option<usize> = if chars.first().copied().is_some_and(is_inpar) {
860        Some(instr_chars.chars().count() - cursor.chars().count())
861    } else {
862        None
863    };
864    let parens_balanced = chars.first().copied().is_some_and(is_inpar) && skip_level == 0; // c:746-747 `!skipparens(...)`
865    let after_paren_idx = str_after_parens.unwrap_or(0);
866    let str_at_hash = parens_balanced && chars.get(after_paren_idx).copied().is_some_and(is_hash); // c:748 `*str == Pound`
867                                                                                                   // c:748 `str[-2] == '/'` — `str` is past `)`, so `str[-2]` is char
868                                                                                                   // before `)`. In chars, that's `chars[after_paren_idx - 2]`.
869    let preceded_by_slash =
870        parens_balanced && after_paren_idx >= 2 && chars.get(after_paren_idx - 2) == Some(&'/');
871
872    if parens_balanced && str_at_hash && preceded_by_slash {
873        // c:749 — `instr++;`
874        let mut cursor: String = chars[1..].iter().collect();
875        let mut endexp = String::new();
876        let p1_opt = crate::ported::pattern::patcompile(&cursor, compflags, Some(&mut endexp)); // c:750
877        if p1_opt.is_none() {
878            return None; // c:751
879        }
880        let p1_real = p1_opt.unwrap();
881        cursor = endexp; // C: `instr` advanced past compiled pattern
882        let c2: Vec<char> = cursor.chars().collect();
883        if c2.first() == Some(&'/')
884            && c2.get(1) == Some(&crate::ported::zsh_h::Outpar)
885            && c2.get(2) == Some(&crate::ported::zsh_h::Pound)
886        {
887            // c:752
888            let mut pdflag: i32 = 0; // c:753
889            let mut adv = 3; // c:755
890            if c2.get(3) == Some(&crate::ported::zsh_h::Pound) {
891                pdflag = 1; // c:757
892                adv = 4; // c:758
893            }
894            let next_instr: String = c2[adv..].iter().collect();
895            // c:760-761 — `l1 = (Complist) zhalloc(...); l1->pat = p1;`
896            /* special case (/)# to avoid infinite recursion */              // c:762
897            // c:763 — `(*((char *)p1 + p1->startoff)) ? 1 + pdflag : 0`
898            let pat_nonempty = !p1_real.1.is_empty()
899                && p1_real
900                    .1
901                    .get(p1_real.0.startoff as usize)
902                    .copied()
903                    .unwrap_or(0)
904                    != 0;
905            let closure = if pat_nonempty { 1 + pdflag } else { 0 };
906            let next_l = parsecomplist(&next_instr); // c:765
907                                                     // c:766 — `return (l1->pat) ? l1 : NULL;`
908            l1 = Box::new(complist {
909                next: next_l,
910                pat: p1_real,
911                closure,
912                follow: 0, // c:764
913            });
914            return Some(l1);
915        }
916    } else {
917        /* parse single path component */
918        // c:769
919        let mut endexp = String::new();
920        let p1_opt = crate::ported::pattern::patcompile(
921            instr,
922            compflags | crate::ported::zsh_h::PAT_FILET,
923            Some(&mut endexp),
924        ); // c:770
925        if p1_opt.is_none() {
926            return None; // c:771
927        }
928        p1 = p1_opt.unwrap();
929        let cursor: Vec<char> = endexp.chars().collect();
930        /* then do the remaining path components */
931        // c:772
932        let head = cursor.first().copied();
933        if head == Some('/') || head.is_none() {
934            // c:773
935            let ef: i32 = if head == Some('/') { 1 } else { 0 }; // c:774
936            let next_l: Option<Box<complist>> = if ef != 0 {
937                let rest: String = cursor[1..].iter().collect();
938                parsecomplist(&rest) // c:779
939            } else {
940                None
941            };
942            // c:780 — `return (ef && !l1->next) ? NULL : l1;`
943            if ef != 0 && next_l.is_none() {
944                return None;
945            }
946            l1 = Box::new(complist {
947                next: next_l,
948                pat: p1,
949                closure: 0, // c:778
950                follow: 0,
951            });
952            return Some(l1);
953        }
954    }
955    crate::ported::utils::errflag.fetch_or(
956        crate::ported::zsh_h::ERRFLAG_ERROR,
957        std::sync::atomic::Ordering::Relaxed,
958    ); // c:783
959    None // c:784
960}
961
962/* turn a string into a Complist struct:  this has path components */
963// c:787
964/// Port of `parsepat(char *str)` from Src/glob.c:791.
965/// Top-level entry: strip leading `(#...)` flag block via
966/// `patgetglobflags`, then initialise `pathbuf`/`pathpos` per the
967/// pattern's absolute-vs-relative head, then dispatch to
968/// `parsecomplist`. Mutates `CURGLOBDATA.{pathbuf,pathpos,pathbufsz}`.
969pub fn parsepat(s: &str) -> Option<Box<complist>> {
970    // c:791
971    // c:793 `long assert; int ignore;` — captured into patgetglobflags result
972    crate::ported::pattern::patcompstart(); // c:796
973    let chars: Vec<char> = s.chars().collect();
974    let zpc = crate::ported::pattern::zpc_special
975        .lock()
976        .unwrap_or_else(|e| e.into_inner());
977    let inpar_c = zpc[crate::ported::zsh_h::ZPC_INPAR as usize] as char;
978    let hash_c = zpc[crate::ported::zsh_h::ZPC_HASH as usize] as char;
979    let ksh_at_c = zpc[crate::ported::zsh_h::ZPC_KSH_AT as usize] as char;
980    drop(zpc);
981
982    /*
983     * Check for initial globbing flags, so that they don't form
984     * a bogus path component.
985     */
986    // c:797-800
987    let mut cursor: String = s.to_string();
988    let first_is_inpar_hash = chars.first() == Some(&inpar_c) && chars.get(1) == Some(&hash_c); // c:801
989    let first_is_ksh_at_inpar_hash = chars.first() == Some(&ksh_at_c)
990        && chars.get(1) == Some(&crate::ported::zsh_h::Inpar)
991        && chars.get(2) == Some(&hash_c); // c:802-803
992    if first_is_inpar_hash || first_is_ksh_at_inpar_hash {
993        let skip = if chars.first() == Some(&crate::ported::zsh_h::Inpar) {
994            2
995        } else {
996            3
997        }; // c:804
998        cursor = chars[skip..].iter().collect();
999        let flag_result = crate::ported::pattern::patgetglobflags(&cursor); // c:805
1000        if flag_result.is_none() {
1001            return None; // c:806
1002        }
1003        let (_bits, _assertp, consumed) = flag_result.unwrap();
1004        cursor = cursor[consumed..].to_string();
1005    }
1006
1007    /* Now there is no (#X) in front, we can check the path. */
1008    // c:809
1009    {
1010        let mut gd = CURGLOBDATA.lock().unwrap_or_else(|e| e.into_inner());
1011        // c:810-811 — `if (!pathbuf) pathbuf = zalloc(pathbufsz = PATH_MAX+1);`
1012        if gd.pathbuf.capacity() == 0 {
1013            gd.pathbufsz = libc::PATH_MAX as usize + 1;
1014            gd.pathbuf = String::with_capacity(gd.pathbufsz);
1015        }
1016        // c:812 — `DPUTS(pathbufcwd, "BUG: glob changed directory");`
1017        debug_assert!(gd.pathbufcwd == 0, "BUG: glob changed directory");
1018        if cursor.starts_with('/') {
1019            // c:813
1020            /* pattern has absolute path */
1021            cursor = cursor[1..].to_string(); // c:814 `str++;`
1022            gd.pathbuf.clear();
1023            gd.pathbuf.push('/'); // c:815 `pathbuf[0] = '/';`
1024            gd.pathpos = 1; // c:816 `pathbuf[pathpos = 1] = '\0';`
1025        } else {
1026            /* pattern is relative to pwd */
1027            // c:817
1028            gd.pathbuf.clear(); // c:818 `pathbuf[pathpos = 0] = '\0';`
1029            gd.pathpos = 0;
1030        }
1031    }
1032
1033    parsecomplist(&cursor) // c:820
1034}
1035
1036/// Parse qualifier (from glob.c qgetnum)
1037/// Parse a numeric glob-qualifier argument.
1038/// Port of `qgetnum(char **s)` from Src/glob.c:827.
1039pub fn qgetnum(s: &str) -> Option<(i64, &str)> {
1040    // c:827
1041    let end = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len());
1042    if end == 0 {
1043        return None;
1044    }
1045    let num = s[..end].parse::<i64>().ok()?;
1046    Some((num, &s[end..]))
1047}
1048
1049impl globdata {
1050    /// `new` — see implementation.
1051    pub fn new() -> Self {
1052        globdata {
1053            matches: Vec::new(),
1054            qualifiers: None,
1055            quals: None,
1056            pathbuf: String::with_capacity(4096),
1057            pathpos: 0,
1058            matchct: 0,
1059            pathbufsz: 4096,
1060            pathbufcwd: 0,
1061            gf_nullglob: 0,
1062            gf_markdirs: 0,
1063            gf_noglobdots: 0,
1064            gf_listtypes: 0,
1065            gf_pre_words: None,
1066            gf_post_words: None,
1067            dot_prefix_verbatim: false,
1068        }
1069    }
1070}
1071
1072// ============================================================================
1073// Mode specification parsing (from glob.c qgetmodespec)
1074// ============================================================================
1075
1076/// Port of `qgetmodespec(char **s)` from `Src/glob.c:844`.
1077///
1078/// Parses a chmod-style spec for the `f` glob qualifier and returns
1079/// C's packed value `(yes & 07777) | ((no & 07777) << 12)` (c:931) —
1080/// the bits that must be set and the bits that must be clear, as
1081/// `qualmodeflags` (c:3817) reads them — plus the unparsed rest, which
1082/// is C's `*s = p` (c:930). `None` is C's `zerr("invalid mode
1083/// specification"); return 0;` (c:890/913/925); the message is already
1084/// out.
1085/// WARNING: param names don't match C — Rust=(s) vs C=(s); the `char **`
1086/// cursor is returned as the rest slice.
1087pub fn qgetmodespec(s: &str) -> Option<(i64, &str)> {
1088    // c:844
1089    use crate::ported::zsh_h::{Equals, Inang, Inbrace, Inbrack, Outang, Outbrace, Outbrack, Quest};
1090    let cs: Vec<(usize, char)> = s.char_indices().collect();
1091    // `*p` past the end reads the terminating NUL.
1092    let at = |i: usize| cs.get(i).map(|&(_, c)| c).unwrap_or('\0');
1093    let (mut yes, mut no): (i64, i64) = (0, 0); // c:846
1094    let mut p = 0usize; // c:847
1095    let mut c = at(p);
1096    let end: char;
1097    // c:849-860 — a bare spec starts with an operator or an octal digit;
1098    // anything else is the opening delimiter.
1099    if c == '=' || c == Equals || c == '+' || c == '-' || c == '?' || c == Quest || ('0'..='7').contains(&c) {
1100        end = '\0'; // c:851
1101    } else {
1102        end = match c {
1103            '<' => '>',
1104            '[' => ']',
1105            '{' => '}',
1106            x if x == Inang => Outang,
1107            x if x == Inbrack => Outbrack,
1108            x if x == Inbrace => Outbrace,
1109            other => other,
1110        }; // c:853-858
1111        p += 1; // c:859
1112    }
1113    let invalid = || {
1114        zerr("invalid mode specification");
1115        None
1116    };
1117    loop {
1118        // c:861 do {
1119        let mut mask: i64 = 0; // c:862
1120        loop {
1121            // c:863 — who letters, only inside a delimited spec.
1122            c = at(p);
1123            if !(matches!(c, 'u' | 'g' | 'o' | 'a') && end != '\0') {
1124                break;
1125            }
1126            mask |= match c {
1127                'o' => 0o1007, // c:865
1128                'g' => 0o2070, // c:866
1129                'u' => 0o4700, // c:867
1130                _ => 0o7777,   // c:868 'a'
1131            };
1132            p += 1; // c:870
1133        }
1134        let how = if c == '+' || c == '-' { c } else { '=' }; // c:872
1135        if c == '+' || c == '-' || c == '=' || c == Equals {
1136            // c:873
1137            p += 1; // c:874
1138        }
1139        let mut val: i64 = 0; // c:875
1140        if mask != 0 {
1141            // c:876
1142            loop {
1143                // c:877 — `while ((c = *p++) != ',' && c != end)`
1144                c = at(p);
1145                p += 1;
1146                if c == ',' || c == end {
1147                    break;
1148                }
1149                match c {
1150                    'x' => val |= 0o0111,  // c:879
1151                    'w' => val |= 0o0222,  // c:880
1152                    'r' => val |= 0o0444,  // c:881
1153                    's' => val |= 0o6000,  // c:882
1154                    't' => val |= 0o1000,  // c:883
1155                    '0'..='7' => {
1156                        // c:884-888
1157                        let t = c as i64 - '0' as i64;
1158                        val |= t | (t << 3) | (t << 6);
1159                    }
1160                    _ => return invalid(), // c:889-891
1161                }
1162            }
1163            if how == '=' || how == '+' {
1164                // c:894
1165                yes |= val & mask; // c:895
1166                val = !val; // c:896
1167            }
1168            if how == '=' || how == '-' {
1169                // c:898
1170                no |= val & mask; // c:899
1171            }
1172        } else if !(end != '\0' && c == end) && c != ',' && c != '\0' {
1173            // c:900
1174            let mut t: i64 = 0o7777; // c:901
1175            loop {
1176                // c:902-912
1177                c = at(p);
1178                if c == '?' || c == Quest {
1179                    t = (t << 3) | 7; // c:905
1180                    val <<= 3; // c:906
1181                } else if ('0'..='7').contains(&c) {
1182                    t <<= 3; // c:908
1183                    val = (val << 3) | (c as i64 - '0' as i64); // c:909
1184                } else {
1185                    break;
1186                }
1187                p += 1; // c:911
1188            }
1189            if end != '\0' && c != end && c != ',' {
1190                // c:913
1191                return invalid(); // c:914-915
1192            }
1193            if how == '=' {
1194                // c:917
1195                yes = (yes & !t) | val; // c:918
1196                no = (no & !t) | (!val & !t); // c:919
1197            } else if how == '+' {
1198                yes |= val; // c:921
1199            } else {
1200                no |= val; // c:923
1201            }
1202        } else {
1203            return invalid(); // c:924-926
1204        }
1205        if !(end != '\0' && c != end) {
1206            // c:928 } while (end && c != end);
1207            break;
1208        }
1209    }
1210    // c:930 `*s = p;`
1211    let rest_off = cs.get(p).map(|&(o, _)| o).unwrap_or(s.len());
1212    Some(((yes & 0o7777) | ((no & 0o7777) << 12), &s[rest_off..])) // c:931
1213}
1214
1215// `impl GlobMatch` block deleted — C builds Gmatch entries inline
1216// in `insert()` (glob.c:346) and `gmatchcmp` (glob.c:936) is a
1217// free function taking two Gmatch pointers. The Rust port mirrors
1218// that shape: no methods on the struct; construction inlined at
1219// the scanner call site, comparator as a free fn below.
1220
1221/// Port of `gmatchcmp(Gmatch a, Gmatch b)` from Src/glob.c:936 —
1222/// the qsort comparator the `o`/`O` glob qualifier drives.
1223///
1224/// `specs` is the equivalent of the C `gf_sortlist` array, each
1225/// entry a packed i32 (the C `struct globsort.tp` field):
1226///   bits 0..=4        — primary key (GS_NAME / GS_DEPTH / GS_EXEC /
1227///                       GS_SIZE / GS_ATIME / GS_MTIME / GS_CTIME /
1228///                       GS_LINKS, plus GS_NONE marker)
1229///   bits << GS_SHIFT  — same keys, follow-link variant
1230///                       (GS__SIZE / GS__ATIME / …)
1231///   GS_DESC bit       — reverse direction (`O` qualifier instead of `o`)
1232/// WARNING: param names don't match C — Rust=(b, specs, numeric_sort) vs C=(a, b)
1233pub fn gmatchcmp(
1234    // c:936
1235    a: &gmatch,
1236    b: &gmatch,
1237    specs: &[i32],
1238    numeric_sort: bool,
1239) -> std::cmp::Ordering {
1240    for &tp in specs {
1241        // c:943
1242        let key = tp & !GS_DESC; // c:944 s->tp & ~GS_DESC
1243        let follow = (key & GS_LINKED) != 0;
1244        let key_unshifted = if follow { key >> GS_SHIFT } else { key };
1245        let cmp = if key_unshifted == GS_NAME {
1246            // c:945 — `gmatch->name` in C zsh is the FULL match string
1247            // (Src/glob.c sets it from the accumulated path during the
1248            // scanner walk), so the qsort comparator inherently sorts by
1249            // full path. The Rust port stores the basename in `name`;
1250            // use `path` instead so `**/*` matches the full-path
1251            // lexicographic order zsh produces. Verified vs
1252            // /opt/homebrew/bin/zsh: `echo /tmp/rg/**/*` → `f sub sub/g`
1253            // (sorted by full path, not basename).
1254            // c:945 `zstrcmp(b->uname, a->uname, …)` — C hands the
1255            // comparator two ready-made `char *`; the key was built once
1256            // per match at c:1963-1973. `sort_matches` fills `uname` the
1257            // same way (including the "./" strip described there), so
1258            // this arm only collates. Deriving it here instead cost a
1259            // `Path::to_string_lossy` + `strip_prefix` pair on EVERY
1260            // comparison — 11.7% of a 60605-match `man` glob, since a
1261            // sort comparator runs O(n log n) times.
1262            zstrcmp(
1263                &a.uname,
1264                &b.uname,
1265                if numeric_sort {
1266                    crate::zsh_h::SORTIT_NUMERICALLY as u32
1267                } else {
1268                    0
1269                },
1270            )
1271        } else if key_unshifted == GS_DEPTH {
1272            // c:949-972 — pairwise: skip the common prefix of the two
1273            // full paths, then `slasha`/`slashb` = does the remainder of
1274            // each (excluding its final char) still contain a `/`, i.e.
1275            // is this path deeper past the divergence point. `r = slasha
1276            // - slashb`; ascending puts the deeper path first.
1277            // c:947 compares `a->name`, the match name WITHOUT the scanner's
1278            // depth-0 `./` (c:424) — `uname`, as for GS_NAME. The raw path
1279            // gave every top-level match a `/` and sorted `**/*(od)` as flat.
1280            let an = &a.uname;
1281            let bn = &b.uname;
1282            let ab = an.as_bytes();
1283            let bb = bn.as_bytes();
1284            // c:951 — `while (*aptr && *aptr == *bptr) aptr++,bptr++;`
1285            let mut i = 0;
1286            while i < ab.len() && i < bb.len() && ab[i] == bb[i] {
1287                i += 1;
1288            }
1289            // c:953-954 — if at the end of one and the prev char is `/`,
1290            // back up one so the trailing component is counted.
1291            let (mut ai, mut bi) = (i, i);
1292            if (ai >= ab.len() || bi >= bb.len()) && ai > 0 && ab[ai - 1] == b'/' {
1293                ai -= 1;
1294                bi -= 1;
1295            }
1296            // c:956-964 — slash present in the remainder, excluding last char.
1297            let has_slash = |s: &[u8]| s.len() > 1 && s[..s.len() - 1].contains(&b'/');
1298            let slasha = has_slash(&ab[ai.min(ab.len())..]) as i32;
1299            let slashb = has_slash(&bb[bi.min(bb.len())..]) as i32;
1300            // c:971 — `r = slasha - slashb`; deeper (slash=1) sorts first.
1301            slashb.cmp(&slasha)
1302        } else if key_unshifted == GS_SIZE {
1303            // c:985
1304            if follow {
1305                a.target_size.cmp(&b.target_size)
1306            } else {
1307                a.size.cmp(&b.size)
1308            }
1309        } else if key_unshifted == GS_ATIME {
1310            // c:988-994 — `r = a->atime - b->atime;` then tie-break by
1311            // `a->ansec - b->ansec` (under GET_ST_ATIME_NSEC).
1312            let primary = if follow {
1313                b.target_atime.cmp(&a.target_atime)
1314            } else {
1315                b.atime.cmp(&a.atime)
1316            };
1317            if primary != std::cmp::Ordering::Equal {
1318                primary
1319            } else if follow {
1320                b.target_ansec.cmp(&a.target_ansec) // c:1019
1321            } else {
1322                b.ansec.cmp(&a.ansec) // c:992
1323            }
1324        } else if key_unshifted == GS_MTIME {
1325            // c:995-1001 — `r = a->mtime - b->mtime;` then tie-break by
1326            // `a->mnsec - b->mnsec` (under GET_ST_MTIME_NSEC). Without
1327            // the nsec tie-break, files touched <1s apart sort by name.
1328            let primary = if follow {
1329                b.target_mtime.cmp(&a.target_mtime)
1330            } else {
1331                b.mtime.cmp(&a.mtime)
1332            };
1333            if primary != std::cmp::Ordering::Equal {
1334                primary
1335            } else if follow {
1336                b.target_mnsec.cmp(&a.target_mnsec) // c:1026
1337            } else {
1338                b.mnsec.cmp(&a.mnsec) // c:999
1339            }
1340        } else if key_unshifted == GS_CTIME {
1341            // c:1002-1008 — `r = a->ctime - b->ctime;` then tie-break by
1342            // `a->cnsec - b->cnsec` (under GET_ST_CTIME_NSEC).
1343            let primary = if follow {
1344                b.target_ctime.cmp(&a.target_ctime)
1345            } else {
1346                b.ctime.cmp(&a.ctime)
1347            };
1348            if primary != std::cmp::Ordering::Equal {
1349                primary
1350            } else if follow {
1351                b.target_cnsec.cmp(&a.target_cnsec) // c:1033
1352            } else {
1353                b.cnsec.cmp(&a.cnsec) // c:1006
1354            }
1355        } else if key_unshifted == GS_LINKS {
1356            // c:1010 — `r = b->links - a->links;` — SAME sign convention
1357            // as GS_SIZE (c:985), so ascending `ol` is fewest-links-first.
1358            // (Was reversed vs GS_SIZE, putting most-linked first.)
1359            if follow {
1360                a.target_links.cmp(&b.target_links)
1361            } else {
1362                a.links.cmp(&b.links)
1363            }
1364        } else if key_unshifted == GS_EXEC {
1365            // c:974
1366            let idx = ((key as u32) >> 16) as usize;
1367            let asx = a.sort_strings.get(idx).map(|s| s.as_str()).unwrap_or("");
1368            let bsx = b.sort_strings.get(idx).map(|s| s.as_str()).unwrap_or("");
1369            // c:981 `zstrcmp(*bsortstrp, *asortstrp, …)` — unlike GS_NAME's
1370            // unmetafied `uname`, the keys are METAFIED: c:1938-1941 stores
1371            // `getsparam("REPLY")` or `tmpptr->name` as-is. Under a UTF-8
1372            // locale that decides the order, so collate C's bytes.
1373            zstrcmp(
1374                crate::metafied_key::metafied_key(asx),
1375                crate::metafied_key::metafied_key(bsx),
1376                if numeric_sort {
1377                    crate::zsh_h::SORTIT_NUMERICALLY as u32
1378                } else {
1379                    0
1380                },
1381            )
1382        } else {
1383            std::cmp::Ordering::Equal // GS_NONE / unknown
1384        };
1385        if cmp != std::cmp::Ordering::Equal {
1386            return if (tp & GS_DESC) != 0 {
1387                cmp.reverse()
1388            } else {
1389                cmp
1390            };
1391        }
1392    }
1393    std::cmp::Ordering::Equal
1394}
1395
1396// `Redirect` struct + `RedirectType` enum + `xpandredir` fn
1397// DELETED. Both types were Rust-only duplicates of `parse::Redirect`
1398// / `parse::RedirectOp` with no callers. The `xpandredir` impl took
1399// the wrong signature anyway — C's `xpandredir(struct redir *fn,
1400// LinkList redirtab)` at `Src/glob.c:2150` mutates a linked-list
1401// in place and returns int; this Rust version returned `Vec<Redirect>`
1402// and operated on the duplicate Redirect type. Port `xpandredir`
1403// freshly against `parse::Redirect` when an actual caller appears.
1404
1405// ============================================================================
1406// Exec string for sorting (from glob.c glob_exec_string)
1407// ============================================================================
1408
1409/// Port of `static char *glob_exec_string(char **sp)` from
1410/// `Src/glob.c:1085`.
1411///
1412/// **C is a PARSER, not an executor.** It extracts the qualifier
1413/// argument string from `*sp` (advancing the pointer past the
1414/// delimiters) and returns the duplicated text. The actual eval
1415/// happens at the call sites (c:1680/1713/1747) which feed the
1416/// returned string into `qualsheval` (for `e:`/`+:`) or
1417/// `gf_sortlist[].exec` (for sort).
1418///
1419/// Previous Rust port was COMPLETELY MIS-IMPLEMENTED:
1420///   1. **Signature wrong:** Rust took `(cmd, filename)` and returned
1421///      command output. C takes `(**sp)` and returns the parsed
1422///      qualifier string (the `cmd` itself, NOT its output).
1423///   2. **Forked `/bin/sh`:** spawned a separate POSIX shell process
1424///      to run the cmd. That's even more broken than `qualsheval`
1425///      was, because the function isn't supposed to execute anything
1426///      at all at this layer.
1427///
1428/// Now mirrors C's parser body (c:1090-1117): handle the `+` prefix
1429/// (identifier form) vs the `e:` delimited form (get_strarg), set
1430/// `*sp` past the closing delimiter, return the dup'd inner text.
1431///
1432/// Returns `(parsed_string, advance_offset)` so the caller can
1433/// emulate C's `*sp = tt + plus;` pointer advance via slice
1434/// indexing.
1435/// WARNING: param names don't match C — Rust=(s, plus) vs C=(sp)
1436pub fn glob_exec_string(s: &str, plus_form: bool) -> Option<(String, usize)> {
1437    // c:1085
1438    if plus_form {
1439        // c:1090
1440        // c:1092 — `tt = itype_end(s, IIDENT, 0);`
1441        // c:1093-1097 — `if (tt == s) { zerr("missing identifier after `+'"); return NULL; }`
1442        let tt = crate::ported::utils::itype_end(s, crate::ported::ztype_h::IIDENT as u32, false);
1443        if tt == 0 {
1444            // c:1093
1445            zerr("missing identifier after `+'"); // c:1095
1446            return None; // c:1096
1447        }
1448        // c:1107 — `sdata = dupstring(s + plus);` (plus=0 here).
1449        // c:1108 — `untokenize(sdata);`
1450        // c:1113 — `*sp = tt;` → advance offset is `tt`.
1451        Some((crate::ported::lex::untokenize(&s[..tt]), tt))
1452    } else {
1453        // c:1099 — `tt = get_strarg(s, &plus);` — find matching delimiter.
1454        // get_strarg returns delimiter-balanced span; for `e:foo:` it
1455        // walks `s` past the inner expr and returns position of the
1456        // closing `:`.
1457        match crate::ported::subst::get_strarg(s) {
1458            Some((del, content, rest)) => {
1459                // Advance offset: bytes consumed of `s` = s.len() - rest.len().
1460                let advance = s.len() - rest.len();
1461                // c:1100-1104 — `if (!*tt) { zerr("missing end of string"); return NULL; }`
1462                // — `tt` sits on the closing delimiter; `*tt == '\0'` means
1463                // none was found. The Rust get_strarg returns an empty `rest`
1464                // both for a closer at the very end and for no closer, so
1465                // tell them apart by length: with a closer, the consumed span
1466                // is the opening delimiter + content + the closer.
1467                if advance <= del.len_utf8() + content.len() {
1468                    zerr("missing end of string"); // c:1102
1469                    return None; // c:1103
1470                }
1471                // c:1107 — `sdata = dupstring(s + plus);`
1472                // c:1108 — `untokenize(sdata);`
1473                // c:1110-1111 — `*sp = tt + plus;`.
1474                Some((crate::ported::lex::untokenize(&content), advance))
1475            }
1476            None => {
1477                zerr("missing end of string"); // c:1102
1478                None
1479            }
1480        }
1481    }
1482}
1483
1484/*
1485 * Insert a glob match.
1486 * If there were words to prepend given by the P glob qualifier, do so.
1487 */
1488// c:1120-1123
1489/// Port of `insert_glob_match(LinkList list, LinkNode next, char *data)`
1490/// from Src/glob.c:1125. Inserts `data` at `next`, with optional
1491/// `gf_pre_words` prefix and `gf_post_words` suffix injection from
1492/// CURGLOBDATA (the `P:before:after:` glob qualifier).
1493pub fn insert_glob_match(list: &mut Vec<String>, next: usize, data: &str) {
1494    // c:1125
1495    let (pre, post) = {
1496        let gd = CURGLOBDATA.lock().unwrap_or_else(|e| e.into_inner());
1497        (gd.gf_pre_words.clone(), gd.gf_post_words.clone()) // c:1127, c:1136
1498    };
1499    // C `insertlinknode(list, next, data)` inserts AFTER node `next`,
1500    // returning the newly added node (so subsequent inserts append in
1501    // order). Our Vec<String> analog inserts at index `next+1` and
1502    // bumps `next` to point at the just-inserted slot.
1503    let mut cur = next;
1504    let n = list.len();
1505    let mut clamp = |i: usize| -> usize {
1506        if i > n {
1507            n
1508        } else {
1509            i
1510        }
1511    };
1512    if let Some(pre_words) = pre {
1513        // c:1127 `if (gf_pre_words)`
1514        for w in pre_words.iter() {
1515            // c:1129
1516            let pos = clamp(cur + 1);
1517            list.insert(pos, w.clone()); // c:1130 `dupstring(getdata(added))`
1518            cur = pos; // c:1130 — return-value advances `next`
1519        }
1520    }
1521    let pos = clamp(cur + 1);
1522    list.insert(pos, data.to_string()); // c:1134
1523    cur = pos;
1524    if let Some(post_words) = post {
1525        // c:1136 `if (gf_post_words)`
1526        for w in post_words.iter() {
1527            // c:1138
1528            let pos = clamp(cur + 1);
1529            list.insert(pos, w.clone()); // c:1139
1530            cur = pos;
1531        }
1532    }
1533}
1534
1535/// Port of `checkglobqual(char *str, int sl, int nobareglob, char **sp)` from Src/glob.c:1160.
1536/// C: `int checkglobqual(char *str, int sl, int nobareglob, char **sp)` —
1537///   confirm the trailing `(...)` is a glob qualifier (not a set of
1538///   alternatives or an exclusion). Returns 1 for a bare qualifier
1539///   list, 2 for the explicit `(#q…)` form, 0 otherwise, and writes the
1540///   index of the opening parenthesis to `*sp` (C: the pointer itself).
1541///
1542/// `str` must be in LEXER-TOKENIZED form. EVERY test in the C body is
1543/// against a TOKEN, never the raw ASCII byte — `Outpar` at c:1163,
1544/// `Inpar` at c:1170/1189/1183, `Bar` at c:1175, `Tilde` at c:1179,
1545/// `Pound` at c:1192. That is exactly what stops a QUOTED `)`, which
1546/// reaches here as the plain byte, from closing the qualifier block:
1547/// in `*(.e['[[ $REPLY == a* ]]'])` the quoted `]` bytes are ordinary
1548/// text while the real closer is `Outbrack`.
1549///
1550/// The previous body here scanned raw ASCII `(`/`)`, dropped
1551/// `nobareglob` on the floor, and had no `#q` / `Bar` / `Tilde` arms,
1552/// so it could not distinguish a quoted delimiter from an active one.
1553pub fn checkglobqual(
1554    str: &[char],
1555    sl: i32,
1556    nobareglob: i32, // c:1160
1557    sp: &mut Option<usize>,
1558) -> i32 {
1559    use crate::ported::zsh_h::{Bar, Inpar, Outpar, Pound, Tilde};
1560
1561    let mut nobareglob = nobareglob;
1562    let mut ret = 1i32; // c:1162 `int paren, ret = 1;`
1563    let sl = sl as usize;
1564    // c:1163-1164 — `if (str[sl - 1] != Outpar) return 0;`
1565    if sl < 2 || str[sl - 1] != Outpar {
1566        return 0;
1567    }
1568
1569    // c:1169-1187 — walk back from `str + sl - 2` to the matching
1570    // `Inpar`, tracking nesting in `paren`. `Outpar` falls THROUGH to
1571    // the `Bar` arm, so a nested group, an alternation, or an
1572    // EXTENDEDGLOB exclusion each force `nobareglob`.
1573    let mut paren = 0i32; // c:1169
1574    let mut start: Option<usize> = None;
1575    let mut i = sl - 2;
1576    loop {
1577        // c:1170 loop condition — `*s && (*s != Inpar || paren)`
1578        if str[i] == Inpar && paren == 0 {
1579            start = Some(i);
1580            break;
1581        }
1582        match str[i] {
1583            // c:1172-1174 `case Outpar: paren++; /*FALLTHROUGH*/`
1584            Outpar => {
1585                paren += 1;
1586                nobareglob = 1; // c:1175-1177 via the fallthrough into Bar
1587            }
1588            // c:1175-1178 `case Bar:` — an alternation.
1589            Bar => nobareglob = 1,
1590            // c:1179-1182 `case Tilde:` — EXTENDEDGLOB exclusion.
1591            Tilde if glob_isset(EXTENDEDGLOB) => nobareglob = 1,
1592            // c:1183-1185 `case Inpar: paren--;`
1593            Inpar => paren -= 1,
1594            _ => {}
1595        }
1596        // c:1186-1187 `if (s == str) break;`
1597        if i == 0 {
1598            break;
1599        }
1600        i -= 1;
1601    }
1602    // c:1189-1190 `if (*s != Inpar) return 0;`
1603    let start = match start {
1604        Some(v) => v,
1605        None => return 0,
1606    };
1607
1608    // c:1191-1198 — under EXTENDEDGLOB a leading `Pound` marks the
1609    // explicit `(#q…)` qualifier; any other `(#X…)` is an inline
1610    // pattern flag, not a qualifier block.
1611    if glob_isset(EXTENDEDGLOB) && str.get(start + 1) == Some(&Pound) {
1612        if str.get(start + 2) != Some(&'q') {
1613            return 0; // c:1193-1194
1614        }
1615        ret = 2; // c:1195
1616    } else if nobareglob != 0 {
1617        return 0; // c:1197-1198
1618    }
1619
1620    // c:1200-1201 `if (sp) *sp = s;`
1621    *sp = Some(start);
1622    ret // c:1203
1623}
1624
1625/// Port of `zglob(LinkList list, LinkNode np, int nountok)` from
1626/// Src/glob.c:1214. Top-level glob expansion: gate on GLOBOPT/
1627/// EXECOPT/haswilds (c:1230-1234), remove the placeholder node,
1628/// expand the pattern via `glob_path` (which covers the c:1240-2012
1629/// qualifier+scanner+sort body), then splice the resulting matches
1630/// back at `node` via `insert_glob_match` (c:1995-2007).
1631pub fn zglob(list: &mut Vec<String>, np: usize, nountok: i32) {
1632    // c:1214
1633    if np >= list.len() {
1634        return;
1635    }
1636    // c:1217 — `LinkNode node = prevnode(np);` — the insertion point
1637    // after the placeholder is uremnode'd. Vec analog: insert at np.
1638    let node: usize = np; // c:1217
1639    let ostr = list[np].clone(); // c:1221 `ostr = getdata(np)`
1640                                 // c:1226 — `nobareglob = !isset(BAREGLOBQUAL);` (consumed by qualifier
1641                                 // parser inside glob_path).
1642
1643    // c:1230 — `if (unset(GLOBOPT) || !haswilds(ostr) || unset(EXECOPT))`
1644    if !crate::ported::zsh_h::isset(crate::ported::zsh_h::GLOBOPT)
1645        || !haswilds(&ostr)
1646        || !crate::ported::zsh_h::isset(crate::ported::zsh_h::EXECOPT)
1647    {
1648        if nountok == 0 {
1649            // c:1231
1650            // c:1232 — untokenize in-place; replace list[np] with the
1651            // untokenized form.
1652            list[np] = crate::ported::lex::untokenize(&ostr);
1653        }
1654        return; // c:1233
1655    }
1656
1657    // c:1235 — `save_globstate(saved);`. zshrs snapshots the
1658    // glob-relevant options into TLS (`enter_glob_scope`, required for
1659    // thread-safety) — the RAII guard restores them on return.
1660    let _glob_scope = enter_glob_scope();
1661
1662    // c:1237-1238 — `str = dupstring(ostr); uremnode(list, np);`
1663    list.remove(np); // c:1238
1664
1665    // c:1240-1995 — the scanner walk + match collection. zshrs drives
1666    // it through `globdata_glob`, the RUST-ONLY read_dir adaptation of
1667    // C's `scanner()` (c:500; C's `lchdir` descent is process-global and
1668    // unsafe under zshrs's threading). globdata_glob owns its own
1669    // globdata; call it directly rather than via the glob_path Vec
1670    // convenience.
1671    // c:1254 `gf_nullglob = isset(NULLGLOB);` then c:1567-1569
1672    // `case 'N': gf_nullglob = !(sense & 1);` — the `(N)` QUALIFIER raises
1673    // the per-glob bit, and c:1873's `else if (!gf_nullglob)` reads THAT, not
1674    // the option. The qualifier parse lives inside `globdata_glob`, whose
1675    // state this dropped on the floor, so the terminal block below fell back
1676    // to the global option and `zglob` treated `PAT(N)` with no match as a
1677    // hard NOMATCH error. Only the fusevm path (`vm_helper::expand_glob`)
1678    // honoured `(N)`; every programmatic `zglob` caller — `_files`,
1679    // `_path_files`, `_terminals`, `_time_zone`, `_correct_filename` — got the
1680    // diagnostic and the errflag instead of an empty list.
1681    let (matches, gf_nullglob) = {
1682        let mut st = globdata::new();
1683        let m = globdata_glob(&mut st, &ostr);
1684        let n = st
1685            .qualifiers
1686            .as_ref()
1687            .map(|q| q.nullglob)
1688            .unwrap_or(false); // c:1567-1569
1689        (m, n)
1690    };
1691
1692    // c:1871-1875 — badcshglob accounting. Each zglob run updates
1693    // the per-command-line counter so globlist's terminal diagnostic
1694    // can distinguish "some failures, no successes" (emit error)
1695    // from "some failures, some successes" (silent).
1696    if !matches.is_empty() {
1697        // c:1872 — `badcshglob |= 2;` (at least one expansion OK).
1698        BADCSHGLOB.fetch_or(2, std::sync::atomic::Ordering::Relaxed);
1699    } else if crate::ported::zsh_h::isset(crate::ported::zsh_h::CSHNULLGLOB) {
1700        // c:1874-1875 — `badcshglob |= 1;` (at least one expansion
1701        // failed) under CSHNULLGLOB.
1702        BADCSHGLOB.fetch_or(1, std::sync::atomic::Ordering::Relaxed);
1703    }
1704
1705    // c:1872-1888 — `Deal with failures to match depending on options`.
1706    // C body verbatim (c:1872-1888):
1707    //   if (matchct)
1708    //       badcshglob |= 2;
1709    //   else if (!gf_nullglob) {
1710    //       if (isset(CSHNULLGLOB)) {
1711    //           badcshglob |= 1;
1712    //       } else if (isset(NOMATCH)) {
1713    //           zerr("no matches found: %s", ostr);
1714    //           zfree(matchbuf, 0);
1715    //           restore_globstate(saved);
1716    //           return;
1717    //       } else {
1718    //           /* treat as an ordinary string */
1719    //           untokenize(matchptr->name = dupstring(ostr));
1720    //           matchptr++;
1721    //           matchct = 1;
1722    //       }
1723    //   }
1724    // gf_nullglob (c:212) is the PER-GLOB nullglob bit: seeded from the
1725    // option at c:1254 and raised by the `(N)` qualifier at c:1567-1569.
1726    // Parity bug #13: previously this Rust arm fell through to the
1727    // ordinary-literal path (c:1882-1887) unconditionally, making
1728    // `echo /never/*` print the literal glob instead of erroring.
1729    if matches.is_empty() {
1730        // c:1254 + c:1567-1569 — `gf_nullglob = isset(NULLGLOB)` then the
1731        // qualifier override. Reading the option ALONE (what this did) made
1732        // `(N)` inert for every programmatic caller.
1733        let nullglob = gf_nullglob || isset(crate::ported::zsh_h::NULLGLOB); // c:1873 !gf_nullglob
1734        let csh_nullglob = isset(crate::ported::zsh_h::CSHNULLGLOB); // c:1874
1735                                                                     // c:Src/glob.c:1843-1854 — `if (!q || errflag) { ... zerr(
1736                                                                     // "bad pattern", ostr); return; }`. When the qualifier
1737                                                                     // parser already emitted a diagnostic (e.g. "number expected"
1738                                                                     // from qgetnum at c:832) and set errflag, the no-matches /
1739                                                                     // bad-pattern terminal block runs but the prior zerr is what
1740                                                                     // the user sees first. Skipping the redundant "no matches
1741                                                                     // found" here matches zsh which has already aborted glob
1742                                                                     // expansion via the errflag-gated return at c:1787 / c:1843.
1743        if errflag.load(Ordering::SeqCst) != 0 {
1744            return;
1745        }
1746        // c:1872 — `else if (!gf_nullglob) { ... }`. The ENTIRE no-match
1747        // handling (CSHNULLGLOB, NOMATCH, literal fallback) is gated on
1748        // nullglob being OFF. When nullglob is set the failed word is
1749        // simply dropped: C never enters the block, matchbuf stays empty,
1750        // and glob() removes the word from the list. Hoisting the
1751        // `!nullglob` test to a single outer gate (matching the C
1752        // `else if`) ensures the literal-insert fallback below is skipped
1753        // under nullglob too — previously it fell through unconditionally
1754        // and echoed the literal, diverging from c:1872.
1755        if !nullglob {
1756            if csh_nullglob {
1757                // c:1874-1875 — `if (isset(CSHNULLGLOB)) { badcshglob |= 1; }`
1758                // (already recorded above). The else-if chain means the
1759                // ordinary-string arm is NOT reached: the failed word is
1760                // DROPPED like nullglob, and globlist's terminal check
1761                // (subst.c:505-507, ported at subst.rs:1419) emits the
1762                // csh-style `no match` when NO word on the line matched.
1763                // Falling through to the literal insert here produced the
1764                // NOMATCH-style "no matches found: PAT" instead.
1765                return;
1766            }
1767            if isset(crate::ported::zsh_h::NOMATCH) {
1768                // c:1876-1880 — `else if (isset(NOMATCH)) { zerr; return; }`
1769                // c:1877 — `ostr` is tokenized; zerrmsg's `%s` renders
1770                // via nicezputs → sb_niceformat → `untokenize(ums)`
1771                // (Src/utils.c), so the message shows the source
1772                // spelling rather than raw token bytes.
1773                crate::ported::utils::zerr(&format!(
1774                    "no matches found: {}",
1775                    crate::ported::lex::untokenize(&ostr)
1776                ));
1777                // c:1878 — `zfree(matchbuf, 0);` (Rust drop handles it)
1778                // c:1879 — `restore_globstate(saved);` (handled by guard)
1779                return; // c:1880
1780            }
1781            // c:1882-1887 — `treat as an ordinary string`. The matchptr++
1782            // bookkeeping in C maps to our `list.insert`.
1783            let restored = if nountok == 0 {
1784                crate::ported::lex::untokenize(&ostr) // c:1884 untokenize(matchptr->name = dupstring(ostr))
1785            } else {
1786                ostr.clone()
1787            };
1788            let pos = if node > list.len() { list.len() } else { node };
1789            list.insert(pos, restored); // c:1885 matchptr++, c:1886 matchct = 1
1790        }
1791        // nullglob set → fall through with matchbuf empty: the word is
1792        // dropped (c:1872 skips the block entirely). c:1889's
1793        // `else if (in_expandredir)` redirect-failure arm is handled by
1794        // the caller (vm_helper.rs redirect-glob path), not here.
1795        return;
1796    }
1797
1798    // c:1995-2007 — splice matches back via insert_glob_match (honors
1799    // gf_pre_words / gf_post_words).
1800    let mut cur = if node == 0 { 0 } else { node - 1 }; // node is `prevnode(np)` semantics
1801    for m in matches.iter() {
1802        insert_glob_match(list, cur, m); // c:1995
1803        cur += 1;
1804        if let Some(g) = CURGLOBDATA.lock().ok() {
1805            // Advance past any pre_words that insert_glob_match added.
1806            if let Some(p) = &g.gf_pre_words {
1807                cur += p.len();
1808            }
1809            if let Some(p) = &g.gf_post_words {
1810                cur += p.len();
1811            }
1812        }
1813    }
1814}
1815
1816/// File type character for -F style listing
1817/// Render a mode bitmap as the `*` qualifier letter (`d`/`b`/
1818/// `c`/`l`/`s`/`p`/etc.).
1819/// Port of `file_type(mode_t filemode)` from Src/glob.c:2018.
1820pub fn file_type(filemode: u32) -> char {
1821    // c:2018
1822    let fmt = filemode & libc::S_IFMT as u32;
1823    if fmt == libc::S_IFBLK as u32 {
1824        '#'
1825    } else if fmt == libc::S_IFCHR as u32 {
1826        '%'
1827    } else if fmt == libc::S_IFDIR as u32 {
1828        '/'
1829    } else if fmt == libc::S_IFIFO as u32 {
1830        '|'
1831    } else if fmt == libc::S_IFLNK as u32 {
1832        '@'
1833    } else if fmt == libc::S_IFREG as u32 {
1834        if filemode & 0o111 != 0 {
1835            '*'
1836        } else {
1837            ' '
1838        }
1839    } else if fmt == libc::S_IFSOCK as u32 {
1840        '='
1841    } else {
1842        '?'
1843    }
1844}
1845
1846// ============================================================================
1847// Brace expansion
1848// ============================================================================
1849
1850/// Check if string has brace expansion
1851/// Check whether a string has brace-expansion `{a,b}` content.
1852/// Port of `hasbraces(char *str)` from Src/glob.c:2042.
1853/// WARNING: param names don't match C — Rust=(s, brace_ccl) vs C=(str)
1854pub fn hasbraces(s: &str, brace_ccl: bool) -> bool {
1855    // c:2042
1856    let mut depth = 0;
1857    let mut has_comma = false;
1858    let mut has_dotdot = false;
1859    let mut brace_open: Option<usize> = None;
1860
1861    // c:2042 — every `return 1` in the C walk sits inside the Outbrace
1862    // arm, which is only reachable after an Inbrace has raised `depth`.
1863    // No Inbrace in the string means the answer is 0, so skip the
1864    // `Vec<char>` build. Inbrace is U+e18F, whose UTF-8 encoding always
1865    // contains the byte 0x8F; testing for that byte can only
1866    // FALSE-POSITIVE (0x8F is also a continuation byte of other
1867    // characters), which just falls through to the real walk.
1868    // `<[u8]>::contains` is libcore's precompiled `memchr`.
1869    if !s.as_bytes().contains(&0x8f) {
1870        return false;
1871    }
1872
1873    let chars: Vec<char> = s.chars().collect();
1874    let len = chars.len();
1875
1876    let mut i = 0;
1877    while i < len {
1878        // c:Src/lex.c:3587-3600 — backslash escape converts the next
1879        // char into a tokenized literal (Bnull/Bnullkeep). xpandbraces
1880        // sees the tokenized form so `\{` never enters the brace
1881        // walk. Accept both the canonical Bnull (`\u{e19f}`,
1882        // Src/zsh.h:195) and ASCII `\` so direct callers (tests,
1883        // utility paths) and pipeline callers (bridge → multsub →
1884        // xpandbraces with Bnull markers from gettokstr) both behave
1885        // the same: skip the escape marker plus the next char.
1886        if (chars[i] == '\\' || chars[i] == '\u{e19f}') && i + 1 < len {
1887            i += 2; // c:3591 — skip Bnull/Bnullkeep + escaped char
1888            continue;
1889        }
1890        match chars[i] {
1891            // c:Src/glob.c:hasbraces — Inbrace/Outbrace/Comma TOKEN
1892            // strictly (\u{e18f} / \u{e190} / \u{e19a}). The lexer emits
1893            // TOKEN form for unescaped `{`/`,`/`}`; `\X` produces
1894            // Bnull + ASCII X. After remnulargs strips Bnull, the
1895            // ASCII X doesn't match the TOKEN check so escaped
1896            // braces correctly bypass expansion.
1897            '\u{e18f}' => {
1898                if depth == 0 {
1899                    brace_open = Some(i);
1900                }
1901                depth += 1;
1902            }
1903            '\u{e190}' if depth > 0 => {
1904                depth -= 1;
1905                if depth == 0 {
1906                    // c:2050-2061 — a comma group always expands.
1907                    if has_comma {
1908                        return true;
1909                    }
1910                    if has_dotdot {
1911                        // c:2071-2096 — a `..` group expands ONLY when it
1912                        // is a valid char range (bracechardots, c:2071) OR
1913                        // a numeric range whose endpoints are immediately
1914                        // bounded by the closing brace: C walks `[-]digits
1915                        // .. [-]digits` and requires the next char to be
1916                        // Outbrace (c:2084) or `..incr..` + Outbrace
1917                        // (c:2087-2095). Trailing garbage (`{0..5%2}`,
1918                        // `{1..3x}`, `{1.2..3}`, `{1..5.}`) fails this, so
1919                        // the group stays LITERAL with braces. Nested
1920                        // groups stay permissive — recursive xpandbraces
1921                        // handles the inner range.
1922                        let content: String =
1923                            chars[brace_open.unwrap_or(0) + 1..i].iter().collect();
1924                        let nested = content.contains('\u{e18f}');
1925                        // c:2074-2096 — structural walk: optional `-`,
1926                        // digits (0+), `..`, optional `-`, digits (0+),
1927                        // then MUST end at the brace (Outbrace), or take a
1928                        // `..incr` step then end. C allows EMPTY endpoints
1929                        // (`{1..}`, `{..5}` pass hasbraces, then fail the
1930                        // range parse and get brace-stripped) — what it
1931                        // forbids is trailing non-range chars (`{0..5%2}`,
1932                        // `{1.2..3}`). Closes with c:2085 `idigit(lbr[1])
1933                        // || idigit(str[-1])` — a digit adjacent to `..`.
1934                        // Dash TOKEN (\u{e19b}) reads as ASCII `-`.
1935                        let numeric_ok = {
1936                            let norm: String = content
1937                                .chars()
1938                                .map(|c| if c == '\u{e19b}' { '-' } else { c })
1939                                .collect();
1940                            let b = norm.as_bytes();
1941                            let n = b.len();
1942                            let mut j = 0;
1943                            let mut shape_ok = false;
1944                            if j < n && b[j] == b'-' {
1945                                j += 1; // c:2074 leading `-`
1946                            }
1947                            while j < n && b[j].is_ascii_digit() {
1948                                j += 1; // c:2076 first number
1949                            }
1950                            if j + 1 < n && b[j] == b'.' && b[j + 1] == b'.' {
1951                                j += 2; // c:2078 `..`
1952                                if j < n && b[j] == b'-' {
1953                                    j += 1; // c:2080
1954                                }
1955                                while j < n && b[j].is_ascii_digit() {
1956                                    j += 1; // c:2082 second number
1957                                }
1958                                if j == n {
1959                                    shape_ok = true; // c:2084 Outbrace
1960                                } else if j + 1 < n && b[j] == b'.' && b[j + 1] == b'.' {
1961                                    j += 2; // c:2087 `..incr`
1962                                    if j < n && b[j] == b'-' {
1963                                        j += 1;
1964                                    }
1965                                    while j < n && b[j].is_ascii_digit() {
1966                                        j += 1; // c:2091
1967                                    }
1968                                    if j == n {
1969                                        shape_ok = true; // c:2093
1970                                    }
1971                                }
1972                            }
1973                            // c:2085/2094 — digit adjacent to `..`.
1974                            shape_ok
1975                                && (b.first().is_some_and(|c| c.is_ascii_digit())
1976                                    || b.last().is_some_and(|c| c.is_ascii_digit()))
1977                        };
1978                        if nested
1979                            || bracechardots(&content).is_some()
1980                            || numeric_ok
1981                            || crate::dash_mode::ksh93_char_step_range(&content)
1982                        {
1983                            return true;
1984                        }
1985                    }
1986                    if brace_ccl {
1987                        if let Some(open) = brace_open {
1988                            if i > open + 1 {
1989                                return true;
1990                            }
1991                        }
1992                    }
1993                    has_comma = false;
1994                    has_dotdot = false;
1995                    brace_open = None;
1996                }
1997            }
1998            // c:Src/glob.c:hasbraces — accept comma / `..` at ANY depth
1999            // (was `depth == 1` only). Nested groups like `{{1,2}}`
2000            // have the comma at depth 2; without this, hasbraces
2001            // returned false and the outer xpandbraces never ran,
2002            // leaving `{{1,2}}` literal instead of expanding to
2003            // `{1} {2}` per zsh's nested-brace pass.
2004            '\u{e19a}' if depth > 0 => has_comma = true,
2005            '.' if depth > 0 && i + 1 < len && chars[i + 1] == '.' => has_dotdot = true,
2006            _ => {}
2007        }
2008        i += 1;
2009    }
2010
2011    false
2012}
2013
2014// ============================================================================
2015// Brace char range parsing (from glob.c bracechardots)
2016// ============================================================================
2017
2018/// Parse character range in braces like {a..z} (from glob.c bracechardots line 2222)
2019/// Port of `bracechardots(char *str, convchar_t *c1p, convchar_t *c2p)` from `Src/glob.c:2222`.
2020/// WARNING: param names don't match C — Rust=(s) vs C=(str, c1p, c2p)
2021pub fn bracechardots(s: &str) -> Option<(char, char, i32)> {
2022    let chars: Vec<char> = s.chars().collect();
2023
2024    // Must be at least "a..b"
2025    if chars.len() < 4 {
2026        return None;
2027    }
2028
2029    // Find ..
2030    let dotdot_pos = s.find("..")?;
2031    if dotdot_pos == 0 {
2032        return None;
2033    }
2034
2035    let left = &s[..dotdot_pos];
2036    let right = &s[dotdot_pos + 2..];
2037
2038    // Check for increment. c:2246-2264 — C's form is exactly `c..c`
2039    // followed by Outbrace; a third `..step` part is not a character
2040    // range (`{1..2..x}` stays literal). Only bash's `{a..e..2}` takes one.
2041    let (end_str, incr) = if let Some(pos) = right.find("..") {
2042        if !crate::dash_mode::bash_mode() {
2043            return None;
2044        }
2045        let end = &right[..pos];
2046        // The step arrives TOKENIZED: a leading `-` is the Dash token, which
2047        // `str::parse` rejects, so `{a..e..-2}` stayed literal. zstrtol
2048        // (c:Src/utils.c:2427) reads Dash as `-`; untokenize to the same effect.
2049        let inc: i32 = crate::lex::untokenize(&right[pos + 2..]).parse().ok()?;
2050        (end, inc)
2051    } else {
2052        (right, 1)
2053    };
2054
2055    // Single character range
2056    // c:2235-2236 — `MB_METACHARINIT(); pnext += MB_METACHARLENCONV(pconv,
2057    // &cstart);` — the endpoint is decoded as ONE multibyte character out
2058    // of the METAFIED text, not counted as a Rust `char`. zshrs stores a
2059    // raw 8-bit byte as the metafied pair `Meta` + `byte ^ 32` (two
2060    // `char`s), so `chars().count() == 1` rejected `{$'\x80'..$'\x81'}`
2061    // outright, and a real multibyte endpoint (`{é..ê}`) was rejected by
2062    // the byte-length test in `expand_range`.
2063    // c:2237-2244 — `if (cstart == WEOF || pnext[0] != '.' ...) return 0;`
2064    // With the MULTIBYTE option off a unit is a single BYTE, which is what
2065    // makes the range over 8-bit endpoints legal only in that mode
2066    // (mb_metacharlenconv, Src/utils.c:5613).
2067    // c:2227-2234 / c:2248-2255 — an endpoint that is a lexer token stands
2068    // for its source character: `if (itok(*pnext)) { if (*pnext == Inbrace)
2069    // return 0; convstr[0] = ztokens[*pnext - Pound]; … }`. Decoding the
2070    // token char itself only happened to work while the locale was UTF-8:
2071    // under LANG=C `left{[..]}right` (Inbrack..Outbrack) decoded to WEOF
2072    // and the range was not recognised.
2073    let untok = |t: &str| -> Option<String> {
2074        let mut it = t.chars();
2075        match (it.next(), it.next()) {
2076            (Some(c), None) if crate::token_char::itok_char(c) => {
2077                if c == crate::ported::zsh_h::Inbrace {
2078                    return None; // c:2229
2079                }
2080                let idx = (c as u32 - Pound as u32) as usize;
2081                Some(ZTOKENS[idx..idx + 1].to_string()) // c:2230
2082            }
2083            _ => Some(t.to_string()),
2084        }
2085    };
2086    let left = untok(left)?;
2087    let end_str = untok(end_str)?;
2088    let lb = crate::ported::utils::unmetafy_str(&left); // c:2236
2089    let (l_len, cstart, _) = crate::ported::utils::mb_metacharlenconv(&lb); // c:2236
2090                                                                            // c:2256-2257 — same decode for the last character of the range.
2091    let rb = crate::ported::utils::unmetafy_str(&end_str); // c:2257
2092    let (r_len, cend, _) = crate::ported::utils::mb_metacharlenconv(&rb); // c:2257
2093                                                                          // c:2239/2264 — `cstart == WEOF` / `*pnext != Outbrace`: the endpoint
2094                                                                          // must decode AND consume exactly the whole endpoint text.
2095    if l_len != 0 && l_len == lb.len() && r_len != 0 && r_len == rb.len() {
2096        if let (Some(c1), Some(c2)) = (cstart, cend) {
2097            return Some((c1, c2, incr)); // c:2266-2270
2098        }
2099    }
2100
2101    None
2102}
2103
2104/// Expand braces in a string
2105/// Brace-expand a string into a flat list.
2106/// Port of `xpandbraces(LinkList list, LinkNode *np)` from Src/glob.c:2276 — same
2107/// `{a,b}` / `{1..10}` / `{a-z}` handling.
2108/// WARNING: param names don't match C — Rust=(s, brace_ccl) vs C=(list, np)
2109pub fn xpandbraces(s: &str, brace_ccl: bool) -> Vec<String> {
2110    // c:2276
2111    if !hasbraces(s, brace_ccl) {
2112        return vec![s.to_string()];
2113    }
2114
2115    // Inline single-brace expansion — direct port of the per-iteration
2116    // brace-scan inside zsh's xpandbraces (Src/glob.c:2276). Walks the
2117    // string, finds the first `{`...`}` group, classifies as range
2118    // (`a..b`) / comma (`a,b`) / ccl (`[abc]`-style char-class), and
2119    // dispatches to the matching expander. Returns Some(parts) on
2120    // expansion, None if no brace group or unmatched.
2121    // c:Src/glob.c:2276 xpandbraces — advances `lbr` through every
2122    // candidate `{` and tries each. Bug #575: zshrs only tried the
2123    // FIRST `{` and returned None for the whole string when that
2124    // group was not expandable, so `{a-c}{1..3}` left the `{1..3}`
2125    // unexpanded. Mirror C by carrying a `from` offset and walking
2126    // to the next `{` on failure.
2127    //
2128    // Returns (Some(parts), _) on successful expansion. Returns
2129    // (None, Some(next_from)) when the current `{...}` was found but
2130    // didn't expand — outer loop should retry from next_from to scan
2131    // for a later expandable group. Returns (None, None) when no
2132    // `{...}` remains.
2133    let try_expand_from = |s: &str, from: usize| -> (Option<Vec<String>>, Option<usize>) {
2134        let chars: Vec<char> = s.chars().collect();
2135        let len = chars.len();
2136        // c:Src/glob.c:xpandbraces — Inbrace TOKEN strict.
2137        let start = match chars[from..].iter().position(|&c| c == '\u{e18f}') {
2138            Some(p) => from + p,
2139            None => return (None, None),
2140        };
2141        let mut depth = 1;
2142        let mut comma_positions = Vec::new();
2143        let mut dotdot_pos = None;
2144        for i in (start + 1)..len {
2145            match chars[i] {
2146                '\u{e18f}' => depth += 1,
2147                '\u{e190}' => {
2148                    depth -= 1;
2149                    if depth == 0 {
2150                        let next_from = i + 1;
2151                        let prefix: String = chars[..start].iter().collect();
2152                        let suffix: String = chars[i + 1..].iter().collect();
2153                        let content: String = chars[start + 1..i].iter().collect();
2154                        if let Some(dp) = dotdot_pos {
2155                            if comma_positions.is_empty() {
2156                                if let Some(parts) = expand_range(&prefix, &content, dp, &suffix) {
2157                                    return (Some(parts), None);
2158                                }
2159                                // c:Src/glob.c:2476-2506 — when range
2160                                // parsing fails INSIDE C's xpandbraces
2161                                // (err != 0 set at c:2355/2360/2369/2371),
2162                                // the function falls through to the
2163                                // normal-comma-expansion path. With no
2164                                // commas, that loop produces a single
2165                                // `prefix + content + suffix` — i.e.
2166                                // strips the outermost braces only.
2167                                // BUT: C only reaches xpandbraces when
2168                                // `hasbraces()` (c:2042) returned 1,
2169                                // and hasbraces requires DIGITS in the
2170                                // range endpoints (c:2076-2096) for the
2171                                // numeric form. Patterns like
2172                                // `{hello..world}` fail hasbraces and
2173                                // never enter xpandbraces — they stay
2174                                // as literal `{hello..world}`.
2175                                //
2176                                // Rust's hasbraces is more permissive
2177                                // (accepts any `..` inside `{}`), so we
2178                                // gate the strip-braces fallback on the
2179                                // SAME shape C uses: at least one digit
2180                                // must appear in the left or right
2181                                // endpoint, mirroring c:2085 `idigit
2182                                // (lbr[1]) || idigit(str[-1])`.
2183                                // `dp` is a CHAR index; byte-slice via a
2184                                // converted offset so multibyte/metafied
2185                                // content (`{$'\x80'..$'\x81'}`) can't panic.
2186                                let dp_byte = content
2187                                    .char_indices()
2188                                    .nth(dp)
2189                                    .map(|(b, _)| b)
2190                                    .unwrap_or(content.len());
2191                                let left = &content[..dp_byte];
2192                                let right = &content[dp_byte + 2..];
2193                                let strip_end =
2194                                    right.find("..").map(|p| &right[..p]).unwrap_or(right);
2195                                let has_digit = left.chars().any(|c| c.is_ascii_digit())
2196                                    || strip_end.chars().any(|c| c.is_ascii_digit());
2197                                if has_digit && !crate::dash_mode::ksh93_mode() {
2198                                    // c:2495-2498 — strip braces.
2199                                    return (
2200                                        Some(vec![format!("{}{}{}", prefix, content, suffix)]),
2201                                        None,
2202                                    );
2203                                }
2204                                // Non-digit `..` content (e.g.
2205                                // `{hello..world}`) — C wouldn't have
2206                                // entered xpandbraces. Preserve the
2207                                // literal pattern intact. Allow outer
2208                                // loop to retry from next_from in case
2209                                // a later brace group is expandable.
2210                                return (None, Some(next_from));
2211                            }
2212                        }
2213                        if !comma_positions.is_empty() {
2214                            return (
2215                                expand_comma(&prefix, &content, &comma_positions, &suffix),
2216                                None,
2217                            );
2218                        }
2219                        if brace_ccl && !content.is_empty() {
2220                            return (expand_ccl(&prefix, &content, &suffix), None);
2221                        }
2222                        // Outer brace has no comma/dotdot at depth 1,
2223                        // but content may contain nested braces (e.g.
2224                        // `{{1,2}}` → `{1} {2}` — outer braces become
2225                        // literal, inner expands).
2226                        if content.contains('\u{e18f}') {
2227                            let inner_expanded = xpandbraces(&content, brace_ccl);
2228                            let mut out: Vec<String> = Vec::with_capacity(inner_expanded.len());
2229                            for piece in inner_expanded {
2230                                out.push(format!("{}{{{}}}{}", prefix, piece, suffix));
2231                            }
2232                            return (Some(out), None);
2233                        }
2234                        // Literal-only group: allow outer to retry
2235                        // from next_from for a later expandable group.
2236                        return (None, Some(next_from));
2237                    }
2238                }
2239                '\u{e19a}' if depth == 1 => comma_positions.push(i - start - 1),
2240                '.' if depth == 1 && i + 1 < len && chars[i + 1] == '.' && dotdot_pos.is_none() => {
2241                    dotdot_pos = Some(i - start - 1);
2242                }
2243                _ => {}
2244            }
2245        }
2246        (None, None)
2247    };
2248
2249    // Outer loop: try expanding starting from each `{` in turn.
2250    // Returns first successful expansion; None if none found.
2251    let try_expand_one = |s: &str| -> Option<Vec<String>> {
2252        let mut from = 0;
2253        loop {
2254            let (result, next) = try_expand_from(s, from);
2255            if let Some(parts) = result {
2256                return Some(parts);
2257            }
2258            match next {
2259                Some(nf) if nf > from => from = nf,
2260                _ => return None,
2261            }
2262        }
2263    };
2264
2265    let mut results = vec![s.to_string()];
2266    let mut changed = true;
2267    while changed {
2268        changed = false;
2269        let mut new_results = Vec::new();
2270        for item in &results {
2271            if let Some(expanded) = try_expand_one(item) {
2272                new_results.extend(expanded);
2273                changed = true;
2274            } else {
2275                new_results.push(item.clone());
2276            }
2277        }
2278        results = new_results;
2279    }
2280    results
2281}
2282
2283/// Simple glob pattern matching
2284/* check to see if a matches b (b is not a filename pattern) */            // c:2510
2285// !!! WARNING: RUST-ONLY HELPER — extra args + flipped arg order vs C !!!
2286// C signature: `int matchpat(char *a, char *b)` — `a` = text to match,
2287// `b` = pattern. Rust callers (cond.rs, watch.rs, glob.rs internal users)
2288// pass `(pattern, text, extended, case_sensitive)` — pattern-FIRST,
2289// flipped from C, plus two per-call option overrides. The BODY below
2290// is C-faithful for the matching path (patcompile + pattry, no Unicode
2291// case-folding); `extended`/`case_sensitive` drive transient
2292// `opt_state_set` overrides around the patcompile call. The transient
2293// swap is the WRONG mechanism — patcompile is not reentrant under
2294// concurrent option mutation. SOLE acceptable use: this same-process,
2295// single-threaded matchpat call path. Replace with: all callers
2296// migrate to (a) global setopt and (b) canonical C arg order
2297// `matchpat(text, pattern)`.
2298// !!! WARNING: RUST-ONLY HELPER — extra args + flipped arg order vs C !!!
2299/// Port of `matchpat(char *a, char *b)` from `Src/glob.c:2514`.
2300/// CALLER CONVENTION: Rust=(pattern, text, ext, cs) — pattern FIRST
2301/// (FLIPPED from C `matchpat(text=a, pattern=b)`). Body remaps to
2302/// C order internally.
2303pub fn matchpat(pattern_in: &str, text_in: &str, extended: bool, case_sensitive: bool) -> bool {
2304    // Remap to C names (a=text, b=pattern) at the function boundary so
2305    // the body below reads identically to Src/glob.c:2514-2530.
2306    let a = text_in;
2307    let b = pattern_in;
2308    // c:2514
2309    crate::ported::signals_h::queue_signals(); // c:2519
2310                                               // GF_IGNCASE handling: zshrs's pattern matcher DOES honor the
2311                                               // flag in `patmatch_internal` (pattern.rs:2245/2312/2333 — P_EXACTLY,
2312                                               // P_ANYOF, P_ANYBUT all check `glob_flags & (GF_IGNCASE|GF_LCMATCHUC)`
2313                                               // and case-fold appropriately). The pre-fold below is a defensive
2314                                               // fast path for the simple case-insensitive `:#`/`:#:` filter — it
2315                                               // bypasses the matcher's per-arm fold and avoids any stale-cache
2316                                               // edge case in the compile→match handoff. Costs one to_lowercase
2317                                               // call per side; correct under all matcher implementations.
2318    let (a_eff, b_eff) = if case_sensitive {
2319        (a.to_string(), b.to_string())
2320    } else {
2321        (a.to_lowercase(), b.to_lowercase())
2322    };
2323    // Per-call option override (Rust-only): snapshot, set, restore.
2324    let prev_extended = crate::ported::options::opt_state_get("extendedglob");
2325    let prev_caseglob = crate::ported::options::opt_state_get("caseglob");
2326    crate::ported::options::opt_state_set("extendedglob", extended);
2327    crate::ported::options::opt_state_set("caseglob", case_sensitive);
2328    // c:2521 — `if (!(p = patcompile(b, PAT_STATIC, NULL)))`. Rust uses
2329    // PAT_HEAPDUP (=0) — pattern::patmatch's canonical compile path;
2330    // PAT_STATIC's static-buffer path is incomplete in zshrs.
2331    let p_opt = crate::ported::pattern::patcompile(
2332        &{
2333            let mut __pat_tok = (&b_eff).to_string();
2334            crate::ported::glob::tokenize(&mut __pat_tok);
2335            __pat_tok
2336        },
2337        crate::ported::zsh_h::PAT_HEAPDUP,
2338        None,
2339    );
2340    if let Some(v) = prev_extended {
2341        crate::ported::options::opt_state_set("extendedglob", v);
2342    }
2343    if let Some(v) = prev_caseglob {
2344        crate::ported::options::opt_state_set("caseglob", v);
2345    }
2346    let ret = match p_opt {
2347        Some(p) => crate::ported::pattern::pattry(&p, &a_eff), // c:2525
2348        None => {
2349            crate::ported::utils::zerr(&format!("bad pattern: {}", b)); // c:2522
2350            false // c:2523
2351        }
2352    };
2353    crate::ported::signals_h::unqueue_signals(); // c:2527
2354    ret // c:2529
2355}
2356
2357/// Port of `get_match_ret(Imatchdata imd, int b, int e)` from `Src/glob.c:2550`.
2358///
2359/// C returns `char *`: `NULL` when there is nothing to emit, `imd->mstr`
2360/// when the match was recorded into `repllist` (SUB_GLOBAL / SUB_LIST),
2361/// else a freshly built buffer. Rust returns `Option<String>` (`None` ==
2362/// C `NULL`). `b`/`e` arrive as *unmetafied* byte offsets and are first
2363/// re-based to metafied byte offsets, exactly as C does. Takes `&mut`
2364/// because the SUB_GLOBAL/SUB_LIST branch pushes onto `imd->repllist`.
2365pub fn get_match_ret(imd: &mut imatchdata, b: usize, e: usize) -> Option<String> {
2366    let mut buf = String::new(); // c:2552 char buf[80]
2367    let mut ll: i64 = 0; // c:2553 ll = 0
2368    let mut bl: usize = 0; // c:2553 bl = 0
2369    let mut t = false; // c:2553 t = 0
2370    let mut add: usize = 0; // c:2553 add = 0
2371    let fl = imd.flags; // c:2553 fl = imd->flags
2372    let mut replstr: Option<String> = imd.replstr.clone(); // c:2552 *replstr = imd->replstr
2373
2374    let ustr_owned = imd.ustr.clone().unwrap_or_default();
2375    let ustr = ustr_owned.as_bytes();
2376    let mstr_owned = imd.mstr.clone().unwrap_or_default();
2377    let mstr = mstr_owned.as_bytes();
2378    let mlen = imd.mlen as usize; // c:2544 imd->mlen
2379
2380    // c:2555-2564 — account for b and e referring to unmetafied string.
2381    let mut p = 0usize; // c:2556 p = imd->ustr
2382    while p < b && p < ustr.len() {
2383        // c:2556 for (; p < imd->ustr + b; p++)
2384        if imeta(ustr[p]) {
2385            add += 1; // c:2558 add++
2386        }
2387        p += 1;
2388    }
2389    let b = b + add; // c:2559 b += add
2390    while p < e && p < ustr.len() {
2391        // c:2560 for (; p < imd->ustr + e; p++)
2392        if imeta(ustr[p]) {
2393            add += 1; // c:2562 add++
2394        }
2395        p += 1;
2396    }
2397    let e = e + add; // c:2563 e += add
2398
2399    // c:2566 — Everything now refers to metafied lengths.
2400    if replstr.is_some() || (fl & SUB_LIST) != 0 {
2401        // c:2567
2402        if (fl & SUB_DOSUBST) != 0 {
2403            // c:2568
2404            let mut rs = dupstring(replstr.as_deref().unwrap_or("")); // c:2569 dupstring(replstr)
2405            rs = singsub(&rs); // c:2570 singsub(&replstr)
2406            rs = untokenize(&rs); // c:2571 untokenize(replstr)
2407            replstr = Some(rs);
2408        }
2409        if (fl & (SUB_GLOBAL | SUB_LIST)) != 0 && imd.repllist.is_some() {
2410            // c:2573 — replacing the chunk, just add this to the list.
2411            let rd = repldata {
2412                b: b as i32,              // c:2578 rd->b = b
2413                e: e as i32,              // c:2579 rd->e = e
2414                replstr: replstr.clone(), // c:2580 rd->replstr = replstr
2415            };
2416            imd.repllist.as_mut().unwrap().push(rd); // c:2581-2584 z/addlinknode(repllist, rd)
2417            return imd.mstr.clone(); // c:2585 return imd->mstr
2418        }
2419        if let Some(ref r) = replstr {
2420            ll += r.len() as i64; // c:2588 ll += strlen(replstr)
2421        }
2422    }
2423    if (fl & SUB_MATCH) != 0 {
2424        // c:2590 matched portion
2425        ll += 1 + (e as i64 - b as i64); // c:2591 ll += 1 + (e - b)
2426    }
2427    if (fl & SUB_REST) != 0 {
2428        // c:2592 unmatched portion
2429        ll += 1 + (mlen as i64 - (e as i64 - b as i64)); // c:2593 ll += 1 + (mlen - (e - b))
2430    }
2431    if (fl & SUB_BIND) != 0 {
2432        // c:2594 position of start of matched portion
2433        buf = format!("{} ", MB_METASTRLEN2END(mstr_owned.as_str(), 0, b) + 1); // c:2596
2434        bl = buf.len(); // c:2597 bl = strlen(buf)
2435        ll += bl as i64; // c:2597 ll += bl
2436    }
2437    if (fl & SUB_EIND) != 0 {
2438        // c:2599 position of end of matched portion
2439        buf.push_str(&format!(
2440            "{} ",
2441            MB_METASTRLEN2END(mstr_owned.as_str(), 0, e) + 1
2442        )); // c:2601
2443        bl = buf.len(); // c:2602 bl = strlen(buf)
2444        ll += bl as i64; // c:2602 ll += bl
2445    }
2446    if (fl & SUB_LEN) != 0 {
2447        // c:2603 length of matched portion — MB_METASTRLEN2END(mstr+b, 0, mstr+e)
2448        let sub = if b <= mstr.len() {
2449            &mstr_owned[b..]
2450        } else {
2451            ""
2452        };
2453        buf.push_str(&format!(
2454            "{} ",
2455            MB_METASTRLEN2END(sub, 0, e.saturating_sub(b))
2456        )); // c:2605
2457        bl = buf.len(); // c:2606 bl = strlen(buf)
2458        ll += bl as i64; // c:2606 ll += bl
2459    }
2460    if bl != 0 {
2461        buf.pop(); // c:2609 buf[bl - 1] = '\0' — drop the trailing space
2462    }
2463
2464    if ll == 0 {
2465        return None; // c:2614 return NULL
2466    }
2467
2468    // c:2617 — rr = r = hcalloc(ll); build the result buffer.
2469    let mut r = String::new();
2470
2471    if (fl & SUB_MATCH) != 0 {
2472        // c:2619-2623 — copy matched portion to new buffer.
2473        let end = e.min(mstr.len());
2474        let start = b.min(end);
2475        r.push_str(&mstr_owned[start..end]); // c:2621
2476        t = true; // c:2622
2477    }
2478    if (fl & SUB_REST) != 0 {
2479        // c:2624 — copy unmatched portion. If both portions requested,
2480        // put a space in between (why?).
2481        if t {
2482            r.push(' '); // c:2627
2483        }
2484        // c:2629-2630 — unmatched bits before the match.
2485        let pre = b.min(mstr.len());
2486        r.push_str(&mstr_owned[..pre]); // c:2630
2487        if let Some(ref rs) = replstr {
2488            r.push_str(rs); // c:2632-2633 copy replstr
2489        }
2490        // c:2634-2635 — unmatched bits after the match.
2491        let post = e.min(mstr.len());
2492        r.push_str(&mstr_owned[post..]); // c:2635
2493        t = true; // c:2636
2494    }
2495    if bl != 0 {
2496        // c:2639-2643 — append the numeric buffer; space first if needed.
2497        if t {
2498            r.push(' '); // c:2642
2499        }
2500        r.push_str(&buf); // c:2643 strcpy(rr, buf)
2501    }
2502    Some(r) // c:2645 return r
2503}
2504
2505/// Port of `compgetmatch(char *pat, int *flp, char **replstrp)` from
2506/// `Src/glob.c:2650`.
2507///
2508/// Compiles the search pattern and decides how the replacement is
2509/// expanded. A pattern with backreferences (`(#b)`, `patnpar`) or match
2510/// references (`(#m)`, `GF_MATCHREF`) needs the replacement re-substituted
2511/// after EACH match, once `$match`/`$MATCH` hold that match's text, so it
2512/// sets `SUB_DOSUBST` (consumed by `igetmatch`, c:2567-2571). Otherwise
2513/// the replacement is substituted once, here.
2514pub fn compgetmatch(pat: &str, flp: &mut i32, replstrp: &mut Option<String>) -> Option<Patprog> {
2515    // c:2662 — `int patflags = PAT_SCAN|PAT_NOANCH | (*replstrp ? 0 : PAT_STATIC);`
2516    // PAT_STATIC's static-buffer path is not used by this port's compiler.
2517    let mut patflags = crate::ported::zsh_h::PAT_SCAN | crate::ported::zsh_h::PAT_NOANCH;
2518    // c:2668-2669 — anchored to the end of the string if we want to match
2519    // it all, or if we are matching at the end and not using substrings.
2520    if (*flp & SUB_ALL) != 0 || ((*flp & SUB_END) != 0 && (*flp & SUB_SUBSTR) == 0) {
2521        patflags &= !crate::ported::zsh_h::PAT_NOANCH; // c:2670
2522    }
2523    // c:2671 — `p = patcompile(pat, patflags, NULL);` — `pat` is already
2524    // tokenized by the caller (c:Src/hist.c:2365 parse_subst_string).
2525    let p = match crate::ported::pattern::patcompile(pat, patflags, None) {
2526        Some(p) => p,
2527        None => {
2528            zerr(&format!("bad pattern: {}", pat)); // c:2673
2529            return None; // c:2674
2530        }
2531    };
2532    if let Some(r) = replstrp.as_mut() {
2533        // c:2676
2534        if p.0.patnpar != 0 || (p.0.globend & crate::ported::zsh_h::GF_MATCHREF) != 0 {
2535            // c:2677-2682 — "Either backreferences or match references, so
2536            // we need to re-substitute replstr each time round."
2537            *flp |= SUB_DOSUBST; // c:2682
2538        } else {
2539            // c:2684-2685 — `singsub(replstrp); untokenize(*replstrp);`
2540            *r = untokenize(&crate::ported::subst::singsub(r));
2541        }
2542    }
2543    Some(p) // c:2689
2544}
2545
2546/// Port of `getmatch(char **sp, char *pat, int fl, int n, char *replstr)`
2547/// from `Src/glob.c:2710`. C body (4 lines):
2548///   `Patprog p;
2549///    if (!(p = compgetmatch(pat, &fl, &replstr))) return 1;
2550///    return igetmatch(sp, p, fl, n, replstr, NULL);`
2551///
2552/// `sp` is C's `char **sp` out-pointer — the result is written back
2553/// through it — and the return value is C's STATUS: 1 when the pattern
2554/// matched, `(fl & SUB_RETFAIL) ? 0 : 1` when it did not (c:3231, the
2555/// tail of `igetmatch`), the distinction `subst()` (c:Src/hist.c:2370)
2556/// branches on.
2557///
2558/// One adaptation: this port's `igetmatch` takes the pattern TEXT (it
2559/// re-compiles per candidate) and returns 0 for a match and 1 for no
2560/// match, INVERTED from C. That inversion is baked into every other
2561/// `igetmatch` caller here, so it is translated at this boundary.
2562pub fn getmatch(sp: &mut String, pat: &str, fl: i32, n: i32, replstr: Option<&str>) -> i32 {
2563    let mut fl = fl;
2564    let mut replstr: Option<String> = replstr.map(str::to_string);
2565    // c:2713 — `if (!(p = compgetmatch(pat, &fl, &replstr))) return 1;`
2566    if compgetmatch(pat, &mut fl, &mut replstr).is_none() {
2567        return 1;
2568    }
2569    // c:2716 `return igetmatch(sp, p, fl, n, replstr, NULL);`
2570    if igetmatch(sp, pat, fl, n, replstr.as_deref()) == 0 {
2571        return 1;
2572    }
2573    // c:3231 — `return (fl & SUB_RETFAIL) ? 0 : 1;`
2574    if fl & SUB_RETFAIL != 0 {
2575        0
2576    } else {
2577        1
2578    }
2579}
2580
2581/// Get match for array elements (from glob.c getmatcharr lines 2690-2750)
2582/// Port of `getmatcharr(char ***ap, char *pat, int fl, int n, char *replstr)` from `Src/glob.c:2727`.
2583///
2584/// WARNING: C drops the elements `igetmatch` reports 0 for (c:2734
2585/// `if (igetmatch(pp, ...)) pp++;`); this port keeps every element, as
2586/// it did before `getmatch` grew its status back. Unchanged here on
2587/// purpose — nothing in this change depends on it.
2588pub fn getmatcharr(
2589    ap: &[String],
2590    pat: &str,
2591    fl: i32,
2592    n: i32,
2593    replstr: Option<&str>,
2594) -> Vec<String> {
2595    ap.iter()
2596        .map(|s| {
2597            let mut buf = s.clone();
2598            getmatch(&mut buf, pat, fl, n, replstr); // c:2734
2599            buf
2600        })
2601        .collect()
2602}
2603
2604/// Port of `int getmatchlist(char *str, Patprog p, LinkList *repllistp)`
2605/// from `Src/glob.c:2749`.
2606/// ```c
2607/// int
2608/// getmatchlist(char *str, Patprog p, LinkList *repllistp)
2609/// {
2610///     char **sp = &str;
2611///     return igetmatch(sp, p, SUB_LONG|SUB_GLOBAL|SUB_SUBSTR|SUB_LIST,
2612///                      0, NULL, repllistp);
2613/// }
2614/// ```
2615/// 3-line delegation to `igetmatch` with the canonical flag set. The
2616/// `repllistp` out-param is the LinkList that receives the match
2617/// position pairs; the Rust port currently lacks a repllistp out-
2618/// channel on `igetmatch`, so this entry mirrors the C structure
2619/// and returns the igetmatch status.
2620pub fn getmatchlist(sp: &mut String, p: &str) -> i32 {
2621    // c:2749
2622    igetmatch(
2623        sp,
2624        p,
2625        SUB_LONG | SUB_GLOBAL | SUB_SUBSTR | SUB_LIST, // c:2761
2626        0,
2627        None,
2628    ) // c:2762
2629}
2630
2631/// File-static `static int in_expandredir = 0;` from `Src/glob.c:1206`
2632/// — set during the `globlist` call inside `xpandredir` so the glob
2633/// pipeline knows the result feeds a redirection (suppresses the
2634/// "no matches" warning in the caller's normal flow).
2635pub static IN_EXPANDREDIR: std::sync::atomic::AtomicI32 = // c:1206
2636    std::sync::atomic::AtomicI32::new(0);
2637
2638/// Direct port of `int xpandredir(struct redir *fn, LinkList redirtab)`
2639/// from `Src/glob.c:2150`. Expands `>>*.c`-style redirections by
2640/// running `prefork` + (when MULTIOS is set) `globlist` over the redir
2641/// name. Single match: rewrite `fn->name` in place and decode the
2642/// MERGEIN/MERGEOUT special syntax (`-` close, `p` pipe-fd, digits =
2643/// fd number). Multi match: clone the redir for each name and append
2644/// to `redirtab`. Returns 1 if multios produced multiple entries, 0
2645/// otherwise.
2646/// WARNING: param names don't match C — Rust=(fn_, redirtab) vs C=(fn, redirtab)
2647pub fn xpandredir(
2648    fn_: &mut redir, // c:2150
2649    redirtab: &mut Vec<redir>,
2650) -> i32 {
2651    use std::sync::atomic::Ordering::SeqCst;
2652    let mut ret = 0; // c:2156
2653    let name = match fn_.name.as_deref() {
2654        // c:2160 init_list1(fake, fn->name)
2655        Some(n) => n.to_string(),
2656        None => return 0,
2657    };
2658    let mut fake: LinkList = LinkList::new();
2659    fake.push_back(name); // c:2160
2660    let mut rf = 0i32;
2661    prefork(
2662        &mut fake, // c:2162 prefork
2663        if isset(MULTIOS) { 0 } else { PREFORK_SINGLE },
2664        &mut rf,
2665    );
2666    // c:2164 — `if (!errflag && isset(MULTIOS))`. C uses LOGICAL NOT
2667    // on `errflag` (an `int`), so the condition is "errflag is zero".
2668    // The previous Rust port wrote `!errflag.load(...) != 0` which is
2669    // BITWISE NOT in Rust (`!i32` is `^-1`), making the condition
2670    // equivalent to `errflag != -1` — true for every common errflag
2671    // value (0 / ERRFLAG_ERROR=1 / ERRFLAG_INT=2). Result: the
2672    // globlist call ran even when errflag was set, papering over
2673    // the abort path that C uses to bail early on prior errors.
2674    if errflag.load(SeqCst) == 0 && isset(MULTIOS) {
2675        // c:2164
2676        IN_EXPANDREDIR.store(1, SeqCst); // c:2165
2677        crate::ported::subst::globlist(&mut fake, 0); // c:2166
2678        IN_EXPANDREDIR.store(0, SeqCst); // c:2167
2679    }
2680    if errflag.load(SeqCst) != 0 {
2681        return 0;
2682    } // c:2169
2683    let names: Vec<String> = fake.iter().cloned().collect();
2684    if names.len() == 1 {
2685        // c:2171 nonempty(&fake) && !nextnode(firstnode)
2686        let s = crate::lex::untokenize(&names[0]); // c:2174 untokenize(s)
2687        fn_.name = Some(s.clone()); // c:2173 fn->name = s
2688        if fn_.typ == REDIR_MERGEIN || fn_.typ == REDIR_MERGEOUT {
2689            // c:2175
2690            let bytes = s.as_bytes();
2691            if bytes.len() == 1 && IS_DASH(bytes[0] as char) {
2692                // c:2176-2177 IS_DASH(s[0]) && !s[1]
2693                fn_.typ = REDIR_CLOSE;
2694            } else if bytes.len() == 1 && bytes[0] == b'p' {
2695                // c:2178 s[0]=='p' && !s[1]
2696                fn_.fd2 = -2;
2697            } else {
2698                let mut i = 0;
2699                while i < bytes.len() && bytes[i].is_ascii_digit() {
2700                    i += 1;
2701                } // c:2181 idigit
2702                if i == bytes.len() && i > 0 {
2703                    // c:2183 !*s && s > fn->name
2704                    fn_.fd2 = crate::ported::utils::zstrtol(&s, 10).0 as i32; // c:2184 zstrtol(.., 10)
2705                } else if fn_.typ == REDIR_MERGEIN {
2706                    // c:2185
2707                    zerr("file number expected"); // c:2186
2708                } else if crate::dash_mode::dash_faithful() {
2709                    // !!! DASH-FAITHFUL GATE (no C counterpart) !!! dash has no
2710                    // `>&file` (both-streams) form: the word after `>&` must be
2711                    // a descriptor number or `-`, else "Bad fd number".
2712                    zerr(&format!("Bad fd number: {}", s));
2713                } else {
2714                    fn_.typ = REDIR_ERRWRITE; // c:2188
2715                }
2716            }
2717        }
2718    } else if fn_.typ == REDIR_MERGEIN {
2719        // c:2192
2720        zerr("file number expected"); // c:2193
2721    } else {
2722        if fn_.typ == REDIR_MERGEOUT {
2723            fn_.typ = REDIR_ERRWRITE;
2724        } // c:2195
2725        for nam in names {
2726            // c:2196 while ((nam = ugetnode(&fake)))
2727            let mut ff = fn_.clone(); // c:2199-2200 zhalloc + *ff = *fn
2728            ff.name = Some(nam); // c:2201 ff->name = nam
2729            redirtab.push(ff); // c:2202 addlinknode(redirtab, ff)
2730            ret = 1; // c:2203
2731        }
2732    }
2733    ret // c:2206
2734}
2735
2736/// Port of `freerepldata(void *ptr)` from Src/glob.c:2766.
2737/// C: `static void freerepldata(void *ptr)` →
2738///   `zfree(ptr, sizeof(struct repldata));`
2739#[allow(unused_variables)]
2740pub fn freerepldata(ptr: *mut std::ffi::c_void) { // c:2766
2741                                                  // Rust drop covers the equivalent.
2742}
2743
2744/// Port of `freematchlist(LinkList repllist)` from Src/glob.c:2773.
2745/// C: `void freematchlist(LinkList repllist)` →
2746///   `freelinklist(repllist, freerepldata);`
2747///
2748/// The C `repllist` is a `LinkList` of `struct repldata` (the same
2749/// node `get_match_ret` records for SUB_GLOBAL/SUB_LIST). The Rust
2750/// port operates on `Vec<repldata>` — matching the canonical
2751/// `imatchdata.repllist` type — not the prior ad-hoc `Vec<(usize,usize)>`.
2752/// Clearing the Vec drops each `repldata` (Rust's `freerepldata` +
2753/// `freelinklist` equivalent).
2754pub fn freematchlist(repllist: Option<&mut Vec<repldata>>) {
2755    // c:2773
2756    if let Some(l) = repllist {
2757        l.clear(); // c:2776 freelinklist(repllist, freerepldata)
2758    }
2759}
2760
2761/// Port of `set_pat_start(Patprog p, int offs)` from `Src/glob.c:2780`.
2762///
2763/// When we advance up the test string from its start, tell the pattern
2764/// matcher that a start-of-string assertion `(#s)` should fail: set
2765/// `PAT_NOTSTART` when `offs` is nonzero (the real start is past the
2766/// actual start), clear it when `offs == 0`. Mutates `p->flags`.
2767/// Replaces the prior fake that sliced the pattern string and returned
2768/// a substring — unrelated to the C behaviour (the matcher reads
2769/// `PAT_NOTSTART` off `prog.flags`, see pattern.rs:4792).
2770pub fn set_pat_start(p: &mut Patprog, offs: i32) {
2771    // c:2780
2772    if offs != 0 {
2773        p.0.flags |= PAT_NOTSTART; // c:2790 p->flags |= PAT_NOTSTART
2774    } else {
2775        p.0.flags &= !PAT_NOTSTART; // c:2792 p->flags &= ~PAT_NOTSTART
2776    }
2777}
2778
2779/// Port of `set_pat_end(Patprog p, char null_me)` from `Src/glob.c:2796`.
2780///
2781/// When we shorten the string at the tail, tell the pattern matcher
2782/// that an end-of-string assertion `(#e)` should fail: set `PAT_NOTEND`
2783/// when the char `null_me` about to be zapped is non-NUL, clear it when
2784/// it is already NUL. Mutates `p->flags`. Replaces the prior fake that
2785/// sliced the pattern string and returned a prefix (the matcher reads
2786/// `PAT_NOTEND` off `prog.flags`, see pattern.rs:2803).
2787pub fn set_pat_end(p: &mut Patprog, null_me: u8) {
2788    // c:2796
2789    if null_me != 0 {
2790        p.0.flags |= PAT_NOTEND; // c:2806 p->flags |= PAT_NOTEND
2791    } else {
2792        p.0.flags &= !PAT_NOTEND; // c:2808 p->flags &= ~PAT_NOTEND
2793    }
2794}
2795
2796/// Port of `igetmatch(char **sp, Patprog p, int fl, int n, char *replstr, LinkList *repllistp)` from Src/glob.c:2832.
2797/// C: `static int igetmatch(char **sp, Patprog p, int fl, int n,
2798///     char *replstr, LinkList *repllistp)` — pattern-replace inner
2799///     matcher; modifies `*sp` in place, optionally collects match
2800///     positions into `*repllistp`.
2801/// WARNING: param names don't match C — Rust=(sp, p, fl, n, replstr) vs C=(sp, p, fl, n, replstr, repllistp)
2802pub fn igetmatch(
2803    sp: &mut String,
2804    p: &str,
2805    fl: i32,
2806    _n: i32, // c:2832
2807    replstr: Option<&str>,
2808) -> i32 {
2809    // c:2840-3100+ — full SUB_* dispatch: longest/shortest/global/end-
2810    // anchor replacement loop with multibyte tracking. Rust port walks
2811    // chars + `matchpat`; full Patprog substrate (with chunked DFA
2812    // execution) lives in src/ported/pattern.rs. SUB_START imported
2813    // from zsh_h.rs at top-of-file rather than redeclared locally.
2814    // c:2669 — the Patprog reaching C's `igetmatch` was compiled by
2815    // `compgetmatch` through `patcompile(pat, patflags, NULL)` from the
2816    // ALREADY-TOKENIZED pattern text (`subst()` runs parse_subst_string
2817    // and singsub over it first, c:Src/hist.c:2365-2369), under the
2818    // ambient options. `matchpat` re-tokenizes its argument, which turns
2819    // a quoted literal — `\(` lexed to Bnull `(`, the Bnull then dropped
2820    // by singsub's remnulargs — back into an active Inpar, so
2821    // `${x:s/\\(/Z/}` died with "bad pattern: (". Compile the text as
2822    // given, once, as C does; each candidate is then tried against it.
2823    // This port anchors by slicing the subject, so the program is
2824    // compiled anchored (no PAT_NOANCH/PAT_SCAN).
2825    let prog = crate::ported::pattern::patcompile(p, crate::ported::zsh_h::PAT_HEAPDUP, None);
2826    let pat_try = |s: &str| -> bool {
2827        prog.as_ref().map_or(false, |pp| crate::ported::pattern::pattry(pp, s))
2828    };
2829    let anchored_start = (fl & SUB_START) != 0;
2830    let anchored_end = (fl & SUB_END) != 0;
2831    let substr_mode = (fl & SUB_SUBSTR) != 0;
2832    let shortest = (fl & SUB_LONG) == 0;
2833    let chars: Vec<char> = sp.chars().collect();
2834    let len = chars.len();
2835    // c:2567-2571 (get_match_ret) — under SUB_DOSUBST (set by compgetmatch
2836    // for a pattern with backreferences or match references) the
2837    // replacement is `singsub`ed afresh for each match, after the match
2838    // has set `$match`/`$MATCH`; otherwise compgetmatch already did it.
2839    let resolve_repl = || -> Option<String> {
2840        replstr.map(|r| {
2841            if (fl & SUB_DOSUBST) != 0 {
2842                untokenize(&crate::ported::subst::singsub(r)) // c:2569-2571
2843            } else {
2844                r.to_string()
2845            }
2846        })
2847    };
2848
2849    // c:2887-2898 — SUB_ALL: entire-string match flag.
2850    if (fl & SUB_ALL) != 0 {
2851        // c:2887
2852        let i = pat_try(sp); // c:2888 pattrylen
2853        if !i {
2854            // c:2889
2855            // c:2890-2893 — no match: clear replstr.
2856            if (fl & SUB_MATCH) != 0 {
2857                // c:2895
2858                *sp = String::new();
2859                return 0; // c:2896
2860            }
2861            return 1; // c:2897
2862        }
2863        if let Some(r) = resolve_repl() {
2864            // c:2894 get_match_ret
2865            *sp = r.to_string();
2866        }
2867        if sp.is_empty() && (fl & SUB_REST) != 0 && i {
2868            // c:2895
2869            return 0; // c:2896
2870        }
2871        return 1; // c:2897
2872    }
2873
2874    // An empty subject: every C arm below reduces to one `pattrylen` on the
2875    // empty string (c:2919 head, c:2950 tail, c:3008 substring), so
2876    // `a=; ${a:s/%/X/}` / `${a:s/*/X/}` give `X` and a non-empty-only
2877    // pattern leaves the subject alone.
2878    if len == 0 {
2879        if !pat_try("") {
2880            return 1;
2881        }
2882        if (fl & SUB_MATCH) == 0 {
2883            if let Some(r) = resolve_repl() {
2884                *sp = r;
2885            }
2886        }
2887        return 0;
2888    }
2889    // c:2998-3041 — SUB_LIST: collect all match offset pairs.
2890    if (fl & SUB_LIST) != 0 {
2891        // c:2998
2892        // Walk all substrings; collect (start, end) pairs the caller
2893        // can read via subsequent `getmatchlist` invocations. Without
2894        // a `repllistp` out-channel through the Rust signature, we
2895        // only walk to validate at-least-one-match; the canonical
2896        // collection point is the caller-supplied LinkList that the
2897        // C body populates at c:3000+. Defer until the out-vec lands.
2898        let mut found = false;
2899        for start in 0..len {
2900            // c:3008
2901            for end in (start + 1)..=len {
2902                // c:3009
2903                let s2: String = chars[start..end].iter().collect();
2904                if pat_try(&s2) {
2905                    // c:3010
2906                    found = true;
2907                    if shortest {
2908                        break;
2909                    } // c:3011 SUB_LONG vs short
2910                }
2911            }
2912            if !substr_mode {
2913                break;
2914            }
2915        }
2916        return if found { 0 } else { 1 };
2917    }
2918
2919    // c:3064-3114 — SUB_GLOBAL: keep scanning after each match and
2920    // replace EVERY one, rather than stopping at the first. C reaches
2921    // this through the same substring walk (`if (fl & SUB_GLOBAL)` at
2922    // c:3072 records the match and carries on from `t`); the arms
2923    // below all stop at one match, so the flag was silently ignored.
2924    //
2925    // Only the un-anchored (SUB_SUBSTR) case needs the loop: with
2926    // SUB_START or SUB_END the pattern can only match in one place, so
2927    // those fall through to the single-match arms unchanged. No caller
2928    // other than `subst()` (c:Src/hist.c:2347) sets SUB_GLOBAL, so this
2929    // is new ground rather than a change to an existing path.
2930    if (fl & SUB_GLOBAL) != 0
2931        && !anchored_start
2932        && !anchored_end
2933        && (fl & (SUB_MATCH | SUB_LIST)) == 0
2934    {
2935        let mut out = String::new();
2936        let mut i = 0usize;
2937        let mut any = false;
2938        while i < len {
2939            // Longest match at this position unless SUB_LONG is off.
2940            // `end` starts at `i + 1`, so a pattern that also matches
2941            // the empty string cannot consume nothing and loop forever.
2942            let mut found: Option<usize> = None;
2943            for end in (i + 1)..=len {
2944                let substr: String = chars[i..end].iter().collect();
2945                if pat_try(&substr) {
2946                    found = Some(end);
2947                    if shortest {
2948                        break;
2949                    }
2950                }
2951            }
2952            match found {
2953                Some(end) => {
2954                    any = true;
2955                    if let Some(r) = resolve_repl() {
2956                        out.push_str(&r);
2957                    }
2958                    i = end;
2959                }
2960                None => {
2961                    out.push(chars[i]);
2962                    i += 1;
2963                }
2964            }
2965        }
2966        if any {
2967            *sp = out;
2968            return 0;
2969        }
2970        return 1;
2971    }
2972
2973    // c:Src/glob.c:2900+ — SUB_MATCH inverts the disposition of the
2974    // anchored-strip operators. Default (no SUB_MATCH): the matched
2975    // portion is REMOVED and the rest is returned (`${var#pat}` →
2976    // tail after the matched prefix). With SUB_MATCH: the matched
2977    // portion is KEPT and the rest is removed (`${(M)var#pat}` → the
2978    // matched prefix only). Previously honoured only in the SUB_ALL
2979    // arm at c:2895; the anchored-prefix / anchored-suffix / substr
2980    // arms below always returned prefix+suffix, so `(M)` was a no-op
2981    // for `#pat` / `%pat`. Mirror the C SUB_MATCH branch by emitting
2982    // just the matched slice.
2983    let match_only = (fl & SUB_MATCH) != 0;
2984    let (match_start, match_end) = if anchored_start && anchored_end {
2985        if pat_try(sp) {
2986            (0, len)
2987        } else {
2988            if match_only {
2989                *sp = String::new();
2990                return 0;
2991            }
2992            return 1;
2993        }
2994    } else if anchored_start {
2995        // c:2913-2938 — the head match may be EMPTY: `pattrylen(p, s,
2996        // umltot, …)` runs unanchored at the end (PAT_NOANCH), so a
2997        // pattern that matches nothing at the head (`:s/#/X/`) matches the
2998        // empty prefix and the replacement is inserted before the string.
2999        let mut best_end: Option<usize> = None;
3000        for end in 0..=len {
3001            let substr: String = chars[..end].iter().collect();
3002            if pat_try(&substr) {
3003                if shortest {
3004                    *sp = if match_only {
3005                        chars[..end].iter().collect()
3006                    } else {
3007                        match resolve_repl() {
3008                            Some(r) => format!("{}{}", r, chars[end..].iter().collect::<String>()),
3009                            None => chars[end..].iter().collect(),
3010                        }
3011                    };
3012                    return 0;
3013                }
3014                best_end = Some(end);
3015            }
3016        }
3017        if let Some(best_end) = best_end {
3018            (0, best_end)
3019        } else {
3020            if match_only {
3021                *sp = String::new();
3022                return 0;
3023            }
3024            return 1;
3025        }
3026    } else if anchored_end {
3027        // c:2949-2953 / c:2980-2999 — the tail match may be EMPTY: SUB_END
3028        // tries `pattrylen(p, send, 0, …)` first, and SUB_END|SUB_LONG walks
3029        // `t <= send`, so `:s/%/X/` appends the replacement.
3030        let mut best_start: Option<usize> = None;
3031        for start in (0..=len).rev() {
3032            let substr: String = chars[start..].iter().collect();
3033            if pat_try(&substr) {
3034                if shortest {
3035                    *sp = if match_only {
3036                        chars[start..].iter().collect()
3037                    } else {
3038                        match resolve_repl() {
3039                            Some(r) => {
3040                                format!("{}{}", chars[..start].iter().collect::<String>(), r)
3041                            }
3042                            None => chars[..start].iter().collect(),
3043                        }
3044                    };
3045                    return 0;
3046                }
3047                best_start = Some(start);
3048            }
3049        }
3050        if let Some(best_start) = best_start {
3051            (best_start, len)
3052        } else {
3053            if match_only {
3054                *sp = String::new();
3055                return 0;
3056            }
3057            return 1;
3058        }
3059    } else {
3060        for start in 0..len {
3061            // c:3029-3051 — "Find the longest match from this position";
3062            // only without SUB_LONG is the shortest one searched for.
3063            let ends: Vec<usize> = if shortest {
3064                ((start + 1)..=len).collect()
3065            } else {
3066                ((start + 1)..=len).rev().collect()
3067            };
3068            for end in ends {
3069                let substr: String = chars[start..end].iter().collect();
3070                if pat_try(&substr) {
3071                    if match_only {
3072                        *sp = chars[start..end].iter().collect();
3073                        return 0;
3074                    }
3075                    let prefix: String = chars[..start].iter().collect();
3076                    let suffix: String = chars[end..].iter().collect();
3077                    *sp = match resolve_repl() {
3078                        Some(r) => format!("{}{}{}", prefix, r, suffix),
3079                        None => format!("{}{}", prefix, suffix),
3080                    };
3081                    return 0;
3082                }
3083            }
3084        }
3085        if match_only {
3086            *sp = String::new();
3087            return 0;
3088        }
3089        return 1;
3090    };
3091    *sp = if match_only {
3092        chars[match_start..match_end].iter().collect()
3093    } else {
3094        let prefix: String = chars[..match_start].iter().collect();
3095        let suffix: String = chars[match_end..].iter().collect();
3096        match resolve_repl() {
3097            Some(r) => format!("{}{}{}", prefix, r, suffix),
3098            None => format!("{}{}", prefix, suffix),
3099        }
3100    };
3101    0
3102}
3103
3104// ============================================================================
3105// Tokenization (from glob.c tokenize family)
3106// ============================================================================
3107
3108// `enum GlobToken` deleted — C uses the byte-token constants
3109// (`Star`/`Quest`/`Inpar`/...) from `Src/zsh.h:159-200`, mirrored in
3110// the Rust port at `zsh_h.rs:128-160`. `tokenize()` (`Src/glob.c:3548`
3111// → `zshtokenize`) mutates the input string in place, replacing each
3112// glob-metacharacter with its high-bit byte token; the Rust port now
3113// matches.
3114
3115// `ztokens[]` from `Src/lex.c:38` — the source-char ↔ token-byte
3116// table the C tokenizer indexes with `(t - ztokens) + Pound`. Each
3117// position N in the string maps to high-bit byte `Pound + N`.
3118pub const ZTOKENS: &str = "#$^*(())$=|{}[]`<>>?~`,-!'\"\\\\";
3119
3120/// The `switch (*s)` labels of `zshtokenize` (c:3592-3639), as a byte
3121/// table: `true` for every byte that can reach an arm which MUTATES the
3122/// string. `\\` `<` `>` `^` `#` `~` `[` `]` `*` `?` `=` `-` `!` `(` `|`
3123/// `)` are the literal case labels; the `Meta` / `Bnull` / `Bnullkeep`
3124/// sentinels (c:3593/3597/3598) are non-ASCII chars, so every byte of
3125/// their UTF-8 encoding is >= 0x80 and the whole high half is marked —
3126/// conservatively, since a `>= 0x80` byte only sends the string down the
3127/// full walk, never past it.
3128///
3129/// C needs no such table: its loop is a byte-pointer walk that costs
3130/// about a cycle per uninteresting byte. The port has to materialize a
3131/// `Vec<char>` and rebuild the `String`, so the same "do nothing" answer
3132/// costs three allocations and a decode/encode pair — 33.4% of a `man
3133/// <TAB>` completion, which tokenizes every one of 60605 candidate
3134/// names and finds a metachar in 3105 of them (5%).
3135static ZSHTOK_TRIGGER: [bool; 256] = {
3136    let mut t = [false; 256];
3137    let mut i = 0;
3138    while i < 256 {
3139        let b = i as u8;
3140        t[i] = b >= 0x80
3141            || matches!(
3142                b,
3143                b'\\'
3144                    | b'<'
3145                    | b'>'
3146                    | b'^'
3147                    | b'#'
3148                    | b'~'
3149                    | b'['
3150                    | b']'
3151                    | b'*'
3152                    | b'?'
3153                    | b'='
3154                    | b'-'
3155                    | b'!'
3156                    | b'('
3157                    | b'|'
3158                    | b')'
3159            );
3160        i += 1;
3161    }
3162    t
3163};
3164
3165/// Tokenize a glob pattern in place — port of `tokenize(char *s)` from
3166/// `Src/glob.c:3548`. One-line C delegation: `zshtokenize(s, 0)`.
3167pub fn tokenize(s: &mut String) {
3168    // c:3548
3169    zshtokenize(s, 0); // c:3552
3170}
3171
3172/// Tokenize for shell — port of `shtokenize(char *s)` from
3173/// `Src/glob.c:3563`. Builds flags from SHGLOB option then delegates
3174/// to `zshtokenize`.
3175pub fn shtokenize(s: &mut String) {
3176    // c:3563
3177    let mut flags = ZSHTOK_SUBST; // c:3567
3178    if isset(SHGLOB) {
3179        // c:3568
3180        flags |= ZSHTOK_SHGLOB; // c:3569
3181    }
3182    zshtokenize(s, flags); // c:3570
3183}
3184
3185/// Port of `zshtokenize(char *s, int flags)` from `Src/glob.c:3575`.
3186/// Walks `s` in place, replacing each glob-metachar with its high-bit
3187/// byte token from the `ZTOKENS` table; respects ZSHTOK_SUBST (use
3188/// Bnullkeep for escaped tokens) and ZSHTOK_SHGLOB (don't tokenize
3189/// `<` / `(` / `|` / `)`).
3190pub fn zshtokenize(s: &mut String, flags: i32) {
3191    // c:3575
3192    // c:3580-3651 — every arm that writes to the string is guarded by
3193    // one of the `switch` labels; a string holding none of them leaves
3194    // C's loop with `*s` byte-for-byte unchanged. Answer that case
3195    // without materializing anything (see ZSHTOK_TRIGGER).
3196    if !s.as_bytes().iter().any(|&b| ZSHTOK_TRIGGER[b as usize]) {
3197        return;
3198    }
3199    // c:3640 `for (t = ztokens; *t; t++)` — C scans the static table by
3200    // pointer. Every ZTOKENS entry is ASCII, so scanning its bytes is
3201    // the same comparison without the per-call `Vec<char>` build.
3202    let ztokens: &[u8] = ZTOKENS.as_bytes();
3203    let mut chars: Vec<char> = s.chars().collect();
3204    let mut bslash = false; // c:3578
3205    let mut i = 0;
3206    while i < chars.len() {
3207        // c:3580 for (; *s; s++)
3208        let c = chars[i];
3209        match c {
3210            x if x == Meta as char => {                                              // c:3583 case Meta
3211                i += 2;                                                      // c:3585 skip both Meta and next
3212                bslash = false;
3213                continue;
3214            }
3215            x if x == Bnull || x == Bnullkeep || x == '\\' => {              // c:3587-3589
3216                if bslash {                                                  // c:3590
3217                    chars[i - 1] = if (flags & ZSHTOK_SUBST) != 0 {          // c:3591
3218                        Bnullkeep
3219                    } else {
3220                        Bnull
3221                    };
3222                } else {
3223                    bslash = true;                                           // c:3595
3224                    i += 1;
3225                    continue;                                                // c:3596 (skip bslash=0 reset)
3226                }
3227            }
3228            '<' => {                                                         // c:3598
3229                if (flags & ZSHTOK_SHGLOB) != 0 {                            // c:3599
3230                    // break — no tokenization
3231                } else if bslash {                                           // c:3601
3232                    chars[i - 1] = if (flags & ZSHTOK_SUBST) != 0 {
3233                        Bnullkeep
3234                    } else {
3235                        Bnull
3236                    };
3237                } else {
3238                    // c:3605-3614 — try to parse `<N-N>` redirection.
3239                    let t = i;                                               // c:3605
3240                    let mut j = i + 1;
3241                    while j < chars.len() && chars[j].is_ascii_digit() {     // c:3606 idigit
3242                        j += 1;
3243                    }
3244                    if j < chars.len() && (chars[j] == '-') {                // c:3607 IS_DASH
3245                        let mut k = j + 1;
3246                        while k < chars.len() && chars[k].is_ascii_digit() { // c:3609
3247                            k += 1;
3248                        }
3249                        if k < chars.len() && chars[k] == '>' {              // c:3611
3250                            chars[t] = Inang;                                // c:3613
3251                            chars[k] = Outang;                               // c:3614
3252                            i = k + 1;
3253                            bslash = false;
3254                            continue;
3255                        }
3256                    }
3257                    // c:3608/3611 `goto cont` — re-switch on current *s;
3258                    // since none of the conditions matched, fall through.
3259                }
3260            }
3261            '(' | '|' | ')' if (flags & ZSHTOK_SHGLOB) != 0 => {             // c:3617-3620
3262                // no tokenization under SHGLOB
3263            }
3264            '>' | '^' | '#' | '~' | '[' | ']' | '*' | '?'                    // c:3621-3631
3265            | '=' | '-' | '!' | '(' | '|' | ')' => {
3266                for (n, &t) in ztokens.iter().enumerate() {                  // c:3633
3267                    if t as char == c {                                      // c:3634
3268                        if bslash {                                          // c:3635
3269                            chars[i - 1] = if (flags & ZSHTOK_SUBST) != 0 {
3270                                Bnullkeep
3271                            } else {
3272                                Bnull
3273                            };
3274                        } else {
3275                            chars[i] = char::from_u32(Pound as u32 + n as u32)
3276                                .unwrap_or(c);                                // c:3638
3277                        }
3278                        break;                                                // c:3639
3279                    }
3280                }
3281            }
3282            _ => {}
3283        }
3284        bslash = false; // c:3646
3285        i += 1;
3286    }
3287    *s = chars.into_iter().collect();
3288}
3289
3290/// Port of `void remnulargs(char *s)` from `Src/glob.c:3649`.
3291///
3292/// C body (c:3651-3676): walks `s` looking for INULL bytes.
3293///   - `Bnullkeep` in SCAN phase: skip (the `continue` at c:3658)
3294///     — don't treat as a null marker.
3295///   - Any other INULL: switch to COPY phase. In copy phase:
3296///       - `Bnullkeep` becomes literal `\` (the "active backslash"
3297///         is re-materialized).
3298///       - Other INULLs: stripped.
3299///       - Non-INULL: kept.
3300///   - If the post-copy string is empty, replace with `Nularg`
3301///     (single-char empty-arg marker).
3302///
3303/// The previous Rust port collapsed the body to
3304/// `s.retain(|c| c != '\0' && c != Bnullkeep)` — a simple
3305/// strip of NUL and Bnullkeep. Three divergences:
3306///   - Stripped Bnullkeep entirely instead of preserving it in
3307///     the scan-only phase (when it appears BEFORE any other
3308///     inull) or converting to `\` in the copy phase.
3309///   - Didn't strip Snull/Dnull/Bnull/Nularg — those inulls
3310///     stayed in the output, polluting downstream processing.
3311///   - Didn't emit Nularg sentinel for empty post-strip strings.
3312pub fn remnulargs(s: &mut String) {
3313    // c:3649
3314    if s.is_empty() {
3315        // c:3654
3316        return;
3317    }
3318    // c:3664-3688 — C's walk rewrites nothing until it meets a
3319    // `Bnullkeep` or an `inull(c)`. Both those sentinels, and the `Meta`
3320    // the port's walk also tracks, are non-ASCII chars, so an all-ASCII
3321    // string can't reach either branch and C leaves it untouched. Say so
3322    // before paying for the `Vec<char>` materialization: `<[u8]>::is_ascii`
3323    // is libcore's precompiled word-at-a-time scan, while the walk below
3324    // is monomorphised into this crate and built unoptimised.
3325    if s.is_ascii() {
3326        return;
3327    }
3328    // c:3656 `inull(c)` predicate: Snull / Dnull / Bnull / Bnullkeep / Nularg.
3329    let is_inull =
3330        |c: char| c == Snull || c == Dnull || c == Bnull || c == Bnullkeep || c == Nularg;
3331    let src: Vec<char> = s.chars().collect();
3332    let mut out: Vec<char> = Vec::with_capacity(src.len());
3333    let mut i = 0usize;
3334    // rs-only adaptation of C's byte walk: C strings are byte arrays and
3335    // store an eight-bit char (e.g. `$'\M-\C-a'` = 0x81) RAW, so its byte
3336    // never aliases an inull sentinel. A Rust `String` is UTF-8, so zshrs
3337    // METAFIES that byte — Meta (0x83) + (byte ^ 0x20) — and 0x81 ^ 0x20 =
3338    // 0xA1, which IS the Nularg sentinel (0xBD/0xBE/0xBF/0xBC likewise
3339    // alias Snull/Dnull/Bnull/Bnullkeep). A metafied byte is DATA, not a
3340    // sentinel, so treat `Meta` + next char as an opaque pair on BOTH the
3341    // scan and copy walks — otherwise `${(qq)v}` for v=$'\M-\C-a' dropped
3342    // the 0xA1 and left the lone Meta (rs emitted `'\xc2\x83'` where zsh
3343    // emits `'\x81'`). C's byte walk needs no such guard.
3344    // Meta is a byte constant (0x83); a metafied string stores it as the
3345    // char U+0083, so compare against the char form.
3346    let meta = char::from(crate::ported::zsh_h::Meta);
3347    // c:3656 — SCAN phase: copy chars, skip standalone Bnullkeep.
3348    while i < src.len() {
3349        let c = src[i];
3350        if c == meta && i + 1 < src.len() {
3351            out.push(c);
3352            out.push(src[i + 1]);
3353            i += 2;
3354            continue;
3355        }
3356        if c == Bnullkeep {
3357            // c:3657 continue
3358            i += 1;
3359            continue;
3360        }
3361        if is_inull(c) {
3362            // c:3663 inull(c)
3363            // c:3664+ — COPY phase: walk the rest, fold Bnullkeep
3364            // to `\\`, strip other inulls.
3365            i += 1;
3366            while i < src.len() {
3367                let d = src[i];
3368                if d == meta && i + 1 < src.len() {
3369                    // Metafied data pair — keep verbatim.
3370                    out.push(d);
3371                    out.push(src[i + 1]);
3372                    i += 2;
3373                    continue;
3374                }
3375                if d == Bnullkeep {
3376                    // c:3666
3377                    out.push('\\'); // c:3667
3378                } else if !is_inull(d) {
3379                    // c:3668
3380                    out.push(d); // c:3669
3381                }
3382                i += 1;
3383            }
3384            break;
3385        }
3386        // SCAN phase non-inull: keep verbatim.
3387        out.push(c);
3388        i += 1;
3389    }
3390    // c:3673-3675 — empty result → Nularg sentinel.
3391    if out.is_empty() {
3392        out.push(Nularg);
3393    }
3394    *s = out.into_iter().collect();
3395}
3396
3397/// Port of `qualdev(UNUSED(char *name), struct stat *buf, off_t dv, UNUSED(char *dummy))` from Src/glob.c:3688.
3398/// C: `static int qualdev(UNUSED(char *name), struct stat *buf, off_t dv,
3399///     UNUSED(char *dummy))` → `return (off_t)buf->st_dev == dv;`
3400#[allow(unused_variables)]
3401pub fn qualdev(name: &str, buf: &libc::stat, dv: i64, dummy: &str) -> i32 {
3402    // c:3688
3403    (buf.st_dev as i64 == dv) as i32 // c:3697
3404}
3405
3406/// Port of `qualnlink(UNUSED(char *name), struct stat *buf, off_t ct, UNUSED(char *dummy))` from Src/glob.c:3697.
3407/// C: ternary on `g_range`: < / > / == against `st_nlink`.
3408#[allow(unused_variables)]
3409pub fn qualnlink(name: &str, buf: &libc::stat, ct: i64, dummy: &str) -> i32 {
3410    // c:3697
3411    // c:3699-3701 — `g_range` selects `<` / `>` / `==`. This MUST read the same
3412    // `g_range` static the qual-eval sets (glob.rs ~5020) and that qualsize /
3413    // qualtime read — not the stale Rust-only `G_RANGE` duplicate, which was
3414    // never stored, leaving every `l[+-]N` comparison stuck on `==`.
3415    let g = g_range.load(Ordering::Relaxed);
3416    let nl = buf.st_nlink as i64; // c:3708
3417    if g < 0 {
3418        (nl < ct) as i32
3419    } else if g > 0 {
3420        (nl > ct) as i32
3421    } else {
3422        (nl == ct) as i32
3423    }
3424}
3425
3426/// Port of `qualuid(UNUSED(char *name), struct stat *buf, off_t uid, UNUSED(char *dummy))` from Src/glob.c:3708.
3427/// C: `return buf->st_uid == uid;`
3428#[allow(unused_variables)]
3429pub fn qualuid(name: &str, buf: &libc::stat, uid: i64, dummy: &str) -> i32 {
3430    // c:3708
3431    (buf.st_uid as i64 == uid) as i32 // c:3717
3432}
3433
3434/// Port of `qualgid(UNUSED(char *name), struct stat *buf, off_t gid, UNUSED(char *dummy))` from Src/glob.c:3717.
3435/// C: `return buf->st_gid == gid;`
3436#[allow(unused_variables)]
3437pub fn qualgid(name: &str, buf: &libc::stat, gid: i64, dummy: &str) -> i32 {
3438    // c:3717
3439    (buf.st_gid as i64 == gid) as i32 // c:3726
3440}
3441
3442/// Port of `qualisdev(UNUSED(char *name), struct stat *buf, UNUSED(off_t junk), UNUSED(char *dummy))` from Src/glob.c:3726.
3443/// C: `return S_ISBLK(buf->st_mode) || S_ISCHR(buf->st_mode);`
3444#[allow(unused_variables)]
3445pub fn qualisdev(name: &str, buf: &libc::stat, junk: i64, dummy: &str) -> i32 {
3446    // c:3726
3447    let m = buf.st_mode as u32 & libc::S_IFMT as u32;
3448    ((m == libc::S_IFBLK as u32) || (m == libc::S_IFCHR as u32)) as i32 // c:3735
3449}
3450
3451/// Port of `qualisblk(UNUSED(char *name), struct stat *buf, UNUSED(off_t junk), UNUSED(char *dummy))` from Src/glob.c:3735.
3452/// C: `return S_ISBLK(buf->st_mode);`
3453#[allow(unused_variables)]
3454pub fn qualisblk(name: &str, buf: &libc::stat, junk: i64, dummy: &str) -> i32 {
3455    // c:3735
3456    ((buf.st_mode as u32 & libc::S_IFMT as u32) == libc::S_IFBLK as u32) as i32 // c:3744
3457}
3458
3459/// Port of `qualischr(UNUSED(char *name), struct stat *buf, UNUSED(off_t junk), UNUSED(char *dummy))` from Src/glob.c:3744.
3460/// C: `return S_ISCHR(buf->st_mode);`
3461#[allow(unused_variables)]
3462pub fn qualischr(name: &str, buf: &libc::stat, junk: i64, dummy: &str) -> i32 {
3463    // c:3744
3464    ((buf.st_mode as u32 & libc::S_IFMT as u32) == libc::S_IFCHR as u32) as i32 // c:3753
3465}
3466
3467/// Port of `qualisdir(UNUSED(char *name), struct stat *buf, UNUSED(off_t junk), UNUSED(char *dummy))` from Src/glob.c:3753.
3468/// C: `return S_ISDIR(buf->st_mode);`
3469#[allow(unused_variables)]
3470pub fn qualisdir(name: &str, buf: &libc::stat, junk: i64, dummy: &str) -> i32 {
3471    // c:3753
3472    ((buf.st_mode as u32 & libc::S_IFMT as u32) == libc::S_IFDIR as u32) as i32 // c:3762
3473}
3474
3475/// Port of `qualisfifo(UNUSED(char *name), struct stat *buf, UNUSED(off_t junk), UNUSED(char *dummy))` from Src/glob.c:3762.
3476/// C: `return S_ISFIFO(buf->st_mode);`
3477#[allow(unused_variables)]
3478pub fn qualisfifo(name: &str, buf: &libc::stat, junk: i64, dummy: &str) -> i32 {
3479    // c:3762
3480    ((buf.st_mode as u32 & libc::S_IFMT as u32) == libc::S_IFIFO as u32) as i32 // c:3771
3481}
3482
3483/// Port of `qualislnk(UNUSED(char *name), struct stat *buf, UNUSED(off_t junk), UNUSED(char *dummy))` from Src/glob.c:3771.
3484/// C: `return S_ISLNK(buf->st_mode);`
3485#[allow(unused_variables)]
3486pub fn qualislnk(name: &str, buf: &libc::stat, junk: i64, dummy: &str) -> i32 {
3487    // c:3771
3488    ((buf.st_mode as u32 & libc::S_IFMT as u32) == libc::S_IFLNK as u32) as i32 // c:3780
3489}
3490
3491/// Port of `qualisreg(UNUSED(char *name), struct stat *buf, UNUSED(off_t junk), UNUSED(char *dummy))` from Src/glob.c:3780.
3492/// C: `return S_ISREG(buf->st_mode);`
3493#[allow(unused_variables)]
3494pub fn qualisreg(name: &str, buf: &libc::stat, junk: i64, dummy: &str) -> i32 {
3495    // c:3780
3496    ((buf.st_mode as u32 & libc::S_IFMT as u32) == libc::S_IFREG as u32) as i32 // c:3789
3497}
3498
3499/// Port of `qualissock(UNUSED(char *name), struct stat *buf, UNUSED(off_t junk), UNUSED(char *dummy))` from Src/glob.c:3789.
3500/// C: `return S_ISSOCK(buf->st_mode);`
3501#[allow(unused_variables)]
3502pub fn qualissock(name: &str, buf: &libc::stat, junk: i64, dummy: &str) -> i32 {
3503    // c:3789
3504    ((buf.st_mode as u32 & libc::S_IFMT as u32) == libc::S_IFSOCK as u32) as i32
3505    // c:3798
3506}
3507
3508/// Port of `qualflags(UNUSED(char *name), struct stat *buf, off_t mod, UNUSED(char *dummy))` from Src/glob.c:3798.
3509/// C: `return mode_to_octal(buf->st_mode) & mod;`
3510#[allow(unused_variables)]
3511/// WARNING: param names don't match C — Rust=(name, buf, dummy) vs C=(name, buf, mod, dummy)
3512pub fn qualflags(name: &str, buf: &libc::stat, r#mod: i64, dummy: &str) -> i32 {
3513    // c:3798
3514    (mode_to_octal(buf.st_mode as u32) as i64 & r#mod) as i32 // c:3807
3515}
3516
3517/// Port of `qualmodeflags(UNUSED(char *name), struct stat *buf, off_t mod, UNUSED(char *dummy))` from Src/glob.c:3807.
3518/// C: `((v & y) == y && !(v & n))` where `y = mod & 07777`, `n = mod >> 12`.
3519#[allow(unused_variables)]
3520/// WARNING: param names don't match C — Rust=(name, buf, dummy) vs C=(name, buf, mod, dummy)
3521pub fn qualmodeflags(name: &str, buf: &libc::stat, r#mod: i64, dummy: &str) -> i32 {
3522    // c:3807
3523    let v = mode_to_octal(buf.st_mode as u32) as i64; // c:3818
3524    let y = r#mod & 0o7777;
3525    let n = r#mod >> 12;
3526    (((v & y) == y) && (v & n) == 0) as i32 // c:3818
3527}
3528
3529/// Port of `qualiscom(UNUSED(char *name), struct stat *buf, UNUSED(off_t mod), UNUSED(char *dummy))` from Src/glob.c:3818.
3530/// C: `return S_ISREG(buf->st_mode) && (buf->st_mode & S_IXUGO);`
3531#[allow(unused_variables)]
3532pub fn qualiscom(name: &str, buf: &libc::stat, r#mod: i64, dummy: &str) -> i32 {
3533    // c:3818
3534    let is_reg = (buf.st_mode as u32 & libc::S_IFMT as u32) == libc::S_IFREG as u32;
3535    let s_ixugo: u32 = (libc::S_IXUSR | libc::S_IXGRP | libc::S_IXOTH) as u32;
3536    (is_reg && (buf.st_mode as u32 & s_ixugo) != 0) as i32 // c:3820
3537}
3538
3539/// Port of `static int g_units;` from `Src/glob.c`. Time/size unit
3540/// selector for the qualtime / qualsize qualifier predicates. Values
3541/// come from the TT_* enum (TT_DAYS=0, TT_HOURS=1, TT_MINS=2,
3542/// TT_WEEKS=3, TT_MONTHS=4 for time; TT_BYTES=0, TT_POSIX_BLOCKS=1,
3543/// TT_KILOBYTES=2, etc. for size).
3544pub static g_units: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
3545
3546/// Port of `static int g_range;` from `Src/glob.c`. Comparison
3547/// direction for qualtime / qualsize / qualnlink: -1 = `<`, 0 = `==`,
3548/// +1 = `>`. Set by the glob qualifier parser.
3549pub static g_range: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
3550
3551/// Port of `static int g_amc;` from `Src/glob.c`. Which mtime to read
3552/// for qualtime: 0 = atime, 1 = mtime, 2 = ctime. Set by the `am`,
3553/// `mm`, `cm` qualifier letters.
3554pub static g_amc: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
3555
3556/// Direct port of `static int qualsize(UNUSED(char *name),
3557/// struct stat *buf, off_t size, UNUSED(char *dummy))` from
3558/// `Src/glob.c:3825-3866`. Glob-qualifier predicate: returns true
3559/// when `buf->st_size` (scaled by `g_units`) compares to `size`
3560/// via `g_range`.
3561///
3562/// **Previous fakery:** the file held a same-named helper that was
3563/// actually a unit-conversion *parser* `(s, units) -> Option<(i64,
3564/// &str)>` — wrong shape, wrong semantics, zero callers. The
3565/// canonical C signature is restored here; the qualifier-eval site
3566/// at glob.rs:3914 already inlines the equivalent (uses
3567/// qualifier::Size struct fields) and is the live path.
3568#[allow(non_snake_case)]
3569pub fn qualsize(_name: &str, buf: &libc::stat, size: i64, _dummy: &str) -> i32 {
3570    use std::sync::atomic::Ordering;
3571    // c:3831 `zlong scaled = buf->st_size;`
3572    let mut scaled: i64 = buf.st_size as i64;
3573    // c:3837-3860 — switch (g_units) ceiling-divide by the unit.
3574    match g_units.load(Ordering::SeqCst) {
3575        x if x == TT_POSIX_BLOCKS => {
3576            scaled = (scaled + 511) / 512; // c:3838-3841
3577        }
3578        x if x == TT_KILOBYTES => {
3579            scaled = (scaled + 1023) / 1024; // c:3842-3845
3580        }
3581        x if x == TT_MEGABYTES => {
3582            scaled = (scaled + 1_048_575) / 1_048_576; // c:3846-3849
3583        }
3584        x if x == TT_GIGABYTES => {
3585            scaled = (scaled + 1_073_741_823) / 1_073_741_824; // c:3851-3854
3586        }
3587        x if x == TT_TERABYTES => {
3588            scaled = (scaled + 1_099_511_627_775) / 1_099_511_627_776; // c:3855-3858
3589        }
3590        _ => {} // c:3860 default: bytes — no scaling.
3591    }
3592    // c:3862-3864 — `g_range < 0 ? < : g_range > 0 ? > : ==`.
3593    let r = g_range.load(Ordering::SeqCst);
3594    i32::from(if r < 0 {
3595        scaled < size
3596    } else if r > 0 {
3597        scaled > size
3598    } else {
3599        scaled == size
3600    })
3601}
3602
3603/// Direct port of `static int qualtime(UNUSED(char *name),
3604/// struct stat *buf, off_t days, UNUSED(char *dummy))` from
3605/// `Src/glob.c:3870-3901`. Glob-qualifier predicate: returns true
3606/// when `now - buf->{a,m,c}time` (selected by `g_amc`, scaled by
3607/// `g_units`) compares to `days` via `g_range`.
3608///
3609/// **Previous fakery:** same misshapen parser as the qualsize one
3610/// above. Restored to C signature; the qualifier-eval site at
3611/// glob.rs:3940 already inlines the equivalent (uses qualifier::
3612/// Atime struct fields with the live atime/mtime/ctime read).
3613#[allow(non_snake_case)]
3614pub fn qualtime(_name: &str, buf: &libc::stat, days: i64, _dummy: &str) -> i32 {
3615    use std::sync::atomic::Ordering;
3616    // c:3876-3878 — `time(&now); diff = now - (g_amc == 0 ? st_atime
3617    //                  : g_amc == 1 ? st_mtime : st_ctime);`
3618    let now: i64 = std::time::SystemTime::now()
3619        .duration_since(std::time::UNIX_EPOCH)
3620        .map(|d| d.as_secs() as i64)
3621        .unwrap_or(0);
3622    let amc = g_amc.load(Ordering::SeqCst);
3623    let stamp: i64 = match amc {
3624        0 => buf.st_atime as i64, // c:3877
3625        1 => buf.st_mtime as i64, // c:3877
3626        _ => buf.st_ctime as i64, // c:3878
3627    };
3628    let mut diff: i64 = now - stamp;
3629    // c:3880-3896 — scale by g_units.
3630    match g_units.load(Ordering::SeqCst) {
3631        x if x == TT_DAYS => diff /= 86400,     // c:3881-3883
3632        x if x == TT_HOURS => diff /= 3600,     // c:3884-3886
3633        x if x == TT_MINS => diff /= 60,        // c:3887-3889
3634        x if x == TT_WEEKS => diff /= 604800,   // c:3890-3892
3635        x if x == TT_MONTHS => diff /= 2592000, // c:3893-3895
3636        _ => {}                                 // c:3896 default: seconds.
3637    }
3638    // c:3898-3900 — same range comparison as qualsize.
3639    let r = g_range.load(Ordering::SeqCst);
3640    i32::from(if r < 0 {
3641        diff < days
3642    } else if r > 0 {
3643        diff > days
3644    } else {
3645        diff == days
3646    })
3647}
3648
3649/// Port of `static int qualsheval(char *name, UNUSED(struct stat *buf),
3650/// UNUSED(off_t days), char *str)` from `Src/glob.c:3907`.
3651///
3652/// C body (c:3907-3943):
3653/// ```c
3654/// if ((prog = parse_string(str, 0))) {
3655///     int ef = errflag, lv = lastval;             // c:3912 save
3656///     unsetparam("reply");                        // c:3915
3657///     setsparam("REPLY", ztrdup(name));           // c:3916
3658///     execode(prog, 1, 0, "globqual");            // c:3919
3659///     ret = lastval;                              // c:3921
3660///     errflag = ef | (errflag & ERRFLAG_INT);     // c:3924 restore
3661///     lastval = lv;                               // c:3925
3662///     return !ret;
3663/// }
3664/// return 0;
3665/// ```
3666///
3667/// The `e:EXPR:` glob qualifier runs `EXPR` against each match
3668/// candidate with `$REPLY` pre-set to the filename. The expr is
3669/// evaluated IN THE CURRENT SHELL — it can read shell locals,
3670/// reference shell functions, set $reply etc.
3671///
3672/// The previous Rust port spawned `/bin/sh -c <expr>` via
3673/// `Command::new("sh")`, which:
3674///   1. Loses access to current zsh function/alias/local-var scope.
3675///   2. Runs `sh` (POSIX), not `zsh` — entire `(e:zsh-feature:)`
3676///      class of qualifiers silently failed.
3677///   3. Skipped errflag/lastval save+restore.
3678///
3679/// Fixed: route through the canonical `ShellExecutor` for in-shell
3680/// evaluation. Set `REPLY` via paramtab (C `setsparam`), restore
3681/// `errflag`/`lastval` per c:3924-3925, mask any parse-time
3682/// ERRFLAG_ERROR while preserving ERRFLAG_INT (user interrupt).
3683/// WARNING: param names don't match C — Rust=(filename, expr) vs C=(name, buf, days, str)
3684pub fn qualsheval(filename: &str, _buf: &libc::stat, _data: i64, expr: &str) -> i32 {
3685    // c:3907
3686    // c:3912 — save errflag + lastval.
3687    let saved_errflag = errflag.load(Ordering::Relaxed); // c:3912
3688    let saved_lastval = LASTVAL.load(Ordering::Relaxed); // c:3912
3689                                                         // c:3915 — `unsetparam("reply");`
3690    crate::ported::params::unsetparam("reply"); // c:3915
3691                                                // c:3916 — `setsparam("REPLY", ztrdup(name));`. Set in the
3692                                                // canonical paramtab so the evaluated expression sees `$REPLY`.
3693    crate::ported::params::setsparam("REPLY", filename); // c:3916
3694                                                         // c:3919 — `execode(prog, 1, 0, "globqual");`. Route through the
3695                                                         // executor via the `crate::ported::exec` accessor wrappers
3696                                                         // (resolve the live executor on demand). Direct ShellExecutor
3697                                                         // reach-in from src/ported/ is forbidden — see memory
3698                                                         // feedback_no_exec_script_from_ported.
3699    let rc = {
3700        // c:Src/glob.c:3919 — `execode(prog, 1, 0, "globqual");`. execode
3701        // (c:Src/exec.c:1245-1266) APPENDS its context for the duration, so
3702        // code inside an `(e:'…':)` / `(+cmd)` glob qualifier sees
3703        // `cmdarg:globqual`. Popped on every return path. Bug #1069.
3704        let sync_eval_ctx = |stack: &[String]| {
3705            let joined = stack.join(":");
3706            if let Ok(mut tab) = crate::ported::params::paramtab().write() {
3707                if let Some(pm) = tab.get_mut("zsh_eval_context") {
3708                    pm.u_arr = Some(stack.to_vec());
3709                    pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
3710                }
3711                if let Some(pm) = tab.get_mut("ZSH_EVAL_CONTEXT") {
3712                    pm.u_str = Some(joined);
3713                    pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
3714                }
3715            }
3716        };
3717        if let Ok(mut ctx) = crate::ported::exec::zsh_eval_context.lock() {
3718            ctx.push("globqual".to_string());
3719            sync_eval_ctx(&ctx);
3720        }
3721        struct GlobQualCtxGuard<F: Fn(&[String])>(F);
3722        impl<F: Fn(&[String])> Drop for GlobQualCtxGuard<F> {
3723            fn drop(&mut self) {
3724                if let Ok(mut ctx) = crate::ported::exec::zsh_eval_context.lock() {
3725                    ctx.pop();
3726                    (self.0)(&ctx);
3727                }
3728            }
3729        }
3730        let _ctx_guard = GlobQualCtxGuard(sync_eval_ctx);
3731        crate::ported::exec::execute_script(expr).unwrap_or(1) // c:3919
3732    };
3733    let ret = LASTVAL.load(Ordering::Relaxed); // c:3921 ret = lastval
3734    let _ = rc;
3735    // c:3924 — `errflag = ef | (errflag & ERRFLAG_INT);`. Restore
3736    // pre-call errflag plus any interrupt bit set during eval.
3737    let post_errflag = errflag.load(Ordering::Relaxed);
3738    errflag.store(
3739        saved_errflag | (post_errflag & ERRFLAG_INT),
3740        Ordering::Relaxed,
3741    ); // c:3924
3742       // c:3925 — `lastval = lv;`. Restore pre-call lastval.
3743    LASTVAL.store(saved_lastval, Ordering::Relaxed); // c:3925
3744                                                     // c:3927-3937 — `inserts = getaparam("reply") || gethparam("reply")`,
3745                                                     // else the `reply`/`REPLY` SCALAR as a one-element list. The eval can
3746                                                     // thus REPLACE the matched name with zero-or-more names (`reply=(…)`)
3747                                                     // or rename it (`REPLY=…`). REPLY was seeded to `filename` at c:3916,
3748                                                     // so this is always Some — at minimum the (possibly modified) REPLY.
3749    let inserts: Vec<String> = match crate::ported::params::getaparam("reply") {
3750        Some(arr) => arr, // c:3927
3751        None => match crate::ported::params::getsparam("reply") // c:3931
3752            .or_else(|| crate::ported::params::getsparam("REPLY"))
3753        {
3754            Some(scalar) => vec![scalar], // c:3934-3936
3755            None => Vec::new(),
3756        },
3757    };
3758    *INSERTS.lock().unwrap() = Some(inserts);
3759    // c:3941 — `return !ret;` — qualifier passes iff lastval is 0.
3760    i32::from(ret == 0)
3761}
3762
3763/// Port of `qualnonemptydir(char *name, struct stat *buf, UNUSED(off_t days), UNUSED(char *str))` from Src/glob.c:3948.
3764/// C: opendir(name) and check if any non-`.`/`..` entries exist.
3765pub fn qualnonemptydir(name: &str, buf: &libc::stat, days: i64, str: &str) -> i32 {
3766    // c:3948
3767    // c:3948 — `if (!S_ISDIR(buf->st_mode)) return 0;`
3768    if (buf.st_mode as u32 & libc::S_IFMT as u32) != libc::S_IFDIR as u32 {
3769        // c:3950
3770        return 0;
3771    }
3772    // c:3953-3964 — opendir + readdir loop, skip "." and ".." entries.
3773    match fs::read_dir(name) {
3774        Ok(entries) => entries.filter_map(|e| e.ok()).any(|e| {
3775            let n = e.file_name();
3776            let s = n.to_string_lossy();
3777            s != "." && s != ".."
3778        }) as i32,
3779        Err(_) => 0,
3780    }
3781}
3782
3783// =====================================================================
3784// Thread-local glob option snapshot — race fix.
3785//
3786// `Src/glob.c` reads `isset(NULLGLOB)` / `isset(BAREGLOBQUAL)` / etc.
3787// inline at each callsite during a glob. In zsh those reads hit the
3788// thread-local C `opts[]` array (zsh is single-threaded). In zshrs
3789// they hit `OPTS_LIVE` — a process-wide `RwLock<HashMap>`.
3790//
3791// Embedders that snapshot/restore options around individual glob
3792// invocations on multiple threads (e.g. stryke's `StrykeGlobOptsGuard`
3793// + `glob_par` rayon parallelism) race: thread A sets bareglobqual=1,
3794// calls glob; while A is mid-parse, thread B's matching guard restores
3795// bareglobqual=0; A's qualifier-parse step sees bareglobqual=0 and
3796// drops the trailing `(N)` as a literal substring, producing false
3797// matches.
3798//
3799// Fix: snapshot every glob-relevant option ONCE at glob() entry into
3800// a thread-local cache, then read all isset() inside glob from the
3801// snapshot. The snapshot is dropped on glob exit via an RAII guard.
3802// Each thread's in-flight glob() sees a coherent set of options
3803// regardless of concurrent setopt/unsetopt on other threads.
3804// =====================================================================
3805
3806/// Snapshot of glob-relevant zsh options taken at glob entry. Holds
3807/// the live `isset(...)` value for each option that the glob engine
3808/// or its helpers (`parse_qualifiers`, `scanner`,
3809/// `sort_matches`, `glob_emit_path`) consult.
3810#[derive(Debug, Clone, Copy, Default)]
3811pub struct GlobOptSnapshot {
3812    pub bareglobqual: bool,
3813    pub braceccl: bool,
3814    pub caseglob: bool,
3815    pub extendedglob: bool,
3816    pub globdots: bool,
3817    pub globstarshort: bool,
3818    pub listtypes: bool,
3819    pub markdirs: bool,
3820    pub nullglob: bool,
3821    pub numericglobsort: bool,
3822}
3823
3824/// RAII guard that drops the thread-local snapshot on scope exit.
3825/// Constructed by `enter_glob_scope()`. Nested glob calls on the
3826/// same thread reuse the outer snapshot (the guard's `populated`
3827/// field tracks whether we installed or just observed).
3828pub struct GlobOptsGuard {
3829    populated: bool,
3830}
3831
3832/// Sort specifier flags
3833// `GlobSort` / `SortOrder` / `SortSpec` deleted — C uses bit-flag
3834// `int tp` in `struct globsort { int tp; char *exec; }` (Src/glob.c:155):
3835//   tp & ~GS_DESC selects the sort key (GS_NAME/GS_DEPTH/GS_EXEC/…),
3836//   tp & GS_DESC reverses direction (`O` vs `o` qualifier),
3837//   tp << GS_SHIFT carries the follow-link variants.
3838// All those bits already exist as i32 constants at glob.rs:33+
3839// (GS_NAME / GS_DEPTH / GS_SIZE / … / GS_DESC / GS_NONE / GS__SIZE /
3840// …). The enum + Ascending/Descending + struct triple-wrapper was a
3841// Rust-only convenience with no C counterpart; callers now operate
3842// on the raw i32 the same way `gmatchcmp()` at glob.c:936 does.
3843
3844// `TimeUnit` / `SizeUnit` / `RangeOp` enums deleted — Rust-only
3845// wrappers around constants/chars that exist as raw values in C:
3846//   `TimeUnit::Seconds` → TT_SECONDS i32 (glob.c:126 → glob.rs:99)
3847//   `TimeUnit::Minutes` → TT_MINS (c:123)
3848//   `TimeUnit::Hours`   → TT_HOURS (c:122)
3849//   `TimeUnit::Days`    → TT_DAYS (c:121)
3850//   `TimeUnit::Weeks`   → TT_WEEKS (c:124)
3851//   `TimeUnit::Months`  → TT_MONTHS (c:125)
3852//   `SizeUnit::Bytes`   → TT_BYTES (c:128)
3853//   `SizeUnit::PosixBlocks` → TT_POSIX_BLOCKS (c:129)
3854//   `SizeUnit::Kilobytes`   → TT_KILOBYTES (c:130)
3855//   `SizeUnit::Megabytes`   → TT_MEGABYTES (c:131)
3856//   `SizeUnit::Gigabytes`   → TT_GIGABYTES (c:132)
3857//   `SizeUnit::Terabytes`   → TT_TERABYTES (c:133)
3858//   `RangeOp::Less`/`Equal`/`Greater` → raw chars `<` `=` `>`
3859//      (zsh's qgetnum at glob.c:827 returns the raw operator char
3860//      and the qualifier handlers switch on it inline).
3861
3862/// Port of `TestMatchFunc` — the `int (*)(char *, struct stat *, off_t,
3863/// char *)` function-pointer type held by `struct qual.func` (Src/glob.c).
3864/// Every glob-qualifier test (qualuid, qualisreg, qualiscom, qualsize, …)
3865/// has this signature so `insert()` can call them uniformly through the
3866/// `func` slot. `name` is the metafied path, `buf` the stat, `data` the
3867/// qualifier argument (uid, mode bits, day count, …), `sdata` the
3868/// expression text (only `qualsheval` uses it; others ignore it).
3869pub type TestMatchFunc = fn(name: &str, buf: &libc::stat, data: i64, sdata: &str) -> i32;
3870
3871/// Port of `struct qual` from `Src/glob.c:138`. A node in the linked list
3872/// of glob qualifiers parsed from a `(…)` suffix. `insert()` (glob.c:346)
3873/// walks this list per candidate file, calling `func` on each node and
3874/// accepting/rejecting the file by `sense`. The terminating node carries
3875/// `func == None` (C tests `qn && qn->func` to stop).
3876///
3877/// REPRESENTATION: C's `next`/`or` are `struct qual *` pointers into a
3878/// graph that `parsepat` builds and relinks through aliased cursors (the
3879/// OR-distribution at glob.c:1797-1820 aliases one node from several
3880/// chains at once). Rust single-ownership `Box` cannot express that, so
3881/// the port uses an arena ([`QualArena`]): nodes live in a `Vec<qual>`
3882/// and `next`/`or` are `Option<usize>` indices. C pointer assignments map
3883/// to index assignments, so the aliased-cursor algorithm translates
3884/// almost verbatim.
3885#[allow(non_camel_case_types)]
3886#[derive(Clone, Debug)]
3887pub struct qual {
3888    /// c:139 — `struct qual *next;` next qualifier in the AND-chain.
3889    pub next: Option<usize>,
3890    /// c:140 — `struct qual *or;` head of an alternative OR-chain.
3891    pub or: Option<usize>,
3892    /// c:141 — `TestMatchFunc func;` the test to call; `None` terminates.
3893    pub func: Option<TestMatchFunc>,
3894    /// c:142 — `off_t data;` argument passed to `func`.
3895    pub data: i64,
3896    /// c:143 — `int sense;` bit0: 0=assert / 1=negate; bit1: follow links.
3897    pub sense: i32,
3898    /// c:144 — `int amc;` which timestamp to test (0=a, 1=m, 2=c).
3899    pub amc: i32,
3900    /// c:145 — `int range;` test `<` / `>` / `=` (signum of comparison).
3901    pub range: i32,
3902    /// c:146 — `int units;` multiplier for time (TT_DAYS…) or size.
3903    pub units: i32,
3904    /// c:147 — `char *sdata;` expression to eval (currently qualsheval only).
3905    pub sdata: Option<String>,
3906}
3907
3908impl qual {
3909    /// A fresh node with the C zero-initialised defaults (`hcalloc`).
3910    pub fn new() -> qual {
3911        qual {
3912            next: None,
3913            or: None,
3914            func: None,
3915            data: 0,
3916            sense: 0,
3917            amc: 0,
3918            range: 0,
3919            units: 0,
3920            sdata: None,
3921        }
3922    }
3923}
3924
3925impl Default for qual {
3926    fn default() -> qual {
3927        qual::new()
3928    }
3929}
3930
3931/// Arena backing the [`qual`] graph. C allocates each `struct qual` with
3932/// `hcalloc` and links them by pointer; the Rust port allocates them in
3933/// this `Vec` and links them by index. `alloc()` mirrors a single
3934/// `hcalloc(sizeof *qn)` and returns the new node's index; `head` holds
3935/// the index of `quals` (the list `insert()` walks), `None` when empty.
3936#[derive(Clone, Default, Debug)]
3937pub struct QualArena {
3938    pub nodes: Vec<qual>,
3939    pub head: Option<usize>,
3940}
3941
3942impl QualArena {
3943    pub fn new() -> QualArena {
3944        QualArena {
3945            nodes: Vec::new(),
3946            head: None,
3947        }
3948    }
3949
3950    /// c:`qn = (struct qual *)hcalloc(sizeof *qn);` — allocate a fresh
3951    /// zeroed node and return its arena index.
3952    pub fn alloc(&mut self) -> usize {
3953        self.nodes.push(qual::new());
3954        self.nodes.len() - 1
3955    }
3956}
3957
3958/// A glob qualifier function
3959#[derive(Debug, Clone)]
3960/// One glob qualifier — Rust-extension sum type. C uses a linked
3961/// list of `struct qual` (`Src/zsh.h:140-152`) with function-pointer
3962/// `func` per node; each variant here maps to one of C's `q*` test
3963/// ported (`qisreg`, `qisdir`, `qowner`, `qtime`, ...) at
3964/// `Src/glob.c:1080-1340`. The full `struct qual` port + per-test fn
3965/// dispatch lives in a later phase; this enum keeps the parsed-form
3966/// the per-match filter inside `scanner()` (line 500) needs.
3967#[allow(non_camel_case_types)]
3968pub enum qualifier {
3969    /// File type qualifiers
3970    IsRegular,
3971    IsDirectory,
3972    IsSymlink,
3973    IsSocket,
3974    IsFifo,
3975    IsBlockDev,
3976    IsCharDev,
3977    IsDevice,
3978    IsExecutable,
3979
3980    /// Permission qualifiers
3981    Readable,
3982    Writable,
3983    Executable,
3984    WorldReadable,
3985    WorldWritable,
3986    WorldExecutable,
3987    GroupReadable,
3988    GroupWritable,
3989    GroupExecutable,
3990    Setuid,
3991    Setgid,
3992    Sticky,
3993
3994    /// Ownership qualifiers
3995    OwnedByEuid,
3996    OwnedByEgid,
3997    OwnedByUid(u32),
3998    OwnedByGid(u32),
3999
4000    /// Numeric qualifiers with range. `unit` is a `TT_*` i32
4001    /// (glob.rs:99-113 / glob.c:121-133); `op` is the raw range
4002    /// operator char (`<`, `=`, `>`) — mirrors C's qgetnum which
4003    /// returns the operator char and the handler switches inline.
4004    Size {
4005        value: u64,
4006        unit: i32,
4007        op: char,
4008    },
4009    Links {
4010        value: u64,
4011        op: char,
4012    },
4013    Atime {
4014        value: i64,
4015        unit: i32,
4016        op: char,
4017    },
4018    Mtime {
4019        value: i64,
4020        unit: i32,
4021        op: char,
4022    },
4023    Ctime {
4024        value: i64,
4025        unit: i32,
4026        op: char,
4027    },
4028
4029    /// Mode specification
4030    Mode {
4031        yes: u32,
4032        no: u32,
4033    },
4034
4035    /// Device number
4036    Device(u64),
4037
4038    /// Non-empty directory
4039    NonEmptyDir,
4040
4041    /// Shell evaluation
4042    Eval(String),
4043
4044    /// `^` — toggle the running match sense for SUBSEQUENT qualifiers in
4045    /// this list. C uses a running `sense ^= 1` (Src/glob.c:1346) stored
4046    /// per `struct qual.sense`; the enum can't carry per-node sense, so a
4047    /// marker in the list reproduces it: `check_qualifier_list` flips a
4048    /// running sense bit on each marker and XORs it into later tests.
4049    /// Fixes `*(/^@)` (dir AND not-symlink), `*(.^x)`, etc.
4050    ToggleSense,
4051
4052    /// `-` — c:1348-1352 `sense ^= 2;` toggles link-following for the
4053    /// SUBSEQUENT qualifiers only: each node then tests the target's stat
4054    /// (c:399-403 `bp = (qn->sense & 2) ? &buf2 : &buf;`). `*(@-.)` is a
4055    /// symlink whose target is a regular file.
4056    ToggleFollow,
4057}
4058
4059// Misnamed `gmatchcmp(&str, &str)` deleted — was a Rust-only
4060// locale-aware string compare claiming to be the C qsort
4061// comparator. The real C `gmatchcmp(Gmatch, Gmatch)` at glob.c:936
4062// is now ported as `gmatchcmp(&GlobMatch, &GlobMatch, &[i32], bool)`
4063// below. The string-compare case the old name claimed routes
4064// through canonical `zstrcmp` (sort.c:191).
4065
4066// `GlobOptions` struct deleted — Rust-only Bag-of-options with no
4067// C counterpart. C reads each option directly from the global
4068// `opts[]` array via `isset(NULL_GLOB)` / `isset(EXTENDED_GLOB)`
4069// / etc. (Src/options.c). The Rust port uses the canonical
4070// `opt_state_get(name) -> Option<bool>`
4071// which reads from the same global store. Inlined at each
4072// callsite as `opt_state_get("name").unwrap_or(default)`.
4073
4074/// Parsed glob qualifier set
4075#[derive(Debug, Clone, Default)]
4076/// Compiled qualifier list for one glob.
4077/// Mirrors the `struct qual *` linked list `parsepat()`
4078/// (Src/glob.c:791) builds — every `(qual)` after a glob pattern
4079/// adds to it.
4080#[allow(non_camel_case_types)]
4081pub struct qualifier_set {
4082    // c:138
4083    pub qualifiers: Vec<qualifier>,
4084    pub alternatives: Vec<Vec<qualifier>>,
4085    /// Packed sort-spec flags, one per `o`/`O` qualifier in the pattern.
4086    /// Each entry is the C `struct globsort.tp` field — `GS_NAME` /
4087    /// `GS_DEPTH` / `GS_EXEC` / `GS_SIZE` / `GS_ATIME` / `GS_MTIME` /
4088    /// `GS_CTIME` / `GS_LINKS` (or their `<< GS_SHIFT` follow-link
4089    /// variants), OR'd with `GS_DESC` for reverse direction.
4090    pub sorts: Vec<i32>, // c:155 struct globsort.tp[]
4091    /// c:74 `struct globsort.exec` — the eval code for each `GS_EXEC`
4092    /// (`oe:…:` / `o+…`) sort spec, indexed by the `idx` packed into the
4093    /// sort entry's high bits (`GS_EXEC | (idx << 16)`). Evaluated per
4094    /// match with `$REPLY` = the match name; the resulting `$REPLY` is
4095    /// the sort key (the original name is still emitted).
4096    pub sort_exec: Vec<String>, // c:74 globsort.exec
4097    /// The faithful glob.c `struct qual` representation of `alternatives`,
4098    /// built at parse end (additive — the enum is kept for now). AND-chain
4099    /// via `next`, alternatives via `or`, per-node `sense`/`range`/`amc`/
4100    /// `units`/`sdata`. The convergence target: `check_qualifiers` walks
4101    /// THIS via `insert()` semantics (glob.c:392-419), and the enum is
4102    /// eventually deleted.
4103    pub quals: QualArena, // c:206 curglobdata.gd_quals
4104    pub first: Option<i32>,
4105    pub last: Option<i32>,
4106    pub colon_mods: Option<String>,
4107    pub pre_words: Vec<String>,
4108    pub post_words: Vec<String>,
4109    /// `(M)` qualifier — append `/` to directory entries in output.
4110    /// Direct port of zsh/Src/glob.c:1557-1561 (`case 'M'`):
4111    ///   `gf_markdirs = !(sense & 1)` — set when the qualifier appears
4112    ///   without a `^` toggle. Stored per-qualifier-set rather than per
4113    ///   GlobOptions so a single glob call's qualifier picks it up.
4114    /// `None` = no `M` qualifier (the MARK_DIRS option decides, c:1255
4115    /// `gf_markdirs = isset(MARKDIRS)`); `Some(false)` = `^M`, which
4116    /// turns marking OFF even under `setopt markdirs`.
4117    pub mark_dirs: Option<bool>,
4118    /// `(T)` qualifier — append type-char (ls -F style) to every entry.
4119    /// Direct port of zsh/Src/glob.c:1562-1566 (`case 'T'`).
4120    pub list_types: bool,
4121    /// `gf_follow` — c:1559/1564 `gf_follow = sense & 2;`: a `-` before
4122    /// `M`/`T` makes the marker describe the symlink TARGET (c:366-371).
4123    pub mark_follow: bool,
4124    /// `(N)` qualifier — per-glob nullglob: empty result on no-match,
4125    /// no error. Direct port of zsh/Src/glob.c:1567-1569 (`case 'N'`):
4126    ///   `gf_nullglob = !(sense & 1)` — set when `(N)` appears without
4127    ///   the `^` toggle.
4128    pub nullglob: bool,
4129    /// `(D)` qualifier — include dotfiles in the glob result (override
4130    /// the global `globdots`/`dotglob` option for this glob alone).
4131    /// Direct port of zsh/Src/glob.c case 'D' which sets
4132    /// `gf_glob.dots = 1` for the duration of this glob expansion.
4133    pub globdots: bool,
4134    /// `(YN)` qualifier — short-circuit: limit number of matches to
4135    /// at most N. Direct port of zsh/Src/glob.c:1579-1595 (`case 'Y'`)
4136    /// which sets the C `shortcircuit` int. The matcher should stop
4137    /// after collecting N entries; the Rust port truncates after the
4138    /// match walk completes (the optimization gain is the same in
4139    /// the typical short-glob case). Bug #41 in docs/BUGS.md.
4140    pub short_circuit: Option<i32>,
4141    /// `(n)` qualifier — per-glob numeric sort. Direct port of
4142    /// zsh/Src/glob.c:1575-1577 (`case 'n': gf_numsort = !(sense & 1)`).
4143    /// Distinct from the `o`/`O` sort KEY `n` (= GS_NAME, lexical):
4144    /// standalone `(n)` makes the name comparison NUMERIC (so `*(n)`
4145    /// orders `f2` before `f10`), OR'd with the global NUMERIC_GLOB_SORT
4146    /// option at sort time (glob.c:947/983 `gf_numsort ?
4147    /// SORTIT_NUMERICALLY : 0`). `None` = not specified (fall back to
4148    /// the global option); `Some(true)` = `(n)`; `Some(false)` = `(^n)`
4149    /// which OVERRIDES the global option to force lexical.
4150    pub numsort: Option<bool>,
4151}
4152
4153/// Enter a glob scope: snapshot options into TLS if no outer scope
4154/// is active, returning a guard that clears the snapshot on Drop.
4155/// Re-entrant calls (nested globdata_glob via brace expansion etc.)
4156/// return a no-op guard that observes the existing snapshot.
4157pub fn enter_glob_scope() -> GlobOptsGuard {
4158    let already = GLOB_OPTS_TLS.with_borrow(|g| g.is_some());
4159    if already {
4160        return GlobOptsGuard { populated: false };
4161    }
4162    let snap = GlobOptSnapshot::capture();
4163    GLOB_OPTS_TLS.with_borrow_mut(|g| *g = Some(snap));
4164    GlobOptsGuard { populated: true }
4165}
4166
4167/// Read a glob-relevant option through the TLS snapshot when one is
4168/// active; fall back to the live `isset()` otherwise. Use this in
4169/// any glob-engine helper that previously read `isset(OPT)` for one
4170/// of the snapshotted options.
4171#[inline]
4172pub fn glob_isset(opt: i32) -> bool {
4173    GLOB_OPTS_TLS.with_borrow(|g| match g {
4174        Some(snap) => match opt {
4175            x if x == BAREGLOBQUAL => snap.bareglobqual,
4176            x if x == BRACECCL => snap.braceccl,
4177            x if x == CASEGLOB => snap.caseglob,
4178            x if x == EXTENDEDGLOB => snap.extendedglob,
4179            x if x == GLOBDOTS => snap.globdots,
4180            x if x == GLOBSTARSHORT => snap.globstarshort,
4181            x if x == LISTTYPES => snap.listtypes,
4182            x if x == MARKDIRS => snap.markdirs,
4183            x if x == NULLGLOB => snap.nullglob,
4184            x if x == NUMERICGLOBSORT => snap.numericglobsort,
4185            _ => isset(opt),
4186        },
4187        None => isset(opt),
4188    })
4189}
4190
4191// ===========================================================
4192// `impl globdata` block above kept only for the `new()`
4193// constructor (Default-style). All scanner / parser / qualifier
4194// ported below are top-level — C glob.c has them as top-level
4195// statics that mutate the file-static `curglobdata`. Each is
4196// flagged with `// RUST-ONLY` and a comment naming the closest
4197// C equivalent + the proper-port target (typically `scanner`
4198// at glob.c:500 driving `insert` at c:346).
4199// ===========================================================
4200
4201/// Main entry point: expand a glob pattern.
4202///
4203/// Closest C equivalent: `zglob` driver at glob.c:1214 calls
4204/// `parsepat` (c:791) and then `scanner` (c:500). The Rust port
4205/// here orchestrates qualifier parsing, brace expansion (which C
4206/// does separately in `xpandbraces`), then drives the faithful
4207/// `parsecomplist` (c:710) → `complist` → `scanner` (c:500) path.
4208pub fn globdata_glob(state: &mut globdata, pattern: &str) -> Vec<String> {
4209    // RUST-ONLY
4210    // Brace pre-expansion. In zsh, `xpandbraces` (zsh/Src/glob.c:2275)
4211    // runs during substitution before glob — patterns reaching glob()
4212    // are already brace-free in the production path (vm_helper handles
4213    // it). For direct programmatic callers of glob_with_options, run
4214    // the brace pass here so `GlobOptions.brace_ccl` is actually
4215    // consulted: with brace_ccl set, `{a-mnop}` expands to a..m,n,o,p
4216    // per glob.c:2424 BRACECCL block; without, only `{a,b}` lists and
4217    // `{1..5}`/`{a..e}` ranges expand. Recurse on each variant and
4218    // concatenate matches.
4219    let brace_ccl = glob_isset(BRACECCL);
4220    if hasbraces(pattern, brace_ccl) {
4221        let mut all = Vec::new();
4222        for variant in xpandbraces(pattern, brace_ccl) {
4223            all.extend(globdata_glob(state, &variant));
4224        }
4225        return all;
4226    }
4227
4228    state.matches.clear();
4229    state.pathbuf.clear();
4230    state.pathpos = 0;
4231
4232    // Parse qualifiers first so a bare-qualifier pattern like `dir(/)`
4233    // (no wildcard, just a stat-based filter) still enters the expansion
4234    // path. Without this, `haswilds("dir(/)")` returns false and the
4235    // pattern echoes back unfiltered, which defeats the whole point of
4236    // qualifiers.
4237    let (pat, quals) = parse_qualifiers(pattern);
4238    state.qualifiers = quals;
4239
4240    // !!! RUST-ONLY decision — NO DIRECT C COUNTERPART !!!
4241    // Is `pat` a lexer-TOKENIZED word (the only form C's `zglob`, c:1221,
4242    // ever receives) or a raw programmatic pattern? In a tokenized word
4243    // only the glob tokens are active: a plain `*`, `?`, `[`, `~` … was
4244    // quoted or substituted and must not be tokenized again. `glob_path`
4245    // also serves raw callers (compsys, builtins) whose metachars are
4246    // plain, so the word's spelling decides: any token means tokenized,
4247    // none means raw.
4248    //
4249    // Under GLOB_SUBST the plain characters are substituted values that C
4250    // would have shtokenized in `strcatsub` (c:Src/subst.c:822/829-830).
4251    // The port's paramsubst does not tokenize them — it carries the option
4252    // on the global table (including the `${~spec}` carrier, subst.rs
4253    // `TILDE_GLOBSUBST_CARRIER`) for the glob layer to apply — so the raw
4254    // treatment, which activates every plain metachar, stands in for that
4255    // shtokenize.
4256    //
4257    // Ask the WHOLE word, qualifier block included: in `"*"(N)` the only
4258    // tokens are the qualifier's Inpar/Outpar, so the stripped `*` alone
4259    // looked raw and was re-tokenized into a live Star — the quoted star
4260    // listed the directory where zsh expands to nothing.
4261    let word_is_tokenized =
4262        crate::ported::lex::has_token(pattern) && !isset(crate::ported::zsh_h::GLOBSUBST);
4263
4264    // c:Src/glob.c — `A~B` exclusion. zsh compiles the ENTIRE glob
4265    // (path components + `~B` exclusion + `**`) into ONE Patprog and
4266    // matches each candidate PATH against it, so the `~B` applies to the
4267    // whole path. zshrs's component scanner can't express a path-level
4268    // exclusion, so split a TOP-LEVEL `~` (extendedglob; depth 0, not in
4269    // `[...]`/`(...)`, not Bnull-escaped) off here into the main pattern
4270    // plus full-path exclusion patterns, glob the main pattern, then drop
4271    // matched paths matching any exclusion below. Without this the `~B`
4272    // (which may contain `/`) was split across path components and either
4273    // excluded everything (`~*/.git/*` → `*.txt~*` matched all) or
4274    // nothing. `~` inside one component still works the same (main globs,
4275    // the post-filter applies the exclusion).
4276    let (pat, glob_exclusions): (String, Vec<String>) =
4277        if glob_isset(EXTENDEDGLOB) && (pat.contains('~') || pat.contains('\u{e198}')) {
4278            // In a TOKENIZED word (see the `pat_tok` note below) only the
4279            // Tilde token is the operator (c:Src/pattern.c:800 compares
4280            // against `zpc_special[ZPC_TILDE]`, the token); a plain `~` was
4281            // quoted or substituted. A raw programmatic pattern keeps the
4282            // plain spelling as the operator.
4283            let raw_tilde_is_op = !word_is_tokenized;
4284            let cv: Vec<char> = pat.chars().collect();
4285            let mut parts: Vec<String> = Vec::new();
4286            let mut cur = String::new();
4287            let mut bd = 0i32; // `[...]` depth
4288            let mut pd = 0i32; // `(...)` depth
4289            let mut k = 0usize;
4290            while k < cv.len() {
4291                let c = cv[k];
4292                match c {
4293                    // Escape — the next char is a literal, never the
4294                    // exclusion operator. Three spellings reach here:
4295                    // `zshtokenize` emits Bnull for `\X` (c:3591) and
4296                    // Bnullkeep under ZSHTOK_SUBST (the flag `shtokenize`
4297                    // uses, so every `${~var}` pattern), and an UNtokenized
4298                    // raw `\X` arrives from zshrs's programmatic glob entry
4299                    // (`glob_path`) — the same raw form `patcomppiece`
4300                    // already accepts (pattern.rs, `b'\\'` arm). Recognising
4301                    // only Bnull split `a\~b*` at an ESCAPED tilde and turned
4302                    // the literal `~` into an exclusion, so `_path_files`'
4303                    // `tmp1=( $~tmp1 )` — whose patterns have the typed word
4304                    // quoted with QT_BACKSLASH_PATTERN (computil.c:5001) —
4305                    // matched nothing: `cp /etc/paths~or<TAB>` completed to
4306                    // nothing where zsh inserts `/etc/paths\~orig`.
4307                    Bnull | Bnullkeep | '\\' => {
4308                        cur.push(c);
4309                        if k + 1 < cv.len() {
4310                            cur.push(cv[k + 1]);
4311                            k += 2;
4312                            continue;
4313                        }
4314                    }
4315                    '[' => {
4316                        bd += 1;
4317                        cur.push(c);
4318                    }
4319                    ']' => {
4320                        if bd > 0 {
4321                            bd -= 1;
4322                        }
4323                        cur.push(c);
4324                    }
4325                    '(' | '\u{e188}' => {
4326                        pd += 1;
4327                        cur.push(c);
4328                    }
4329                    ')' | '\u{e18a}' => {
4330                        if pd > 0 {
4331                            pd -= 1;
4332                        }
4333                        cur.push(c);
4334                    }
4335                    '~' | '\u{e198}' if bd == 0 && pd == 0 && (c == '\u{e198}' || raw_tilde_is_op) => {
4336                        parts.push(std::mem::take(&mut cur));
4337                    }
4338                    _ => cur.push(c),
4339                }
4340                k += 1;
4341            }
4342            parts.push(cur);
4343            // Only split when there's a non-empty main pattern AND at
4344            // least one exclusion (a leading `~` is home-dir, handled by
4345            // filesub before glob — never reaches here as an exclusion).
4346            if parts.len() > 1 && !parts[0].is_empty() {
4347                let main = parts.remove(0);
4348                (main, parts)
4349            } else {
4350                // No usable split — restore the original pattern verbatim
4351                // (the `~` separators dropped during the scan).
4352                (cv.iter().collect(), Vec::new())
4353            }
4354        } else {
4355            (pat, Vec::new())
4356        };
4357
4358    // c:Src/glob.c:1843-1854 — `if (!q || errflag) { ... return; }`. When
4359    // the qualifier parser already emitted a diagnostic (e.g. "number
4360    // expected" from qgetnum at c:832) and set errflag, abort glob
4361    // expansion entirely rather than continuing to scan with a partial
4362    // qualifier set. Without this gate, `?(a)` (where `(a)` is an
4363    // invalid qualifier) error-prints "number expected" then continues
4364    // to expand `?` against the dir, emitting matches alongside the
4365    // error — bug #549.
4366    if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::SeqCst)
4367        & crate::ported::zsh_h::ERRFLAG_ERROR
4368        != 0
4369    {
4370        return Vec::new();
4371    }
4372
4373    // Now check wildcards on the qualifier-stripped pattern. A pure
4374    // literal with a qualifier (`name(.)`) still needs to enter the
4375    // scanner so the qualifier filter can run against the literal name.
4376    // haswilds scans TOKENIZED strings (c:Src/glob.c:1230 runs on the
4377    // lexer-tokenized word); glob_path receives both tokenized (zglob)
4378    // and untokenized (expand_glob fast paths) patterns, so tokenize a
4379    // local copy for the check — existing token chars pass through
4380    // zshtokenize untouched, untokenized metachars gain their tokens.
4381    //
4382    // Only an UNTOKENIZED pattern may be tokenized, though. C never runs
4383    // zshtokenize over a word that reaches zglob (c:1221 `ostr` is the
4384    // lexer's tokenized word after prefork): a plain `*` in it is a QUOTED
4385    // or SUBSTITUTED character and must stay literal. Tokenizing it again
4386    // made `x=a; print $x\**` (node `a*<Star>`) match `ab` as well as
4387    // `a*b`. See `word_is_tokenized` above.
4388    let mut pat_tok = pat.clone();
4389    if !word_is_tokenized {
4390        tokenize(&mut pat_tok); // c:Src/glob.c:3548
4391    }
4392    if !haswilds(&pat_tok) && state.qualifiers.is_none() {
4393        return vec![pattern.to_string()];
4394    }
4395
4396    // c:glob() — `gd.gf_noglobdots = !isset(GLOBDOTS)` (plus the `(D)`
4397    // qualifier). parsecomplist reads this from CURGLOBDATA to compile
4398    // PAT_NOGLD into each component, which is what makes pattry reject
4399    // leading-dot files (pattern.rs:2890). Set it before parsing so the
4400    // faithful flag-driven dot filtering replaces the old manual skip.
4401    {
4402        let globdots = glob_isset(GLOBDOTS)
4403            || state
4404                .qualifiers
4405                .as_ref()
4406                .map(|qf| qf.globdots)
4407                .unwrap_or(false);
4408        let mut gd = CURGLOBDATA.lock().unwrap_or_else(|e| e.into_inner());
4409        gd.gf_noglobdots = if globdots { 0 } else { 1 };
4410    }
4411
4412    // c:parsepat (glob.c:809-820) — init pathbuf for absolute vs relative,
4413    // strip the leading `/`, then parse into a `complist` and run the
4414    // faithful `scanner`. parsecomplist consumes TOKENIZED input (it tests
4415    // for the `Star` token and hands segments to patcompile), so feed it
4416    // `pat_tok` (already tokenized above; tokenize is idempotent).
4417    // c:Src/glob.c:796 — `patcompstart();` is the FIRST statement of
4418    // parsepat, before the pathbuf setup and parsecomplist below. It
4419    // runs patcompcharsset (c:464), which fills `zpc_special` from
4420    // `zpc_chars` and marks the option-disabled entries. This inlined
4421    // copy of parsepat's body (c:809-820) omitted it, so `zpc_special`
4422    // stayed all-zero on this path and every gate that compares against
4423    // it was dead: `parsecomplist`'s `(dir/)#` branch (c:746-748) tests
4424    // `*instr == zpc_special[ZPC_INPAR]` and `*str ==
4425    // zpc_special[ZPC_HASH]`, both of which compared Inpar/Pound against
4426    // NUL and never fired. `(sub/)#end` therefore matched only the
4427    // zero-repetition case (`end`), not `sub/end`, `sub/sub/end`, …
4428    crate::ported::pattern::patcompstart(); // c:796
4429
4430    // c:797-807 — `Check for initial globbing flags, so that they don't form
4431    // a bogus path component.` This inlined copy of parsepat started at c:809
4432    // and skipped the flag strip entirely, which is load-bearing on an
4433    // ABSOLUTE pattern: the `/` split below then made the leading `(#…)` a
4434    // path component of its own and looked for a directory literally named
4435    // `(#a1)`. Measured against zsh 5.9.2:
4436    //
4437    //   % zsh   -f -c 'setopt extendedglob; print -r -- (#i)/ETC/hosts'
4438    //   /etc/hosts
4439    //   % zshrs (before)
4440    //   zsh:1: no matches found: (#i)/ETC/hosts
4441    //
4442    // A RELATIVE pattern was unaffected — the flags stay glued to the first
4443    // component, where `patcompile`'s own `(#…)` hoist loop picks them up —
4444    // so this only ever showed on an absolute path, and every pattern-level
4445    // flag was affected, not just `(#a)`: `(#i)`, `(#l)`, `(#b)`, `(#m)`.
4446    //
4447    // `patgetglobflags` writes the `patglobflags` atomic, and `patcompile`'s
4448    // PAT_FILE branch (pattern.rs:615) deliberately leaves it alone, so the
4449    // flags reach EVERY path component exactly as c:568 has them.
4450    let mut globflags_bad = false;
4451    {
4452        let cv: Vec<char> = pat_tok.chars().collect();
4453        // c:801-803 tests `zpc_special[ZPC_INPAR]` / `[ZPC_HASH]` /
4454        // `[ZPC_KSH_AT]`. zshrs seeds those slots with RAW ASCII
4455        // (pattern.rs:443-457) while `pat_tok` here is TOKENIZED, so accept
4456        // either spelling of the pair — but only while the slot is live:
4457        // patcompcharsset (c:Src/pattern.c:480-483) sets ZPC_HASH to Marker
4458        // without EXTENDEDGLOB, ZPC_INPAR under SHGLOB and ZPC_KSH_AT without
4459        // KSHGLOB, and then `(#i)a` is an ordinary group holding a literal
4460        // `#`, not a flag block.
4461        let (inpar_live, hash_live, ksh_at_live) = {
4462            let sp = crate::ported::pattern::zpc_special
4463                .lock()
4464                .unwrap_or_else(|e| e.into_inner());
4465            let marker = crate::ported::zsh_h::Marker as u32 as u8;
4466            (
4467                sp[crate::ported::zsh_h::ZPC_INPAR as usize] != marker,
4468                sp[crate::ported::zsh_h::ZPC_HASH as usize] != marker,
4469                sp[crate::ported::zsh_h::ZPC_KSH_AT as usize] != marker,
4470            )
4471        };
4472        let is_inpar = |c: char| inpar_live && (c == '(' || c == crate::ported::zsh_h::Inpar);
4473        let is_hash = |c: char| hash_live && (c == '#' || c == crate::ported::zsh_h::Pound);
4474        let is_outpar = |c: char| c == ')' || c == crate::ported::zsh_h::Outpar;
4475        // c:804 — `str += (*str == Inpar) ? 2 : 3;`. The Rust
4476        // `patgetglobflags` consumes the leading `(#` itself (pattern.rs:1927
4477        // `s.starts_with("(#")`), so only the ksh `@` is skipped here.
4478        let skip = if cv.len() >= 3 && ksh_at_live && cv[0] == '@' && is_inpar(cv[1]) && is_hash(cv[2]) {
4479            Some(1) // c:802-803 ksh `@(#…)`
4480        } else if cv.len() >= 2 && is_inpar(cv[0]) && is_hash(cv[1]) {
4481            Some(0) // c:801 plain `(#…)`
4482        } else {
4483            None
4484        };
4485        if let Some(skip) = skip {
4486            // Feed `patgetglobflags` the flag block in the ASCII spelling it
4487            // parses, bounded at the closing paren so the rest of the path
4488            // is never rewritten. Its loop terminates on a literal `)`
4489            // (pattern.rs:1937) and its `consumed` is the BYTE offset just
4490            // past it (pattern.rs:2049).
4491            let end = cv[skip..].iter().position(|&c| is_outpar(c));
4492            let ascii: Option<String> = end.map(|e| {
4493                cv[skip..=skip + e]
4494                    .iter()
4495                    .map(|&c| match c {
4496                        x if is_inpar(x) => '(',
4497                        x if is_hash(x) => '#',
4498                        x if is_outpar(x) => ')',
4499                        other => other,
4500                    })
4501                    .collect()
4502            });
4503            match ascii
4504                .as_deref()
4505                .and_then(crate::ported::pattern::patgetglobflags)
4506            {
4507                Some((_bits, _assertp, consumed)) => {
4508                    // c:804-806 — `str` now points past the flag block.
4509                    let nchars = ascii.as_deref().unwrap_or("")[..consumed].chars().count();
4510                    pat_tok = cv[skip + nchars..].iter().collect();
4511                }
4512                // c:805-806 — `if (!patgetglobflags(&str, …)) return NULL;`.
4513                // parsepat's NULL is the same failure `parsecomplist` reports
4514                // below, so route it to the one error path (c:1842-1854).
4515                None => globflags_bad = true,
4516            }
4517        }
4518    }
4519
4520    state.pathbufcwd = 0; // c:812 — `DPUTS(pathbufcwd, ...)` invariant
4521    let parse_src = if let Some(rest) = pat_tok.strip_prefix('/') {
4522        // c:813-816 — absolute path.
4523        state.pathbuf.push('/');
4524        state.pathpos = 1;
4525        rest.to_string()
4526    } else {
4527        // c:817-818 — relative to pwd.
4528        pat_tok.clone()
4529    };
4530    // c:424 — C's match name is the pathbuf text plus the entry name, so a
4531    // typed `./` / `.//` / `././` prefix survives verbatim. The scanner only
4532    // adds a `./` of its own when the pathbuf is EMPTY at depth 0, which a
4533    // pattern starting with `./` never has.
4534    state.dot_prefix_verbatim = parse_src.starts_with("./");
4535    let complist = if globflags_bad {
4536        None // c:806 — parsepat returned NULL before parsecomplist ran
4537    } else {
4538        parsecomplist(&parse_src)
4539    };
4540    if let Some(complist) = complist {
4541        scanner(state, Some(&complist), 0, false);
4542    } else {
4543        // c:Src/glob.c:1842-1854 — `q = parsepat(str); if (!q || errflag)`.
4544        // This is the `!q` half: the pattern failed to COMPILE (e.g. an
4545        // unclosed `[` character class — patcomppiece returns -1 at
4546        // pattern.rs:1687). C then either treats the word as a literal
4547        // string (when `unset(BADPATTERN)`) or aborts with
4548        // `zerr("bad pattern: %s", ostr)`. zshrs previously dropped the
4549        // `None` silently, so `echo abc[def` fell through to the
4550        // no-matches / literal-under-NO_NOMATCH path instead of the
4551        // compile diagnostic — and BADPATTERN was ignored entirely.
4552        // (The `errflag` half — a diagnostic already emitted by the
4553        // qualifier parser — is handled by the gate at glob.rs:3770.)
4554        if !isset(crate::ported::zsh_h::BADPATTERN) {
4555            // c:1846-1848 —
4556            //     if (!nountok)
4557            //         untokenize(ostr);
4558            //     insertlinknode(list, node, ostr);
4559            // `ostr` is the TOKENIZED word, so C untokenizes it before
4560            // handing it back as an ordinary literal string. Returning
4561            // `pattern` verbatim kept the Inbrack/Outbrack/Star token
4562            // bytes in the result and they were dropped downstream:
4563            // `unsetopt badpattern; print [a` printed `a`, not `[a`
4564            // (E01options.ztst:5).
4565            return vec![crate::ported::lex::untokenize(pattern)];
4566        }
4567        // c:1851-1852 — clear any stale error bit so zerr fires, then
4568        // emit. utils::zerr re-sets ERRFLAG_ERROR, which zglob's gate at
4569        // glob.rs:1541 reads to suppress the redundant "no matches found".
4570        errflag.fetch_and(
4571            !crate::ported::zsh_h::ERRFLAG_ERROR,
4572            std::sync::atomic::Ordering::SeqCst,
4573        );
4574        // c:1852 `zerr("bad pattern: %s", ostr)` — `ostr` is still
4575        // TOKENIZED here (Inbrack/Outbrack/Star/… ), and C renders the
4576        // `%s` through `nicezputs` (c:Src/utils.c:316), whose
4577        // sb_niceformat maps a token byte back through `ztokens`
4578        // (c:Src/utils.c niceztrlen: `if (itok(c)) c = ztokens[c - Pound]`).
4579        // Formatting `pattern` with Rust's `{}` skipped that step, so
4580        // `echo [[` reported `bad pattern: \u{e191}\u{e191}` (printed as two
4581        // invisible C1 bytes) where zsh prints `bad pattern: [[`.
4582        zerr(&format!(
4583            "bad pattern: {}",
4584            crate::ported::utils::nicedup(pattern, 0)
4585        ));
4586        // No lastval write here: C's zglob leaves it alone. The CALLER owns the
4587        // status — c:Src/exec.c:3702-3705 `if (errflag) { lastval = 1; goto err; }`
4588        // after an argv globlist, but c:4218-4222 (a typeset assignment array)
4589        // only `break`s, so `typeset a=([y z])` keeps the previous status.
4590        return Vec::new();
4591    }
4592
4593    // c:Src/glob.c — apply top-level `~B` exclusions to the full matched
4594    // paths (split off above). A match is dropped if its emitted path
4595    // matches ANY exclusion pattern (`*` matches `/` here, matching zsh's
4596    // flat-string exclusion semantics).
4597    let verbatim = state.dot_prefix_verbatim;
4598    if !glob_exclusions.is_empty() {
4599        let eg = glob_isset(EXTENDEDGLOB);
4600        let cg = glob_isset(CASEGLOB);
4601        state.matches.retain(|m| {
4602            // c:424 — verbatim pathbuf text when the pattern starts with `./`.
4603            let p = if verbatim { m.path.to_string_lossy().into_owned() } else { glob_emit_path(&m.path) };
4604            !glob_exclusions.iter().any(|ex| matchpat(ex, &p, eg, cg))
4605        });
4606    }
4607
4608    // c:1680 — populate GS_EXEC sort keys before sorting. Evaluate each
4609    // `oe:…:` / `o+…` code per match with $REPLY = the match name, and
4610    // capture the resulting $REPLY as that match's sort string (gmatchcmp
4611    // reads gmatch.sort_strings[idx]). Exec sort changes ORDER only — the
4612    // original name is still what's emitted.
4613    let sort_codes: Vec<String> = state
4614        .qualifiers
4615        .as_ref()
4616        .map(|q| q.sort_exec.clone())
4617        .unwrap_or_default();
4618    if !sort_codes.is_empty() {
4619        for m in state.matches.iter_mut() {
4620            // c:424 — verbatim pathbuf text when the pattern starts with `./`.
4621            let name = if verbatim { m.path.to_string_lossy().into_owned() } else { glob_emit_path(&m.path) };
4622            for code in &sort_codes {
4623                crate::ported::params::setsparam("REPLY", &name);
4624                // c:1936 — `execode(prog, 1, 0, "globsort");`: the sort code
4625                // runs with `globsort` appended to $zsh_eval_context, as the
4626                // `(e…)` qualifier runs with `globqual` (c:3930).
4627                let globsort_ctx = crate::ported::exec::EvalContextFrame::push("globsort");
4628                let _ = crate::ported::exec::execute_script(code);
4629                drop(globsort_ctx);
4630                let key = crate::ported::params::getsparam("REPLY").unwrap_or_else(|| name.clone());
4631                m.sort_strings.push(key);
4632            }
4633        }
4634    }
4635
4636    // c:Src/glob.c:518 — the `Y` limit belongs to the SCAN, not to the result:
4637    //     scanner(q->next, shortcircuit);
4638    //     if (shortcircuit && shortcircuit == matchct)
4639    //         return;
4640    // The scanner simply stops once N matches exist, so the survivors are the
4641    // first N *found*, and only then does c:1868's sort run over them. Applying
4642    // the limit after sorting (as apply_selection used to) keeps the first N by
4643    // SORT ORDER instead — a different set: `*(.Y2on)` must be the two files
4644    // the scan reached, re-ordered by name, not the two alphabetically-first.
4645    //
4646    // This port collects every match before sorting, so the faithful place for
4647    // the truncation is here — after collection, before sort_matches. The
4648    // `[first,last]` subscript stays in apply_selection: c:1868's sort precedes
4649    // it, so THAT one really is a post-sort selection.
4650    //
4651    // Zero means "no limit" (c:518's leading `shortcircuit &&`), hence `> 0`.
4652    if let Some(n) = state.qualifiers.as_ref().and_then(|q| q.short_circuit) {
4653        if n > 0 {
4654            let n = n as usize;
4655            if state.matches.len() > n {
4656                state.matches.truncate(n); // c:518
4657            }
4658        }
4659    }
4660
4661    // Sort results
4662    let unsorted = sort_matches(state);
4663
4664    // c:481 — `++matchct` per insert(): the count zglob's no-match
4665    // dispatch reads (c:1871-1886) is taken BEFORE the `[first,last]`
4666    // subscript (c:1981-1989) trims the list, so `*([100])` over a
4667    // non-empty directory expands to nothing rather than tripping
4668    // NOMATCH. Publish it for the caller (`expand_glob`), which only
4669    // sees the trimmed Vec.
4670    CURGLOBDATA
4671        .lock()
4672        .unwrap_or_else(|e| e.into_inner())
4673        .matchct = state.matches.len() as i32;
4674
4675    // Apply subscript selection
4676    apply_selection(state, unsorted);
4677
4678    // Extract filenames. Mark-dirs / list-types come from EITHER
4679    // the canonical global option store OR the parsed `(M)`/`(T)`
4680    // qualifier on this glob. Direct port of zsh/Src/glob.c:355,372
4681    // — output marker emission consults the per-glob `gf_markdirs`
4682    // / `gf_listtypes` flags which the qualifier parser at
4683    // glob.c:1557-1566 sets.
4684    let mark_dirs = state
4685        .qualifiers
4686        .as_ref()
4687        .and_then(|q| q.mark_dirs)
4688        .unwrap_or_else(|| glob_isset(MARKDIRS)); // c:1255 / c:1558
4689    // c:1562-1566 — `gf_listtypes` is set ONLY by the `T` glob
4690    // qualifier (`*(T)`), NOT by the global LISTTYPES option.
4691    // LISTTYPES is the completion-listing option (see man zshoptions
4692    // "LIST_TYPES") and doesn't affect glob output. Including the
4693    // option here mis-decorated every executable-file glob with a
4694    // trailing `*` and every directory with `/` for users with the
4695    // default `setopt listtypes` ON.
4696    let list_types = state
4697        .qualifiers
4698        .as_ref()
4699        .map(|q| q.list_types)
4700        .unwrap_or(false);
4701    let mark_follow = state.qualifiers.as_ref().is_some_and(|q| q.mark_follow);
4702    let colon_mods = state.qualifiers.as_ref().and_then(|q| q.colon_mods.clone());
4703    // c:Src/glob.c — a pattern ending in `/` ("trailing slash" syntax)
4704    // forces matches to be directories AND preserves the slash in the
4705    // output. zsh's parsepat treats `pat/` as `pat` + an empty trailing
4706    // component; the scanner restricts to directories and the emitter
4707    // appends `/`. The Rust port's parser short-circuits the empty
4708    // trailing component, so we filter + re-add the suffix here at emit
4709    // time. `/` is never a glob metachar so a literal trailing-slash
4710    // check on the raw pattern is sufficient.
4711    // Use the qualifier-stripped `pat` for the trailing-slash check.
4712    // For `*/(N)` the original `pattern` ends with `)`, so the check
4713    // on `pattern` returned false and the trailing-slash directory-
4714    // filter was skipped — `*/(N)` then matched every file, not just
4715    // directories. The qualifier-stripped `pat` is `*/` which still
4716    // carries the trailing `/`.
4717    let trailing_slash = pat.ends_with('/') && pat.len() > 1;
4718    // c:Src/glob.c — preserve user-typed `./` / `../` prefix in
4719    // output. glob_emit_path strips CurDir uniformly so the
4720    // leading `./` would be lost. Detect the source prefix here
4721    // and re-prepend after emit. Same `pat` (post-qualifier) — the
4722    // prefix is on the path part, not the qualifier.
4723    let leading_dot_prefix: &str = if pat.starts_with("../") {
4724        "../"
4725    } else if pat.starts_with("./") {
4726        "./"
4727    } else {
4728        ""
4729    };
4730    let mut results: Vec<String> = state
4731        .matches
4732        .iter()
4733        .filter(|m| !trailing_slash || fs::metadata(&m.path).map(|md| md.is_dir()).unwrap_or(false))
4734        .map(|m| {
4735            // c:424 — verbatim pathbuf text when the pattern starts with `./`.
4736            let mut s = if verbatim { m.path.to_string_lossy().into_owned() } else { glob_emit_path(&m.path) };
4737            if !verbatim
4738                && !leading_dot_prefix.is_empty()
4739                && !s.starts_with(leading_dot_prefix)
4740                && !s.starts_with('/')
4741            {
4742                s = format!("{}{}", leading_dot_prefix, s);
4743            }
4744            if trailing_slash && !s.ends_with('/') {
4745                s.push('/');
4746            }
4747            if mark_dirs || list_types {
4748                // c:358-371 — lstat; under gf_follow a symlink is re-stat'ed,
4749                // falling back to the lstat.
4750                if let Ok(lmeta) = fs::symlink_metadata(&m.path) {
4751                    let meta = if mark_follow && lmeta.file_type().is_symlink() {
4752                        fs::metadata(&m.path).unwrap_or(lmeta)
4753                    } else {
4754                        lmeta
4755                    };
4756                    let ch = file_type(meta.mode());
4757                    if list_types || (mark_dirs && ch == '/') {
4758                        // c:372-376 — `*t++ = file_type(mode);` appended
4759                        // unconditionally: a `*/` match already ends in the
4760                        // path's `/`, so zsh prints `d1//` under MARK_DIRS.
4761                        s.push(ch);
4762                    }
4763                }
4764            }
4765            // Apply colon modifiers AFTER mark/list-type appendage —
4766            // zsh applies them last in glob.c:432 modify() per emitted
4767            // node, so `(M:t)` would mark THEN tail (effectively just
4768            // tail since the slash is gone). Faithful order.
4769            if let Some(ref m) = colon_mods {
4770                // c:430-432 — `modify(&news, &mod, 1);` with no check of
4771                // where `mod` stopped: an unknown modifier ends the chain
4772                // silently (`*(:u:x:t)` is `*(:u)`); only paramsubst reports.
4773                s = crate::ported::subst::modify(&s, m).0; // c:432 — tail ignored
4774            }
4775            s
4776        })
4777        .collect();
4778
4779    // c:421-426 — zsh applies the colon modifier in insert() (during
4780    // collection) BEFORE the match sort, so the default (name) sort
4781    // orders the MODIFIED names: `*(.:e)` on (apple.z,banana.a,cherry.m)
4782    // sorts the extensions → (a,m,z), not name order (z,a,m). The Rust
4783    // pipeline modifies at emit (after sort_matches), so re-sort the
4784    // modified results here — but ONLY for the default name sort; an
4785    // explicit `o`/`O` keeps its stat-keyed order with modified output.
4786    let explicit_sort = state
4787        .qualifiers
4788        .as_ref()
4789        .map(|q| !q.sorts.is_empty())
4790        .unwrap_or(false);
4791    if colon_mods.is_some() && !explicit_sort {
4792        // c:945 — that sort compares the keys with `zstrcmp` (Src/sort.c:191),
4793        // i.e. `strcoll` under the current locale, and honours gf_numsort.
4794        // Rust's `Vec::sort` is BYTEWISE, so `*.txt(:r)` came back as
4795        // `Bravo Charlie alpha delta` while the very same directory globbed
4796        // as plain `*.txt` — which goes through `sort_matches` → `gmatchcmp`
4797        // → `zstrcmp` — gave zsh's `alpha Bravo Charlie delta`. The two
4798        // orderings inside one shell disagreed; only the modified one was
4799        // wrong.
4800        let numeric = state
4801            .qualifiers
4802            .as_ref()
4803            .and_then(|q| q.numsort)
4804            .unwrap_or_else(|| glob_isset(NUMERICGLOBSORT)); // c:1258/1575
4805        results.sort_by(|a, b| {
4806            crate::ported::sort::zstrcmp(
4807                a,
4808                b,
4809                if numeric {
4810                    crate::zsh_h::SORTIT_NUMERICALLY as u32
4811                } else {
4812                    0
4813                },
4814            )
4815        });
4816    }
4817
4818    // c:1744-1756 / insert_glob_match c:1430 — `P:word:` (gf_pre_words)
4819    // emits `word` as a separate element BEFORE each match; `^P:word:`
4820    // (gf_post_words) emits it AFTER. Applied last, after sort/modifiers.
4821    let (pre_words, post_words) = state
4822        .qualifiers
4823        .as_ref()
4824        .map(|q| (q.pre_words.clone(), q.post_words.clone()))
4825        .unwrap_or_default();
4826    if !pre_words.is_empty() || !post_words.is_empty() {
4827        let mut expanded =
4828            Vec::with_capacity(results.len() * (1 + pre_words.len() + post_words.len()));
4829        for s in results {
4830            expanded.extend(pre_words.iter().cloned());
4831            expanded.push(s);
4832            expanded.extend(post_words.iter().cloned());
4833        }
4834        results = expanded;
4835    }
4836
4837    // c:Src/glob.c:1872-1888 — no-match path. Return Vec::new() so
4838    // the caller (vm_helper.rs::expand_glob) drives the
4839    // NULLGLOB / NOMATCH / literal-fallback policy. Previously
4840    // this pushed `pattern.to_string()` on no-match, which made
4841    // `expand_glob` see a non-empty result and skip the NOMATCH
4842    // check entirely. zsh's `setopt nomatch` is on by default, so
4843    // `echo /never_real/*` should emit "no matches found" and
4844    // exit 1 — not silently print the literal. Parity bug #13
4845    // recurrence. Per-glob `(N)` qualifier (nullglob_per_qual)
4846    // and global NULLGLOB are now consulted in expand_glob too.
4847    let _ = state.qualifiers.as_ref().map(|q| q.nullglob);
4848    results
4849}
4850
4851// `sort_matches_by_type` deleted — dead code (no callers anywhere
4852// in the tree). The sort path lives in `GlobMatch::compare` which
4853// dispatches off the canonical `GS_*` tp bits exactly like C's
4854// `gmatchcmp()` at glob.c:936.
4855
4856// ============================================================================
4857// Pattern matching with replacement (from glob.c getmatch family)
4858// ============================================================================
4859
4860// `MatchFlags` deleted — Rust-only bool-bag wrapper. C uses bare
4861// `int fl` with `SUB_*` bits from `Src/zsh.h:1981+` (mirrored at
4862// `zsh_h.rs:2463+`). Callers now pass an `i32` and test bits.
4863
4864// Trimmed local `struct imatchdata` deleted — it was a fake 5-field
4865// duplicate (str/pattern/match_start/match_end/replacement) of the
4866// canonical `Src/zsh.h:1740` port that already lives in
4867// `zsh_h.rs::imatchdata` (mstr/mlen/ustr/ulen/flags/replstr/repllist).
4868// `get_match_ret` now uses the canonical struct; see its port above.
4869
4870/// Strip a trailing `(qual)` block from a glob pattern.
4871/// **RUST-ONLY** — C glob.c handles qualifier parsing inline in
4872/// `parsepat` (c:791) as it builds the Complist. Move to that
4873/// shape when porting parsepat for real.
4874pub fn parse_qualifiers(pattern: &str) -> (String, Option<qualifier_set>) {
4875    // RUST-ONLY
4876    // c:Src/glob.c:1797-1844 — merge the qualifier group parsed EARLIER
4877    // (`old`, the right-hand one) into the group just parsed to its left
4878    // (`newq`). Shared by the tokenized and the untokenized qualifier scans
4879    // in [`parse_qualifiers`], which both peel groups off the end of the word.
4880    let merge_qualifier_groups = |old: qualifier_set, newq: qualifier_set| -> qualifier_set {
4881        // c:1797-1842 — merge the previous group into the new
4882        // (left-hand) one. The AND-chains are distributed over
4883        // every pair of alternatives, new set first ("we are
4884        // searching for sets of qualifiers from the right");
4885        // with no `or` on either side that is plain chaining.
4886        let alts = |a: &QualArena| -> Vec<Vec<qual>> {
4887            let mut out = Vec::new();
4888            let mut h = a.head;
4889            while let Some(hi) = h {
4890                let mut chain = Vec::new();
4891                let mut n = Some(hi);
4892                while let Some(ni) = n {
4893                    chain.push(a.nodes[ni].clone());
4894                    n = a.nodes[ni].next;
4895                }
4896                out.push(chain);
4897                h = a.nodes[hi].or;
4898            }
4899            out
4900        };
4901        let mut q = newq;
4902        let (na, oa) = (alts(&q.quals), alts(&old.quals));
4903        if !oa.is_empty() {
4904            let mut arena = QualArena::new();
4905            let mut prev_head: Option<usize> = None;
4906            let news = if na.is_empty() { vec![Vec::new()] } else { na };
4907            for qn in &news {
4908                for qo in &oa {
4909                    let base = arena.nodes.len();
4910                    for (k, node) in qn.iter().chain(qo.iter()).enumerate() {
4911                        let mut node = node.clone();
4912                        node.or = None;
4913                        node.next = Some(base + k + 1);
4914                        arena.nodes.push(node);
4915                    }
4916                    let last = arena.nodes.len() - 1;
4917                    arena.nodes[last].next = None;
4918                    match prev_head {
4919                        None => arena.head = Some(base),
4920                        Some(p) => arena.nodes[p].or = Some(base),
4921                    }
4922                    prev_head = Some(base);
4923                }
4924            }
4925            q.quals = arena;
4926        }
4927        // The enum mirror of the same distribution.
4928        if !old.alternatives.is_empty() {
4929            let news = if q.alternatives.is_empty() {
4930                vec![Vec::new()]
4931            } else {
4932                std::mem::take(&mut q.alternatives)
4933            };
4934            q.alternatives = news
4935                .iter()
4936                .flat_map(|n| old.alternatives.iter().map(move |o| [n.clone(), o.clone()].concat()))
4937                .collect();
4938        }
4939        // gf_sortlist (c:1702) fills in parse order: the right
4940        // group's specs first. GS_EXEC entries carry an index
4941        // into `sort_exec`, shifted past the earlier ones.
4942        let shift = old.sort_exec.len() as i32;
4943        let new_sorts: Vec<i32> = q
4944            .sorts
4945            .iter()
4946            .map(|&t| if (t & GS_EXEC) != 0 { t + (shift << 16) } else { t })
4947            .collect();
4948        q.sorts = old.sorts.iter().copied().chain(new_sorts).collect();
4949        q.sort_exec = old.sort_exec.iter().cloned().chain(q.sort_exec).collect();
4950        // c:1336-1340 — `colonmod = dyncat(newcolonmod, colonmod)`.
4951        q.colon_mods = match (q.colon_mods.take(), old.colon_mods) {
4952            (Some(n), Some(o)) => Some(n + &o),
4953            (n, o) => n.or(o),
4954        };
4955        // The gf_* globals are simply overwritten by the group
4956        // parsed later (the left one) when it sets them.
4957        q.pre_words = old.pre_words.into_iter().chain(q.pre_words).collect();
4958        q.post_words = old.post_words.into_iter().chain(q.post_words).collect();
4959        q.first = q.first.or(old.first);
4960        q.last = q.last.or(old.last);
4961        q.mark_dirs = q.mark_dirs.or(old.mark_dirs);
4962        q.numsort = q.numsort.or(old.numsort);
4963        q.short_circuit = q.short_circuit.or(old.short_circuit);
4964        q.list_types |= old.list_types;
4965        q.mark_follow |= old.mark_follow;
4966        q.nullglob |= old.nullglob;
4967        q.globdots |= old.globdots;
4968        q
4969    };
4970    // c:Src/glob.c:1158-1202 `checkglobqual` — C decides where the
4971    // qualifier block starts by testing the LEXER TOKENS `Outpar` /
4972    // `Inpar` (c:1163 `if (str[sl - 1] != Outpar) return 0;`, c:1170
4973    // `*s != Inpar`, c:1189), never the raw ASCII bytes. That is the
4974    // whole mechanism by which a QUOTED metachar stays literal: in
4975    // `*(.e['[[ $REPLY == a* ]]'])` the body's `]` bytes are raw ASCII
4976    // while the qualifier's real closer is `Outbrack`, so the scan
4977    // walks straight past the quoted ones.
4978    //
4979    // zshrs's glob layer is reachable BOTH ways: `zglob` (c:1221) and
4980    // the fusevm word path hand it the tokenized word, while
4981    // programmatic callers (compsys `glob_path`, `builtin.rs`,
4982    // `subst.rs`) hand it an untokenized string. Pick the scanner off
4983    // the terminator so both keep working — token mode is the faithful
4984    // c:1163 test, raw mode is the untokenized-caller fallback that
4985    // reconstructs "was this escaped" from backslashes instead.
4986    if pattern.ends_with(crate::ported::zsh_h::Outpar) {
4987        // c:1260-1261 — `while (!nobareglob || (isset(EXTENDEDGLOB) &&
4988        // !zpc_disables[ZPC_HASH]))`: qualifier groups are peeled off the
4989        // END of the word one at a time. After the first, `nobareglob = 1`
4990        // (c:1302), so only further explicit `(#q…)` groups are taken —
4991        // `*(#q/)(#q^F)` applies BOTH (empty directories); the port used
4992        // to keep the last group and glob the rest as pattern text.
4993        let mut word: &str = pattern;
4994        let mut acc: Option<qualifier_set> = None;
4995        // c:1226 `nobareglob = !isset(BAREGLOBQUAL);`
4996        let mut nobareglob = i32::from(!glob_isset(BAREGLOBQUAL));
4997        let hash_live = || {
4998            glob_isset(EXTENDEDGLOB)
4999                && crate::ported::pattern::zpc_disables
5000                    .lock()
5001                    .unwrap_or_else(|e| e.into_inner())[crate::ported::zsh_h::ZPC_HASH as usize]
5002                    == 0
5003        };
5004        while nobareglob == 0 || hash_live() {
5005            if !word.ends_with(crate::ported::zsh_h::Outpar) {
5006                break; // c:1163 — no further qualifier block
5007            }
5008            // c:1296-1310 — checkglobqual, then cut the block out at the
5009            // returned `Inpar` (`*s++ = 0`), stepping over the `#q` when
5010            // qualsfound == 2 (c:1310 `s += 2`).
5011            let cv: Vec<char> = word.chars().collect();
5012            let mut sp: Option<usize> = None;
5013            let qualsfound = checkglobqual(&cv, cv.len() as i32, nobareglob, &mut sp);
5014            let start = match sp {
5015                Some(v) if qualsfound != 0 => v,
5016                _ => break, // c:1297-1298
5017            };
5018            nobareglob = 1; // c:1302
5019            let body_start = if qualsfound == 2 { start + 3 } else { start + 1 };
5020            let qual_content: String = cv[body_start..cv.len() - 1].iter().collect();
5021            let byte_start: usize = cv[..start].iter().map(|c| c.len_utf8()).sum();
5022            let newq = parse_qualifier_string(&qual_content);
5023            word = &word[..byte_start];
5024            let failed = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed) != 0;
5025            acc = Some(match acc {
5026                None => newq, // c:1843-1844 `else if (newquals) quals = newquals;`
5027                Some(old) => merge_qualifier_groups(old, newq),
5028            });
5029            if failed {
5030                break; // c:1786-1788 — errflag: zglob returns
5031            }
5032        }
5033        return match acc {
5034            Some(qs) => (word.to_string(), Some(qs)),
5035            None => (pattern.to_string(), None),
5036        };
5037    }
5038
5039    // Untokenized fallback, for the programmatic `glob_path` callers
5040    // that never went through the lexer (compsys, `builtin.rs`,
5041    // `subst.rs`). No `Bnull` tokens are present to carry escaping, so
5042    // it is recovered from literal backslashes instead.
5043    //
5044    // Same c:1260-1302 loop as the tokenized scan above: groups are peeled
5045    // off the END one at a time, and after the first only an explicit
5046    // `(#q…)` is taken. Taking just the last group globbed `*(#qom)(-/)`
5047    // (what `_path_files -/` builds for `file-sort`, sh:178) with the
5048    // `(#qom)` left in the pattern text, where it sorts nothing.
5049    // One step of the untokenized qualifier scan in [`parse_qualifiers`]: strip
5050    // the LAST `(…)` group of `pattern` when it is a qualifier block. With
5051    // `nobareglob` set (every group after the first, c:1302) only an explicit
5052    // `(#q…)` qualifies.
5053    let parse_qualifiers_raw = |pattern: &str, nobareglob_in: bool| -> (String, Option<qualifier_set>) {
5054        if !pattern.ends_with(')') {
5055            return (pattern.to_string(), None);
5056        }
5057
5058        let bytes = pattern.as_bytes();
5059        // Backslash-escaped trailing `)` is literal — no qualifier block
5060        // present. C's glob path handles this via Bnull preservation in
5061        // the lexer; zshrs's path arrives here with `\)` for quoted
5062        // close-parens (e.g. assoc-value `'\)'`). Without the gate, the
5063        // qualifier-extraction walk below treats the literal `)` as the
5064        // qualifier terminator.
5065        let last_paren_escaped = {
5066            let mut bs = 0usize;
5067            let mut j = bytes.len() - 1;
5068            while j > 0 && bytes[j - 1] == b'\\' {
5069                bs += 1;
5070                j -= 1;
5071            }
5072            bs % 2 == 1
5073        };
5074        if last_paren_escaped {
5075            return (pattern.to_string(), None);
5076        }
5077
5078        // Find matching open paren
5079        let mut depth = 0;
5080        let mut qual_start = None;
5081
5082        // c:Src/glob.c haswilds backslash handling — a `\(` / `\)` is a
5083        // literal paren, not a qualifier delimiter. Track which paren
5084        // bytes are escaped by a preceding `\` (counting consecutive
5085        // backslashes — even count means the paren is unescaped, odd
5086        // count means escaped). Without this gate, `\(*\)` is mis-parsed
5087        // as `(*\)` qualifier + empty pattern prefix, sending `*\` to
5088        // the qualifier-letter switch which emits "unknown file
5089        // attribute: \".
5090        let is_escaped = |idx: usize| -> bool {
5091            let mut bs = 0usize;
5092            let mut j = idx;
5093            while j > 0 && bytes[j - 1] == b'\\' {
5094                bs += 1;
5095                j -= 1;
5096            }
5097            bs % 2 == 1
5098        };
5099
5100        for i in (0..bytes.len()).rev() {
5101            match bytes[i] {
5102                b')' => {
5103                    if !is_escaped(i) {
5104                        depth += 1;
5105                    }
5106                }
5107                b'(' => {
5108                    if !is_escaped(i) {
5109                        depth -= 1;
5110                        if depth == 0 {
5111                            qual_start = Some(i);
5112                            break;
5113                        }
5114                    }
5115                }
5116                _ => {}
5117            }
5118        }
5119
5120        let start = match qual_start {
5121            Some(s) => s,
5122            None => return (pattern.to_string(), None),
5123        };
5124
5125        // Check for (#q...) explicit qualifier syntax.
5126        let qual_str = &pattern[start + 1..pattern.len() - 1];
5127        // c:Src/glob.c:1192-1197 — `if (isset(EXTENDEDGLOB) &&
5128        // !zpc_disables[ZPC_HASH] && s[1] == Pound) { if (s[2] != 'q')
5129        // return 0; ret = 2; }`. The leading `#` inside `(...)` is ONLY
5130        // special under EXTENDEDGLOB: `(#q...)` is the explicit glob
5131        // qualifier, and `(#X...)` for any other X is an inline pattern flag
5132        // (passed through). Without EXTENDEDGLOB the `#` is not special at
5133        // all — the `(...)` is a bare qualifier group and a leading `#` is an
5134        // (unknown) attribute char, so `*(#q.)` errors `unknown file
5135        // attribute: #` rather than silently applying the `.` qualifier.
5136        let (is_explicit, qual_content) = if glob_isset(EXTENDEDGLOB) && qual_str.starts_with('#') {
5137            if let Some(after) = qual_str.strip_prefix("#q") {
5138                (true, after) // c:1195 ret = 2 — explicit glob qualifier
5139            } else {
5140                // c:1194 `if (s[2] != 'q') return 0;` — inline pattern flag
5141                // (`(#i)`, `(#c1,2)`, `(#a)`, `(#l)`, `(#s)`, `(#e)`, `(#m)`).
5142                return (pattern.to_string(), None);
5143            }
5144        } else if glob_isset(BAREGLOBQUAL) && !nobareglob_in {
5145            (false, qual_str)
5146        } else {
5147            return (pattern.to_string(), None);
5148        };
5149
5150        // c:1169-1186 — alternatives or exclusions, not qualifiers: an inner
5151        // `)` (a nested group) falls through to the `Bar` arm, and both set
5152        // `nobareglob` unless `disable -p '|'`; `~` only does under
5153        // EXTENDEDGLOB (unless disabled). Only an explicit `(#q…)` survives
5154        // that (c:1192-1197). A `$~v` value lands here untokenized, but C
5155        // shtokenized it (c:Src/subst.c:822/830), so its parens are the
5156        // Inpar/Outpar tokens this scan stands for: zsh answers
5157        // `v='*(e:reply=($REPLY):)'; print $~v` with "no matches found".
5158        let nobareglob = {
5159            let disp = crate::ported::pattern::zpc_disables
5160                .lock()
5161                .unwrap_or_else(|e| e.into_inner());
5162            (start + 1..bytes.len() - 1).any(|k| {
5163                !is_escaped(k)
5164                    && match bytes[k] {
5165                        b')' | b'|' => disp[crate::ported::zsh_h::ZPC_BAR as usize] == 0,
5166                        b'~' => {
5167                            glob_isset(EXTENDEDGLOB)
5168                                && disp[crate::ported::zsh_h::ZPC_TILDE as usize] == 0
5169                        }
5170                        _ => false,
5171                    }
5172            })
5173        };
5174        if !is_explicit && nobareglob {
5175            return (pattern.to_string(), None);
5176        }
5177
5178        // Parse the qualifiers
5179        let qs = parse_qualifier_string(qual_content);
5180        (pattern[..start].to_string(), Some(qs))
5181    };
5182    let hash_live = || {
5183        glob_isset(EXTENDEDGLOB)
5184            && crate::ported::pattern::zpc_disables
5185                .lock()
5186                .unwrap_or_else(|e| e.into_inner())[crate::ported::zsh_h::ZPC_HASH as usize]
5187                == 0
5188    };
5189    let mut word = pattern.to_string();
5190    let mut acc: Option<qualifier_set> = None;
5191    let mut nobareglob = false; // c:1226, gated per group inside the raw scan
5192    while !nobareglob || hash_live() {
5193        let (rest, newq) = match parse_qualifiers_raw(&word, nobareglob) {
5194            (rest, Some(q)) => (rest, q),
5195            _ => break, // c:1297-1298
5196        };
5197        nobareglob = true; // c:1302
5198        word = rest;
5199        acc = Some(match acc {
5200            None => newq, // c:1843-1844
5201            Some(old) => merge_qualifier_groups(old, newq),
5202        });
5203        if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed) != 0 {
5204            break; // c:1786-1788
5205        }
5206    }
5207    match acc {
5208        Some(qs) => (word, Some(qs)),
5209        None => (pattern.to_string(), None),
5210    }
5211}
5212
5213/// Parse the body of a `(...)` qualifier block into a qualifier_set.
5214/// **RUST-ONLY** — see header on `parse_qualifiers`.
5215fn parse_qualifier_string(s: &str) -> qualifier_set {
5216    // RUST-ONLY
5217    let mut qs = qualifier_set::default();
5218    // c:Src/glob.c:1312-1314 — `for (ptr = s; *ptr; ptr++) if (*ptr ==
5219    // Dash) *ptr = '-';`. The qualifier body arrives in LEXER-TOKENIZED
5220    // form, and C normalises the one token it rewrites wholesale before
5221    // the switch runs. Every OTHER token that can appear here (`Star`,
5222    // `Hat`, `Equals`, `Inbrack`) keeps its token spelling and is
5223    // matched by a dedicated `case` in the switch below — see c:1343,
5224    // c:1358, c:1385, c:1723. Untokenized callers reach here with no
5225    // tokens at all, so this pass is a no-op for them.
5226    let s: String = s
5227        .chars()
5228        .map(|c| {
5229            if c == crate::ported::zsh_h::Dash {
5230                '-'
5231            } else {
5232                c
5233            }
5234        })
5235        .collect();
5236    let mut chars = s.chars().peekable();
5237    let mut negated = false;
5238    let mut follow = false;
5239
5240    while let Some(c) = chars.next() {
5241        match c {
5242            // c:1343-1344 `case Hat: case '^':`
5243            '^' | crate::ported::zsh_h::Hat => {
5244                // c:1346 — `sense ^= 1`. Keep the running `negated` for
5245                // the flag qualifiers (N/D/M/T) that read it, AND emit a
5246                // marker so the per-qualifier sense reaches the list walk.
5247                negated = !negated;
5248                qs.qualifiers.push(qualifier::ToggleSense);
5249            }
5250            // c:1348-1352 — `case '-': case Dash: sense ^= 2;`
5251            '-' => {
5252                follow = !follow;
5253                qs.qualifiers.push(qualifier::ToggleFollow);
5254            }
5255            ',' => {
5256                // Start new alternative
5257                if !qs.qualifiers.is_empty() {
5258                    qs.alternatives.push(std::mem::take(&mut qs.qualifiers));
5259                }
5260                negated = false;
5261                follow = false;
5262            }
5263            ':' => {
5264                // Colon modifiers - rest of string
5265                let rest: String = chars.collect();
5266                qs.colon_mods = Some(format!(":{}", rest));
5267                break;
5268            }
5269            // File type qualifiers
5270            '/' => qs.qualifiers.push(qualifier::IsDirectory),
5271            '.' => qs.qualifiers.push(qualifier::IsRegular),
5272            '@' => qs.qualifiers.push(qualifier::IsSymlink),
5273            // c:1358-1359 `case Equals: case '=':`
5274            '=' | crate::ported::zsh_h::Equals => qs.qualifiers.push(qualifier::IsSocket),
5275            'p' => qs.qualifiers.push(qualifier::IsFifo),
5276            '%' => match chars.peek() {
5277                Some('b') => {
5278                    chars.next();
5279                    qs.qualifiers.push(qualifier::IsBlockDev);
5280                }
5281                Some('c') => {
5282                    chars.next();
5283                    qs.qualifiers.push(qualifier::IsCharDev);
5284                }
5285                _ => qs.qualifiers.push(qualifier::IsDevice),
5286            },
5287            // c:1385 `case Star:` — C matches ONLY the token, since an
5288            // unquoted `*` in a qualifier list always reaches the glob
5289            // layer tokenized. The raw spelling is kept alongside it for
5290            // the untokenized programmatic `glob_path` callers.
5291            '*' | crate::ported::zsh_h::Star => qs.qualifiers.push(qualifier::IsExecutable),
5292            // Permission qualifiers
5293            'r' => qs.qualifiers.push(qualifier::Readable),
5294            'w' => qs.qualifiers.push(qualifier::Writable),
5295            'x' => qs.qualifiers.push(qualifier::Executable),
5296            'R' => qs.qualifiers.push(qualifier::WorldReadable),
5297            'W' => qs.qualifiers.push(qualifier::WorldWritable),
5298            'X' => qs.qualifiers.push(qualifier::WorldExecutable),
5299            'A' => qs.qualifiers.push(qualifier::GroupReadable),
5300            'I' => qs.qualifiers.push(qualifier::GroupWritable),
5301            'E' => qs.qualifiers.push(qualifier::GroupExecutable),
5302            's' => qs.qualifiers.push(qualifier::Setuid),
5303            'S' => qs.qualifiers.push(qualifier::Setgid),
5304            't' => qs.qualifiers.push(qualifier::Sticky),
5305            // c:Src/glob.c case 'f' — file-mode qualifier accepts
5306            // an octal mode (`f755`) or chmod-style symbolic spec
5307            // (`fu+x`/`fg-w`/`fa=r`) and matches files whose mode
5308            // bits agree. Multiple delimited specs can be chained
5309            // by separator character. Direct port of qgetmodespec
5310            // dispatch + mode-bit accumulator in qgetmodespec.
5311            // c:1548-1552 — `case 'f': func = qualmodeflags; data =
5312            // qgetmodespec(&s);`. qgetmodespec owns the whole spec: the
5313            // bare-vs-delimited split, who letters, `,`-chained clauses, and
5314            // leaves the cursor where C's `*s = p` does.
5315            'f' => {
5316                let rest: String = chars.clone().collect();
5317                match qgetmodespec(&rest) {
5318                    Some((data, r)) => {
5319                        for _ in 0..rest[..rest.len() - r.len()].chars().count() {
5320                            chars.next();
5321                        }
5322                        // c:3819 — `y = mod & 07777, n = mod >> 12`.
5323                        qs.qualifiers.push(qualifier::Mode {
5324                            yes: (data & 0o7777) as u32,
5325                            no: (data >> 12) as u32,
5326                        });
5327                    }
5328                    None => {
5329                        // qgetmodespec already said "invalid mode
5330                        // specification"; errflag aborts the glob.
5331                        crate::ported::utils::errflag.fetch_or(
5332                            crate::ported::utils::ERRFLAG_ERROR,
5333                            std::sync::atomic::Ordering::Relaxed,
5334                        );
5335                        return qs;
5336                    }
5337                }
5338            }
5339            // Ownership
5340            'U' => qs.qualifiers.push(qualifier::OwnedByEuid),
5341            'G' => qs.qualifiers.push(qualifier::OwnedByEgid),
5342            'u' => {
5343                let uid = parse_uid_gid(&mut chars, false);
5344                qs.qualifiers.push(qualifier::OwnedByUid(uid));
5345            }
5346            'g' => {
5347                let gid = parse_uid_gid(&mut chars, true);
5348                qs.qualifiers.push(qualifier::OwnedByGid(gid));
5349            }
5350            // Size
5351            'L' => {
5352                let (unit, op, val) = parse_size_spec(&mut chars);
5353                qs.qualifiers.push(qualifier::Size {
5354                    value: val,
5355                    unit,
5356                    op,
5357                });
5358            }
5359            // Link count
5360            'l' => {
5361                let (op, val) = parse_range_spec(&mut chars);
5362                qs.qualifiers.push(qualifier::Links { value: val, op });
5363            }
5364            // c:Src/glob.c:1445-1449 — `case 'd': func = qualdev;
5365            // data = qgetnum(&s);`. Matches files by device number
5366            // (`qualdev`, c:3688, is `buf->st_dev == dv`). Both the
5367            // `qualifier::Device` variant and the `qualdev` matcher were
5368            // already ported and wired at glob.rs:4894 — only this parser arm
5369            // was missing, so `*(d5)` never reached them and a bare `*(d)`
5370            // reported "unknown file attribute: d" where zsh says
5371            // "number expected".
5372            //
5373            // c:826-834 qgetnum takes PLAIN digits — deliberately NOT
5374            // `parse_range_spec`, which would also accept the `+`/`-` range
5375            // operators that `l`/`L`/`m` use and that C does not allow here.
5376            'd' => {
5377                let mut num = String::new();
5378                while let Some(&pc) = chars.peek() {
5379                    if pc.is_ascii_digit() {
5380                        num.push(pc);
5381                        chars.next();
5382                    } else {
5383                        break;
5384                    }
5385                }
5386                if num.is_empty() {
5387                    // c:830-833 — `if (!idigit(**s)) { zerr("number expected");
5388                    // return 0; }`. Matches the sibling error arms in this
5389                    // parser (`Y`, `f`): report, set errflag, stop.
5390                    crate::ported::utils::zerr("number expected"); // c:832
5391                    crate::ported::utils::errflag.fetch_or(
5392                        crate::ported::utils::ERRFLAG_ERROR,
5393                        std::sync::atomic::Ordering::Relaxed,
5394                    );
5395                    return qs;
5396                }
5397                qs.qualifiers
5398                    .push(qualifier::Device(num.parse().unwrap_or(0))); // c:1448
5399            }
5400            // Times
5401            'a' => {
5402                let (unit, op, val) = parse_time_spec(&mut chars);
5403                qs.qualifiers.push(qualifier::Atime {
5404                    value: val as i64,
5405                    unit,
5406                    op,
5407                });
5408            }
5409            'm' => {
5410                let (unit, op, val) = parse_time_spec(&mut chars);
5411                qs.qualifiers.push(qualifier::Mtime {
5412                    value: val as i64,
5413                    unit,
5414                    op,
5415                });
5416            }
5417            'c' => {
5418                let (unit, op, val) = parse_time_spec(&mut chars);
5419                qs.qualifiers.push(qualifier::Ctime {
5420                    value: val as i64,
5421                    unit,
5422                    op,
5423                });
5424            }
5425            // Sort qualifier — `o<key>` ascending / `O<key>`
5426            // descending. Mirrors the parser arm in zsh's glob.c
5427            // that builds `struct globsort` entries (tp = GS_* OR
5428            // GS_DESC, optionally shifted by GS_SHIFT for the
5429            // follow-links variant).
5430            'o' | 'O' => {
5431                let desc = c == 'O';
5432                if let Some(&sc) = chars.peek() {
5433                    let key: i32 = match sc {
5434                        'n' => {
5435                            chars.next();
5436                            GS_NAME
5437                        }
5438                        'L' => {
5439                            chars.next();
5440                            GS_SIZE
5441                        }
5442                        'l' => {
5443                            chars.next();
5444                            GS_LINKS
5445                        }
5446                        'a' => {
5447                            chars.next();
5448                            GS_ATIME
5449                        }
5450                        'm' => {
5451                            chars.next();
5452                            GS_MTIME
5453                        }
5454                        'c' => {
5455                            chars.next();
5456                            GS_CTIME
5457                        }
5458                        'd' => {
5459                            chars.next();
5460                            GS_DEPTH
5461                        }
5462                        'N' => {
5463                            chars.next();
5464                            GS_NONE
5465                        }
5466                        'e' | '+' => {
5467                            // c:1672-1687 — GS_EXEC sort: `glob_exec_string`
5468                            // parses the eval code (delimited `e:code:` or
5469                            // identifier `+name`); store it indexed and pack
5470                            // the index into the sort entry. The code is
5471                            // consumed HERE (not left for the main loop), so
5472                            // `oe:…:` is a SORT (eval = key, original name
5473                            // emitted), not a separate eval qualifier.
5474                            let is_plus = sc == '+';
5475                            chars.next(); // consume 'e' / '+'
5476                            let rest: String = chars.clone().collect();
5477                            match glob_exec_string(&rest, is_plus) {
5478                                Some((code, consumed)) => {
5479                                    for _ in 0..rest[..consumed].chars().count() {
5480                                        chars.next();
5481                                    }
5482                                    let idx = qs.sort_exec.len();
5483                                    qs.sort_exec.push(code);
5484                                    GS_EXEC | ((idx as i32) << 16)
5485                                }
5486                                None => {
5487                                    // glob_exec_string already emitted the
5488                                    // diagnostic + set errflag; bail.
5489                                    return qs;
5490                                }
5491                            }
5492                        }
5493                        _ => {
5494                            // c:1689 — `default: zerr("unknown sort
5495                            // specifier"); restore_globstate(saved);
5496                            // return;`. Was `_ => GS_NAME`, which silently
5497                            // accepted invalid keys (and reparsed them as
5498                            // separate qualifiers, e.g. `oD` → o + D-dotglob
5499                            // instead of erroring like zsh).
5500                            crate::ported::utils::zerr("unknown sort specifier");
5501                            crate::ported::utils::errflag.fetch_or(
5502                                crate::ported::utils::ERRFLAG_ERROR,
5503                                std::sync::atomic::Ordering::Relaxed,
5504                            );
5505                            return qs;
5506                        }
5507                    };
5508                    let shifted = if follow && (key & GS_NORMAL) != 0 {
5509                        key << GS_SHIFT // c:1692-1694
5510                    } else {
5511                        key
5512                    };
5513                    // c:1695-1702 — a sort key may not be repeated:
5514                    //     if (t != GS_EXEC) {
5515                    //         if (gf_sorts & t) {
5516                    //             zerr("doubled sort specifier");
5517                    //             restore_globstate(saved);
5518                    //             return;
5519                    //         }
5520                    //     }
5521                    //     gf_sorts |= t;
5522                    // GS_EXEC is exempt — `oe:…:` may appear repeatedly, each
5523                    // with its own code. This check was missing entirely, so
5524                    // `*(.onon)` and `*(.onOn)` silently sorted where zsh
5525                    // fails. Note the test is on the SHIFTED key, so `oL` and
5526                    // `OL` collide (same key, different direction) while `oL`
5527                    // and `oLm` do not (the follow variant shifts).
5528                    if (shifted & GS_EXEC) == 0
5529                        && qs.sorts.iter().any(|s| (s & !GS_DESC) == shifted)
5530                    {
5531                        crate::ported::utils::zerr("doubled sort specifier"); // c:1697
5532                        crate::ported::utils::errflag.fetch_or(
5533                            crate::ported::utils::ERRFLAG_ERROR,
5534                            std::sync::atomic::Ordering::Relaxed,
5535                        );
5536                        return qs; // c:1699
5537                    }
5538                    // c:1658-1662 — `if (gf_nsorts == MAX_SORTS) { zerr("too
5539                    // many glob sort specifiers"); ... return; }`. MAX_SORTS is
5540                    // 12 (c:164). Unreachable in practice now that the doubled
5541                    // check above rejects repeats, but it is C's bound and the
5542                    // list is otherwise unbounded.
5543                    if qs.sorts.len() == MAX_SORTS {
5544                        crate::ported::utils::zerr("too many glob sort specifiers"); // c:1659
5545                        crate::ported::utils::errflag.fetch_or(
5546                            crate::ported::utils::ERRFLAG_ERROR,
5547                            std::sync::atomic::Ordering::Relaxed,
5548                        );
5549                        return qs; // c:1661
5550                    }
5551                    let tp = shifted | (if desc { GS_DESC } else { 0 });
5552                    qs.sorts.push(tp);
5553                } else {
5554                    // c:1666-1690 — C does not guard on "is there a next
5555                    // character": it reads `switch (*s)` unconditionally, so an
5556                    // `o`/`O` at the very end of the qualifier list sees the
5557                    // terminating `)` and lands on `default: zerr("unknown sort
5558                    // specifier")`. This port skipped the whole block when the
5559                    // iterator was exhausted, so a trailing `o` was silently
5560                    // ignored and `*(No)` listed every match where zsh fails.
5561                    // A NON-trailing bad spec (`*(ozN)`) already errored — it
5562                    // reaches the `_` arm above — which is why only the
5563                    // end-of-list form survived.
5564                    crate::ported::utils::zerr("unknown sort specifier"); // c:1688
5565                    crate::ported::utils::errflag.fetch_or(
5566                        crate::ported::utils::ERRFLAG_ERROR,
5567                        std::sync::atomic::Ordering::Relaxed,
5568                    );
5569                    return qs; // c:1690
5570                }
5571            }
5572            // Flags
5573            // c:1567-1569 — `case 'N': gf_nullglob = !(sense & 1);`.
5574            // Per-glob nullglob: empty result on no-match, no error.
5575            'N' => qs.nullglob = !negated,
5576            'D' => qs.globdots = !negated,
5577            // c:1575-1577 — `case 'n': gf_numsort = !(sense & 1)`.
5578            // Standalone `(n)` enables numeric sort for this glob (the
5579            // `^n` toggle disables it). Consumed in sort_matches.
5580            'n' => qs.numsort = Some(!negated),
5581            // (M) / (T) — set per-qualifier-set flags. glob.c:1557-1566:
5582            //   case 'M': gf_markdirs = !(sense & 1);  break;
5583            //   case 'T': gf_listtypes = !(sense & 1); break;
5584            // `sense & 1` = the `^`-toggle bit. zshrs's parser tracks
5585            // `negated`; mirror by `!negated`. Read at output-emit
5586            // time to mark dirs / list types like coreutils ls -F.
5587            // c:1557-1566 — `if ((gf_markdirs = !(sense & 1))) gf_follow = sense & 2;`
5588            'M' => {
5589                qs.mark_dirs = Some(!negated);
5590                if !negated {
5591                    qs.mark_follow = follow;
5592                }
5593            }
5594            'T' => {
5595                qs.list_types = !negated;
5596                if qs.list_types {
5597                    qs.mark_follow = follow;
5598                }
5599            }
5600            'F' => qs.qualifiers.push(qualifier::NonEmptyDir),
5601            // c:1744-1756 — `P:word:` adds `word` to gf_pre_words (or
5602            // gf_post_words under `^`), emitted as a separate element
5603            // before/after each match. `glob_exec_string` parses the
5604            // delimited word.
5605            'P' => {
5606                let rest: String = chars.clone().collect();
5607                if let Some((word, consumed)) = glob_exec_string(&rest, false) {
5608                    for _ in 0..rest[..consumed].chars().count() {
5609                        chars.next();
5610                    }
5611                    if negated {
5612                        qs.post_words.push(word); // c:1750 gf_post_words
5613                    } else {
5614                        qs.pre_words.push(word); // c:1750 gf_pre_words
5615                    }
5616                }
5617            }
5618            // Subscript — c:1723-1741 `case '[': case Inbrack:`
5619            //
5620            //     char *os = --s;
5621            //     struct value v;
5622            //     v.scanflags = SCANPM_WANTVALS;
5623            //     v.pm = NULL; v.start = 0; v.end = -1;
5624            //     v.valflags = 0; v.arr = NULL;
5625            //     if (getindex(&s, &v, 0) || s == os) {
5626            //         zerr("invalid subscript");
5627            //         restore_globstate(saved);
5628            //         return;
5629            //     }
5630            //     first = v.start;
5631            //     end = v.end;
5632            //
5633            // C hands the bracket straight to the ONE subscript parser
5634            // the shell has (`getindex`, Src/params.c:2001), so a glob
5635            // qualifier subscript gets the same unterminated-bracket
5636            // diagnostic and the same arithmetic evaluation of its
5637            // operands as `${a[...]}`. This port called a private
5638            // RUST-ONLY `parse_subscript` that split on `,` and
5639            // `str::parse`d each half, dropping the qualifier whenever
5640            // either half was not a decimal literal — so `*(N[)`,
5641            // `*(N[a])` and `*(N[1,])` all listed every match at rc=0
5642            // where zsh reports `invalid subscript`, an empty result
5643            // and `bad math expression: empty string` respectively.
5644            '[' | crate::ported::zsh_h::Inbrack => {
5645                // c:1725 `char *os = --s;` — rewind onto the bracket;
5646                // getindex consumes it itself (c:2006 `*s++ = '['`).
5647                let os: String = std::iter::once(c).chain(chars.clone()).collect();
5648                // c:1727-1732 — the Value getindex fills in.
5649                let mut v = crate::ported::zsh_h::value {
5650                    pm: None,                                                // c:1728
5651                    arr: Vec::new(),                                         // c:1732
5652                    scanflags: crate::ported::zsh_h::SCANPM_WANTVALS as i32, // c:1727
5653                    valflags: 0,                                             // c:1731
5654                    start: 0,                                                // c:1729
5655                    end: -1,                                                 // c:1730
5656                };
5657                let mut s: &str = &os;
5658                let rc = crate::ported::params::getindex(&mut s, &mut v, 0); // c:1735
5659                if rc != 0 || s.len() == os.len() {
5660                    // c:1735 `|| s == os`
5661                    crate::ported::utils::zerr("invalid subscript"); // c:1736
5662                    crate::ported::utils::errflag.fetch_or(
5663                        crate::ported::utils::ERRFLAG_ERROR,
5664                        std::sync::atomic::Ordering::Relaxed,
5665                    );
5666                    return qs; // c:1738
5667                }
5668                // Re-sync the qualifier walker onto the text getindex
5669                // stopped at. `os` carries the already-consumed `[`, so
5670                // step the iterator over the remainder only.
5671                let consumed = os.len() - s.len();
5672                for _ in 1..os[..consumed].chars().count() {
5673                    chars.next();
5674                }
5675                // c:1785-1788 — `if (errflag) { restore_globstate(saved);
5676                // return; }`. getindex returns 0 after `mathevalarg`
5677                // rejects an empty operand (`*(N[1,])`), so the abort has
5678                // to come off errflag exactly as it does in C.
5679                if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed) != 0 {
5680                    return qs; // c:1787
5681                }
5682                qs.first = Some(v.start); // c:1740
5683                qs.last = Some(v.end); // c:1741
5684            }
5685            // c:Src/glob.c:1579-1595 `case 'Y'` — short-circuit:
5686            // limit matches to at most N. Reads a numeric argument
5687            // immediately following the `Y`. Bug #41 in docs/BUGS.md.
5688            'Y' => {
5689                // c:1579-1594:
5690                //     const char *s_saved = s;
5691                //     shortcircuit = !(sense & 1);
5692                //     if (shortcircuit) {
5693                //         data = qgetnum(&s);
5694                //         if ((shortcircuit = data) != data) {
5695                //             /* Integer overflow */
5696                //             zerr("value too big: Y%s", s_saved);
5697                //             restore_globstate(saved);
5698                //             return;
5699                //         }
5700                //     }
5701                // `data` is a zlong and `shortcircuit` an int, so the
5702                // assignment-then-compare is a 64→32 TRUNCATION test: a limit
5703                // that does not survive the narrowing is fatal. This parsed
5704                // straight into i32 and silently dropped the qualifier when
5705                // that failed, so `*(Y2147483648)` listed every match where
5706                // zsh errors — the limit was simply ignored.
5707                //
5708                // s_saved is the text AFTER the `Y`, running to the end of the
5709                // qualifier list, which is why the message carries any trailing
5710                // qualifiers too: `*(.NY99…)` reports `value too big: Y99…`
5711                // exactly as spelled.
5712                let s_saved: String = chars.clone().collect(); // c:1582
5713                                                               // c:1582-1583 — `shortcircuit = !(sense & 1); if
5714                                                               // (shortcircuit) { … }`. The NEGATED spelling `^Y` clears
5715                                                               // the limit and consumes NO argument, so the qgetnum call
5716                                                               // (and its "number expected" diagnostic) is skipped
5717                                                               // entirely — `*(Y1^Y)` is legal. `Some(0)` is the same
5718                                                               // "no limit" the apply sites read via their `n > 0` test,
5719                                                               // matching c:518's leading `shortcircuit &&`.
5720                if negated {
5721                    qs.short_circuit = Some(0); // c:1582
5722                    continue;
5723                }
5724                let mut num_str = String::new();
5725                while let Some(&pc) = chars.peek() {
5726                    if pc.is_ascii_digit() {
5727                        num_str.push(pc);
5728                        chars.next();
5729                    } else {
5730                        break;
5731                    }
5732                }
5733                // c:830-833 — `qgetnum` opens with `if (!idigit(**s)) {
5734                // zerr("number expected"); return 0; }`. A bare `*(Y)` is
5735                // therefore a hard error that aborts the glob, not a
5736                // silently-ignored qualifier; the `d` arm below already
5737                // spells this out the same way.
5738                if num_str.is_empty() {
5739                    crate::ported::utils::zerr("number expected"); // c:832
5740                    crate::ported::utils::errflag.fetch_or(
5741                        crate::ported::utils::ERRFLAG_ERROR,
5742                        std::sync::atomic::Ordering::Relaxed,
5743                    );
5744                    return qs; // c:833 (`return 0` → caller aborts on errflag)
5745                }
5746                // c:1586 — qgetnum is `while (idigit(**s)) v = v * 10 + …`,
5747                // with no overflow check of its own; it simply wraps. Mirror
5748                // that rather than failing the parse, so the truncation test
5749                // below is what decides, exactly as in C.
5750                let mut data: i64 = 0;
5751                for ch in num_str.chars() {
5752                    data = data
5753                        .wrapping_mul(10)
5754                        .wrapping_add((ch as i64) - ('0' as i64));
5755                }
5756                let sc = data as i32; // c:1587 `shortcircuit = data`
5757                if sc as i64 != data {
5758                    // c:1587 `!= data`
5759                    crate::ported::utils::zerr(&format!("value too big: Y{s_saved}")); // c:1589
5760                    crate::ported::utils::errflag.fetch_or(
5761                        crate::ported::utils::ERRFLAG_ERROR,
5762                        std::sync::atomic::Ordering::Relaxed,
5763                    );
5764                    return qs; // c:1591
5765                }
5766                qs.short_circuit = Some(sc);
5767            }
5768            // c:Src/glob.c:1599-1620 `case 'e'` — `(e:CODE:)` shell-
5769            // eval qualifier. The body is delimited by the char
5770            // immediately following 'e' (matched at both ends).
5771            // Common forms in zsh scripts: `(e:'[[ -L $REPLY ]]':)`
5772            // for symlink filter, etc. The body runs against each
5773            // candidate path with $REPLY set; the file is kept iff
5774            // the body returns 0. zshrs's qualifier::Eval variant
5775            // was defined but had no parser arm — was rejected as
5776            // "unknown file attribute: e". Bug #469.
5777            // c:1707-1720 — `case '+': case 'e':` —
5778            //     tt = glob_exec_string(&s);
5779            //     if (tt == NULL) { data = 0; }
5780            //     else { func = qualsheval; sdata = tt; }
5781            // glob_exec_string reads the `+IDENT` or the delimited `e:CODE:`
5782            // form (get_strarg bracket pairing, c:Src/subst.c:1366-1391) and
5783            // UNTOKENIZES it (c:1108), so an unquoted `e:REPLY=x:` runs
5784            // `REPLY=x` rather than a word carrying an Equals token.
5785            'e' | '+' => {
5786                let rest: String = chars.clone().collect();
5787                match glob_exec_string(&rest, c == '+') {
5788                    Some((code, consumed)) => {
5789                        // c:1110-1113 — `*sp = tt + plus;`
5790                        for _ in 0..rest[..consumed].chars().count() {
5791                            chars.next();
5792                        }
5793                        qs.qualifiers.push(qualifier::Eval(code));
5794                    }
5795                    None => {
5796                        // glob_exec_string emitted the diagnostic; errflag
5797                        // aborts the glob (c:1786-1788).
5798                        crate::ported::utils::errflag.fetch_or(
5799                            crate::ported::utils::ERRFLAG_ERROR,
5800                            std::sync::atomic::Ordering::Relaxed,
5801                        );
5802                        return qs;
5803                    }
5804                }
5805            }
5806            // c:Src/glob.c:1758-1762 — `default: zerr("unknown file
5807            // attribute: %c", *s); restore_globstate(saved); return;`.
5808            // Bug #583: zshrs's `_ => {}` arm silently accepted any
5809            // unknown qualifier letter, so `*(z)` returned all files
5810            // rc=0 instead of erroring "unknown file attribute: z".
5811            // Mirror C's strict behavior. The exemption for ASCII
5812            // whitespace that used to sit here was justified as "C's
5813            // qualifier loop stops on those naturally via its `case ')'`
5814            // arm" — C has no such arm. c:1308-1310 NULs the trailing
5815            // `)` (`str[sl-1] = 0`) before the walk, so the loop
5816            // condition is `while (*s && !newcolonmod)` over the body
5817            // ALONE and a space reaches `default:` like any other
5818            // unhandled byte: zsh answers `*(N )` with `unknown file
5819            // attribute:  ` at rc=1 where the port listed every match at
5820            // rc=0. `\0` still terminates the C loop. The overwritten
5821            // closer is the Outpar TOKEN only; a QUOTED `)` left in the
5822            // body (`*(e"(true))")`, whose get_strarg stops at the first
5823            // `)`) is an ordinary byte and reaches `default:` too.
5824            ch if ch != '\0' => {
5825                // c:1758-1760 — `untokenize(--s); convchar_t attr =
5826                // unmeta_one(s, NULL); zerr("unknown file attribute:
5827                // %c", attr);`. C rewinds onto the offending char,
5828                // UNTOKENIZES it (Src/lex.c:2080 `ztokens[c - Pound]`)
5829                // and reports the source character. The port printed
5830                // the raw token instead, so `echo *(#q.)` without
5831                // EXTENDEDGLOB — where the lexer has already turned `#`
5832                // into Pound (U+e184) — said `unknown file attribute:
5833                // \u{e184}` where zsh says `unknown file attribute: #`.
5834                // `unmeta_one` is a no-op here: zshrs holds characters,
5835                // not metafied bytes.
5836                let attr = crate::ported::lex::untokenize_ztokens(&ch.to_string()); // c:1758
5837                crate::ported::utils::zerr(&format!("unknown file attribute: {}", attr));
5838                crate::ported::utils::errflag.fetch_or(
5839                    crate::ported::utils::ERRFLAG_ERROR,
5840                    std::sync::atomic::Ordering::Relaxed,
5841                );
5842                return qs;
5843            }
5844            _ => {}
5845        }
5846    }
5847
5848    if !qs.qualifiers.is_empty() {
5849        qs.alternatives.push(std::mem::take(&mut qs.qualifiers));
5850    }
5851
5852    // Build the faithful glob.c `struct qual` arena from `alternatives`
5853    // (additive: matching still uses the enum). Each alternative is an
5854    // AND-chain (`next` links); alternatives chain via `or`. ToggleSense
5855    // markers toggle a running per-node `sense` (c:1346). Each variant
5856    // maps to its leaf TestMatchFunc + data, mirroring check_single_
5857    // qualifier's dispatch (Src/glob.c:1343-1620 case → func/data).
5858    {
5859        let op_range = |o: char| -> i32 {
5860            match o {
5861                '<' => -1,
5862                '>' => 1,
5863                _ => 0,
5864            }
5865        };
5866        let mut arena = QualArena::new();
5867        let mut prev_alt_head: Option<usize> = None;
5868        for alt in &qs.alternatives {
5869            let mut sense = 0i32;
5870            let mut chain_tail: Option<usize> = None;
5871            let mut chain_head: Option<usize> = None;
5872            for q in alt {
5873                // (func, data, range, amc, units, sdata) — None = no node.
5874                let fields: Option<(TestMatchFunc, i64, i32, i32, i32, Option<String>)> = match q {
5875                    qualifier::ToggleSense => {
5876                        sense ^= 1; // c:1346
5877                        None
5878                    }
5879                    qualifier::ToggleFollow => {
5880                        sense ^= 2; // c:1351
5881                        None
5882                    }
5883                    qualifier::IsRegular => Some((qualisreg, 0, 0, 0, 0, None)),
5884                    qualifier::IsDirectory => Some((qualisdir, 0, 0, 0, 0, None)),
5885                    qualifier::IsSymlink => Some((qualislnk, 0, 0, 0, 0, None)),
5886                    qualifier::IsSocket => Some((qualissock, 0, 0, 0, 0, None)),
5887                    qualifier::IsFifo => Some((qualisfifo, 0, 0, 0, 0, None)),
5888                    qualifier::IsBlockDev => Some((qualisblk, 0, 0, 0, 0, None)),
5889                    qualifier::IsCharDev => Some((qualischr, 0, 0, 0, 0, None)),
5890                    qualifier::IsDevice => Some((qualisdev, 0, 0, 0, 0, None)),
5891                    qualifier::IsExecutable => Some((qualiscom, 0, 0, 0, 0, None)),
5892                    qualifier::Readable => Some((qualflags, 0o400, 0, 0, 0, None)),
5893                    qualifier::Writable => Some((qualflags, 0o200, 0, 0, 0, None)),
5894                    qualifier::Executable => Some((qualflags, 0o100, 0, 0, 0, None)),
5895                    qualifier::WorldReadable => Some((qualflags, 0o004, 0, 0, 0, None)),
5896                    qualifier::WorldWritable => Some((qualflags, 0o002, 0, 0, 0, None)),
5897                    qualifier::WorldExecutable => Some((qualflags, 0o001, 0, 0, 0, None)),
5898                    qualifier::GroupReadable => Some((qualflags, 0o040, 0, 0, 0, None)),
5899                    qualifier::GroupWritable => Some((qualflags, 0o020, 0, 0, 0, None)),
5900                    qualifier::GroupExecutable => Some((qualflags, 0o010, 0, 0, 0, None)),
5901                    qualifier::Setuid => Some((qualflags, 0o4000, 0, 0, 0, None)),
5902                    qualifier::Setgid => Some((qualflags, 0o2000, 0, 0, 0, None)),
5903                    qualifier::Sticky => Some((qualflags, 0o1000, 0, 0, 0, None)),
5904                    qualifier::OwnedByEuid => {
5905                        Some((qualuid, unsafe { libc::geteuid() } as i64, 0, 0, 0, None))
5906                    }
5907                    qualifier::OwnedByEgid => {
5908                        Some((qualgid, unsafe { libc::getegid() } as i64, 0, 0, 0, None))
5909                    }
5910                    qualifier::OwnedByUid(u) => Some((qualuid, *u as i64, 0, 0, 0, None)),
5911                    qualifier::OwnedByGid(g) => Some((qualgid, *g as i64, 0, 0, 0, None)),
5912                    qualifier::Size { value, unit, op } => {
5913                        Some((qualsize, *value as i64, op_range(*op), -1, *unit, None))
5914                    }
5915                    qualifier::Links { value, op } => {
5916                        Some((qualnlink, *value as i64, op_range(*op), 0, 0, None))
5917                    }
5918                    qualifier::Atime { value, unit, op } => {
5919                        Some((qualtime, *value, op_range(*op), 0, *unit, None))
5920                    }
5921                    qualifier::Mtime { value, unit, op } => {
5922                        Some((qualtime, *value, op_range(*op), 1, *unit, None))
5923                    }
5924                    qualifier::Ctime { value, unit, op } => {
5925                        Some((qualtime, *value, op_range(*op), 2, *unit, None))
5926                    }
5927                    qualifier::Mode { yes, no } => Some((
5928                        qualmodeflags,
5929                        (*yes as i64) | ((*no as i64) << 12),
5930                        0,
5931                        0,
5932                        0,
5933                        None,
5934                    )),
5935                    qualifier::Device(d) => Some((qualdev, *d as i64, 0, 0, 0, None)),
5936                    qualifier::NonEmptyDir => Some((qualnonemptydir, 0, 0, 0, 0, None)),
5937                    qualifier::Eval(code) => Some((qualsheval, 0, 0, 0, 0, Some(code.clone()))),
5938                };
5939                let Some((func, data, range, amc, units, sdata)) = fields else {
5940                    continue;
5941                };
5942                let idx = arena.alloc(); // c:1768 hcalloc
5943                {
5944                    let node = &mut arena.nodes[idx];
5945                    node.func = Some(func); // c:1774
5946                    node.data = data; // c:1776
5947                    node.sense = sense; // c:1775
5948                    node.range = range; // c:1778
5949                    node.units = units; // c:1779
5950                    node.amc = amc; // c:1780
5951                    node.sdata = sdata; // c:1777
5952                }
5953                if let Some(t) = chain_tail {
5954                    arena.nodes[t].next = Some(idx); // c:1770
5955                }
5956                chain_tail = Some(idx);
5957                if chain_head.is_none() {
5958                    chain_head = Some(idx);
5959                }
5960            }
5961            // Link this alternative's head into the or-chain (c:1324).
5962            if let Some(h) = chain_head {
5963                if let Some(p) = prev_alt_head {
5964                    arena.nodes[p].or = Some(h);
5965                }
5966                prev_alt_head = Some(h);
5967                if arena.head.is_none() {
5968                    arena.head = Some(h);
5969                }
5970            }
5971        }
5972        qs.quals = arena;
5973    }
5974
5975    qs
5976}
5977
5978/// Parse a numeric uid/gid or a delimited user/group name from a `u`/`g`
5979/// qualifier char stream. Port of the name-resolution arms of the `u`/`g`
5980/// qualifier cases at `Src/glob.c:1468-1535`:
5981/// `if (idigit(*s)) data = qgetnum(&s);` else `get_strarg`-delimited name
5982/// resolved through `getpwnam(3)` / `getgrnam(3)`. On an unknown name it
5983/// emits the C diagnostic (`zerr` sets errflag) and returns 0, matching
5984/// C's `data = 0;` arm. `is_group` selects getgrnam over getpwnam.
5985fn parse_uid_gid(chars: &mut std::iter::Peekable<std::str::Chars>, is_group: bool) -> u32 {
5986    // c:1468/1511 — `if (idigit(*s)) data = qgetnum(&s);`
5987    if chars.peek().map(|c| c.is_ascii_digit()).unwrap_or(false) {
5988        let mut num = String::new();
5989        while let Some(&c) = chars.peek() {
5990            if c.is_ascii_digit() {
5991                num.push(c);
5992                chars.next();
5993            } else {
5994                break;
5995            }
5996        }
5997        return num.parse().unwrap_or(0);
5998    }
5999    // c:1471-1505 — `tt = get_strarg(s, &arglen);` the char right after the
6000    // qualifier is the delimiter; the name runs to the next delimiter.
6001    let delim = match chars.next() {
6002        Some(d) => d,
6003        None => {
6004            // c:1481/1524 — `zerr("missing delimiter for 'u'/'g' glob qualifier")`.
6005            zerr(if is_group {
6006                "missing delimiter for 'g' glob qualifier"
6007            } else {
6008                "missing delimiter for 'u' glob qualifier"
6009            });
6010            return 0;
6011        }
6012    };
6013    // c:Src/subst.c:1366-1391 — `get_strarg` maps the four bracket families
6014    // to their closing partner (raw ASCII at c:1367-1378, tokenized at
6015    // c:1379-1390); every other delimiter closes itself (c:1391). Scanning
6016    // for a repeat of the OPENING char left the close bracket unconsumed,
6017    // so `*(u{0})` resolved the name "0}" instead of "0".
6018    let close_delim = match delim {
6019        '(' => ')',                                                      // c:1367-1369
6020        '[' => ']',                                                      // c:1370-1372
6021        '{' => '}',                                                      // c:1373-1375
6022        '<' => '>',                                                      // c:1376-1378
6023        crate::ported::zsh_h::Inpar => crate::ported::zsh_h::Outpar,     // c:1379-1381
6024        crate::ported::zsh_h::Inang => crate::ported::zsh_h::Outang,     // c:1382-1384
6025        crate::ported::zsh_h::Inbrace => crate::ported::zsh_h::Outbrace, // c:1385-1387
6026        crate::ported::zsh_h::Inbrack => crate::ported::zsh_h::Outbrack, // c:1388-1390
6027        _ => delim,                                                      // c:1391
6028    };
6029    let mut name = String::new();
6030    let mut closed = false;
6031    for c in chars.by_ref() {
6032        if c == close_delim {
6033            closed = true;
6034            break;
6035        }
6036        name.push(c);
6037    }
6038    // c:1477-1479 / c:1519-1521 — `if (!*tt)`: get_strarg ran off the end
6039    // without finding the closer, so `*(u+1)` (`+` opens a name) is a
6040    // missing delimiter, not a lookup of the user `1`.
6041    if !closed {
6042        zerr(if is_group {
6043            "missing delimiter for 'g' glob qualifier"
6044        } else {
6045            "missing delimiter for 'u' glob qualifier"
6046        });
6047        return 0;
6048    }
6049    // c:1488/1530 — resolve via getpwnam(3) / getgrnam(3).
6050    let cstr = match std::ffi::CString::new(name.as_bytes()) {
6051        Ok(c) => c,
6052        Err(_) => return 0,
6053    };
6054    unsafe {
6055        if is_group {
6056            let gr = libc::getgrnam(cstr.as_ptr()); // c:1530
6057            if !gr.is_null() {
6058                return (*gr).gr_gid; // c:1531
6059            }
6060            zerr("unknown group"); // c:1533
6061        } else {
6062            let pw = libc::getpwnam(cstr.as_ptr()); // c:1488
6063            if !pw.is_null() {
6064                return (*pw).pw_uid; // c:1489
6065            }
6066            // c:1491 — `zerr("unknown username '%s'", s + arglen);`
6067            zerr(&format!("unknown username '{}'", name));
6068        }
6069    }
6070    // c:1493/1535 — `data = 0;` after the unknown-name diagnostic.
6071    0
6072}
6073
6074/// Parse the unit/op/value tail of an `(L...)` size qualifier.
6075/// **RUST-ONLY** — C parses inline in parsepat; the size-unit
6076/// constants are `TT_BYTES`/`TT_KILOBYTES`/… at glob.c:128-133.
6077fn parse_size_spec(
6078    // RUST-ONLY
6079    chars: &mut std::iter::Peekable<std::str::Chars>,
6080) -> (i32, char, u64) {
6081    let unit: i32 = match chars.peek() {
6082        Some('p') | Some('P') => {
6083            chars.next();
6084            TT_POSIX_BLOCKS
6085        }
6086        Some('k') | Some('K') => {
6087            chars.next();
6088            TT_KILOBYTES
6089        }
6090        Some('m') | Some('M') => {
6091            chars.next();
6092            TT_MEGABYTES
6093        }
6094        Some('g') | Some('G') => {
6095            chars.next();
6096            TT_GIGABYTES
6097        }
6098        Some('t') | Some('T') => {
6099            chars.next();
6100            TT_TERABYTES
6101        }
6102        _ => TT_BYTES,
6103    };
6104    let (op, val) = parse_range_spec(chars);
6105    (unit, op, val)
6106}
6107
6108/// Parse the unit/op/value tail of an `(a/m/c...)` time qualifier.
6109///
6110/// !!! WARNING: RUST-ONLY HELPER — NO C COUNTERPART !!!
6111/// C parses this inline in `parsepat`; the time-unit constants are
6112/// `TT_SECONDS`/`TT_MINS`/… at c:Src/glob.c:121-126.
6113///
6114/// It was previously NAMED `schedgetfn` — a real and entirely unrelated C
6115/// function (`Src/sched.c:341`, the `$zsh_scheduled_events` getfn) — which
6116/// made `build.rs`'s no-new-functions gate accept it. That disguised a
6117/// Rust-only helper as a port and put it in permanent violation of
6118/// `ported_fn_names_match_c`, which correctly saw a `sched.c` name in
6119/// `glob.rs`. Its two siblings, `parse_size_spec` and `parse_range_spec`,
6120/// were always handled the honest way — descriptive names, listed in
6121/// `tests/data/fake_fn_allowlist.txt` — and this now joins them.
6122fn parse_time_spec(
6123    // RUST-ONLY (clashes with sched.c:341 name, unrelated fn)
6124    chars: &mut std::iter::Peekable<std::str::Chars>,
6125) -> (i32, char, u64) {
6126    let unit: i32 = match chars.peek() {
6127        Some('s') => {
6128            chars.next();
6129            TT_SECONDS
6130        }
6131        Some('m') => {
6132            chars.next();
6133            TT_MINS
6134        }
6135        Some('h') => {
6136            chars.next();
6137            TT_HOURS
6138        }
6139        Some('d') => {
6140            chars.next();
6141            TT_DAYS
6142        }
6143        Some('w') => {
6144            chars.next();
6145            TT_WEEKS
6146        }
6147        Some('M') => {
6148            chars.next();
6149            TT_MONTHS
6150        }
6151        _ => TT_DAYS,
6152    };
6153    let (op, val) = parse_range_spec(chars);
6154    (unit, op, val)
6155}
6156
6157/// Parse `[+-]?NUMBER` operator+value tail. Mirrors C qgetnum
6158/// (glob.c:827) which returns the int value; here we return the
6159/// operator char along with the value since callers need both.
6160fn parse_range_spec(chars: &mut std::iter::Peekable<std::str::Chars>) -> (char, u64) {
6161    // RUST-ONLY
6162    // C's qgetnum at glob.c:827 returns the operator char inline.
6163    // `+N` = greater, `-N` = less, bare digits = equal.
6164    let op: char = match chars.peek() {
6165        Some('+') => {
6166            chars.next();
6167            '>'
6168        }
6169        Some('-') => {
6170            chars.next();
6171            '<'
6172        }
6173        _ => '=',
6174    };
6175    let mut num = String::new();
6176    while let Some(&c) = chars.peek() {
6177        if c.is_ascii_digit() {
6178            num.push(c);
6179            chars.next();
6180        } else {
6181            break;
6182        }
6183    }
6184    // c:Src/glob.c:826-834 — `qgetnum` errors with "number expected"
6185    // when not followed by `idigit(**s)`. Previously the Rust parser
6186    // silently treated the missing number as 0 and let the outer
6187    // qualifier loop continue consuming bytes as fresh qualifier
6188    // letters — `*(Lr)` parsed as `L0 + r` (readable, size=0) and
6189    // dropped to the no-matches path. Setting errflag here propagates
6190    // the canonical error message and gates the "no matches found"
6191    // emission in zglob (c:1843-1854 `if (errflag) ... return`).
6192    if num.is_empty() {
6193        crate::ported::utils::zerr("number expected"); // c:832
6194        crate::ported::utils::errflag.fetch_or(
6195            crate::ported::zsh_h::ERRFLAG_ERROR,
6196            std::sync::atomic::Ordering::SeqCst,
6197        );
6198        return (op, 0); // c:833
6199    }
6200    let val = num.parse().unwrap_or(0);
6201    (op, val)
6202}
6203
6204// `parse_subscript` (a RUST-ONLY `[FIRST,LAST]` splitter that
6205// `str::parse`d each half and dropped the qualifier on any non-decimal
6206// operand) is deleted: c:1735 routes the glob qualifier subscript
6207// through `getindex` (Src/params.c:2001), the shell's one subscript
6208// parser, and the `case '[':` arm above now does the same.
6209
6210/// Port of `static void insert(char *s, int checked)` from `Src/glob.c:346`.
6211/// Records one matched path into the glob result list: runs the qualifier
6212/// walk (`check_qualifiers` = the c:381-419 `struct qual` walk over the
6213/// arena) and, if accepted, builds the `gmatch` entry from the file's
6214/// stat and appends it (c:421-490) — honoring any `$reply`/`$REPLY`
6215/// replacement (INSERTS, c:428) set by an `(e:…:)` eval qualifier.
6216/// Called by the scanner per matched name, mirroring glob.c:576/668.
6217pub fn insert(state: &mut globdata, s: &Path, checked: i32) {
6218    use std::os::unix::fs::MetadataExt;
6219    // c:348-350 — `struct stat buf, buf2, *bp; int statted = 0;`. `buf`
6220    // is the lstat result, `buf2` the link-followed one; `statted` keeps
6221    // C's bit meaning (1 = buf valid, 2 = buf2 valid). C stats the file
6222    // AT MOST ONCE per variant and only when something actually reads
6223    // the result — the scanner already proved the file exists by
6224    // readdir'ing it (`checked`), so a plain `dir/*(N)` costs no stat at
6225    // all. The port used to `symlink_metadata` every match
6226    // unconditionally: 60605 wasted lstat + 60605 wasted stat on the
6227    // `man` completion glob, 37.6% of its CPU.
6228    let mut statted: i32 = 0;
6229    let mut buf: Option<fs::Metadata> = None;
6230    let mut buf2: Option<fs::Metadata> = None;
6231    // c:353 — `inserts = NULL;`: drop any $reply/$REPLY replacement left
6232    // by a previous file's (e:…:) eval before this file's walk.
6233    *INSERTS.lock().unwrap() = None;
6234    // c:187 `gd_gf_sorts` (macro at c:218) — the OR of every sort key the
6235    // glob's `o`/`O` qualifiers asked for. c:434/c:438 consult it to
6236    // decide whether this match needs its stat fields filled in. With no
6237    // explicit sort qualifier C leaves it at the c:1856 default GS_NAME,
6238    // which is in neither GS_NORMAL nor GS_LINKED.
6239    let gf_sorts: i32 = state.qualifiers.as_ref().map_or(0, |q| {
6240        q.sorts.iter().fold(0, |acc, &tp| acc | (tp & !GS_DESC))
6241    });
6242
6243    // c:355-364 — `if (gf_listtypes || gf_markdirs)` stat for the type
6244    // marker, and drop the match if the stat fails. The port appends the
6245    // marker at emit time (glob_emit_path), so only C's `checked =
6246    // statted = 1` side effect on the arms below is reproduced here.
6247    if state.gf_listtypes != 0 || state.gf_markdirs != 0 {
6248        match fs::symlink_metadata(s) {
6249            Ok(m) => {
6250                buf = Some(m);
6251                statted = 1; // c:363 `checked = statted = 1;`
6252            }
6253            Err(_) => return, // c:358-360
6254        }
6255    }
6256
6257    // c:381-418 — qualifier walk; reject the file if any test fails. The
6258    // walk needs the stat itself, so it hands its buffer back for the
6259    // arms below (C's `statted = 1` at c:391).
6260    if !check_qualifiers(state, s, &mut buf) {
6261        return;
6262    }
6263    if buf.is_some() {
6264        statted |= 1;
6265    }
6266    // c:419-423 — `else if (!checked) { if (statfullpath(s, NULL, 1))
6267    // return; }`: a match the scanner did NOT readdir (a pure literal
6268    // path section, c:576 `insert(str, 0)`) still has to be proven to
6269    // exist. Nothing reads the buffer, so C passes NULL.
6270    if statted & 1 == 0 && checked == 0 && fs::symlink_metadata(s).is_err() {
6271        return;
6272    }
6273    // c:434-437 — `if (!statted && (gf_sorts & GS_NORMAL))`: a
6274    // size/time/link sort key needs the stat fields, so fill them now.
6275    if statted & 1 == 0 && (gf_sorts & GS_NORMAL) != 0 {
6276        buf = fs::symlink_metadata(s).ok(); // c:435
6277        statted |= 1;
6278    }
6279    // c:438-445 — `if (!(statted & 2) && (gf_sorts & GS_LINKED))`: the
6280    // follow-link sort variants need the TARGET's stat. C reuses `buf`
6281    // verbatim when the match is not a symlink (c:440-441 memcpy), and
6282    // falls back to it when the target stat fails (a dangling link).
6283    if statted & 2 == 0 && (gf_sorts & GS_LINKED) != 0 {
6284        buf2 = match &buf {
6285            // c:439-441
6286            Some(m) if !m.file_type().is_symlink() => Some(m.clone()),
6287            Some(m) => fs::metadata(s).ok().or_else(|| Some(m.clone())),
6288            // c:442-443 `if (statfullpath(s,&buf2,0)) statfullpath(s,&buf2,1);`
6289            None => fs::metadata(s)
6290                .ok()
6291                .or_else(|| fs::symlink_metadata(s).ok()),
6292        };
6293        statted |= 2;
6294    }
6295
6296    // c:446-478 — copy the stat fields into the match entry, but only
6297    // the ones actually stat'd (`if (statted & 1)` / `if (statted & 2)`);
6298    // the rest stay zero as C's calloc'd match buffer leaves them.
6299    let name = s
6300        .file_name()
6301        .map(|n| n.to_string_lossy().to_string())
6302        .unwrap_or_default();
6303    let meta = buf;
6304    // c:463-478 — `if (statted & 2)` fills the `_size`/`_atime`/… twins.
6305    // When only the lstat ran, C's GS_LINKED keys are never consulted, so
6306    // mirroring the lstat values here is equivalent and keeps a match
6307    // that IS a symlink self-consistent.
6308    let target = buf2.as_ref().or(meta.as_ref());
6309    let (tsize, tatime, tmtime, tctime, tlinks, tansec, tmnsec, tcnsec) = match target {
6310        Some(tm) => (
6311            tm.size(),
6312            tm.atime(),
6313            tm.mtime(),
6314            tm.ctime(),
6315            tm.nlink(),
6316            tm.atime_nsec(),
6317            tm.mtime_nsec(),
6318            tm.ctime_nsec(),
6319        ),
6320        None => (0, 0, 0, 0, 0, 0, 0, 0),
6321    };
6322    // c:428 — `while (!inserts || (news = *inserts++))`: an (e:…:) eval
6323    // may have set $reply/$REPLY (INSERTS), replacing this match with
6324    // zero-or-more names; otherwise emit the original name once. The
6325    // synthetic path makes glob_emit_path yield the replacement string;
6326    // the stat fields stay those of the matched file (C keeps
6327    // matchptr->size etc. from buf).
6328    let emit: Vec<(String, PathBuf)> = match INSERTS.lock().unwrap().take() {
6329        Some(list) => list
6330            .into_iter()
6331            .map(|n| (n.clone(), PathBuf::from(&n)))
6332            .collect(),
6333        None => vec![(name.clone(), s.to_path_buf())],
6334    };
6335    for (nm, pth) in emit {
6336        state.matches.push(gmatch {
6337            name: nm,
6338            uname: String::new(), // c:1963-1973 — filled before the sort
6339            path: pth,
6340            size: meta.as_ref().map_or(0, |m| m.size()),
6341            atime: meta.as_ref().map_or(0, |m| m.atime()),
6342            mtime: meta.as_ref().map_or(0, |m| m.mtime()),
6343            ctime: meta.as_ref().map_or(0, |m| m.ctime()),
6344            links: meta.as_ref().map_or(0, |m| m.nlink()),
6345            mode: meta.as_ref().map_or(0, |m| m.mode()),
6346            uid: meta.as_ref().map_or(0, |m| m.uid()),
6347            gid: meta.as_ref().map_or(0, |m| m.gid()),
6348            dev: meta.as_ref().map_or(0, |m| m.dev()),
6349            ino: meta.as_ref().map_or(0, |m| m.ino()),
6350            target_size: tsize,
6351            target_atime: tatime,
6352            target_mtime: tmtime,
6353            target_ctime: tctime,
6354            target_links: tlinks,
6355            ansec: meta.as_ref().map_or(0, |m| m.atime_nsec()),
6356            mnsec: meta.as_ref().map_or(0, |m| m.mtime_nsec()),
6357            cnsec: meta.as_ref().map_or(0, |m| m.ctime_nsec()),
6358            target_ansec: tansec,
6359            target_mnsec: tmnsec,
6360            target_cnsec: tcnsec,
6361            sort_strings: Vec::new(),
6362        });
6363        state.matchct += 1; // c:479 matchptr++
6364    }
6365}
6366
6367/// Drive the OR-of-AND qualifier filter against `path`. **RUST-ONLY**
6368/// — C glob.c does qualifier eval inline inside `insert()` (c:381+).
6369///
6370/// `stat_out` is C's `struct stat buf` (c:348), which `insert()` shares
6371/// with this walk: c:385 fills it here and c:391's `statted = 1` stops
6372/// c:434 from stat'ing the same file a second time. Only the plain lstat
6373/// is handed back — a node with the follow bit reads C's `buf2`
6374/// (c:399-402), which the arms after c:419 track separately.
6375fn check_qualifiers(state: &globdata, path: &Path, stat_out: &mut Option<fs::Metadata>) -> bool {
6376    use std::os::unix::fs::MetadataExt;
6377    use std::sync::atomic::Ordering;
6378    let qs = match &state.qualifiers {
6379        Some(q) => q,
6380        None => return true,
6381    };
6382    // No test qualifiers (only flags/sort/words) → no filtering.
6383    let arena = &qs.quals;
6384    if arena.head.is_none() {
6385        return true;
6386    }
6387
6388    // c:385 `statfullpath(s, &buf, 1)` — this IS insert()'s `buf`.
6389    let meta = match fs::symlink_metadata(path) {
6390        Ok(m) => m,
6391        Err(_) => return false, // c:385-387
6392    };
6393    *stat_out = Some(meta.clone());
6394    // Bridge meta → libc::stat for the leaf test fns (no extra syscall).
6395    let to_stat = |meta: &fs::Metadata| -> libc::stat {
6396        let mut st: libc::stat = unsafe { std::mem::zeroed() };
6397        st.st_mode = meta.mode() as _;
6398        st.st_uid = meta.uid() as _;
6399        st.st_gid = meta.gid() as _;
6400        st.st_dev = meta.dev() as _;
6401        st.st_nlink = meta.nlink() as _;
6402        st.st_size = meta.size() as _;
6403        st.st_atime = meta.atime() as _;
6404        st.st_mtime = meta.mtime() as _;
6405        st.st_ctime = meta.ctime() as _;
6406        st
6407    };
6408    let st = to_stat(&meta);
6409    // c:397-402 — `buf2`, stat'ed once, the first time a node with the
6410    // follow bit (sense & 2) is reached:
6411    //     if (!S_ISLNK(buf.st_mode) || statfullpath(s, &buf2, 0))
6412    //         memcpy(&buf2, &buf, sizeof(buf));
6413    // A broken symlink keeps its lstat, so `*(-@)` still sees it.
6414    let mut st2: Option<libc::stat> = None;
6415    // qualsheval / qualnonemptydir need the name (REPLY / opendir).
6416    // c:424 — verbatim pathbuf text when the pattern starts with `./`.
6417    let name = if state.dot_prefix_verbatim { path.to_string_lossy().into_owned() } else { glob_emit_path(path) };
6418
6419    // c:387-419 — insert()'s qual-walk over the `struct qual` arena: AND
6420    // via `next`, OR (alternatives) via `or`, per-node `sense`. `qo` is
6421    // the current alternative head; a rejected node advances to `qo->or`
6422    // (next alternative) or, if none, rejects the file. Falling off the
6423    // `next` chain (or hitting a func-less terminator) accepts.
6424    let mut qo = arena.head;
6425    let mut qn = qo;
6426    loop {
6427        let Some(qn_i) = qn else { return true }; // c:419 — chain matched
6428        let (func, data, sense, range, amc, units, sdata, next) = {
6429            let n = &arena.nodes[qn_i];
6430            (
6431                n.func,
6432                n.data,
6433                n.sense,
6434                n.range,
6435                n.amc,
6436                n.units,
6437                n.sdata.clone(),
6438                n.next,
6439            )
6440        };
6441        let func = match func {
6442            Some(f) => f,
6443            None => return true, // c:392 — `qn && qn->func` terminator
6444        };
6445        // c:393-395 — set the scratch the range/time/size leaf fns read.
6446        g_range.store(range, Ordering::SeqCst);
6447        g_amc.store(amc, Ordering::SeqCst);
6448        g_units.store(units, Ordering::SeqCst);
6449        // c:403 — `bp = (qn->sense & 2) ? &buf2 : &buf;`
6450        let bp = if sense & 2 != 0 {
6451            &*st2.get_or_insert_with(|| {
6452                if meta.file_type().is_symlink() {
6453                    fs::metadata(path).map(|m| to_stat(&m)).unwrap_or(st)
6454                } else {
6455                    st
6456                }
6457            })
6458        } else {
6459            &st
6460        };
6461        let r = func(&name, bp, data, sdata.as_deref().unwrap_or(""));
6462        // c:407-409 — reject if `(!(func()) ^ sense) & 1`.
6463        let reject = ((if r == 0 { 1 } else { 0 }) ^ (sense & 1)) & 1 == 1;
6464        if reject {
6465            // c:411-415 — try the next alternative, else reject the file.
6466            match arena.nodes[qo.unwrap()].or {
6467                Some(or_i) => {
6468                    qo = Some(or_i);
6469                    qn = Some(or_i);
6470                }
6471                None => return false,
6472            }
6473        } else {
6474            qn = next; // c:417
6475        }
6476    }
6477}
6478
6479/// Sort the per-state matches per the qualifier `o`/`O` keys.
6480/// **RUST-ONLY** — C does this inline at the end of `scanner()`
6481/// (glob.c:1700ish, qsort with `gmatchcmp` comparator).
6482/// Returns whether `gf_sortlist[0]` is `GS_NONE`, which decides the
6483/// direction of the `[first,last]` walk (c:1991).
6484fn sort_matches(state: &mut globdata) -> bool {
6485    // RUST-ONLY
6486    // Default sort is GS_NAME ascending (c:204 gf_sortlist
6487    // initial setup). Per-qualifier `o<key>` / `O<key>` overrides.
6488    // c:1855-1857 — when gf_nsorts == 0 (no explicit o/O qualifier),
6489    // glob.c falls back to GS_NAME (or GS_NONE under shortcircuit).
6490    // Mirror that here so a qualifier set carrying ONLY non-sort
6491    // qualifiers (`Lk+0`, etc.) still sorts alphabetically.
6492    // c:1855-1857 is a CONDITIONAL default, not a fixed one:
6493    //     if (!gf_nsorts) {
6494    //         gf_sortlist[0].tp = gf_sorts = (shortcircuit ? GS_NONE : GS_NAME);
6495    //         gf_nsorts = 1;
6496    //     }
6497    // A `Y` limit means "give me the first N the scanner finds", so with no
6498    // explicit o/O spec the default becomes GS_NONE and the results stay in
6499    // SCAN order — sorting them would defeat the short-circuit, since the first
6500    // N by name are not the first N found. This always chose GS_NAME, so a
6501    // limited glob returned the alphabetically-first N instead of the
6502    // scan-order-first N, and even `*(Y99)` (a limit larger than the match
6503    // count, which discards nothing) came back sorted where zsh leaves it in
6504    // readdir order.
6505    //
6506    // `Y0` is the counter-case and must still sort: c:1583 parses the argument
6507    // over `shortcircuit`, so 0 lands there legitimately and c:518's
6508    // `if (shortcircuit && …)` reads it as "no limit" — hence the `> 0` test
6509    // rather than `.is_some()`.
6510    let limited = state
6511        .qualifiers
6512        .as_ref()
6513        .and_then(|q| q.short_circuit)
6514        .is_some_and(|n| n > 0); // c:1856 `shortcircuit ?`
6515    let default_spec = if limited { GS_NONE } else { GS_NAME }; // c:1856
6516    let mut specs: Vec<i32> = state
6517        .qualifiers
6518        .as_ref()
6519        .map(|q| {
6520            if q.sorts.is_empty() {
6521                vec![default_spec] // c:1856
6522            } else {
6523                q.sorts.clone()
6524            }
6525        })
6526        .unwrap_or_else(|| vec![GS_NAME]);
6527    // c:1991 — the subscript walk tests only `gf_sortlist[0]`.
6528    let unsorted = specs[0] & GS_NONE != 0;
6529
6530    // GS_NONE marker — caller wants no sort at all.
6531    if specs.iter().any(|&tp| (tp & GS_NONE) != 0) {
6532        return unsorted;
6533    }
6534
6535    // c:936 gmatchcmp returns 0 when every sort key ties. In C the final
6536    // order of equal-key matches is then whatever the libc `qsort` does with
6537    // all-equal elements (glob.c:1976) — NON-stable on macOS/BSD (arbitrary,
6538    // input-dependent: `*(oL)` on three 0-byte files a/b/d prints `d a b`),
6539    // and on glibc it depends on readdir order. So zsh has NO portable,
6540    // defined tie-break here. zshrs is a cross-architecture shell and must be
6541    // DETERMINISTIC, so we impose an explicit GS_NAME final tie-breaker:
6542    // equal-key matches order by name ascending, identical on every platform.
6543    // This is an intentional determinism guarantee, NOT a bug-for-bug match of
6544    // zsh's qsort-defined order (which can't be reproduced portably). Skip
6545    // when the last key is already GS_NAME (the no-qualifier default, or an
6546    // explicit trailing `on`) so we don't double-compare.
6547    if specs
6548        .last()
6549        .map_or(true, |&last| (last & !GS_DESC) != GS_NAME)
6550    {
6551        specs.push(GS_NAME); // deterministic name-ascending tie-break
6552    }
6553
6554    // c:1958-1973 — "Where necessary, create unmetafied version of names
6555    // for comparison." C runs this loop once over the match list, right
6556    // before the qsort at c:1977, so `gmatchcmp` (c:945) collates two
6557    // ready-made strings. The port's key is the full match path with the
6558    // scanner's depth-0 "./" prefix stripped:
6559    //
6560    //   c:424 — C stores bare-relative match names (`dyncat(pathbuf,
6561    //   news)`, no "./"), so its single qsort sorts the exact strings it
6562    //   emits. The Rust scanner joins depth-0 matches against base "."
6563    //   (glob.rs:527 + :584), giving a leading "./" that deeper matches
6564    //   lack and that `glob_emit_path` strips at emit. Since '.' (0x2E)
6565    //   sorts before any letter, that stray prefix clustered every
6566    //   top-level entry ahead of any nested path (`**/*` → `d e m d/z`
6567    //   instead of `d d/z e m`). Strip it so the sort key matches the
6568    //   emit key.
6569    //
6570    // c:355-378 — insert() appends the `(M)`/`(T)`/MARK_DIRS type marker to
6571    // the name BEFORE storing it (c:444 `matchptr->name = news`), so the
6572    // marker is part of the sort key: under en_US collation `foo.c` sorts
6573    // before `foo/`, while the unmarked `foo` sorts first. The port stamps
6574    // the marker at emit (after this sort), so add it to the key here.
6575    let mark_dirs = state
6576        .qualifiers
6577        .as_ref()
6578        .and_then(|q| q.mark_dirs)
6579        .unwrap_or_else(|| glob_isset(MARKDIRS)); // c:1255 / c:1558
6580    let list_types = state.qualifiers.as_ref().is_some_and(|q| q.list_types);
6581    let mark_follow = state.qualifiers.as_ref().is_some_and(|q| q.mark_follow);
6582    for m in state.matches.iter_mut() {
6583        let full = m.path.to_string_lossy();
6584        m.uname = full.strip_prefix("./").unwrap_or(&full).to_string();
6585        // c:355 — `if (gf_listtypes || gf_markdirs)`
6586        if mark_dirs || list_types {
6587            // c:358 — `statfullpath(s, &buf, 1)` (lstat); c:366-371 — under
6588            // gf_follow a symlink is re-stat'ed, falling back to the lstat.
6589            if let Ok(lmeta) = fs::symlink_metadata(&m.path) {
6590                let meta = if mark_follow && lmeta.file_type().is_symlink() {
6591                    fs::metadata(&m.path).unwrap_or(lmeta)
6592                } else {
6593                    lmeta
6594                };
6595                let ch = file_type(meta.mode()); // c:377
6596                // c:372 — `if (gf_listtypes || S_ISDIR(mode))`
6597                if list_types || ch == '/' {
6598                    m.uname.push(ch); // c:376-378
6599                }
6600            }
6601        }
6602    }
6603
6604    // c:1258/1575 — gf_numsort starts at the global NUMERIC_GLOB_SORT
6605    // option, then a per-glob `(n)`/`(^n)` qualifier OVERRIDES it
6606    // absolutely (so `(^n)` forces lexical even when the option is on).
6607    let numeric = state
6608        .qualifiers
6609        .as_ref()
6610        .and_then(|q| q.numsort)
6611        .unwrap_or_else(|| glob_isset(NUMERICGLOBSORT));
6612    state
6613        .matches
6614        .sort_by(|a, b| gmatchcmp(a, b, &specs, numeric));
6615    unsorted
6616}
6617
6618/// Apply `[FIRST,LAST]` qualifier subscript on the match list.
6619/// **RUST-ONLY** — C uses `gd_pre_first` / `gd_first` index tracking
6620/// during scanner emit; here we slice after the full walk.
6621fn apply_selection(state: &mut globdata, unsorted: bool) {
6622    // RUST-ONLY
6623    let (first, last, short_circuit) = match &state.qualifiers {
6624        Some(q) => (q.first, q.last, q.short_circuit),
6625        None => return,
6626    };
6627
6628    let len = state.matches.len() as i32;
6629    if len == 0 {
6630        return;
6631    }
6632
6633    let start = match first {
6634        Some(f) if f < 0 => (len + f).max(0) as usize,
6635        Some(f) => f as usize, // c:1739 `first = v.start` (getindex: 0-based, c:2145)
6636        None => 0,
6637    };
6638
6639    let end = match last {
6640        Some(l) if l < 0 => (len + l + 1).max(0) as usize,
6641        Some(l) => l.min(len) as usize,
6642        None => len as usize,
6643    };
6644
6645    // c:1991-1998 — with `GS_NONE` first (`oN`, or the `Y` default) C never
6646    // sorted `matchbuf`, so it inserts "back to front": it starts at
6647    // `matchbuf + matchct - first - 1` and walks DOWN, each match landing
6648    // right after the same node (insert_glob_match, c:1124-1141). The
6649    // subscript therefore counts from the END of the scan order, while the
6650    // words still come out in scan order: files scanned `1 10 2` give
6651    // `*(oN[1,2])` → `10 2` and `*(oN[1])` → `2`.
6652    let (start, end) = if unsorted {
6653        let n = len as usize;
6654        (n.saturating_sub(end), n.saturating_sub(start))
6655    } else {
6656        (start, end)
6657    };
6658
6659    // c:1990-2004 — C never copies the match array for the subscript: it
6660    // walks the existing `matchbuf` from `matchbuf + matchct - first - end`
6661    // and inserts straight out of it. The Rust equivalent that keeps the
6662    // elements where they are is truncate-then-drain: both move `gmatch`
6663    // values (each owning a PathBuf + String) instead of deep-cloning the
6664    // whole 60k-entry list the way `to_vec()` did.
6665    if start < end && start < state.matches.len() {
6666        state.matches.truncate(end.min(state.matches.len())); // c:1986-1989 `end` clamp
6667        state.matches.drain(..start); // c:1981-1985 `first` offset
6668    } else {
6669        state.matches.clear();
6670    }
6671
6672    // c:Src/glob.c:1579-1595 `case 'Y'` — apply the short-circuit
6673    // limit after the [first,last] slice (so `(Y3[2,4])` would yield
6674    // up to 3 from the [2,4] subscript range). C truncates DURING
6675    // the scanner walk via `if (shortcircuit == matchct) break;` —
6676    // here we slice post-walk which produces the same set on the
6677    // common short-glob case where the scanner doesn't recurse
6678    // unboundedly. Bug #41 in docs/BUGS.md.
6679    // NOTE: the `Y` short-circuit is deliberately NOT applied here. It bounds
6680    // the SCAN (c:Src/glob.c:518), so it must run BEFORE the sort — see the
6681    // truncation at the sort_matches call site. Only the `[first,last]`
6682    // subscript belongs in this post-sort selection.
6683    let _ = short_circuit;
6684}
6685
6686// `haswilds()` is defined in `Src/pattern.c:4306`, not glob.c — the
6687// canonical Rust port lives at `crate::ported::pattern::haswilds`.
6688// This file previously carried a divergent re-implementation tracking
6689// bracket/escape state instead of the token-aware `zpc_disables[]` /
6690// SHGLOB / KSHGLOB / EXTENDEDGLOB checks the C source actually does.
6691// Drift deleted; glob.rs callers route through the canonical port.
6692
6693#[cfg(test)]
6694mod gs_tt_tests {
6695    use super::*;
6696
6697    #[test]
6698    fn gs_size_offset_matches_c() {
6699        let _g = crate::test_util::global_state_lock();
6700        // c:83 — GS_SIZE = GS_SHIFT_BASE = 8.
6701        assert_eq!(GS_SIZE, 8);
6702        // c:84 — GS_ATIME = GS_SHIFT_BASE << 1 = 16.
6703        assert_eq!(GS_ATIME, 16);
6704        // c:87 — GS_LINKS = GS_SHIFT_BASE << 4 = 128.
6705        assert_eq!(GS_LINKS, 128);
6706    }
6707
6708    #[test]
6709    fn gs_normal_covers_all_size_keys() {
6710        let _g = crate::test_util::global_state_lock();
6711        // c:99 — GS_NORMAL = SIZE | ATIME | MTIME | CTIME | LINKS.
6712        assert_ne!(GS_NORMAL & GS_SIZE, 0);
6713        assert_ne!(GS_NORMAL & GS_ATIME, 0);
6714        assert_ne!(GS_NORMAL & GS_MTIME, 0);
6715        assert_ne!(GS_NORMAL & GS_CTIME, 0);
6716        assert_ne!(GS_NORMAL & GS_LINKS, 0);
6717    }
6718
6719    #[test]
6720    fn tt_namespaces_share_indices() {
6721        let _g = crate::test_util::global_state_lock();
6722        // c:121-126 vs c:128-133 — TT_DAYS == TT_BYTES == 0, etc.
6723        assert_eq!(TT_DAYS, TT_BYTES);
6724        assert_eq!(TT_HOURS, TT_POSIX_BLOCKS);
6725        assert_eq!(TT_MINS, TT_KILOBYTES);
6726        assert_eq!(TT_WEEKS, TT_MEGABYTES);
6727        assert_eq!(TT_MONTHS, TT_GIGABYTES);
6728        assert_eq!(TT_SECONDS, TT_TERABYTES);
6729    }
6730
6731    #[test]
6732    fn max_sorts_is_12() {
6733        let _g = crate::test_util::global_state_lock();
6734        assert_eq!(MAX_SORTS, 12);
6735    }
6736}
6737
6738fn expand_range(
6739    prefix: &str,
6740    content: &str,
6741    dotdot_pos: usize,
6742    suffix: &str,
6743) -> Option<Vec<String>> {
6744    // c:Src/glob.c::xpandbraces parses brace-range endpoints via
6745    // zstrtol, which is TOKEN-aware and treats Dash TOKEN (\u{e19b})
6746    // as ASCII `-`. Rust's i64::parse only accepts ASCII, so
6747    // untokenize the content here before splitting/parsing — matches
6748    // C semantics without weakening the strict-TOKEN gates above.
6749    // A `Meta` (`\u{83}`) starts a METAFIED byte pair (`Meta` + `byte ^
6750    // 32`), NOT a token: C's `itok` set begins at `Pound` (Src/zsh.h:164)
6751    // and `Meta` sits below it, so `bracechardots` (c:2227) leaves such a
6752    // pair to MB_METACHARLENCONV instead of the `ztokens` lookup. The
6753    // escaped byte routinely LANDS in the token range (`$'\x80'` →
6754    // `\u{83}\u{a0}`), so scanning it as a token here untokenized the
6755    // pair and destroyed the range endpoint. Skip the pair when deciding
6756    // whether the content needs untokenizing.
6757    let owned: String;
6758    let content: &str = if {
6759        let cs: Vec<char> = content.chars().collect();
6760        let mut i = 0usize;
6761        let mut found = false;
6762        while i < cs.len() {
6763            if cs[i] == char::from(crate::ported::zsh_h::Meta) {
6764                i += 2; // metafied byte pair — not a token
6765                continue;
6766            }
6767            let cu = cs[i] as u32;
6768            if (0xe184..=0xe1a1).contains(&cu)
6769                && cs[i] != '\u{e18f}'
6770                && cs[i] != '\u{e190}'
6771                && cs[i] != '\u{e19a}'
6772            {
6773                found = true;
6774                break;
6775            }
6776            i += 1;
6777        }
6778        found
6779    } {
6780        owned = crate::lex::untokenize(content);
6781        &owned
6782    } else {
6783        content
6784    };
6785    // `dotdot_pos` is a CHAR index (xpandbraces counts it over a char vector,
6786    // `i - start - 1`), but the slicing below is byte-based. For ASCII content
6787    // they coincide; for content holding multibyte/metafied chars (e.g.
6788    // `{$'\x80'..$'\x81'}`, where the bytes are Meta-escaped) a raw byte slice
6789    // at the char index lands mid-char and panics. Convert to a byte offset.
6790    let dotdot_byte = content
6791        .char_indices()
6792        .nth(dotdot_pos)
6793        .map(|(b, _)| b)
6794        .unwrap_or(content.len());
6795    let left = &content[..dotdot_byte];
6796    let right_start = dotdot_byte + 2; // ".." is two ASCII bytes
6797
6798    // Check for second `..` for `{N..M..S}` step form. Step may be
6799    // signed: negative-step REVERSES the natural direction sequence
6800    // per zsh's brace expansion (Src/glob.c::xpandbraces recursive
6801    // iteration with sign tracking). Examples:
6802    //   {1..32..3}   →  1,4,7,…,31 (natural ascending)
6803    //   {1..32..-3}  → 31,28,…,1   (same set, reversed)
6804    //   {32..1..3}   → 32,29,…,2   (natural descending)
6805    //   {32..1..-3}  →  2,5,…,32   (same set, reversed)
6806    //
6807    // c:Src/glob.c:2365-2369 — `if (dotdot == 2 && *p == '.' &&`
6808    //                         `    p[1] == '.') {`
6809    //                         `    rincr = zstrtol(p+2, &p, 10);`
6810    //                         `    wid3 = p - dots2 - 2;`
6811    //                         `    if (p != str2 || !rincr)`
6812    //                         `        err++;`
6813    // The `!rincr` test (c:2368) rejects a zero step: when err > 0,
6814    // the C code falls past the c:2374 `if (!err)` block, so the
6815    // brace expansion is abandoned and the literal pattern is
6816    // preserved. Parity bug #15: previously the Rust port silently
6817    // clamped step to 1 via `.max(1)`, producing `1 2 3 4 5` for
6818    // `{1..5..0}` instead of zsh's literal `1..5..0`.
6819    let (right, incr_abs, incr_sign_negative, step_text, raw_step) =
6820        if let Some(pos) = content[right_start..].find("..") {
6821            let r = &content[right_start..right_start + pos];
6822            let s_text = &content[right_start + pos + 2..];
6823            // c:2366-2369 — `rincr = zstrtol(p+2, &p, 10); … if (p != str2
6824            // || !rincr) err++;` — an empty or non-numeric step (`{1..2..}`)
6825            // is an error just like a zero one, not a step of 1.
6826            let raw: i64 = match s_text.parse() {
6827                Ok(v) => v,
6828                Err(_) => return None,
6829            };
6830            // bash treats a zero step as 1 (`{1..3..0}` → 1 2 3); the
6831            // `.max(1)` at each use site applies that.
6832            if raw == 0 && !crate::dash_mode::bash_mode() {
6833                // c:2368 — `!rincr` → err++ → no expansion. Return
6834                // None so xpandbraces falls back to literal.
6835                return None;
6836            }
6837            (r, raw.unsigned_abs(), raw < 0, s_text, Some(raw))
6838        } else {
6839            (&content[right_start..], 1u64, false, "", None)
6840        };
6841
6842    // Try numeric range
6843    if let (Ok(start), Ok(end)) = (left.parse::<i64>(), right.parse::<i64>()) {
6844        let mut results = Vec::new();
6845
6846        // Iterate from `start` toward `end` with abs(step). Sign of
6847        // start/end relative to each other determines natural
6848        // direction; step is always |step|.
6849        let step = incr_abs.max(1) as i64;
6850        let mut vals: Vec<i64> = Vec::new();
6851        if crate::dash_mode::ksh93_mode() {
6852            vals = crate::dash_mode::ksh93_range_values(start, end, raw_step);
6853        } else if start <= end {
6854            let mut v = start;
6855            while v <= end {
6856                vals.push(v);
6857                v += step;
6858            }
6859        } else {
6860            let mut v = start;
6861            while v >= end {
6862                vals.push(v);
6863                v -= step;
6864            }
6865        }
6866        // bash takes the direction from the endpoints alone and ignores the
6867        // step's sign (`{6..1..-2}` → 6 4 2, `{1..6..-2}` → 1 3 5); only zsh
6868        // reverses the sequence for a negative step.
6869        if incr_sign_negative && !crate::dash_mode::bash_mode() && !crate::dash_mode::ksh93_mode() {
6870            vals.reverse();
6871        }
6872
6873        // Padding: zsh pads with leading zeros when ANY of the three
6874        // textual fields (left endpoint, right endpoint, step) has a
6875        // leading zero after stripping the optional sign. Width is
6876        // the max textual width across left/right/step. For negative
6877        // values, the sign prefix counts toward width — we emit `-`
6878        // then zero-pad the remaining digits (`-02`, not `0-2`).
6879        // Direct port of Src/lex.c::dobrace_pad logic which detects
6880        // pad mode per the textual form of all three fields.
6881        // zsh's pad-detection: pad fires when any of the three
6882        // textual fields (left / right / step) is a multi-digit
6883        // form with a leading zero (e.g. "01", "00", "003"), with
6884        // ONE exception: when the LEFT endpoint is exactly "0"
6885        // (bare single zero, possibly signed), pad is suppressed
6886        // regardless of right's form. Empirical zsh:
6887        //   {0..-5}    → 0 -1 -2 -3 -4 -5    (left=0 → no pad)
6888        //   {0..03}    → 0 1 2 3              (left=0 → no pad)
6889        //   {00..-5}   → 00 -1 -2 -3 -4 -5    (left=00 → pad w=2)
6890        //   {1..03}    → 01 02 03             (right=03 → pad w=2)
6891        //   {1..-03}   → 001 000 -01 -02 -03  (right=-03 → pad w=3)
6892        let lstrip = left.trim_start_matches(['+', '-']);
6893        let rstrip = right.trim_start_matches(['+', '-']);
6894        let sstrip = step_text.trim_start_matches(['+', '-']);
6895        let is_padded_field = |stripped: &str| stripped.len() >= 2 && stripped.starts_with('0');
6896        // Suppress pad ONLY when LEFT is exactly the single-char "0"
6897        // (no sign, no extra digits). "-0" or "00" both pad.
6898        let left_is_bare_zero = left == "0";
6899        // ksh93 never zero-pads a numeric range (`{01..03}` -> `1 2 3`).
6900        let pad = !crate::dash_mode::ksh93_mode()
6901            && !left_is_bare_zero
6902            && (is_padded_field(lstrip)
6903                || is_padded_field(rstrip)
6904                || (!step_text.is_empty() && is_padded_field(sstrip)));
6905        let width = left.len().max(right.len()).max(step_text.len());
6906
6907        for v in vals {
6908            let formatted = if pad {
6909                if v < 0 {
6910                    let abs = (-v).to_string();
6911                    let inner_w = width.saturating_sub(1);
6912                    format!("-{:0>w$}", abs, w = inner_w)
6913                } else {
6914                    format!("{:0>w$}", v, w = width)
6915                }
6916            } else {
6917                v.to_string()
6918            };
6919            results.push(format!("{}{}{}", prefix, formatted, suffix));
6920        }
6921        return Some(results);
6922    }
6923
6924    // Try character range
6925    // c:Src/lex.c::dobrace alpha-range path — zsh only handles bare a..z,
6926    // NOT a..z..N (it emits literal `{a..z..3}` because the step form is
6927    // numeric-only). bash DOES support an alpha step (`{a..e..2}` → a c e),
6928    // so honor `step_text` only under --bash; --zsh keeps refusing it so the
6929    // literal survives (verified: real zsh emits `{a..e..2}` unchanged).
6930    // bash ignores the step's SIGN for alpha (`{a..e..-2}` == `{a..e..2}`),
6931    // so use abs(step) and take direction purely from start vs end.
6932    let alpha_step_ok = step_text.is_empty()
6933        || crate::dash_mode::bash_mode()
6934        || crate::dash_mode::ksh93_mode();
6935    // c:2311 — `if (bracechardots(str, &cstart, &cend))` decides the
6936    // CHARACTER range. The endpoints are METAFIED text, decoded through
6937    // MB_METACHARLENCONV (c:2236/2257), so a metafied 8-bit byte
6938    // (`$'\x80'` → `Meta` `\u{e1a0}`) and a real multibyte character both
6939    // resolve to their scalar. The old byte-length test (`left.len() ==
6940    // 1`) accepted only ASCII and left `{é..ê}` / `{$'\x80'..$'\x81'}`
6941    // unexpanded.
6942    let lb = crate::ported::utils::unmetafy_str(left); // c:2236
6943    let (l_len, cstart, _) = crate::ported::utils::mb_metacharlenconv(&lb); // c:2236
6944    let rb = crate::ported::utils::unmetafy_str(right); // c:2257
6945    let (r_len, cend, _) = crate::ported::utils::mb_metacharlenconv(&rb); // c:2257
6946    let endpoints_ok = l_len != 0 && l_len == lb.len() && r_len != 0 && r_len == rb.len();
6947    if endpoints_ok && cstart.is_some() && cend.is_some() && alpha_step_ok {
6948        let start = cstart?; // c:2311 cstart
6949        let end = cend?; // c:2311 cend
6950        // Walk FROM `start` toward `end`: bash steps from the left endpoint,
6951        // so `{f..a..2}` is `f d b`. Stepping up from the low end and
6952        // reversing gave `e c a` whenever the span is not a step multiple.
6953        let step = incr_abs.max(1) as i64;
6954        let (from, to) = (start as i64, end as i64);
6955        let dir = if from <= to { step } else { -step };
6956
6957        let mut results = Vec::new();
6958        let mut chars: Vec<char> = Vec::new();
6959        if crate::dash_mode::ksh93_mode() {
6960            chars.extend(
6961                crate::dash_mode::ksh93_range_values(from, to, raw_step)
6962                    .into_iter()
6963                    .filter_map(|v| char::from_u32(v as u32)),
6964            );
6965        } else {
6966            let mut v = from;
6967            while (dir > 0 && v <= to) || (dir < 0 && v >= to) {
6968                if let Some(c) = char::from_u32(v as u32) {
6969                    chars.push(c);
6970                }
6971                v += dir;
6972            }
6973        }
6974
6975        for c in chars {
6976            // c:2328-2334 — `MB_CHARINIT(); ncptr = MB_NICECHAR(cend);
6977            // ... memcpy(p + strp, ncptr, nclen);` — every element of a
6978            // CHARACTER range is spliced in through `MB_NICECHAR`
6979            // (Src/zsh.h:3288 `wcs_nicechar(cp, NULL, NULL)`), not as the
6980            // raw character. Printable characters render as themselves, so
6981            // `{a..e}` is unchanged, while a non-printable 8-bit endpoint
6982            // comes out as its `\M-^@`-style escape — which is exactly what
6983            // `{$'\x80'..$'\x81'}` prints in zsh.
6984            let ncptr = crate::ported::utils::wcs_nicechar(c, None, None); // c:2329
6985            results.push(format!("{}{}{}", prefix, ncptr, suffix)); // c:2332-2334
6986        }
6987        return Some(results);
6988    }
6989
6990    None
6991}
6992
6993fn expand_comma(
6994    prefix: &str,
6995    content: &str,
6996    positions: &[usize],
6997    suffix: &str,
6998) -> Option<Vec<String>> {
6999    // positions are CHAR indices into `content` (per the xpandbraces
7000    // walker above that builds them from `chars: Vec<char>`). Slice
7001    // by char-index, not byte-index — `&content[last..pos]` would
7002    // panic on multi-byte token chars like Comma TOKEN (`\u{e19a}`,
7003    // 2 UTF-8 bytes) and Inbrace (`\u{e18f}`, 2 bytes).
7004    let chars: Vec<char> = content.chars().collect();
7005    let mut results = Vec::new();
7006    let mut last: usize = 0;
7007    for &pos in positions {
7008        let part: String = chars[last..pos].iter().collect();
7009        results.push(format!("{}{}{}", prefix, part, suffix));
7010        last = pos + 1;
7011    }
7012    let tail: String = chars[last..].iter().collect();
7013    results.push(format!("{}{}{}", prefix, tail, suffix));
7014    Some(results)
7015}
7016
7017fn expand_ccl(prefix: &str, content: &str, suffix: &str) -> Option<Vec<String>> {
7018    // c:2421-2470 — `if (!comma && isset(BRACECCL)) { /* {a-mnop} */`
7019    //     "Here we expand each character to a separate node, but also
7020    //      ranges of characters like a-m.  ccl is a set of flags saying
7021    //      whether each character is present; the final list is in
7022    //      lexical order."
7023    use crate::ported::zsh_h::{Meta, Pound};
7024    use crate::ported::ztype_h::{imeta, itok};
7025    // !!! RUST-ONLY adapter (no C counterpart) !!! C walks the metafied
7026    // BYTES of the word. A zshrs word is a UTF-8 `str` holding tokens as
7027    // PUA chars (crate::token_char), a byte that is not valid UTF-8 as `Meta` +
7028    // `byte ^ 32`, and NUL as a plain char. Rebuild C's byte string: a
7029    // token is its one byte, a Meta pair stays a pair, and any other char
7030    // contributes its UTF-8 bytes, metafied where C's would be (`imeta`,
7031    // which includes NUL).
7032    let mut str: Vec<u8> = Vec::with_capacity(content.len());
7033    let mut chars = content.chars();
7034    while let Some(c) = chars.next() {
7035        let cu = c as u32;
7036        if cu == Meta as u32 {
7037            str.push(Meta);
7038            if let Some(n) = chars.next() {
7039                str.push(n as u32 as u8);
7040            }
7041        } else if crate::token_char::itok_char(c) {
7042            str.push(cu as u8); // the PUA token's low byte IS the C token byte
7043        } else {
7044            let mut buf = [0u8; 4];
7045            for &b in c.encode_utf8(&mut buf).as_bytes() {
7046                if imeta(b) {
7047                    str.push(Meta);
7048                    str.push(b ^ 32);
7049                } else {
7050                    str.push(b);
7051                }
7052            }
7053        }
7054    }
7055    let untok = |b: u8| -> u8 {
7056        if itok(b) {
7057            ZTOKENS.as_bytes()[(b - Pound as u8) as usize]
7058        } else {
7059            b
7060        }
7061    };
7062    let str2 = str.len();
7063    let mut ccl = [false; 256]; // c:2426 `char ccl[256]`
7064    let mut lastch: i32 = -1; // c:2429
7065    let mut p = 0usize;
7066    while p < str2 {
7067        // c:2433
7068        let mut c1 = untok(str[p]); // c:2434-2435
7069        p += 1;
7070        if c1 == Meta && p < str2 {
7071            c1 = 32 ^ str[p]; // c:2436-2437 `c1 = 32 ^ *p++;`
7072            p += 1;
7073        }
7074        // c:2438-2441 — `if (itok(c2 = *p)) …; if ((char) c2 == Meta) c2 = 32 ^ p[1];`
7075        let mut c2 = str.get(p).map_or(0, |&b| untok(b));
7076        if c2 == Meta {
7077            c2 = 32 ^ str.get(p + 1).copied().unwrap_or(0);
7078        }
7079        // c:2442-2446 — `if (IS_DASH((char)c1) && lastch >= 0 &&
7080        //                   p < str2 && lastch <= (int)c2)`
7081        if c1 == b'-' && lastch >= 0 && p < str2 && lastch <= c2 as i32 {
7082            while lastch < c2 as i32 {
7083                ccl[lastch as usize] = true; // c:2445 `ccl[lastch++] = 1;`
7084                lastch += 1;
7085            }
7086            lastch = -1; // c:2446
7087        } else {
7088            lastch = c1 as i32; // c:2448 `ccl[lastch = c1] = 1;`
7089            ccl[c1 as usize] = true;
7090        }
7091    }
7092    // c:2452-2466 — walk ccl from the top, inserting each after `last`, so
7093    // the list comes out in ascending byte order.
7094    let mut results = Vec::new();
7095    for c1 in 0..=255u8 {
7096        if !ccl[c1 as usize] {
7097            continue;
7098        }
7099        // c:2455-2463 — `if (imeta(c1)) { str[pl] = Meta; str[pl+1] = c1 ^ 32; }
7100        // else str[pl] = c1;`. In zshrs' representation (see above) an ASCII
7101        // byte, NUL included, is its own char, and any byte >= 0x80 is a
7102        // lone non-UTF-8 byte, held as `Meta` + `byte ^ 32`. Emitting NUL as
7103        // C's Meta pair left a stray Meta char in the word.
7104        let mid = if c1 < 0x80 {
7105            char::from(c1).to_string()
7106        } else {
7107            [char::from(Meta), char::from(c1 ^ 32)].iter().collect()
7108        };
7109        results.push(format!("{}{}{}", prefix, mid, suffix));
7110    }
7111    Some(results)
7112}
7113
7114// ============================================================================
7115// Convenience functions
7116// ============================================================================
7117
7118/// Split a pattern at its trailing zsh-style qualifier suffix. Returns
7119/// `(pattern_without_qualifier, qualifier_inner)` — the inner is the bytes
7120/// between the matching parens, without the surrounding `()` and without
7121/// any leading `#q`. Returns `(pattern, None)` when there is no qualifier
7122/// suffix. Useful for callers that want to use the pattern half with the
7123/// runtime [`matchpat`] (which has no qualifier semantics) while
7124/// reporting or applying the qualifier separately.
7125/// Split a glob pattern into (path-pattern, qualifier-string).
7126/// Port of the qualifier-detection step in `parsepat()`
7127/// (Src/glob.c:791).
7128pub fn split_qualifier(pattern: &str) -> (&str, Option<&str>) {
7129    if !pattern.ends_with(')') {
7130        return (pattern, None);
7131    }
7132    let bytes = pattern.as_bytes();
7133    let mut depth = 0;
7134    for i in (0..bytes.len()).rev() {
7135        match bytes[i] {
7136            b')' => depth += 1,
7137            b'(' => {
7138                depth -= 1;
7139                if depth == 0 {
7140                    let inner = &pattern[i + 1..pattern.len() - 1];
7141                    let inner = inner.strip_prefix("#q").unwrap_or(inner);
7142                    return (&pattern[..i], Some(inner));
7143                }
7144            }
7145            _ => {}
7146        }
7147    }
7148    (pattern, None)
7149}
7150
7151/// Strip the `./` prefix that `read_dir(".")` introduces on relative match
7152/// paths. Rust's `read_dir(".")` yields `entry.path()` like `./foo` while
7153/// `read_dir("foo")` yields `foo/bar` — zsh prints the latter shape for both.
7154/// `scanner` uses base `"."` only for the top-level relative scan (the
7155/// `from_cwd.is_empty()` arm), so that prefix is the ONLY text this port adds
7156/// to a match; a USER-typed leading `./` / `../` is re-prepended from the
7157/// pattern by `globdata_glob`.
7158///
7159/// Everything else is emitted VERBATIM. zsh preserves the literal path text
7160/// the user typed, including empty (`//`) and `.` components:
7161/// ```text
7162/// $ zsh -f -c 'print -r -- sub//f*(N) sub/./f*(N) ./sub//f*(N)'
7163/// sub//foo sub/./foo ./sub//foo
7164/// ```
7165/// This used to rebuild the string via `Path::components()`, which drops
7166/// `CurDir` and never yields empty components — so `sub//foo` came back as
7167/// `sub/foo` and `sub/./foo` as `sub/foo`. That is the same class of bug as
7168/// the `addpath` slash-squeeze (glob.rs:354): `_path_files` anchors its
7169/// matches on the on-line path text, so a rewritten path drops every match
7170/// and completion after `ls sub//` produces nothing at all.
7171fn glob_emit_path(path: &Path) -> String {
7172    let s = path.to_string_lossy();
7173    let mut rest: &str = &s;
7174    while let Some(stripped) = rest.strip_prefix("./") {
7175        rest = stripped;
7176    }
7177    if rest.is_empty() {
7178        ".".to_string()
7179    } else {
7180        rest.to_string()
7181    }
7182}
7183
7184/// Check if path is a symlink
7185/// Check whether a glob match is a symlink.
7186/// Port of the `S_ISLNK(lstat.st_mode)` test in Src/glob.c.
7187pub fn is_symlink(path: &str) -> bool {
7188    fs::symlink_metadata(path)
7189        .map(|m| m.file_type().is_symlink())
7190        .unwrap_or(false)
7191}
7192
7193// ===========================================================
7194// Methods moved verbatim from src/ported/vm_helper because their
7195// C counterpart's source file maps 1:1 to this Rust module.
7196// Phase: glob
7197// ===========================================================
7198
7199// BEGIN moved-from-exec-rs
7200// (impl ShellExecutor block moved to src/exec_shims.rs — see file marker)
7201
7202// END moved-from-exec-rs
7203
7204// ===========================================================
7205// Methods moved verbatim from src/ported/vm_helper because their
7206// C counterpart's source file maps 1:1 to this Rust module.
7207// Phase: drift
7208// ===========================================================
7209
7210// BEGIN moved-from-exec-rs
7211// (impl ShellExecutor block moved to src/exec_shims.rs — see file marker)
7212
7213// END moved-from-exec-rs
7214
7215/// !!! RUST-ONLY adapter — NO DIRECT C COUNTERPART !!!
7216///
7217/// Thin "pattern string in, match list out" entry over the glob engine
7218/// for call sites holding a single pattern that want its matches as a
7219/// `Vec<String>`, rather than going through C's in-place
7220/// `zglob(LinkList, LinkNode, int)` list mutation. Snapshots the
7221/// glob-relevant options into TLS (`enter_glob_scope` — required because
7222/// zshrs runs glob on multiple threads; see that fn's doc) and drives
7223/// `globdata_glob`, the RUST-ONLY read_dir-based adaptation of C's
7224/// `scanner()` (Src/glob.c:500 — C descends with `lchdir`, which is
7225/// process-global and unsafe under zshrs's threading, so the port walks
7226/// absolute paths instead). ALL real glob semantics — brace expansion,
7227/// `(a|b)` alternation, `^`/`~` exclusion, qualifiers, `**`, sorting —
7228/// live in `globdata_glob`/`scanner`/`patcompile`, not here. (The 130
7229/// lines of ad-hoc `^`/`~`/alternation read_dir+matchpat code that used
7230/// to live here were verified redundant with `globdata_glob` and
7231/// removed.) The faithful C entry is `zglob` (c:1214).
7232pub fn glob_path(pattern: &str) -> Vec<String> {
7233    let _glob_scope = enter_glob_scope();
7234    // c:1864 `matchct = 0;` — nothing matched yet for this glob.
7235    CURGLOBDATA.lock().unwrap_or_else(|e| e.into_inner()).matchct = 0;
7236    let mut state = globdata::new();
7237    globdata_glob(&mut state, pattern)
7238}
7239
7240// The qualifier-comparison direction static is `g_range` (Src/glob.c, ported at
7241// glob.rs ~2799). The duplicate `G_RANGE` that used to live here was a Rust-only
7242// erroneous second copy — never written by the qual-eval, so qualnlink (its only
7243// reader) was stuck on `==`. Removed; qualnlink now reads `g_range`.
7244
7245// `mode_to_octal` lives at `crate::ported::utils::mode_to_octal`
7246// (port of `Src/utils.c:7634` — 12 bit-by-bit POSIX permission
7247// mappings). Local alias kept for the call sites at c:1610/1618
7248// which used the masked identity before the canonical port landed.
7249fn mode_to_octal(mode: u32) -> u32 {
7250    crate::ported::utils::mode_to_octal(mode) as u32
7251}
7252
7253#[cfg(test)]
7254mod tests {
7255    use super::*;
7256    use std::fs::{self, File};
7257    use tempfile::TempDir;
7258
7259    use crate::ported::options::{opt_state_set, opt_state_unset};
7260    use crate::ported::zsh_h::{redir, ERRFLAG_ERROR, REDIR_WRITE};
7261
7262    /// Convert ASCII brace-expansion source to the lexer-tokenized
7263    /// form `hasbraces` / `xpandbraces` consume per c:Src/glob.c —
7264    /// ASCII `{` → Inbrace (\u{e18f}), `}` → Outbrace (\u{e190}), `,` →
7265    /// Comma (\u{e19a}). Backslash-escaped variants (`\{`, `\}`, `\,`)
7266    /// emit Bnull + literal so the canonical "escape via Bnull"
7267    /// distinction reaches the brace walker. Used by every test in
7268    /// this module that wants to drive the canonical TOKEN-strict
7269    /// path with readable ASCII source.
7270    fn tok(s: &str) -> String {
7271        let mut out = String::with_capacity(s.len());
7272        let mut chars = s.chars().peekable();
7273        while let Some(c) = chars.next() {
7274            match c {
7275                '\\' => match chars.peek() {
7276                    Some('{') | Some('}') | Some(',') => {
7277                        out.push('\u{e19f}');
7278                        out.push(chars.next().unwrap());
7279                    }
7280                    _ => out.push(c),
7281                },
7282                '{' => out.push('\u{e18f}'),
7283                '}' => out.push('\u{e190}'),
7284                ',' => out.push('\u{e19a}'),
7285                _ => out.push(c),
7286            }
7287        }
7288        out
7289    }
7290
7291    fn setup_test_dir() -> TempDir {
7292        let dir = TempDir::new().unwrap();
7293        let base = dir.path();
7294
7295        // Create test files
7296        File::create(base.join("file1.txt")).unwrap();
7297        File::create(base.join("file2.txt")).unwrap();
7298        File::create(base.join("file3.rs")).unwrap();
7299        File::create(base.join(".hidden")).unwrap();
7300
7301        // Create subdirectory
7302        fs::create_dir(base.join("subdir")).unwrap();
7303        File::create(base.join("subdir/nested.txt")).unwrap();
7304
7305        dir
7306    }
7307
7308    #[test]
7309    fn test_haswilds() {
7310        let _g = crate::test_util::global_state_lock();
7311        // c:Src/glob.c:3548 — full glob tokenize (shadows the
7312        // brace-only `fn tok` test helper above on purpose:
7313        // haswilds consumes glob tokens, not just braces).
7314        let tok = |s: &str| {
7315            let mut t = s.to_string();
7316            tokenize(&mut t);
7317            t
7318        };
7319        assert!(haswilds(&tok("*.txt")));
7320        assert!(haswilds(&tok("file?.txt")));
7321        assert!(haswilds(&tok("file[12].txt")));
7322        assert!(!haswilds(&tok("file.txt")));
7323        assert!(!haswilds(&tok("path/to/file.txt")));
7324    }
7325
7326    #[test]
7327    fn test_pattern_match() {
7328        let _g = crate::test_util::global_state_lock();
7329    /// `**/` fans sibling subdirs out over the rayon pool; the merged result
7330    /// must be the same set a plain recursive walk finds, with dot dirs and
7331    /// non-matching files excluded.
7332    #[test]
7333    fn recursive_glob_parallel_matches_plain_walk() {
7334        let _g = crate::test_util::global_state_lock();
7335        let dir = TempDir::new().unwrap();
7336        let base = dir.path();
7337        let mut expected: Vec<String> = Vec::new();
7338        for a in 0..6 {
7339            for b in 0..4 {
7340                let d = base.join(format!("d{a}/s{b}/leaf"));
7341                fs::create_dir_all(&d).unwrap();
7342                for (parent, tag) in [(d.parent().unwrap().to_path_buf(), "s"), (d.clone(), "l")] {
7343                    let hit = parent.join(format!("{tag}.zsh"));
7344                    File::create(&hit).unwrap();
7345                    File::create(parent.join(format!("{tag}.txt"))).unwrap();
7346                    expected.push(hit.to_string_lossy().into_owned());
7347                }
7348            }
7349        }
7350        fs::create_dir_all(base.join(".hid/x")).unwrap();
7351        File::create(base.join(".hid/x/h.zsh")).unwrap();
7352        expected.sort();
7353
7354        let mut pattern = format!("{}/**/*.zsh", base.display());
7355        zshtokenize(&mut pattern, ZSHTOK_SUBST);
7356        let mut state = globdata::new();
7357        let mut got = globdata_glob(&mut state, &pattern);
7358        got.sort();
7359        assert_eq!(got, expected);
7360    }
7361
7362        assert!(matchpat("*.txt", "file.txt", false, true));
7363        assert!(matchpat("file?.txt", "file1.txt", false, true));
7364        assert!(!matchpat("*.txt", "file.rs", false, true));
7365        assert!(matchpat("file[12].txt", "file1.txt", false, true));
7366        assert!(!matchpat("file[12].txt", "file3.txt", false, true));
7367    }
7368
7369    #[test]
7370    fn test_brace_expansion() {
7371        let _g = crate::test_util::global_state_lock();
7372        let result = xpandbraces(&tok("{a,b,c}"), false);
7373        assert_eq!(result, vec!["a", "b", "c"]);
7374
7375        let result = xpandbraces(&tok("file{1,2,3}.txt"), false);
7376        assert_eq!(result, vec!["file1.txt", "file2.txt", "file3.txt"]);
7377
7378        let result = xpandbraces(&tok("{1..5}"), false);
7379        assert_eq!(result, vec!["1", "2", "3", "4", "5"]);
7380
7381        let result = xpandbraces(&tok("{a..e}"), false);
7382        assert_eq!(result, vec!["a", "b", "c", "d", "e"]);
7383    }
7384
7385    /// c:Src/glob.c — an ESCAPED `~` is a literal, never the
7386    /// extendedglob exclusion operator. `shtokenize` (ZSHTOK_SUBST,
7387    /// c:3591) encodes `\X` as `Bnullkeep X` — that is the form every
7388    /// `${~var}` pattern arrives in, including `_path_files`' final
7389    /// `tmp1=( $~tmp1 )` whose patterns were quoted with
7390    /// QT_BACKSLASH_PATTERN by `compfiles` (computil.c:5001). The
7391    /// exclusion splitter recognised only `Bnull`, so it split at the
7392    /// escaped tilde and the glob matched nothing:
7393    /// `cp /etc/paths~or<TAB>` inserted nothing where zsh inserts
7394    /// `/etc/paths\~orig`.
7395    #[test]
7396    fn glob_escaped_tilde_is_literal_not_exclusion() {
7397        let _g = crate::test_util::global_state_lock();
7398        let dir = TempDir::new().unwrap();
7399        let base = dir.path();
7400        File::create(base.join("a~b")).unwrap();
7401        File::create(base.join("aXb")).unwrap();
7402
7403        let saved = opt_state_get("extendedglob");
7404        opt_state_set("extendedglob", true);
7405
7406        // Bnullkeep (`${~var}`), Bnull (plain tokenize) and the raw
7407        // backslash form that `glob_path` callers such as `_path_files`
7408        // hand over untokenized must all be treated as an escape.
7409        for tokflags in [Some(ZSHTOK_SUBST), Some(0), None] {
7410            let mut pattern = format!("{}/a\\~b*", base.display());
7411            if let Some(flags) = tokflags {
7412                zshtokenize(&mut pattern, flags);
7413            }
7414            let mut state = globdata::new();
7415            let results = globdata_glob(&mut state, &pattern);
7416            assert_eq!(
7417                results.len(),
7418                1,
7419                "escaped tilde must match literally (tokflags={tokflags:?}), got {results:?}"
7420            );
7421            assert!(results[0].ends_with("a~b"), "matched {:?}", results[0]);
7422        }
7423
7424        // An UNescaped top-level `~` still excludes.
7425        let mut pattern = format!("{}/a*~*Xb", base.display());
7426        zshtokenize(&mut pattern, ZSHTOK_SUBST);
7427        let mut state = globdata::new();
7428        let results = globdata_glob(&mut state, &pattern);
7429        assert_eq!(results.len(), 1, "exclusion still applies, got {results:?}");
7430        assert!(results[0].ends_with("a~b"), "matched {:?}", results[0]);
7431
7432        match saved {
7433            Some(v) => opt_state_set("extendedglob", v),
7434            None => opt_state_unset("extendedglob"),
7435        }
7436    }
7437
7438    /// c:Src/glob.c:1260-1302 — qualifier groups are peeled off the end one
7439    /// at a time, so `*(#qOn)(.)` applies BOTH. `_path_files` builds exactly
7440    /// this shape for `file-sort` (sh:178, `*(#qom)(-/)` under `-/`) and
7441    /// hands it over untokenized; the untokenized scan took only the last
7442    /// group, leaving `(#qOn)` as pattern text that sorts nothing. zsh 5.9.2:
7443    /// `setopt extendedglob; print *(#qOn)(.)` in {a,b,c,d/} prints `c b a`.
7444    #[test]
7445    fn test_glob_two_qualifier_groups_apply_both() {
7446        let _g = crate::test_util::global_state_lock();
7447        let dir = TempDir::new().unwrap();
7448        let base = dir.path();
7449        for f in ["a", "b", "c"] {
7450            File::create(base.join(f)).unwrap();
7451        }
7452        fs::create_dir(base.join("d")).unwrap();
7453
7454        let saved = opt_state_get("extendedglob");
7455        opt_state_set("extendedglob", true);
7456        let mut got = Vec::new();
7457        for tokflags in [Some(ZSHTOK_SUBST), None] {
7458            let mut pattern = format!("{}/*(#qOn)(.)", base.display());
7459            if let Some(flags) = tokflags {
7460                zshtokenize(&mut pattern, flags);
7461            }
7462            let mut state = globdata::new();
7463            let names: Vec<String> = globdata_glob(&mut state, &pattern)
7464                .iter()
7465                .map(|p| p.rsplit('/').next().unwrap_or("").to_string())
7466                .collect();
7467            got.push((tokflags, names));
7468        }
7469        match saved {
7470            Some(v) => opt_state_set("extendedglob", v),
7471            None => opt_state_unset("extendedglob"),
7472        }
7473        for (tokflags, names) in got {
7474            assert_eq!(names, vec!["c", "b", "a"], "tokflags={tokflags:?}");
7475        }
7476    }
7477
7478    #[test]
7479    fn test_glob_simple() {
7480        let _g = crate::test_util::global_state_lock();
7481        let dir = setup_test_dir();
7482        let pattern = format!("{}/*.txt", dir.path().display());
7483
7484        let mut state = globdata::new();
7485        let results = globdata_glob(&mut state, &pattern);
7486
7487        assert_eq!(results.len(), 2);
7488        assert!(results.iter().any(|s| s.ends_with("file1.txt")));
7489        assert!(results.iter().any(|s| s.ends_with("file2.txt")));
7490    }
7491
7492    #[test]
7493    fn test_glob_hidden() {
7494        let _g = crate::test_util::global_state_lock();
7495        let dir = setup_test_dir();
7496        let pattern = format!("{}/*", dir.path().display());
7497
7498        // Default (globdots off) → hidden files skipped. `dotglob`
7499        // is the bash alias for zsh's canonical `globdots` option;
7500        // the C-faithful read goes through `isset(GLOBDOTS)` which
7501        // resolves the canonical name.
7502        opt_state_set("globdots", false);
7503        let mut state = globdata::new();
7504        let results = globdata_glob(&mut state, &pattern);
7505        assert!(!results.iter().any(|s| s.contains(".hidden")));
7506
7507        // setopt globdots → hidden files included.
7508        opt_state_set("globdots", true);
7509        let mut state = globdata::new();
7510        let results = globdata_glob(&mut state, &pattern);
7511        assert!(results.iter().any(|s| s.contains(".hidden")));
7512        opt_state_set("globdots", false); // reset for other tests
7513    }
7514
7515    #[test]
7516    fn test_glob_emit_path_strips_read_dir_dot_slash() {
7517        let _g = crate::test_util::global_state_lock();
7518        assert_eq!(glob_emit_path(Path::new("./sub")), "sub");
7519        assert_eq!(glob_emit_path(Path::new("sub/deeper")), "sub/deeper");
7520        assert_eq!(glob_emit_path(Path::new("././x")), "x");
7521        assert_eq!(glob_emit_path(Path::new("../up")), "../up");
7522        // Empty path (`Path::new("")`) → "." as before.
7523        assert_eq!(glob_emit_path(Path::new("")), ".");
7524        assert_eq!(glob_emit_path(Path::new("./")), ".");
7525    }
7526
7527    /// Beyond the leading `read_dir(".")` prefix, `glob_emit_path` must emit
7528    /// the path VERBATIM — it must not re-normalize it through
7529    /// `Path::components()`, which silently drops empty (`//`) and `.`
7530    /// components. zsh keeps the literal text the user typed:
7531    /// ```text
7532    /// $ zsh -f -c 'print -r -- sub//f*(N) sub/./f*(N) ./sub//f*(N)'
7533    /// sub//foo sub/./foo ./sub//foo
7534    /// ```
7535    /// `_path_files` anchors its matches on that text, so any rewrite drops
7536    /// every match and completion after `ls sub//` yields nothing at all.
7537    #[test]
7538    fn test_glob_emit_path_keeps_empty_and_dot_components_verbatim() {
7539        let _g = crate::test_util::global_state_lock();
7540        // Empty components survive (`//`), relative and absolute.
7541        assert_eq!(glob_emit_path(Path::new("sub//foo")), "sub//foo");
7542        assert_eq!(glob_emit_path(Path::new("//usr//bin")), "//usr//bin");
7543        // Interior `.` / `..` survive.
7544        assert_eq!(glob_emit_path(Path::new("sub/./foo")), "sub/./foo");
7545        assert_eq!(glob_emit_path(Path::new("/usr/./bin")), "/usr/./bin");
7546        assert_eq!(glob_emit_path(Path::new("a/..//a/b")), "a/..//a/b");
7547        // Only the engine-introduced leading `./` is removed; what follows
7548        // it is untouched (globdata_glob re-prepends a user-typed `./`).
7549        assert_eq!(glob_emit_path(Path::new("./sub//foo")), "sub//foo");
7550        // Trailing separator is preserved (previously dropped).
7551        assert_eq!(glob_emit_path(Path::new("sub/")), "sub/");
7552    }
7553
7554    /// `Src/glob.c:270-273` — `addpath` appends the component then a `/`
7555    /// UNCONDITIONALLY. An empty component (`l == 0`, which is exactly
7556    /// what a `//` in the pattern produces, since `patcompile` with
7557    /// `PAT_FILET` cuts a pure section at the first `/`) must therefore
7558    /// still push one `/`. A `if !s.ends_with('/')` guard used to live
7559    /// here and silently collapsed `//` → `/`.
7560    #[test]
7561    fn addpath_appends_slash_unconditionally() {
7562        // Empty component after an existing separator: C writes nothing
7563        // from `s`, then writes '/', so `/` becomes `//`.
7564        let mut p = String::from("/");
7565        addpath(&mut p, "");
7566        assert_eq!(p, "//");
7567
7568        // Two empty components in a row (pattern `///x`).
7569        addpath(&mut p, "");
7570        assert_eq!(p, "///");
7571
7572        // Ordinary components are unaffected — a non-empty component can
7573        // never itself end in `/` (patcompile cut the section there).
7574        let mut q = String::from("/");
7575        addpath(&mut q, "usr");
7576        assert_eq!(q, "/usr/");
7577        addpath(&mut q, "");
7578        assert_eq!(q, "/usr//");
7579        addpath(&mut q, "local");
7580        assert_eq!(q, "/usr//local/");
7581
7582        // Relative start (`pathbuf` empty per c:818).
7583        let mut r = String::new();
7584        addpath(&mut r, "a");
7585        assert_eq!(r, "a/");
7586        addpath(&mut r, ".");
7587        assert_eq!(r, "a/./");
7588        addpath(&mut r, "..");
7589        assert_eq!(r, "a/./../");
7590    }
7591
7592    /// End-to-end: the globber must reproduce the literal path text the
7593    /// user typed, including empty (`//`) and `.` / `..` components.
7594    /// `_path_files` anchors its completion matches on that exact text
7595    /// (`${(@)tmp1#$prepath$realpath$testpath}`), so any squeezing of the
7596    /// path discards every match and completion after `ls //usr//`
7597    /// produces nothing at all.
7598    ///
7599    /// zsh reference:
7600    /// ```text
7601    /// $ zsh -f -c 'print -r -- //usr//b*(N) /usr/./b*(N)'
7602    /// //usr//bin /usr/./bin
7603    /// ```
7604    #[test]
7605    fn glob_preserves_empty_and_dot_path_components() {
7606        let _g = crate::test_util::global_state_lock();
7607        let dir = setup_test_dir();
7608        let base = dir.path().display().to_string();
7609
7610        // `//` between the directory and the wildcard component.
7611        let mut state = globdata::new();
7612        let results = globdata_glob(&mut state, &format!("{}//file1.tx?", base));
7613        assert_eq!(results, vec![format!("{}//file1.txt", base)]);
7614
7615        // `/./` — a non-empty component, already preserved; pinned so a
7616        // future "normalize the path" change can't quietly drop it.
7617        let mut state = globdata::new();
7618        let results = globdata_glob(&mut state, &format!("{}/./file1.tx?", base));
7619        assert_eq!(results, vec![format!("{}/./file1.txt", base)]);
7620
7621        // `/..//` — `..` followed by an empty component, round-tripped
7622        // verbatim even though it resolves back to the same directory.
7623        let mut state = globdata::new();
7624        let results = globdata_glob(
7625            &mut state,
7626            &format!("{}/subdir/..//subdir/nested.tx?", base),
7627        );
7628        assert_eq!(
7629            results,
7630            vec![format!("{}/subdir/..//subdir/nested.txt", base)]
7631        );
7632
7633        // Leading `//` on an absolute pattern (`parsepat` c:813-816 strips
7634        // exactly ONE leading `/`; the second becomes an empty component).
7635        let mut state = globdata::new();
7636        let results = globdata_glob(&mut state, &format!("/{}//file2.tx?", base));
7637        assert_eq!(results, vec![format!("/{}//file2.txt", base)]);
7638    }
7639
7640    #[test]
7641    fn test_file_type_char() {
7642        let _g = crate::test_util::global_state_lock();
7643        assert_eq!(file_type(libc::S_IFDIR as u32), '/');
7644        assert_eq!(file_type(libc::S_IFREG as u32), ' ');
7645        assert_eq!(file_type(libc::S_IFREG as u32 | 0o111), '*');
7646        assert_eq!(file_type(libc::S_IFLNK as u32), '@');
7647    }
7648
7649    /// `Src/glob.c:2018-2036` — `file_type(mode_t filemode)` returns a
7650    /// single-char marker keyed off S_ISBLK/CHR/DIR/FIFO/LNK/REG/SOCK,
7651    /// with `S_IXUGO` (== 0o111 = S_IXUSR|S_IXGRP|S_IXOTH) distinguishing
7652    /// executable regular files (`*`) from non-executable (` `). The
7653    /// catch-all (e.g. door, port, unknown types) returns `?`.
7654    /// Pin every branch by position.
7655    #[test]
7656    fn file_type_every_branch_matches_c_dispatch() {
7657        let _g = crate::test_util::global_state_lock();
7658        assert_eq!(
7659            file_type(libc::S_IFBLK as u32),
7660            '#',
7661            "c:2020 — S_ISBLK → '#'"
7662        );
7663        assert_eq!(
7664            file_type(libc::S_IFCHR as u32),
7665            '%',
7666            "c:2022 — S_ISCHR → '%'"
7667        );
7668        assert_eq!(
7669            file_type(libc::S_IFDIR as u32),
7670            '/',
7671            "c:2024 — S_ISDIR → '/'"
7672        );
7673        assert_eq!(
7674            file_type(libc::S_IFIFO as u32),
7675            '|',
7676            "c:2026 — S_ISFIFO → '|'"
7677        );
7678        assert_eq!(
7679            file_type(libc::S_IFLNK as u32),
7680            '@',
7681            "c:2028 — S_ISLNK → '@'"
7682        );
7683        // Regular file: ' ' if not executable, '*' if executable (any bit in S_IXUGO).
7684        assert_eq!(
7685            file_type(libc::S_IFREG as u32),
7686            ' ',
7687            "c:2030 — non-executable regular file → ' '"
7688        );
7689        // Each individual exec bit triggers '*'.
7690        assert_eq!(
7691            file_type(libc::S_IFREG as u32 | 0o100),
7692            '*',
7693            "c:2030 — S_IXUSR alone is enough"
7694        );
7695        assert_eq!(
7696            file_type(libc::S_IFREG as u32 | 0o010),
7697            '*',
7698            "c:2030 — S_IXGRP alone is enough"
7699        );
7700        assert_eq!(
7701            file_type(libc::S_IFREG as u32 | 0o001),
7702            '*',
7703            "c:2030 — S_IXOTH alone is enough"
7704        );
7705        assert_eq!(
7706            file_type(libc::S_IFSOCK as u32),
7707            '=',
7708            "c:2033 — S_ISSOCK → '='"
7709        );
7710        // Catch-all for unknown S_IFMT — bare 0 returns '?'.
7711        assert_eq!(file_type(0), '?', "c:2035 — unknown st_mode → '?'");
7712    }
7713
7714    #[test]
7715    fn test_zstrcmp_numeric() {
7716        let _g = crate::test_util::global_state_lock();
7717        let n = crate::zsh_h::SORTIT_NUMERICALLY as u32;
7718        assert_eq!(zstrcmp("file1", "file2", n), std::cmp::Ordering::Less);
7719        assert_eq!(zstrcmp("file10", "file2", n), std::cmp::Ordering::Greater);
7720        assert_eq!(zstrcmp("file10", "file10", n), std::cmp::Ordering::Equal);
7721    }
7722
7723    /// Race-fix verification: snapshot pins bareglobqual for the
7724    /// duration of a glob scope even when the live store flips
7725    /// underneath. Mimics the stryke pattern in
7726    /// MenkeTechnologies/strykelang#3 — one thread is mid-glob with
7727    /// bareglobqual=1 snapshot when another flips it off.
7728    #[test]
7729    fn glob_opts_snapshot_isolates_concurrent_setopt() {
7730        let _g = crate::test_util::global_state_lock();
7731
7732        // Preserve the existing live state so the test is hermetic.
7733        let saved = opt_state_get("bareglobqual");
7734
7735        // Live store says bareglobqual=true; enter scope captures that.
7736        opt_state_set("bareglobqual", true);
7737        let _scope = enter_glob_scope();
7738        assert!(
7739            glob_isset(BAREGLOBQUAL),
7740            "TLS snapshot reads bareglobqual=true at scope entry"
7741        );
7742
7743        // Simulate a concurrent thread flipping the live store.
7744        opt_state_set("bareglobqual", false);
7745        assert!(
7746            glob_isset(BAREGLOBQUAL),
7747            "TLS snapshot must still report bareglobqual=true \
7748             even though live store now reads false"
7749        );
7750
7751        // Restore.
7752        match saved {
7753            Some(v) => opt_state_set("bareglobqual", v),
7754            None => opt_state_unset("bareglobqual"),
7755        }
7756    }
7757
7758    /// After the scope guard drops, reads fall back to the live store.
7759    #[test]
7760    fn glob_opts_snapshot_clears_on_drop() {
7761        let _g = crate::test_util::global_state_lock();
7762
7763        let saved = opt_state_get("nullglob");
7764        opt_state_set("nullglob", true);
7765        {
7766            let _scope = enter_glob_scope();
7767            assert!(glob_isset(NULLGLOB), "snapshot live=true → true inside");
7768        }
7769        // Outside scope: flip live store, read should follow live.
7770        opt_state_set("nullglob", false);
7771        assert!(
7772            !glob_isset(NULLGLOB),
7773            "post-scope: glob_isset falls back to live store"
7774        );
7775
7776        match saved {
7777            Some(v) => opt_state_set("nullglob", v),
7778            None => opt_state_unset("nullglob"),
7779        }
7780    }
7781
7782    /// Nested glob scopes share the outer snapshot — inner doesn't
7783    /// re-capture or clear on its own Drop.
7784    #[test]
7785    fn glob_opts_snapshot_nested_is_noop() {
7786        let _g = crate::test_util::global_state_lock();
7787
7788        let saved = opt_state_get("extendedglob");
7789        opt_state_set("extendedglob", true);
7790        let _outer = enter_glob_scope();
7791        assert!(glob_isset(EXTENDEDGLOB));
7792        {
7793            let _inner = enter_glob_scope();
7794            // Live store flip while nested.
7795            opt_state_set("extendedglob", false);
7796            assert!(glob_isset(EXTENDEDGLOB), "inner observes outer snapshot");
7797        } // inner drops — outer snapshot still active.
7798        assert!(
7799            glob_isset(EXTENDEDGLOB),
7800            "outer snapshot survives inner drop"
7801        );
7802
7803        match saved {
7804            Some(v) => opt_state_set("extendedglob", v),
7805            None => opt_state_unset("extendedglob"),
7806        }
7807    }
7808
7809    /// c:2150 (xpandredir port) — when the redir name has no wildcard
7810    /// AND no `$var`/`!hist`/`{a,b}` to expand, prefork+globlist leave
7811    /// the name unchanged. The single-match path at c:2171 must
7812    /// rewrite `fn.name` to the same string and return 0 (no multi-fan).
7813    /// Regression that returns 1 would trigger MULTIOS dispatch on a
7814    /// single literal filename — wrong shell semantics.
7815    #[test]
7816    fn xpandredir_single_literal_filename_returns_zero() {
7817        let _g = crate::test_util::global_state_lock();
7818        let mut fn_ = redir {
7819            typ: REDIR_WRITE,
7820            flags: 0,
7821            fd1: 1,
7822            fd2: -1,
7823            name: Some("/tmp/zshrs_test_out".to_string()),
7824            varid: None,
7825            here_terminator: None,
7826            munged_here_terminator: None,
7827        };
7828        let mut tab: Vec<redir> = Vec::new();
7829        let r = xpandredir(&mut fn_, &mut tab);
7830        assert_eq!(r, 0, "literal filename → single match → ret=0");
7831        assert_eq!(
7832            fn_.name.as_deref(),
7833            Some("/tmp/zshrs_test_out"),
7834            "literal name must round-trip through prefork unchanged"
7835        );
7836        assert!(tab.is_empty(), "no multi-match → redirtab not appended");
7837    }
7838
7839    /// c:2176-2177 — `>&-` (REDIR_MERGEOUT with name "-") collapses to
7840    /// REDIR_CLOSE per the `IS_DASH(s[0]) && !s[1]` branch. Regression
7841    /// where this fails leaves `>&-` as a literal merge-fd attempt,
7842    /// which the executor would interpret as "merge with fd -1".
7843    #[test]
7844    fn xpandredir_dash_merge_collapses_to_close() {
7845        let _g = crate::test_util::global_state_lock();
7846        let mut fn_ = redir {
7847            typ: REDIR_MERGEOUT,
7848            flags: 0,
7849            fd1: 1,
7850            fd2: -1,
7851            name: Some("-".to_string()),
7852            varid: None,
7853            here_terminator: None,
7854            munged_here_terminator: None,
7855        };
7856        let mut tab: Vec<redir> = Vec::new();
7857        let _ = xpandredir(&mut fn_, &mut tab);
7858        assert_eq!(
7859            fn_.typ, REDIR_CLOSE,
7860            "`>&-` must rewrite typ to REDIR_CLOSE"
7861        );
7862    }
7863
7864    /// c:2150 — empty `fn.name` should return 0 cleanly. Catches a
7865    /// regression that panics on `.as_deref().unwrap()` for absent name.
7866    #[test]
7867    fn xpandredir_with_no_name_returns_zero_no_panic() {
7868        let _g = crate::test_util::global_state_lock();
7869        let mut fn_ = redir {
7870            typ: REDIR_WRITE,
7871            flags: 0,
7872            fd1: 1,
7873            fd2: -1,
7874            name: None,
7875            varid: None,
7876            here_terminator: None,
7877            munged_here_terminator: None,
7878        };
7879        let mut tab: Vec<redir> = Vec::new();
7880        assert_eq!(xpandredir(&mut fn_, &mut tab), 0);
7881    }
7882
7883    /// `IN_EXPANDREDIR` static defaults to 0 — set transiently inside
7884    /// xpandredir per c:2165/2167. After a normal call it must restore
7885    /// to 0. Regression that leaks the flag would skew unrelated glob
7886    /// expansions outside redirections.
7887    #[test]
7888    fn in_expandredir_flag_is_zero_at_rest() {
7889        let _g = crate::test_util::global_state_lock();
7890        assert_eq!(IN_EXPANDREDIR.load(Ordering::SeqCst), 0);
7891    }
7892
7893    /// c:4306 — `haswilds` honours backslash-escapes: `\*` is a literal
7894    /// star, NOT a wildcard. Regression that ignores the escape would
7895    /// trigger globbing on `printf '%s\n' \*`, breaking shell scripts
7896    /// that quote literal stars.
7897    #[test]
7898    fn haswilds_respects_backslash_escape() {
7899        let _g = crate::test_util::global_state_lock();
7900        // c:Src/glob.c:3548 — full glob tokenize (shadows the
7901        // brace-only `fn tok` test helper above on purpose:
7902        // haswilds consumes glob tokens, not just braces).
7903        let tok = |s: &str| {
7904            let mut t = s.to_string();
7905            tokenize(&mut t);
7906            t
7907        };
7908        assert!(haswilds(&tok("*.txt")), "bare * is wild");
7909        assert!(
7910            !haswilds(&tok(r"\*.txt")),
7911            "escaped \\* is literal — NOT wild"
7912        );
7913        assert!(
7914            !haswilds(&tok(r"\?.txt")),
7915            "escaped \\? is literal — NOT wild"
7916        );
7917    }
7918
7919    /// c:4306 — `[` immediately enters bracket mode AND counts as a
7920    /// wildcard. The early return on `[` is critical — even
7921    /// unterminated brackets must be flagged so `cd [` doesn't try
7922    /// to chdir to a literal `[`.
7923    #[test]
7924    fn haswilds_open_bracket_alone_is_a_wildcard() {
7925        let _g = crate::test_util::global_state_lock();
7926        // c:Src/glob.c:3548 — full glob tokenize (shadows the
7927        // brace-only `fn tok` test helper above on purpose:
7928        // haswilds consumes glob tokens, not just braces).
7929        let tok = |s: &str| {
7930            let mut t = s.to_string();
7931            tokenize(&mut t);
7932            t
7933        };
7934        assert!(haswilds(&tok("[abc]")), "char-class is wild");
7935        assert!(haswilds(&tok("foo[")), "even unterminated [ is wild");
7936    }
7937
7938    /// c:4306 — wildcard chars `*` `?` inside an OPEN bracket-context
7939    /// are NOT additional wildcards (the bracket already is one).
7940    /// Regression that double-counts would make haswilds report `[*]`
7941    /// as wildcard-twice — cosmetic, but confuses any caller using
7942    /// the bool as a "should I glob?" gate that AND-tests with another
7943    /// flag.
7944    #[test]
7945    fn haswilds_extglob_chars_inside_bracket_dont_double_count() {
7946        let _g = crate::test_util::global_state_lock();
7947        // c:Src/glob.c:3548 — full glob tokenize (shadows the
7948        // brace-only `fn tok` test helper above on purpose:
7949        // haswilds consumes glob tokens, not just braces).
7950        let tok = |s: &str| {
7951            let mut t = s.to_string();
7952            tokenize(&mut t);
7953            t
7954        };
7955        // Once `[` is seen, function returns true immediately, so the
7956        // post-bracket chars don't matter. But this docs the contract.
7957        assert!(haswilds(&tok("[*]")));
7958    }
7959
7960    /// c:4306 — plain text returns false. Catches a regression where
7961    /// any non-empty input is flagged as wild (would break `cd /tmp`
7962    /// by triggering glob expansion on a literal path).
7963    #[test]
7964    fn haswilds_plain_text_not_wild() {
7965        let _g = crate::test_util::global_state_lock();
7966        // c:Src/glob.c:3548 — full glob tokenize (shadows the
7967        // brace-only `fn tok` test helper above on purpose:
7968        // haswilds consumes glob tokens, not just braces).
7969        let tok = |s: &str| {
7970            let mut t = s.to_string();
7971            tokenize(&mut t);
7972            t
7973        };
7974        assert!(!haswilds(&tok("plain")));
7975        assert!(!haswilds(&tok("")));
7976        assert!(!haswilds(&tok("/usr/local/bin")));
7977        assert!(!haswilds(&tok("file.txt")));
7978    }
7979
7980    /// c:4363-4371 — `#` and `^` are recognised as wildcards by
7981    /// `haswilds` **only when `EXTENDEDGLOB` is set**, matching the C
7982    /// source's `isset(EXTENDEDGLOB)` gate at pattern.c:4364/4369. `~`
7983    /// is **not** a filename-generation wildcard (zsh handles it as
7984    /// tilde expansion in a separate pipeline stage); a prior
7985    /// glob.rs-local `haswilds` impl returned true for `~`, which
7986    /// caused zglob to mis-classify tilde-prefixed args as needing
7987    /// filename generation. Pin the corrected behavior.
7988    #[test]
7989    fn haswilds_extended_glob_chars_recognised() {
7990        let _g = crate::test_util::global_state_lock();
7991        // c:Src/glob.c:3548 — full glob tokenize (shadows the
7992        // brace-only `fn tok` test helper above on purpose:
7993        // haswilds consumes glob tokens, not just braces).
7994        let tok = |s: &str| {
7995            let mut t = s.to_string();
7996            tokenize(&mut t);
7997            t
7998        };
7999        // EXTENDEDGLOB off → `#` and `^` are not wild.
8000        crate::ported::options::opt_state_set("extendedglob", false);
8001        assert!(
8002            !haswilds(&tok("foo#bar")),
8003            "# not wild without EXTENDEDGLOB"
8004        );
8005        assert!(
8006            !haswilds(&tok("foo^bar")),
8007            "^ not wild without EXTENDEDGLOB"
8008        );
8009        // EXTENDEDGLOB on → `#` and `^` are wild (c:4364, c:4369).
8010        crate::ported::options::opt_state_set("extendedglob", true);
8011        assert!(haswilds(&tok("foo#bar")), "# is extglob wild");
8012        assert!(haswilds(&tok("foo^bar")), "^ is extglob wild");
8013        crate::ported::options::opt_state_set("extendedglob", false);
8014        // `~` is NOT in haswilds' switch (c:4324-4373) — tilde expansion
8015        // is a separate pipeline stage.
8016        assert!(
8017            !haswilds(&tok("~/file")),
8018            "~ is NOT a filename-generation wildcard"
8019        );
8020    }
8021
8022    /// c:2514 — `matchpat` returns true for exact match. Sanity check
8023    /// the simplest dispatch path (the matcher recipe builds a
8024    /// trivial pattern, runs it).
8025    #[test]
8026    fn matchpat_exact_literal_matches() {
8027        let _g = crate::test_util::global_state_lock();
8028        assert!(matchpat("hello", "hello", false, true));
8029        assert!(!matchpat("hello", "world", false, true));
8030    }
8031
8032    /// c:2514 — `matchpat` with case_sensitive=false MUST treat upper
8033    /// and lower as equal (`HELLO` matches `hello`). Regression keeping
8034    /// case-sensitive when flag is false would silently break every
8035    /// `[[ "$x" = (#i)foo ]]`-style match.
8036    #[test]
8037    fn matchpat_case_insensitive_when_flag_clear() {
8038        let _g = crate::test_util::global_state_lock();
8039        assert!(
8040            matchpat("hello", "HELLO", false, false),
8041            "case-insensitive match must succeed across cases"
8042        );
8043        assert!(matchpat("FoO", "foo", false, false));
8044    }
8045
8046    /// c:2514 — case-sensitive (default) MUST reject case-different
8047    /// inputs. Pinning the contract.
8048    #[test]
8049    fn matchpat_case_sensitive_rejects_case_different() {
8050        let _g = crate::test_util::global_state_lock();
8051        assert!(
8052            !matchpat("hello", "HELLO", false, true),
8053            "case-sensitive default must reject HELLO != hello"
8054        );
8055    }
8056
8057    /// c:2780 — `set_pat_start(p, offs)` sets `PAT_NOTSTART` when offs is
8058    /// nonzero (matched substring starts past the real start, so `(#s)`
8059    /// must fail) and clears it when offs is 0. A regression here would
8060    /// let `(#s)` anchors fire mid-string during substring globbing.
8061    #[test]
8062    fn set_pat_start_toggles_pat_notstart_flag() {
8063        let _g = crate::test_util::global_state_lock();
8064        let mut p = mk_test_patprog();
8065        set_pat_start(&mut p, 2);
8066        assert!(
8067            p.0.flags & PAT_NOTSTART != 0,
8068            "offs!=0 must set PAT_NOTSTART"
8069        );
8070        set_pat_start(&mut p, 0);
8071        assert!(
8072            p.0.flags & PAT_NOTSTART == 0,
8073            "offs==0 must clear PAT_NOTSTART"
8074        );
8075    }
8076
8077    /// c:2796 — `set_pat_end(p, null_me)` sets `PAT_NOTEND` when the char
8078    /// being zapped is non-NUL (string shortened at tail, so `(#e)` must
8079    /// fail) and clears it when that char is already NUL. A regression
8080    /// would let `(#e)` anchors fire before the real end.
8081    #[test]
8082    fn set_pat_end_toggles_pat_notend_flag() {
8083        let _g = crate::test_util::global_state_lock();
8084        let mut p = mk_test_patprog();
8085        set_pat_end(&mut p, b'x');
8086        assert!(
8087            p.0.flags & PAT_NOTEND != 0,
8088            "non-NUL null_me must set PAT_NOTEND"
8089        );
8090        set_pat_end(&mut p, 0);
8091        assert!(
8092            p.0.flags & PAT_NOTEND == 0,
8093            "NUL null_me must clear PAT_NOTEND"
8094        );
8095    }
8096
8097    /// Build a zeroed `Patprog` for flag-toggle tests.
8098    fn mk_test_patprog() -> Patprog {
8099        Box::new((
8100            crate::ported::zsh_h::patprog {
8101                startoff: 0,
8102                size: 0,
8103                mustoff: 0,
8104                patmlen: 0,
8105                globflags: 0,
8106                globend: 0,
8107                flags: 0,
8108                patnpar: 0,
8109                patstartch: 0,
8110            },
8111            Vec::new(),
8112        ))
8113    }
8114
8115    /// c:2773 — `freematchlist(None)` is a no-op (matches C's
8116    /// `if (repllist) freelinklist(...)`). Regression panicking on
8117    /// None would crash every glob-replace path with no matches.
8118    #[test]
8119    fn freematchlist_handles_none_safely() {
8120        let _g = crate::test_util::global_state_lock();
8121        freematchlist(None);
8122        // No assertion — survival is the test.
8123    }
8124
8125    /// c:2773 — `freematchlist(Some(&mut Vec))` clears the list.
8126    /// Regression that drops a stale entry would leak match positions
8127    /// across calls.
8128    #[test]
8129    fn freematchlist_clears_provided_vec() {
8130        let _g = crate::test_util::global_state_lock();
8131        let mut v = vec![
8132            repldata {
8133                b: 0,
8134                e: 5,
8135                replstr: None,
8136            },
8137            repldata {
8138                b: 10,
8139                e: 15,
8140                replstr: Some("x".to_string()),
8141            },
8142        ];
8143        freematchlist(Some(&mut v));
8144        assert!(v.is_empty(), "freematchlist must clear the input vec");
8145    }
8146
8147    /// `Src/glob.c:2042-2142` — `hasbraces(str)` returns true when
8148    /// `str` contains a brace-expansion candidate. A bare lbrace/rbrace
8149    /// is NOT a brace expansion (it's a literal). Detection requires a
8150    /// matched pair containing either a comma or a dotdot range. Pin
8151    /// the canonical contract.
8152    #[test]
8153    fn hasbraces_matched_pair_with_comma_or_dotdot() {
8154        let _g = crate::test_util::global_state_lock();
8155        // Matched + comma → true.
8156        assert!(
8157            hasbraces(&tok("a{b,c}d"), false),
8158            "c:2127 — lbrace + comma + rbrace is a brace expansion"
8159        );
8160        // Matched + dotdot → true.
8161        assert!(
8162            hasbraces(&tok("file{1..3}.txt"), false),
8163            "c:2082 — N..M range is a brace expansion"
8164        );
8165        // Matched WITHOUT comma/dotdot → false (it's a literal).
8166        assert!(
8167            !hasbraces(&tok("{abc}"), false),
8168            "literal braces are NOT a brace expansion without comma or dotdot"
8169        );
8170        // Unmatched → false.
8171        assert!(
8172            !hasbraces(&tok("{abc"), false),
8173            "lone lbrace without matching rbrace is not brace expansion"
8174        );
8175        assert!(
8176            !hasbraces(&tok("abc}"), false),
8177            "lone rbrace is not brace expansion"
8178        );
8179        // No braces → false.
8180        assert!(!hasbraces(&tok("plain"), false));
8181        assert!(!hasbraces(&tok(""), false));
8182    }
8183
8184    /// `Src/glob.c:2049-2063` — BRACECCL branch: when `isset(BRACECCL)`
8185    /// (the `brace_ccl` parameter), any non-empty matched braces
8186    /// become a character-class set, regardless of whether the body
8187    /// contains a comma. Empty pair is still not.
8188    #[test]
8189    fn hasbraces_brace_ccl_makes_any_pair_match() {
8190        let _g = crate::test_util::global_state_lock();
8191        // c:2049 — BRACECCL: non-empty pair is enough.
8192        assert!(
8193            hasbraces(&tok("{abc}"), true),
8194            "c:2049 — BRACECCL: non-empty pair is char-class set"
8195        );
8196        assert!(
8197            hasbraces(&tok("x{q}y"), true),
8198            "c:2049 — single-char pair counts under BRACECCL"
8199        );
8200        // Empty pair shouldn't trigger.
8201        assert!(
8202            !hasbraces(&tok("{}"), true),
8203            "empty pair still not a brace expansion even under BRACECCL"
8204        );
8205        // Without BRACECCL, plain literal-letter pair is NOT a brace expansion.
8206        assert!(
8207            !hasbraces(&tok("{abc}"), false),
8208            "c:2049 — BRACECCL off → plain literal pair stays literal"
8209        );
8210    }
8211
8212    /// `Src/glob.c:2042-2142` — depth tracking: comma/dotdot count
8213    /// only at depth==1 in our port. So nested inner comma doesn't
8214    /// qualify the outer pair, but TWO independent top-level pairs
8215    /// (each with comma) DO trigger detection.
8216    #[test]
8217    fn hasbraces_depth_1_check_for_comma_dotdot() {
8218        let _g = crate::test_util::global_state_lock();
8219        assert!(
8220            hasbraces(&tok("a{1,2}b{3,4}c"), false),
8221            "two independent top-level pairs, first one matches at depth 1"
8222        );
8223    }
8224
8225    /// Pin: `remnulargs` per `Src/glob.c:3649-3679`:
8226    ///   - Strings with NO inull bytes are unchanged.
8227    ///   - Strings with ONLY Bnullkeep: keep as-is in scan phase
8228    ///     (Bnullkeep is "active backslash" that hasn't triggered
8229    ///     copy phase yet).
8230    ///   - After encountering any other inull: switch to copy phase:
8231    ///     * Bnullkeep → '\\' (literal backslash).
8232    ///     * Other inulls (Snull/Dnull/Bnull/Nularg) → stripped.
8233    ///     * Non-inull → kept.
8234    ///   - Empty post-strip → replaced with single Nularg.
8235    #[test]
8236    fn remnulargs_matches_c_inull_handling() {
8237        let _g = crate::test_util::global_state_lock();
8238
8239        // Plain ASCII unchanged (no inulls).
8240        let mut s = "hello".to_string();
8241        remnulargs(&mut s);
8242        assert_eq!(
8243            s, "hello",
8244            "c:3654 — no inull bytes leaves string unchanged"
8245        );
8246
8247        // Snull-triggered copy: Snull stripped, rest kept.
8248        let mut s = format!("ab{}cd", Snull);
8249        remnulargs(&mut s);
8250        assert_eq!(
8251            s, "abcd",
8252            "c:3663 — Snull triggers copy; itself stripped, rest kept"
8253        );
8254
8255        // Bnullkeep AFTER Snull trigger → '\\' (active backslash).
8256        let mut s = format!("ab{}c{}d", Snull, Bnullkeep);
8257        remnulargs(&mut s);
8258        assert_eq!(
8259            s, "abc\\d",
8260            "c:3666 — Bnullkeep in copy phase becomes literal '\\\\'"
8261        );
8262
8263        // Other inulls (Bnull, Dnull) stripped in copy phase.
8264        let mut s = format!("a{}b{}c{}d", Snull, Bnull, Dnull);
8265        remnulargs(&mut s);
8266        assert_eq!(s, "abcd", "c:3668 — Bnull/Dnull stripped in copy phase");
8267
8268        // Empty post-strip → Nularg.
8269        let mut s = format!("{}", Snull);
8270        remnulargs(&mut s);
8271        assert_eq!(
8272            s,
8273            format!("{}", Nularg),
8274            "c:3674 — empty result replaced by Nularg sentinel"
8275        );
8276    }
8277
8278    /// `Src/glob.c:1085-1117` — `glob_exec_string` is a PARSER, not
8279    /// an executor. Extracts the qualifier inner text from `*sp`
8280    /// (advancing past delimiters). Previous Rust port forked
8281    /// `/bin/sh` to execute the cmd — that's entirely the wrong
8282    /// layer; execution happens at the call sites via qualsheval.
8283    /// Pin: plus-form extracts identifier; delimited form extracts
8284    /// content + advances.
8285    #[test]
8286    fn glob_exec_string_parses_qualifier_text() {
8287        let _g = crate::test_util::global_state_lock();
8288        // Plus form: identifier ends at first non-ident char.
8289        // C: `(+myfunc:rest)` — `s` points past `+`, plus_form=true.
8290        let r = glob_exec_string("myfunc rest", true);
8291        assert!(r.is_some(), "c:1092 — identifier parse should succeed");
8292        let (ident, _adv) = r.unwrap();
8293        assert_eq!(
8294            ident, "myfunc",
8295            "c:1092 — itype_end stops at first non-IIDENT char"
8296        );
8297
8298        // Plus form with no identifier (immediate non-ident) — error.
8299        let r = glob_exec_string(" leading-space", true);
8300        assert!(
8301            r.is_none(),
8302            "c:1093-1096 — empty identifier emits zerr + returns None"
8303        );
8304    }
8305
8306    /// `Src/glob.c:3907-3943` — `qualsheval(name, _, _, expr)` sets
8307    /// `$REPLY` via paramtab, evaluates `expr` IN THE CURRENT SHELL,
8308    /// then restores errflag+lastval. Previous Rust port spawned
8309    /// `/bin/sh -c expr` which:
8310    ///   1. Couldn't access shell locals / functions / aliases.
8311    ///   2. Ran `sh` (POSIX), not `zsh` — every zsh-feature qualifier
8312    ///      silently failed.
8313    ///
8314    /// Pin: after qualsheval, errflag is restored (mod ERRFLAG_INT),
8315    /// lastval is restored, and `$REPLY` is set to the filename.
8316    /// Easiest direct pin: errflag/lastval restore around an `expr`
8317    /// that mutates them.
8318    #[test]
8319    fn qualsheval_restores_errflag_and_lastval() {
8320        let _g = crate::test_util::global_state_lock();
8321        // Seed errflag and lastval with distinctive values.
8322        errflag.store(0, Ordering::Relaxed);
8323        LASTVAL.store(42, Ordering::Relaxed);
8324        // Even if the expr mutates these via the executor, c:3924-3925
8325        // restore them after.
8326        let st0: libc::stat = unsafe { std::mem::zeroed() };
8327        let _ = qualsheval("/tmp/file", &st0, 0, ":"); // no-op expr
8328                                                       // c:3924 — errflag restored.
8329        assert_eq!(
8330            errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR,
8331            0,
8332            "c:3924 — qualsheval must restore errflag (no ERRFLAG_ERROR leak)"
8333        );
8334        // c:3925 — lastval restored.
8335        assert_eq!(
8336            LASTVAL.load(Ordering::Relaxed),
8337            42,
8338            "c:3925 — qualsheval must restore lastval to pre-call value"
8339        );
8340        // c:3916 — $REPLY visible in paramtab.
8341        assert_eq!(
8342            crate::ported::params::getsparam("REPLY"),
8343            Some("/tmp/file".to_string()),
8344            "c:3916 — qualsheval must set $REPLY to filename"
8345        );
8346    }
8347
8348    /// `Src/glob.c:2164` — `if (!errflag && isset(MULTIOS))` is the
8349    /// logical zero-check guarding globlist recursion. Previous Rust
8350    /// port used `!errflag.load(...) != 0` which is BITWISE NOT
8351    /// (`^-1`), evaluating to `errflag != -1` — almost-always-true.
8352    ///
8353    /// Pin: a `matchpat` smoke that DOES NOT mutate errflag, then a
8354    /// direct check that the fix sense is correct (errflag==0 enters
8355    /// the branch, errflag!=0 skips). Can't easily exercise the full
8356    /// xpandredir path without a redir struct + IN_EXPANDREDIR
8357    /// global, so this is a sense-of-the-condition pin.
8358    #[test]
8359    fn xpandredir_errflag_check_uses_logical_zero() {
8360        let _g = crate::test_util::global_state_lock();
8361        // Mirror the in-port logic with the canonical zero-check.
8362        let saved = errflag.load(Ordering::Relaxed);
8363        // c:2164 — errflag==0 path: branch should fire.
8364        errflag.store(0, Ordering::Relaxed);
8365        let zero_enters = errflag.load(Ordering::Relaxed) == 0;
8366        assert!(
8367            zero_enters,
8368            "c:2164 — errflag==0 enters the branch (the canonical sense)"
8369        );
8370        // c:2164 — errflag!=0 path: branch should skip.
8371        errflag.store(1, Ordering::Relaxed);
8372        let nonzero_skips = errflag.load(Ordering::Relaxed) == 0;
8373        assert!(
8374            !nonzero_skips,
8375            "c:2164 — errflag!=0 skips the branch (regression of bitwise-NOT bug fix)"
8376        );
8377        errflag.store(saved, Ordering::Relaxed);
8378    }
8379
8380    // ═══════════════════════════════════════════════════════════════════
8381    // Additional unit coverage — pure pattern/brace/glob behaviour that
8382    // doesn't need a real filesystem. Uses `matchpat` for high-level
8383    // pattern checks (extended=false, case_sensitive=true is the most
8384    // common path) and `xpandbraces` for brace expansion.
8385    // ═══════════════════════════════════════════════════════════════════
8386
8387    // ── matchpat: full-string pattern matching ───────────────────────
8388    #[test]
8389    fn matchpat_literal_no_wildcards() {
8390        let _g = crate::test_util::global_state_lock();
8391        assert!(matchpat("foo", "foo", false, true));
8392        assert!(!matchpat("foo", "bar", false, true));
8393        assert!(!matchpat("foo", "foobar", false, true));
8394    }
8395
8396    #[test]
8397    fn matchpat_star_consumes_substring() {
8398        let _g = crate::test_util::global_state_lock();
8399        assert!(matchpat("a*b", "ab", false, true));
8400        assert!(matchpat("a*b", "ahellob", false, true));
8401        assert!(!matchpat("a*b", "abc", false, true));
8402    }
8403
8404    #[test]
8405    fn matchpat_question_is_single_char() {
8406        let _g = crate::test_util::global_state_lock();
8407        assert!(matchpat("a?c", "abc", false, true));
8408        assert!(matchpat("a?c", "axc", false, true));
8409        assert!(!matchpat("a?c", "ac", false, true));
8410        assert!(!matchpat("a?c", "abbc", false, true));
8411    }
8412
8413    #[test]
8414    fn matchpat_bracket_class_inline() {
8415        let _g = crate::test_util::global_state_lock();
8416        assert!(matchpat("file[abc].txt", "filea.txt", false, true));
8417        assert!(matchpat("file[abc].txt", "fileb.txt", false, true));
8418        assert!(!matchpat("file[abc].txt", "filed.txt", false, true));
8419    }
8420
8421    #[test]
8422    fn matchpat_case_sensitive_strict() {
8423        let _g = crate::test_util::global_state_lock();
8424        assert!(matchpat("Foo", "Foo", false, true));
8425        assert!(!matchpat("Foo", "foo", false, true));
8426        assert!(!matchpat("Foo", "FOO", false, true));
8427    }
8428
8429    #[test]
8430    fn matchpat_empty_pattern_matches_only_empty() {
8431        let _g = crate::test_util::global_state_lock();
8432        assert!(matchpat("", "", false, true));
8433        assert!(!matchpat("", "x", false, true));
8434    }
8435
8436    #[test]
8437    fn matchpat_star_alone_matches_anything() {
8438        let _g = crate::test_util::global_state_lock();
8439        assert!(matchpat("*", "", false, true));
8440        assert!(matchpat("*", "a", false, true));
8441        assert!(matchpat("*", "abcdef", false, true));
8442    }
8443
8444    #[test]
8445    fn matchpat_question_alone_one_char() {
8446        let _g = crate::test_util::global_state_lock();
8447        assert!(!matchpat("?", "", false, true));
8448        assert!(matchpat("?", "a", false, true));
8449        assert!(!matchpat("?", "ab", false, true));
8450    }
8451
8452    // ── haswilds: meta-char detection ───────────────────────────────
8453    #[test]
8454    fn haswilds_each_glob_meta() {
8455        let _g = crate::test_util::global_state_lock();
8456        // c:Src/glob.c:3548 — full glob tokenize (shadows the
8457        // brace-only `fn tok` test helper above on purpose:
8458        // haswilds consumes glob tokens, not just braces).
8459        let tok = |s: &str| {
8460            let mut t = s.to_string();
8461            tokenize(&mut t);
8462            t
8463        };
8464        assert!(haswilds(&tok("*")));
8465        assert!(haswilds(&tok("?")));
8466        assert!(haswilds(&tok("[abc]")));
8467        assert!(haswilds(&tok("a*b")));
8468        assert!(haswilds(&tok("a?b")));
8469    }
8470
8471    #[test]
8472    fn haswilds_plain_strings_have_no_wildcards() {
8473        let _g = crate::test_util::global_state_lock();
8474        // c:Src/glob.c:3548 — full glob tokenize (shadows the
8475        // brace-only `fn tok` test helper above on purpose:
8476        // haswilds consumes glob tokens, not just braces).
8477        let tok = |s: &str| {
8478            let mut t = s.to_string();
8479            tokenize(&mut t);
8480            t
8481        };
8482        assert!(!haswilds(&tok("")));
8483        assert!(!haswilds(&tok("plain.txt")));
8484        assert!(!haswilds(&tok("/abs/path/file.rs")));
8485        assert!(!haswilds(&tok("./rel/file")));
8486    }
8487
8488    // ── xpandbraces: brace expansion (zsh extension, not POSIX) ─────
8489    #[test]
8490    fn xpandbraces_three_alternatives() {
8491        let _g = crate::test_util::global_state_lock();
8492        assert_eq!(xpandbraces(&tok("{a,b,c}"), false), vec!["a", "b", "c"]);
8493    }
8494
8495    #[test]
8496    fn xpandbraces_with_prefix_and_suffix() {
8497        let _g = crate::test_util::global_state_lock();
8498        assert_eq!(
8499            xpandbraces(&tok("pre{x,y,z}post"), false),
8500            vec!["prexpost", "preypost", "prezpost"]
8501        );
8502    }
8503
8504    #[test]
8505    fn xpandbraces_numeric_range_ascending() {
8506        let _g = crate::test_util::global_state_lock();
8507        assert_eq!(
8508            xpandbraces(&tok("{1..5}"), false),
8509            vec!["1", "2", "3", "4", "5"]
8510        );
8511    }
8512
8513    #[test]
8514    fn xpandbraces_alpha_range_ascending() {
8515        let _g = crate::test_util::global_state_lock();
8516        assert_eq!(
8517            xpandbraces(&tok("{a..e}"), false),
8518            vec!["a", "b", "c", "d", "e"]
8519        );
8520    }
8521
8522    #[test]
8523    fn xpandbraces_single_alternative_passes_through_literal() {
8524        // Real zsh 5.9 (`print -l {a}`) outputs `{a}` verbatim — no
8525        // expansion because there is no comma or range inside the
8526        // braces. xpandbraces operates on TOKEN form throughout
8527        // (c:Src/glob.c::xpandbraces); the eventual ASCII conversion
8528        // happens later in untokenize. So unit-level pass-through
8529        // preserves TOKEN bytes, not ASCII braces.
8530        let _g = crate::test_util::global_state_lock();
8531        let out = xpandbraces(&tok("{a}"), false);
8532        assert_eq!(
8533            out,
8534            vec![tok("{a}")],
8535            "zsh 5.9 returns the input verbatim (TOKEN form preserved at xpandbraces layer)"
8536        );
8537    }
8538
8539    #[test]
8540    fn xpandbraces_no_braces_returns_input() {
8541        let _g = crate::test_util::global_state_lock();
8542        // Pure literal has nothing to expand — should yield the input
8543        // as a single element (or empty for empty input).
8544        let out = xpandbraces(&tok("plain"), false);
8545        assert_eq!(out, vec!["plain"]);
8546    }
8547
8548    // ── file_type: mode → marker char (used by `ls -F`-style output) ─
8549    #[test]
8550    fn file_type_dir_marker_is_slash() {
8551        let _g = crate::test_util::global_state_lock();
8552        assert_eq!(file_type(libc::S_IFDIR as u32), '/');
8553    }
8554
8555    #[test]
8556    fn file_type_regular_plain_is_space() {
8557        let _g = crate::test_util::global_state_lock();
8558        assert_eq!(file_type(libc::S_IFREG as u32), ' ');
8559    }
8560
8561    #[test]
8562    fn file_type_regular_executable_is_star() {
8563        let _g = crate::test_util::global_state_lock();
8564        // Any of the three executable bits should switch the marker.
8565        for x in [0o100, 0o010, 0o001] {
8566            assert_eq!(
8567                file_type(libc::S_IFREG as u32 | x),
8568                '*',
8569                "exec bit 0o{x:o} should produce '*'"
8570            );
8571        }
8572    }
8573
8574    #[test]
8575    fn file_type_symlink_is_at() {
8576        let _g = crate::test_util::global_state_lock();
8577        assert_eq!(file_type(libc::S_IFLNK as u32), '@');
8578    }
8579
8580    #[test]
8581    fn file_type_fifo_is_pipe() {
8582        let _g = crate::test_util::global_state_lock();
8583        assert_eq!(file_type(libc::S_IFIFO as u32), '|');
8584    }
8585
8586    #[test]
8587    fn file_type_socket_is_equal() {
8588        let _g = crate::test_util::global_state_lock();
8589        assert_eq!(file_type(libc::S_IFSOCK as u32), '=');
8590    }
8591
8592    // ═══════════════════════════════════════════════════════════════════
8593    // xpandbraces edge cases — anchored to `print -l <pattern>` in zsh 5.9.
8594    // Where zshrs diverges, the test FAILS to expose the bug.
8595    // ═══════════════════════════════════════════════════════════════════
8596
8597    /// `{1..10..2}` → 1 3 5 7 9 (step range). zsh: 5 elements.
8598    #[test]
8599    fn xpandbraces_numeric_step_two() {
8600        let _g = crate::test_util::global_state_lock();
8601        assert_eq!(
8602            xpandbraces(&tok("{1..10..2}"), false),
8603            vec!["1", "3", "5", "7", "9"]
8604        );
8605    }
8606
8607    /// `{10..1..2}` → 10 8 6 4 2 (descending step).
8608    #[test]
8609    fn xpandbraces_numeric_descending_step_two() {
8610        let _g = crate::test_util::global_state_lock();
8611        assert_eq!(
8612            xpandbraces(&tok("{10..1..2}"), false),
8613            vec!["10", "8", "6", "4", "2"]
8614        );
8615    }
8616
8617    /// `{5..1}` → 5 4 3 2 1 (descending range, no step).
8618    #[test]
8619    fn xpandbraces_numeric_descending_range() {
8620        let _g = crate::test_util::global_state_lock();
8621        assert_eq!(
8622            xpandbraces(&tok("{5..1}"), false),
8623            vec!["5", "4", "3", "2", "1"]
8624        );
8625    }
8626
8627    /// `{01..10}` → 01 02 03 ... 10 (zero-padded; pad preserved).
8628    #[test]
8629    fn xpandbraces_zero_padded_numeric_range() {
8630        let _g = crate::test_util::global_state_lock();
8631        assert_eq!(
8632            xpandbraces(&tok("{01..10}"), false),
8633            vec!["01", "02", "03", "04", "05", "06", "07", "08", "09", "10"]
8634        );
8635    }
8636
8637    /// `{001..010}` → 001 002 ... 010 (3-digit pad preserved).
8638    #[test]
8639    fn xpandbraces_three_digit_pad_preserved() {
8640        let _g = crate::test_util::global_state_lock();
8641        assert_eq!(
8642            xpandbraces(&tok("{001..010}"), false),
8643            vec!["001", "002", "003", "004", "005", "006", "007", "008", "009", "010"]
8644        );
8645    }
8646
8647    /// `\{a,b,c\}` → no expansion (escaped braces aren't brace
8648    /// delimiters). xpandbraces returns input unchanged; the lexer
8649    /// upstream strips the backslashes before this layer sees them.
8650    /// Anchored test: zsh `print -r -- \{a,b,c\}` → `{a,b,c}` because
8651    /// the lexer tokenizes `\{`/`\}` to `Bnull{`/`Bnull}` before
8652    /// xpandbraces runs, then untokenize drops the Bnulls. At the
8653    /// xpandbraces-unit level (raw backslashes), the only invariant
8654    /// to verify is "no expansion fires" — the brace literal survives.
8655    #[test]
8656    fn xpandbraces_escaped_braces_remain_literal_anchored_to_zsh() {
8657        let _g = crate::test_util::global_state_lock();
8658        // tok() emits Bnull+ASCII `{` for `\{` and Bnull+ASCII `}` for
8659        // `\}`, with Comma TOKEN for the unescaped commas inside.
8660        // xpandbraces scans for Inbrace TOKEN only (per
8661        // c:Src/glob.c::xpandbraces) — finds none here, so the
8662        // string passes through unchanged at this layer. The Bnulls
8663        // are stripped later by remnulargs in prefork.
8664        assert_eq!(
8665            xpandbraces(&tok("\\{a,b,c\\}"), false),
8666            vec![tok("\\{a,b,c\\}")],
8667            "xpandbraces unit: escaped braces survive without expansion (no per-element splat)"
8668        );
8669    }
8670
8671    /// `{a,b}{c,d}` → ac ad bc bd (Cartesian product).
8672    /// 2 * 2 = 4 elements, in row-major order.
8673    #[test]
8674    fn xpandbraces_cartesian_product_two_by_two() {
8675        let _g = crate::test_util::global_state_lock();
8676        assert_eq!(
8677            xpandbraces(&tok("{a,b}{c,d}"), false),
8678            vec!["ac", "ad", "bc", "bd"]
8679        );
8680    }
8681
8682    /// `{a,{b,c}}` → a b c (nested flattened).
8683    #[test]
8684    fn xpandbraces_nested_braces_flatten() {
8685        let _g = crate::test_util::global_state_lock();
8686        assert_eq!(xpandbraces(&tok("{a,{b,c}}"), false), vec!["a", "b", "c"]);
8687    }
8688
8689    /// `{}` → `{}` literal (empty brace doesn't expand). xpandbraces
8690    /// preserves TOKEN form at this layer (c:Src/glob.c::xpandbraces);
8691    /// ASCII conversion happens later in untokenize.
8692    #[test]
8693    fn xpandbraces_empty_braces_remain_literal() {
8694        let _g = crate::test_util::global_state_lock();
8695        assert_eq!(xpandbraces(&tok("{}"), false), vec![tok("{}")]);
8696    }
8697
8698    /// `a{b,c}d{e,f}` → abde abdf acde acdf (cartesian with surrounding text).
8699    #[test]
8700    fn xpandbraces_cartesian_with_surrounding_text() {
8701        let _g = crate::test_util::global_state_lock();
8702        assert_eq!(
8703            xpandbraces(&tok("a{b,c}d{e,f}"), false),
8704            vec!["abde", "abdf", "acde", "acdf"]
8705        );
8706    }
8707
8708    /// `{a..z..3}` → `{a..z..3}` literal (alpha range with step NOT expanded
8709    /// by zsh — surprising but verified). xpandbraces preserves TOKEN form
8710    /// at this layer; ASCII conversion happens later in untokenize.
8711    #[test]
8712    fn xpandbraces_alpha_step_unsupported_anchored_to_zsh() {
8713        let _g = crate::test_util::global_state_lock();
8714        assert_eq!(
8715            xpandbraces(&tok("{a..z..3}"), false),
8716            vec![tok("{a..z..3}")],
8717            "zsh: alpha range with step → literal (TOKEN form preserved at xpandbraces layer)"
8718        );
8719    }
8720
8721    // ═══════════════════════════════════════════════════════════════════
8722    // zsh test-corpus pins — direct anchors to Test/D09brace.ztst.
8723    // Tests verify brace expansion matches zsh's documented output.
8724    // ═══════════════════════════════════════════════════════════════════
8725
8726    /// `Test/D09brace.ztst:10-12` — basic brace expansion with
8727    /// nested range: `X{1,2,{3..6},7,8}Y` expands all 8 values.
8728    #[test]
8729    fn zsh_corpus_basic_brace_with_nested_range() {
8730        let _g = crate::test_util::global_state_lock();
8731        assert_eq!(
8732            xpandbraces(&tok("X{1,2,{3..6},7,8}Y"), false),
8733            vec!["X1Y", "X2Y", "X3Y", "X4Y", "X5Y", "X6Y", "X7Y", "X8Y"],
8734            "ztst:11 — basic brace expansion with nested range",
8735        );
8736    }
8737
8738    /// `Test/D09brace.ztst:30-32` — numeric range with leading zero
8739    /// padding: `X{01..4}Y` → all 4 values 0-padded to 2 digits.
8740    #[test]
8741    fn zsh_corpus_numeric_range_zero_padding() {
8742        let _g = crate::test_util::global_state_lock();
8743        assert_eq!(
8744            xpandbraces(&tok("X{01..4}Y"), false),
8745            vec!["X01Y", "X02Y", "X03Y", "X04Y"],
8746            "ztst:32 — leading-zero padding propagates to all values",
8747        );
8748    }
8749
8750    /// `Test/D09brace.ztst:34-36` — padding comes from RHS too:
8751    /// `X{1..04}Y` → 2-digit zero-padded.
8752    #[test]
8753    fn zsh_corpus_numeric_range_padding_from_rhs() {
8754        let _g = crate::test_util::global_state_lock();
8755        assert_eq!(
8756            xpandbraces(&tok("X{1..04}Y"), false),
8757            vec!["X01Y", "X02Y", "X03Y", "X04Y"],
8758            "ztst:36 — RHS padding `04` propagates",
8759        );
8760    }
8761
8762    /// `Test/D09brace.ztst:38-40` — unpadded range >9: `X{7..12}Y`
8763    /// numbers stay unpadded.
8764    #[test]
8765    fn zsh_corpus_numeric_range_no_padding_when_unspecified() {
8766        let _g = crate::test_util::global_state_lock();
8767        assert_eq!(
8768            xpandbraces(&tok("X{7..12}Y"), false),
8769            vec!["X7Y", "X8Y", "X9Y", "X10Y", "X11Y", "X12Y"],
8770            "ztst:40 — no padding when neither end has leading zero",
8771        );
8772    }
8773
8774    /// `Test/D09brace.ztst:42-44` — `X{07..12}Y` → 2-digit padded.
8775    #[test]
8776    fn zsh_corpus_numeric_range_lhs_padding_propagates() {
8777        let _g = crate::test_util::global_state_lock();
8778        assert_eq!(
8779            xpandbraces(&tok("X{07..12}Y"), false),
8780            vec!["X07Y", "X08Y", "X09Y", "X10Y", "X11Y", "X12Y"],
8781            "ztst:44 — LHS padding `07` propagates",
8782        );
8783    }
8784
8785    /// `Test/D09brace.ztst:46-48` — wider RHS padding overrides:
8786    /// `X{7..012}Y` → 3-digit padded.
8787    #[test]
8788    fn zsh_corpus_numeric_range_max_padding_width_wins() {
8789        let _g = crate::test_util::global_state_lock();
8790        assert_eq!(
8791            xpandbraces(&tok("X{7..012}Y"), false),
8792            vec!["X007Y", "X008Y", "X009Y", "X010Y", "X011Y", "X012Y"],
8793            "ztst:48 — widest padding width wins",
8794        );
8795    }
8796
8797    /// `Test/D09brace.ztst:50-52` — decreasing range: `X{4..1}Y` →
8798    /// 4,3,2,1 (reversed).
8799    #[test]
8800    fn zsh_corpus_numeric_range_decreasing() {
8801        let _g = crate::test_util::global_state_lock();
8802        assert_eq!(
8803            xpandbraces(&tok("X{4..1}Y"), false),
8804            vec!["X4Y", "X3Y", "X2Y", "X1Y"],
8805            "ztst:52 — decreasing range emits in reverse",
8806        );
8807    }
8808
8809    /// `Test/D09brace.ztst:54-56` — combined braces: `X{1..4}{1..4}Y`
8810    /// → 16-element cross product.
8811    #[test]
8812    fn zsh_corpus_combined_braces_cross_product() {
8813        let _g = crate::test_util::global_state_lock();
8814        assert_eq!(
8815            xpandbraces(&tok("X{1..4}{1..4}Y"), false),
8816            vec![
8817                "X11Y", "X12Y", "X13Y", "X14Y", "X21Y", "X22Y", "X23Y", "X24Y", "X31Y", "X32Y",
8818                "X33Y", "X34Y", "X41Y", "X42Y", "X43Y", "X44Y",
8819            ],
8820            "ztst:56 — combined-brace cross product",
8821        );
8822    }
8823
8824    /// `Test/D09brace.ztst:58-60` — negative numbers in range:
8825    /// `X{-4..4}Y` → `-4`..`4` (9 values including 0).
8826    #[test]
8827    fn zsh_corpus_negative_numbers_in_range() {
8828        let _g = crate::test_util::global_state_lock();
8829        assert_eq!(
8830            xpandbraces(&tok("X{-4..4}Y"), false),
8831            vec!["X-4Y", "X-3Y", "X-2Y", "X-1Y", "X0Y", "X1Y", "X2Y", "X3Y", "X4Y",],
8832            "ztst:60 — negative-to-positive range",
8833        );
8834    }
8835
8836    /// `Test/D09brace.ztst:62-64` — reverse-direction numeric range:
8837    /// `X{4..-4}Y` walks from 4 to -4 descending.
8838    #[test]
8839    fn zsh_corpus_brace_descending_range_from_positive_to_negative() {
8840        let _g = crate::test_util::global_state_lock();
8841        assert_eq!(
8842            xpandbraces(&tok("X{4..-4}Y"), false),
8843            vec!["X4Y", "X3Y", "X2Y", "X1Y", "X0Y", "X-1Y", "X-2Y", "X-3Y", "X-4Y",],
8844            "ztst:64 — descending 4..-4 produces 9 values",
8845        );
8846    }
8847
8848    /// `Test/D09brace.ztst:66-68` — stepped padded range:
8849    /// `X{004..-4..2}Y` = X004Y X002Y X000Y X-02Y X-04Y.
8850    /// Padding width 3 from LHS; step is +2 (sign of step is ignored,
8851    /// direction is from start to end).
8852    #[test]
8853    fn zsh_corpus_brace_stepped_padded_descending() {
8854        let _g = crate::test_util::global_state_lock();
8855        assert_eq!(
8856            xpandbraces(&tok("X{004..-4..2}Y"), false),
8857            vec!["X004Y", "X002Y", "X000Y", "X-02Y", "X-04Y"],
8858            "ztst:68 — stepped+padded descending",
8859        );
8860    }
8861
8862    /// `Test/D09brace.ztst:74-76` — step alignment 1: `X{1..32..3}Y`
8863    /// = X1Y X4Y X7Y X10Y X13Y X16Y X19Y X22Y X25Y X28Y X31Y.
8864    /// Step 3 starting from 1, capped at 32.
8865    #[test]
8866    fn zsh_corpus_brace_step_alignment_1_to_32_step_3() {
8867        let _g = crate::test_util::global_state_lock();
8868        assert_eq!(
8869            xpandbraces(&tok("X{1..32..3}Y"), false),
8870            vec![
8871                "X1Y", "X4Y", "X7Y", "X10Y", "X13Y", "X16Y", "X19Y", "X22Y", "X25Y", "X28Y",
8872                "X31Y",
8873            ],
8874            "ztst:76 — {{1..32..3}} step alignment",
8875        );
8876    }
8877
8878    /// `Test/D09brace.ztst:100-102` — `hey{a..j}there` — char range.
8879    #[test]
8880    fn zsh_corpus_brace_char_range_simple() {
8881        let _g = crate::test_util::global_state_lock();
8882        assert_eq!(
8883            xpandbraces(&tok("hey{a..j}there"), false),
8884            vec![
8885                "heyathere",
8886                "heybthere",
8887                "heycthere",
8888                "heydthere",
8889                "heyethere",
8890                "heyfthere",
8891                "heygthere",
8892                "heyhthere",
8893                "heyithere",
8894                "heyjthere",
8895            ],
8896            "ztst:102 — char range a..j",
8897        );
8898    }
8899
8900    /// `Test/D09brace.ztst:108-110` — reverse char range:
8901    /// `crumbs{y..p}ooh` walks down y → p (10 values).
8902    #[test]
8903    fn zsh_corpus_brace_char_range_reverse() {
8904        let _g = crate::test_util::global_state_lock();
8905        assert_eq!(
8906            xpandbraces(&tok("crumbs{y..p}ooh"), false),
8907            vec![
8908                "crumbsyooh",
8909                "crumbsxooh",
8910                "crumbswooh",
8911                "crumbsvooh",
8912                "crumbsuooh",
8913                "crumbstooh",
8914                "crumbssooh",
8915                "crumbsrooh",
8916                "crumbsqooh",
8917                "crumbspooh",
8918            ],
8919            "ztst:110 — char range y..p reverse",
8920        );
8921    }
8922
8923    /// `Test/D09brace.ztst:104-106` — nested brace: `gosh{1,{Z..a},2}cripes`
8924    /// — inner `{Z..a}` is char range Z to a in ASCII order (10 chars).
8925    /// Full output: gosh1cripes goshZcripes gosh[cripes gosh\cripes
8926    /// gosh]cripes gosh^cripes gosh_cripes gosh`cripes goshacripes
8927    /// gosh2cripes.
8928    #[test]
8929    fn zsh_corpus_brace_nested_with_char_range_ascii() {
8930        let _g = crate::test_util::global_state_lock();
8931        assert_eq!(
8932            xpandbraces(&tok("gosh{1,{Z..a},2}cripes"), false),
8933            vec![
8934                "gosh1cripes",
8935                "goshZcripes",
8936                "gosh[cripes",
8937                "gosh\\cripes",
8938                "gosh]cripes",
8939                "gosh^cripes",
8940                "gosh_cripes",
8941                "gosh`cripes",
8942                "goshacripes",
8943                "gosh2cripes",
8944            ],
8945            "ztst:106 — nested brace with ASCII char range",
8946        );
8947    }
8948
8949    /// `Test/D09brace.ztst:116-118` — unmatched closing brace after
8950    /// matched braces stays literal: `{1..10}{..` → `1{.. 2{.. ...`.
8951    /// `xpandbraces` itself preserves TOKEN form (Inbrace=`\u{e18f}`,
8952    /// Outbrace=`\u{e190}`) per the convention pinned by
8953    /// `xpandbraces_alpha_step_unsupported_anchored_to_zsh` — the
8954    /// later untokenize pass in the user-output pipeline turns
8955    /// surviving brace tokens into literal `{` / `}`. This test
8956    /// asserts the tokenized intermediate form.
8957    #[test]
8958    fn zsh_corpus_brace_unmatched_after_matched_left_literal() {
8959        let _g = crate::test_util::global_state_lock();
8960        let tokb = '\u{e18f}'; // Inbrace
8961        let expected: Vec<String> = (1..=10).map(|n| format!("{}{}..", n, tokb)).collect();
8962        assert_eq!(
8963            xpandbraces(&tok("{1..10}{.."), false),
8964            expected,
8965            "ztst:118 — unmatched trailing {{.. preserved as tokenized Inbrace at xpandbraces layer",
8966        );
8967    }
8968
8969    // ── split_qualifier: parse trailing (qual) block ─────────────────
8970    /// `*.txt` (no trailing parens) → (input, None).
8971    #[test]
8972    fn split_qualifier_no_parens_returns_input_and_none() {
8973        let _g = crate::test_util::global_state_lock();
8974        let (head, qual) = split_qualifier("*.txt");
8975        assert_eq!(head, "*.txt");
8976        assert_eq!(qual, None);
8977    }
8978
8979    /// `*(N)` → ("*", Some("N")) — null-glob qualifier.
8980    #[test]
8981    fn split_qualifier_star_paren_N_extracts_null_glob_qual() {
8982        let _g = crate::test_util::global_state_lock();
8983        let (head, qual) = split_qualifier("*(N)");
8984        assert_eq!(head, "*");
8985        assert_eq!(qual, Some("N"));
8986    }
8987
8988    /// `*(.)` → ("*", Some(".")) — regular-file qualifier.
8989    #[test]
8990    fn split_qualifier_star_paren_dot_extracts_regular_file_qual() {
8991        let _g = crate::test_util::global_state_lock();
8992        let (head, qual) = split_qualifier("*(.)");
8993        assert_eq!(head, "*");
8994        assert_eq!(qual, Some("."));
8995    }
8996
8997    /// `*.rs(.)` → ("*.rs", Some(".")) — qualifier on glob pattern.
8998    #[test]
8999    fn split_qualifier_pattern_with_qual_extracts_correctly() {
9000        let _g = crate::test_util::global_state_lock();
9001        let (head, qual) = split_qualifier("*.rs(.)");
9002        assert_eq!(head, "*.rs");
9003        assert_eq!(qual, Some("."));
9004    }
9005
9006    /// `*(#qN)` → ("*", Some("N")) — `#q` prefix stripped per zsh syntax.
9007    #[test]
9008    fn split_qualifier_hash_q_prefix_stripped() {
9009        let _g = crate::test_util::global_state_lock();
9010        let (head, qual) = split_qualifier("*(#qN)");
9011        assert_eq!(head, "*");
9012        assert_eq!(qual, Some("N"));
9013    }
9014
9015    /// `*(om[1])` → ("*", Some("om[1]")) — sort-by-mtime keep first.
9016    #[test]
9017    fn split_qualifier_multichar_qual_with_brackets() {
9018        let _g = crate::test_util::global_state_lock();
9019        let (head, qual) = split_qualifier("*(om[1])");
9020        assert_eq!(head, "*");
9021        assert_eq!(qual, Some("om[1]"));
9022    }
9023
9024    /// `(a)(b)` — multiple paren groups: split takes the OUTERMOST trailing.
9025    #[test]
9026    fn split_qualifier_multiple_groups_takes_outermost_trailing() {
9027        let _g = crate::test_util::global_state_lock();
9028        let (head, qual) = split_qualifier("(a)(b)");
9029        assert_eq!(head, "(a)");
9030        assert_eq!(qual, Some("b"));
9031    }
9032
9033    // ═══════════════════════════════════════════════════════════════════
9034    // parse_qualifier_string — file-type / permission / sort qualifiers.
9035    // Tests call the private fn directly (same crate, same file).
9036    // Each qualifier letter maps to a `qualifier::*` variant per the
9037    // zsh glob.c arm at c:1495+. Pin the parse, not the match — the
9038    // match path needs a real filesystem.
9039    //
9040    // IMPORTANT: at end of parse_qualifier_string (c:3433-3435 in this
9041    // file), if `qualifiers` is non-empty it gets moved into
9042    // `alternatives[]`. So tests read the qualifier from `alternatives[0]`
9043    // — qualifiers itself is empty after the move.
9044    // ═══════════════════════════════════════════════════════════════════
9045
9046    /// Helper: read the only qualifier from a single-letter parse result.
9047    fn first_qual(qs: &qualifier_set) -> &qualifier {
9048        // After the post-loop move, single-letter input lands in
9049        // alternatives[0][0]. Fall back to qualifiers[0] for safety in
9050        // case the move ever changes.
9051        if !qs.alternatives.is_empty() && !qs.alternatives[0].is_empty() {
9052            &qs.alternatives[0][0]
9053        } else {
9054            &qs.qualifiers[0]
9055        }
9056    }
9057
9058    /// Empty qualifier body → no alternatives, no qualifiers.
9059    #[test]
9060    fn parse_qualifier_string_empty_returns_empty_set() {
9061        let _g = crate::test_util::global_state_lock();
9062        let qs = parse_qualifier_string("");
9063        assert!(qs.qualifiers.is_empty());
9064        assert!(qs.alternatives.is_empty());
9065        assert!(!qs.nullglob);
9066    }
9067
9068    /// `/` → IsDirectory (in alternatives[0]).
9069    #[test]
9070    fn parse_qualifier_string_slash_is_directory() {
9071        let _g = crate::test_util::global_state_lock();
9072        let qs = parse_qualifier_string("/");
9073        assert!(matches!(first_qual(&qs), qualifier::IsDirectory));
9074    }
9075
9076    /// `.` → IsRegular.
9077    #[test]
9078    fn parse_qualifier_string_dot_is_regular() {
9079        let _g = crate::test_util::global_state_lock();
9080        let qs = parse_qualifier_string(".");
9081        assert!(matches!(first_qual(&qs), qualifier::IsRegular));
9082    }
9083
9084    /// `parsecomplist` splits a path into per-component `complist` nodes,
9085    /// each literal section a PAT_PURES Patprog (relies on patcompile's
9086    /// PAT_FILE PURES segment-stop at `/`, pattern.c:584-610).
9087    #[test]
9088    fn parsecomplist_splits_path_components() {
9089        let _g = crate::test_util::global_state_lock();
9090        fn pure(p: &crate::ported::pattern::Patprog) -> Option<String> {
9091            if (p.0.flags & crate::ported::zsh_h::PAT_PURES as i32) != 0 {
9092                let off = p.0.startoff as usize;
9093                let l = p.0.patmlen as usize;
9094                p.1.get(off..off + l)
9095                    .map(|b| String::from_utf8_lossy(b).into_owned())
9096            } else {
9097                None
9098            }
9099        }
9100        fn sections(mut q: &complist) -> Vec<String> {
9101            let mut out = Vec::new();
9102            loop {
9103                out.push(pure(&q.pat).unwrap_or_else(|| "<pat>".into()));
9104                match &q.next {
9105                    Some(n) => q = n,
9106                    None => break,
9107                }
9108            }
9109            out
9110        }
9111        // Literal path → split into per-component PAT_PURES sections.
9112        let mut t = "a/b/c.txt".to_string();
9113        tokenize(&mut t);
9114        let cl = parsecomplist(&t).expect("parsecomplist None");
9115        assert_eq!(sections(&cl), vec!["a", "b", "c.txt"]);
9116
9117        // Literal prefix before a pattern still splits the literal dirs;
9118        // the trailing glob section is a real pattern (not PAT_PURES).
9119        let mut t2 = "a/b/*.txt".to_string();
9120        tokenize(&mut t2);
9121        let cl2 = parsecomplist(&t2).expect("parsecomplist None");
9122        let s2 = sections(&cl2);
9123        assert_eq!(s2.len(), 3, "a/b/*.txt → 3 sections, got {s2:?}");
9124        assert_eq!(&s2[..2], &["a".to_string(), "b".to_string()]);
9125        assert_eq!(s2[2], "<pat>", "trailing *.txt must be a pattern section");
9126
9127        // Pattern-FIRST then literal dir: */foo → [<pat>, foo].
9128        let mut t3 = "*/foo".to_string();
9129        tokenize(&mut t3);
9130        let cl3 = parsecomplist(&t3).expect("parsecomplist None for */foo");
9131        let s3 = sections(&cl3);
9132        assert_eq!(s3.len(), 2, "*/foo → 2 sections, got {s3:?}");
9133        assert_eq!(s3[0], "<pat>", "leading * must be a pattern section");
9134        assert_eq!(s3[1], "foo");
9135    }
9136
9137    /// The `struct qual` arena is built from the parse alongside the enum:
9138    /// AND via `next`, alternatives via `or`, per-node `sense` from `^`.
9139    #[test]
9140    fn parse_qualifier_string_builds_qual_arena() {
9141        let _g = crate::test_util::global_state_lock();
9142        // single qualifier → one node, sense 0, head set, func present.
9143        let q = parse_qualifier_string(".");
9144        assert_eq!(q.quals.nodes.len(), 1);
9145        assert_eq!(q.quals.head, Some(0));
9146        assert_eq!(q.quals.nodes[0].sense, 0);
9147        assert!(q.quals.nodes[0].func.is_some());
9148        assert_eq!(q.quals.nodes[0].next, None);
9149
9150        // leading `^` → per-node sense flipped (c:1346).
9151        let q = parse_qualifier_string("^.");
9152        assert_eq!(q.quals.nodes.len(), 1);
9153        assert_eq!(q.quals.nodes[0].sense, 1);
9154
9155        // `/^@` → AND-chain of 2: dir(sense 0) -next-> symlink(sense 1).
9156        let q = parse_qualifier_string("/^@");
9157        assert_eq!(q.quals.nodes.len(), 2);
9158        assert_eq!(q.quals.nodes[0].sense, 0);
9159        assert_eq!(q.quals.nodes[0].next, Some(1));
9160        assert_eq!(q.quals.nodes[1].sense, 1);
9161        assert_eq!(q.quals.nodes[1].next, None);
9162
9163        // `.,/` → two alternatives: node0 -or-> node1, neither chained.
9164        let q = parse_qualifier_string(".,/");
9165        assert_eq!(q.quals.nodes.len(), 2);
9166        assert_eq!(q.quals.nodes[0].or, Some(1));
9167        assert_eq!(q.quals.nodes[0].next, None);
9168        assert_eq!(q.quals.head, Some(0));
9169    }
9170
9171    /// `@` → IsSymlink.
9172    #[test]
9173    fn parse_qualifier_string_at_is_symlink() {
9174        let _g = crate::test_util::global_state_lock();
9175        let qs = parse_qualifier_string("@");
9176        assert!(matches!(first_qual(&qs), qualifier::IsSymlink));
9177    }
9178
9179    /// `=` → IsSocket.
9180    #[test]
9181    fn parse_qualifier_string_equals_is_socket() {
9182        let _g = crate::test_util::global_state_lock();
9183        let qs = parse_qualifier_string("=");
9184        assert!(matches!(first_qual(&qs), qualifier::IsSocket));
9185    }
9186
9187    /// `p` → IsFifo.
9188    #[test]
9189    fn parse_qualifier_string_p_is_fifo() {
9190        let _g = crate::test_util::global_state_lock();
9191        let qs = parse_qualifier_string("p");
9192        assert!(matches!(first_qual(&qs), qualifier::IsFifo));
9193    }
9194
9195    /// `*` → IsExecutable.
9196    #[test]
9197    fn parse_qualifier_string_star_is_executable() {
9198        let _g = crate::test_util::global_state_lock();
9199        let qs = parse_qualifier_string("*");
9200        assert!(matches!(first_qual(&qs), qualifier::IsExecutable));
9201    }
9202
9203    /// `%b` → IsBlockDev.
9204    #[test]
9205    fn parse_qualifier_string_pct_b_is_block_device() {
9206        let _g = crate::test_util::global_state_lock();
9207        let qs = parse_qualifier_string("%b");
9208        assert!(matches!(first_qual(&qs), qualifier::IsBlockDev));
9209    }
9210
9211    /// `%c` → IsCharDev.
9212    #[test]
9213    fn parse_qualifier_string_pct_c_is_char_device() {
9214        let _g = crate::test_util::global_state_lock();
9215        let qs = parse_qualifier_string("%c");
9216        assert!(matches!(first_qual(&qs), qualifier::IsCharDev));
9217    }
9218
9219    /// `r` / `w` / `x` → Readable / Writable / Executable.
9220    #[test]
9221    fn parse_qualifier_string_perm_letters_map_to_perm_qualifiers() {
9222        let _g = crate::test_util::global_state_lock();
9223        for (letter, expected) in [
9224            ("r", qualifier::Readable),
9225            ("w", qualifier::Writable),
9226            ("x", qualifier::Executable),
9227        ] {
9228            let qs = parse_qualifier_string(letter);
9229            assert_eq!(
9230                std::mem::discriminant(first_qual(&qs)),
9231                std::mem::discriminant(&expected),
9232                "letter {letter:?} should map to {expected:?}"
9233            );
9234        }
9235    }
9236
9237    /// `U` → OwnedByEuid (process EUID).
9238    #[test]
9239    fn parse_qualifier_string_capital_U_is_owned_by_euid() {
9240        let _g = crate::test_util::global_state_lock();
9241        let qs = parse_qualifier_string("U");
9242        assert!(matches!(first_qual(&qs), qualifier::OwnedByEuid));
9243    }
9244
9245    /// `G` → OwnedByEgid.
9246    #[test]
9247    fn parse_qualifier_string_capital_G_is_owned_by_egid() {
9248        let _g = crate::test_util::global_state_lock();
9249        let qs = parse_qualifier_string("G");
9250        assert!(matches!(first_qual(&qs), qualifier::OwnedByEgid));
9251    }
9252
9253    /// Multiple file-type qualifiers stack — `./` → IsRegular then IsDirectory
9254    /// both end up in alternatives[0].
9255    #[test]
9256    fn parse_qualifier_string_multiple_letters_stack_in_one_alternative() {
9257        let _g = crate::test_util::global_state_lock();
9258        let qs = parse_qualifier_string("./");
9259        assert_eq!(qs.alternatives.len(), 1);
9260        assert_eq!(qs.alternatives[0].len(), 2);
9261        assert!(matches!(qs.alternatives[0][0], qualifier::IsRegular));
9262        assert!(matches!(qs.alternatives[0][1], qualifier::IsDirectory));
9263    }
9264
9265    /// `,` separates alternatives — `/,.` → two alternatives, each with one
9266    /// qualifier (IsDirectory and IsRegular respectively).
9267    #[test]
9268    fn parse_qualifier_string_comma_creates_two_alternatives() {
9269        let _g = crate::test_util::global_state_lock();
9270        let qs = parse_qualifier_string("/,.");
9271        assert_eq!(qs.alternatives.len(), 2, "two alternatives expected");
9272        assert!(matches!(qs.alternatives[0][0], qualifier::IsDirectory));
9273        assert!(matches!(qs.alternatives[1][0], qualifier::IsRegular));
9274    }
9275
9276    /// `:` introduces colon-modifiers; rest captured into colon_mods.
9277    #[test]
9278    fn parse_qualifier_string_colon_captures_modifiers_in_field() {
9279        let _g = crate::test_util::global_state_lock();
9280        let qs = parse_qualifier_string(":h");
9281        assert_eq!(qs.colon_mods.as_deref(), Some(":h"));
9282    }
9283
9284    /// `:` after a qualifier captures BOTH.
9285    #[test]
9286    fn parse_qualifier_string_qualifier_then_colon_mod() {
9287        let _g = crate::test_util::global_state_lock();
9288        let qs = parse_qualifier_string("/:h");
9289        assert!(matches!(first_qual(&qs), qualifier::IsDirectory));
9290        assert_eq!(qs.colon_mods.as_deref(), Some(":h"));
9291    }
9292
9293    /// `N` flag → nullglob set, no qualifier pushed.
9294    #[test]
9295    fn parse_qualifier_string_capital_N_sets_nullglob_flag() {
9296        let _g = crate::test_util::global_state_lock();
9297        let qs = parse_qualifier_string("N");
9298        assert!(qs.nullglob, "(N) must set nullglob");
9299        assert!(qs.alternatives.is_empty(), "N doesn't push to qualifiers");
9300    }
9301
9302    /// `M` flag → mark_dirs.
9303    #[test]
9304    fn parse_qualifier_string_capital_M_sets_mark_dirs_flag() {
9305        let _g = crate::test_util::global_state_lock();
9306        let qs = parse_qualifier_string("M");
9307        assert_eq!(qs.mark_dirs, Some(true), "(M) must set mark_dirs");
9308    }
9309
9310    /// `T` flag → list_types.
9311    #[test]
9312    fn parse_qualifier_string_capital_T_sets_list_types_flag() {
9313        let _g = crate::test_util::global_state_lock();
9314        let qs = parse_qualifier_string("T");
9315        assert!(qs.list_types, "(T) must set list_types");
9316    }
9317
9318    // ── split_qualifier integration with parse_qualifier_string ──────
9319    /// `*(.)` — split yields ("*", Some(".")); parse yields IsRegular.
9320    #[test]
9321    fn split_then_parse_dot_qualifier_yields_is_regular() {
9322        let _g = crate::test_util::global_state_lock();
9323        let (head, qual) = split_qualifier("*(.)");
9324        assert_eq!(head, "*");
9325        let qs = parse_qualifier_string(qual.unwrap());
9326        assert!(matches!(first_qual(&qs), qualifier::IsRegular));
9327    }
9328
9329    /// `*(/)` — directory-only glob.
9330    #[test]
9331    fn split_then_parse_slash_qualifier_yields_is_directory() {
9332        let _g = crate::test_util::global_state_lock();
9333        let (_, qual) = split_qualifier("*(/)");
9334        let qs = parse_qualifier_string(qual.unwrap());
9335        assert!(matches!(first_qual(&qs), qualifier::IsDirectory));
9336    }
9337
9338    // ═══════════════════════════════════════════════════════════════════
9339    // Sort qualifiers — `o<key>` ascending, `O<key>` descending.
9340    // ═══════════════════════════════════════════════════════════════════
9341
9342    /// `oN` — sort by NONE (no sorting key requested).
9343    #[test]
9344    fn parse_qualifier_string_oN_pushes_gs_none() {
9345        let _g = crate::test_util::global_state_lock();
9346        let qs = parse_qualifier_string("oN");
9347        assert_eq!(qs.sorts.len(), 1);
9348        assert_ne!(qs.sorts[0] & GS_NONE, 0, "oN must set GS_NONE bit");
9349    }
9350
9351    /// `on` — sort by name ascending.
9352    #[test]
9353    fn parse_qualifier_string_on_pushes_gs_name_ascending() {
9354        let _g = crate::test_util::global_state_lock();
9355        let qs = parse_qualifier_string("on");
9356        assert_eq!(qs.sorts.len(), 1);
9357        assert_eq!(qs.sorts[0] & GS_NAME, GS_NAME);
9358        assert_eq!(qs.sorts[0] & GS_DESC, 0);
9359    }
9360
9361    /// `On` — sort by name descending.
9362    #[test]
9363    fn parse_qualifier_string_On_pushes_gs_name_descending() {
9364        let _g = crate::test_util::global_state_lock();
9365        let qs = parse_qualifier_string("On");
9366        assert_eq!(qs.sorts.len(), 1);
9367        assert_eq!(qs.sorts[0] & GS_NAME, GS_NAME);
9368        assert_eq!(qs.sorts[0] & GS_DESC, GS_DESC);
9369    }
9370
9371    /// Multiple sort keys stack.
9372    #[test]
9373    fn parse_qualifier_string_chained_sort_keys_stack() {
9374        let _g = crate::test_util::global_state_lock();
9375        let qs = parse_qualifier_string("onOL");
9376        assert_eq!(qs.sorts.len(), 2);
9377    }
9378
9379    // ── Size qualifier — `L<unit><op><value>` ───────────────────────
9380    // NOTE: zshrs's parse_range_spec normalises `+` (greater) → '>' and
9381    // `-` (less) → '<' for the internal op char, matching C's qgetnum
9382    // convention (Src/glob.c c:1620-ish).
9383
9384    /// `Lk+1` — size > 1 kilobyte. zsh syntax: `L<unit><op><value>`
9385    /// (unit char BEFORE op).
9386    #[test]
9387    fn parse_qualifier_string_Lk_plus_one_size_kilobyte() {
9388        let _g = crate::test_util::global_state_lock();
9389        let qs = parse_qualifier_string("Lk+1");
9390        match first_qual(&qs) {
9391            qualifier::Size { value, unit, op } => {
9392                assert_eq!(*value, 1);
9393                assert_eq!(*unit, TT_KILOBYTES);
9394                assert_eq!(*op, '>', "+ is stored as '>' (greater than)");
9395            }
9396            other => panic!("expected Size, got {other:?}"),
9397        }
9398    }
9399
9400    /// `L-100` — size < 100 (default unit bytes). Stored op is '<'.
9401    #[test]
9402    fn parse_qualifier_string_L_minus_100_size_bytes() {
9403        let _g = crate::test_util::global_state_lock();
9404        let qs = parse_qualifier_string("L-100");
9405        match first_qual(&qs) {
9406            qualifier::Size { value, unit, op } => {
9407                assert_eq!(*value, 100);
9408                assert_eq!(*unit, TT_BYTES);
9409                assert_eq!(*op, '<', "- is stored as '<' (less than)");
9410            }
9411            other => panic!("expected Size, got {other:?}"),
9412        }
9413    }
9414
9415    /// `Lm+1` — size > 1 megabyte; unit recognised.
9416    #[test]
9417    fn parse_qualifier_string_Lm_megabyte_unit() {
9418        let _g = crate::test_util::global_state_lock();
9419        let qs = parse_qualifier_string("Lm+1");
9420        match first_qual(&qs) {
9421            qualifier::Size { unit, .. } => assert_eq!(*unit, TT_MEGABYTES),
9422            other => panic!("expected Size, got {other:?}"),
9423        }
9424    }
9425
9426    // ── Time qualifier — same op normalisation ───────────────────────
9427    /// `m-1` — modified less than 1 day ago. Stored op '<'.
9428    #[test]
9429    fn parse_qualifier_string_m_minus_1_day_default_unit() {
9430        let _g = crate::test_util::global_state_lock();
9431        let qs = parse_qualifier_string("m-1");
9432        match first_qual(&qs) {
9433            qualifier::Mtime { value, unit, op } => {
9434                assert_eq!(*value, 1);
9435                assert_eq!(*unit, TT_DAYS);
9436                assert_eq!(*op, '<');
9437            }
9438            other => panic!("expected Mtime, got {other:?}"),
9439        }
9440    }
9441
9442    /// `mh+24` — modified more than 24 hours ago. Stored op '>'.
9443    #[test]
9444    fn parse_qualifier_string_mh_plus_24_hours() {
9445        let _g = crate::test_util::global_state_lock();
9446        let qs = parse_qualifier_string("mh+24");
9447        match first_qual(&qs) {
9448            qualifier::Mtime { value, unit, op } => {
9449                assert_eq!(*value, 24);
9450                assert_eq!(*unit, TT_HOURS);
9451                assert_eq!(*op, '>');
9452            }
9453            other => panic!("expected Mtime, got {other:?}"),
9454        }
9455    }
9456
9457    /// `a-7` — accessed less than 7 days ago. Stored op '<'.
9458    #[test]
9459    fn parse_qualifier_string_a_minus_7_days() {
9460        let _g = crate::test_util::global_state_lock();
9461        let qs = parse_qualifier_string("a-7");
9462        match first_qual(&qs) {
9463            qualifier::Atime { value, unit, op } => {
9464                assert_eq!(*value, 7);
9465                assert_eq!(*unit, TT_DAYS);
9466                assert_eq!(*op, '<');
9467            }
9468            other => panic!("expected Atime, got {other:?}"),
9469        }
9470    }
9471
9472    // ── Subscript qualifier — `[N]` keep first ───────────────────────
9473    /// `[1]` — first entry only. C's `first = v.start` (c:1739) is getindex's
9474    /// 0-based offset (c:2145) and `end = v.end` the 1-based bound.
9475    #[test]
9476    fn parse_qualifier_string_bracket_one_sets_first_to_zero() {
9477        let _g = crate::test_util::global_state_lock();
9478        let qs = parse_qualifier_string("[1]");
9479        assert_eq!(qs.first, Some(0));
9480        assert_eq!(qs.last, Some(1));
9481    }
9482
9483    /// `om[1]` — sort by mtime, keep first.
9484    #[test]
9485    fn parse_qualifier_string_om_bracket_one_sort_and_subscript() {
9486        let _g = crate::test_util::global_state_lock();
9487        let qs = parse_qualifier_string("om[1]");
9488        assert_eq!(qs.sorts.len(), 1);
9489        assert_eq!(qs.sorts[0] & GS_MTIME, GS_MTIME);
9490        assert_eq!(qs.first, Some(0));
9491    }
9492
9493    // ═══════════════════════════════════════════════════════════════════
9494    // tokenize / remnulargs C-parity tests — pin Src/glob.c:3551 (tokenize)
9495    // and Src/glob.c:3649 (remnulargs). These two functions form the
9496    // input → glob-token → output sandwich every glob pattern walks
9497    // through; regressions silently corrupt every pattern match.
9498    // Tests that capture KNOWN ZSHRS BUGS use #[ignore = "ZSHRS BUG: …"].
9499    // ═══════════════════════════════════════════════════════════════════
9500
9501    /// `tokenize("abc")` is a no-op for plain ASCII — no glob metachars.
9502    /// C glob.c:3551 — `for (; (c = *s); s++) zstrtok(...)` — only
9503    /// recognized glob bytes get replaced with their token form.
9504    #[test]
9505    fn tokenize_pure_ascii_unchanged() {
9506        let _g = crate::test_util::global_state_lock();
9507        let mut s = String::from("abc");
9508        tokenize(&mut s);
9509        assert_eq!(s, "abc");
9510    }
9511
9512    /// `tokenize("")` on empty input returns empty unchanged.
9513    #[test]
9514    fn tokenize_empty_unchanged() {
9515        let _g = crate::test_util::global_state_lock();
9516        let mut s = String::new();
9517        tokenize(&mut s);
9518        assert_eq!(s, "");
9519    }
9520
9521    /// `remnulargs("abc")` is a no-op for pure ASCII (no inull bytes).
9522    /// C glob.c:3649-3680 — only Snull/Dnull/Bnull/Bnullkeep/Nularg
9523    /// are stripped or transformed.
9524    #[test]
9525    fn remnulargs_pure_ascii_unchanged() {
9526        let _g = crate::test_util::global_state_lock();
9527        let mut s = String::from("hello");
9528        remnulargs(&mut s);
9529        assert_eq!(s, "hello");
9530    }
9531
9532    /// `remnulargs("")` returns empty unchanged (c:3653 early exit).
9533    #[test]
9534    fn remnulargs_empty_returns_empty() {
9535        let _g = crate::test_util::global_state_lock();
9536        let mut s = String::new();
9537        remnulargs(&mut s);
9538        assert_eq!(s, "");
9539    }
9540
9541    /// `remnulargs` strips Snull (single-quote scope marker).
9542    /// C glob.c:3656 — inull predicate includes Snull (0x84).
9543    /// Input `"a\u{e184}b"` → `"ab"` (strip the Snull byte).
9544    #[test]
9545    fn remnulargs_strips_snull_marker() {
9546        let _g = crate::test_util::global_state_lock();
9547        let mut s = format!("a{}b", crate::ported::zsh_h::Snull);
9548        remnulargs(&mut s);
9549        assert_eq!(s, "ab", "Snull byte should be stripped");
9550    }
9551
9552    /// `remnulargs` strips Dnull (double-quote scope marker).
9553    #[test]
9554    fn remnulargs_strips_dnull_marker() {
9555        let _g = crate::test_util::global_state_lock();
9556        let mut s = format!("a{}b", crate::ported::zsh_h::Dnull);
9557        remnulargs(&mut s);
9558        assert_eq!(s, "ab", "Dnull byte should be stripped");
9559    }
9560
9561    /// `remnulargs` strips Bnull (backslash-quoted marker — c:3658
9562    /// `inull(c)` includes Bnull). The non-Bnullkeep variant of the
9563    /// active-backslash marker.
9564    #[test]
9565    fn remnulargs_strips_bnull_marker() {
9566        let _g = crate::test_util::global_state_lock();
9567        let mut s = format!("a{}b", crate::ported::zsh_h::Bnull);
9568        remnulargs(&mut s);
9569        assert_eq!(s, "ab", "Bnull byte should be stripped");
9570    }
9571
9572    /// `remnulargs` on a single Nularg returns the Nularg sentinel
9573    /// preserved. C glob.c:3690-3692 — if post-strip output is empty
9574    /// AND original had any inull, emit Nularg as the empty-arg marker.
9575    #[test]
9576    fn remnulargs_empty_post_strip_emits_nularg_sentinel() {
9577        let _g = crate::test_util::global_state_lock();
9578        let mut s = format!("{}", crate::ported::zsh_h::Snull);
9579        remnulargs(&mut s);
9580        assert_eq!(
9581            s,
9582            format!("{}", crate::ported::zsh_h::Nularg),
9583            "all-inull input should collapse to single Nularg marker"
9584        );
9585    }
9586
9587    /// `remnulargs` on the Bnullkeep marker: c:3669 — Bnullkeep
9588    /// either (a) preserved as-is when it appears BEFORE any other
9589    /// inull (scan phase), or (b) transformed to literal `\` (copy
9590    /// phase). Pin the simpler case: a lone Bnullkeep is stripped.
9591    #[test]
9592    fn remnulargs_lone_bnullkeep_stripped() {
9593        let _g = crate::test_util::global_state_lock();
9594        let mut s = format!("a{}b", crate::ported::zsh_h::Bnullkeep);
9595        remnulargs(&mut s);
9596        // C behavior depends on whether the byte is in scan or copy
9597        // phase. Faithful port should give either "ab" (scan strip)
9598        // or "a\\b" (copy phase materialize). Either is acceptable;
9599        // pin that it's NOT the raw input.
9600        assert!(
9601            s == "ab" || s == "a\\b",
9602            "Bnullkeep should be stripped or materialized; got {s:?}"
9603        );
9604    }
9605
9606    // ═══════════════════════════════════════════════════════════════════
9607    // Additional C-parity tests for Src/glob.c file_type + hasbraces.
9608    // ═══════════════════════════════════════════════════════════════════
9609
9610    /// c:2018 — file_type for regular file with no exec bits → ' '.
9611    #[test]
9612    #[cfg(unix)]
9613    fn file_type_regular_non_exec_returns_space() {
9614        let r = file_type(libc::S_IFREG as u32 | 0o644);
9615        assert_eq!(r, ' ');
9616    }
9617
9618    /// c:2018 — file_type for regular file WITH exec bit → '*'.
9619    #[test]
9620    #[cfg(unix)]
9621    fn file_type_regular_exec_returns_star() {
9622        let r = file_type(libc::S_IFREG as u32 | 0o755);
9623        assert_eq!(r, '*');
9624    }
9625
9626    /// c:2018 — file_type for directory → '/'.
9627    #[test]
9628    #[cfg(unix)]
9629    fn file_type_directory_returns_slash() {
9630        let r = file_type(libc::S_IFDIR as u32 | 0o755);
9631        assert_eq!(r, '/');
9632    }
9633
9634    /// c:2018 — file_type for symlink → '@'.
9635    #[test]
9636    #[cfg(unix)]
9637    fn file_type_symlink_returns_at() {
9638        let r = file_type(libc::S_IFLNK as u32 | 0o777);
9639        assert_eq!(r, '@');
9640    }
9641
9642    /// c:2018 — file_type for FIFO → '|'.
9643    #[test]
9644    #[cfg(unix)]
9645    fn file_type_fifo_returns_pipe() {
9646        let r = file_type(libc::S_IFIFO as u32 | 0o644);
9647        assert_eq!(r, '|');
9648    }
9649
9650    /// c:2018 — file_type for socket → '='.
9651    #[test]
9652    #[cfg(unix)]
9653    fn file_type_socket_returns_equals() {
9654        let r = file_type(libc::S_IFSOCK as u32 | 0o777);
9655        assert_eq!(r, '=');
9656    }
9657
9658    /// c:2018 — file_type for char device → '%'.
9659    #[test]
9660    #[cfg(unix)]
9661    fn file_type_char_device_returns_percent() {
9662        let r = file_type(libc::S_IFCHR as u32 | 0o644);
9663        assert_eq!(r, '%');
9664    }
9665
9666    /// c:2018 — file_type for block device → '#'.
9667    #[test]
9668    #[cfg(unix)]
9669    fn file_type_block_device_returns_hash() {
9670        let r = file_type(libc::S_IFBLK as u32 | 0o644);
9671        assert_eq!(r, '#');
9672    }
9673
9674    /// c:2018 — file_type for unknown mode → '?'.
9675    #[test]
9676    fn file_type_unknown_mode_returns_question() {
9677        // Bare 0 mode (none of the S_IF* bits set) → '?'.
9678        assert_eq!(file_type(0), '?');
9679    }
9680
9681    /// c:2018 — file_type is deterministic.
9682    #[test]
9683    #[cfg(unix)]
9684    fn file_type_is_deterministic() {
9685        for mode in [
9686            libc::S_IFREG as u32 | 0o644,
9687            libc::S_IFDIR as u32,
9688            libc::S_IFLNK as u32,
9689            0u32,
9690        ] {
9691            let first = file_type(mode);
9692            for _ in 0..5 {
9693                assert_eq!(file_type(mode), first);
9694            }
9695        }
9696    }
9697
9698    /// c:2042 — hasbraces on plain string returns false.
9699    #[test]
9700    fn hasbraces_no_braces_returns_false() {
9701        let _g = crate::test_util::global_state_lock();
9702        assert!(!hasbraces("hello", false));
9703        assert!(!hasbraces("", false));
9704    }
9705
9706    /// c:2042 — hasbraces on plain ASCII `{a,b}` returns FALSE because
9707    /// the C port checks TOKEN bytes (Inbrace=0x8f / Outbrace=0x90 /
9708    /// Comma=0x9a) not literal ASCII. The lexer pre-tokenizes input
9709    /// before hasbraces runs in the real pipeline. Pin the actual
9710    /// behavior so a regen that adds ASCII fast-path is caught.
9711    #[test]
9712    fn hasbraces_plain_ascii_braces_returns_false_uses_tokens() {
9713        let _g = crate::test_util::global_state_lock();
9714        assert!(
9715            !hasbraces("{a,b}", false),
9716            "plain ASCII not tokenized → false; lexer pre-tokenizes in real pipeline"
9717        );
9718    }
9719
9720    /// c:2042 — hasbraces on tokenized brace expansion returns true.
9721    /// Construct the TOKEN form: Inbrace + 'a' + Comma + 'b' + Outbrace.
9722    #[test]
9723    fn hasbraces_tokenized_brace_expansion_returns_true() {
9724        let _g = crate::test_util::global_state_lock();
9725        let tokenized = format!(
9726            "{}a{}b{}",
9727            '\u{e18f}', // Inbrace token
9728            '\u{e19a}', // Comma token
9729            '\u{e190}'  // Outbrace token
9730        );
9731        assert!(
9732            hasbraces(&tokenized, false),
9733            "Inbrace+a+Comma+b+Outbrace must trigger brace expansion"
9734        );
9735    }
9736
9737    /// c:2042 — hasbraces on unclosed `{` returns false (no matching pair).
9738    #[test]
9739    fn hasbraces_unclosed_returns_false() {
9740        let _g = crate::test_util::global_state_lock();
9741        assert!(!hasbraces("{", false));
9742        assert!(!hasbraces("{abc", false));
9743    }
9744
9745    // ═══════════════════════════════════════════════════════════════════
9746    // Additional C-parity tests for Src/glob.c
9747    // c:950 qgetnum / c:994 qgetmodespec / c:1250 glob_exec_string /
9748    // c:1351 checkglobqual / c:1539 hasbraces / c:1617 bracechardots /
9749    // c:1802 matchpat / c:1899 getmatch / c:1505 file_type
9750    // ═══════════════════════════════════════════════════════════════════
9751
9752    /// c:950 — `qgetnum("")` empty returns None.
9753    #[test]
9754    fn qgetnum_empty_returns_none() {
9755        assert!(qgetnum("").is_none(), "empty → None");
9756    }
9757
9758    /// c:950 — `qgetnum` returns Option<(i64, &str)> (type pin).
9759    #[test]
9760    fn qgetnum_returns_option_i64_str_tuple_type() {
9761        let _: Option<(i64, &str)> = qgetnum("42");
9762    }
9763
9764    /// c:950 — `qgetnum("123")` returns Some((123, "")) (canonical decimal).
9765    #[test]
9766    fn qgetnum_canonical_decimal_parses() {
9767        let r = qgetnum("123");
9768        assert!(r.is_some(), "valid digits → Some");
9769        let (n, rest) = r.unwrap();
9770        assert_eq!(n, 123);
9771        assert_eq!(rest, "");
9772    }
9773
9774    /// c:994 — `qgetmodespec("")` empty returns None.
9775    #[test]
9776    fn qgetmodespec_empty_returns_none() {
9777        assert!(qgetmodespec("").is_none());
9778    }
9779
9780    /// c:1539 — `hasbraces("")` empty returns false.
9781    #[test]
9782    fn hasbraces_empty_returns_false() {
9783        let _g = crate::test_util::global_state_lock();
9784        assert!(!hasbraces("", false));
9785    }
9786
9787    /// c:1539 — `hasbraces` is pure (no side effects).
9788    #[test]
9789    fn hasbraces_is_pure() {
9790        let _g = crate::test_util::global_state_lock();
9791        for s in ["", "abc", "{", "{a,b}", "no braces"] {
9792            let first = hasbraces(s, false);
9793            for _ in 0..3 {
9794                assert_eq!(
9795                    hasbraces(s, false),
9796                    first,
9797                    "hasbraces({:?}, false) must be pure",
9798                    s
9799                );
9800            }
9801        }
9802    }
9803
9804    /// c:1505 — `file_type(0)` returns char (compile-time type pin).
9805    #[test]
9806    fn file_type_returns_char_type() {
9807        let _: char = file_type(0);
9808    }
9809
9810    /// c:1505 — `file_type` is pure for arbitrary mode.
9811    #[test]
9812    fn file_type_is_pure() {
9813        for m in [0u32, 0o100000, 0o040000, 0o120000, 0o140000, 0o160000] {
9814            let first = file_type(m);
9815            for _ in 0..3 {
9816                assert_eq!(file_type(m), first, "file_type({:o}) must be pure", m);
9817            }
9818        }
9819    }
9820
9821    /// c:1802 — `matchpat("", "", false, false)` returns bool (type pin).
9822    #[test]
9823    fn matchpat_returns_bool_type() {
9824        let _g = crate::test_util::global_state_lock();
9825        let _: bool = matchpat("", "", false, false);
9826    }
9827
9828    /// c:2710 — `getmatch` returns C's int status and writes the result
9829    /// back through the `char **sp` out-pointer (type pin).
9830    #[test]
9831    fn getmatch_returns_string_type() {
9832        let _g = crate::test_util::global_state_lock();
9833        let mut sp = String::new();
9834        let _: i32 = getmatch(&mut sp, "", 0, 0, None);
9835    }
9836
9837    /// c:2676-2686 — a pattern without backreferences substitutes the
9838    /// replacement once and leaves SUB_DOSUBST clear.
9839    #[test]
9840    fn compgetmatch_plain_pattern_substitutes_replstr_once() {
9841        let _g = crate::test_util::global_state_lock();
9842        let mut fl = 0;
9843        let mut r = Some("X".to_string());
9844        assert!(compgetmatch("a*", &mut fl, &mut r).is_some());
9845        assert_eq!(fl & SUB_DOSUBST, 0);
9846        assert_eq!(r.as_deref(), Some("X"));
9847    }
9848}