Skip to main content

cljrs_value/
regex.rs

1//! Regular expressions: the engine wrapper behind `Value::Pattern`, plus the
2//! stateful `Matcher` that `re-find`/`re-matches`/`re-seq` drive.
3//!
4//! Clojure's `#"…"` literal is a first-class value, so whichever engine is
5//! selected is linked into *every* build — there is no way to opt out of regex
6//! support and still read Clojure source. Two engines are selectable, and
7//! `Pattern` is the seam between them:
8//!
9//! - `regex-full` (default) — the `regex` crate. Fast, linear-time, full
10//!   Unicode character classes. Brings `regex-automata`, `regex-syntax`,
11//!   `aho-corasick` and `memchr` with it.
12//! - `small-regex` — the `regex-lite` crate: roughly a tenth of the size, no
13//!   DFA/SIMD machinery. Materially slower on pathological patterns and it has
14//!   **no Unicode character classes**, so this is a behaviour change and not
15//!   only a size one. Measured on a stripped release build of the interpreter
16//!   plus `clojure.core`/`clojure.string` with `deps` off: 3.68 MB of `.text`
17//!   down to 2.89 MB, 5.77 MB of binary down to 4.10 MB.
18//!
19//! Features are additive, so `regex-full` wins when both are enabled: a build
20//! that pulls in one dependent asking for the small engine and another taking
21//! the default gets the more capable engine rather than a silent semantic
22//! downgrade. Cargo also unions features across every edge to a package, so one
23//! internal edge left at its defaults would re-enable `regex-full` for the whole
24//! graph and no second, direct dependency could switch it off again. Every
25//! workspace crate therefore takes its own internal dependencies with default
26//! features off and re-exports both features (along with `deps`, which those
27//! defaults used to carry), so `default-features = false` at the embedder's own
28//! edge is enough to select the small engine.
29
30use crate::{PersistentVector, Value};
31use cljrs_gc::{GcPtr, GcVisitor, MarkVisitor, Trace};
32use std::borrow::Cow;
33use std::fmt;
34use std::sync::{Mutex, OnceLock};
35
36#[cfg(feature = "regex-full")]
37use regex as engine;
38#[cfg(all(feature = "small-regex", not(feature = "regex-full")))]
39use regex_lite as engine;
40
41#[cfg(not(any(feature = "regex-full", feature = "small-regex")))]
42compile_error!(
43    "cljrs-value needs a regex engine: keep the default `regex-full` feature, \
44     or enable `small-regex` to use regex-lite instead."
45);
46
47/// A compiled regular expression — the payload of `Value::Pattern`.
48///
49/// Every regex operation in the runtime goes through this type, so swapping
50/// engines is confined to this file.
51#[derive(Debug, Clone)]
52pub struct Pattern {
53    re: engine::Regex,
54    /// The `\A(?:…)\z` form, compiled on the first `re-matches` against this
55    /// pattern and shared by every later one. `Some(None)` records a
56    /// compilation that failed. Neither engine exposes an anchored search, and
57    /// filtering an unanchored one is not the same thing: both pick the
58    /// leftmost-first match and would offer `a` for `a|ab`, so the anchors have
59    /// to be inside the automaton where they can steer the match.
60    anchored: OnceLock<Option<engine::Regex>>,
61}
62
63/// A pattern that failed to compile. Engine-independent so that callers do not
64/// name `regex::Error` or `regex_lite::Error`.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct PatternError(String);
67
68impl fmt::Display for PatternError {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        f.write_str(&self.0)
71    }
72}
73
74impl std::error::Error for PatternError {}
75
76impl Pattern {
77    /// Compile `pattern`.
78    pub fn new(pattern: &str) -> Result<Pattern, PatternError> {
79        engine::Regex::new(pattern)
80            .map(|re| Pattern {
81                re,
82                anchored: OnceLock::new(),
83            })
84            .map_err(|e| PatternError(e.to_string()))
85    }
86
87    /// The pattern source, as written in the `#"…"` literal.
88    pub fn as_str(&self) -> &str {
89        self.re.as_str()
90    }
91
92    /// Leftmost match anywhere in `haystack`.
93    pub fn captures<'h>(&self, haystack: &'h str) -> Option<Captures<'h>> {
94        self.re.captures(haystack).map(Captures)
95    }
96
97    /// Match the whole of `haystack`, or nothing — Java's `Matcher.matches()`,
98    /// which Clojure's `re-matches` delegates to.
99    ///
100    /// Anchored inside the pattern rather than by checking the span of an
101    /// unanchored match, because the two differ wherever the engine's
102    /// leftmost-first preference picks a shorter alternative: `a|ab` prefers
103    /// `a`, and `.*?x?` prefers the empty match, neither of which reaches the
104    /// end of the haystack even though a full match exists. With `\A`/`\z` in
105    /// the automaton the engine only ever offers a whole-haystack match.
106    pub fn captures_full<'h>(&self, haystack: &'h str) -> Option<Captures<'h>> {
107        match self.anchored() {
108            Some(re) => re.captures(haystack).map(Captures),
109            // Anchoring an already-valid pattern can only fail on a size or
110            // nesting limit. Fall back to the span of an unanchored match: it
111            // still never accepts a partial one, it can only miss a full match
112            // the engine resolved in favour of a shorter alternative.
113            None => self
114                .captures(haystack)
115                .filter(|cap| cap.start() == 0 && cap.end() == haystack.len()),
116        }
117    }
118
119    /// The anchored twin of this pattern, compiled once. `(?:…)` keeps the
120    /// group numbering — and any leading inline flags — of the original.
121    fn anchored(&self) -> Option<&engine::Regex> {
122        self.anchored
123            .get_or_init(|| engine::Regex::new(&format!(r"\A(?:{})\z", self.as_str())).ok())
124            .as_ref()
125    }
126
127    /// Leftmost match at or after byte offset `start`. Look-around still sees
128    /// the text before `start`, which is what makes this the right primitive
129    /// for stepping a `Matcher` forward.
130    pub fn captures_at<'h>(&self, haystack: &'h str, start: usize) -> Option<Captures<'h>> {
131        self.re.captures_at(haystack, start).map(Captures)
132    }
133
134    /// Replace the leftmost match in `haystack`; `$1`-style references in
135    /// `replacement` expand to capture groups.
136    pub fn replace<'h>(&self, haystack: &'h str, replacement: &str) -> Cow<'h, str> {
137        self.re.replace(haystack, replacement)
138    }
139
140    /// Replace every non-overlapping match in `haystack`.
141    pub fn replace_all<'h>(&self, haystack: &'h str, replacement: &str) -> Cow<'h, str> {
142        self.re.replace_all(haystack, replacement)
143    }
144
145    /// Split `haystack` around each match.
146    pub fn split<'h>(&self, haystack: &'h str) -> impl Iterator<Item = &'h str> {
147        self.re.split(haystack)
148    }
149
150    /// Split `haystack` around each match, yielding at most `limit` pieces;
151    /// the last piece holds the unsplit remainder.
152    pub fn splitn<'h>(&self, haystack: &'h str, limit: usize) -> impl Iterator<Item = &'h str> {
153        self.re.splitn(haystack, limit)
154    }
155}
156
157impl fmt::Display for Pattern {
158    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159        f.write_str(self.as_str())
160    }
161}
162
163impl Trace for Pattern {
164    fn trace(&self, _: &mut MarkVisitor) {}
165}
166
167/// A successful match and its capture groups.
168///
169/// Borrows the haystack, so it never escapes the call that produced it —
170/// `MatchResult` is the owned form that outlives a match.
171#[derive(Debug)]
172pub struct Captures<'h>(engine::Captures<'h>);
173
174impl<'h> Captures<'h> {
175    /// Text of the whole match (group 0).
176    pub fn full(&self) -> &'h str {
177        self.whole().as_str()
178    }
179
180    /// Byte offset where the whole match begins. Non-zero whenever the
181    /// leftmost match starts part-way into the haystack.
182    pub fn start(&self) -> usize {
183        self.whole().start()
184    }
185
186    /// Byte offset just past the whole match — where the next search starts.
187    pub fn end(&self) -> usize {
188        self.whole().end()
189    }
190
191    /// Number of groups, group 0 included; always at least 1.
192    pub fn group_count(&self) -> usize {
193        self.0.len()
194    }
195
196    /// Every group in order, starting with group 0. `None` marks a group that
197    /// did not participate in the match.
198    pub fn groups(&self) -> impl Iterator<Item = Option<&'h str>> + '_ {
199        self.0.iter().map(|g| g.map(|m| m.as_str()))
200    }
201
202    /// Group 0, which always participates in a successful match. Not
203    /// `Captures::get_match`, which `regex-lite` does not have.
204    fn whole(&self) -> engine::Match<'h> {
205        self.0
206            .get(0)
207            .expect("group 0 always participates in a successful match")
208    }
209}
210
211#[derive(Debug, Clone)]
212pub enum MatchPhase {
213    New,
214    /// A match is available from `capture()`; the payload is the byte offset
215    /// the next search resumes from, which is past the end of the haystack once
216    /// there is nothing left to search.
217    Matching(usize),
218    Complete,
219}
220
221#[derive(Debug, Clone)]
222struct MatcherState {
223    phase: MatchPhase,
224    last_match: Option<MatchResult>,
225}
226
227#[derive(Debug)]
228pub struct Matcher {
229    pub pattern: GcPtr<Pattern>,
230    haystack: GcPtr<String>,
231    state: Mutex<MatcherState>,
232    match_all: bool,
233}
234
235#[derive(Debug, Clone)]
236pub struct MatchResult {
237    pub full: String,
238    pub groups: Vec<Option<String>>,
239}
240
241impl Clone for Matcher {
242    fn clone(&self) -> Matcher {
243        let state = self.state.lock().unwrap().clone();
244        Matcher {
245            pattern: self.pattern.clone(),
246            haystack: self.haystack.clone(),
247            state: Mutex::new(state.clone()),
248            match_all: self.match_all,
249        }
250    }
251}
252
253impl Trace for Matcher {
254    fn trace(&self, visitor: &mut MarkVisitor) {
255        visitor.visit(&self.pattern);
256        visitor.visit(&self.haystack);
257    }
258}
259
260impl Matcher {
261    pub fn new(pattern: Pattern, source: String, match_all: bool) -> Self {
262        Self::from_ptr(GcPtr::new(pattern), source, match_all)
263    }
264
265    /// As `new`, but sharing an already-allocated pattern — a `#"…"` literal
266    /// keeps its `Pattern` across evaluations, so its anchored form (see
267    /// `Pattern::captures_full`) is compiled once rather than per call.
268    pub fn from_ptr(pattern: GcPtr<Pattern>, source: String, match_all: bool) -> Self {
269        Self {
270            pattern,
271            haystack: GcPtr::new(source),
272            state: Mutex::new(MatcherState {
273                phase: MatchPhase::New,
274                last_match: None,
275            }),
276            match_all,
277        }
278    }
279
280    pub fn next(&self) -> MatchPhase {
281        let mut state = self.state.lock().unwrap();
282        let pattern = self.pattern.get();
283        let haystack = self.haystack.get();
284        match state.phase {
285            MatchPhase::New => {
286                // `match_all` is `re-matches`: the whole haystack has to match,
287                // so the search itself is anchored rather than filtered after
288                // the fact.
289                let cap = if self.match_all {
290                    pattern.captures_full(haystack)
291                } else {
292                    pattern.captures(haystack)
293                };
294                *state = Self::step(cap, haystack);
295            }
296            MatchPhase::Matching(n) => {
297                // A `match_all` matcher yields at most one match: it spans the
298                // whole haystack, so there is nothing left to step to. Without
299                // this, patterns that can match empty (`a*`) would keep
300                // handing back the zero-width match at the end.
301                let cap = if self.match_all || n > haystack.len() {
302                    None
303                } else {
304                    pattern.captures_at(haystack, n)
305                };
306                *state = Self::step(cap, haystack);
307            }
308            MatchPhase::Complete => {}
309        }
310        state.phase.clone()
311    }
312
313    /// The state a search lands in: `Matching` while matches remain, `Complete`
314    /// once one comes up empty.
315    fn step(cap: Option<Captures<'_>>, haystack: &str) -> MatcherState {
316        match cap {
317            Some(cap) => MatcherState {
318                phase: MatchPhase::Matching(resume_from(&cap, haystack)),
319                last_match: Some(MatchResult::new(&cap)),
320            },
321            None => MatcherState {
322                phase: MatchPhase::Complete,
323                last_match: None,
324            },
325        }
326    }
327
328    pub fn capture(&self) -> Option<MatchResult> {
329        let state = self.state.lock().unwrap();
330        state.last_match.clone()
331    }
332
333    pub fn phase(&self) -> MatchPhase {
334        self.state.lock().unwrap().phase.clone()
335    }
336}
337
338/// Where the search after `cap` resumes, following Java's `Matcher.find`: the
339/// end of the match, bumped past one character when the match was zero-width.
340/// Without the bump an empty match is found at the same offset forever, so
341/// `(re-seq #"a*" "aaa")` never terminates. The result can land one past the
342/// end of the haystack, which is how the matcher knows it is done.
343fn resume_from(cap: &Captures<'_>, haystack: &str) -> usize {
344    let end = cap.end();
345    if cap.start() != end {
346        return end;
347    }
348    // One *character*, not one byte: `captures_at` panics off a UTF-8 boundary.
349    match haystack[end..].chars().next() {
350        Some(c) => end + c.len_utf8(),
351        None => end + 1,
352    }
353}
354
355impl MatchResult {
356    pub fn new(cap: &Captures<'_>) -> Self {
357        Self {
358            full: cap.full().to_string(),
359            groups: cap.groups().map(|g| g.map(|e| e.to_string())).collect(),
360        }
361    }
362
363    pub fn to_value(&self) -> Value {
364        if self.groups.len() == 1 || self.groups.iter().skip(1).all(|g| g.is_none()) {
365            Value::Str(GcPtr::new(self.full.to_string()))
366        } else {
367            let groups: Vec<Value> = self
368                .groups
369                .iter()
370                .map(|g| match g {
371                    Some(m) => Value::Str(GcPtr::new(m.to_string())),
372                    None => Value::Nil,
373                })
374                .collect();
375            Value::Vector(GcPtr::new(PersistentVector::from_iter(groups)))
376        }
377    }
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383
384    /// `Value` is shared across threads, so the engine's `Regex` must be too.
385    #[test]
386    fn pattern_is_send_and_sync() {
387        fn assert_send_sync<T: Send + Sync>() {}
388        assert_send_sync::<Pattern>();
389    }
390
391    #[test]
392    fn captures_expose_groups_in_order() {
393        let p = Pattern::new(r"(\d+)-(\d+)").unwrap();
394        let cap = p.captures("x 12-345 y").unwrap();
395        assert_eq!(cap.full(), "12-345");
396        assert_eq!(cap.group_count(), 3);
397        assert_eq!(cap.end(), 8);
398        assert_eq!(
399            cap.groups().collect::<Vec<_>>(),
400            vec![Some("12-345"), Some("12"), Some("345")]
401        );
402    }
403
404    #[test]
405    fn non_participating_group_is_none() {
406        let p = Pattern::new(r"(a)|(b)").unwrap();
407        let cap = p.captures("b").unwrap();
408        assert_eq!(
409            cap.groups().collect::<Vec<_>>(),
410            vec![Some("b"), None, Some("b")]
411        );
412    }
413
414    #[test]
415    fn captures_at_resumes_after_a_match() {
416        let p = Pattern::new(r"\d+").unwrap();
417        let cap = p.captures_at("a1 b22", 2).unwrap();
418        assert_eq!(cap.full(), "22");
419    }
420
421    #[test]
422    fn invalid_pattern_reports_the_engine_message() {
423        let err = Pattern::new(r"(").unwrap_err();
424        assert!(!err.to_string().is_empty());
425    }
426
427    #[test]
428    fn split_replace_and_display() {
429        let p = Pattern::new(r",\s*").unwrap();
430        assert_eq!(p.split("a, b,c").collect::<Vec<_>>(), vec!["a", "b", "c"]);
431        assert_eq!(p.splitn("a, b,c", 2).collect::<Vec<_>>(), vec!["a", "b,c"]);
432        assert_eq!(p.replace("a, b,c", "|"), "a|b,c");
433        assert_eq!(p.replace_all("a, b,c", "|"), "a|b|c");
434        assert_eq!(p.as_str(), r",\s*");
435        assert_eq!(p.to_string(), r",\s*");
436    }
437
438    /// `re-matches` semantics: the whole haystack has to match, and a
439    /// group-less pattern is no different from one with groups.
440    /// One `re-matches` against `haystack`, as `builtin_re_matches` drives it.
441    fn full_match(pattern: &str, haystack: &str) -> Option<MatchResult> {
442        let m = Matcher::new(Pattern::new(pattern).unwrap(), haystack.to_string(), true);
443        m.next();
444        m.capture()
445    }
446
447    #[test]
448    fn match_all_requires_the_whole_haystack() {
449        for (pattern, haystack) in [
450            (r"\d+", "42"),
451            (r"\d+", "424"),
452            (r"\d+", "4"),
453            (r"a+", "aaa"),
454            (r".*", "hello"),
455        ] {
456            assert_eq!(
457                full_match(pattern, haystack).map(|c| c.full),
458                Some(haystack.to_string()),
459                "{pattern} should match all of {haystack}"
460            );
461        }
462
463        let cap = full_match(r"(\d+)-(\d+)", "12-345").unwrap();
464        assert_eq!(cap.full, "12-345");
465        assert_eq!(
466            cap.groups,
467            vec![Some("12-345".into()), Some("12".into()), Some("345".into())]
468        );
469
470        // A match that stops short of the end, whatever the group count.
471        assert!(full_match(r"(a)(b)", "abc").is_none());
472        assert!(full_match(r"(a)", "ab").is_none());
473        assert!(full_match(r"\d+", "42x").is_none());
474        // …and one that starts part-way in.
475        assert!(full_match(r"(a)(b)", "xab").is_none());
476        assert!(full_match(r"\d+", "x42").is_none());
477        // No match at all.
478        assert!(full_match(r"\d+", "abc").is_none());
479    }
480
481    /// Both engines are leftmost-first, so an unanchored search offers the
482    /// shorter alternative and a span check would reject a haystack that does
483    /// match in full. Anchoring the pattern itself is what makes these work.
484    #[test]
485    fn match_all_beats_leftmost_first_preference() {
486        assert_eq!(full_match(r"a|ab", "ab").map(|c| c.full), Some("ab".into()));
487        assert_eq!(
488            full_match(r"(a|ab)(c|bc)", "abc").map(|c| c.full),
489            Some("abc".into())
490        );
491        // Lazy repetition prefers to stop early; `\z` forces it to the end.
492        assert_eq!(
493            full_match(r".*?", "hello").map(|c| c.full),
494            Some("hello".into())
495        );
496        assert_eq!(
497            full_match(r"(\w+?)(\d*)", "ab12").map(|c| c.groups),
498            Some(vec![
499                Some("ab12".into()),
500                Some("ab".into()),
501                Some("12".into())
502            ])
503        );
504        // The unanchored search these replace really does come up short.
505        let p = Pattern::new(r"a|ab").unwrap();
506        assert_eq!(p.captures("ab").unwrap().full(), "a");
507    }
508
509    /// Anchoring wraps the source in `(?:…)`, which must not renumber groups or
510    /// swallow a leading inline flag.
511    #[test]
512    fn match_all_preserves_groups_and_inline_flags() {
513        let cap = full_match(r"(?i)(a)(b)", "AB").unwrap();
514        assert_eq!(cap.full, "AB");
515        assert_eq!(
516            cap.groups,
517            vec![Some("AB".into()), Some("A".into()), Some("B".into())]
518        );
519        // An empty pattern matches only an empty haystack.
520        assert_eq!(full_match(r"", "").map(|c| c.full), Some(String::new()));
521        assert!(full_match(r"", "a").is_none());
522    }
523
524    /// A successful `match_all` search consumes the haystack, so the matcher
525    /// has nothing left to yield — `a*` must not keep offering the zero-width
526    /// match at the end.
527    #[test]
528    fn match_all_yields_at_most_one_match() {
529        let m = Matcher::new(Pattern::new(r"a*").unwrap(), "aaa".to_string(), true);
530        assert!(matches!(m.next(), MatchPhase::Matching(3)));
531        assert_eq!(m.capture().unwrap().full, "aaa");
532        assert!(matches!(m.next(), MatchPhase::Complete));
533        assert!(m.capture().is_none());
534        assert!(matches!(m.next(), MatchPhase::Complete));
535    }
536
537    /// A rejected `match_all` search has nowhere left to step, so it must land
538    /// in `Complete` rather than sitting in `New` forever.
539    #[test]
540    fn match_all_completes_when_the_match_is_partial() {
541        let m = Matcher::new(Pattern::new(r"(a)").unwrap(), "ab".to_string(), true);
542        assert!(matches!(m.next(), MatchPhase::Complete));
543        assert!(m.capture().is_none());
544
545        let mut steps = 0;
546        while let MatchPhase::New | MatchPhase::Matching(_) = m.next() {
547            steps += 1;
548            assert!(steps < 10, "matcher never reached a terminal state");
549        }
550    }
551
552    /// Every match a matcher yields, with a bound so a regression fails instead
553    /// of hanging the suite.
554    fn drain(pattern: &str, haystack: &str) -> Vec<String> {
555        let m = Matcher::new(Pattern::new(pattern).unwrap(), haystack.to_string(), false);
556        let mut found = Vec::new();
557        while let MatchPhase::Matching(_) = m.next() {
558            found.push(m.capture().unwrap().full);
559            assert!(
560                found.len() < 16,
561                "matcher never reached Complete: {found:?}"
562            );
563        }
564        assert!(matches!(m.phase(), MatchPhase::Complete));
565        found
566    }
567
568    /// A zero-width match is found at the same offset forever unless the search
569    /// moves on. Java's `find` bumps by one character after an empty match, so
570    /// `#"a*"` terminates and yields the trailing empty match Clojure does.
571    #[test]
572    fn zero_width_matches_advance_and_terminate() {
573        assert_eq!(drain(r"a*", "aaa"), vec!["aaa", ""]);
574        assert_eq!(drain(r"a*", "bab"), vec!["", "a", "", ""]);
575        assert_eq!(drain(r"", "ab"), vec!["", "", ""]);
576        assert_eq!(drain(r"", ""), vec![""]);
577        assert_eq!(drain(r"x*", "ab"), vec!["", "", ""]);
578    }
579
580    /// The bump is one character, not one byte: a byte-sized step would land
581    /// inside `é` and panic in `captures_at`.
582    #[test]
583    fn zero_width_advance_respects_utf8_boundaries() {
584        assert_eq!(drain(r"x*", "é"), vec!["", ""]);
585        assert_eq!(drain(r"x*", "日本"), vec!["", "", ""]);
586        assert_eq!(drain(r"é*", "éé"), vec!["éé", ""]);
587    }
588
589    #[test]
590    fn matcher_walks_every_match_then_completes() {
591        let p = Pattern::new(r"\d+").unwrap();
592        let m = Matcher::new(p, "a1 b22 c333".to_string(), false);
593
594        let mut found = Vec::new();
595        while let MatchPhase::Matching(_) = m.next() {
596            found.push(m.capture().unwrap().full);
597        }
598        assert_eq!(found, vec!["1", "22", "333"]);
599        assert!(matches!(m.phase(), MatchPhase::Complete));
600        assert!(m.capture().is_none());
601    }
602}