Skip to main content

zsh/extensions/
regex_mod.rs

1//! Regex module - port of Modules/regex.c
2//!
3//! Provides regex matching conditional for the =~ operator.
4
5use regex::Regex;
6use std::collections::HashMap;
7
8/// Result of a regex match operation
9#[derive(Debug, Clone)]
10pub struct RegexMatch {
11    /// `matched` field.
12    pub matched: bool,
13    /// `full_match` field.
14    pub full_match: Option<String>,
15    /// `captures` field.
16    pub captures: Vec<Option<String>>,
17    /// `match_start` field.
18    pub match_start: Option<usize>,
19    /// `match_end` field.
20    pub match_end: Option<usize>,
21    /// `capture_starts` field.
22    pub capture_starts: Vec<Option<usize>>,
23    /// `capture_ends` field.
24    pub capture_ends: Vec<Option<usize>>,
25}
26
27impl RegexMatch {
28    /// `no_match` — see implementation.
29    pub fn no_match() -> Self {
30        Self {
31            matched: false,
32            full_match: None,
33            captures: Vec::new(),
34            match_start: None,
35            match_end: None,
36            capture_starts: Vec::new(),
37            capture_ends: Vec::new(),
38        }
39    }
40}
41
42/// Options for regex matching
43#[derive(Debug, Clone, Default)]
44pub struct RegexOptions {
45    /// `case_insensitive` field.
46    pub case_insensitive: bool,
47    /// `bash_rematch` field.
48    pub bash_rematch: bool,
49    /// `ksh_arrays` field.
50    pub ksh_arrays: bool,
51}
52
53/// Compile and run a regex against `text`, returning match metadata.
54/// Port of `bin_regexparse` / `zcond_regex_match` from
55/// Src/Modules/regex.c. The C source uses the system POSIX
56/// `regcomp(3)`/`regexec(3)`; Rust's `regex` crate covers the same
57/// PCRE-like dialect zsh exposes via `[[ s =~ pat ]]`.
58pub fn regex_match(
59    text: &str,
60    pattern: &str,
61    options: &RegexOptions,
62) -> Result<RegexMatch, String> {
63    let re = if options.case_insensitive {
64        Regex::new(&format!("(?i){}", pattern))
65    } else {
66        Regex::new(pattern)
67    }
68    .map_err(|e| format!("failed to compile regex: {}", e))?;
69
70    let caps = match re.captures(text) {
71        Some(c) => c,
72        None => return Ok(RegexMatch::no_match()),
73    };
74
75    let full_match = caps.get(0).map(|m| m.as_str().to_string());
76    let match_start = caps.get(0).map(|m| m.start());
77    let match_end = caps.get(0).map(|m| m.end());
78
79    let mut captures = Vec::new();
80    let mut capture_starts = Vec::new();
81    let mut capture_ends = Vec::new();
82
83    for i in 1..caps.len() {
84        if let Some(m) = caps.get(i) {
85            captures.push(Some(m.as_str().to_string()));
86            capture_starts.push(Some(m.start()));
87            capture_ends.push(Some(m.end()));
88        } else {
89            captures.push(None);
90            capture_starts.push(None);
91            capture_ends.push(None);
92        }
93    }
94
95    Ok(RegexMatch {
96        matched: true,
97        full_match,
98        captures,
99        match_start,
100        match_end,
101        capture_starts,
102        capture_ends,
103    })
104}
105
106/// Convert byte offsets to character offsets
107fn byte_to_char_offset(s: &str, byte_offset: usize) -> usize {
108    s[..byte_offset].chars().count()
109}
110
111/// Surface match groups into the canonical `$MATCH` / `$MBEGIN` /
112/// `$MEND` / `$match` / `$mbegin` / `$mend` parameter set zsh's
113/// `[[ s =~ pat ]]` populates.
114/// Port of the per-match parameter installation done by
115/// `regex_match()` in Src/Modules/regex.c. Honours `bash_rematch`
116/// (BASH_REMATCH array) and `ksh_arrays` (1-based vs 0-based)
117/// flag toggles the C source also tracks.
118pub fn get_match_variables(
119    result: &RegexMatch,
120    text: &str,
121    options: &RegexOptions,
122) -> HashMap<String, String> {
123    let mut vars = HashMap::new();
124
125    if !result.matched {
126        return vars;
127    }
128
129    if options.bash_rematch {
130        if let Some(ref full) = result.full_match {
131            vars.insert("BASH_REMATCH[0]".to_string(), full.clone());
132        }
133        for (i, cap) in result.captures.iter().enumerate() {
134            if let Some(c) = cap {
135                vars.insert(format!("BASH_REMATCH[{}]", i + 1), c.clone());
136            }
137        }
138    } else {
139        if let Some(ref full) = result.full_match {
140            vars.insert("MATCH".to_string(), full.clone());
141        }
142
143        let base = if options.ksh_arrays { 0 } else { 1 };
144
145        if let Some(start) = result.match_start {
146            let char_start = byte_to_char_offset(text, start);
147            vars.insert("MBEGIN".to_string(), (char_start + base).to_string());
148        }
149
150        if let Some(end) = result.match_end {
151            let char_end = byte_to_char_offset(text, end);
152            vars.insert("MEND".to_string(), (char_end + base - 1).to_string());
153        }
154
155        for (i, cap) in result.captures.iter().enumerate() {
156            if let Some(c) = cap {
157                vars.insert(format!("match[{}]", i + base), c.clone());
158            }
159        }
160
161        for (i, start) in result.capture_starts.iter().enumerate() {
162            if let Some(s) = start {
163                let char_start = byte_to_char_offset(text, *s);
164                vars.insert(
165                    format!("mbegin[{}]", i + base),
166                    (char_start + base).to_string(),
167                );
168            } else {
169                vars.insert(format!("mbegin[{}]", i + base), "-1".to_string());
170            }
171        }
172
173        for (i, end) in result.capture_ends.iter().enumerate() {
174            if let Some(e) = end {
175                let char_end = byte_to_char_offset(text, *e);
176                vars.insert(
177                    format!("mend[{}]", i + base),
178                    (char_end + base - 1).to_string(),
179                );
180            } else {
181                vars.insert(format!("mend[{}]", i + base), "-1".to_string());
182            }
183        }
184    }
185
186    vars
187}
188
189/// !!! WARNING: BACKEND ADAPTER — NO C COUNTERPART !!!
190///
191/// C zsh's `zsh/pcre` hands the pattern straight to `pcre2_compile`
192/// (Src/Modules/pcre.c:94). zshrs matches with `fancy_regex`/`regex`
193/// instead, and the two engines disagree on ONE escape that zsh's own
194/// test-suite exercises: PCRE2 reads `\0` as an OCTAL character code —
195/// `\0` is NUL, and up to two further octal digits may follow
196/// (`\012`) — while `regex` reads `\0` as a back-reference to group 0
197/// and refuses to compile ("Invalid back reference to group 0").
198///
199/// Rewrite that one form into the `\x{…}` escape both engines agree
200/// on, leaving every other escape (including `\1`…`\9` back-references
201/// and `\\`) byte-for-byte alone.
202///
203/// Reference: pcre2pattern(3), "Non-printing characters" — "\0dd
204/// character with octal code 0dd".
205pub fn pcre2_octal_escapes(pat: &str) -> String {
206    let b: Vec<char> = pat.chars().collect();
207    let mut out = String::with_capacity(pat.len());
208    let mut i = 0usize;
209    while i < b.len() {
210        if b[i] != '\\' || i + 1 >= b.len() {
211            out.push(b[i]);
212            i += 1;
213            continue;
214        }
215        if b[i + 1] != '0' {
216            // Any other escape — copy the pair verbatim so `\\` can't
217            // make the NEXT character look like the start of an escape.
218            out.push(b[i]);
219            out.push(b[i + 1]);
220            i += 2;
221            continue;
222        }
223        // `\0` plus up to two more octal digits.
224        let mut val: u32 = 0;
225        let mut n = 0usize;
226        while n < 3 && i + 1 + n < b.len() && ('0'..='7').contains(&b[i + 1 + n]) {
227            val = val * 8 + (b[i + 1 + n] as u32 - '0' as u32);
228            n += 1;
229        }
230        out.push_str(&format!("\\x{{{:x}}}", val));
231        i += 1 + n;
232    }
233    out
234}
235
236/// `[[ lhs =~ rhs ]]` cond-test entry point.
237/// Port of `cond_regex_match()` from Src/Modules/regex.c — the
238/// dispatch hook the lexer wires for the `=~` operator. Returns
239/// `(matched, match-record)` so the caller can decide whether to
240/// install the match-vars side effects.
241pub fn cond_regex_match(lhs: &str, rhs: &str, options: &RegexOptions) -> (bool, RegexMatch) {
242    match regex_match(lhs, rhs, options) {
243        Ok(result) => (result.matched, result),
244        Err(_) => (false, RegexMatch::no_match()),
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    #[test]
253    fn test_regex_match_simple() {
254        let _g = crate::test_util::global_state_lock();
255        let opts = RegexOptions::default();
256        let result = regex_match("hello world", "hello", &opts).unwrap();
257        assert!(result.matched);
258        assert_eq!(result.full_match, Some("hello".to_string()));
259    }
260
261    #[test]
262    fn test_regex_match_no_match() {
263        let _g = crate::test_util::global_state_lock();
264        let opts = RegexOptions::default();
265        let result = regex_match("hello world", "goodbye", &opts).unwrap();
266        assert!(!result.matched);
267    }
268
269    #[test]
270    fn test_regex_match_captures() {
271        let _g = crate::test_util::global_state_lock();
272        let opts = RegexOptions::default();
273        let result = regex_match("hello world", "(hello) (world)", &opts).unwrap();
274        assert!(result.matched);
275        assert_eq!(result.full_match, Some("hello world".to_string()));
276        assert_eq!(result.captures.len(), 2);
277        assert_eq!(result.captures[0], Some("hello".to_string()));
278        assert_eq!(result.captures[1], Some("world".to_string()));
279    }
280
281    #[test]
282    fn test_regex_match_case_insensitive() {
283        let _g = crate::test_util::global_state_lock();
284        let opts = RegexOptions {
285            case_insensitive: true,
286            ..Default::default()
287        };
288        let result = regex_match("HELLO WORLD", "hello", &opts).unwrap();
289        assert!(result.matched);
290    }
291
292    #[test]
293    fn test_regex_match_case_sensitive() {
294        let _g = crate::test_util::global_state_lock();
295        let opts = RegexOptions::default();
296        let result = regex_match("HELLO WORLD", "hello", &opts).unwrap();
297        assert!(!result.matched);
298    }
299
300    #[test]
301    fn test_regex_match_positions() {
302        let _g = crate::test_util::global_state_lock();
303        let opts = RegexOptions::default();
304        let result = regex_match("foo bar baz", "bar", &opts).unwrap();
305        assert!(result.matched);
306        assert_eq!(result.match_start, Some(4));
307        assert_eq!(result.match_end, Some(7));
308    }
309
310    #[test]
311    fn test_regex_match_invalid_pattern() {
312        let _g = crate::test_util::global_state_lock();
313        let opts = RegexOptions::default();
314        let result = regex_match("test", "[invalid", &opts);
315        assert!(result.is_err());
316    }
317
318    #[test]
319    fn test_get_match_variables_zsh() {
320        let _g = crate::test_util::global_state_lock();
321        let opts = RegexOptions::default();
322        let result = regex_match("hello world", "(hello) (world)", &opts).unwrap();
323        let vars = get_match_variables(&result, "hello world", &opts);
324
325        assert_eq!(vars.get("MATCH"), Some(&"hello world".to_string()));
326        assert_eq!(vars.get("MBEGIN"), Some(&"1".to_string()));
327        assert_eq!(vars.get("MEND"), Some(&"11".to_string()));
328    }
329
330    #[test]
331    fn test_get_match_variables_bash() {
332        let _g = crate::test_util::global_state_lock();
333        let opts = RegexOptions {
334            bash_rematch: true,
335            ..Default::default()
336        };
337        let result = regex_match("hello world", "(hello) (world)", &opts).unwrap();
338        let vars = get_match_variables(&result, "hello world", &opts);
339
340        assert_eq!(
341            vars.get("BASH_REMATCH[0]"),
342            Some(&"hello world".to_string())
343        );
344        assert_eq!(vars.get("BASH_REMATCH[1]"), Some(&"hello".to_string()));
345        assert_eq!(vars.get("BASH_REMATCH[2]"), Some(&"world".to_string()));
346    }
347
348    #[test]
349    fn test_cond_regex_match() {
350        let _g = crate::test_util::global_state_lock();
351        let opts = RegexOptions::default();
352        let (matched, _) = cond_regex_match("hello world", "hello", &opts);
353        assert!(matched);
354
355        let (matched, _) = cond_regex_match("hello world", "goodbye", &opts);
356        assert!(!matched);
357    }
358
359    #[test]
360    fn test_byte_to_char_offset_ascii() {
361        let _g = crate::test_util::global_state_lock();
362        assert_eq!(byte_to_char_offset("hello", 0), 0);
363        assert_eq!(byte_to_char_offset("hello", 5), 5);
364    }
365
366    #[test]
367    fn test_byte_to_char_offset_unicode() {
368        let _g = crate::test_util::global_state_lock();
369        let s = "héllo";
370        assert_eq!(byte_to_char_offset(s, 0), 0);
371        assert_eq!(byte_to_char_offset(s, 1), 1);
372        assert_eq!(byte_to_char_offset(s, 3), 2);
373    }
374}