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.
204 lines
8.2 KiB
Swift
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)
|
|
}
|
|
}
|