PureXML/Tests/ContentModelOracle.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

115 lines
4.6 KiB
Swift

@testable import PureXML
/// An independent, brute-force content-model membership oracle over element names,
/// deliberately implemented separately from PureXML's matcher so the instance
/// differential can adjudicate a disagreement with libxml2: libxml2's content
/// validation has leniency bugs (it accepts some invalid nested/nullable/unbounded
/// models), so "PureXML rejects, libxml2 accepts" is not by itself a PureXML fault.
/// The generated instances are empty `xs:string` elements, so an instance is valid
/// iff its element-name sequence is in this language; this oracle is that decision,
/// verified against an external regex engine on the known cases.
indirect enum ContentModel: Sendable {
case element(String)
case any
case sequence([ContentModel])
case choice([ContentModel])
/// An `xs:all` group; each member is its own `occurs(element, min, 1)`.
case all([ContentModel])
case occurs(ContentModel, Int, Int?)
}
enum ContentModelOracle {
/// Whether the model can match the empty sequence.
static func nullable(_ model: ContentModel) -> Bool {
switch model {
case .element, .any: false
case let .sequence(parts): parts.allSatisfy(nullable)
case let .choice(parts): parts.contains(where: nullable)
case let .all(parts): parts.allSatisfy(nullable)
case let .occurs(sub, low, _): low == 0 || nullable(sub)
}
}
/// Every position reachable after matching `model` starting at `start`.
static func ends(_ model: ContentModel, _ tokens: [String], _ start: Int) -> Set<Int> {
switch model {
case let .element(name):
return start < tokens.count && tokens[start] == name ? [start + 1] : []
case .any:
return start < tokens.count ? [start + 1] : []
case let .sequence(parts):
var positions: Set<Int> = [start]
for part in parts {
var next: Set<Int> = []
for position in positions {
next.formUnion(ends(part, tokens, position))
}
positions = next
if positions.isEmpty {
break
}
}
return positions
case let .choice(parts):
var out: Set<Int> = []
for part in parts {
out.formUnion(ends(part, tokens, start))
}
return out
case let .all(parts):
return allEnds(parts, Array(repeating: false, count: parts.count), tokens, start)
case let .occurs(sub, low, high):
return occursEnds(sub, low, high, tokens, start)
}
}
/// Positions reachable by matching `sub` between `low` and `high` times. A
/// nullable `sub` is bounded by stopping once the reachable set stops growing.
private static func occursEnds(_ sub: ContentModel, _ low: Int, _ high: Int?, _ tokens: [String], _ start: Int) -> Set<Int> {
var byReps: [Set<Int>] = [[start]]
let cap = min(high ?? Int.max, tokens.count + low + 2)
var reps = 0
while reps < cap {
var next: Set<Int> = []
for position in byReps[reps] {
next.formUnion(ends(sub, tokens, position))
}
byReps.append(next)
reps += 1
let priorPositions = byReps[0 ..< reps].reduce(into: Set<Int>()) { $0.formUnion($1) }
if next.isSubset(of: priorPositions), reps >= low {
break
}
}
var out: Set<Int> = []
if low == 0 {
out.insert(start)
}
for count in low ... cap where count < byReps.count {
out.formUnion(byReps[count])
}
return out
}
/// Positions reachable by matching the remaining (not-`done`) members of an
/// `xs:all` group in any order; a member that is nullable may be skipped.
private static func allEnds(_ members: [ContentModel], _ done: [Bool], _ tokens: [String], _ start: Int) -> Set<Int> {
var out: Set<Int> = []
if members.indices.allSatisfy({ done[$0] || nullable(members[$0]) }) {
out.insert(start)
}
for index in members.indices where !done[index] {
for end in ends(members[index], tokens, start) where end > start {
var next = done
next[index] = true
out.formUnion(allEnds(members, next, tokens, end))
}
}
return out
}
static func matches(_ model: ContentModel, _ tokens: [String]) -> Bool {
ends(model, tokens, 0).contains(tokens.count)
}
}