sup_xml_core/regex/parser.rs
1//! XSD §F regex parser — XSD-flavour source → [`Expr`] AST.
2//!
3//! Implements the BNF from XSD Part 2 §F: `regExp` is a `branch`
4//! list joined by `|`; each branch is a sequence of `piece`s; each
5//! piece is an `atom` with an optional `quantifier`. Atoms are
6//! single characters, escapes, character classes, or
7//! parenthesised regexps.
8//!
9//! Pre-translation rejects constructs XSD §F forbids:
10//! back-references, lookaround, inline modifiers, anchor escapes.
11//! These errors fire at schema compile time so users get a single
12//! clear diagnostic up front instead of a confusing
13//! deferred-compile failure at first match.
14
15use super::class::ClassSet;
16use super::unicode;
17
18/// Cap on counted-repetition expansion. An NFA built from
19/// `a{0,8192}` has 8192 split states; allow generous schemas but
20/// reject pathological ones up front rather than risk runaway
21/// memory at compile time.
22const MAX_REPETITION: u32 = 4096;
23
24/// Parsed XSD regex. Classes are flattened to [`ClassSet`] at
25/// parse time so the NFA builder doesn't need to know about
26/// `\p{...}`, `\d`, class subtraction, etc.
27#[derive(Debug, Clone)]
28pub enum Expr {
29 /// Matches the empty string.
30 Empty,
31 /// Concatenation of subexpressions, evaluated left-to-right.
32 Concat(Vec<Expr>),
33 /// Alternation — match any one branch. XSD §F has no
34 /// preference between branches (no backtracking semantics to
35 /// preserve), but the NFA emits them in source order.
36 Alt(Vec<Expr>),
37 /// Counted repetition. `max == None` means unbounded.
38 Quant(Box<Expr>, u32, Option<u32>),
39 /// Single-codepoint match against a character class. Literal
40 /// chars and `.` lower to single-range / universe classes.
41 Class(ClassSet),
42 /// Position anchor (XPath 2.0 only; XSD §F has none). Matches
43 /// the empty string when the simulator is at the asserted
44 /// position. The `m`-flag (multiline) variants assert on line
45 /// boundaries; without `m` they assert on input boundaries.
46 Anchor(AnchorKind),
47}
48
49/// Position-anchor variety used by [`Expr::Anchor`].
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum AnchorKind {
52 /// `^` — start of input (single-line) or start of any line
53 /// (multiline). Multiline routing is decided at compile time
54 /// by the caller; the AST only records that the anchor exists.
55 Start,
56 /// `$` — end of input (single-line) or end of any line
57 /// (multiline).
58 End,
59}
60
61/// Source-level dialect. XSD §F.1 forbids `^` and `$` (patterns
62/// are implicitly whole-input anchored); XPath 2.0 §7.6 keeps the
63/// XSD grammar but adds them back as explicit anchors usable
64/// anywhere in the pattern.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
66pub enum Dialect {
67 /// XSD Part 2 §F — what `xs:pattern` facets compile under.
68 Xsd,
69 /// XPath 2.0 §7.6 — what `fn:matches`, `fn:replace`,
70 /// `fn:tokenize`, and `xsl:analyze-string` compile under in
71 /// XSLT 3.0+ hosts. Adds explicit anchors `^` / `$` and
72 /// non-capturing `(?:...)` groups on top of the XSD grammar.
73 Xpath,
74 /// XPath 2.0 §7.6 strict — same as [`Dialect::Xpath`] but
75 /// without the XPath 3.0 extensions (`(?:…)` / inline flags).
76 /// Used by XSLT 2.0 hosts where the W3C conformance suite
77 /// expects FORX0002 on the 3.0 extensions.
78 Xpath20,
79}
80
81/// Parse XSD §F source into an [`Expr`].
82pub fn parse(src: &str) -> Result<Expr, String> {
83 parse_with(src, Dialect::Xsd)
84}
85
86/// Parse with a specific source dialect. See [`Dialect`].
87pub fn parse_with(src: &str, dialect: Dialect) -> Result<Expr, String> {
88 let mut p = Parser { input: src.as_bytes(), pos: 0, chars: src, dialect, depth: 0 };
89 let expr = p.parse_regexp()?;
90 if p.pos != p.input.len() {
91 return Err(format!("unexpected '{}' at position {}",
92 p.peek_char().unwrap_or(' '), p.pos));
93 }
94 Ok(expr)
95}
96
97struct Parser<'a> {
98 input: &'a [u8],
99 pos: usize,
100 /// Original source, for slicing when we need to read a
101 /// multi-byte codepoint (the input is UTF-8; `input[pos]` is
102 /// only valid for ASCII boundary tests).
103 chars: &'a str,
104 dialect: Dialect,
105 /// Recursion depth of the current `parse_regexp` nesting, bumped
106 /// on entry to each parenthesised group so a pathologically nested
107 /// pattern (`((((…))))`) is rejected before it overflows the
108 /// recursive-descent call stack. Patterns reach the parser
109 /// straight from untrusted XSD `<xs:pattern>` facets and XPath
110 /// `matches()`/`replace()`/`tokenize()` arguments.
111 depth: u32,
112}
113
114/// Maximum `(…)` nesting accepted in a pattern. A group nests four
115/// recursive-descent frames (`parse_regexp`/`parse_branch`/`parse_piece`/
116/// `parse_atom`); 256 levels keeps the worst-case stack bounded while
117/// sitting far above any pattern a human writes.
118const MAX_REGEX_DEPTH: u32 = 256;
119
120impl<'a> Parser<'a> {
121 fn parse_regexp(&mut self) -> Result<Expr, String> {
122 self.depth += 1;
123 if self.depth > MAX_REGEX_DEPTH {
124 // The Parser is consumed on error and not reused, so there
125 // is no need to decrement before bailing.
126 return Err(format!(
127 "regular expression nesting depth exceeds limit ({MAX_REGEX_DEPTH})"
128 ));
129 }
130 let result = self.parse_alternation();
131 self.depth -= 1;
132 result
133 }
134
135 fn parse_alternation(&mut self) -> Result<Expr, String> {
136 let first = self.parse_branch()?;
137 if !self.eat(b'|') {
138 return Ok(first);
139 }
140 let mut branches = vec![first];
141 loop {
142 branches.push(self.parse_branch()?);
143 if !self.eat(b'|') { break; }
144 }
145 Ok(Expr::Alt(branches))
146 }
147
148 fn parse_branch(&mut self) -> Result<Expr, String> {
149 let mut pieces: Vec<Expr> = Vec::new();
150 while let Some(b) = self.peek() {
151 if b == b'|' || b == b')' { break; }
152 pieces.push(self.parse_piece()?);
153 }
154 Ok(match pieces.len() {
155 0 => Expr::Empty,
156 1 => pieces.pop().unwrap(),
157 _ => Expr::Concat(pieces),
158 })
159 }
160
161 fn parse_piece(&mut self) -> Result<Expr, String> {
162 let atom = self.parse_atom()?;
163 let (min, max) = self.parse_quantifier()?;
164 // Per XSD §F.1 and XPath 2.0 §7.6 a piece is `atom
165 // quantifier?` — at most one quantifier. Patterns like
166 // `a+*`, `a{1}?`, `a*+`, `a??*` are syntactically invalid
167 // and must be rejected as FORX0002 in XPath dialect. The
168 // `?` after a quantifier is the "reluctant" modifier
169 // (XPath 2.0 §7.6.1) — `a*?` is a single quantifier, not
170 // two — so we consume one optional reluctant marker before
171 // checking for a stray follow-on quantifier.
172 let had_quantifier = (min, max) != (1, Some(1));
173 if had_quantifier {
174 // Reluctant marker `?` is part of the quantifier.
175 self.eat(b'?');
176 if let Some(b) = self.peek() {
177 if matches!(b, b'?' | b'*' | b'+' | b'{') {
178 return Err(format!(
179 "stray quantifier '{}' after another quantifier — \
180 XPath 2.0/3.0 §7.6 grammar",
181 b as char
182 ));
183 }
184 }
185 }
186 Ok(match (min, max) {
187 (1, Some(1)) => atom,
188 _ => Expr::Quant(Box::new(atom), min, max),
189 })
190 }
191
192 fn parse_atom(&mut self) -> Result<Expr, String> {
193 let b = self.peek().ok_or("unexpected end of input")?;
194 // XPath 2.0 §7.6: `^` and `$` are zero-width position
195 // anchors in every XPath dialect (Xpath20 only drops the
196 // XPath 3.0 extensions, not the anchors). XSD §F.1 treats
197 // them as literal characters, so we only intercept for XPath.
198 if matches!(self.dialect, Dialect::Xpath | Dialect::Xpath20) {
199 if b == b'^' { self.bump(); return Ok(Expr::Anchor(AnchorKind::Start)); }
200 if b == b'$' { self.bump(); return Ok(Expr::Anchor(AnchorKind::End)); }
201 }
202 match b {
203 b'(' => {
204 self.bump();
205 if self.eat(b'?') {
206 return self.parse_question_construct();
207 }
208 let inner = self.parse_regexp()?;
209 if !self.eat(b')') {
210 return Err("unbalanced '(' in pattern".into());
211 }
212 Ok(inner)
213 }
214 b'[' => {
215 self.bump();
216 Ok(Expr::Class(self.parse_class()?))
217 }
218 b'.' => {
219 self.bump();
220 // XSD §F.1.3: `.` matches any char except line
221 // terminators (#x0A, #x0D).
222 let nl = ClassSet::from_ranges(vec![(0x0A, 0x0A), (0x0D, 0x0D)]);
223 Ok(Expr::Class(ClassSet::universe().subtract(&nl)))
224 }
225 b'\\' => {
226 self.bump();
227 let esc = self.bump_char()
228 .ok_or("trailing backslash")?;
229 Ok(Expr::Class(self.parse_escape(esc)?))
230 }
231 b')' | b'|' | b'*' | b'+' | b'?' | b'{' =>
232 Err(format!("unexpected metacharacter '{}' at position {}",
233 b as char, self.pos)),
234 // Unmatched ']' or '}' outside of their structural context
235 // is treated as a literal in XSD dialect (matching PCRE /
236 // .NET behavior — the Microsoft XSTS suite relies on it).
237 // XPath 2.0/3.0 §7.6 reserves both characters; XPath
238 // dialect raises FORX0002.
239 b']' | b'}' => match self.dialect {
240 Dialect::Xsd => {
241 let c = self.bump_char().expect("peek returned Some");
242 Ok(Expr::Class(ClassSet::from_char(c)))
243 }
244 Dialect::Xpath | Dialect::Xpath20 => Err(format!(
245 "unmatched '{}' — XPath 2.0/3.0 §7.6 grammar",
246 b as char
247 )),
248 },
249 _ => {
250 let c = self.bump_char().expect("peek returned Some");
251 Ok(Expr::Class(ClassSet::from_char(c)))
252 }
253 }
254 }
255
256 /// Handle a `(?...)` construct, with `(?` already consumed.
257 ///
258 /// XSD §F.1 doesn't define any `(?...)` form — but real-world
259 /// schemas (especially Microsoft-generated ones) lean heavily
260 /// on PCRE extensions: non-capturing groups `(?:…)`, inline
261 /// modifiers `(?i)`, scoped modifier groups `(?i:…)`,
262 /// atomic groups `(?>…)`, and so on. Rejecting all of them
263 /// loses ~250 schemas in the XSTS conformance suite.
264 ///
265 /// The pragmatic interpretation we adopt:
266 ///
267 /// * `(?:…)` — non-capturing group, parse inner expression.
268 /// * `(?X:…)` / `(?X-Y:…)` for modifier letters X / Y —
269 /// modifier scope, parse inner expression, ignore modifier.
270 /// * `(?X)` / `(?X-Y)` — inline modifier directive (no body),
271 /// accept and continue parsing the surrounding pattern.
272 /// * `(?>…)` — atomic group, parse inner expression
273 /// (atomicity affects backtracking but not boolean
274 /// "does the whole input match?").
275 /// * `(?=…)` / `(?!…)` — positive / negative lookahead.
276 /// Spec-forbidden lookaround. Rejected.
277 /// * `(?<=…)` / `(?<!…)` — positive / negative lookbehind.
278 /// Rejected.
279 /// * `(?(…)…)` and similar PCRE conditionals — accepted
280 /// opaquely (skip to matching close paren).
281 fn parse_question_construct(&mut self) -> Result<Expr, String> {
282 let next = self.peek().ok_or("unterminated '(?' in pattern")?;
283
284 // True lookaround / lookbehind — spec-forbidden, no safe
285 // lenient interpretation (silently accepting them would
286 // produce wrong matches).
287 if next == b'=' || next == b'!' {
288 return Err("lookaround '(?=…)' / '(?!…)' is not part of XSD §F".into());
289 }
290 if next == b'<' {
291 // Could be (?<= or (?<! — both lookbehind.
292 return Err("lookbehind '(?<…)' is not part of XSD §F".into());
293 }
294
295 // `(?:…)` — non-capturing group. Accepted by XSD and
296 // XPath 3.0+; XPath 2.0 (which the W3C conformance suite
297 // gates on XSD 1.0 grammar) rejects it with FORX0002.
298 if next == b':' {
299 if self.dialect == Dialect::Xpath20 {
300 return Err(
301 "non-capturing group '(?:' is XPath 3.0+ syntax \
302 not permitted in XPath 2.0 (FORX0002)".into()
303 );
304 }
305 self.bump();
306 let inner = self.parse_regexp()?;
307 if !self.eat(b')') {
308 return Err("unbalanced '(' in pattern".into());
309 }
310 return Ok(inner);
311 }
312
313 // XPath dialect (XPath 2.0 §7.6, XPath 3.0 §7.7) rejects
314 // every `(?…)` construct other than `(?:…)` above. The
315 // W3C conformance suite expects FORX0002 for inline-modifier
316 // forms like `(?i)` and for atomic groups like `(?>…)`.
317 // XSD dialect keeps the lenient PCRE-style interpretation
318 // for compatibility with real-world schemas.
319 if self.dialect == Dialect::Xpath {
320 return Err(format!(
321 "invalid `(?{}…)` construct — XPath 2.0/3.0 regex \
322 supports only `(?:…)` non-capturing groups",
323 next as char
324 ));
325 }
326
327 // Any other `(?...)` form: consume modifier letters
328 // (and optional `-letters` for negation, e.g. `(?-i:…)`)
329 // up to either `:` (scoped modifier opens a body), `)`
330 // (inline directive, no body), or any other construct.
331 // For everything else — atomic groups `(?>…)`,
332 // conditionals `(?(…)…)`, etc. — skip opaquely to the
333 // matching `)`.
334 let start = self.pos;
335 while let Some(b) = self.peek() {
336 if b.is_ascii_alphabetic() || b == b'-' {
337 self.bump();
338 } else {
339 break;
340 }
341 }
342 let modifiers_consumed = self.pos - start;
343
344 match self.peek() {
345 Some(b':') => {
346 self.bump();
347 let inner = self.parse_regexp()?;
348 if !self.eat(b')') {
349 return Err("unbalanced '(' in pattern".into());
350 }
351 Ok(inner)
352 }
353 Some(b')') if modifiers_consumed > 0 => {
354 // Inline modifier directive like `(?i)pattern` —
355 // consume the `)` and emit an empty match; the
356 // following pattern is parsed by the enclosing
357 // `parse_regexp` loop.
358 self.bump();
359 Ok(Expr::Empty)
360 }
361 Some(_) => {
362 // Anything else (atomic groups, conditionals,
363 // PCRE-specific constructs). Be lenient: skip
364 // opaquely to the matching close paren and emit
365 // an empty match. This loses any pattern
366 // semantics inside, which is fine for the
367 // "schema compiles" goal — the rare instance
368 // tests against these constructs will surface
369 // separately.
370 self.skip_to_matching_close_paren()?;
371 Ok(Expr::Empty)
372 }
373 None => Err("unterminated '(?' construct".into()),
374 }
375 }
376
377 /// Consume bytes through the matching close paren of an
378 /// already-opened `(`, handling nested parens and backslash
379 /// escapes. Used for opaque PCRE-isms we don't actually
380 /// interpret.
381 fn skip_to_matching_close_paren(&mut self) -> Result<(), String> {
382 let mut depth: i32 = 1;
383 while depth > 0 {
384 let b = self.peek().ok_or("unterminated '(' in pattern")?;
385 match b {
386 b'\\' => {
387 self.bump();
388 if self.peek().is_some() { self.bump(); }
389 }
390 b'(' => { self.bump(); depth += 1; }
391 b')' => { self.bump(); depth -= 1; }
392 b'[' => {
393 // Skip a char class — the bracket pair can
394 // legitimately contain `(` / `)` literals.
395 self.bump();
396 while let Some(b) = self.peek() {
397 self.bump();
398 if b == b'\\' && self.peek().is_some() { self.bump(); }
399 else if b == b']' { break; }
400 }
401 }
402 _ => { self.bump(); }
403 }
404 }
405 Ok(())
406 }
407
408 /// Body of `[...]`, with the opening `[` already consumed.
409 /// Recognises `^` negation, range syntax `a-z`, character-class
410 /// escapes, and XSD §F.1.5 subtraction `[a-z-[aeiou]]`.
411 fn parse_class(&mut self) -> Result<ClassSet, String> {
412 let negated = self.eat(b'^');
413 let mut acc = ClassSet::empty();
414 // XSD §F.1.1 / FORX0002 — an empty character class `[]` is
415 // not a valid regex. The same applies to `[^]` (negated
416 // empty): the spec requires at least one atom. Reject
417 // immediately so callers see the spec error rather than a
418 // silently-matching empty set.
419 if self.peek() == Some(b']') {
420 return Err("empty character class is not permitted".into());
421 }
422 loop {
423 let b = self.peek().ok_or("unclosed character class")?;
424 if b == b']' { break; }
425
426 // Subtraction operator: `-[...]` mid-class consumes the
427 // accumulator and replaces it with the difference.
428 if b == b'-' && self.input.get(self.pos + 1) == Some(&b'[') {
429 self.bump(); // -
430 self.bump(); // [
431 let sub = self.parse_class()?;
432 let mut head = if negated { acc.complement() } else { acc };
433 head = head.subtract(&sub);
434 // After subtraction the class must close immediately —
435 // XSD §F.1.5 forbids more atoms in this class body.
436 if !self.eat(b']') {
437 return Err(
438 "subtraction must be the last operation in a character class"
439 .into(),
440 );
441 }
442 return Ok(head);
443 }
444
445 let lo_atom = self.parse_class_atom()?;
446 // `a-b` is only a range when `lo_atom` is a single char
447 // and the `-` isn't immediately followed by `]` or `[`
448 // (which would be class close / subtraction).
449 if let ClassAtom::Char(lo) = lo_atom {
450 let mut is_range = false;
451 if self.peek() == Some(b'-') {
452 let next2 = self.input.get(self.pos + 1).copied();
453 if next2 != Some(b']') && next2 != Some(b'[') {
454 is_range = true;
455 }
456 }
457 if is_range {
458 self.bump();
459 let hi_atom = self.parse_class_atom()?;
460 match hi_atom {
461 ClassAtom::Char(hi) => {
462 if hi < lo {
463 return Err(format!(
464 "inverted character range: '{lo}' > '{hi}'"
465 ));
466 }
467 acc = acc.union(&ClassSet::from_range(lo as u32, hi as u32));
468 }
469 // XSD §F.1.5 and XPath 2.0 §7.6 require both
470 // range endpoints to be single-character
471 // atoms. Class-shorthand escapes (`\d`,
472 // `\w`, `\s`, …) can't serve as a range
473 // bound; reject in XPath dialect. XSD
474 // dialect stays lenient for compatibility
475 // with Microsoft-authored schemas — there
476 // the lower bound becomes a literal and the
477 // shorthand's set is unioned in.
478 ClassAtom::Set(s) => match self.dialect {
479 Dialect::Xsd => {
480 acc = acc.union(&ClassSet::from_char(lo)).union(&s);
481 }
482 Dialect::Xpath | Dialect::Xpath20 => return Err(format!(
483 "class-shorthand escape cannot be the \
484 upper bound of a range starting at '{lo}'"
485 )),
486 },
487 }
488 } else {
489 acc = acc.union(&ClassSet::from_char(lo));
490 }
491 } else if let ClassAtom::Set(s) = lo_atom {
492 acc = acc.union(&s);
493 }
494 }
495 self.bump(); // consume ']'
496 Ok(if negated { acc.complement() } else { acc })
497 }
498
499 /// One atom inside a class body — either a single literal/escaped
500 /// char or a multi-codepoint set (from `\d`, `\p{L}`, …).
501 fn parse_class_atom(&mut self) -> Result<ClassAtom, String> {
502 let b = self.peek().ok_or("unclosed character class")?;
503 if b == b'\\' {
504 self.bump();
505 let esc = self.bump_char().ok_or("trailing backslash")?;
506 // PCRE `\xHH` / `\x{HHHH}` and `\uHHHH` / `\u{HHHH}` —
507 // resolve to the actual codepoint so they remain usable
508 // as range bounds (`[Ք-]`). XSD dialect only; XPath
509 // dialect rejects the escapes themselves below.
510 if (esc == 'x' || esc == 'u') && self.dialect == Dialect::Xsd {
511 if let Some(c) = self.parse_hex_codepoint(esc) {
512 return Ok(ClassAtom::Char(c));
513 }
514 }
515 match single_char_escape(esc) {
516 Some(c) => Ok(ClassAtom::Char(c)),
517 None => Ok(ClassAtom::Set(self.parse_escape(esc)?)),
518 }
519 } else {
520 // XSD §F.1.5 / XPath 2.0 §7.6 reserve `[` inside a
521 // class body (it would open a subtraction operand,
522 // which must follow a `-`). XSD dialect lets a stray
523 // `[` through as a literal for Microsoft-schema
524 // compatibility; XPath dialect raises FORX0002.
525 if b == b'[' && self.dialect == Dialect::Xpath {
526 return Err(
527 "literal '[' inside a character class — XPath \
528 2.0/3.0 §7.6 requires it to be escaped".into()
529 );
530 }
531 let c = self.bump_char().expect("peek returned Some");
532 Ok(ClassAtom::Char(c))
533 }
534 }
535
536 /// Consume a PCRE hex/unicode escape body (`\xHH`, `\x{H+}`,
537 /// `\uHHHH`, `\u{H+}`) starting just after the escape letter.
538 /// Returns `None` if the body doesn't look like one (caller falls
539 /// back to lenient handling).
540 fn parse_hex_codepoint(&mut self, esc: char) -> Option<char> {
541 let braced = self.peek() == Some(b'{');
542 if braced { self.bump(); }
543 let want = if braced { usize::MAX } else if esc == 'u' { 4 } else { 2 };
544 let start = self.pos;
545 while self.pos - start < want {
546 match self.peek() {
547 Some(b) if b.is_ascii_hexdigit() => { self.bump(); }
548 _ => break,
549 }
550 }
551 let body = std::str::from_utf8(&self.input[start..self.pos]).ok()?;
552 if body.is_empty() { return None; }
553 let cp = u32::from_str_radix(body, 16).ok()?;
554 if braced && !self.eat(b'}') { return None; }
555 char::from_u32(cp)
556 }
557
558 /// Map `\X` outside a class body to a [`ClassSet`]. Inside a
559 /// class body, [`parse_class_atom`] short-circuits single-char
560 /// escapes via [`single_char_escape`] first; this is the
561 /// shared path for multi-char shortcuts.
562 fn parse_escape(&mut self, esc: char) -> Result<ClassSet, String> {
563 // Single-char escapes lift to a one-codepoint class.
564 if let Some(c) = single_char_escape(esc) {
565 return Ok(ClassSet::from_char(c));
566 }
567 match esc {
568 'd' => Ok(unicode::xsd_digit().clone()),
569 'D' => Ok(unicode::xsd_digit().complement()),
570 's' => Ok(unicode::xsd_whitespace().clone()),
571 'S' => Ok(unicode::xsd_whitespace().complement()),
572 'w' => Ok(unicode::xsd_word().clone()),
573 'W' => Ok(unicode::xsd_word().complement()),
574 'i' => Ok(name_start_class().clone()),
575 'I' => Ok(name_start_class().complement()),
576 'c' => Ok(name_char_class().clone()),
577 'C' => Ok(name_char_class().complement()),
578
579 'p' | 'P' => {
580 if !self.eat(b'{') {
581 return Err(format!("\\{esc} must be followed by '{{name}}'"));
582 }
583 let mut name = String::new();
584 while let Some(b) = self.peek() {
585 if b == b'}' { break; }
586 let c = self.bump_char().expect("peek returned Some");
587 name.push(c);
588 }
589 if !self.eat(b'}') {
590 return Err(format!("unclosed \\{esc}{{...}} property name"));
591 }
592 let set = unicode::property_set(&name)
593 .ok_or_else(|| format!("unknown Unicode property '{name}'"))?
594 .clone();
595 Ok(if esc == 'P' { set.complement() } else { set })
596 }
597
598 // XSD §F.1.4 explicitly forbids back-references — there
599 // are no capture-group semantics in the XSD regex
600 // flavour. But Microsoft-generated schemas use them
601 // (`\1`, `\2`, …) regardless. In XSD dialect we accept
602 // the syntax to let those schemas compile, treating
603 // `\N` as a "match any character" placeholder; real
604 // back-ref semantics aren't implemented. XPath 2.0/3.0
605 // forbid back-references in the pattern of fn:matches,
606 // fn:replace, and fn:tokenize (XPath 3.0 §5.6.1.1) —
607 // raise FORX0002 in that dialect.
608 '0'..='9' => match self.dialect {
609 Dialect::Xsd => Ok(ClassSet::universe()),
610 Dialect::Xpath | Dialect::Xpath20 => Err(format!(
611 "back-reference \\{esc} is not permitted in an \
612 XPath 2.0/3.0 regex pattern"
613 )),
614 },
615
616 // XSD §F.1.4 forbids anchor / boundary escapes
617 // (`\b`, `\B`, `\A`, `\Z`, `\z`). Lenient in XSD
618 // dialect for Microsoft-schema compatibility; XPath
619 // dialect rejects them as FORX0002.
620 'b' | 'B' | 'A' | 'Z' | 'z' => match self.dialect {
621 Dialect::Xsd => Ok(ClassSet::universe()),
622 Dialect::Xpath | Dialect::Xpath20 => Err(format!(
623 "boundary escape \\{esc} is not part of the \
624 XPath 2.0/3.0 regex grammar"
625 )),
626 },
627
628 // PCRE hex-byte / Unicode escapes (`\x41`, `A`).
629 // Not in XSD §F or XPath 2.0/3.0 but appear in
630 // Microsoft schemas; accept in XSD dialect only.
631 'x' | 'u' => match self.dialect {
632 Dialect::Xsd => match self.parse_hex_codepoint(esc) {
633 Some(c) => Ok(ClassSet::from_char(c)),
634 None => Ok(ClassSet::universe()),
635 },
636 Dialect::Xpath | Dialect::Xpath20 => Err(format!(
637 "escape \\{esc} is not part of the XPath 2.0/3.0 \
638 regex grammar"
639 )),
640 },
641
642 _ => Err(format!("unrecognised escape \\{esc}")),
643 }
644 }
645
646 /// Parse a trailing quantifier — `?`, `*`, `+`, `{n}`, `{n,}`,
647 /// `{n,m}`. Returns `(1, Some(1))` when no quantifier is
648 /// present. Enforces [`MAX_REPETITION`] on counted forms.
649 fn parse_quantifier(&mut self) -> Result<(u32, Option<u32>), String> {
650 let Some(b) = self.peek() else { return Ok((1, Some(1))); };
651 let parsed = match b {
652 b'?' => { self.bump(); (0, Some(1)) }
653 b'*' => { self.bump(); (0, None) }
654 b'+' => { self.bump(); (1, None) }
655 b'{' => {
656 self.bump();
657 // XSD §F.1.7 / XPath 2.0 §7.6.1 only define `{n}`,
658 // `{n,}`, `{n,m}` — the leading count is required.
659 // PCRE / Microsoft accept `{,m}` as a shorthand for
660 // `{0,m}`; allow that in XSD dialect for schema
661 // compatibility and reject it in XPath dialect.
662 let min = if self.peek() == Some(b',') {
663 if self.dialect == Dialect::Xpath {
664 return Err(
665 "quantifier '{,m}' requires an explicit \
666 minimum — XPath 2.0/3.0 §7.6 grammar".into()
667 );
668 }
669 0
670 } else {
671 let n = self.read_uint()?;
672 if n > MAX_REPETITION {
673 return Err(format!(
674 "quantifier minimum {n} exceeds cap {MAX_REPETITION}"
675 ));
676 }
677 n
678 };
679 if self.eat(b'}') {
680 (min, Some(min))
681 } else if self.eat(b',') {
682 if self.eat(b'}') {
683 (min, None)
684 } else {
685 let max = self.read_uint()?;
686 if max > MAX_REPETITION {
687 return Err(format!(
688 "quantifier maximum {max} exceeds cap {MAX_REPETITION}"
689 ));
690 }
691 if max < min {
692 return Err(format!(
693 "quantifier range {{{min},{max}}} is empty"
694 ));
695 }
696 if !self.eat(b'}') {
697 return Err("unclosed '{' quantifier".into());
698 }
699 (min, Some(max))
700 }
701 } else {
702 return Err("malformed '{' quantifier".into());
703 }
704 }
705 _ => return Ok((1, Some(1))),
706 };
707 // Lazy / chained-quantifier handling differs by dialect.
708 //
709 // * XSD dialect: silently consume any trailing `?` (lazy
710 // marker — boolean match is greedy/lazy-agnostic) and
711 // then coalesce chained quantifiers (`?*`, `{0,16}*`,
712 // `+?+`) into the loosest `(0, None)`. Strict PCRE
713 // rejects these, but Microsoft-authored schemas in the
714 // XSTS suite rely on the lenient interpretation.
715 //
716 // * XPath dialect: per XPath 2.0 §7.6.1 a single optional
717 // `?` is the reluctant marker — accept and leave it for
718 // [`parse_piece`] to consume. No coalescing: a second
719 // quantifier following a reluctant `?` is invalid and
720 // must surface as FORX0002 in [`parse_piece`]'s
721 // stray-quantifier check.
722 if self.dialect == Dialect::Xsd {
723 if self.peek() == Some(b'?') {
724 self.bump();
725 }
726 let mut widened = parsed;
727 while let Some(b) = self.peek() {
728 match b {
729 b'*' | b'+' | b'?' => { self.bump(); widened = (0, None); }
730 b'{' => {
731 let save = self.pos;
732 self.bump();
733 if self.read_uint().is_ok() || self.peek() == Some(b',') {
734 while let Some(c) = self.peek() {
735 self.bump();
736 if c == b'}' { break; }
737 }
738 widened = (0, None);
739 } else {
740 self.pos = save;
741 break;
742 }
743 }
744 _ => break,
745 }
746 }
747 return Ok(widened);
748 }
749 Ok(parsed)
750 }
751
752 fn read_uint(&mut self) -> Result<u32, String> {
753 let start = self.pos;
754 while let Some(b) = self.peek() {
755 if b.is_ascii_digit() { self.bump(); } else { break; }
756 }
757 if self.pos == start {
758 return Err("expected digit in quantifier".into());
759 }
760 std::str::from_utf8(&self.input[start..self.pos])
761 .unwrap() // ASCII digits — always valid UTF-8
762 .parse()
763 .map_err(|e: std::num::ParseIntError| e.to_string())
764 }
765
766 // ── byte-level helpers ────────────────────────────────────────────────
767
768 fn peek(&self) -> Option<u8> {
769 self.input.get(self.pos).copied()
770 }
771
772 fn peek_char(&self) -> Option<char> {
773 self.chars[self.pos..].chars().next()
774 }
775
776 /// Advance one byte. Caller must already have verified that
777 /// `pos` is on an ASCII byte (the metacharacters above are all
778 /// ASCII); for arbitrary codepoint advance use [`bump_char`].
779 fn bump(&mut self) { self.pos += 1; }
780
781 fn bump_char(&mut self) -> Option<char> {
782 let c = self.chars[self.pos..].chars().next()?;
783 self.pos += c.len_utf8();
784 Some(c)
785 }
786
787 fn eat(&mut self, b: u8) -> bool {
788 if self.peek() == Some(b) { self.bump(); true } else { false }
789 }
790}
791
792enum ClassAtom {
793 Char(char),
794 Set(ClassSet),
795}
796
797/// Single-char escapes — punctuation, the named control characters,
798/// and any non-alphanumeric character as a literal of itself. XSD
799/// §F's SingleCharEsc list is narrower (just the metacharacters)
800/// but schemas in the wild commonly write `\_`, `\@`, and similar
801/// no-op escapes; rejecting them would break drop-in compatibility.
802/// Returns `None` for escapes that should lower to multi-codepoint
803/// classes (`\d`, `\p{…}`, …) or that aren't valid in XSD at all
804/// (back-references `\1`, unknown letters); those route through
805/// [`Parser::parse_escape`].
806fn single_char_escape(esc: char) -> Option<char> {
807 Some(match esc {
808 'n' => '\n',
809 'r' => '\r',
810 't' => '\t',
811 c if !c.is_ascii_alphanumeric() => c,
812 _ => return None,
813 })
814}
815
816// ── XML 1.0 Name characters (XML 1.0 §2.3) ─────────────────────────────
817
818fn name_start_class() -> &'static ClassSet {
819 use std::sync::OnceLock;
820 static CELL: OnceLock<ClassSet> = OnceLock::new();
821 CELL.get_or_init(|| ClassSet::from_ranges(vec![
822 (':' as u32, ':' as u32),
823 ('A' as u32, 'Z' as u32),
824 ('_' as u32, '_' as u32),
825 ('a' as u32, 'z' as u32),
826 (0x00C0, 0x00D6),
827 (0x00D8, 0x00F6),
828 (0x00F8, 0x02FF),
829 (0x0370, 0x037D),
830 (0x037F, 0x1FFF),
831 (0x200C, 0x200D),
832 (0x2070, 0x218F),
833 (0x2C00, 0x2FEF),
834 (0x3001, 0xD7FF),
835 (0xF900, 0xFDCF),
836 (0xFDF0, 0xFFFD),
837 (0x10000, 0xEFFFF),
838 ]))
839}
840
841fn name_char_class() -> &'static ClassSet {
842 use std::sync::OnceLock;
843 static CELL: OnceLock<ClassSet> = OnceLock::new();
844 CELL.get_or_init(|| ClassSet::from_ranges(vec![
845 ('-' as u32, '-' as u32),
846 ('.' as u32, '.' as u32),
847 ('0' as u32, '9' as u32),
848 (':' as u32, ':' as u32),
849 ('A' as u32, 'Z' as u32),
850 ('_' as u32, '_' as u32),
851 ('a' as u32, 'z' as u32),
852 (0x00B7, 0x00B7),
853 (0x00C0, 0x00D6),
854 (0x00D8, 0x00F6),
855 (0x00F8, 0x037D),
856 (0x037F, 0x1FFF),
857 (0x200C, 0x200D),
858 (0x203F, 0x2040),
859 (0x2070, 0x218F),
860 (0x2C00, 0x2FEF),
861 (0x3001, 0xD7FF),
862 (0xF900, 0xFDCF),
863 (0xFDF0, 0xFFFD),
864 (0x10000, 0xEFFFF),
865 ]))
866}
867
868#[cfg(test)]
869mod tests {
870 use super::*;
871
872 fn p(src: &str) -> Expr { parse(src).unwrap() }
873
874 fn class_of(e: &Expr) -> &ClassSet {
875 match e { Expr::Class(c) => c, _ => panic!("not a class: {e:?}") }
876 }
877
878 #[test]
879 fn empty_pattern() {
880 assert!(matches!(p(""), Expr::Empty));
881 }
882
883 #[test]
884 fn literal_char() {
885 let e = p("a");
886 assert!(class_of(&e).contains('a'));
887 assert!(!class_of(&e).contains('b'));
888 }
889
890 #[test]
891 fn concatenation() {
892 match p("abc") {
893 Expr::Concat(v) => assert_eq!(v.len(), 3),
894 other => panic!("expected Concat, got {other:?}"),
895 }
896 }
897
898 #[test]
899 fn alternation() {
900 match p("a|b|c") {
901 Expr::Alt(v) => assert_eq!(v.len(), 3),
902 other => panic!("expected Alt, got {other:?}"),
903 }
904 }
905
906 #[test]
907 fn quantifier_star() {
908 match p("a*") {
909 Expr::Quant(_, 0, None) => {}
910 other => panic!("expected Quant(_, 0, None), got {other:?}"),
911 }
912 }
913
914 #[test]
915 fn quantifier_range() {
916 match p("a{2,4}") {
917 Expr::Quant(_, 2, Some(4)) => {}
918 other => panic!("expected Quant(_, 2, Some(4)), got {other:?}"),
919 }
920 }
921
922 #[test]
923 fn class_with_subtraction() {
924 let e = p("[a-z-[aeiou]]");
925 let c = class_of(&e);
926 assert!(c.contains('b'));
927 assert!(!c.contains('a'));
928 assert!(!c.contains('e'));
929 }
930
931 #[test]
932 fn class_negated() {
933 let e = p("[^0-9]");
934 let c = class_of(&e);
935 assert!(!c.contains('5'));
936 assert!(c.contains('a'));
937 }
938
939 #[test]
940 fn shortcut_digit_in_class() {
941 let e = p(r"[\d]");
942 let c = class_of(&e);
943 assert!(c.contains('5'));
944 assert!(!c.contains('a'));
945 }
946
947 #[test]
948 fn whitespace_shortcut_is_xsd_flavour() {
949 let e = p(r"\s");
950 let c = class_of(&e);
951 assert!(c.contains(' '));
952 assert!(c.contains('\t'));
953 assert!(!c.contains('\u{A0}'), "XSD \\s is the four XML whitespace chars only");
954 }
955
956 #[test]
957 fn property_unknown_errors() {
958 let err = parse(r"\p{NotARealCategory}").unwrap_err();
959 assert!(err.contains("unknown Unicode property"));
960 }
961
962 #[test]
963 fn property_letter() {
964 let e = p(r"\p{L}");
965 let c = class_of(&e);
966 assert!(c.contains('a'));
967 assert!(c.contains('中'));
968 assert!(!c.contains('1'));
969 }
970
971 #[test]
972 fn rejects_true_lookaround() {
973 // True lookaround/lookbehind have no safe lenient mapping —
974 // silently accepting them would mis-match. Rejected.
975 assert!(parse("(?=foo)").is_err());
976 assert!(parse("(?!foo)").is_err());
977 assert!(parse("(?<=foo)").is_err());
978 assert!(parse("(?<!foo)").is_err());
979 }
980
981 #[test]
982 fn accepts_inline_modifier_directives_leniently() {
983 // `(?i)pattern` is a PCRE inline modifier directive — not
984 // in XSD §F, but Microsoft-generated schemas use them.
985 // We accept and ignore the modifier so the pattern compiles.
986 assert!(parse("(?i)foo").is_ok());
987 assert!(parse("(?m)bar").is_ok());
988 assert!(parse("(?s)baz").is_ok());
989 assert!(parse("(?-i:quux)").is_ok());
990 }
991
992 #[test]
993 fn accepts_modifier_groups_leniently() {
994 // `(?i:foo)` is a scoped modifier group. Same treatment
995 // as inline directives — accept, ignore modifier.
996 assert!(parse("(?i:foo)").is_ok());
997 assert!(parse("(?r:foo)").is_ok());
998 assert!(parse("(?n:(foo))").is_ok());
999 }
1000
1001 #[test]
1002 fn accepts_back_references_leniently() {
1003 // XSD §F forbids back-references but Microsoft-generated
1004 // schemas use them. We accept `\N` as a match-any
1005 // placeholder so the pattern compiles; real back-ref
1006 // semantics aren't implemented.
1007 assert!(parse(r"(a)\1").is_ok());
1008 assert!(parse(r"(a)(b)\2").is_ok());
1009 }
1010
1011 #[test]
1012 fn accepts_anchor_escapes_leniently() {
1013 // \b, \B, \z, \Z, \A — anchors not in XSD §F. Accepted
1014 // as match-any placeholders (degraded but compiles).
1015 assert!(parse(r"\bfoo").is_ok());
1016 assert!(parse(r"foo\Z").is_ok());
1017 }
1018
1019 #[test]
1020 fn rejects_unbalanced() {
1021 assert!(parse("(foo").is_err());
1022 assert!(parse("foo)").is_err());
1023 assert!(parse("[abc").is_err());
1024 }
1025
1026 #[test]
1027 fn dot_excludes_line_terminators() {
1028 let e = p(".");
1029 let c = class_of(&e);
1030 assert!(c.contains('a'));
1031 assert!(c.contains(' '));
1032 assert!(!c.contains('\n'));
1033 assert!(!c.contains('\r'));
1034 }
1035
1036 #[test]
1037 fn quantifier_cap() {
1038 let err = parse("a{99999}").unwrap_err();
1039 assert!(err.contains("exceeds cap"));
1040 }
1041
1042 #[test]
1043 fn deep_group_nesting_rejected() {
1044 // A pattern nested past MAX_REGEX_DEPTH must error rather than
1045 // overflow the recursive-descent stack. Build `(((…a…)))` with
1046 // enough groups to exceed the cap.
1047 let n = (MAX_REGEX_DEPTH as usize) + 50;
1048 let pattern = format!("{}a{}", "(".repeat(n), ")".repeat(n));
1049 let err = parse(&pattern).unwrap_err();
1050 assert!(
1051 err.contains("nesting depth exceeds limit"),
1052 "expected depth-limit error, got: {err}"
1053 );
1054 }
1055
1056 #[test]
1057 fn moderate_group_nesting_accepted() {
1058 // Nesting comfortably under the cap must still parse.
1059 let n = 32;
1060 let pattern = format!("{}a{}", "(".repeat(n), ")".repeat(n));
1061 assert!(parse(&pattern).is_ok());
1062 }
1063}