PureXML/Sources/Schema/ContentNFABuilder.swift
Mihaela Mihaljevic 380160cc2b refactor(validation): decompose XSD instance validation into composable rules
The two monolithic XSD content/identity rules only satisfied the OpenAPIKit
validation idiom at the wrong granularity: no per-constraint removability,
no isolable failing/near-miss tests. Split them into 32 named rules, each
finding tagged at its origin and cached per XSDContext so the walk still
runs once per document (the #101 decision against duplicating XSD's
recursive type resolution still holds). Wired the streaming path's ID/IDREF
and identity checks through the same framework instead of calling the
validators imperatively, and added the unwrapAndLookup combinator the rule
already named as required.

Also includes the mandated repo-wide swiftformat/swiftlint pass.
2026-07-12 14:12:17 +02:00

174 lines
6.7 KiB
Swift

extension PureXML.Schema {
struct ContentNFAFragment {
var start: Int
var accept: Int
var counters: [Int]
var nullable: Bool
}
/// Builds a counted ``ContentNFA`` from a particle.
struct ContentNFABuilder {
private var states: [ContentState] = []
private var counters: [ContentCounterScope] = []
static func build(_ particle: PureXML.Schema.Particle) -> ContentNFA {
var builder = ContentNFABuilder()
let fragment = builder.particle(particle)
return ContentNFA(
states: builder.states,
counters: builder.counters,
start: fragment.start,
accept: fragment.accept,
)
}
private mutating func addState() -> Int {
states.append(ContentState())
return states.count - 1
}
private mutating func addEpsilon(
from source: Int,
to target: Int,
guards: [ContentCounterGuard] = [],
actions: [ContentCounterAction] = [],
) {
states[source].epsilon.append(ContentEpsilonEdge(target: target, guards: guards, actions: actions))
}
private mutating func particle(_ particle: PureXML.Schema.Particle) -> ContentNFAFragment {
let start = addState()
let loop = addState()
let accept = addState()
let counter = counters.count
let bodyNullable = termIsNullable(particle.term)
counters.append(ContentCounterScope(range: particle.occurrenceRange, nullableBody: bodyNullable))
let body = term(particle.term)
let resetAll = [ContentCounterAction.reset(counter)] + body.counters.map(ContentCounterAction.reset)
addEpsilon(from: start, to: loop, actions: resetAll)
addEpsilon(
from: loop,
to: accept,
guards: [
.canExit(
counter: counter,
minimum: particle.occurrenceRange.minimum,
nullableBody: bodyNullable,
),
],
)
addEpsilon(
from: loop,
to: body.start,
guards: [.belowMaximum(counter: counter, maximum: particle.occurrenceRange.maximum)],
actions: body.counters.map(ContentCounterAction.reset),
)
addEpsilon(from: body.accept, to: loop, actions: [.increment(counter)])
return ContentNFAFragment(
start: start,
accept: accept,
counters: [counter] + body.counters,
nullable: particleIsNullable(particle),
)
}
private mutating func term(_ term: PureXML.Schema.Term) -> ContentNFAFragment {
switch term {
case let .element(name, type, _, constraint, _, _):
let fragment = labeled(.name(name))
states[fragment.start].elementType = type
states[fragment.start].valueConstraint = constraint
return fragment
case let .wildcard(wildcard):
return labeled(.wildcard(wildcard))
case let .group(group):
return self.group(group)
}
}
private mutating func labeled(_ label: TermLabel) -> ContentNFAFragment {
let start = addState()
let accept = addState()
states[start].label = label
states[start].target = accept
return ContentNFAFragment(start: start, accept: accept, counters: [], nullable: false)
}
private mutating func group(_ group: PureXML.Schema.Group) -> ContentNFAFragment {
// `all` is validated by counting, not in the NFA; treat it as a
// sequence here so a nested occurrence still parses.
group.compositor == .choice ? choice(group.particles) : sequence(group.particles)
}
private mutating func sequence(_ particles: [PureXML.Schema.Particle]) -> ContentNFAFragment {
guard !particles.isEmpty else { return labeledEmpty() }
var first: Int?
var last: Int?
var fragmentCounters: [Int] = []
var nullable = true
for member in particles {
let part = particle(member)
if let previous = last {
addEpsilon(from: previous, to: part.start)
} else {
first = part.start
}
last = part.accept
fragmentCounters.append(contentsOf: part.counters)
nullable = nullable && part.nullable
}
return ContentNFAFragment(
start: first ?? addState(),
accept: last ?? addState(),
counters: fragmentCounters,
nullable: nullable,
)
}
private mutating func choice(_ particles: [PureXML.Schema.Particle]) -> ContentNFAFragment {
let start = addState()
let accept = addState()
var fragmentCounters: [Int] = []
var nullable = false
for member in particles {
let part = particle(member)
addEpsilon(from: start, to: part.start)
addEpsilon(from: part.accept, to: accept)
fragmentCounters.append(contentsOf: part.counters)
nullable = nullable || part.nullable
}
return ContentNFAFragment(
start: start,
accept: accept,
counters: fragmentCounters,
nullable: nullable,
)
}
private mutating func labeledEmpty() -> ContentNFAFragment {
let start = addState()
let accept = addState()
addEpsilon(from: start, to: accept)
return ContentNFAFragment(start: start, accept: accept, counters: [], nullable: true)
}
private func particleIsNullable(_ particle: PureXML.Schema.Particle) -> Bool {
particle.occurrenceRange.minimum.isZero || termIsNullable(particle.term)
}
private func termIsNullable(_ term: PureXML.Schema.Term) -> Bool {
switch term {
case .element, .wildcard:
false
case let .group(group):
switch group.compositor {
case .sequence, .all:
group.particles.allSatisfy(particleIsNullable)
case .choice:
group.particles.contains(where: particleIsNullable)
}
}
}
}
}