Skip to main content

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}