From ae271d65f6308bfa504d7f9fa6f5fcd54108407b Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Tue, 9 Jun 2026 00:40:56 +0200 Subject: [PATCH] feat(relaxng): located, recovering validation errors for editor use Adds RelaxNG.errors(in:) reporting every way a document fails the schema as individual located errors with coding paths, recovering past each failure so all of a faulty document's problems surface at once instead of only the first. Errors carry recovery hints naming the element types accepted at the failure point, and distinguish unexpected elements, invalid or missing attributes, invalid text/datatype content, and missing required content. RelaxNG.validation() exposes the schema as a Validation, composing with the same framework as the XSD, DTD, and Schematron rules. The boolean derivative engine remains the authority on validity; the diagnostic walk only places and explains the failures, recovering each step so it never cascades. Splits the engine and the diagnostics into separate files. --- CHANGELOG.md | 11 ++ Sources/Schema/RelaxNGDerivative.swift | 26 ++-- Sources/Schema/RelaxNGDiagnostics.swift | 163 ++++++++++++++++++++++++ Sources/Schema/RelaxNGSchema.swift | 33 +++++ Tests/RelaxNGLocatedErrorTests.swift | 111 ++++++++++++++++ 5 files changed, 335 insertions(+), 9 deletions(-) create mode 100644 Sources/Schema/RelaxNGDiagnostics.swift create mode 100644 Tests/RelaxNGLocatedErrorTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index e37c089..a7fd3f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- 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 + each failure so an editor can surface all of a faulty document's problems at + once rather than only the first. Errors carry recovery hints (`expected , + `) naming the element types accepted at the failure point, and distinguish + unexpected elements, invalid or missing attributes, invalid text/datatype + content, and missing required content. `RelaxNG.validation()` exposes the schema + as a `Validation`, so RELAX NG composes with the same validation + framework as the XSD, DTD, and Schematron rules. The boolean engine remains the + authority on validity; the walk only places and explains the failures. - XML catalog group-level `prefer` (#86). A `prefer` attribute on a `group` (or a nested `catalog`) now overrides the catalog-wide preference for the `public` entries inside it, inherited through nested groups. Resolution of an external diff --git a/Sources/Schema/RelaxNGDerivative.swift b/Sources/Schema/RelaxNGDerivative.swift index b51271f..29d3100 100644 --- a/Sources/Schema/RelaxNGDerivative.swift +++ b/Sources/Schema/RelaxNGDerivative.swift @@ -13,7 +13,7 @@ extension PureXML.Schema { // MARK: Simplifying constructors - private func choice(_ lhs: PureXML.Schema.Pattern, _ rhs: PureXML.Schema.Pattern) -> PureXML.Schema.Pattern { + func choice(_ lhs: PureXML.Schema.Pattern, _ rhs: PureXML.Schema.Pattern) -> PureXML.Schema.Pattern { if case .notAllowed = lhs { return rhs } if case .notAllowed = rhs { return lhs } return .choice(lhs, rhs) @@ -41,7 +41,7 @@ extension PureXML.Schema { return .after(lhs, rhs) } - private func resolve(_ name: String) -> PureXML.Schema.Pattern { + func resolve(_ name: String) -> PureXML.Schema.Pattern { defines[name] ?? .notAllowed } @@ -64,7 +64,7 @@ extension PureXML.Schema { // MARK: Text and attribute derivatives - private func textDeriv(_ pattern: PureXML.Schema.Pattern, _ string: String) -> PureXML.Schema.Pattern { + func textDeriv(_ pattern: PureXML.Schema.Pattern, _ string: String) -> PureXML.Schema.Pattern { switch pattern { case let .choice(lhs, rhs): return choice(textDeriv(lhs, string), textDeriv(rhs, string)) case let .interleave(lhs, rhs): @@ -99,7 +99,7 @@ extension PureXML.Schema { return nullable(residual) } - private func attributeDeriv(_ pattern: PureXML.Schema.Pattern, _ attribute: PureXML.Model.Attribute) -> PureXML.Schema.Pattern { + func attributeDeriv(_ pattern: PureXML.Schema.Pattern, _ attribute: PureXML.Model.Attribute) -> PureXML.Schema.Pattern { switch pattern { case let .choice(lhs, rhs): return choice(attributeDeriv(lhs, attribute), attributeDeriv(rhs, attribute)) case let .group(lhs, rhs): @@ -152,7 +152,7 @@ extension PureXML.Schema.RelaxNGEngine { return endTagDeriv(withChildren) } - private func startTagOpenDeriv(_ pattern: PureXML.Schema.Pattern, _ name: PureXML.Model.QualifiedName) -> PureXML.Schema.Pattern { + func startTagOpenDeriv(_ pattern: PureXML.Schema.Pattern, _ name: PureXML.Model.QualifiedName) -> PureXML.Schema.Pattern { switch pattern { case let .choice(lhs, rhs): return choice(startTagOpenDeriv(lhs, name), startTagOpenDeriv(rhs, name)) @@ -185,7 +185,7 @@ extension PureXML.Schema.RelaxNGEngine { } } - private func startTagCloseDeriv(_ pattern: PureXML.Schema.Pattern) -> PureXML.Schema.Pattern { + func startTagCloseDeriv(_ pattern: PureXML.Schema.Pattern) -> PureXML.Schema.Pattern { switch pattern { case let .choice(lhs, rhs): choice(startTagCloseDeriv(lhs), startTagCloseDeriv(rhs)) case let .group(lhs, rhs): group(startTagCloseDeriv(lhs), startTagCloseDeriv(rhs)) @@ -222,7 +222,7 @@ extension PureXML.Schema.RelaxNGEngine { // MARK: Helpers /// Merges adjacent text and CDATA nodes into single text nodes. - private static func coalesceText(_ children: [PureXML.Model.Node]) -> [PureXML.Model.Node] { + static func coalesceText(_ children: [PureXML.Model.Node]) -> [PureXML.Model.Node] { var result: [PureXML.Model.Node] = [] var run = "" for child in children { @@ -246,12 +246,20 @@ extension PureXML.Schema.RelaxNGEngine { string.allSatisfy(\.isWhitespace) } - private static func isWhitespaceText(_ node: PureXML.Model.Node) -> Bool { + static func isWhitespaceText(_ node: PureXML.Model.Node) -> Bool { if case let .text(value) = node { return isWhitespace(value) } return false } - private static func isNamespaceDeclaration(_ attribute: PureXML.Model.Attribute) -> Bool { + static func isNamespaceDeclaration(_ attribute: PureXML.Model.Attribute) -> Bool { attribute.name.prefix == "xmlns" || (attribute.name.prefix == nil && attribute.name.localName == "xmlns") } + + static func elementNameCounts(_ children: [PureXML.Model.Node]) -> [String: Int] { + var counts: [String: Int] = [:] + for case let .element(element) in children { + counts[element.name.description, default: 0] += 1 + } + return counts + } } diff --git a/Sources/Schema/RelaxNGDiagnostics.swift b/Sources/Schema/RelaxNGDiagnostics.swift new file mode 100644 index 0000000..42b044d --- /dev/null +++ b/Sources/Schema/RelaxNGDiagnostics.swift @@ -0,0 +1,163 @@ +/// Located, recovering RELAX NG diagnostics: one error per problem, the way an +/// editor surfaces every issue in a faulty document at once rather than stopping +/// at the first. Validity is decided by ``RelaxNGEngine/matches(start:root:)``; +/// this walk only places and explains the failures and recovers past each so the +/// rest still surface, so a valid document yields none. +extension PureXML.Schema.RelaxNGEngine { + typealias Pattern = PureXML.Schema.Pattern + typealias Failure = PureXML.Validation.ValidationError + typealias PathKey = PureXML.Validation.PathKey + + func locatedErrors(start: Pattern, root: PureXML.Model.Element) -> [Failure] { + guard !matches(start: start, root: .element(root)) else { return [] } + var errors: [Failure] = [] + let path: [PathKey] = [.element(root.name.description)] + let opened = startTagOpenDeriv(start, root.name) + if isNotAllowed(opened) { + errors.append(.init(reason: "element <\(root.name.description)> is not a valid document root\(expected(start))", at: path)) + } else { + validateElement(opened, root, path, &errors) + } + if errors.isEmpty { errors.append(.init(reason: "does not satisfy the RELAX NG schema", at: path)) } + return errors + } + + private func isNotAllowed(_ pattern: Pattern) -> Bool { + if case .notAllowed = pattern { return true } + return false + } + + /// Validates one element already matched at its start tag (`opened` is its + /// `after(content, …)` form): its attributes, then its children against the + /// content peeled from `opened`. + private func validateElement(_ opened: Pattern, _ element: PureXML.Model.Element, _ path: [PathKey], _ errors: inout [Failure]) { + let name = element.name.description + let before = errors.count + var residual = opened + for attribute in element.attributes where !Self.isNamespaceDeclaration(attribute) { + let next = attributeDeriv(residual, attribute) + if isNotAllowed(next) { + errors.append(.init( + reason: "attribute @\(attribute.name.description) is not allowed on <\(name)> or has an invalid value", + at: path + [.attribute(attribute.name.description)], + )) + } else { + residual = next + } + } + let closed = startTagCloseDeriv(residual) + if isNotAllowed(closed) { + // A genuine missing/extra attribute, unless an attribute error above + // already explains why the requirement is unsatisfied (no cascade). + if errors.count == before { errors.append(.init(reason: "element <\(name)> is missing a required attribute", at: path)) } + return + } + // An attribute problem leaves the content model unreliable; stop here so a + // value error does not also surface as bogus content errors. + guard errors.count == before else { return } + validateChildren(contentOf(closed), element, path, &errors) + } + + /// Threads the children through `content`, recording each that does not fit, + /// recovering so later children are still checked, and recursing into every + /// well-placed child element. A sequencing failure at this level suppresses a + /// redundant "missing content"; a deep subtree error does not. + private func validateChildren(_ content: Pattern, _ element: PureXML.Model.Element, _ path: [PathKey], _ errors: inout [Failure]) { + let name = element.name.description + let totals = Self.elementNameCounts(element.children) + var seen: [String: Int] = [:] + var residual = content + var sequenceBroken = false + for node in Self.coalesceText(element.children) where !Self.isWhitespaceText(node) { + switch node { + case let .text(value), let .cdata(value): + let next = textDeriv(residual, value) + if isNotAllowed(next) { + errors.append(.init(reason: "text content is not valid in <\(name)>", at: path)) + sequenceBroken = true + } else { + residual = next + } + case let .element(child): + let childName = child.name.description + seen[childName, default: 0] += 1 + let index = (totals[childName] ?? 0) > 1 ? seen[childName] : nil + let childPath = path + [.element(childName, index: index)] + let unexpected: Bool + (residual, unexpected) = consumeChild(residual, child, parent: name, path: childPath, errors: &errors) + sequenceBroken = sequenceBroken || unexpected + default: + break + } + } + if !sequenceBroken, !nullable(residual) { + errors.append(.init(reason: "element <\(name)> is missing required content\(expected(residual))", at: path)) + } + } + + /// Consumes one child element, recursing into it. Returns the residual for the + /// next sibling and whether the child was unexpected here (a sequencing break, + /// as opposed to a child that fit but whose own content was invalid). + private func consumeChild( + _ residual: Pattern, + _ child: PureXML.Model.Element, + parent: String, + path: [PathKey], + errors: inout [Failure], + ) -> (Pattern, Bool) { + let name = child.name.description + let opened = startTagOpenDeriv(residual, child.name) + if isNotAllowed(opened) { + errors.append(.init(reason: "element <\(name)> is not expected in <\(parent)>\(expected(residual))", at: path)) + return (residual, true) + } + validateElement(opened, child, path, &errors) + return (forceClose(opened), false) + } + + /// The content (the `after` left side) an opened element's children must match. + private func contentOf(_ pattern: Pattern) -> Pattern { + switch pattern { + case let .choice(lhs, rhs): choice(contentOf(lhs), contentOf(rhs)) + case let .after(lhs, _): lhs + default: .notAllowed + } + } + + /// The residual after accepting an element regardless of its content validity, + /// so sibling checking continues (the recovery step). + private func forceClose(_ pattern: Pattern) -> Pattern { + switch pattern { + case let .choice(lhs, rhs): choice(forceClose(lhs), forceClose(rhs)) + case let .after(_, rhs): rhs + default: .notAllowed + } + } + + /// A "; expected a, b" hint naming the element types the pattern accepts next, + /// for recovery guidance. Empty when only wildcards or text are expected. + private func expected(_ pattern: Pattern) -> String { + let names = Set(expectedNames(pattern, visiting: [])).sorted() + return names.isEmpty ? "" : "; expected \(names.joined(separator: ", "))" + } + + private func expectedNames(_ pattern: Pattern, visiting: Set) -> [String] { + switch pattern { + case let .element(nameClass, _): literalNames(nameClass) + case let .choice(lhs, rhs), let .interleave(lhs, rhs): expectedNames(lhs, visiting: visiting) + expectedNames(rhs, visiting: visiting) + case let .group(lhs, rhs): expectedNames(lhs, visiting: visiting) + (nullable(lhs) ? expectedNames(rhs, visiting: visiting) : []) + case let .oneOrMore(inner), let .list(inner): expectedNames(inner, visiting: visiting) + case let .after(lhs, _): expectedNames(lhs, visiting: visiting) + case let .ref(name): visiting.contains(name) ? [] : expectedNames(resolve(name), visiting: visiting.union([name])) + default: [] + } + } + + private func literalNames(_ nameClass: PureXML.Schema.NameClass) -> [String] { + switch nameClass { + case let .name(_, local): ["<\(local)>"] + case let .choice(lhs, rhs): literalNames(lhs) + literalNames(rhs) + default: [] + } + } +} diff --git a/Sources/Schema/RelaxNGSchema.swift b/Sources/Schema/RelaxNGSchema.swift index 3a9facd..67f88cd 100644 --- a/Sources/Schema/RelaxNGSchema.swift +++ b/Sources/Schema/RelaxNGSchema.swift @@ -27,5 +27,38 @@ public extension PureXML.Schema { } return RelaxNGEngine(defines: defines).matches(start: start, root: .element(root)) } + + /// Every way `xml` fails the schema, as located errors with recovery hints, + /// so an editor can show all of a faulty document's problems at once rather + /// than only the first. An empty array means the document is valid. + public func errors(in xml: String) throws -> [PureXML.Validation.ValidationError] { + guard case let .document(children) = try PureXML.parse(xml), + let root = children.compactMap(\.element).first + else { + return [PureXML.Validation.ValidationError(reason: "the document has no root element", at: [])] + } + return RelaxNGEngine(defines: defines).locatedErrors(start: start, root: root) + } + + /// The schema as a single ``PureXML/Validation/Validation`` over the + /// document root, so RELAX NG composes with the rest of the validation + /// framework exactly like the XSD, DTD, and Schematron rules. + public func validation() -> PureXML.Validation.Validation { + let start = start + let defines = defines + return .init( + description: "Document satisfies the RELAX NG schema", + check: { context in + let root: PureXML.Model.Element? = switch context.subject { + case let .document(children): children.compactMap(\.element).first + case let .element(element): element + default: nil + } + guard let root else { return [] } + return RelaxNGEngine(defines: defines).locatedErrors(start: start, root: root) + }, + when: { $0.codingPath.isEmpty }, + ) + } } } diff --git a/Tests/RelaxNGLocatedErrorTests.swift b/Tests/RelaxNGLocatedErrorTests.swift new file mode 100644 index 0000000..35576e8 --- /dev/null +++ b/Tests/RelaxNGLocatedErrorTests.swift @@ -0,0 +1,111 @@ +@testable import PureXML +import Testing + +@Suite("RELAX NG located, recovering validation errors") +struct RelaxNGLocatedErrorTests { + private func errors(_ rng: String, _ xml: String) throws -> [PureXML.Validation.ValidationError] { + try PureXML.Schema.RelaxNG(rng).errors(in: xml) + } + + private func reasons(_ rng: String, _ xml: String) throws -> [String] { + try errors(rng, xml).map(\.reason) + } + + private func paths(_ rng: String, _ xml: String) throws -> [[String]] { + try errors(rng, xml).map { $0.codingPath.map(\.stringValue) } + } + + private let twoChildren = """ + + + + + """ + + @Test("A valid document yields no errors") + func test_valid() throws { + #expect(try errors(twoChildren, "").isEmpty) + } + + @Test("Two independent subtree errors are both reported (recovery, not one error)") + func test_multipleErrorsWithRecovery() throws { + let xml = "" + let found = try errors(twoChildren, xml) + #expect(found.count == 2) + let reasons = found.map(\.reason) + #expect(reasons.contains { $0.contains("") && $0.contains("") }) + #expect(reasons.contains { $0.contains("") && $0.contains("") }) + } + + @Test("Each error is located at the offending nested element") + func test_locatedPaths() throws { + let xml = "" + let located = try paths(twoChildren, xml) + #expect(located.contains(["root", "a", "wrong"])) + #expect(located.contains(["root", "b", "alsowrong"])) + } + + @Test("An unexpected element carries an expected-content recovery hint") + func test_expectedHint() throws { + let xml = "" + let found = try reasons(twoChildren, xml) + #expect(found.count == 1) + #expect(found.first?.contains("expected ") == true) + } + + @Test("Missing required content is reported with what was expected") + func test_missingContent() throws { + let xml = "" + let found = try reasons(twoChildren, xml) + #expect(found.contains { $0.contains("missing required content") && $0.contains("") }) + } + + @Test("An extra trailing element is reported as unexpected") + func test_extraElement() throws { + let xml = "" + let found = try errors(twoChildren, xml) + #expect(found.count == 1) + #expect(found.first?.reason.contains("") == true) + } + + @Test("An invalid attribute value is located on its element") + func test_attribute() throws { + let rng = """ + + + + """ + let found = try errors(rng, "") + #expect(found.count == 1) + #expect(found.first?.reason.contains("@count") == true) + #expect(found.first?.codingPath.map(\.stringValue) == ["n", "@count"]) + } + + @Test("Invalid datatype content is located on the element") + func test_dataValue() throws { + let rng = """ + + + + """ + let found = try reasons(rng, "abc") + #expect(found.contains { $0.contains("") }) + } + + @Test("The validation() value composes with the framework like the other validators") + func test_validationValueForm() throws { + let schema = try PureXML.Schema.RelaxNG(twoChildren) + let node = try PureXML.parse("") + let collected = PureXML.Validation.Validator.blank + .validating(schema.validation()) + .errors(for: node, in: ()) + #expect(collected.count == 2) + } + + @Test("The same value-space datatypes still validate through located errors") + func test_compactSchema() throws { + let schema = try PureXML.Schema.RelaxNG(compact: "element greeting { \"hi\" }") + #expect(try schema.errors(in: "hi").isEmpty) + #expect(try !schema.errors(in: "bye").isEmpty) + } +}