Skip to main content

sup_xml_core/regex/
mod.rs

1//! XSD §F regex engine — native parser, NFA, Pike VM.
2//!
3//! XSD Part 2 §F defines its own regex flavour: implicit
4//! whole-string anchoring, an `\i` / `\c` shortcut family for XML
5//! Name characters, character class subtraction (`[a-z-[aeiou]]`),
6//! the spec's own `\s` / `\w` definitions, and `\p{IsBlock}` named
7//! Unicode blocks.  It also forbids back-references, lookaround,
8//! and inline modifiers — XSD patterns are pure regular languages.
9//!
10//! ## Pipeline
11//!
12//! 1. [`parser`] consumes XSD §F source into an [`parser::Expr`] AST.
13//! 2. [`nfa::Program`] compiles the AST via Thompson's construction
14//!    into a flat state list with a side table of character classes
15//!    (`Vec<ClassSet>`, hash-consed for dedup).
16//! 3. [`vm`] runs the NFA against an input string using two
17//!    state-set buffers and a generation-counter dedup, owned by a
18//!    thread-local scratch arena so `is_match` stays allocation-free
19//!    in steady state.
20//!
21//! The matcher is O(N · M) in the input length times NFA state
22//! count and never backtracks — pathological patterns like
23//! `(a|a)*b` cost the same as `a*b`.
24
25#![forbid(unsafe_code)]
26
27mod class;
28mod linear;
29mod nfa;
30pub mod parser;
31mod ucd;
32mod unicode;
33mod vm;
34
35use std::cell::RefCell;
36use std::collections::HashMap;
37use std::sync::Arc;
38
39use linear::LinearMatcher;
40use nfa::Program;
41
42pub use parser::Dialect;
43pub use ucd::UnicodeVersion;
44pub use unicode::with_unicode_version;
45
46thread_local! {
47    /// Per-thread compile cache keyed by (src, dialect, version).
48    /// Patterns are returned as `Arc<Pattern>` so callers share one
49    /// NFA across calls — critical for hot paths like `fn:matches`
50    /// inside a 1.1M-codepoint iteration where the pattern source
51    /// is constant.  Unbounded; production callers with thousands
52    /// of distinct patterns should fall back to [`Pattern::compile_with`]
53    /// directly to avoid the cache growing without bound.
54    static COMPILE_CACHE: RefCell<HashMap<(String, Dialect, UnicodeVersion), Arc<Pattern>>>
55        = RefCell::new(HashMap::new());
56}
57
58/// Cached compile through the thread-local pattern cache.  See
59/// [`COMPILE_CACHE`] for the cache lifetime / scope.  Misses fall
60/// through to [`Pattern::compile_with`]; the resulting `Pattern`
61/// is wrapped in `Arc` and inserted before being returned.
62pub fn compile_with_cached(
63    src: &str, dialect: Dialect,
64) -> Result<Arc<Pattern>, String> {
65    let version = unicode::current_ucd_version();
66    let key = (src.to_string(), dialect, version);
67    if let Some(hit) = COMPILE_CACHE.with(|c| c.borrow().get(&key).cloned()) {
68        return Ok(hit);
69    }
70    let pat = Pattern::compile_with(src, dialect)?;
71    let arc = Arc::new(pat);
72    COMPILE_CACHE.with(|c| {
73        c.borrow_mut().insert(key, arc.clone());
74    });
75    Ok(arc)
76}
77
78/// A compiled XSD §F pattern.
79///
80/// Compilation parses the source and either lowers it to a
81/// forward-only linear matcher (the common `[class]{quant}…` shape)
82/// or compiles it to an NFA driven by a Pike VM.  Matching is
83/// linear in the input length in both cases; the linear path skips
84/// per-codepoint NFA dispatch for the patterns that fit it.
85pub struct Pattern {
86    src:  String,
87    body: Body,
88}
89
90enum Body {
91    /// Forward-only fast path — see [`linear::LinearMatcher`].
92    Linear(LinearMatcher),
93    /// Full NFA simulation — see [`vm`].
94    Full(Program),
95}
96
97impl Pattern {
98    /// Compile an XSD §F pattern.  Returns `Err` on syntax errors,
99    /// disallowed constructs (back-references, lookaround, inline
100    /// modifiers), or quantifier counts that would exceed the
101    /// counted-repetition cap.
102    pub fn compile(src: &str) -> Result<Self, String> {
103        Self::compile_with(src, Dialect::Xsd)
104    }
105
106    /// Compile under a specific source dialect.  XPath 2.0 mode
107    /// recognises `^` / `$` as position anchors; XSD mode treats
108    /// them as literal characters.  See [`Dialect`].
109    ///
110    /// XSD-mode patterns can take the linear fast path when their
111    /// shape fits it.  XPath-mode patterns always route through
112    /// the NFA — find semantics needs the VM's per-position
113    /// re-seeding, which the linear matcher doesn't support.
114    pub fn compile_with(src: &str, dialect: Dialect) -> Result<Self, String> {
115        let ast = parser::parse_with(src, dialect)?;
116        let body = match dialect {
117            Dialect::Xsd => match LinearMatcher::try_build(&ast) {
118                Some(lm) => Body::Linear(lm),
119                None     => Body::Full(nfa::compile(&ast)?),
120            },
121            Dialect::Xpath | Dialect::Xpath20 => Body::Full(nfa::compile(&ast)?),
122        };
123        Ok(Self { src: src.into(), body })
124    }
125
126    /// Compile bypassing the linear fast path — always builds the
127    /// Pike VM body.  Used by the regex microbench in
128    /// `crates/bench/benches/xsd_regex.rs` to measure the speedup
129    /// the linear path provides on patterns that fit it.  Not
130    /// part of the supported API.
131    #[doc(hidden)]
132    pub fn compile_nfa_only(src: &str) -> Result<Self, String> {
133        let ast = parser::parse(src)?;
134        Ok(Self { src: src.into(), body: Body::Full(nfa::compile(&ast)?) })
135    }
136
137    /// Returns true iff `s` matches the pattern in its entirety.
138    /// XSD §F patterns are implicitly anchored to both ends of the
139    /// lexical value.
140    pub fn is_match(&self, s: &str) -> bool {
141        match &self.body {
142            Body::Linear(m) => m.is_match(s),
143            Body::Full(p)   => vm::is_match(p, s),
144        }
145    }
146
147    /// Find-style match: true iff any substring of `s` matches the
148    /// pattern.  This is the semantics XPath 2.0 `fn:matches` uses
149    /// — `matches("foo bar", "bar")` is true.  Pair with the
150    /// [`Dialect::Xpath`] compiler so `^` / `$` can be used to
151    /// re-anchor when the caller wants whole-input semantics.
152    ///
153    /// Only valid on patterns compiled with [`Dialect::Xpath`] —
154    /// XSD-mode patterns may take the linear whole-string fast
155    /// path and have no NFA to run find against.
156    pub fn find_match(&self, s: &str) -> bool {
157        match &self.body {
158            Body::Linear(_) => panic!(
159                "find_match called on a Linear-compiled Pattern; \
160                 compile with Dialect::Xpath for find semantics"
161            ),
162            Body::Full(p)   => vm::find_match(p, s),
163        }
164    }
165
166    /// Iterate the non-overlapping matches of the pattern over
167    /// `input`, in left-to-right order, returning `(start_byte,
168    /// end_byte)` for each.  Leftmost-first match: at each position
169    /// the simulator takes the highest-priority path the NFA admits
170    /// (XPath / Perl semantics — `a|ana` prefers `a`), then resumes
171    /// searching immediately after the match's end.  Zero-length
172    /// matches advance one character past the match position so the
173    /// loop terminates on patterns like `a*`.
174    ///
175    /// Used by `xsl:analyze-string` to partition its input into
176    /// matching / non-matching segments.  Only valid on patterns
177    /// compiled with [`Dialect::Xpath`] — XSD-mode patterns may
178    /// take the linear whole-string fast path that has no NFA.
179    pub fn find_iter(&self, input: &str) -> Vec<(usize, usize)> {
180        let prog = match &self.body {
181            Body::Full(p)   => p,
182            Body::Linear(_) => panic!(
183                "find_iter called on a Linear-compiled Pattern; \
184                 compile with Dialect::Xpath for find-style iteration"
185            ),
186        };
187        // Pre-compute the original input's codepoint count so the
188        // simulator's `$` anchor fires only at end-of-input.  The
189        // running `char_pos` increments as we step over each match.
190        let total_chars = input.chars().count();
191        let mut out:      Vec<(usize, usize)> = Vec::new();
192        let mut pos:      usize = 0;
193        let mut char_pos: usize = 0;
194        while pos <= input.len() {
195            let slice = &input[pos..];
196            match vm::leftmost_match_at_start(prog, slice, char_pos, total_chars) {
197                Some(len) if len > 0 => {
198                    out.push((pos, pos + len));
199                    // Advance `char_pos` by the number of codepoints
200                    // the match consumed.
201                    char_pos += input[pos..pos + len].chars().count();
202                    pos += len;
203                }
204                Some(_) => {
205                    // Zero-length match — record it and step past
206                    // the current codepoint so we don't loop.
207                    out.push((pos, pos));
208                    if pos == input.len() { break; }
209                    let c = input[pos..].chars().next().unwrap();
210                    pos      += c.len_utf8();
211                    char_pos += 1;
212                }
213                None => {
214                    if pos == input.len() { break; }
215                    let c = input[pos..].chars().next().unwrap();
216                    pos      += c.len_utf8();
217                    char_pos += 1;
218                }
219            }
220        }
221        out
222    }
223
224    /// Original XSD-flavour source, preserved for diagnostics.
225    pub fn src(&self) -> &str { &self.src }
226}
227
228impl Clone for Pattern {
229    fn clone(&self) -> Self {
230        let body = match &self.body {
231            Body::Linear(m) => Body::Linear(m.clone()),
232            Body::Full(p)   => Body::Full(p.clone()),
233        };
234        Self { src: self.src.clone(), body }
235    }
236}
237
238impl std::fmt::Debug for Pattern {
239    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
240        f.debug_struct("Pattern").field("src", &self.src).finish()
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    /// XPath 2.0 §7.6 adds `^` / `$` as zero-width anchors on top of
249    /// the XSD grammar.  Both the `Xpath` (3.0) and `Xpath20` dialects
250    /// must honour them — `Xpath20` only drops the XPath 3.0 extensions
251    /// (`(?:…)`, inline flags), not the anchors.  XSD mode alone treats
252    /// `^` / `$` as literal characters.
253    #[test]
254    fn caret_dollar_are_anchors_in_both_xpath_dialects() {
255        for d in [Dialect::Xpath, Dialect::Xpath20] {
256            let re = Pattern::compile_with("^a$", d).unwrap();
257            assert!(re.is_match("a"), "{d:?}: `^a$` should anchor-match \"a\"");
258            assert!(!re.is_match("^a$"), "{d:?}: `^`/`$` must be anchors, not literals");
259
260            // The shape `re.xsl` in the W3C suite builds: `^(...)$`.
261            let g = Pattern::compile_with("^(a+)$", d).unwrap();
262            assert!(g.is_match("aaa"), "{d:?}: `^(a+)$` should match \"aaa\"");
263            assert!(!g.is_match("baaa"), "{d:?}: anchored, so a leading `b` fails");
264
265            // fn:matches uses find (substring) semantics; anchors must
266            // still constrain the match position.
267            assert!(re.find_match("a"), "{d:?}: find `^a$` in \"a\"");
268            assert!(!re.find_match("xa"), "{d:?}: `^` pins to start");
269            let tail = Pattern::compile_with("a$", d).unwrap();
270            assert!(tail.find_match("ba"), "{d:?}: `a$` matches the tail of \"ba\"");
271            assert!(!tail.find_match("ab"), "{d:?}: `$` pins to end");
272        }
273    }
274
275    /// XSD §F.1 has no anchors — `^` / `$` are ordinary characters
276    /// there, and patterns are implicitly whole-value anchored.
277    #[test]
278    fn caret_dollar_are_literals_in_xsd_dialect() {
279        let re = Pattern::compile_with("^a$", Dialect::Xsd).unwrap();
280        assert!(re.is_match("^a$"), "XSD: `^`/`$` are literal characters");
281        assert!(!re.is_match("a"), "XSD: the literal `^`/`$` must be present");
282    }
283}