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.
115 lines
4.6 KiB
Swift
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)
|
|
}
|
|
}
|