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}