PureYAML/Sources/Validation/Validator.swift
Mihaela M d195dd3fa1 refactor(validation): retire the legacy Rule for one check type
A Rule was exactly a Check<Model.Value, Model.Value>: it fired on every
node, switched on the value itself, and carried the root. It was therefore a
second copy of the one check machinery, the duplication the validation idiom
exists to prevent (principle 10), now also resolved for the tagged layer.

Remove Rule and its Context. The model validator holds only checks; its
default is the type-directed BuiltinCheck.mappingKeysAreUnique; validating
takes a Check. Custom whole-tree rules become Check<Model.Value, Model.Value>
with the former context.root now context.document.

Behaviour is byte-identical: the mapping-key builtin reports the same issues
at the same paths in the same DFS order, so the public validate/collect/blank
surface is unchanged. All 399 tests pass; the coverage gate reconciles to 60
public types.
2026-06-28 16:43:31 +02:00

143 lines
5.4 KiB
Swift

public extension PureYAML.Validation {
/// Traverses a YAML value tree and applies type-directed checks at each node. The check value, its
/// erasure, and its context all come from the one validation framework (validation-rules.md,
/// principle 10); this layer adds only its document type (`Model.Value`), its built-in checks, and
/// its traversal.
struct Validator: Sendable {
public var checks: [AnyCheck<PureYAML.Model.Value>]
public init(checks: [AnyCheck<PureYAML.Model.Value>] = Self.defaultChecks) {
self.checks = checks
}
public static var blank: Self {
Self(checks: [])
}
public static var defaultChecks: [AnyCheck<PureYAML.Model.Value>] {
[AnyCheck(BuiltinCheck.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)
}
/// 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 Validatable, PureYAML.Model.Value>) -> Self {
var copy = self
copy.checks.append(AnyCheck(check))
return copy
}
/// Removes every 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.checks.removeAll { $0.description == description }
return copy
}
/// The descriptions of the active 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] {
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],
) {
// 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, and a
// `Check<Model.Value>` (a whole-tree rule) fires on the value offer.
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))
}
}
}