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") }) + } +}