From 951b808f025110dbfae8d19f659598c5e9375ab1 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 8 Jun 2026 06:18:50 +0200 Subject: [PATCH] feat(regex): XML Schema regular-expression engine Add the PureXML.Regex namespace: a recursive-descent parser for the XSD regex grammar (literals, ., character classes with ranges and negation, the \d \w \s family and negations, single-char escapes, grouping, alternation, and the ?, *, +, {n}, {n,}, {n,m} quantifiers) compiled to a Thompson NFA. matches(_:) is whole-string as xs:pattern requires, and runs linear in the input with no backtracking blow-up. \p{...} category escapes are not yet supported. Closes #59 Closes #60 Closes #30 --- CHANGELOG.md | 7 + README.md | 2 +- Sources/PureXML.swift | 3 + Sources/Regex/RegexAutomaton.swift | 173 ++++++++++++++++++++++ Sources/Regex/RegexParser.swift | 228 +++++++++++++++++++++++++++++ Sources/Regex/RegexSyntax.swift | 87 +++++++++++ Tests/RegexTests.swift | 86 +++++++++++ 7 files changed, 585 insertions(+), 1 deletion(-) create mode 100644 Sources/Regex/RegexAutomaton.swift create mode 100644 Sources/Regex/RegexParser.swift create mode 100644 Sources/Regex/RegexSyntax.swift create mode 100644 Tests/RegexTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index b9f00a9..4eb1944 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index c04ac87..05dc594 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/Sources/PureXML.swift b/Sources/PureXML.swift index e3a9443..7783ff1 100644 --- a/Sources/PureXML.swift +++ b/Sources/PureXML.swift @@ -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 {} } diff --git a/Sources/Regex/RegexAutomaton.swift b/Sources/Regex/RegexAutomaton.swift new file mode 100644 index 0000000..486f483 --- /dev/null +++ b/Sources/Regex/RegexAutomaton.swift @@ -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 = [] + 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) -> Set { + 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) + } +} diff --git a/Sources/Regex/RegexParser.swift b/Sources/Regex/RegexParser.swift new file mode 100644 index 0000000..b4b6a3d --- /dev/null +++ b/Sources/Regex/RegexParser.swift @@ -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 } + } + } +} diff --git a/Sources/Regex/RegexSyntax.swift b/Sources/Regex/RegexSyntax.swift new file mode 100644 index 0000000..c51f02b --- /dev/null +++ b/Sources/Regex/RegexSyntax.swift @@ -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] = [] + 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 + } +} diff --git a/Tests/RegexTests.swift b/Tests/RegexTests.swift new file mode 100644 index 0000000..abf1dac --- /dev/null +++ b/Tests/RegexTests.swift @@ -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("") } + } +}