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}