Merge: RELAX NG located, recovering errors (#79)
This commit is contained in:
commit
e8af2563dd
11
CHANGELOG.md
11
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 <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
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
163
Sources/Schema/RelaxNGDiagnostics.swift
Normal file
163
Sources/Schema/RelaxNGDiagnostics.swift
Normal 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: []
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
111
Tests/RelaxNGLocatedErrorTests.swift
Normal file
111
Tests/RelaxNGLocatedErrorTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user