Merge epic/30-regex: XML Schema regular-expression engine

This commit is contained in:
Mihaela Mihaljevic 2026-06-08 06:18:50 +02:00
commit 2bc7dd2410
7 changed files with 585 additions and 1 deletions

View File

@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Regular-expression engine (#30, the XML Schema regex flavor, gating XSD). The
new `PureXML.Regex` namespace compiles a pattern (literals, `.`, character
classes with ranges and negation, the escapes `\d \D \w \W \s \S \i \I \c \C`,
single-character escapes, grouping, alternation, and the quantifiers `?`, `*`,
`+`, `{n}`, `{n,}`, `{n,m}`) to a Thompson NFA and matches the whole string, as
`xs:pattern` facets require. Matching is linear in the input with no
backtracking blow-up. `\p{...}` Unicode category escapes are not yet supported.
- Canonical XML (#26, the libxml2 `c14n.h` model). The new `PureXML.Canonical`
namespace renders the C14N form of a node: namespace declarations sorted by
prefix (default first) and attributes by namespace URI then local name, empty

View File

@ -90,7 +90,7 @@ flowchart TB
classDef epic fill:#f2e5ff,stroke:#af52de,color:#111827
classDef todo fill:#f2f4f7,stroke:#8e8e93,color:#111827
Querying["Querying: #21 XPath, #22 xmlPattern, #23 XPointer (shipped)"]:::done
Regexp["#30 Regular expression engine"]:::epic
Regexp["#30 Regular expression engine"]:::done
Xsd["#2 XSD / RELAX NG validation"]:::epic
Schematron["#25 Schematron validation"]:::done
Regexp --> Xsd

View File

@ -39,4 +39,7 @@ public enum PureXML {
/// Canonical XML (C14N) serialization types.
public enum Canonical {}
/// Regular-expression types (the XML Schema regex flavor).
public enum Regex {}
}

View File

@ -0,0 +1,173 @@
extension PureXML.Regex {
/// One NFA state: epsilon transitions and at most one character-class
/// transition (Thompson construction).
struct NFAState: Sendable {
var epsilon: [Int] = []
var transition: CharTransition?
}
/// A character-consuming transition to another state.
struct CharTransition: Sendable {
let charClass: CharClass
let target: Int
}
/// A compiled non-deterministic finite automaton.
struct NFA: Sendable {
var states: [NFAState]
var start: Int
var accept: Int
/// Whether the automaton matches the whole string (XSD patterns are
/// implicitly anchored at both ends).
func matchesWhole(_ string: String) -> Bool {
var current = closure([start])
for character in string {
var next: Set<Int> = []
for state in current {
if let transition = states[state].transition, transition.charClass.matches(character) {
next.insert(transition.target)
}
}
if next.isEmpty { return false }
current = closure(next)
}
return current.contains(accept)
}
private func closure(_ seed: Set<Int>) -> Set<Int> {
var seen = seed
var stack = Array(seed)
while let state = stack.popLast() {
for next in states[state].epsilon where seen.insert(next).inserted {
stack.append(next)
}
}
return seen
}
}
/// Builds an ``NFA`` from a ``Node`` by Thompson construction.
struct NFABuilder {
private var states: [NFAState] = []
static func build(_ node: Node) -> NFA {
var builder = NFABuilder()
let fragment = builder.fragment(for: node)
return NFA(states: builder.states, start: fragment.start, accept: fragment.accept)
}
private mutating func addState() -> Int {
states.append(NFAState())
return states.count - 1
}
private mutating func fragment(for node: Node) -> (start: Int, accept: Int) {
switch node {
case .empty:
let start = addState()
let accept = addState()
states[start].epsilon.append(accept)
return (start, accept)
case let .characters(charClass):
let start = addState()
let accept = addState()
states[start].transition = CharTransition(charClass: charClass, target: accept)
return (start, accept)
case let .concat(nodes):
return concatenate(nodes)
case let .alternate(nodes):
return alternate(nodes)
case let .repeated(inner, minimum, maximum):
return repeatedFragment(inner, minimum: minimum, maximum: maximum)
}
}
private mutating func concatenate(_ nodes: [Node]) -> (start: Int, accept: Int) {
guard !nodes.isEmpty else { return fragment(for: .empty) }
var first: Int?
var last: Int?
for node in nodes {
let part = fragment(for: node)
if let previous = last { states[previous].epsilon.append(part.start) } else { first = part.start }
last = part.accept
}
return (first ?? addState(), last ?? addState())
}
private mutating func alternate(_ nodes: [Node]) -> (start: Int, accept: Int) {
let start = addState()
let accept = addState()
for node in nodes {
let part = fragment(for: node)
states[start].epsilon.append(part.start)
states[part.accept].epsilon.append(accept)
}
return (start, accept)
}
private mutating func repeatedFragment(_ node: Node, minimum: Int, maximum: Int?) -> (start: Int, accept: Int) {
let start = addState()
var current = start
for _ in 0 ..< minimum {
let part = fragment(for: node)
states[current].epsilon.append(part.start)
current = part.accept
}
let accept = addState()
if let maximum {
appendOptional(node, count: maximum - minimum, from: current, to: accept)
} else {
appendStar(node, from: current, to: accept)
}
return (start, accept)
}
private mutating func appendOptional(_ node: Node, count: Int, from: Int, to accept: Int) {
var current = from
for _ in 0 ..< max(0, count) {
let part = fragment(for: node)
states[current].epsilon.append(part.start)
states[current].epsilon.append(accept)
current = part.accept
}
states[current].epsilon.append(accept)
}
private mutating func appendStar(_ node: Node, from current: Int, to accept: Int) {
let part = fragment(for: node)
states[current].epsilon.append(part.start)
states[current].epsilon.append(accept)
states[part.accept].epsilon.append(part.start)
states[part.accept].epsilon.append(accept)
}
}
}
public extension PureXML.Regex {
/// A compiled regular expression in the XML Schema flavor: literals, `.`, the
/// character-class escapes (`\d \D \w \W \s \S \i \I \c \C`), single-character
/// escapes, character classes with ranges and negation, grouping, alternation
/// (`|`), and the quantifiers `?`, `*`, `+`, `{n}`, `{n,}`, `{n,m}`. Matching
/// is whole-string (anchored), as XSD `pattern` facets require. Built on a
/// Thompson NFA, so it runs in time linear in the input with no backtracking
/// blow-up. Unicode category escapes (`\p{...}`) are not yet supported.
struct Pattern: Sendable {
private let nfa: NFA
/// Compiles a pattern.
public init(_ pattern: String) throws {
nfa = try NFABuilder.build(RegexParser.parse(pattern))
}
/// Whether `string` matches the pattern in full.
public func matches(_ string: String) -> Bool {
nfa.matchesWhole(string)
}
}
/// Compiles and matches in one step.
static func matches(_ pattern: String, _ string: String) throws -> Bool {
try Pattern(pattern).matches(string)
}
}

View File

@ -0,0 +1,228 @@
/// One member of a character class: a single scalar (a possible range endpoint)
/// or a named sub-class. File-scope and private.
private enum ClassMember {
case scalar(Unicode.Scalar)
case named(PureXML.Regex.NamedClass)
case negatedNamed(PureXML.Regex.NamedClass)
}
extension PureXML.Regex {
/// Recursive-descent parser for the XML Schema regular-expression grammar,
/// producing a ``Node`` tree. Anchoring is implicit (the whole string is
/// matched), so `^` and `$` are ordinary characters.
struct RegexParser {
private let chars: [Character]
private var index = 0
private init(_ pattern: String) {
chars = Array(pattern)
}
static func parse(_ pattern: String) throws -> Node {
guard !pattern.isEmpty else { throw RegexError.empty }
var parser = RegexParser(pattern)
let node = try parser.parseAlternation()
guard parser.isAtEnd else { throw RegexError.unbalanced }
return node
}
private mutating func parseAlternation() throws -> Node {
var branches = try [parseBranch()]
while peek() == "|" {
advance()
try branches.append(parseBranch())
}
return branches.count == 1 ? branches[0] : .alternate(branches)
}
private mutating func parseBranch() throws -> Node {
var pieces: [Node] = []
while let character = peek(), character != "|", character != ")" {
try pieces.append(parsePiece())
}
if pieces.isEmpty { return .empty }
return pieces.count == 1 ? pieces[0] : .concat(pieces)
}
private mutating func parsePiece() throws -> Node {
if let character = peek(), "?*+".contains(character) || character == "{" {
throw RegexError.danglingQuantifier
}
let atom = try parseAtom()
return try applyQuantifier(to: atom)
}
private mutating func applyQuantifier(to atom: Node) throws -> Node {
switch peek() {
case "?": advance()
return .repeated(atom, min: 0, max: 1)
case "*": advance()
return .repeated(atom, min: 0, max: nil)
case "+": advance()
return .repeated(atom, min: 1, max: nil)
case "{": return try parseBoundedQuantifier(atom)
default: return atom
}
}
private mutating func parseBoundedQuantifier(_ atom: Node) throws -> Node {
advance()
let minimum = parseInteger()
var maximum: Int? = minimum
if peek() == "," {
advance()
maximum = peek() == "}" ? nil : parseInteger()
}
guard peek() == "}", let minimum else { throw RegexError.badQuantifier }
advance()
return .repeated(atom, min: minimum, max: maximum)
}
private mutating func parseAtom() throws -> Node {
guard let character = peek() else { throw RegexError.danglingQuantifier }
switch character {
case "(":
advance()
let inner = try parseAlternation()
guard peek() == ")" else { throw RegexError.unbalanced }
advance()
return inner
case "[":
return try .characters(parseClass())
case ".":
advance()
return .characters(.named(.anyButLineBreak))
case "\\":
advance()
return try .characters(parseEscape())
default:
advance()
return .characters(.single(scalar(of: character)))
}
}
// MARK: Escapes
private mutating func parseEscape() throws -> CharClass {
guard let character = peek() else { throw RegexError.badEscape("") }
advance()
if let single = Self.singleEscape(character) {
return .single(single)
}
if let member = Self.classEscape(character) {
return classFrom(member)
}
throw RegexError.badEscape(String(character))
}
private func classFrom(_ member: ClassMember) -> CharClass {
switch member {
case let .scalar(value): .single(value)
case let .named(named): CharClass(named: [named])
case let .negatedNamed(named): CharClass(negatedNamed: [named])
}
}
private static func singleEscape(_ character: Character) -> Unicode.Scalar? {
switch character {
case "n": "\n"
case "r": "\r"
case "t": "\t"
case "\\", ".", "|", "-", "^", "?", "*", "+", "{", "}", "(", ")", "[", "]":
character.unicodeScalars.first
default: nil
}
}
private static let classEscapes: [Character: ClassMember] = [
"d": .named(.digit), "D": .negatedNamed(.digit),
"w": .named(.word), "W": .negatedNamed(.word),
"s": .named(.space), "S": .negatedNamed(.space),
"i": .named(.nameStart), "I": .negatedNamed(.nameStart),
"c": .named(.nameChar), "C": .negatedNamed(.nameChar),
]
private static func classEscape(_ character: Character) -> ClassMember? {
classEscapes[character]
}
// MARK: Character classes
private mutating func parseClass() throws -> CharClass {
advance()
var cls = CharClass()
if peek() == "^" {
advance()
cls.negated = true
}
while let character = peek(), character != "]" {
try parseClassMember(into: &cls)
}
guard peek() == "]" else { throw RegexError.badClass }
advance()
return cls
}
private mutating func parseClassMember(into cls: inout CharClass) throws {
let member = try nextClassMember()
guard case let .scalar(low) = member else {
add(member, to: &cls)
return
}
if peek() == "-", peek(1) != "]" {
advance()
guard case let .scalar(high) = try nextClassMember() else { throw RegexError.badClass }
cls.ranges.append(low ... high)
} else {
cls.ranges.append(low ... low)
}
}
private func add(_ member: ClassMember, to cls: inout CharClass) {
switch member {
case let .scalar(value): cls.ranges.append(value ... value)
case let .named(named): cls.named.append(named)
case let .negatedNamed(named): cls.negatedNamed.append(named)
}
}
private mutating func nextClassMember() throws -> ClassMember {
guard let character = peek() else { throw RegexError.badClass }
advance()
guard character == "\\" else { return .scalar(scalar(of: character)) }
guard let escaped = peek() else { throw RegexError.badClass }
advance()
if let single = Self.singleEscape(escaped) { return .scalar(single) }
if let member = Self.classEscape(escaped) { return member }
throw RegexError.badEscape(String(escaped))
}
// MARK: Cursor
private func scalar(of character: Character) -> Unicode.Scalar {
character.unicodeScalars.first ?? Unicode.Scalar(0)
}
private mutating func parseInteger() -> Int? {
var digits = ""
while let character = peek(), character.isNumber {
digits.append(character)
advance()
}
return Int(digits)
}
private var isAtEnd: Bool {
index >= chars.count
}
private func peek(_ ahead: Int = 0) -> Character? {
let target = index + ahead
return target < chars.count ? chars[target] : nil
}
private mutating func advance() {
if index < chars.count { index += 1 }
}
}
}

View File

@ -0,0 +1,87 @@
extension PureXML.Regex {
/// An error compiling a regular expression.
enum RegexError: Swift.Error, Equatable, Sendable, CustomStringConvertible {
case empty
case unbalanced
case danglingQuantifier
case badEscape(String)
case badClass
case badQuantifier
case unsupported(String)
var description: String {
switch self {
case .empty: "the pattern is empty"
case .unbalanced: "unbalanced parentheses"
case .danglingQuantifier: "a quantifier has nothing to repeat"
case let .badEscape(detail): "invalid escape '\\\(detail)'"
case .badClass: "malformed character class"
case .badQuantifier: "malformed quantifier"
case let .unsupported(detail): "unsupported construct: \(detail)"
}
}
}
/// A named character class (`\d`, `\w`, `\s`, `\i`, `\c`) or the `.` wildcard.
enum NamedClass: Equatable, Sendable {
case digit
case word
case space
case nameStart
case nameChar
case anyButLineBreak
func contains(_ scalar: Unicode.Scalar) -> Bool {
switch self {
case .digit: scalar.properties.numericType == .decimal
case .space: scalar == " " || scalar == "\t" || scalar == "\n" || scalar == "\r"
case .word: scalar.properties.isAlphabetic || scalar.properties.numericType == .decimal || scalar == "_"
case .nameStart: PureXML.Parsing.XMLCharacter.isNameStart(scalar)
case .nameChar: PureXML.Parsing.XMLCharacter.isNameChar(scalar)
case .anyButLineBreak: scalar != "\n" && scalar != "\r"
}
}
}
/// A character class: ranges, named sub-classes, and an optional negation.
struct CharClass: Equatable, Sendable {
var negated = false
var ranges: [ClosedRange<Unicode.Scalar>] = []
var named: [NamedClass] = []
var negatedNamed: [NamedClass] = []
func matches(_ character: Character) -> Bool {
guard character.unicodeScalars.count == 1, let scalar = character.unicodeScalars.first else {
return false
}
let hit = ranges.contains { $0.contains(scalar) }
|| named.contains { $0.contains(scalar) }
|| negatedNamed.contains { !$0.contains(scalar) }
return negated ? !hit : hit
}
static func single(_ scalar: Unicode.Scalar) -> CharClass {
CharClass(ranges: [scalar ... scalar])
}
static func named(_ named: NamedClass) -> CharClass {
CharClass(named: [named])
}
}
/// The parsed regular-expression tree. Groups are non-capturing; `?`, `*`,
/// `+`, and `{n,m}` all become ``repeated``.
indirect enum Node: Equatable, Sendable {
case empty
case characters(CharClass)
case concat([Node])
case alternate([Node])
case repeated(Node, min: Int, max: Int?)
}
}
extension Unicode.Scalar: @retroactive Comparable {
public static func < (lhs: Unicode.Scalar, rhs: Unicode.Scalar) -> Bool {
lhs.value < rhs.value
}
}

86
Tests/RegexTests.swift Normal file
View File

@ -0,0 +1,86 @@
@testable import PureXML
import Testing
@Suite("Regex")
struct RegexTests {
private func matches(_ pattern: String, _ string: String) throws -> Bool {
try PureXML.Regex.matches(pattern, string)
}
@Test("Literal patterns match the whole string")
func test_literal() throws {
#expect(try matches("abc", "abc"))
#expect(try !matches("abc", "abcd"))
#expect(try !matches("abc", "ab"))
}
@Test("The dot matches any character except line breaks")
func test_dot() throws {
#expect(try matches("a.c", "axc"))
#expect(try !matches("a.c", "a\nc"))
}
@Test("Quantifiers ? * + bound repetition")
func test_quantifiers() throws {
#expect(try matches("ab?c", "ac"))
#expect(try matches("ab?c", "abc"))
#expect(try matches("ab*c", "abbbc"))
#expect(try matches("ab+c", "abc"))
#expect(try !matches("ab+c", "ac"))
}
@Test("Bounded quantifiers {n}, {n,}, {n,m}")
func test_boundedQuantifiers() throws {
#expect(try matches("a{3}", "aaa"))
#expect(try !matches("a{3}", "aa"))
#expect(try matches("a{2,}", "aaaa"))
#expect(try matches("a{2,4}", "aaa"))
#expect(try !matches("a{2,4}", "aaaaa"))
}
@Test("Alternation and grouping")
func test_alternationGrouping() throws {
#expect(try matches("cat|dog", "dog"))
#expect(try matches("(ab)+", "ababab"))
#expect(try matches("gr(a|e)y", "grey"))
#expect(try !matches("gr(a|e)y", "groy"))
}
@Test("Character classes with ranges and negation")
func test_charClasses() throws {
#expect(try matches("[a-z]+", "hello"))
#expect(try !matches("[a-z]+", "Hello"))
#expect(try matches("[^0-9]+", "abc"))
#expect(try matches("[abc]{2}", "ca"))
}
@Test("Class escapes \\d \\w \\s and their negations")
func test_classEscapes() throws {
#expect(try matches("\\d{4}", "2026"))
#expect(try !matches("\\d{4}", "20x6"))
#expect(try matches("\\w+", "a_1"))
#expect(try matches("a\\sb", "a b"))
#expect(try matches("\\D+", "abc"))
}
@Test("Escaped metacharacters are literal")
func test_escapes() throws {
#expect(try matches("a\\.b", "a.b"))
#expect(try !matches("a\\.b", "axb"))
#expect(try matches("\\(\\)", "()"))
}
@Test("A realistic ISBN-like pattern")
func test_realistic() throws {
let pattern = "\\d{3}-\\d{1,5}-\\d{1,7}-\\d{1,6}-[0-9X]"
#expect(try matches(pattern, "978-0-13-468599-1"))
#expect(try !matches(pattern, "abc-0-13-468599-1"))
}
@Test("Malformed patterns are rejected")
func test_errors() {
#expect(throws: PureXML.Regex.RegexError.self) { _ = try PureXML.Regex.Pattern("a(b") }
#expect(throws: PureXML.Regex.RegexError.self) { _ = try PureXML.Regex.Pattern("*ab") }
#expect(throws: PureXML.Regex.RegexError.self) { _ = try PureXML.Regex.Pattern("") }
}
}