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}