Merge: XSD located content-model errors
This commit is contained in:
commit
489ff498fa
@ -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
|
||||
|
||||
@ -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: ", "))"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
60
Tests/XSDLocatedContentTests.swift
Normal file
60
Tests/XSDLocatedContentTests.swift
Normal 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") })
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user