PureXML/Sources/Schema/ContentMatcher.swift

251 lines
11 KiB
Swift

extension PureXML.Schema {
/// A transition label in the content-model automaton: a specific expanded
/// element name, or a wildcard.
enum TermLabel: Sendable {
case name(PureXML.Model.QualifiedName)
case wildcard(Wildcard)
func matches(_ actual: PureXML.Model.QualifiedName) -> Bool {
switch self {
case let .wildcard(wildcard): wildcard.admits(actual)
case let .name(declared): declared.localName == actual.localName
&& declared.namespaceURI == actual.namespaceURI
}
}
}
/// A guard on a counted epsilon transition.
enum ContentCounterGuard: Sendable {
case canExit(counter: Int, minimum: NonNegativeDecimal, nullableBody: Bool)
case belowMaximum(counter: Int, maximum: OccurrenceUpper)
func accepts(_ values: [Int]) -> Bool {
switch self {
case let .canExit(counter, minimum, nullableBody):
nullableBody || minimum.isLessThanOrEqual(to: values[counter])
case let .belowMaximum(counter, maximum):
maximum.isGreaterThan(values[counter])
}
}
}
/// An action on a counted epsilon transition.
enum ContentCounterAction: Sendable {
case reset(Int)
case increment(Int)
func apply(to values: inout [Int], limit: Int) {
switch self {
case let .reset(counter):
values[counter] = 0
case let .increment(counter):
if values[counter] < limit {
values[counter] += 1
}
}
}
}
/// One epsilon transition in the counted content-model automaton.
struct ContentEpsilonEdge: Sendable {
var target: Int
var guards: [ContentCounterGuard] = []
var actions: [ContentCounterAction] = []
}
/// One occurrence counter in the counted content-model automaton.
struct ContentCounterScope: Sendable {
var range: OccurrenceRange
var nullableBody: Bool
}
/// One state of the counted content-model automaton.
struct ContentState: Sendable {
var epsilon: [ContentEpsilonEdge] = []
var label: TermLabel?
var target: Int?
/// The declared type and value constraint of the element particle this
/// state's `.name` transition stands for, carried so that whoever the
/// automaton matches a child to can be assessed against that exact
/// particle (its type, its `fixed`/`default`), not a by-name lookup that
/// cannot tell two same-named particles apart.
var elementType: ElementType?
var valueConstraint: ValueConstraint?
}
/// The content-model particle a child was matched to, for per-child
/// assessment: a named element (with the particle's declared type and value
/// constraint) or a wildcard (carrying its `processContents`).
enum MatchedParticle: Sendable {
case element(type: ElementType?, valueConstraint: ValueConstraint?)
case wildcard(Wildcard)
}
/// One active configuration in the counted content-model automaton.
struct ContentConfiguration: Sendable, Hashable {
var state: Int
var counters: [Int]
}
/// A counted automaton over element names compiled from a ``Particle``. `all`
/// groups are validated separately by ``ContentMatcher`` and are not represented
/// here.
struct ContentNFA: Sendable {
var states: [ContentState]
var counters: [ContentCounterScope]
var start: Int
var accept: Int
func matchesWhole(_ names: [PureXML.Model.QualifiedName]) -> Bool {
let inputLength = names.count
var current = startStates(inputLength: inputLength)
for name in names {
guard let next = step(current, over: name, inputLength: inputLength) else { return false }
current = next
}
return isComplete(current)
}
/// After consuming `names`, the labels the automaton can accept next and
/// whether the content may legally end here. The follow-set: the exact set
/// of element names allowed at this point, for completions and for telling
/// whether something is still required. Returns an empty, not-complete
/// result when the prefix is already invalid.
func follow(after names: [PureXML.Model.QualifiedName]) -> (allowed: [TermLabel], complete: Bool) {
let inputLength = names.count
var current = startStates(inputLength: inputLength)
for name in names {
guard let next = step(current, over: name, inputLength: inputLength) else { return ([], false) }
current = next
}
return (admissible(from: current), isComplete(current))
}
// MARK: Incremental matching
//
// The active set is the epsilon-closure of the configurations reachable after some
// prefix of children. Callers that scan a child sequence advance it one
// child at a time (`startStates` then `step` per child) rather than calling
// `follow(after:)` on a growing prefix, which re-walks the whole prefix each
// time and is quadratic in the child count over a content model (#129).
/// The active set before any child: the closure of the start state.
func startStates(inputLength: Int = 0) -> Set<ContentConfiguration> {
let seed = ContentConfiguration(state: start, counters: Array(repeating: 0, count: counters.count))
return closure([seed], counterLimit: counterLimit(for: inputLength))
}
/// The labels admissible from `current`, for diagnostics and completions.
func admissible(from current: Set<ContentConfiguration>) -> [TermLabel] {
var labels: [TermLabel] = []
for configuration in current where states[configuration.state].target != nil {
if let label = states[configuration.state].label { labels.append(label) }
}
return labels
}
/// The particle each child matched, in order, for per-child assessment.
/// A unique-particle-attribution-valid content model is deterministic, so
/// at each position at most one particle accepts the child; the entry is
/// nil from the first child the model rejects onward (a structure error,
/// reported separately), since the match path is then undefined.
///
/// When more than one active state accepts the child (only possible if the
/// compile-time UPA check missed an ambiguity), selection is still made
/// reproducible rather than left to `Set` iteration order: a named particle
/// is preferred over a wildcard, then the lowest state index wins.
func matchedParticles(_ names: [PureXML.Model.QualifiedName]) -> [MatchedParticle?] {
let inputLength = names.count
var current = startStates(inputLength: inputLength)
var result: [MatchedParticle?] = []
for name in names {
var next: Set<ContentConfiguration> = []
var namedMatch: ContentConfiguration?
var namedState: Int?
var anyMatch: ContentConfiguration?
var anyState: Int?
for configuration in current {
let state = configuration.state
guard let label = states[state].label, let target = states[state].target, label.matches(name) else { continue }
next.insert(ContentConfiguration(state: target, counters: configuration.counters))
if anyState.map({ state < $0 }) ?? true {
anyState = state
anyMatch = configuration
}
if case .name = label, namedState.map({ state < $0 }) ?? true {
namedState = state
namedMatch = configuration
}
}
guard let chosenState = namedState ?? anyState, (namedMatch ?? anyMatch) != nil, !next.isEmpty else {
result.append(contentsOf: Array(repeating: nil, count: names.count - result.count))
return result
}
let matched = states[chosenState]
if case let .wildcard(wildcard) = matched.label {
result.append(.wildcard(wildcard))
} else {
result.append(.element(type: matched.elementType, valueConstraint: matched.valueConstraint))
}
current = closure(next, counterLimit: counterLimit(for: inputLength))
}
return result
}
/// Advances `current` by consuming one element `name`, or nil when `name`
/// is not admissible there.
func step(_ current: Set<ContentConfiguration>, over name: PureXML.Model.QualifiedName, inputLength: Int = 0) -> Set<ContentConfiguration>? {
var next: Set<ContentConfiguration> = []
for configuration in current {
let state = configuration.state
if let label = states[state].label, let target = states[state].target, label.matches(name) {
next.insert(ContentConfiguration(state: target, counters: configuration.counters))
}
}
if next.isEmpty { return nil }
return closure(next, counterLimit: counterLimit(for: inputLength))
}
/// Whether the content may legally end at `current`.
func isComplete(_ current: Set<ContentConfiguration>) -> Bool {
current.contains { $0.state == accept }
}
private func closure(_ seed: Set<ContentConfiguration>, counterLimit: Int) -> Set<ContentConfiguration> {
var seen = seed
var stack = Array(seed)
while let configuration = stack.popLast() {
for edge in states[configuration.state].epsilon {
guard let next = apply(edge, to: configuration, counterLimit: counterLimit),
seen.insert(next).inserted
else {
continue
}
stack.append(next)
}
}
return seen
}
private func apply(
_ edge: ContentEpsilonEdge,
to configuration: ContentConfiguration,
counterLimit: Int,
) -> ContentConfiguration? {
for guardCondition in edge.guards where !guardCondition.accepts(configuration.counters) {
return nil
}
var values = configuration.counters
for action in edge.actions {
action.apply(to: &values, limit: counterLimit)
}
return ContentConfiguration(state: edge.target, counters: values)
}
private func counterLimit(for inputLength: Int) -> Int {
inputLength == Int.max ? Int.max : inputLength + 1
}
}
}