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.
110 lines
5.7 KiB
Swift
110 lines
5.7 KiB
Swift
/// Located content-model diagnostics: pinpoint which child breaks the model and
|
|
/// what was expected there, so an editor shows placed errors with recovery hints
|
|
/// rather than one opaque "content does not match" per element.
|
|
extension PureXML.Schema.ComplexValidator {
|
|
/// Walks the children through the content automaton, flagging the first child
|
|
/// the follow-set rejects, or the missing content when the sequence ends early.
|
|
func sequenceStructureErrors(_ particle: PureXML.Schema.Particle, children: [PureXML.Model.Element], at path: XSDPath, into errors: inout [XSDFinding]) {
|
|
let matcher = PureXML.Schema.ContentRTNMatcher(PureXML.Schema.ContentRTNBuilder.build(particle))
|
|
let steps = Self.childSteps(children)
|
|
// Advance one active state-set across the children rather than re-walking
|
|
// the prefix per child (which is quadratic over the content model, #129).
|
|
let inputLength = children.count
|
|
var current = matcher.startStates(inputLength: inputLength)
|
|
for (index, child) in children.enumerated() {
|
|
guard let next = matcher.step(current, over: child.name) else {
|
|
let allowed = matcher.admissible(from: current)
|
|
errors.append(XSDFinding(
|
|
.contentModelStructure,
|
|
reason: "element '\(child.name.localName)' is not allowed here\(Self.expectation(allowed))",
|
|
at: path + [steps[index]],
|
|
))
|
|
return
|
|
}
|
|
current = next
|
|
}
|
|
if !matcher.isComplete(current) {
|
|
errors.append(XSDFinding(.contentModelStructure, reason: "content is incomplete\(Self.expectation(matcher.admissible(from: current)))", at: path))
|
|
}
|
|
}
|
|
|
|
/// Locates `all`-group violations: each child that is not an in-bounds member,
|
|
/// recovering past it, then each required member that never appeared.
|
|
func allStructureErrors(_ group: PureXML.Schema.Group, children: [PureXML.Model.Element], groupOptional: Bool = false, at path: XSDPath, into errors: inout [XSDFinding]) {
|
|
var counts = [Int](repeating: 0, count: group.particles.count)
|
|
let steps = Self.childSteps(children)
|
|
for (index, child) in children.enumerated() {
|
|
guard let position = group.particles.indices.first(where: { slot in
|
|
let member = group.particles[slot]
|
|
let room = member.maxOccurs.map { counts[slot] < $0 } ?? true
|
|
return room && Self.memberMatches(member.term, child.name)
|
|
}) else {
|
|
errors.append(XSDFinding(.contentModelStructure, reason: "element '\(child.name.localName)' is not allowed here", at: path + [steps[index]]))
|
|
continue
|
|
}
|
|
counts[position] += 1
|
|
}
|
|
// An optional `all` group (`minOccurs="0"`) that contributes no children is
|
|
// absent (zero occurrences), so its required members are not expected; but
|
|
// once any child appears the group is present once and every member's own
|
|
// `minOccurs` applies. A required group (`minOccurs="1"`) always enforces.
|
|
if groupOptional, children.isEmpty {
|
|
return
|
|
}
|
|
for (index, member) in group.particles.enumerated() where counts[index] < member.minOccurs {
|
|
if case let .element(name, _, _, _, _, _) = member.term {
|
|
errors.append(XSDFinding(.contentModelStructure, reason: "element '\(name.localName)' is required but missing", at: path))
|
|
}
|
|
}
|
|
}
|
|
|
|
static func memberMatches(_ term: PureXML.Schema.Term, _ name: PureXML.Model.QualifiedName) -> Bool {
|
|
switch term {
|
|
case let .element(declared, _, _, _, _, _): declared.localName == name.localName && declared.namespaceURI == name.namespaceURI
|
|
case let .wildcard(wildcard): wildcard.admits(name)
|
|
case .group: false
|
|
}
|
|
}
|
|
|
|
/// The particle each child of an `all` group matched, for per-child
|
|
/// assessment. `all` is matched by counting, not by the automaton, so it has
|
|
/// its own pass mirroring ``allStructureErrors``.
|
|
func allMatchedParticles(_ group: PureXML.Schema.Group, children: [PureXML.Model.Element]) -> [PureXML.Schema.MatchedParticle?] {
|
|
var counts = [Int](repeating: 0, count: group.particles.count)
|
|
var result: [PureXML.Schema.MatchedParticle?] = []
|
|
for child in children {
|
|
guard let slot = group.particles.indices.first(where: { index in
|
|
let member = group.particles[index]
|
|
let room = member.maxOccurs.map { counts[index] < $0 } ?? true
|
|
return room && Self.memberMatches(member.term, child.name)
|
|
}) else {
|
|
result.append(nil)
|
|
continue
|
|
}
|
|
counts[slot] += 1
|
|
result.append(Self.matchedParticle(of: group.particles[slot].term))
|
|
}
|
|
return result
|
|
}
|
|
|
|
static func matchedParticle(of term: PureXML.Schema.Term) -> PureXML.Schema.MatchedParticle? {
|
|
switch term {
|
|
case let .element(_, type, _, valueConstraint, _, _): .element(type: type, valueConstraint: valueConstraint)
|
|
case let .wildcard(wildcard): .wildcard(wildcard)
|
|
case .group: nil
|
|
}
|
|
}
|
|
|
|
/// A "; expected a, b" hint naming the elements the automaton accepts next.
|
|
static func expectation(_ labels: [PureXML.Schema.TermLabel]) -> String {
|
|
let names = labels.compactMap { label -> String? in
|
|
if case let .name(qualified) = label {
|
|
return "<\(qualified.localName)>"
|
|
}
|
|
return nil
|
|
}
|
|
let unique = Set(names).sorted()
|
|
return unique.isEmpty ? "" : "; expected \(unique.joined(separator: ", "))"
|
|
}
|
|
}
|