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.
This commit is contained in:
Mihaela Mihaljevic 2026-06-08 12:00:34 +02:00
parent 6ac3804ba1
commit 1fbe359ada
10 changed files with 554 additions and 54 deletions

View File

@ -55,8 +55,9 @@ fetching (`nanohttp`/`nanoftp`) and the threading/memory infrastructure stay out
and external resolution is opt-in through an injected resolver so XXE stays closed.
The original epics and the documented-subset parity work (#61) are all shipped
(summarized above). A read-only conformance audit then surfaced the remaining
depth gaps versus libxml2, tracked under one epic with per-subsystem children:
(summarized above). The current focus is the differentiator: fault-tolerant
reading and a rich, composable validation layer (#92). A read-only conformance
audit also surfaced the remaining depth gaps versus libxml2 (#71):
```mermaid
flowchart TB
@ -64,7 +65,9 @@ flowchart TB
classDef review fill:#fff7d6,stroke:#ffcc00,color:#111827
classDef epic fill:#f2e5ff,stroke:#af52de,color:#111827
classDef todo fill:#f2f4f7,stroke:#8e8e93,color:#111827
Validation["#92 OpenAPIKit-idiom validation framework + recovering reader: read invalid documents without crashing, return rich located results"]:::epic
Gaps["#71 Beyond-parity libxml2 gaps: DTD entities/defaulting (#72 #73), parser breadth (#74), XSD instance/derivation/wildcards/namespaces (#75 #76 #77 #78), RELAX NG + Schematron (#79 #80), XSLT elements/functions (#81 #82), HTML5 full tree (#83 #84), C14N/catalog/XInclude (#85 #86 #87), XPath/XPointer (#88), regex (#89), DOM/serialization (#90 #91)"]:::epic
Validation --> Gaps
```
## Status

View File

@ -125,13 +125,15 @@ public extension PureXML {
try Model.TreeNode(parse(xml, limits: limits, resolver: resolver))
}
/// Validates a parsed XML node with the default validation rules.
@discardableResult
/// Validates a parsed XML node with the default structural rules, throwing a
/// ``PureXML/Validation/ValidationErrorCollection`` that locates every failure
/// by coding path. Pass a custom ``PureXML/Validation/Validator`` to add or
/// remove rules, or call ``Validation/Validator/errors(for:)`` to collect the
/// errors without throwing.
static func validate(
_ node: Model.Node,
using validator: Validation.Validator = .init(),
strict: Bool = true,
) throws -> [Validation.Issue] {
try validator.validate(node, strict: strict)
using validator: Validation.Validator<Void> = .init(),
) throws {
try validator.validate(node)
}
}

View File

@ -14,3 +14,5 @@ public extension PureXML.Validation {
}
}
}
extension PureXML.Validation.Issue: Swift.Error {}

View File

@ -0,0 +1,47 @@
public extension PureXML.Validation {
/// The schema-independent structural validations: well-formedness rules that
/// hold for any document regardless of a DTD or schema.
enum Structural {
/// An element must not declare the same attribute name twice. Reports one
/// error per duplicate.
public static var uniqueAttributes: Validation<PureXML.Model.Element, Void> {
.init(description: "Element attribute names are unique") { context in
var seen: Set<String> = []
var errors: [ValidationError] = []
for attribute in context.subject.attributes {
let key = attribute.name.description
if !seen.insert(key).inserted {
errors.append(ValidationError(
reason: "Duplicate attribute '\(key)' on <\(context.subject.name.description)>",
at: context.codingPath,
))
}
}
return errors
}
}
/// The default structural rule set.
static var defaults: [Validation<PureXML.Model.Element, Void>] {
[uniqueAttributes]
}
}
}
public extension PureXML.Validation.Validator where Document == Void {
/// The default structural validator: schema-independent well-formedness rules.
init() {
self.init(validations: PureXML.Validation.Structural.defaults.map { PureXML.Validation.AnyValidation($0) })
}
/// Validates a node with the structural rules, throwing a
/// ``PureXML/Validation/ValidationErrorCollection`` on any failure.
func validate(_ node: PureXML.Model.Node) throws {
try validate(node, in: ())
}
/// The structural errors for a node, in document order; empty when valid.
func errors(for node: PureXML.Model.Node) -> [PureXML.Validation.ValidationError] {
errors(for: node, in: ())
}
}

View File

@ -0,0 +1,73 @@
public extension PureXML.Validation {
/// An atomic validation: a positive `description` of the correct state, a
/// `validate` function returning one ``ValidationError`` per problem, and a
/// `predicate` that scopes when the rule applies. Validations are composable
/// values, combined with the operator algebra, never imperative if-trees.
///
/// Specialized on `Subject` (the value type it fires on, anywhere in the tree)
/// and parameterized over `Document` (the cross-cutting context, or `Void`
/// when a rule needs nothing beyond its subject).
struct Validation<Subject: Validatable, Document> {
public let validate: (ValidationContext<Subject, Document>) -> [ValidationError]
public let predicate: (ValidationContext<Subject, Document>) -> Bool
public let description: String
/// Runs the validation against `subject`, gating on the predicate first.
public func apply(to subject: Subject, at codingPath: [PathKey], in document: Document) -> [ValidationError] {
let context = ValidationContext(document: document, subject: subject, codingPath: codingPath)
guard predicate(context) else { return [] }
return validate(context)
}
/// The multi-error form: return one error per problem. Use when a single
/// value can fail in several places at once.
public init(
description: String? = nil,
check validate: @escaping (ValidationContext<Subject, Document>) -> [ValidationError],
when predicate: @escaping (ValidationContext<Subject, Document>) -> Bool = { _ in true },
) {
self.validate = validate
self.predicate = predicate
self.description = description ?? String(describing: Subject.self)
}
/// The single-error Bool form (the dominant one): a positive `description`
/// and a check returning `false` when invalid, which auto-produces the
/// error `"Failed to satisfy: <description>"`.
public init(
description: String,
check validate: @escaping (ValidationContext<Subject, Document>) -> Bool,
when predicate: @escaping (ValidationContext<Subject, Document>) -> Bool = { _ in true },
) {
self.init(
description: description,
check: { context in
validate(context) ? [] : [ValidationError(reason: "Failed to satisfy: \(description)", at: context.codingPath)]
},
when: predicate,
)
}
}
}
extension PureXML.Validation {
/// Erases the subject type of a ``Validation`` so a heterogeneous list can be
/// applied at every node. Filters by runtime type, guarding against an
/// optional satisfying a non-optional validation.
struct AnyValidation<Document> {
let description: String
private let applyErased: (Any, [PathKey], Document) -> [ValidationError]
init<Subject>(_ validation: Validation<Subject, Document>) {
description = validation.description
applyErased = { input, codingPath, document in
guard let subject = input as? Subject, type(of: subject) == type(of: input) else { return [] }
return validation.apply(to: subject, at: codingPath, in: document)
}
}
func apply(to value: Any, at codingPath: [PathKey], in document: Document) -> [ValidationError] {
applyErased(value, codingPath, document)
}
}
}

View File

@ -0,0 +1,112 @@
// The declarative combinator algebra for ``PureXML/Validation/Validation``.
// Free functions returning closures, so validations compose by value rather than
// through imperative control flow. Generic over both the subject type and the
// document, per the one-framework-parameterized-over-the-document rule.
typealias Validatable = PureXML.Validation.Validatable
typealias VContext<Subject: PureXML.Validation.Validatable, Document> =
PureXML.Validation.ValidationContext<Subject, Document>
typealias VError = PureXML.Validation.ValidationError
typealias VRule<Subject: PureXML.Validation.Validatable, Document> =
PureXML.Validation.Validation<Subject, Document>
// MARK: Context -> error array
func && <T: Validatable, D>(
lhs: @escaping (VContext<T, D>) -> [VError],
rhs: @escaping (VContext<T, D>) -> [VError],
) -> (VContext<T, D>) -> [VError] {
{ context in lhs(context) + rhs(context) }
}
func || <T: Validatable, D>(
lhs: @escaping (VContext<T, D>) -> [VError],
rhs: @escaping (VContext<T, D>) -> [VError],
) -> (VContext<T, D>) -> [VError] {
{ context in
let left = lhs(context)
if left.isEmpty { return [] }
let right = rhs(context)
return right.isEmpty ? [] : left + right
}
}
// MARK: Context -> Bool
func && <T: Validatable, D>(
lhs: @escaping (VContext<T, D>) -> Bool,
rhs: @escaping (VContext<T, D>) -> Bool,
) -> (VContext<T, D>) -> Bool {
{ context in lhs(context) && rhs(context) }
}
func || <T: Validatable, D>(
lhs: @escaping (VContext<T, D>) -> Bool,
rhs: @escaping (VContext<T, D>) -> Bool,
) -> (VContext<T, D>) -> Bool {
{ context in lhs(context) || rhs(context) }
}
// MARK: Subject KeyPath -> Bool
func == <T: Validatable, U: Equatable, D>(lhs: KeyPath<T, U>, rhs: U) -> (VContext<T, D>) -> Bool {
{ $0.subject[keyPath: lhs] == rhs }
}
func != <T: Validatable, U: Equatable, D>(lhs: KeyPath<T, U>, rhs: U) -> (VContext<T, D>) -> Bool {
{ $0.subject[keyPath: lhs] != rhs }
}
func > <T: Validatable, U: Comparable, D>(lhs: KeyPath<T, U>, rhs: U) -> (VContext<T, D>) -> Bool {
{ $0.subject[keyPath: lhs] > rhs }
}
func >= <T: Validatable, U: Comparable, D>(lhs: KeyPath<T, U>, rhs: U) -> (VContext<T, D>) -> Bool {
{ $0.subject[keyPath: lhs] >= rhs }
}
func < <T: Validatable, U: Comparable, D>(lhs: KeyPath<T, U>, rhs: U) -> (VContext<T, D>) -> Bool {
{ $0.subject[keyPath: lhs] < rhs }
}
func <= <T: Validatable, U: Comparable, D>(lhs: KeyPath<T, U>, rhs: U) -> (VContext<T, D>) -> Bool {
{ $0.subject[keyPath: lhs] <= rhs }
}
// MARK: Digging and lifting
/// Dig to a value by KeyPath and run arbitrary logic on it.
func take<T: Validatable, U, D>(_ path: KeyPath<T, U>, check: @escaping (U) -> Bool) -> (VContext<T, D>) -> Bool {
{ check($0.subject[keyPath: path]) }
}
/// Run child-typed validations against a lifted value, keeping the parent path
/// and document.
func lift<T: Validatable, U: Validatable, D>(
_ path: KeyPath<T, U>,
into validations: VRule<U, D>...,
) -> (VContext<T, D>) -> [VError] {
{ context in
validations.flatMap { $0.apply(to: context.subject[keyPath: path], at: context.codingPath, in: context.document) }
}
}
/// Unwrap an optional, erroring if nil, else run child validations.
func unwrap<T: Validatable, U: Validatable, D>(
_ path: KeyPath<T, U?>,
into validations: VRule<U, D>...,
description: String? = nil,
) -> (VContext<T, D>) -> [VError] {
{ context in
guard let subject = context.subject[keyPath: path] else {
let reason = description.map { "Tried to unwrap but found nil: \($0)" } ?? "Tried to unwrap an optional and found nil"
return [VError(reason: reason, at: context.codingPath)]
}
return validations.flatMap { $0.apply(to: subject, at: context.codingPath, in: context.document) }
}
}
/// Apply many validations to the same context (`lift` with `\.self`).
func all<T: Validatable, D>(_ validations: VRule<T, D>...) -> (VContext<T, D>) -> [VError] {
{ context in validations.flatMap { $0.apply(to: context.subject, at: context.codingPath, in: context.document) } }
}

View File

@ -0,0 +1,91 @@
public extension PureXML.Validation {
/// A marker for values a ``Validation`` can specialize on. Conform the model
/// types (and the containers traversed) so a `Validation<Subject, Document>`
/// can fire on every `Subject` in the tree.
protocol Validatable {}
/// One step of a validation coding path: an element name (with an optional
/// sibling index) or an attribute (`@name`). Conforms to the standard-library
/// `CodingKey`, so paths render and thread without bookkeeping.
struct PathKey: CodingKey, Equatable, Sendable {
public var stringValue: String
public var intValue: Int?
public init(stringValue: String) {
self.stringValue = stringValue
intValue = nil
}
public init?(intValue: Int) {
stringValue = String(intValue)
self.intValue = intValue
}
init(stringValue: String, intValue: Int?) {
self.stringValue = stringValue
self.intValue = intValue
}
/// An element step, optionally carrying its position among same-named
/// siblings (rendered `name[index]`).
static func element(_ name: String, index: Int? = nil) -> PathKey {
PathKey(stringValue: name, intValue: index)
}
/// An attribute step, rendered `@name`.
static func attribute(_ name: String) -> PathKey {
PathKey(stringValue: "@\(name)", intValue: nil)
}
/// Renders a path the XML way: `root/item[2]/@id`.
static func render(_ path: [PathKey]) -> String {
path.map { key in
key.intValue.map { "\(key.stringValue)[\($0)]" } ?? key.stringValue
}.joined(separator: "/")
}
}
/// The read-only bundle every check receives: the whole `Document` (the
/// cross-cutting context a rule may consult), the current `Subject` value, and
/// the coding path where it sits.
struct ValidationContext<Subject: Validatable, Document> {
public let document: Document
public let subject: Subject
public let codingPath: [PathKey]
}
/// A validation failure: a reason and the coding path where it occurred. The
/// description strips a trailing period and renders the location consistently.
struct ValidationError: Swift.Error, Equatable, Sendable, CustomStringConvertible {
public let reason: String
public let codingPath: [PathKey]
public init(reason: String, at path: [PathKey]) {
self.reason = reason
codingPath = path
}
public var description: String {
let reasonText = reason.hasSuffix(".") ? String(reason.dropLast()) : reason
let path = PathKey.render(codingPath)
return path.isEmpty ? "\(reasonText) at root of document" : "\(reasonText) at path: \(path)"
}
}
/// The one value thrown at the end of validation, holding every error found.
struct ValidationErrorCollection: Swift.Error, Equatable, Sendable, CustomStringConvertible {
public let values: [ValidationError]
public init(values: [ValidationError]) {
self.values = values
}
public var description: String {
values.map(\.description).joined(separator: "\n")
}
}
}
extension PureXML.Model.Node: PureXML.Validation.Validatable {}
extension PureXML.Model.Element: PureXML.Validation.Validatable {}
extension PureXML.Model.Attribute: PureXML.Validation.Validatable {}

View File

@ -1,58 +1,112 @@
public extension PureXML.Validation {
/// Path-aware validation over a parsed XML tree.
/// 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.
///
/// The default validator checks structural rules that do not depend on a
/// schema: an element must not declare the same attribute name twice. Schema
/// (DTD, XSD, RELAX NG) validation is intentionally out of scope for the
/// library target and is layered on by callers.
struct Validator: Sendable {
public init() {}
/// 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>]
/// Collects validation issues for a node tree.
public func collect(_ node: PureXML.Model.Node) -> [Issue] {
var issues: [Issue] = []
walk(node, into: &issues)
return issues
init(validations: [AnyValidation<Document>]) {
self.validations = validations
}
/// Validates a node tree, throwing the first error-severity issue.
@discardableResult
public func validate(_ node: PureXML.Model.Node, strict: Bool = true) throws -> [Issue] {
let issues = collect(node)
if let error = issues.first(where: { $0.severity == .error }) {
throw error
}
if strict, let warning = issues.first(where: { $0.severity == .warning }) {
throw warning
}
return issues
/// A validator with no rules; add your own with ``validating(_:)``.
public static var blank: Validator {
Validator(validations: [])
}
private func walk(_ node: PureXML.Model.Node, into issues: inout [Issue]) {
/// 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):
for child in children {
walk(child, into: &issues)
}
walkChildren(children, path: path, document, &errors)
case let .element(element):
var seen: Set<String> = []
apply(element, at: path, in: document, into: &errors)
for attribute in element.attributes {
let key = attribute.name.description
if !seen.insert(key).inserted {
issues.append(.init(
severity: .error,
message: "duplicate attribute '\(key)' on <\(element.name.description)>",
))
}
}
for child in element.children {
walk(child, into: &issues)
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)
}
}
}
}
extension PureXML.Validation.Issue: Swift.Error {}

View File

@ -0,0 +1,89 @@
@testable import PureXML
import Testing
/// Unit tests for the validation framework primitives: the `Validation` value,
/// the erased wrapper's type guard, and the combinator algebra exercised as pure
/// functions against hand-built contexts (no full traversal).
@Suite("Validation framework")
struct ValidationFrameworkTests {
private typealias Element = PureXML.Model.Element
private typealias Error = PureXML.Validation.ValidationError
private typealias Context = PureXML.Validation.ValidationContext<PureXML.Model.Element, Void>
private func context(_ element: Element, path: [PureXML.Validation.PathKey] = []) -> Context {
Context(document: (), subject: element, codingPath: path)
}
private let sample = PureXML.Model.Element("e", attributes: [.init("a", "1")])
@Test("The single-error Bool form auto-formats the failure")
func test_boolForm() {
let rule = PureXML.Validation.Validation<Element, Void>(
description: "Element is named box",
check: \Element.name.localName == "box",
)
#expect(rule.apply(to: sample, at: [.element("e")], in: ()).first?.reason == "Failed to satisfy: Element is named box")
let box = PureXML.Model.Element("box")
#expect(rule.apply(to: box, at: [], in: ()).isEmpty)
}
@Test("The predicate gates the check")
func test_predicateGate() {
var ran = false
let rule = PureXML.Validation.Validation<Element, Void>(
description: "never",
check: { _ in ran = true
return []
},
when: \Element.name.localName == "other",
)
_ = rule.apply(to: sample, at: [], in: ())
#expect(ran == false)
}
@Test("The erased wrapper fires only on the exact type")
func test_erasureTypeGuard() {
let rule = PureXML.Validation.Validation<Element, Void>(description: "always fails", check: { _ in false })
let erased = PureXML.Validation.AnyValidation(rule)
// Wrong type yields no errors.
#expect(erased.apply(to: "a string", at: [], in: ()).isEmpty)
// An optional wrapping the type must not satisfy a non-optional validation.
let optional: Element? = sample
#expect(erased.apply(to: optional as Any, at: [], in: ()).isEmpty)
// The exact type runs.
#expect(erased.apply(to: sample, at: [], in: ()).count == 1)
}
@Test("&& concatenates both error lists; || short-circuits")
func test_andOr() {
let fail: (Context) -> [Error] = { [Error(reason: "x", at: $0.codingPath)] }
let pass: (Context) -> [Error] = { _ in [] }
#expect((fail && fail)(context(sample)).count == 2)
#expect((pass || fail)(context(sample)).isEmpty)
#expect((fail || fail)(context(sample)).count == 2)
}
@Test("take digs to a value and runs logic")
func test_take() {
let check: (Context) -> Bool = take(\Element.attributes) { $0.count == 1 }
#expect(check(context(sample)))
#expect(!check(context(PureXML.Model.Element("e"))))
}
@Test("all applies many validations to the same context")
func test_all() {
let one = PureXML.Validation.Validation<Element, Void>(description: "one", check: { _ in false })
let two = PureXML.Validation.Validation<Element, Void>(description: "two", check: { _ in false })
let combined: (Context) -> [Error] = all(one, two)
#expect(combined(context(sample, path: [.element("e")])).count == 2)
}
@Test("withoutValidating removes a rule by description")
func test_withoutValidating() {
let validator = PureXML.Validation.Validator<Void>.blank
.validating(PureXML.Validation.Structural.uniqueAttributes)
#expect(validator.validationDescriptions == ["Element attribute names are unique"])
let stripped = validator.withoutValidating("Element attribute names are unique")
#expect(stripped.validationDescriptions.isEmpty)
}
}

View File

@ -3,15 +3,16 @@ import Testing
@Suite("Validation")
struct ValidationTests {
@Test("Duplicate attribute is an error")
@Test("Duplicate attribute is one located error")
func test_duplicateAttributeIsError() {
let element = PureXML.Model.Element(
"input",
attributes: [.init("id", "a"), .init("id", "b")],
)
let issues = PureXML.Validation.Validator().collect(.element(element))
#expect(issues.count == 1)
#expect(issues.first?.severity == .error)
let errors = PureXML.Validation.Validator().errors(for: .element(element))
#expect(errors.count == 1)
#expect(errors.first?.reason == "Duplicate attribute 'id' on <input>")
#expect(errors.first?.codingPath.isEmpty == true)
}
@Test("Unique attributes validate cleanly")
@ -20,7 +21,33 @@ struct ValidationTests {
"input",
attributes: [.init("id", "a"), .init("name", "b")],
)
let issues = try PureXML.validate(.element(element))
#expect(issues.isEmpty)
try PureXML.validate(.element(element))
}
@Test("A located failure renders its coding path")
func test_errorRendersPath() {
let bad = PureXML.Model.Element("cell", attributes: [.init("id", "a"), .init("id", "b")])
let root = PureXML.Model.Element("table", children: [
.element(PureXML.Model.Element("row", children: [.element(bad)])),
])
// Parse output is a document node, so the root element is named in the path.
let errors = PureXML.Validation.Validator().errors(for: .document([.element(root)]))
#expect(errors.count == 1)
#expect(String(describing: errors[0]) == "Duplicate attribute 'id' on <cell> at path: table/row/cell")
}
@Test("Throwing form collects errors into one collection")
func test_throwsCollection() {
let element = PureXML.Model.Element("x", attributes: [.init("a", "1"), .init("a", "2")])
#expect(throws: PureXML.Validation.ValidationErrorCollection.self) {
try PureXML.validate(.element(element))
}
}
@Test("A blank validator runs no rules")
func test_blankValidator() {
let element = PureXML.Model.Element("x", attributes: [.init("a", "1"), .init("a", "2")])
let validator = PureXML.Validation.Validator<Void>.blank
#expect(validator.errors(for: .element(element), in: ()).isEmpty)
}
}