PureYAML/Sources/Validation/Validator.swift
Mihaela M 2e45ecaffe feat(validation): add type-directed Check dispatch
Introduce the core of the validation-rules idiom: a Check<Subject> with a
positive description, a when predicate, and a Bool or multi-error check
that fires only on values of its Subject type wherever they occur. AnyCheck
erases the subject and dispatches by runtime type, guarding an optional or
a wrong type from matching. The validator gains a checks list, a
validating(_:) overload, and a traversal that offers each node the values
it may specialise on. BuiltinCheck.mappingKeysAreUnique is the
type-directed counterpart of the legacy rule.

The machinery has its own negative tests (wrong type, optional, false
predicate yield nothing; same type twice yields two errors) and the builtin
ships a failing and a near-miss test. The coverage gate catches the five
new public types, which are classified as validation machinery. The default
validator is unchanged, so this is additive; retiring the legacy Rule,
moving the default onto the check, the combinator algebra, the error
rendering, and de-duplicating the Tagged validator are the remaining steps.
2026-06-28 07:23:23 +02:00

153 lines
5.5 KiB
Swift

public extension PureYAML.Validation {
/// Traverses a YAML value tree and applies validation rules at each node.
struct Validator: Sendable {
public var rules: [Rule]
public var checks: [AnyCheck]
public init(rules: [Rule] = Self.defaultRules, checks: [AnyCheck] = []) {
self.rules = rules
self.checks = checks
}
public static var blank: Self {
Self(rules: [], checks: [])
}
public static var defaultRules: [Rule] {
[.mappingKeysAreUnique]
}
@discardableResult
public func validate(
_ value: PureYAML.Model.Value,
strict: Bool = true,
) throws -> [Issue] {
let result = collect(value)
let failures = strict ? result.issues : result.errors
if !failures.isEmpty {
throw Issue.Collection(failures)
}
return strict ? [] : result.warnings
}
public func collect(_ value: PureYAML.Model.Value) -> Result {
var issues: [Issue] = []
walk(root: value, subject: value, path: .root, issues: &issues)
return Result(issues)
}
@discardableResult
public func validate(
_ documents: [PureYAML.Stream.Document],
strict: Bool = true,
) throws -> [PureYAML.Stream.Issue] {
let result = collect(documents)
let failures = strict ? result.issues : result.errors
if !failures.isEmpty {
throw PureYAML.Stream.Issue.Collection(failures)
}
return strict ? [] : result.warnings
}
public func collect(_ documents: [PureYAML.Stream.Document]) -> PureYAML.Stream.Result {
var issues: [PureYAML.Stream.Issue] = []
for document in documents {
let result = collect(document.value)
issues.append(contentsOf: result.issues.map { issue in
PureYAML.Stream.Issue(documentIndex: document.index, issue: issue)
})
}
return PureYAML.Stream.Result(issues)
}
public func validating(_ rule: Rule) -> Self {
var copy = self
copy.rules.append(rule)
return copy
}
/// Adds a type-directed check, which fires only on values of its `Subject` type wherever they
/// occur in the tree.
public func validating(_ check: Check<some Any>) -> Self {
var copy = self
copy.checks.append(AnyCheck(check))
return copy
}
/// Removes every rule or check whose description matches, the identity the validation idiom
/// uses for removal. Descriptions are therefore required to be unique and stable.
public func withoutValidating(_ description: String) -> Self {
var copy = self
copy.rules.removeAll { $0.description == description }
copy.checks.removeAll { $0.description == description }
return copy
}
/// The descriptions of the active rules and checks, in application order: the fixed point a
/// configuration-pin test asserts against, so adding, removing, or rewording one changes it
/// deliberately.
public var validationDescriptions: [String] {
rules.map(\.description) + checks.map(\.description)
}
}
}
extension PureYAML.Validation.Validator {
func walk(
root: PureYAML.Model.Value,
subject: PureYAML.Model.Value,
path: PureYAML.Validation.Path,
issues: inout [PureYAML.Validation.Issue],
) {
let context = PureYAML.Validation.Context(root: root, subject: subject, path: path)
for rule in rules {
issues.append(contentsOf: rule.apply(context))
}
// Offer each type-directed check the values it may specialise on at this node: the value
// itself, and, for a mapping, the mapping and each of its keys. The erased check filters by
// runtime type, so a `Check<Model.Mapping>` ignores the value and key offers.
offer(subject, at: path, root: root, issues: &issues)
if case let .mapping(mapping) = subject {
offer(mapping, at: path, root: root, issues: &issues)
for pair in mapping.pairs {
offer(pair.keyNode, at: path.appending(pair.keyNode.pathComponent), root: root, issues: &issues)
}
}
switch subject {
case let .mapping(mapping):
for pair in mapping.pairs {
walk(
root: root,
subject: pair.value,
path: path.appending(pair.keyNode.pathComponent),
issues: &issues,
)
}
case let .sequence(values):
for index in values.indices {
walk(
root: root,
subject: values[index],
path: path.appending(.index(index)),
issues: &issues,
)
}
case .null, .bool, .int, .double, .string:
break
}
}
func offer(
_ subject: any PureYAML.Validation.Validatable,
at path: PureYAML.Validation.Path,
root: PureYAML.Model.Value,
issues: inout [PureYAML.Validation.Issue],
) {
for check in checks {
issues.append(contentsOf: check.apply(to: subject, at: path, in: root))
}
}
}