use super::class::ClassSet;
use super::unicode;
const MAX_REPETITION: u32 = 4096;
#[derive(Debug, Clone)]
pub enum Expr {
Empty,
Concat(Vec<Expr>),
Alt(Vec<Expr>),
Quant(Box<Expr>, u32, Option<u32>),
Class(ClassSet),
Anchor(AnchorKind),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AnchorKind {
Start,
End,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Dialect {
Xsd,
Xpath,
Xpath20,
}
pub fn parse(src: &str) -> Result<Expr, String> {
parse_with(src, Dialect::Xsd)
}
pub fn parse_with(src: &str, dialect: Dialect) -> Result<Expr, String> {
let mut p = Parser { input: src.as_bytes(), pos: 0, chars: src, dialect, depth: 0 };
let expr = p.parse_regexp()?;
if p.pos != p.input.len() {
return Err(format!("unexpected '{}' at position {}",
p.peek_char().unwrap_or(' '), p.pos));
}
Ok(expr)
}
struct Parser<'a> {
input: &'a [u8],
pos: usize,
chars: &'a str,
dialect: Dialect,
depth: u32,
}
const MAX_REGEX_DEPTH: u32 = 256;
impl<'a> Parser<'a> {
fn parse_regexp(&mut self) -> Result<Expr, String> {
self.depth += 1;
if self.depth > MAX_REGEX_DEPTH {
return Err(format!(
"regular expression nesting depth exceeds limit ({MAX_REGEX_DEPTH})"
));
}
let result = self.parse_alternation();
self.depth -= 1;
result
}
fn parse_alternation(&mut self) -> Result<Expr, String> {
let first = self.parse_branch()?;
if !self.eat(b'|') {
return Ok(first);
}
let mut branches = vec![first];
loop {
branches.push(self.parse_branch()?);
if !self.eat(b'|') { break; }
}
Ok(Expr::Alt(branches))
}
fn parse_branch(&mut self) -> Result<Expr, String> {
let mut pieces: Vec<Expr> = Vec::new();
while let Some(b) = self.peek() {
if b == b'|' || b == b')' { break; }
pieces.push(self.parse_piece()?);
}
Ok(match pieces.len() {
0 => Expr::Empty,
1 => pieces.pop().unwrap(),
_ => Expr::Concat(pieces),
})
}
fn parse_piece(&mut self) -> Result<Expr, String> {
let atom = self.parse_atom()?;
let (min, max) = self.parse_quantifier()?;
let had_quantifier = (min, max) != (1, Some(1));
if had_quantifier {
self.eat(b'?');
if let Some(b) = self.peek() {
if matches!(b, b'?' | b'*' | b'+' | b'{') {
return Err(format!(
"stray quantifier '{}' after another quantifier — \
XPath 2.0/3.0 §7.6 grammar",
b as char
));
}
}
}
Ok(match (min, max) {
(1, Some(1)) => atom,
_ => Expr::Quant(Box::new(atom), min, max),
})
}
fn parse_atom(&mut self) -> Result<Expr, String> {
let b = self.peek().ok_or("unexpected end of input")?;
if matches!(self.dialect, Dialect::Xpath | Dialect::Xpath20) {
if b == b'^' { self.bump(); return Ok(Expr::Anchor(AnchorKind::Start)); }
if b == b'$' { self.bump(); return Ok(Expr::Anchor(AnchorKind::End)); }
}
match b {
b'(' => {
self.bump();
if self.eat(b'?') {
return self.parse_question_construct();
}
let inner = self.parse_regexp()?;
if !self.eat(b')') {
return Err("unbalanced '(' in pattern".into());
}
Ok(inner)
}
b'[' => {
self.bump();
Ok(Expr::Class(self.parse_class()?))
}
b'.' => {
self.bump();
let nl = ClassSet::from_ranges(vec![(0x0A, 0x0A), (0x0D, 0x0D)]);
Ok(Expr::Class(ClassSet::universe().subtract(&nl)))
}
b'\\' => {
self.bump();
let esc = self.bump_char()
.ok_or("trailing backslash")?;
Ok(Expr::Class(self.parse_escape(esc)?))
}
b')' | b'|' | b'*' | b'+' | b'?' | b'{' =>
Err(format!("unexpected metacharacter '{}' at position {}",
b as char, self.pos)),
b']' | b'}' => match self.dialect {
Dialect::Xsd => {
let c = self.bump_char().expect("peek returned Some");
Ok(Expr::Class(ClassSet::from_char(c)))
}
Dialect::Xpath | Dialect::Xpath20 => Err(format!(
"unmatched '{}' — XPath 2.0/3.0 §7.6 grammar",
b as char
)),
},
_ => {
let c = self.bump_char().expect("peek returned Some");
Ok(Expr::Class(ClassSet::from_char(c)))
}
}
}
fn parse_question_construct(&mut self) -> Result<Expr, String> {
let next = self.peek().ok_or("unterminated '(?' in pattern")?;
if next == b'=' || next == b'!' {
return Err("lookaround '(?=…)' / '(?!…)' is not part of XSD §F".into());
}
if next == b'<' {
return Err("lookbehind '(?<…)' is not part of XSD §F".into());
}
if next == b':' {
if self.dialect == Dialect::Xpath20 {
return Err(
"non-capturing group '(?:' is XPath 3.0+ syntax \
not permitted in XPath 2.0 (FORX0002)".into()
);
}
self.bump();
let inner = self.parse_regexp()?;
if !self.eat(b')') {
return Err("unbalanced '(' in pattern".into());
}
return Ok(inner);
}
if self.dialect == Dialect::Xpath {
return Err(format!(
"invalid `(?{}…)` construct — XPath 2.0/3.0 regex \
supports only `(?:…)` non-capturing groups",
next as char
));
}
let start = self.pos;
while let Some(b) = self.peek() {
if b.is_ascii_alphabetic() || b == b'-' {
self.bump();
} else {
break;
}
}
let modifiers_consumed = self.pos - start;
match self.peek() {
Some(b':') => {
self.bump();
let inner = self.parse_regexp()?;
if !self.eat(b')') {
return Err("unbalanced '(' in pattern".into());
}
Ok(inner)
}
Some(b')') if modifiers_consumed > 0 => {
self.bump();
Ok(Expr::Empty)
}
Some(_) => {
self.skip_to_matching_close_paren()?;
Ok(Expr::Empty)
}
None => Err("unterminated '(?' construct".into()),
}
}
fn skip_to_matching_close_paren(&mut self) -> Result<(), String> {
let mut depth: i32 = 1;
while depth > 0 {
let b = self.peek().ok_or("unterminated '(' in pattern")?;
match b {
b'\\' => {
self.bump();
if self.peek().is_some() { self.bump(); }
}
b'(' => { self.bump(); depth += 1; }
b')' => { self.bump(); depth -= 1; }
b'[' => {
self.bump();
while let Some(b) = self.peek() {
self.bump();
if b == b'\\' && self.peek().is_some() { self.bump(); }
else if b == b']' { break; }
}
}
_ => { self.bump(); }
}
}
Ok(())
}
fn parse_class(&mut self) -> Result<ClassSet, String> {
let negated = self.eat(b'^');
let mut acc = ClassSet::empty();
if self.peek() == Some(b']') {
return Err("empty character class is not permitted".into());
}
loop {
let b = self.peek().ok_or("unclosed character class")?;
if b == b']' { break; }
if b == b'-' && self.input.get(self.pos + 1) == Some(&b'[') {
self.bump(); self.bump(); let sub = self.parse_class()?;
let mut head = if negated { acc.complement() } else { acc };
head = head.subtract(&sub);
if !self.eat(b']') {
return Err(
"subtraction must be the last operation in a character class"
.into(),
);
}
return Ok(head);
}
let lo_atom = self.parse_class_atom()?;
if let ClassAtom::Char(lo) = lo_atom {
let mut is_range = false;
if self.peek() == Some(b'-') {
let next2 = self.input.get(self.pos + 1).copied();
if next2 != Some(b']') && next2 != Some(b'[') {
is_range = true;
}
}
if is_range {
self.bump();
let hi_atom = self.parse_class_atom()?;
match hi_atom {
ClassAtom::Char(hi) => {
if hi < lo {
return Err(format!(
"inverted character range: '{lo}' > '{hi}'"
));
}
acc = acc.union(&ClassSet::from_range(lo as u32, hi as u32));
}
ClassAtom::Set(s) => match self.dialect {
Dialect::Xsd => {
acc = acc.union(&ClassSet::from_char(lo)).union(&s);
}
Dialect::Xpath | Dialect::Xpath20 => return Err(format!(
"class-shorthand escape cannot be the \
upper bound of a range starting at '{lo}'"
)),
},
}
} else {
acc = acc.union(&ClassSet::from_char(lo));
}
} else if let ClassAtom::Set(s) = lo_atom {
acc = acc.union(&s);
}
}
self.bump(); Ok(if negated { acc.complement() } else { acc })
}
fn parse_class_atom(&mut self) -> Result<ClassAtom, String> {
let b = self.peek().ok_or("unclosed character class")?;
if b == b'\\' {
self.bump();
let esc = self.bump_char().ok_or("trailing backslash")?;
if (esc == 'x' || esc == 'u') && self.dialect == Dialect::Xsd {
if let Some(c) = self.parse_hex_codepoint(esc) {
return Ok(ClassAtom::Char(c));
}
}
match single_char_escape(esc) {
Some(c) => Ok(ClassAtom::Char(c)),
None => Ok(ClassAtom::Set(self.parse_escape(esc)?)),
}
} else {
if b == b'[' && self.dialect == Dialect::Xpath {
return Err(
"literal '[' inside a character class — XPath \
2.0/3.0 §7.6 requires it to be escaped".into()
);
}
let c = self.bump_char().expect("peek returned Some");
Ok(ClassAtom::Char(c))
}
}
fn parse_hex_codepoint(&mut self, esc: char) -> Option<char> {
let braced = self.peek() == Some(b'{');
if braced { self.bump(); }
let want = if braced { usize::MAX } else if esc == 'u' { 4 } else { 2 };
let start = self.pos;
while self.pos - start < want {
match self.peek() {
Some(b) if b.is_ascii_hexdigit() => { self.bump(); }
_ => break,
}
}
let body = std::str::from_utf8(&self.input[start..self.pos]).ok()?;
if body.is_empty() { return None; }
let cp = u32::from_str_radix(body, 16).ok()?;
if braced && !self.eat(b'}') { return None; }
char::from_u32(cp)
}
fn parse_escape(&mut self, esc: char) -> Result<ClassSet, String> {
if let Some(c) = single_char_escape(esc) {
return Ok(ClassSet::from_char(c));
}
match esc {
'd' => Ok(unicode::xsd_digit().clone()),
'D' => Ok(unicode::xsd_digit().complement()),
's' => Ok(unicode::xsd_whitespace().clone()),
'S' => Ok(unicode::xsd_whitespace().complement()),
'w' => Ok(unicode::xsd_word().clone()),
'W' => Ok(unicode::xsd_word().complement()),
'i' => Ok(name_start_class().clone()),
'I' => Ok(name_start_class().complement()),
'c' => Ok(name_char_class().clone()),
'C' => Ok(name_char_class().complement()),
'p' | 'P' => {
if !self.eat(b'{') {
return Err(format!("\\{esc} must be followed by '{{name}}'"));
}
let mut name = String::new();
while let Some(b) = self.peek() {
if b == b'}' { break; }
let c = self.bump_char().expect("peek returned Some");
name.push(c);
}
if !self.eat(b'}') {
return Err(format!("unclosed \\{esc}{{...}} property name"));
}
let set = unicode::property_set(&name)
.ok_or_else(|| format!("unknown Unicode property '{name}'"))?
.clone();
Ok(if esc == 'P' { set.complement() } else { set })
}
'0'..='9' => match self.dialect {
Dialect::Xsd => Ok(ClassSet::universe()),
Dialect::Xpath | Dialect::Xpath20 => Err(format!(
"back-reference \\{esc} is not permitted in an \
XPath 2.0/3.0 regex pattern"
)),
},
'b' | 'B' | 'A' | 'Z' | 'z' => match self.dialect {
Dialect::Xsd => Ok(ClassSet::universe()),
Dialect::Xpath | Dialect::Xpath20 => Err(format!(
"boundary escape \\{esc} is not part of the \
XPath 2.0/3.0 regex grammar"
)),
},
'x' | 'u' => match self.dialect {
Dialect::Xsd => match self.parse_hex_codepoint(esc) {
Some(c) => Ok(ClassSet::from_char(c)),
None => Ok(ClassSet::universe()),
},
Dialect::Xpath | Dialect::Xpath20 => Err(format!(
"escape \\{esc} is not part of the XPath 2.0/3.0 \
regex grammar"
)),
},
_ => Err(format!("unrecognised escape \\{esc}")),
}
}
fn parse_quantifier(&mut self) -> Result<(u32, Option<u32>), String> {
let Some(b) = self.peek() else { return Ok((1, Some(1))); };
let parsed = match b {
b'?' => { self.bump(); (0, Some(1)) }
b'*' => { self.bump(); (0, None) }
b'+' => { self.bump(); (1, None) }
b'{' => {
self.bump();
let min = if self.peek() == Some(b',') {
if self.dialect == Dialect::Xpath {
return Err(
"quantifier '{,m}' requires an explicit \
minimum — XPath 2.0/3.0 §7.6 grammar".into()
);
}
0
} else {
let n = self.read_uint()?;
if n > MAX_REPETITION {
return Err(format!(
"quantifier minimum {n} exceeds cap {MAX_REPETITION}"
));
}
n
};
if self.eat(b'}') {
(min, Some(min))
} else if self.eat(b',') {
if self.eat(b'}') {
(min, None)
} else {
let max = self.read_uint()?;
if max > MAX_REPETITION {
return Err(format!(
"quantifier maximum {max} exceeds cap {MAX_REPETITION}"
));
}
if max < min {
return Err(format!(
"quantifier range {{{min},{max}}} is empty"
));
}
if !self.eat(b'}') {
return Err("unclosed '{' quantifier".into());
}
(min, Some(max))
}
} else {
return Err("malformed '{' quantifier".into());
}
}
_ => return Ok((1, Some(1))),
};
if self.dialect == Dialect::Xsd {
if self.peek() == Some(b'?') {
self.bump();
}
let mut widened = parsed;
while let Some(b) = self.peek() {
match b {
b'*' | b'+' | b'?' => { self.bump(); widened = (0, None); }
b'{' => {
let save = self.pos;
self.bump();
if self.read_uint().is_ok() || self.peek() == Some(b',') {
while let Some(c) = self.peek() {
self.bump();
if c == b'}' { break; }
}
widened = (0, None);
} else {
self.pos = save;
break;
}
}
_ => break,
}
}
return Ok(widened);
}
Ok(parsed)
}
fn read_uint(&mut self) -> Result<u32, String> {
let start = self.pos;
while let Some(b) = self.peek() {
if b.is_ascii_digit() { self.bump(); } else { break; }
}
if self.pos == start {
return Err("expected digit in quantifier".into());
}
std::str::from_utf8(&self.input[start..self.pos])
.unwrap() .parse()
.map_err(|e: std::num::ParseIntError| e.to_string())
}
fn peek(&self) -> Option<u8> {
self.input.get(self.pos).copied()
}
fn peek_char(&self) -> Option<char> {
self.chars[self.pos..].chars().next()
}
fn bump(&mut self) { self.pos += 1; }
fn bump_char(&mut self) -> Option<char> {
let c = self.chars[self.pos..].chars().next()?;
self.pos += c.len_utf8();
Some(c)
}
fn eat(&mut self, b: u8) -> bool {
if self.peek() == Some(b) { self.bump(); true } else { false }
}
}
enum ClassAtom {
Char(char),
Set(ClassSet),
}
fn single_char_escape(esc: char) -> Option<char> {
Some(match esc {
'n' => '\n',
'r' => '\r',
't' => '\t',
c if !c.is_ascii_alphanumeric() => c,
_ => return None,
})
}
fn name_start_class() -> &'static ClassSet {
use std::sync::OnceLock;
static CELL: OnceLock<ClassSet> = OnceLock::new();
CELL.get_or_init(|| ClassSet::from_ranges(vec![
(':' as u32, ':' as u32),
('A' as u32, 'Z' as u32),
('_' as u32, '_' as u32),
('a' as u32, 'z' as u32),
(0x00C0, 0x00D6),
(0x00D8, 0x00F6),
(0x00F8, 0x02FF),
(0x0370, 0x037D),
(0x037F, 0x1FFF),
(0x200C, 0x200D),
(0x2070, 0x218F),
(0x2C00, 0x2FEF),
(0x3001, 0xD7FF),
(0xF900, 0xFDCF),
(0xFDF0, 0xFFFD),
(0x10000, 0xEFFFF),
]))
}
fn name_char_class() -> &'static ClassSet {
use std::sync::OnceLock;
static CELL: OnceLock<ClassSet> = OnceLock::new();
CELL.get_or_init(|| ClassSet::from_ranges(vec![
('-' as u32, '-' as u32),
('.' as u32, '.' as u32),
('0' as u32, '9' as u32),
(':' as u32, ':' as u32),
('A' as u32, 'Z' as u32),
('_' as u32, '_' as u32),
('a' as u32, 'z' as u32),
(0x00B7, 0x00B7),
(0x00C0, 0x00D6),
(0x00D8, 0x00F6),
(0x00F8, 0x037D),
(0x037F, 0x1FFF),
(0x200C, 0x200D),
(0x203F, 0x2040),
(0x2070, 0x218F),
(0x2C00, 0x2FEF),
(0x3001, 0xD7FF),
(0xF900, 0xFDCF),
(0xFDF0, 0xFFFD),
(0x10000, 0xEFFFF),
]))
}
#[cfg(test)]
mod tests {
use super::*;
fn p(src: &str) -> Expr { parse(src).unwrap() }
fn class_of(e: &Expr) -> &ClassSet {
match e { Expr::Class(c) => c, _ => panic!("not a class: {e:?}") }
}
#[test]
fn empty_pattern() {
assert!(matches!(p(""), Expr::Empty));
}
#[test]
fn literal_char() {
let e = p("a");
assert!(class_of(&e).contains('a'));
assert!(!class_of(&e).contains('b'));
}
#[test]
fn concatenation() {
match p("abc") {
Expr::Concat(v) => assert_eq!(v.len(), 3),
other => panic!("expected Concat, got {other:?}"),
}
}
#[test]
fn alternation() {
match p("a|b|c") {
Expr::Alt(v) => assert_eq!(v.len(), 3),
other => panic!("expected Alt, got {other:?}"),
}
}
#[test]
fn quantifier_star() {
match p("a*") {
Expr::Quant(_, 0, None) => {}
other => panic!("expected Quant(_, 0, None), got {other:?}"),
}
}
#[test]
fn quantifier_range() {
match p("a{2,4}") {
Expr::Quant(_, 2, Some(4)) => {}
other => panic!("expected Quant(_, 2, Some(4)), got {other:?}"),
}
}
#[test]
fn class_with_subtraction() {
let e = p("[a-z-[aeiou]]");
let c = class_of(&e);
assert!(c.contains('b'));
assert!(!c.contains('a'));
assert!(!c.contains('e'));
}
#[test]
fn class_negated() {
let e = p("[^0-9]");
let c = class_of(&e);
assert!(!c.contains('5'));
assert!(c.contains('a'));
}
#[test]
fn shortcut_digit_in_class() {
let e = p(r"[\d]");
let c = class_of(&e);
assert!(c.contains('5'));
assert!(!c.contains('a'));
}
#[test]
fn whitespace_shortcut_is_xsd_flavour() {
let e = p(r"\s");
let c = class_of(&e);
assert!(c.contains(' '));
assert!(c.contains('\t'));
assert!(!c.contains('\u{A0}'), "XSD \\s is the four XML whitespace chars only");
}
#[test]
fn property_unknown_errors() {
let err = parse(r"\p{NotARealCategory}").unwrap_err();
assert!(err.contains("unknown Unicode property"));
}
#[test]
fn property_letter() {
let e = p(r"\p{L}");
let c = class_of(&e);
assert!(c.contains('a'));
assert!(c.contains('中'));
assert!(!c.contains('1'));
}
#[test]
fn rejects_true_lookaround() {
assert!(parse("(?=foo)").is_err());
assert!(parse("(?!foo)").is_err());
assert!(parse("(?<=foo)").is_err());
assert!(parse("(?<!foo)").is_err());
}
#[test]
fn accepts_inline_modifier_directives_leniently() {
assert!(parse("(?i)foo").is_ok());
assert!(parse("(?m)bar").is_ok());
assert!(parse("(?s)baz").is_ok());
assert!(parse("(?-i:quux)").is_ok());
}
#[test]
fn accepts_modifier_groups_leniently() {
assert!(parse("(?i:foo)").is_ok());
assert!(parse("(?r:foo)").is_ok());
assert!(parse("(?n:(foo))").is_ok());
}
#[test]
fn accepts_back_references_leniently() {
assert!(parse(r"(a)\1").is_ok());
assert!(parse(r"(a)(b)\2").is_ok());
}
#[test]
fn accepts_anchor_escapes_leniently() {
assert!(parse(r"\bfoo").is_ok());
assert!(parse(r"foo\Z").is_ok());
}
#[test]
fn rejects_unbalanced() {
assert!(parse("(foo").is_err());
assert!(parse("foo)").is_err());
assert!(parse("[abc").is_err());
}
#[test]
fn dot_excludes_line_terminators() {
let e = p(".");
let c = class_of(&e);
assert!(c.contains('a'));
assert!(c.contains(' '));
assert!(!c.contains('\n'));
assert!(!c.contains('\r'));
}
#[test]
fn quantifier_cap() {
let err = parse("a{99999}").unwrap_err();
assert!(err.contains("exceeds cap"));
}
#[test]
fn deep_group_nesting_rejected() {
let n = (MAX_REGEX_DEPTH as usize) + 50;
let pattern = format!("{}a{}", "(".repeat(n), ")".repeat(n));
let err = parse(&pattern).unwrap_err();
assert!(
err.contains("nesting depth exceeds limit"),
"expected depth-limit error, got: {err}"
);
}
#[test]
fn moderate_group_nesting_accepted() {
let n = 32;
let pattern = format!("{}a{}", "(".repeat(n), ")".repeat(n));
assert!(parse(&pattern).is_ok());
}
}