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