From ea5387db90865b8b203c12148b3326fb78f21f6b Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Tue, 9 Jun 2026 00:55:17 +0200 Subject: [PATCH] feat(xsd): located, recovering content-model errors A content-model violation is now placed at the offending child with a recovery hint naming what was expected, instead of one opaque "content does not match the content model" per element. The sequence/choice automaton is walked child by child via its follow-set to flag the first child the model rejects, or the incomplete content at the end; an xs:all group recovers past each stray child and reports every missing required member. Well-placed children are still validated for their own content, so an editor sees every problem at once. Brings XSD to the same located, recovering standard as the RELAX NG, DTD, and Schematron validators. --- CHANGELOG.md | 9 +++ Sources/Schema/ComplexValidator.swift | 81 ++++++++++++++++++++++++--- Tests/XSDCategoryTests.swift | 6 +- Tests/XSDLocatedContentTests.swift | 60 ++++++++++++++++++++ 4 files changed, 146 insertions(+), 10 deletions(-) create mode 100644 Tests/XSDLocatedContentTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index a7fd3f7..cb3a9b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `, `content is incomplete; + expected `), 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 diff --git a/Sources/Schema/ComplexValidator.swift b/Sources/Schema/ComplexValidator.swift index 122e866..a11d867 100644 --- a/Sources/Schema/ComplexValidator.swift +++ b/Sources/Schema/ComplexValidator.swift @@ -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: ", "))" + } +} diff --git a/Tests/XSDCategoryTests.swift b/Tests/XSDCategoryTests.swift index 90ddefe..5b191c4 100644 --- a/Tests/XSDCategoryTests.swift +++ b/Tests/XSDCategoryTests.swift @@ -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 ") + #expect(failure?.codingPath.map(\.stringValue) == ["a", "c"]) } // MARK: Nillable and fixed diff --git a/Tests/XSDLocatedContentTests.swift b/Tests/XSDLocatedContentTests.swift new file mode 100644 index 0000000..275586f --- /dev/null +++ b/Tests/XSDLocatedContentTests.swift @@ -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 ") + #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 ") + #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") }) + } +}