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.
218 lines
10 KiB
Swift
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: []
|
|
}
|
|
}
|
|
}
|