PureXML/Sources/Validation/Validator.swift
Mihaela Mihaljevic 1fbe359ada feat(validation): composable validation framework + structural rule
Slice 1 of #92. Introduce the OpenAPIKit-idiom validation framework, replacing
the imperative if-tree structural validator:

- ValidationError (reason + coding path) renders "<reason> at path: root/row/cell";
  ValidationErrorCollection gathers every failure and is thrown once.
- Validation<Subject, Document> is the atomic, composable rule value, with the
  multi-error and single-error Bool inits (the latter auto-forms "Failed to
  satisfy: <description>"). Predicate-gated, type-directed via an erased
  AnyValidation that guards an optional from matching a non-optional rule.
- The framework is parameterized over both Subject and Document so the structural,
  DTD, XSD, and Schematron layers share one machinery (Document = Void for rules
  that need only their subject).
- Validator<Document> walks the node tree, building the coding path, with blank /
  default factories and fluent validating / withoutValidating.
- The combinator algebra (&&, ||, comparison lifts, take, lift, unwrap, all) as
  free functions returning closures.

The structural duplicate-attribute rule is reborn as Validation<Element, Void>.
Combinators and the erased wrapper are unit-tested as pure functions per the rule.

The legacy Validation.Issue stays only for the not-yet-migrated schema validators
and is retired in a later slice.
2026-06-08 12:00:34 +02:00

113 lines
5.1 KiB
Swift

public extension PureXML.Validation {
/// Applies a set of ``Validation`` values across a parsed node tree, gathering
/// every error with its coding path and throwing one ``ValidationErrorCollection``
/// at the end. Holds an ordered list of erased validations (a layer's default
/// set plus any custom additions). Build one with ``blank`` and the fluent
/// ``validating(_:)``/``withoutValidating(_:)`` methods, or take a layer's
/// default factory.
///
/// Parameterized over `Document`, the cross-cutting context the rules consult
/// (a schema, a scope, or `Void` for rules that need only their subject).
struct Validator<Document> {
var validations: [AnyValidation<Document>]
init(validations: [AnyValidation<Document>]) {
self.validations = validations
}
/// A validator with no rules; add your own with ``validating(_:)``.
public static var blank: Validator {
Validator(validations: [])
}
/// The descriptions of the active rules, in application order.
public var validationDescriptions: [String] {
validations.map(\.description)
}
/// Returns a validator with `additions` appended after the current rules.
public func validating<Subject>(_ additions: Validation<Subject, Document>...) -> Validator {
Validator(validations: validations + additions.map { AnyValidation($0) })
}
/// Returns a validator with a single-error Bool rule appended.
public func validating<Subject>(
_ description: String,
check: @escaping (ValidationContext<Subject, Document>) -> Bool,
when predicate: @escaping (ValidationContext<Subject, Document>) -> Bool = { _ in true },
) -> Validator {
validating(Validation(description: description, check: check, when: predicate))
}
/// Returns a validator with the rules matching `descriptions` removed.
public func withoutValidating(_ descriptions: String...) -> Validator {
Validator(validations: validations.filter { !descriptions.contains($0.description) })
}
// MARK: Running
/// Gathers every error from validating `node` in `document`, in document
/// order. Returns an empty array when the tree is valid.
public func errors(for node: PureXML.Model.Node, in document: Document) -> [ValidationError] {
var errors: [ValidationError] = []
walk(node, path: [], document, &errors)
return errors
}
/// Validates `node` in `document`, throwing a ``ValidationErrorCollection``
/// if any rule fails.
public func validate(_ node: PureXML.Model.Node, in document: Document) throws {
let found = errors(for: node, in: document)
if !found.isEmpty { throw ValidationErrorCollection(values: found) }
}
private func walk(_ node: PureXML.Model.Node, path: [PathKey], _ document: Document, _ errors: inout [ValidationError]) {
apply(node, at: path, in: document, into: &errors)
switch node {
case let .document(children):
walkChildren(children, path: path, document, &errors)
case let .element(element):
apply(element, at: path, in: document, into: &errors)
for attribute in element.attributes {
apply(attribute, at: path + [.attribute(attribute.name.localName)], in: document, into: &errors)
}
walkChildren(element.children, path: path, document, &errors)
case .text, .cdata, .comment, .processingInstruction:
break
}
}
/// Recurses element children, extending the path by `name` (and a sibling
/// index only when more than one child shares that name).
private func walkChildren(
_ children: [PureXML.Model.Node],
path: [PathKey],
_ document: Document,
_ errors: inout [ValidationError],
) {
var totals: [String: Int] = [:]
for child in children {
if case let .element(element) = child { totals[element.name.description, default: 0] += 1 }
}
var seen: [String: Int] = [:]
for child in children {
guard case let .element(element) = child else {
walk(child, path: path, document, &errors)
continue
}
let name = element.name.description
let index = (seen[name] ?? 0) + 1
seen[name] = index
let step: PathKey = (totals[name] ?? 0) > 1 ? .element(name, index: index) : .element(name)
walk(child, path: path + [step], document, &errors)
}
}
private func apply(_ value: some Validatable, at path: [PathKey], in document: Document, into errors: inout [ValidationError]) {
for validation in validations {
errors += validation.apply(to: value, at: path, in: document)
}
}
}
}