Running the full W3C XSTS archive to completion exposed three crash and hang classes, now fixed with regression tests, which unblocked the #129 first measurement. - Content-model and regex NFA builders now cap total state count (2^20), degrading further occurrence/quantifier repetition to star, so nested high-maxOccurs schemas no longer OOM (was 8 GB on the MS Particles set). - Content-model validation advances one active state-set per child instead of re-walking the prefix each time, removing the O(children^2) spin. - Regex class subtraction [abc-[b]] no longer traps on a reversed range, and a reversed range such as [z-a] is rejected cleanly. XSTS first measurement baselined: 75 valid schemas rejected, 2467 invalid schemas accepted, 604 valid instances rejected, 582 invalid instances accepted across 14383 groups. The suite runs green in 8s, opt-in via XSTS_ROOT. Refs #129, #122.
203 lines
8.1 KiB
Swift
203 lines
8.1 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).
|
|
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
|
|
}
|
|
|
|
/// `{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)
|
|
}
|
|
}
|