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;
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(engine::Regex);
53
54/// A pattern that failed to compile. Engine-independent so that callers do not
55/// name `regex::Error` or `regex_lite::Error`.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct PatternError(String);
58
59impl fmt::Display for PatternError {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        f.write_str(&self.0)
62    }
63}
64
65impl std::error::Error for PatternError {}
66
67impl Pattern {
68    /// Compile `pattern`.
69    pub fn new(pattern: &str) -> Result<Pattern, PatternError> {
70        engine::Regex::new(pattern)
71            .map(Pattern)
72            .map_err(|e| PatternError(e.to_string()))
73    }
74
75    /// The pattern source, as written in the `#"…"` literal.
76    pub fn as_str(&self) -> &str {
77        self.0.as_str()
78    }
79
80    /// Leftmost match anywhere in `haystack`.
81    pub fn captures<'h>(&self, haystack: &'h str) -> Option<Captures<'h>> {
82        self.0.captures(haystack).map(Captures)
83    }
84
85    /// Leftmost match at or after byte offset `start`. Look-around still sees
86    /// the text before `start`, which is what makes this the right primitive
87    /// for stepping a `Matcher` forward.
88    pub fn captures_at<'h>(&self, haystack: &'h str, start: usize) -> Option<Captures<'h>> {
89        self.0.captures_at(haystack, start).map(Captures)
90    }
91
92    /// Replace the leftmost match in `haystack`; `$1`-style references in
93    /// `replacement` expand to capture groups.
94    pub fn replace<'h>(&self, haystack: &'h str, replacement: &str) -> Cow<'h, str> {
95        self.0.replace(haystack, replacement)
96    }
97
98    /// Replace every non-overlapping match in `haystack`.
99    pub fn replace_all<'h>(&self, haystack: &'h str, replacement: &str) -> Cow<'h, str> {
100        self.0.replace_all(haystack, replacement)
101    }
102
103    /// Split `haystack` around each match.
104    pub fn split<'h>(&self, haystack: &'h str) -> impl Iterator<Item = &'h str> {
105        self.0.split(haystack)
106    }
107
108    /// Split `haystack` around each match, yielding at most `limit` pieces;
109    /// the last piece holds the unsplit remainder.
110    pub fn splitn<'h>(&self, haystack: &'h str, limit: usize) -> impl Iterator<Item = &'h str> {
111        self.0.splitn(haystack, limit)
112    }
113}
114
115impl fmt::Display for Pattern {
116    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117        f.write_str(self.as_str())
118    }
119}
120
121impl Trace for Pattern {
122    fn trace(&self, _: &mut MarkVisitor) {}
123}
124
125/// A successful match and its capture groups.
126///
127/// Borrows the haystack, so it never escapes the call that produced it —
128/// `MatchResult` is the owned form that outlives a match.
129#[derive(Debug)]
130pub struct Captures<'h>(engine::Captures<'h>);
131
132impl<'h> Captures<'h> {
133    /// Text of the whole match (group 0).
134    pub fn full(&self) -> &'h str {
135        self.whole().as_str()
136    }
137
138    /// Byte offset just past the whole match — where the next search starts.
139    pub fn end(&self) -> usize {
140        self.whole().end()
141    }
142
143    /// Number of groups, group 0 included; always at least 1.
144    pub fn group_count(&self) -> usize {
145        self.0.len()
146    }
147
148    /// Every group in order, starting with group 0. `None` marks a group that
149    /// did not participate in the match.
150    pub fn groups(&self) -> impl Iterator<Item = Option<&'h str>> + '_ {
151        self.0.iter().map(|g| g.map(|m| m.as_str()))
152    }
153
154    /// Group 0, which always participates in a successful match. Not
155    /// `Captures::get_match`, which `regex-lite` does not have.
156    fn whole(&self) -> engine::Match<'h> {
157        self.0
158            .get(0)
159            .expect("group 0 always participates in a successful match")
160    }
161}
162
163#[derive(Debug, Clone)]
164pub enum MatchPhase {
165    New,
166    Matching(usize),
167    Complete,
168}
169
170#[derive(Debug, Clone)]
171struct MatcherState {
172    phase: MatchPhase,
173    last_match: Option<MatchResult>,
174}
175
176#[derive(Debug)]
177pub struct Matcher {
178    pub pattern: GcPtr<Pattern>,
179    haystack: GcPtr<String>,
180    state: Mutex<MatcherState>,
181    match_all: bool,
182}
183
184#[derive(Debug, Clone)]
185pub struct MatchResult {
186    pub full: String,
187    pub groups: Vec<Option<String>>,
188}
189
190impl Clone for Matcher {
191    fn clone(&self) -> Matcher {
192        let state = self.state.lock().unwrap().clone();
193        Matcher {
194            pattern: self.pattern.clone(),
195            haystack: self.haystack.clone(),
196            state: Mutex::new(state.clone()),
197            match_all: self.match_all,
198        }
199    }
200}
201
202impl Trace for Matcher {
203    fn trace(&self, visitor: &mut MarkVisitor) {
204        visitor.visit(&self.pattern);
205        visitor.visit(&self.haystack);
206    }
207}
208
209impl Matcher {
210    pub fn new(pattern: Pattern, source: String, match_all: bool) -> Self {
211        Self {
212            pattern: GcPtr::new(pattern),
213            haystack: GcPtr::new(source),
214            state: Mutex::new(MatcherState {
215                phase: MatchPhase::New,
216                last_match: None,
217            }),
218            match_all,
219        }
220    }
221
222    pub fn next(&self) -> MatchPhase {
223        let mut state = self.state.lock().unwrap();
224        match state.phase {
225            MatchPhase::New => match self.pattern.get().captures(self.haystack.get()) {
226                Some(cap) => {
227                    // TODO: the `match_all` (re-matches) guard compares a group
228                    // count against a byte length; anchoring wants
229                    // `cap.end() == haystack.len()` and a start of 0 instead.
230                    // Left as-is here so the engine swap is behaviour-neutral.
231                    if !self.match_all || cap.group_count() == self.haystack.get().len() {
232                        *state = MatcherState {
233                            phase: MatchPhase::Matching(cap.end()),
234                            last_match: Some(MatchResult::new(&cap)),
235                        }
236                    }
237                }
238                None => {
239                    *state = MatcherState {
240                        phase: MatchPhase::Complete,
241                        last_match: None,
242                    }
243                }
244            },
245            MatchPhase::Matching(n) => {
246                match self.pattern.get().captures_at(self.haystack.get(), n) {
247                    Some(cap) => {
248                        *state = MatcherState {
249                            phase: MatchPhase::Matching(cap.end()),
250                            last_match: Some(MatchResult::new(&cap)),
251                        }
252                    }
253                    None => {
254                        *state = MatcherState {
255                            phase: MatchPhase::Complete,
256                            last_match: None,
257                        };
258                    }
259                }
260            }
261            MatchPhase::Complete => {}
262        }
263        state.phase.clone()
264    }
265
266    pub fn capture(&self) -> Option<MatchResult> {
267        let state = self.state.lock().unwrap();
268        state.last_match.clone()
269    }
270
271    pub fn phase(&self) -> MatchPhase {
272        self.state.lock().unwrap().phase.clone()
273    }
274}
275
276impl MatchResult {
277    pub fn new(cap: &Captures<'_>) -> Self {
278        Self {
279            full: cap.full().to_string(),
280            groups: cap.groups().map(|g| g.map(|e| e.to_string())).collect(),
281        }
282    }
283
284    pub fn to_value(&self) -> Value {
285        if self.groups.len() == 1 || self.groups.iter().skip(1).all(|g| g.is_none()) {
286            Value::Str(GcPtr::new(self.full.to_string()))
287        } else {
288            let groups: Vec<Value> = self
289                .groups
290                .iter()
291                .map(|g| match g {
292                    Some(m) => Value::Str(GcPtr::new(m.to_string())),
293                    None => Value::Nil,
294                })
295                .collect();
296            Value::Vector(GcPtr::new(PersistentVector::from_iter(groups)))
297        }
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    /// `Value` is shared across threads, so the engine's `Regex` must be too.
306    #[test]
307    fn pattern_is_send_and_sync() {
308        fn assert_send_sync<T: Send + Sync>() {}
309        assert_send_sync::<Pattern>();
310    }
311
312    #[test]
313    fn captures_expose_groups_in_order() {
314        let p = Pattern::new(r"(\d+)-(\d+)").unwrap();
315        let cap = p.captures("x 12-345 y").unwrap();
316        assert_eq!(cap.full(), "12-345");
317        assert_eq!(cap.group_count(), 3);
318        assert_eq!(cap.end(), 8);
319        assert_eq!(
320            cap.groups().collect::<Vec<_>>(),
321            vec![Some("12-345"), Some("12"), Some("345")]
322        );
323    }
324
325    #[test]
326    fn non_participating_group_is_none() {
327        let p = Pattern::new(r"(a)|(b)").unwrap();
328        let cap = p.captures("b").unwrap();
329        assert_eq!(
330            cap.groups().collect::<Vec<_>>(),
331            vec![Some("b"), None, Some("b")]
332        );
333    }
334
335    #[test]
336    fn captures_at_resumes_after_a_match() {
337        let p = Pattern::new(r"\d+").unwrap();
338        let cap = p.captures_at("a1 b22", 2).unwrap();
339        assert_eq!(cap.full(), "22");
340    }
341
342    #[test]
343    fn invalid_pattern_reports_the_engine_message() {
344        let err = Pattern::new(r"(").unwrap_err();
345        assert!(!err.to_string().is_empty());
346    }
347
348    #[test]
349    fn split_replace_and_display() {
350        let p = Pattern::new(r",\s*").unwrap();
351        assert_eq!(p.split("a, b,c").collect::<Vec<_>>(), vec!["a", "b", "c"]);
352        assert_eq!(p.splitn("a, b,c", 2).collect::<Vec<_>>(), vec!["a", "b,c"]);
353        assert_eq!(p.replace("a, b,c", "|"), "a|b,c");
354        assert_eq!(p.replace_all("a, b,c", "|"), "a|b|c");
355        assert_eq!(p.as_str(), r",\s*");
356        assert_eq!(p.to_string(), r",\s*");
357    }
358
359    #[test]
360    fn matcher_walks_every_match_then_completes() {
361        let p = Pattern::new(r"\d+").unwrap();
362        let m = Matcher::new(p, "a1 b22 c333".to_string(), false);
363
364        let mut found = Vec::new();
365        while let MatchPhase::Matching(_) = m.next() {
366            found.push(m.capture().unwrap().full);
367        }
368        assert_eq!(found, vec!["1", "22", "333"]);
369        assert!(matches!(m.phase(), MatchPhase::Complete));
370        assert!(m.capture().is_none());
371    }
372}