PureXML/Sources/Schema/RelaxNGDiagnostics.swift
Mihaela Mihaljevic 380160cc2b refactor(validation): decompose XSD instance validation into composable rules
The two monolithic XSD content/identity rules only satisfied the OpenAPIKit
validation idiom at the wrong granularity: no per-constraint removability,
no isolable failing/near-miss tests. Split them into 32 named rules, each
finding tagged at its origin and cached per XSDContext so the walk still
runs once per document (the #101 decision against duplicating XSD's
recursive type resolution still holds). Wired the streaming path's ID/IDREF
and identity checks through the same framework instead of calling the
validators imperatively, and added the unwrapAndLookup combinator the rule
already named as required.

Also includes the mandated repo-wide swiftformat/swiftlint pass.
2026-07-12 14:12:17 +02:00

218 lines
10 KiB
Swift

/// 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: attributeDetail(residual, attribute, on: name), 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: "\(textDetail(residual, value)) 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)
}
/// Explains an attribute failure: an undeclared attribute, or, when the name
/// is declared, why its value is invalid (reusing the datatype detail).
private func attributeDetail(_ residual: Pattern, _ attribute: PureXML.Model.Attribute, on name: String) -> String {
guard let content = attributeContent(residual, attribute.name, visiting: []) else {
return "attribute @\(attribute.name.description) is not allowed on <\(name)>"
}
return "attribute @\(attribute.name.description) on <\(name)>: \(textDetail(content, attribute.value))"
}
/// The value pattern of a declared attribute whose name matches, or nil when
/// no attribute of that name is permitted at this point.
private func attributeContent(_ pattern: Pattern, _ wanted: PureXML.Model.QualifiedName, visiting: Set<String>) -> Pattern? {
switch pattern {
case let .attribute(nameClass, content):
nameClass.contains(wanted) ? content : nil
case let .choice(lhs, rhs), let .group(lhs, rhs), let .interleave(lhs, rhs):
attributeContent(lhs, wanted, visiting: visiting) ?? attributeContent(rhs, wanted, visiting: visiting)
case let .after(lhs, _), let .oneOrMore(lhs):
attributeContent(lhs, wanted, visiting: visiting)
case let .ref(reference):
visiting.contains(reference) ? nil : attributeContent(resolve(reference), wanted, visiting: visiting.union([reference]))
default:
nil
}
}
/// Explains why `value` does not satisfy the text/datatype `pattern`, quoting
/// the offending value and naming the datatype it failed (`'abc' is not a
/// valid integer`) or the literal it should have matched, falling back to a
/// generic message when the content admits no text at all.
private func textDetail(_ pattern: Pattern, _ value: String) -> String {
switch pattern {
case let .data(type):
type.validate(value) ?? "text content '\(value)' is not valid"
case let .value(type, literal):
type.valueMatches(value, literal: literal)
? "text content '\(value)' is not valid"
: "text content '\(value)' must be '\(literal)'"
case let .list(inner):
textDetail(inner, value)
case let .choice(lhs, rhs):
textDetail(isNotAllowed(textDeriv(lhs, value)) ? rhs : lhs, value)
case let .group(lhs, _), let .after(lhs, _):
textDetail(lhs, value)
case let .ref(name):
textDetail(resolve(name), value)
default:
"text content is not allowed"
}
}
/// 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: []
}
}
}