PureXML/Sources/Schema/ContentRTN.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

234 lines
11 KiB
Swift

extension PureXML.Schema {
/// A call edge: enter the callee sub-program at `calleeStart`, returning to
/// `returnTo` when the callee's return state is reached. The `actions` reset the
/// callee's counter scopes on entry (so a shared fragment entered twice does not
/// carry a stale count); `guards` are the calling occurrence's `belowMaximum`.
struct ContentCallEdge: Sendable {
var calleeStart: Int
var returnTo: Int
var guards: [ContentCounterGuard] = []
var actions: [ContentCounterAction] = []
}
/// One state of the counted Recursive Transition Network: like ``ContentState``
/// but a state may also `call` a shared group fragment, and a group fragment's
/// accept is a `return` (it pops the graph-structured stack to its caller).
struct ContentRTNState: Sendable {
var epsilon: [ContentEpsilonEdge] = []
var calls: [ContentCallEdge] = []
/// A group fragment's accept: pop the GSS and continue at the caller's
/// `returnTo`. The top-level program accept is NOT a return.
var isReturn: Bool = false
var label: TermLabel?
var target: Int?
var elementType: ElementType?
var valueConstraint: ValueConstraint?
}
/// A counted RTN compiled from a ``Particle``: each `<xs:group>` definition
/// (identified by ``Group/definitionID``) is compiled once and reached by a
/// `call`, so a multiply-referenced group is shared, not inlined `2^K` times.
/// `all` groups are validated separately and are not represented here, exactly
/// as in ``ContentNFA``.
struct ContentRTN: Sendable {
var states: [ContentRTNState]
var counters: [ContentCounterScope]
var start: Int
var accept: Int
}
}
extension PureXML.Schema {
/// Builds a counted ``ContentRTN`` from a particle, sharing each `definitionID`
/// group fragment. The occurrence-wrapper shape (reset/loop/exit/increment)
/// matches ``ContentNFABuilder`` exactly; the only difference is that a group
/// with a `definitionID` compiles to a `call` into its shared fragment instead
/// of being inlined.
struct ContentRTNBuilder {
private var states: [ContentRTNState] = []
private var counters: [ContentCounterScope] = []
/// The compiled (start, accept, counters) of each shared group fragment.
private var fragments: [Int: ContentNFAFragment] = [:]
/// Definition ids whose fragment is mid-compilation, to detect a (malformed)
/// cyclic group definition instead of recurring without bound.
private var compiling: Set<Int> = []
static func build(_ particle: PureXML.Schema.Particle) -> ContentRTN {
var builder = ContentRTNBuilder()
let fragment = builder.particle(particle)
return ContentRTN(
states: builder.states,
counters: builder.counters,
start: fragment.start,
accept: fragment.accept,
)
}
private mutating func addState() -> Int {
states.append(ContentRTNState())
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):
if let id = group.definitionID {
return call(group, id: id)
}
return self.group(group)
}
}
/// A reference to a shared group: a `call` state into the group's fragment
/// (compiled once), returning to a fresh accept. The callee's counters are
/// deliberately NOT propagated to the caller: every counter scope is reset on
/// its own particle's entry edge, which is on the path from the callee's start,
/// so the shared fragment resets itself on every (re-)entry without the caller
/// re-listing it. Propagating them would re-list each shared fragment's counters
/// at every reference, nesting to a `2^K` reset-action list even though the
/// states stay `O(K)`, which is the whole point of sharing.
private mutating func call(_ group: PureXML.Schema.Group, id: Int) -> ContentNFAFragment {
let fragment = groupFragment(group, id: id)
let callState = addState()
let returnState = addState()
states[callState].calls.append(ContentCallEdge(calleeStart: fragment.start, returnTo: returnState))
return ContentNFAFragment(start: callState, accept: returnState, counters: [], nullable: fragment.nullable)
}
/// The shared fragment for a group definition, compiled once and memoized.
/// Its accept is marked `isReturn`.
private mutating func groupFragment(_ group: PureXML.Schema.Group, id: Int) -> ContentNFAFragment {
if let cached = fragments[id] {
return cached
}
// `definitionID` groups are acyclic: XSD forbids a group referencing itself
// except through an element, and the parser's expansion breaks every cycle at
// an element boundary, so a fragment is never referenced during its own
// compilation (the inlining `ContentNFABuilder` relies on the same property).
// Guard it anyway: a cyclic id would otherwise recur without bound, the exact
// interactive-safety hazard this matcher exists to remove, so bound the
// recursion by degrading a (malformed, unreachable) cycle to a terminating
// empty fragment rather than overflowing the stack.
guard compiling.insert(id).inserted else { return labeledEmpty() }
defer { compiling.remove(id) }
let built = group.compositor == .choice ? choice(group.particles) : sequence(group.particles)
states[built.accept].isReturn = true
fragments[id] = built
return built
}
private mutating func group(_ group: PureXML.Schema.Group) -> ContentNFAFragment {
group.compositor == .choice ? choice(group.particles) : sequence(group.particles)
}
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 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)
}
}
}
}
}