Skip to main content

syntect/parsing/
regex.rs

1use serde::de::{Deserialize, Deserializer};
2use serde::ser::{Serialize, Serializer};
3use std::error::Error;
4use std::sync::OnceLock;
5
6/// An abstraction for regex patterns.
7///
8/// * Allows swapping out the regex implementation because it's only in this module.
9/// * Makes regexes serializable and deserializable using just the pattern string.
10/// * Lazily compiles regexes on first use to improve initialization time.
11#[derive(Debug)]
12pub struct Regex {
13    regex_str: String,
14    regex: OnceLock<regex_impl::Regex>,
15    /// Lazily-compiled variant that won't match zero-length strings.
16    /// Used for `MatchOperation::None` patterns, where a zero-width match
17    /// would stall the parser at the same position.
18    ///
19    /// `None` means the pattern can only ever match zero-width, so compiling with
20    /// the engine's `FIND_NOT_EMPTY` option fails (fancy-regex reports
21    /// `PatternCanNeverMatch`). In that case searches return no match, which is
22    /// what the parser wants — a scope-only pattern that can only match
23    /// zero-width would stall.
24    regex_not_empty: OnceLock<Option<regex_impl::Regex>>,
25}
26
27/// A region contains text positions for capture groups in a match result.
28#[derive(Clone, Debug, Eq, PartialEq)]
29pub struct Region {
30    region: regex_impl::Region,
31}
32
33impl Regex {
34    /// Create a new regex from the pattern string.
35    ///
36    /// Note that the regex compilation happens on first use, which is why this method does not
37    /// return a result.
38    pub fn new(regex_str: String) -> Self {
39        Self {
40            regex_str,
41            regex: OnceLock::new(),
42            regex_not_empty: OnceLock::new(),
43        }
44    }
45
46    /// Check whether the pattern compiles as a valid regex or not.
47    pub fn try_compile(regex_str: &str) -> Option<Box<dyn Error + Send + Sync + 'static>> {
48        regex_impl::Regex::new(regex_str).err()
49    }
50
51    /// Return the regex pattern.
52    pub fn regex_str(&self) -> &str {
53        &self.regex_str
54    }
55
56    /// Check if the regex matches the given text.
57    pub fn is_match(&self, text: &str) -> bool {
58        self.regex().is_match(text)
59    }
60
61    /// Search for the pattern in the given text from begin/end positions.
62    ///
63    /// If a region is passed, it is used for storing match group positions. The argument allows
64    /// the [`Region`] to be reused between searches, which makes a significant performance
65    /// difference.
66    ///
67    /// When `allow_empty` is `false`, zero-length matches are rejected by the engine itself.
68    /// Pass `false` for match patterns whose operation does not push, set, pop, or embed a
69    /// context, to prevent the parser from stalling at the same position. Pass `true` in every
70    /// other situation (lookaheads used with branch/fail, empty patterns used with pop/set,
71    /// escape patterns, variable-substitution helpers, etc.).
72    ///
73    /// [`Region`]: struct.Region.html
74    pub fn search(
75        &self,
76        text: &str,
77        begin: usize,
78        end: usize,
79        region: Option<&mut Region>,
80        allow_empty: bool,
81    ) -> bool {
82        if allow_empty {
83            return self
84                .regex()
85                .search(text, begin, end, region.map(|r| &mut r.region));
86        }
87        match self.regex_not_empty() {
88            Some(regex) => regex.search(text, begin, end, region.map(|r| &mut r.region)),
89            None => false,
90        }
91    }
92
93    fn regex(&self) -> &regex_impl::Regex {
94        self.regex.get_or_init(|| {
95            regex_impl::Regex::new(&self.regex_str).expect("regex string should be pre-tested")
96        })
97    }
98
99    fn regex_not_empty(&self) -> Option<&regex_impl::Regex> {
100        self.regex_not_empty
101            .get_or_init(|| regex_impl::Regex::new_find_not_empty(&self.regex_str).ok())
102            .as_ref()
103    }
104}
105
106impl Clone for Regex {
107    fn clone(&self) -> Self {
108        Regex {
109            regex_str: self.regex_str.clone(),
110            regex: OnceLock::new(),
111            regex_not_empty: OnceLock::new(),
112        }
113    }
114}
115
116impl PartialEq for Regex {
117    fn eq(&self, other: &Regex) -> bool {
118        self.regex_str == other.regex_str
119    }
120}
121
122impl Eq for Regex {}
123
124impl Serialize for Regex {
125    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
126    where
127        S: Serializer,
128    {
129        serializer.serialize_str(&self.regex_str)
130    }
131}
132
133impl<'de> Deserialize<'de> for Regex {
134    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
135    where
136        D: Deserializer<'de>,
137    {
138        let regex_str = String::deserialize(deserializer)?;
139        Ok(Regex::new(regex_str))
140    }
141}
142
143impl Region {
144    pub fn new() -> Self {
145        Self {
146            region: regex_impl::new_region(),
147        }
148    }
149
150    /// Get the start/end positions of the capture group with given index.
151    ///
152    /// If there is no match for that group or the index does not correspond to a group, `None` is
153    /// returned. The index 0 returns the whole match.
154    pub fn pos(&self, index: usize) -> Option<(usize, usize)> {
155        self.region.pos(index)
156    }
157}
158
159impl Default for Region {
160    fn default() -> Self {
161        Self::new()
162    }
163}
164
165#[cfg(feature = "regex-onig")]
166mod regex_impl {
167    pub use onig::Region;
168    use onig::{MatchParam, RegexOptions, SearchOptions, Syntax};
169    use std::error::Error;
170
171    #[derive(Debug)]
172    pub struct Regex {
173        regex: onig::Regex,
174    }
175
176    pub fn new_region() -> Region {
177        Region::with_capacity(8)
178    }
179
180    impl Regex {
181        pub fn new(regex_str: &str) -> Result<Regex, Box<dyn Error + Send + Sync + 'static>> {
182            let result = onig::Regex::with_options(
183                regex_str,
184                RegexOptions::REGEX_OPTION_CAPTURE_GROUP,
185                Syntax::default(),
186            );
187            match result {
188                Ok(regex) => Ok(Regex { regex }),
189                Err(error) => Err(Box::new(error)),
190            }
191        }
192
193        pub fn new_find_not_empty(
194            regex_str: &str,
195        ) -> Result<Regex, Box<dyn Error + Send + Sync + 'static>> {
196            let result = onig::Regex::with_options(
197                regex_str,
198                RegexOptions::REGEX_OPTION_CAPTURE_GROUP
199                    | RegexOptions::REGEX_OPTION_FIND_NOT_EMPTY,
200                Syntax::default(),
201            );
202            match result {
203                Ok(regex) => Ok(Regex { regex }),
204                Err(error) => Err(Box::new(error)),
205            }
206        }
207
208        pub fn is_match(&self, text: &str) -> bool {
209            self.regex
210                .match_with_options(text, 0, SearchOptions::SEARCH_OPTION_NONE, None)
211                .is_some()
212        }
213
214        pub fn search(
215            &self,
216            text: &str,
217            begin: usize,
218            end: usize,
219            region: Option<&mut Region>,
220        ) -> bool {
221            let matched = self.regex.search_with_param(
222                text,
223                begin,
224                end,
225                SearchOptions::SEARCH_OPTION_NONE,
226                region,
227                MatchParam::default(),
228            );
229
230            // If there's an error during search, treat it as non-matching.
231            // For example, in case of catastrophic backtracking, onig should
232            // fail with a "retry-limit-in-match over" error eventually.
233            matches!(matched, Ok(Some(_)))
234        }
235    }
236}
237
238// If both regex-fancy and regex-onig are requested, this condition makes regex-onig win.
239#[cfg(all(feature = "regex-fancy", not(feature = "regex-onig")))]
240mod regex_impl {
241    use std::error::Error;
242
243    #[derive(Debug)]
244    pub struct Regex {
245        regex: fancy_regex::Regex,
246    }
247
248    #[derive(Clone, Debug, Eq, PartialEq)]
249    pub struct Region {
250        positions: Vec<Option<(usize, usize)>>,
251    }
252
253    pub fn new_region() -> Region {
254        Region {
255            positions: Vec::with_capacity(8),
256        }
257    }
258
259    impl Regex {
260        pub fn new(regex_str: &str) -> Result<Regex, Box<dyn Error + Send + Sync + 'static>> {
261            let result = fancy_regex::RegexBuilder::new(regex_str)
262                .oniguruma_mode(true)
263                .build();
264            match result {
265                Ok(regex) => Ok(Regex { regex }),
266                Err(error) => Err(Box::new(error)),
267            }
268        }
269
270        pub fn new_find_not_empty(
271            regex_str: &str,
272        ) -> Result<Regex, Box<dyn Error + Send + Sync + 'static>> {
273            let result = fancy_regex::RegexBuilder::new(regex_str)
274                .oniguruma_mode(true)
275                .find_not_empty(true)
276                .build();
277            match result {
278                Ok(regex) => Ok(Regex { regex }),
279                Err(error) => Err(Box::new(error)),
280            }
281        }
282
283        pub fn is_match(&self, text: &str) -> bool {
284            // Errors are treated as non-matches
285            self.regex.is_match(text).unwrap_or(false)
286        }
287
288        pub fn search(
289            &self,
290            text: &str,
291            begin: usize,
292            end: usize,
293            region: Option<&mut Region>,
294        ) -> bool {
295            // If there's an error during search, treat it as non-matching.
296            // For example, in case of catastrophic backtracking, fancy-regex should
297            // fail with an error eventually.
298            if let Ok(Some(captures)) = self.regex.captures_from_pos(&text[..end], begin) {
299                if let Some(region) = region {
300                    region.init_from_captures(&captures);
301                }
302                true
303            } else {
304                false
305            }
306        }
307    }
308
309    impl Region {
310        fn init_from_captures(&mut self, captures: &fancy_regex::Captures) {
311            self.positions.clear();
312            for i in 0..captures.len() {
313                let pos = captures.get(i).map(|m| (m.start(), m.end()));
314                self.positions.push(pos);
315            }
316        }
317
318        pub fn pos(&self, i: usize) -> Option<(usize, usize)> {
319            if i < self.positions.len() {
320                self.positions[i]
321            } else {
322                None
323            }
324        }
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    #[test]
333    fn caches_compiled_regex() {
334        let regex = Regex::new(String::from(r"\w+"));
335
336        assert!(regex.regex.get().is_none());
337        assert!(regex.is_match("test"));
338        assert!(regex.regex.get().is_some());
339    }
340
341    #[test]
342    fn serde_as_string() {
343        let pattern: Regex = serde_json::from_str("\"just a string\"").unwrap();
344        assert_eq!(pattern.regex_str(), "just a string");
345        let back_to_str = serde_json::to_string(&pattern).unwrap();
346        assert_eq!(back_to_str, "\"just a string\"");
347    }
348}