Skip to main content

turbo_esregex/
lib.rs

1#![feature(arbitrary_self_types_pointers)]
2
3use std::vec;
4
5use anyhow::{Result, bail};
6use bincode::{
7    Decode, Encode,
8    de::Decoder,
9    enc::Encoder,
10    error::{DecodeError, EncodeError},
11    impl_borrow_decode,
12};
13
14/// A simple regular expression implementation following ecmascript semantics
15///
16/// Delegates to the `regex` crate when possible and `regress` otherwise.
17#[derive(Debug, Clone)]
18#[turbo_tasks::value(eq = "manual", shared, serialization = "custom")]
19pub struct EsRegex {
20    #[turbo_tasks(trace_ignore)]
21    delegate: EsRegexImpl,
22    // Store the original arguments used to construct
23    // this regex to support equality and serialization.
24    pub pattern: String,
25    pub flags: String,
26}
27
28#[derive(Debug, Clone)]
29enum EsRegexImpl {
30    Regex(regex::Regex),
31    Regress(regress::Regex),
32}
33
34/// Equality uses the source inputs since our delegate regex impls don't support
35/// equality natively.
36/// NOTE: there are multiple 'equivalent' ways to write a regex and this
37/// approach does _not_ attempt to equate them.
38impl PartialEq for EsRegex {
39    fn eq(&self, other: &Self) -> bool {
40        self.pattern == other.pattern && self.flags == other.flags
41    }
42}
43impl Eq for EsRegex {}
44
45impl Encode for EsRegex {
46    fn encode<E: Encoder>(&self, encoder: &mut E) -> Result<(), EncodeError> {
47        self.pattern.encode(encoder)?;
48        self.flags.encode(encoder)?;
49        Ok(())
50    }
51}
52
53impl<Context> Decode<Context> for EsRegex {
54    fn decode<D: Decoder<Context = Context>>(decoder: &mut D) -> Result<Self, DecodeError> {
55        let pattern: String = Decode::decode(decoder)?;
56        let flags: String = Decode::decode(decoder)?;
57        // TODO: perf: there's cloning happening here, we should be able to just move the `String`
58        EsRegex::new(&pattern, &flags).map_err(|err| DecodeError::OtherString(err.to_string()))
59    }
60}
61
62impl_borrow_decode!(EsRegex);
63
64impl EsRegex {
65    /// Support ecmascript style regular expressions by selecting the `regex` crate when possible
66    /// and using regress when not.
67    pub fn new(pattern: &str, flags: &str) -> Result<Self> {
68        // rust regex doesn't allow escaped slashes, but they are necessary in js
69        let pattern = pattern.replace("\\/", "/");
70
71        let mut applied_flags = String::new();
72        for flag in flags.chars() {
73            match flag {
74                // indices for substring matches: not relevant for the regex itself
75                'd' => {}
76                // global: default in rust, ignore
77                'g' => {}
78                // case-insensitive: letters match both upper and lower case
79                'i' => applied_flags.push('i'),
80                // multi-line mode: ^ and $ match begin/end of line
81                'm' => applied_flags.push('m'),
82                // allow . to match \n
83                's' => applied_flags.push('s'),
84                // Unicode support (enabled by default)
85                'u' => applied_flags.push('u'),
86                // sticky search: not relevant for the regex itself
87                'y' => {}
88                _ => bail!("unsupported flag `{flag}` in regex: `{pattern}` with flags: `{flags}`"),
89            }
90        }
91
92        let regex = if !applied_flags.is_empty() {
93            regex::Regex::new(&format!("(?{applied_flags}){pattern}"))
94        } else {
95            regex::Regex::new(&pattern)
96        };
97
98        let delegate = match regex {
99            Ok(reg) => Ok(EsRegexImpl::Regex(reg)),
100            Err(_e) => {
101                // We failed to parse as an regex:Regex, try using regress. Regress uses the es
102                // flags format so we can pass the original flags value.
103                match regress::Regex::with_flags(&pattern, regress::Flags::from(flags)) {
104                    Ok(reg) => Ok(EsRegexImpl::Regress(reg)),
105                    // Propagate the error as is, regress has useful error messages.
106                    Err(e) => Err(e),
107                }
108            }
109        }?;
110        Ok(Self {
111            delegate,
112            pattern,
113            flags: flags.to_string(),
114        })
115    }
116
117    /// Returns true if there is any match for this regex in the `haystack`.
118    pub fn is_match(&self, haystack: &str) -> bool {
119        match &self.delegate {
120            EsRegexImpl::Regex(r) => r.is_match(haystack),
121            EsRegexImpl::Regress(r) => r.find(haystack).is_some(),
122        }
123    }
124
125    /// Returns the normalized `regex`-crate source (with inline flags already applied) if this
126    /// regex is backed by the `regex` crate, or `None` if it falls back to `regress` (e.g. it uses
127    /// lookahead/backreferences). Useful for combining several patterns into a [`regex::RegexSet`].
128    pub fn as_regex_str(&self) -> Option<&str> {
129        match &self.delegate {
130            EsRegexImpl::Regex(r) => Some(r.as_str()),
131            EsRegexImpl::Regress(_) => None,
132        }
133    }
134
135    /// Searches for the first match of the regex in the `haystack`, and iterates over the capture
136    /// groups within that first match.
137    ///
138    /// `None` is returned if there is no match. Individual capture groups may be `None` if the
139    /// capture group wasn't included in the match.
140    ///
141    /// The first capture group is always present ([`Some`]) and represents the entire match.
142    ///
143    /// Capture groups are represented as string slices of the `haystack`, and live for the lifetime
144    /// of `haystack`.
145    pub fn captures<'h>(&self, haystack: &'h str) -> Option<Captures<'h>> {
146        let delegate = match &self.delegate {
147            EsRegexImpl::Regex(r) => CapturesImpl::Regex {
148                captures: r.captures(haystack)?,
149                idx: 0,
150            },
151            EsRegexImpl::Regress(r) => {
152                let re_match = r.find(haystack)?;
153                CapturesImpl::Regress {
154                    captures_iter: re_match.captures.into_iter(),
155                    haystack,
156                    match_range: Some(re_match.range),
157                }
158            }
159        };
160        Some(Captures { delegate })
161    }
162}
163
164/// A group of [`EsRegex`]es matched against a haystack as a unit.
165///
166/// The members backed by the `regex` crate are compiled into a single [`regex::RegexSet`] once,
167/// when the group is built, rather than on every match. The remainder (those that fall back to
168/// `regress`, e.g. for lookahead) are matched one at a time.
169#[derive(Debug, Clone)]
170#[turbo_tasks::value(eq = "manual", shared, serialization = "custom")]
171pub struct EsRegexSet {
172    /// The members, in the order they were given. Also the source of truth for equality and
173    /// serialization, since [`regex::RegexSet`] supports neither.
174    regexes: Vec<EsRegex>,
175    /// The combined members, or `None` if the combined program couldn't be built.
176    #[turbo_tasks(trace_ignore)]
177    set: Option<regex::RegexSet>,
178    /// Indices into `regexes` of the members `set` doesn't cover. Usually empty.
179    individual: Vec<u32>,
180}
181
182impl PartialEq for EsRegexSet {
183    fn eq(&self, other: &Self) -> bool {
184        self.regexes == other.regexes
185    }
186}
187impl Eq for EsRegexSet {}
188
189impl Encode for EsRegexSet {
190    fn encode<E: Encoder>(&self, encoder: &mut E) -> Result<(), EncodeError> {
191        self.regexes.encode(encoder)
192    }
193}
194
195impl<Context> Decode<Context> for EsRegexSet {
196    fn decode<D: Decoder<Context = Context>>(decoder: &mut D) -> Result<Self, DecodeError> {
197        let regexes: Vec<EsRegex> = Decode::decode(decoder)?;
198        Ok(EsRegexSet::new(regexes))
199    }
200}
201
202impl_borrow_decode!(EsRegexSet);
203
204impl Default for EsRegexSet {
205    fn default() -> Self {
206        Self::new(Vec::new())
207    }
208}
209
210impl EsRegexSet {
211    /// Builds the combined matcher. Members backed by `regress` can't join a
212    /// [`regex::RegexSet`], and the combined program has its own size limit; either way the
213    /// leftovers are recorded up front and matched one at a time.
214    pub fn new(regexes: Vec<EsRegex>) -> Self {
215        let set = regex::RegexSet::new(regexes.iter().filter_map(EsRegex::as_regex_str)).ok();
216        let individual = regexes
217            .iter()
218            .enumerate()
219            .filter(|(_, regex)| set.is_none() || regex.as_regex_str().is_none())
220            .map(|(index, _)| index as u32)
221            .collect();
222        Self {
223            regexes,
224            set,
225            individual,
226        }
227    }
228
229    /// Returns true if any member matches somewhere in the `haystack`.
230    pub fn is_match(&self, haystack: &str) -> bool {
231        if let Some(set) = &self.set
232            && set.is_match(haystack)
233        {
234            return true;
235        }
236        self.individual
237            .iter()
238            .any(|&index| self.regexes[index as usize].is_match(haystack))
239    }
240
241    /// Returns true if the group has no members.
242    pub fn is_empty(&self) -> bool {
243        self.regexes.is_empty()
244    }
245}
246
247pub struct Captures<'h> {
248    delegate: CapturesImpl<'h>,
249}
250
251enum CapturesImpl<'h> {
252    // We have to use `regex::Captures` (which is not an iterator) here instead of
253    // `regex::SubCaptureMatches` (an iterator) because `SubCaptureMatches` must have a reference
254    // to `Capture`, and that would require a self-referential struct.
255    //
256    // Ideally, `regex::Capture` would implement `IntoIterator`, and we could use that here
257    // instead.
258    Regex {
259        captures: regex::Captures<'h>,
260        idx: usize,
261    },
262    // We can't use the iterator from `regress::Match::groups()` due to similar lifetime issues.
263    Regress {
264        captures_iter: vec::IntoIter<Option<regress::Range>>,
265        haystack: &'h str,
266        match_range: Option<regress::Range>,
267    },
268}
269
270impl<'h> Iterator for Captures<'h> {
271    type Item = Option<&'h str>;
272
273    fn next(&mut self) -> Option<Self::Item> {
274        match &mut self.delegate {
275            CapturesImpl::Regex { captures, idx } => {
276                if *idx >= captures.len() {
277                    None
278                } else {
279                    let capture = Some(captures.get(*idx).map(|sub_match| sub_match.as_str()));
280                    *idx += 1;
281                    capture
282                }
283            }
284            CapturesImpl::Regress {
285                captures_iter,
286                haystack,
287                match_range,
288            } => {
289                if let Some(range) = match_range.take() {
290                    // always yield range first
291                    Some(Some(&haystack[range]))
292                } else {
293                    Some(captures_iter.next()?.map(|range| &haystack[range]))
294                }
295            }
296        }
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::{EsRegex, EsRegexImpl, EsRegexSet};
303
304    #[test]
305    fn es_regex_set_matches_either_delegate() {
306        // `a(?!b)` needs regress; `^/docs` is handled by the shared `RegexSet`.
307        let set = EsRegexSet::new(vec![
308            EsRegex::new("^/docs", "").unwrap(),
309            EsRegex::new("a(?!b)", "").unwrap(),
310        ]);
311        assert_eq!(set.individual, vec![1]);
312        assert!(set.is_match("/docs/getting-started"));
313        assert!(set.is_match("ac"));
314        assert!(!set.is_match("/blog"));
315        assert!(!set.is_match("ab"));
316    }
317
318    #[test]
319    fn es_regex_set_combines_every_member_when_it_can() {
320        let set = EsRegexSet::new(vec![
321            EsRegex::new("^/docs", "").unwrap(),
322            EsRegex::new("^/blog", "").unwrap(),
323        ]);
324        // A miss only queries the combined set, not every member again.
325        assert!(set.individual.is_empty());
326        assert!(set.is_match("/docs"));
327        assert!(set.is_match("/blog"));
328        assert!(!set.is_match("/about"));
329    }
330
331    #[test]
332    fn empty_es_regex_set_never_matches() {
333        let set = EsRegexSet::default();
334        assert!(set.is_empty());
335        assert!(!set.is_match(""));
336        assert!(!set.is_match("/docs"));
337    }
338
339    #[test]
340    fn oversized_es_regex_set_falls_back_to_matching_individually() {
341        // Each of these compiles on its own but together they blow the combined size limit.
342        const N: usize = 60_000;
343        let regexes = vec![
344            EsRegex::new(&format!("^/docs/[0-9a-zA-Z]{{{N}}}"), "").unwrap(),
345            EsRegex::new(&format!("^/blog/[0-9a-zA-Z]{{{N}}}"), "").unwrap(),
346        ];
347        assert!(regexes.iter().all(|regex| regex.as_regex_str().is_some()));
348        let set = EsRegexSet::new(regexes);
349        assert!(set.set.is_none());
350        assert_eq!(set.individual, vec![0, 1]);
351        assert!(set.is_match(&format!("/docs/{}", "a".repeat(N))));
352        assert!(set.is_match(&format!("/blog/{}", "a".repeat(N))));
353        assert!(!set.is_match("/about"));
354    }
355
356    #[test]
357    fn es_regex_set_round_trip_bincode() {
358        let set = EsRegexSet::new(vec![
359            EsRegex::new("^/docs", "").unwrap(),
360            EsRegex::new("a(?!b)", "").unwrap(),
361        ]);
362        let config = bincode::config::standard();
363        let encoded = bincode::encode_to_vec(&set, config).unwrap();
364        let (decoded, len) = bincode::decode_from_slice::<EsRegexSet, _>(&encoded, config).unwrap();
365        assert_eq!(set, decoded);
366        assert_eq!(len, encoded.len());
367        // The `RegexSet` is rebuilt on decode, not carried in the encoding.
368        assert!(decoded.is_match("/docs"));
369        assert!(decoded.is_match("ac"));
370    }
371
372    #[test]
373    fn round_trip_bincode() {
374        let regex = EsRegex::new("[a-z]", "i").unwrap();
375        let config = bincode::config::standard();
376        let encoded = bincode::encode_to_vec(&regex, config).unwrap();
377        let (decoded, len) = bincode::decode_from_slice::<EsRegex, _>(&encoded, config).unwrap();
378        assert_eq!(regex, decoded);
379        assert_eq!(len, encoded.len());
380    }
381
382    #[test]
383    fn es_regex_matches_simple() {
384        let regex = EsRegex::new("a", "").unwrap();
385        assert!(matches!(regex.delegate, EsRegexImpl::Regex { .. }));
386        assert!(regex.is_match("a"));
387    }
388
389    #[test]
390    fn es_regex_matches_negative_lookahead() {
391        // This feature is not supported by the regex crate
392        let regex = EsRegex::new("a(?!b)", "").unwrap();
393        assert!(matches!(regex.delegate, EsRegexImpl::Regress { .. }));
394        assert!(!regex.is_match("ab"));
395        assert!(regex.is_match("ac"));
396    }
397
398    #[test]
399    fn invalid_regex() {
400        // This is invalid since there is nothing being repeated
401        // Don't bother asserting on the message since we delegate
402        // that to the underlying implementations.
403        assert!(matches!(EsRegex::new("*", ""), Err { .. }))
404    }
405
406    #[test]
407    fn captures_with_regex() {
408        let regex = EsRegex::new(r"(notmatched)|(\d{4})-(\d{2})-(\d{2})", "").unwrap();
409        assert!(matches!(regex.delegate, EsRegexImpl::Regex { .. }));
410
411        let captures = regex.captures("Today is 2024-01-15");
412        assert!(captures.is_some());
413        let caps: Vec<_> = captures.unwrap().collect();
414        assert_eq!(caps.len(), 5); // full match + 4 groups
415        assert_eq!(caps[0], Some("2024-01-15")); // full match
416        assert_eq!(caps[1], None); // 'notmatched' -- this branch isn't taken
417        assert_eq!(caps[2], Some("2024")); // year
418        assert_eq!(caps[3], Some("01")); // month
419        assert_eq!(caps[4], Some("15")); // day
420    }
421
422    #[test]
423    fn captures_with_regress() {
424        let regex = EsRegex::new(r"(\w+)(?=baz)", "").unwrap();
425        assert!(matches!(regex.delegate, EsRegexImpl::Regress { .. }));
426
427        let captures = regex.captures("foobar");
428        assert!(captures.is_none());
429
430        let captures = regex.captures("foobaz");
431        assert!(captures.is_some());
432        let caps: Vec<_> = captures.unwrap().collect();
433        assert_eq!(caps.len(), 2); // full match + 1 group
434        assert_eq!(caps[0], Some("foo")); // full match
435        assert_eq!(caps[1], Some("foo")); // captured group
436    }
437}