Skip to main content

zsh/
pattern_data_escape.rs

1//! Rust-only utility (NOT a port — lives outside `src/ported/` by design).
2//!
3//! The DATA half of docs/BUGS.md #1090: how a backslash that is a
4//! CHARACTER OF A VALUE has to be spelled before it reaches
5//! `ported::pattern::patcompile`.
6//!
7//! C never needs this transform. Its pattern compiler consumes the
8//! LEXER's encoding, where a source-level quote already arrived as
9//! `Bnull`/`Bnullkeep` + payload (c:Src/zsh.h:195-200), so a RAW
10//! backslash in `patcompile`'s input can only be data. A substituted
11//! value acquires its pattern meaning in `zshtokenize`
12//! (c:Src/glob.c:3585-3653), reached from `strcatsub`'s
13//! `if (glbsub) shtokenize(dest)` (c:Src/subst.c:822/830) for
14//! `${~spec}` / `GLOB_SUBST`, and that function rewrites a backslash
15//! into a quote marker ONLY when the next character reaches its
16//! `ztokens` scan:
17//!
18//! ```text
19//! c:Src/glob.c:3597-3605   case Bnull: case Bnullkeep: case '\\':
20//!                              if (bslash) { s[-1] = … Bnullkeep/Bnull; break; }
21//!                              bslash = 1; continue;
22//! c:Src/glob.c:3640-3648   for (t = ztokens; *t; t++)
23//!                              if (*t == *s) {
24//!                                  if (bslash) s[-1] = … Bnullkeep/Bnull;
25//!                                  else *s = (t - ztokens) + Pound;
26//!                                  break;
27//!                              }
28//! c:Src/glob.c:3651        bslash = 0;
29//! ```
30//!
31//! Before anything else — a space, a `$`, a `{` — no `switch` arm fires,
32//! c:3651 just clears `bslash`, and BOTH bytes survive in the string as
33//! ordinary literal data. That is why real zsh answers
34//!
35//! ```text
36//! p='a\ b'; [[ 'a b'  == ${~p} ]]   # no match — the pattern holds a backslash
37//! p='a\ b'; [[ 'a\ b' == ${~p} ]]   # match
38//! ```
39//!
40//! `ported::pattern`'s input normalizer (src/ported/pattern.rs, the `\\`
41//! arm) reads a lone raw `\X` as a QUOTE of X — the spelling every
42//! SOURCE-level pattern path in zshrs hands it (the cond/case pattern
43//! builder in `extensions::compile_zsh`, `${v//\%/%%}`'s builder in
44//! `ported::subst`) — and spells a literal backslash as the pair `\\`.
45//! So doubling exactly the backslashes `zshtokenize` declines to consume
46//! is what carries C's `Bnull`-vs-raw split into the Rust encoding.
47//! Backslashes the tokenizer WOULD consume are left in place so the
48//! downstream tokenizer/normalizer still folds them into a quote at
49//! their original position.
50//!
51//! Callers are the "this pattern text came out of a VALUE" sites:
52//!   * `ported::subst::paramsubst` — the search-subscript patterns
53//!     (`${a[(I)…]}` / `(i)` / `(r)` / `(R)` / `(K)`), which reach
54//!     `patcompile` through `tokenize` alone (c:Src/params.c:1727).
55//!   * `fusevm_bridge`'s `BUILTIN_GLOB_SUBST_GUARD` /
56//!     `BUILTIN_PAT_DATA_BACKSLASH` — the `${~spec}` and `setopt
57//!     globsubst` legs of a `[[ … == pat ]]` RHS and a `case` arm, the
58//!     `strcatsub` `shtokenize` C runs at c:Src/subst.c:822/830.
59
60/// The `switch` labels of `zshtokenize` that can consume a preceding
61/// backslash — c:Src/glob.c:3599 (`\\`), c:3606 (`<`), c:3623-3625
62/// (`(`/`|`/`)`) and c:3629-3639 (`>`/`^`/`#`/`~`/`[`/`]`/`*`/`?`/`=`/
63/// `-`/`!`).
64///
65/// A character that is in the `ztokens` TABLE (c:Src/lex.c:38) but has
66/// NO `switch` label — `$`, `{`, `}`, `` ` ``, `,`, `'`, `"` — never
67/// reaches the c:3640 scan, so its backslash stays data. zsh answers 2,
68/// not 1, for
69/// ```text
70/// a=('a$b' 'a\$b'); q='a\$b'; print ${a[(I)$q]}
71/// ```
72fn quotes_a_metachar(c: char) -> bool {
73    matches!(
74        c,
75        '<' | '('
76            | '|'
77            | ')'
78            | '>'
79            | '^'
80            | '#'
81            | '~'
82            | '['
83            | ']'
84            | '*'
85            | '?'
86            | '='
87            | '-'
88            | '!'
89            | '\\'
90    )
91}
92
93/// Rewrite a pattern string that came out of a VALUE so
94/// `ported::pattern`'s normalizer reads its backslashes the way
95/// `zshtokenize` does — see the module docs.
96///
97/// Every backslash `zshtokenize` would NOT consume (c:Src/glob.c:3651
98/// `bslash = 0` with nothing rewritten) is doubled, which is the
99/// normalizer's literal-backslash form. A trailing lone backslash is
100/// data too (c:3590 `for (; *s; s++)` ends before any arm can fire) and
101/// is doubled as well.
102pub fn escape_data_backslashes(v: &str) -> String {
103    if !v.contains('\\') {
104        return v.to_string();
105    }
106    let cs: Vec<char> = v.chars().collect();
107    let mut out = String::with_capacity(v.len() + 4);
108    let mut i = 0;
109    while i < cs.len() {
110        let c = cs[i];
111        if c != '\\' {
112            out.push(c);
113            i += 1;
114            continue;
115        }
116        if cs.get(i + 1).copied().is_some_and(quotes_a_metachar) {
117            // c:3600-3602 / c:3642-3643 — the escape is honored; leave the
118            // pair for the tokenizer to fold into `Bnull`/`Bnullkeep`.
119            out.push(c);
120            out.push(cs[i + 1]);
121            i += 2;
122        } else {
123            // c:3651 `bslash = 0` with nothing rewritten — a data backslash.
124            out.push('\\');
125            out.push('\\');
126            i += 1;
127        }
128    }
129    out
130}
131
132/// The same DATA-vs-QUOTE split as [`escape_data_backslashes`], applied one
133/// step LATER: to a string that has ALREADY been through `tokenize()` +
134/// `remnulargs()` and is about to be handed straight to `patcompile`.
135///
136/// `zshtokenize` (c:Src/glob.c:3585, flags 0) folds every backslash it
137/// honors into a `Bnull` written at the BACKSLASH's own position, leaving
138/// the escaped character raw (c:3600-3602 `s[-1] = … Bnull` and c:3642-3643
139/// for the `ztokens` scan). `remnulargs` (c:Src/glob.c:3658) then deletes
140/// those `Bnull`s. So in the string C finally hands to `patcompile` every
141/// surviving raw `\` is a literal backslash CHARACTER: `patcomppiece` has
142/// no `case '\\'` at all (c:Src/pattern.c:1579-1601 — only `case Bnullkeep`
143/// at c:1589 is special), so that byte compiles as ordinary text. This is
144/// why real zsh answers
145///
146/// ```text
147/// # the pattern is `\` + `(`, so it needs a literal backslash in the word
148/// compset -P '\\\('     # PREFIX='('   -> no match
149/// compset -P '\('       # PREFIX='('   -> match
150/// ```
151///
152/// `ported::pattern`'s normalizer instead reads a raw `\X` as a QUOTE of X
153/// (src/ported/pattern.rs, the `\\` arm) and spells a literal backslash as
154/// the pair `\\`. Doubling every remaining backslash is therefore the exact
155/// transposition of C's post-`remnulargs` encoding into the Rust one.
156///
157/// Distinct from [`escape_data_backslashes`], which runs BEFORE `tokenize`
158/// and must leave a `\<metachar>` pair alone so the tokenizer can still fold
159/// it into a quote. Here the tokenizer has already run, so no pair is
160/// pending and every backslash left is data.
161pub fn escape_tokenized_data_backslashes(v: &str) -> String {
162    if !v.contains('\\') {
163        return v.to_string();
164    }
165    v.replace('\\', "\\\\")
166}
167
168/// The SH_GLOB half of the same `strcatsub` step: `shtokenize` builds its
169/// flags from the option (c:Src/glob.c:3575-3580)
170///
171/// ```text
172/// int flags = ZSHTOK_SUBST;
173/// if (isset(SHGLOB))
174///     flags |= ZSHTOK_SHGLOB;
175/// ```
176///
177/// and `zshtokenize` then DECLINES to tokenize `(`, `|` and `)`
178/// (c:Src/glob.c:3617-3620)
179///
180/// ```text
181/// case '(':
182/// case '|':
183/// case ')':
184///     if (flags & ZSHTOK_SHGLOB)
185///         break;
186/// ```
187///
188/// so under SH_GLOB those three characters stay ordinary data. zsh applies
189/// that unconditionally — even a KSH_GLOB group loses its meaning:
190/// `zsh -fc 'setopt shglob kshglob; v="  x  "; print "[${v##+([[:space:]])}]"'`
191/// prints `[  x  ]`, unchanged.
192///
193/// zshrs cannot express the suppression by skipping its own tokenize pass,
194/// because the consumers tokenize the ASSEMBLED word once, after the value
195/// has been concatenated with any source-level pattern text around it;
196/// skipping there would de-meta the source half too. Spelling the three
197/// characters in the normalizer's literal form (`\X`) instead carries the
198/// suppression on exactly the bytes it belongs to.
199///
200/// `keep_ksh_groups` is the bash/ksh DROP-IN exemption — see
201/// [`dropin_keeps_ksh_groups`]. With it set, a `(` that opens a ksh-style
202/// extended group (`@(`, `*(`, `+(`, `?(`, `!(`) keeps its meaning, and so
203/// do that group's `|` separators and its closing `)`; every other paren
204/// and every `|` outside such a group is still literal, which is precisely
205/// how bash and ksh read them. Groups nest, so the decision is stacked.
206///
207/// Characters inside a `[…]` class are left alone: they are class members
208/// under every one of these rules, and `zshtokenize` — a flat scan — would
209/// not have touched them either.
210///
211/// A backslash pair is stepped over whole, as in [`escape_data_backslashes`],
212/// so an escape that is already present is never re-escaped into a literal
213/// backslash plus a live metacharacter.
214pub fn escape_shglob_parens(v: &str, keep_ksh_groups: bool) -> String {
215    if !v.contains(['(', '|', ')']) {
216        return v.to_string();
217    }
218    let cs: Vec<char> = v.chars().collect();
219    let mut out = String::with_capacity(v.len() + 4);
220    // One entry per open `(`: true when that group survives as a real
221    // ksh-glob group, false when its parens were made literal.
222    let mut groups: Vec<bool> = Vec::new();
223    let mut in_class = false;
224    let mut i = 0;
225    while i < cs.len() {
226        let c = cs[i];
227        if c == '\\' && i + 1 < cs.len() {
228            out.push(c);
229            out.push(cs[i + 1]);
230            i += 2;
231            continue;
232        }
233        if in_class {
234            if c == ']' {
235                in_class = false;
236            }
237            out.push(c);
238            i += 1;
239            continue;
240        }
241        match c {
242            '[' => {
243                in_class = true;
244                out.push(c);
245            }
246            '(' => {
247                let ksh = keep_ksh_groups
248                    && i > 0
249                    && matches!(cs[i - 1], '@' | '*' | '+' | '?' | '!')
250                    && !(i >= 2 && cs[i - 2] == '\\');
251                groups.push(ksh);
252                if !ksh {
253                    out.push('\\');
254                }
255                out.push(c);
256            }
257            ')' => {
258                // An unmatched `)` closes nothing, so it is ordinary text.
259                if !groups.pop().unwrap_or(false) {
260                    out.push('\\');
261                }
262                out.push(c);
263            }
264            '|' => {
265                if !groups.last().copied().unwrap_or(false) {
266                    out.push('\\');
267                }
268                out.push(c);
269            }
270            _ => out.push(c),
271        }
272        i += 1;
273    }
274    out
275}
276
277/// Whether SH_GLOB's `(` / `|` / `)` suppression applies at all right now.
278///
279/// The zsh rule is the option, so this is simply `isset(SHGLOB)` — EXCEPT in
280/// a bare Korn drop-in.
281///
282/// !!! DROP-IN GATE — no zsh C counterpart !!!
283/// `zshrs --ksh` reaches EMULATE_KSH, which raises SH_GLOB, but its
284/// reference is ksh, and ksh reads a BARE `(` as a grouping character in its
285/// own right — `ksh -c 'v="-a"; print -r -- "[${v##-(a|b*)}]"'` prints `[]`,
286/// i.e. the group matched and the whole value was stripped, where zsh under
287/// SH_GLOB leaves `-(a|b*)` as six literal characters. So the Korn drop-in
288/// suppresses nothing.
289///
290/// bash is the middle case and is handled by [`dropin_keeps_ksh_groups`]:
291/// `(` is special there ONLY after `@ * + ? !`.
292///
293/// False for none of `--zsh`, native zshrs, or a zsh user's own
294/// `emulate sh` / `emulate ksh` beyond what the option itself says — those
295/// keep zsh's answer.
296pub fn shglob_hides_parens() -> bool {
297    crate::ported::zsh_h::isset(crate::ported::zsh_h::SHGLOB)
298}
299
300/// Whether a bash DROP-IN is running with extended patterns on.
301///
302/// !!! DROP-IN GATE — no zsh C counterpart !!!
303/// `zshrs --bash` reaches EMULATE_SH, which raises SH_GLOB, but its
304/// reference is bash. bash gives `@(…)` / `+(…)` their extended meaning in
305/// exactly the positions zsh suppresses
306/// (`bash -c 'shopt -s extglob; v="  x  "; echo "[${v##+([[:space:]])}]"'`
307/// prints `[x  ]`, where zsh prints `[  x  ]`), while still reading a BARE
308/// `(` as ordinary text — bash makes `(` special only after `@ * + ? !`.
309/// So the bash drop-in keeps the ksh groups and literalizes everything else.
310///
311/// False in `--zsh`, in native zshrs, and under a zsh user's own
312/// `emulate sh`, where zsh's answer is the correct one. bash's `extglob`
313/// shopt is zshrs's `kshglob` (src/extensions/dash_mode.rs SHOPT table), so
314/// the option carries the enable.
315pub fn dropin_keeps_ksh_groups() -> bool {
316    (crate::dash_mode::bash_mode() || crate::dash_mode::korn_mode())
317        && crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHGLOB)
318}
319
320/// Whether a SOURCE-level `[[ … ]]` / `case` pattern must follow the
321/// EMULATED shell's paren rules instead of zsh's.
322///
323/// !!! DROP-IN GATE — no zsh C counterpart !!!
324/// zsh's answer for such a pattern comes from WHEN it was tokenized: the
325/// parser turned `(` into a grouping token before SH_GLOB was set, so the
326/// option later strands the `)` and the pattern is bad (c:Src/pattern.c:
327/// 500-510 + :913-917). That history is a zsh artifact. A bare POSIX-family
328/// drop-in has no such history — bash, ksh, dash and sh decide what `(`
329/// means when they match, so a pattern zsh rejects is simply ordinary text
330/// there:
331/// ```text
332/// bash -c "[[ '-a' = -(a|b*) ]] && echo M || echo N"   # N
333/// ksh  -c "[[ '-a' = -(a|b*) ]] && echo M || echo N"   # N
334/// zsh  -fc "setopt shglob; [[ '-a' = -(a|b*) ]]"       # bad pattern
335/// ```
336/// [`dropin_keeps_ksh_groups`] then decides whether `@(…)` / `+(…)` survive
337/// inside that text.
338///
339/// `posix_faithful` is what separates the bare drop-in from the zsh-STYLE
340/// leg (`--sh --zsh`, or a zsh user typing `emulate sh`), which must keep
341/// zsh's answer.
342pub fn dropin_source_pattern_parens_literal() -> bool {
343    crate::dash_mode::posix_faithful() && crate::ported::zsh_h::isset(crate::ported::zsh_h::SHGLOB)
344}
345
346/// !!! WARNING: RUST-ONLY HELPER — NO C COUNTERPART !!!
347///
348/// Fills `ported::subst::DEFAULT_WORD_GLOB_BARS` for the default / alternate
349/// word of `${x:-word}` / `${x-word}` / `${x:+word}` / `${x+word}`. In C the
350/// lexer makes a `|` inside `${…}` the `Bar` token (c:Src/lex.c:1000-1008),
351/// multsub keeps the token in the substituted value (c:Src/subst.c:3207-3228),
352/// and globlist then reads a top-level alternation in the assembled word:
353/// `${x:-b|a}` matches the file `b`, `X${x:-b|a}` is the pattern `Xb` | `a`.
354/// zshrs untokenizes the word before BUILTIN_DEFAULT_WORD_GLOB, which puts
355/// the recorded `Bar`s back.
356///
357/// `word` is the SOURCE text of the default / alternate word as paramsubst
358/// holds it: an unquoted `|` is a raw `|`, while a quoted (`"b|a"`,
359/// `'b|a'`) or escaped (`b\|a`) one arrives as `Bnull |` and stays literal.
360/// A raw `|` inside `(…)` or `[…]` is left to the glob engine's own
361/// grouping. `value` is what the word substituted to. Only a word with no
362/// substitution syntax is recorded: its source text IS the value, so the
363/// `|` positions are known without running multsub again (a `$(…)` in the
364/// word must not run twice).
365///
366/// Returns whether the word holds a top-level source `|`, so the caller can
367/// arm the default-word glob for it.
368pub fn note_default_word_bars(word: &str, value: &str) -> bool {
369    use crate::ported::zsh_h::{
370        Bar, Bnull, Bnullkeep, Dnull, Inbrack, Inpar, Nularg, Outbrack, Outpar, Qstring, Qtick,
371        Snull, Tick,
372    };
373    let cs: Vec<char> = word.chars().collect();
374    let mut barred = String::with_capacity(word.len());
375    let mut paren = 0i32;
376    let mut bracket = false;
377    let mut found = false;
378    let mut substitutes = false;
379    let mut i = 0;
380    while i < cs.len() {
381        let c = cs[i];
382        if (c == Bnull || c == Bnullkeep) && i + 1 < cs.len() {
383            barred.push(cs[i + 1]); // quoted / escaped: literal
384            i += 2;
385            continue;
386        }
387        if c == Snull || c == Dnull || c == Nularg {
388            i += 1;
389            continue;
390        }
391        if matches!(c, '$' | '`' | '\u{e185}') || c == Qstring || c == Tick || c == Qtick {
392            substitutes = true;
393        }
394        match c {
395            '(' | Inpar => paren += 1,
396            ')' | Outpar => paren = (paren - 1).max(0),
397            '[' | Inbrack => bracket = true,
398            ']' | Outbrack => bracket = false,
399            _ => {}
400        }
401        if (c == '|' || c == Bar) && paren == 0 && !bracket {
402            found = true;
403            barred.push(Bar);
404            i += 1;
405            continue;
406        }
407        if ('\u{e184}'..='\u{e19c}').contains(&c) {
408            barred.push_str(&crate::ported::lex::untokenize(&c.to_string()));
409        } else {
410            barred.push(c);
411        }
412        i += 1;
413    }
414    if found && !substitutes && crate::ported::lex::untokenize(&barred) == value {
415        crate::ported::subst::DEFAULT_WORD_GLOB_BARS
416            .with(|b| b.borrow_mut().push((value.to_string(), barred)));
417    }
418    found
419}
420
421/// Engine selected by the options of a ksh93 `~(options)` / `~(options:pat)`
422/// pattern prefix.
423#[derive(Clone, Copy, PartialEq)]
424enum Ksh93PatEngine {
425    /// ksh glob (`K`, the default; `X`/`N` read the same here).
426    Glob,
427    /// `F`: fixed string, found anywhere in the subject.
428    Fixed,
429    /// `E` / `P`: extended regular expression, searched anywhere in the subject.
430    Ere,
431    /// `G`: basic regular expression, searched anywhere in the subject.
432    Bre,
433}
434
435/// Options parsed from `~(…)`: `(case-insensitive, engine)`, or `None` for a
436/// letter this port does not model. `-` turns the letters after it off, `+`
437/// on again.
438fn ksh93_pat_options(flags: &[char]) -> Option<(bool, Ksh93PatEngine)> {
439    let (mut icase, mut engine, mut off) = (false, Ksh93PatEngine::Glob, false);
440    for &c in flags {
441        match c {
442            '-' => off = true,
443            '+' => off = false,
444            'i' => icase = !off,
445            'F' if !off => engine = Ksh93PatEngine::Fixed,
446            'E' | 'P' if !off => engine = Ksh93PatEngine::Ere,
447            'G' if !off => engine = Ksh93PatEngine::Bre,
448            'K' | 'X' | 'N' | 'F' | 'E' | 'P' | 'G' => {}
449            _ => return None,
450        }
451    }
452    Some((icase, engine))
453}
454
455/// Index just past the `)` closing the group whose `(` is at `open`.
456fn ksh93_pat_group_end(cs: &[char], open: usize) -> Option<usize> {
457    let mut depth = 0;
458    let mut i = open;
459    while i < cs.len() {
460        match cs[i] {
461            '\\' => i += 1,
462            '(' => depth += 1,
463            ')' => {
464                depth -= 1;
465                if depth == 0 {
466                    return Some(i + 1);
467                }
468            }
469            _ => {}
470        }
471        i += 1;
472    }
473    None
474}
475
476/// `ab`-style swap: the other-case twin of an ASCII letter.
477fn ksh93_pat_swapcase(c: char) -> char {
478    if c.is_ascii_lowercase() { c.to_ascii_uppercase() } else { c.to_ascii_lowercase() }
479}
480
481/// Rewrite the glob `cs` (normalized) / `orig` (as given) so that its letters
482/// match either case: `a` -> `[aA]`, `[a-c]` -> `[a-cA-C]`.
483fn ksh93_pat_fold_case(orig: &[char], cs: &[char], out: &mut String) {
484    let mut i = 0;
485    while i < cs.len() {
486        match cs[i] {
487            '\\' => {
488                out.push(orig[i]);
489                if let Some(&n) = orig.get(i + 1) {
490                    out.push(n);
491                }
492                i += 2;
493            }
494            '[' => {
495                // A class runs to the first `]` after at least one member.
496                let start = i + 1 + usize::from(matches!(cs.get(i + 1), Some('!' | '^')));
497                match cs[(start + 1).min(cs.len())..].iter().position(|&c| c == ']') {
498                    Some(rel) => {
499                        let close = start + 1 + rel;
500                        out.extend(&orig[i..close]);
501                        out.extend(cs[start..close].iter().filter(|c| c.is_ascii_alphabetic()).map(|&c| ksh93_pat_swapcase(c)));
502                        out.push(orig[close]);
503                        i = close + 1;
504                    }
505                    None => {
506                        out.push(orig[i]);
507                        i += 1;
508                    }
509                }
510            }
511            c if c.is_ascii_alphabetic() => {
512                out.extend(['[', c, ksh93_pat_swapcase(c), ']']);
513                i += 1;
514            }
515            _ => {
516                out.push(orig[i]);
517                i += 1;
518            }
519        }
520    }
521}
522
523/// Translate a POSIX basic regular expression to the ERE the `regex` crate
524/// reads: `\( \) \{ \} \| \+ \?` become operators, their bare forms literals.
525fn ksh93_bre_to_ere(bre: &[char]) -> String {
526    let mut out = String::new();
527    let mut i = 0;
528    while i < bre.len() {
529        match (bre[i], bre.get(i + 1)) {
530            ('\\', Some(&n)) if "(){}|+?".contains(n) => {
531                out.push(n);
532                i += 2;
533            }
534            ('\\', Some(&n)) => {
535                out.extend(['\\', n]);
536                i += 2;
537            }
538            (c, _) if "(){}|+?".contains(c) => {
539                out.extend(['\\', c]);
540                i += 1;
541            }
542            (c, _) => {
543                out.push(c);
544                i += 1;
545            }
546        }
547    }
548    out
549}
550
551/// ksh93 pattern options: `[[ ab == ~(i)AB ]]`, `~(i:AB)c`, `a~(i)B*`,
552/// `~(E)b+`, `~(F)a.c`, `~(G)a\(b\)c`. `Some(verdict)` when `pat` carries a
553/// `~(options)` prefix this port models, `None` to let the ordinary matcher
554/// run. Verified against ksh93u+m 1.0.10:
555///
556///   * `i` makes the pattern text after it case-insensitive until `~(-i)`; the
557///     scoped `~(i:pat)` form covers only `pat`.
558///   * `F`, `E`/`P` and `G` replace the glob with a fixed string / ERE / BRE
559///     that is SEARCHED for anywhere in the subject (`[[ abc == ~(E)b ]]` is
560///     true), unlike a glob, which must cover it.
561///   * `K`, `X`, `N` read as the default ksh glob.
562///
563/// !!! RUST-ONLY EXTENSION — no zsh C counterpart; --ksh only (mksh has no
564/// `~(…)` pattern options). In zsh `~(…)` is extended-glob negation. !!!
565pub fn ksh93_flagged_match(s: &str, pat: &str) -> Option<bool> {
566    use crate::ported::zsh_h::{Inpar, Outpar, Tilde};
567    if !crate::dash_mode::ksh93_mode() {
568        return None;
569    }
570    let orig: Vec<char> = pat.chars().collect();
571    let cs: Vec<char> = orig
572        .iter()
573        .map(|&c| match c {
574            Tilde => '~',
575            Inpar => '(',
576            Outpar => ')',
577            c => c,
578        })
579        .collect();
580    let first = (0..cs.len().saturating_sub(1)).find(|&i| {
581        cs[i] == '~' && cs[i + 1] == '(' && (i == 0 || cs[i - 1] != '\\')
582    })?;
583    let (icase, engine) = {
584        let end = cs[first + 2..].iter().position(|&c| !(c.is_ascii_alphabetic() || c == '-' || c == '+'))? + first + 2;
585        ksh93_pat_options(&cs[first + 2..end])?
586    };
587    if engine != Ksh93PatEngine::Glob {
588        // The regex / fixed-string engines take the whole pattern: no text
589        // around the `~(options)` group is modelled.
590        let end = cs[first + 2..].iter().position(|&c| !(c.is_ascii_alphabetic() || c == '-' || c == '+'))? + first + 2;
591        let body: Vec<char> = match cs[end] {
592            ')' if first == 0 => cs[end + 1..].to_vec(),
593            ':' if first == 0 && ksh93_pat_group_end(&cs, 1) == Some(cs.len()) => cs[end + 1..cs.len() - 1].to_vec(),
594            _ => return None,
595        };
596        let text: String = body.iter().collect();
597        return match engine {
598            Ksh93PatEngine::Fixed if icase => Some(s.to_lowercase().contains(&text.to_lowercase())),
599            Ksh93PatEngine::Fixed => Some(s.contains(&text)),
600            _ => {
601                let re = if engine == Ksh93PatEngine::Bre { ksh93_bre_to_ere(&body) } else { text };
602                regex::RegexBuilder::new(&re).case_insensitive(icase).build().ok().map(|r| r.is_match(s))
603            }
604        };
605    }
606    // Glob engine: walk the pattern once, tracking the case-fold state.
607    let mut out = String::new();
608    let mut fold = false;
609    let mut i = 0;
610    let mut lit_from = 0;
611    while i < cs.len() {
612        if cs[i] == '\\' {
613            i += 2;
614            continue;
615        }
616        if cs[i] == '~' && cs.get(i + 1) == Some(&'(') {
617            let Some(rel) = cs[i + 2..].iter().position(|&c| !(c.is_ascii_alphabetic() || c == '-' || c == '+')) else { break };
618            let end = i + 2 + rel;
619            let Some((ic, eng)) = ksh93_pat_options(&cs[i + 2..end]) else { return None };
620            if eng != Ksh93PatEngine::Glob || !matches!(cs[end], ')' | ':') {
621                return None;
622            }
623            flush_range(&mut out, &orig, &cs, lit_from, i, fold);
624            // `~(-i)` clears the fold; `~(i)` sets it; other letters leave it.
625            let off = cs[i + 2..end].contains(&'-');
626            let new_fold = if cs[i + 2..end].contains(&'i') { !off && ic } else { fold };
627            if cs[end] == ')' {
628                fold = new_fold;
629                i = end + 1;
630            } else {
631                let close = ksh93_pat_group_end(&cs, i + 1)?;
632                flush_range(&mut out, &orig, &cs, end + 1, close - 1, new_fold);
633                i = close;
634            }
635            lit_from = i;
636            continue;
637        }
638        i += 1;
639    }
640    flush_range(&mut out, &orig, &cs, lit_from, cs.len(), fold);
641    Some(crate::vm_helper::glob_match_static(s, &out))
642}
643
644/// [`ksh93_pat_fold_case`] over `orig[from..to]` when `fold`, else a copy.
645fn flush_range(out: &mut String, orig: &[char], cs: &[char], from: usize, to: usize, fold: bool) {
646    if fold {
647        ksh93_pat_fold_case(&orig[from..to], &cs[from..to], out);
648    } else {
649        out.extend(&orig[from..to]);
650    }
651}