PureXML/Sources/Regex/RegexAutomaton.swift
Mihaela Mihaljevic be8a39394d fix(schema): advance XSTS valid-instances-rejected to 4 (#146)
Consolidates the in-progress #146 work; valid-instances-rejected reaches 4
on the 2006-11-06 XSTS archive (buckets now 1/262/4/147).

Regex: match Unicode scalars rather than grapheme clusters; the empty
pattern matches only the empty string; pin the category repertoire to
Unicode 3.2, the version the corpus is consistent at, so code points added
in 3.2 (Tagalog, Cyrillic Komi) are word characters while later additions
(the Tamil digit zero) stay unassigned and match \D. New XSDCategory and a
3.2 assignment table derived from DerivedAge, replacing the old Unicode31
files.

Content model: assess each child against the particle the content automaton
matched, not a by-name lookup (#180). Two same-named particles keep their
own fixed values, and an element matching a skip wildcard is no longer
validated against its global declaration. Selection is made reproducible
when an ambiguous schema slips past the UPA check.

XSTS runner: compile and merge every schemaDocument in a schemaTest, not
just the first; this also surfaced one previously-masked invalid-instance
acceptance (recorded in the baselines).

Also folds in attribute-group anyAttribute inheritance, extension wildcard
union, namespace-qualified type tables for multi-document
xsi:schemaLocation, per-container elementFormDefault, strict and lax
attribute wildcard validation, built-in type resolution, and xs:redefine
simpleType overrides.
2026-06-16 22:22:05 +02:00

204 lines
8.2 KiB
Swift

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). XSD regular expressions match a
/// sequence of Unicode code points, not Swift extended grapheme clusters.
func matchesWhole(_ string: String) -> Bool {
var current = closure([start])
for scalar in string.unicodeScalars {
var next: Set<Int> = []
for state in current {
if let transition = states[state].transition, transition.charClass.matches(scalar) {
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
}
/// `{n,m}` quantifiers unroll into automaton states. Mirroring the XSD
/// content-model builder (`Schema.ContentNFABuilder`), each repetition is
/// capped and the total state count is bounded: past the ceiling a
/// repetition degrades to star, the libxml2 posture. Without this an
/// `xs:pattern` such as `a{1000000000}` (the XSTS msMeta Regex set)
/// allocates until the process is OOM-killed (#129). Real patterns are
/// tens of states, so only pathological quantifiers reach the ceiling.
private static let repeatUnrollCap = 16384
private static let totalStateCap = 1 << 20
private var stateBudgetExhausted: Bool {
states.count >= Self.totalStateCap
}
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
let boundedMin = Swift.min(minimum, Self.repeatUnrollCap)
var unrolled = 0
for _ in 0 ..< boundedMin {
if stateBudgetExhausted { break }
let part = fragment(for: node)
states[current].epsilon.append(part.start)
current = part.accept
unrolled += 1
}
let accept = addState()
if stateBudgetExhausted || unrolled < boundedMin || minimum > Self.repeatUnrollCap {
// An absurd minimum, or a build that hit the total state ceiling
// mid-unroll, repeats freely (star) past the cap.
appendStar(node, from: current, to: accept)
} else if let maximum, maximum - boundedMin <= Self.repeatUnrollCap {
appendOptional(node, count: maximum - boundedMin, 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) {
if stateBudgetExhausted {
// Ran out of state budget: let the remainder repeat freely.
appendStar(node, from: current, to: accept)
return
}
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}`. Character
/// classes also support Unicode category and block escapes (`\p{L}`, `\P{Nd}`,
/// `\p{IsBasicLatin}`) and subtraction (`[a-z-[aeiou]]`). 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.
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)
}
}