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/// `[[ lhs =~ rhs ]]` cond-test entry point.
190/// Port of `cond_regex_match()` from Src/Modules/regex.c — the
191/// dispatch hook the lexer wires for the `=~` operator. Returns
192/// `(matched, match-record)` so the caller can decide whether to
193/// install the match-vars side effects.
194pub fn cond_regex_match(lhs: &str, rhs: &str, options: &RegexOptions) -> (bool, RegexMatch) {
195    match regex_match(lhs, rhs, options) {
196        Ok(result) => (result.matched, result),
197        Err(_) => (false, RegexMatch::no_match()),
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn test_regex_match_simple() {
207        let _g = crate::test_util::global_state_lock();
208        let opts = RegexOptions::default();
209        let result = regex_match("hello world", "hello", &opts).unwrap();
210        assert!(result.matched);
211        assert_eq!(result.full_match, Some("hello".to_string()));
212    }
213
214    #[test]
215    fn test_regex_match_no_match() {
216        let _g = crate::test_util::global_state_lock();
217        let opts = RegexOptions::default();
218        let result = regex_match("hello world", "goodbye", &opts).unwrap();
219        assert!(!result.matched);
220    }
221
222    #[test]
223    fn test_regex_match_captures() {
224        let _g = crate::test_util::global_state_lock();
225        let opts = RegexOptions::default();
226        let result = regex_match("hello world", "(hello) (world)", &opts).unwrap();
227        assert!(result.matched);
228        assert_eq!(result.full_match, Some("hello world".to_string()));
229        assert_eq!(result.captures.len(), 2);
230        assert_eq!(result.captures[0], Some("hello".to_string()));
231        assert_eq!(result.captures[1], Some("world".to_string()));
232    }
233
234    #[test]
235    fn test_regex_match_case_insensitive() {
236        let _g = crate::test_util::global_state_lock();
237        let opts = RegexOptions {
238            case_insensitive: true,
239            ..Default::default()
240        };
241        let result = regex_match("HELLO WORLD", "hello", &opts).unwrap();
242        assert!(result.matched);
243    }
244
245    #[test]
246    fn test_regex_match_case_sensitive() {
247        let _g = crate::test_util::global_state_lock();
248        let opts = RegexOptions::default();
249        let result = regex_match("HELLO WORLD", "hello", &opts).unwrap();
250        assert!(!result.matched);
251    }
252
253    #[test]
254    fn test_regex_match_positions() {
255        let _g = crate::test_util::global_state_lock();
256        let opts = RegexOptions::default();
257        let result = regex_match("foo bar baz", "bar", &opts).unwrap();
258        assert!(result.matched);
259        assert_eq!(result.match_start, Some(4));
260        assert_eq!(result.match_end, Some(7));
261    }
262
263    #[test]
264    fn test_regex_match_invalid_pattern() {
265        let _g = crate::test_util::global_state_lock();
266        let opts = RegexOptions::default();
267        let result = regex_match("test", "[invalid", &opts);
268        assert!(result.is_err());
269    }
270
271    #[test]
272    fn test_get_match_variables_zsh() {
273        let _g = crate::test_util::global_state_lock();
274        let opts = RegexOptions::default();
275        let result = regex_match("hello world", "(hello) (world)", &opts).unwrap();
276        let vars = get_match_variables(&result, "hello world", &opts);
277
278        assert_eq!(vars.get("MATCH"), Some(&"hello world".to_string()));
279        assert_eq!(vars.get("MBEGIN"), Some(&"1".to_string()));
280        assert_eq!(vars.get("MEND"), Some(&"11".to_string()));
281    }
282
283    #[test]
284    fn test_get_match_variables_bash() {
285        let _g = crate::test_util::global_state_lock();
286        let opts = RegexOptions {
287            bash_rematch: true,
288            ..Default::default()
289        };
290        let result = regex_match("hello world", "(hello) (world)", &opts).unwrap();
291        let vars = get_match_variables(&result, "hello world", &opts);
292
293        assert_eq!(
294            vars.get("BASH_REMATCH[0]"),
295            Some(&"hello world".to_string())
296        );
297        assert_eq!(vars.get("BASH_REMATCH[1]"), Some(&"hello".to_string()));
298        assert_eq!(vars.get("BASH_REMATCH[2]"), Some(&"world".to_string()));
299    }
300
301    #[test]
302    fn test_cond_regex_match() {
303        let _g = crate::test_util::global_state_lock();
304        let opts = RegexOptions::default();
305        let (matched, _) = cond_regex_match("hello world", "hello", &opts);
306        assert!(matched);
307
308        let (matched, _) = cond_regex_match("hello world", "goodbye", &opts);
309        assert!(!matched);
310    }
311
312    #[test]
313    fn test_byte_to_char_offset_ascii() {
314        let _g = crate::test_util::global_state_lock();
315        assert_eq!(byte_to_char_offset("hello", 0), 0);
316        assert_eq!(byte_to_char_offset("hello", 5), 5);
317    }
318
319    #[test]
320    fn test_byte_to_char_offset_unicode() {
321        let _g = crate::test_util::global_state_lock();
322        let s = "héllo";
323        assert_eq!(byte_to_char_offset(s, 0), 0);
324        assert_eq!(byte_to_char_offset(s, 1), 1);
325        assert_eq!(byte_to_char_offset(s, 3), 2);
326    }
327}