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.
143 lines
5.4 KiB
Swift
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))
|
|
}
|
|
}
|
|
}
|