Merge: RELAX NG located, recovering errors (#79)

This commit is contained in:
Mihaela Mihaljevic 2026-06-09 00:40:56 +02:00
commit e8af2563dd
5 changed files with 335 additions and 9 deletions

View File

@ -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 <a>,
<b>`) 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<Node, Void>`, 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

View File

@ -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
}
}

View File

@ -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>) -> [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: []
}
}
}

View File

@ -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<PureXML.Model.Node, Void> {
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 },
)
}
}
}

View File

@ -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 = """
<element name="root" xmlns="http://relaxng.org/ns/structure/1.0">
<element name="a"><element name="x"><empty/></element></element>
<element name="b"><element name="y"><empty/></element></element>
</element>
"""
@Test("A valid document yields no errors")
func test_valid() throws {
#expect(try errors(twoChildren, "<root><a><x/></a><b><y/></b></root>").isEmpty)
}
@Test("Two independent subtree errors are both reported (recovery, not one error)")
func test_multipleErrorsWithRecovery() throws {
let xml = "<root><a><wrong/></a><b><alsowrong/></b></root>"
let found = try errors(twoChildren, xml)
#expect(found.count == 2)
let reasons = found.map(\.reason)
#expect(reasons.contains { $0.contains("<wrong>") && $0.contains("<a>") })
#expect(reasons.contains { $0.contains("<alsowrong>") && $0.contains("<b>") })
}
@Test("Each error is located at the offending nested element")
func test_locatedPaths() throws {
let xml = "<root><a><wrong/></a><b><alsowrong/></b></root>"
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 = "<root><a><wrong/></a><b><y/></b></root>"
let found = try reasons(twoChildren, xml)
#expect(found.count == 1)
#expect(found.first?.contains("expected <x>") == true)
}
@Test("Missing required content is reported with what was expected")
func test_missingContent() throws {
let xml = "<root><a><x/></a></root>"
let found = try reasons(twoChildren, xml)
#expect(found.contains { $0.contains("missing required content") && $0.contains("<b>") })
}
@Test("An extra trailing element is reported as unexpected")
func test_extraElement() throws {
let xml = "<root><a><x/></a><b><y/></b><c/></root>"
let found = try errors(twoChildren, xml)
#expect(found.count == 1)
#expect(found.first?.reason.contains("<c>") == true)
}
@Test("An invalid attribute value is located on its element")
func test_attribute() throws {
let rng = """
<element name="n" datatypeLibrary="http://www.w3.org/2001/XMLSchema-datatypes" xmlns="http://relaxng.org/ns/structure/1.0">
<attribute name="count"><data type="integer"/></attribute>
</element>
"""
let found = try errors(rng, "<n count='abc'/>")
#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 = """
<element name="n" datatypeLibrary="http://www.w3.org/2001/XMLSchema-datatypes" xmlns="http://relaxng.org/ns/structure/1.0">
<data type="integer"/>
</element>
"""
let found = try reasons(rng, "<n>abc</n>")
#expect(found.contains { $0.contains("<n>") })
}
@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("<root><a><wrong/></a><b><alsowrong/></b></root>")
let collected = PureXML.Validation.Validator<Void>.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: "<greeting>hi</greeting>").isEmpty)
#expect(try !schema.errors(in: "<greeting>bye</greeting>").isEmpty)
}
}