Merge: XSD located content-model errors

This commit is contained in:
Mihaela Mihaljevic 2026-06-09 00:55:18 +02:00
commit 489ff498fa
4 changed files with 146 additions and 10 deletions

View File

@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- XSD located content-model errors. A content-model violation is now placed at
the offending child with a recovery hint naming what was expected there
(`element 'x' is not allowed here; expected <b>`, `content is incomplete;
expected <b>`), instead of one opaque "content does not match the content
model" per element. An `xs:all` group recovers past each stray child and
reports every missing required member, and well-placed children are still
validated for their own content, so an editor sees every problem at once. This
brings XSD content validation to the same located, recovering standard as the
RELAX NG, DTD, and Schematron validators.
- RELAX NG located, recovering validation errors (#79). Alongside the boolean
`validate(_:)`, `RelaxNG.errors(in:)` now reports every way a document fails the
schema as individual located errors (each with a coding path), recovering past

View File

@ -166,15 +166,13 @@ public extension PureXML.Schema {
at path: XSDPath,
into errors: inout [XSDFailure],
) {
let names = children.map(\.name)
let structureValid: Bool = if case let .group(group) = particle.term, group.compositor == .all {
Self.matchesAll(group, names: names)
// Locate each content-model violation at the offending child (or the
// missing one), with a recovery hint, rather than one opaque failure,
// then still validate every well-placed child's own content.
if case let .group(group) = particle.term, group.compositor == .all {
allStructureErrors(group, children: children, at: path, into: &errors)
} else {
ContentNFABuilder.build(particle).matchesWhole(names)
}
if !structureValid {
errors.append(XSDFailure(reason: "content does not match the content model", at: path))
return
sequenceStructureErrors(particle, children: children, at: path, into: &errors)
}
validateChildren(
children,
@ -277,3 +275,70 @@ public extension PureXML.Schema {
}
}
}
/// 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.
private 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 [XSDFailure]) {
let nfa = PureXML.Schema.ContentNFABuilder.build(particle)
let steps = Self.childSteps(children)
var prefix: [PureXML.Model.QualifiedName] = []
for (index, child) in children.enumerated() {
let allowed = nfa.follow(after: prefix).allowed
if allowed.contains(where: { $0.matches(child.name) }) {
prefix.append(child.name)
} else {
errors.append(XSDFailure(reason: "element '\(child.name.localName)' is not allowed here\(Self.expectation(allowed))", at: path + [steps[index]]))
return
}
}
let (allowed, complete) = nfa.follow(after: prefix)
if !complete {
errors.append(XSDFailure(reason: "content is incomplete\(Self.expectation(allowed))", 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], at path: XSDPath, into errors: inout [XSDFailure]) {
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(XSDFailure(reason: "element '\(child.name.localName)' is not allowed here", at: path + [steps[index]]))
continue
}
counts[position] += 1
}
for (index, member) in group.particles.enumerated() where counts[index] < member.minOccurs {
if case let .element(name, _) = member.term {
errors.append(XSDFailure(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
}
}
/// 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: ", "))"
}
}

View File

@ -65,14 +65,16 @@ struct XSDCategoryTests {
#expect(!validate(element("a", children: [.text("x")]), type).isEmpty)
}
@Test("Content: a child outside the content model is a model mismatch")
@Test("Content: a child outside the content model is located with an expected hint")
func test_contentModelMismatch() {
let particle = Schema.Particle(term: .group(Schema.Group(compositor: .sequence, particles: [
Schema.Particle(term: .element(name: Name("b"), type: nil)),
])))
let type = Schema.ComplexType(content: .elementOnly(particle))
#expect(validate(element("a", children: [.element(element("b"))]), type).isEmpty)
#expect(validate(element("a", children: [.element(element("c"))]), type).first?.reason == "content does not match the content model")
let failure = validate(element("a", children: [.element(element("c"))]), type).first
#expect(failure?.reason == "element 'c' is not allowed here; expected <b>")
#expect(failure?.codingPath.map(\.stringValue) == ["a", "c"])
}
// MARK: Nillable and fixed

View File

@ -0,0 +1,60 @@
@testable import PureXML
import Testing
@Suite("XSD located content-model errors")
struct XSDLocatedContentTests {
private typealias Schema = PureXML.Schema
private typealias Name = PureXML.Model.QualifiedName
private func element(_ name: String, children: [PureXML.Model.Node] = []) -> PureXML.Model.Element {
PureXML.Model.Element(name: Name(localName: name), attributes: [], children: children)
}
private func validate(_ element: PureXML.Model.Element, _ type: Schema.ComplexType) -> [PureXML.Validation.ValidationError] {
Schema.ComplexValidator().validate(element, against: type)
}
private func sequence(_ names: [String]) -> Schema.ComplexType {
let particles = names.map { Schema.Particle(term: .element(name: Name(localName: $0), type: nil)) }
return Schema.ComplexType(content: .elementOnly(Schema.Particle(term: .group(Schema.Group(compositor: .sequence, particles: particles)))))
}
@Test("An unexpected child is located with the expected element named")
func test_unexpectedLocated() {
let type = sequence(["a", "b"])
let bad = element("root", children: [.element(element("a")), .element(element("x"))])
let failure = validate(bad, type).first
#expect(failure?.reason == "element 'x' is not allowed here; expected <b>")
#expect(failure?.codingPath.map(\.stringValue) == ["x"])
}
@Test("Missing required content reports what was expected, at the element")
func test_incompleteLocated() {
let type = sequence(["a", "b"])
let bad = element("root", children: [.element(element("a"))])
let failure = validate(bad, type).first
#expect(failure?.reason == "content is incomplete; expected <b>")
#expect(failure?.codingPath.isEmpty == true)
}
@Test("A valid sequence produces no content errors")
func test_valid() {
let type = sequence(["a", "b"])
let good = element("root", children: [.element(element("a")), .element(element("b"))])
#expect(validate(good, type).isEmpty)
}
@Test("An xs:all group reports every stray child and every missing member (recovery)")
func test_allRecovery() {
let members = ["a", "b"].map {
Schema.Particle(minOccurs: 1, maxOccurs: 1, term: .element(name: Name(localName: $0), type: nil))
}
let type = Schema.ComplexType(content: .elementOnly(Schema.Particle(term: .group(Schema.Group(compositor: .all, particles: members)))))
// Two stray children, and member b never appears: three located problems.
let bad = element("root", children: [.element(element("x")), .element(element("y")), .element(element("a"))])
let reasons = validate(bad, type).map(\.reason)
#expect(reasons.contains { $0.contains("'x'") && $0.contains("not allowed") })
#expect(reasons.contains { $0.contains("'y'") && $0.contains("not allowed") })
#expect(reasons.contains { $0.contains("'b'") && $0.contains("required but missing") })
}
}