The tagged-node validator carried its own copy of the validation machinery: a Tagged.Rule cloning Validation's check type and a Tagged.Context cloning its context, differing only in subject type. That is the per-layer duplication the validation idiom exists to prevent (principle 10). Generalise the type-directed framework over both the subject and the document: SubjectContext, Check, and AnyCheck now take a Document type parameter for the validated tree's root. The model layer validates with a Model.Value document, the tagged layer with a Tagged.Node document, each truthful rather than forcing one root type to stand in for the other. The tagged layer keeps only what is genuinely its own: the Node document type, its built-in check (now Tagged.BuiltinCheck, a Check<Node, Node>), and its traversal. Tagged.Rule and the tagged Context are removed. The validator surface is unchanged but validating now takes a Check<Node, Node>. All 399 tests pass; the coverage gate reconciles to 62 public types.
153 lines
5.6 KiB
Swift
153 lines
5.6 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<PureYAML.Model.Value>]
|
|
|
|
public init(rules: [Rule] = Self.defaultRules, checks: [AnyCheck<PureYAML.Model.Value>] = []) {
|
|
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 Validatable, PureYAML.Model.Value>) -> 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))
|
|
}
|
|
}
|
|
}
|