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.
This commit is contained in:
parent
d3b4fab0d9
commit
2e45ecaffe
11
CHANGELOG.md
11
CHANGELOG.md
@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- Type-directed validation, the core of the validation-rules idiom: a `Check<Subject>` carries a
|
||||
positive description, a `when` predicate, and a Bool or multi-error check, and fires only on
|
||||
values of its `Subject` type wherever they occur in the tree. `AnyCheck` erases the subject type
|
||||
and dispatches by runtime type, guarding against an optional or a wrong type matching. The
|
||||
validator gains a `checks` list and a `validating(_:)` overload for checks, and the traversal
|
||||
offers each node the values it may specialise on (the value, and for a mapping the mapping and
|
||||
its keys). `BuiltinCheck.mappingKeysAreUnique` is the type-directed counterpart of the legacy
|
||||
rule, firing on `Model.Mapping` directly. The machinery has its own negative tests (wrong type,
|
||||
optional, and false predicate yield nothing; the same type twice yields two errors) and the
|
||||
builtin ships a failing and a near-miss test. The default validator is unchanged, so this is
|
||||
additive; the legacy `Rule` is retired in a later step.
|
||||
- The validator is now configurable by rule identity, per the validation-rules idiom:
|
||||
`withoutValidating(_ description:)` removes a rule by its description (the removal identity, so
|
||||
descriptions must be unique and stable), and `validationDescriptions` lists the active rules in
|
||||
|
||||
27
Sources/Validation/AnyCheck.swift
Normal file
27
Sources/Validation/AnyCheck.swift
Normal file
@ -0,0 +1,27 @@
|
||||
public extension PureYAML.Validation {
|
||||
/// A type-erased `Check`, so checks specialised on different subject types live in one list. It
|
||||
/// filters by runtime type: a `Check<Subject>` fires only on a `Subject` value, never on an
|
||||
/// optional of it (the `type(of:)` guard) or a value of any other type.
|
||||
struct AnyCheck: Sendable {
|
||||
public let description: String
|
||||
private let applyErased: @Sendable (any Validatable, Path, PureYAML.Model.Value) -> [Issue]
|
||||
|
||||
public init<Subject: Validatable>(_ check: Check<Subject>) {
|
||||
description = check.description
|
||||
applyErased = { subject, path, document in
|
||||
guard let typed = subject as? Subject, type(of: subject) == Subject.self else {
|
||||
return []
|
||||
}
|
||||
return check.apply(SubjectContext(document: document, subject: typed, path: path))
|
||||
}
|
||||
}
|
||||
|
||||
public func apply(
|
||||
to subject: any Validatable,
|
||||
at path: Path,
|
||||
in document: PureYAML.Model.Value,
|
||||
) -> [Issue] {
|
||||
applyErased(subject, path, document)
|
||||
}
|
||||
}
|
||||
}
|
||||
22
Sources/Validation/Check.Builtins.swift
Normal file
22
Sources/Validation/Check.Builtins.swift
Normal file
@ -0,0 +1,22 @@
|
||||
public extension PureYAML.Validation {
|
||||
/// The caseless namespace of built-in type-directed checks, referenceable by name.
|
||||
enum BuiltinCheck {
|
||||
/// Reports each duplicate key within a single mapping. The type-directed counterpart of the
|
||||
/// legacy `Rule.mappingKeysAreUnique`; fires on `Model.Mapping` values directly rather than
|
||||
/// switching on a `Model.Value`.
|
||||
public static var mappingKeysAreUnique: Check<PureYAML.Model.Mapping> {
|
||||
Check(description: "Mapping keys are unique") { context in
|
||||
var seen = Set<PureYAML.Model.Key>()
|
||||
var issues: [Issue] = []
|
||||
for pair in context.subject.pairs where !seen.insert(pair.keyNode).inserted {
|
||||
issues.append(Issue(
|
||||
severity: .error,
|
||||
reason: "Duplicate mapping key '\(pair.keyNode.description)'",
|
||||
path: context.path.appending(pair.keyNode.pathComponent),
|
||||
))
|
||||
}
|
||||
return issues
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
48
Sources/Validation/Check.swift
Normal file
48
Sources/Validation/Check.swift
Normal file
@ -0,0 +1,48 @@
|
||||
public extension PureYAML.Validation {
|
||||
/// A type-directed validation value (the `Validation<Subject>` of the validation idiom): a
|
||||
/// positive `description`, a `predicate` that gates application, and a multi-error `check`. It is
|
||||
/// applied only to values of type `Subject`, anywhere they occur in the traversed tree.
|
||||
struct Check<Subject: Validatable>: Sendable {
|
||||
public let description: String
|
||||
public let predicate: @Sendable (SubjectContext<Subject>) -> Bool
|
||||
public let check: @Sendable (SubjectContext<Subject>) -> [Issue]
|
||||
|
||||
/// Multi-error form: return one issue per problem. Use when a subject can fail several ways.
|
||||
public init(
|
||||
description: String,
|
||||
check: @escaping @Sendable (SubjectContext<Subject>) -> [Issue],
|
||||
when predicate: @escaping @Sendable (SubjectContext<Subject>) -> Bool = { _ in true },
|
||||
) {
|
||||
self.description = description
|
||||
self.predicate = predicate
|
||||
self.check = check
|
||||
}
|
||||
|
||||
/// Single-error Bool form: a positive description plus a check whose `false` auto-produces the
|
||||
/// one issue `"Failed to satisfy: <description>"`. The dominant form.
|
||||
public init(
|
||||
description: String,
|
||||
severity: Severity = .error,
|
||||
check: @escaping @Sendable (SubjectContext<Subject>) -> Bool,
|
||||
when predicate: @escaping @Sendable (SubjectContext<Subject>) -> Bool = { _ in true },
|
||||
) {
|
||||
self.init(
|
||||
description: description,
|
||||
check: { context in
|
||||
check(context)
|
||||
? []
|
||||
: [Issue(severity: severity, reason: "Failed to satisfy: \(description)", path: context.path)]
|
||||
},
|
||||
when: predicate,
|
||||
)
|
||||
}
|
||||
|
||||
/// Gates on the predicate, then runs the check.
|
||||
public func apply(_ context: SubjectContext<Subject>) -> [Issue] {
|
||||
guard predicate(context) else {
|
||||
return []
|
||||
}
|
||||
return check(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
19
Sources/Validation/SubjectContext.swift
Normal file
19
Sources/Validation/SubjectContext.swift
Normal file
@ -0,0 +1,19 @@
|
||||
public extension PureYAML.Validation {
|
||||
/// The read-only bundle a type-directed check receives: the whole `document` (for cross-cutting
|
||||
/// checks), the current `subject` of the check's type, and the `path` to where it sits.
|
||||
struct SubjectContext<Subject: Validatable>: Sendable {
|
||||
public var document: PureYAML.Model.Value
|
||||
public var subject: Subject
|
||||
public var path: Path
|
||||
|
||||
public init(
|
||||
document: PureYAML.Model.Value,
|
||||
subject: Subject,
|
||||
path: Path,
|
||||
) {
|
||||
self.document = document
|
||||
self.subject = subject
|
||||
self.path = path
|
||||
}
|
||||
}
|
||||
}
|
||||
10
Sources/Validation/Validatable.swift
Normal file
10
Sources/Validation/Validatable.swift
Normal file
@ -0,0 +1,10 @@
|
||||
public extension PureYAML.Validation {
|
||||
/// Marker for a value a type-directed validation can be applied to. A `Check<Subject>` fires on
|
||||
/// `Subject` values wherever they appear in the traversed tree; this protocol constrains the
|
||||
/// subjects so the erased dispatch can filter by runtime type.
|
||||
protocol Validatable: Sendable {}
|
||||
}
|
||||
|
||||
extension PureYAML.Model.Value: PureYAML.Validation.Validatable {}
|
||||
extension PureYAML.Model.Mapping: PureYAML.Validation.Validatable {}
|
||||
extension PureYAML.Model.Key: PureYAML.Validation.Validatable {}
|
||||
@ -2,13 +2,15 @@ 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) {
|
||||
public init(rules: [Rule] = Self.defaultRules, checks: [AnyCheck] = []) {
|
||||
self.rules = rules
|
||||
self.checks = checks
|
||||
}
|
||||
|
||||
public static var blank: Self {
|
||||
Self(rules: [])
|
||||
Self(rules: [], checks: [])
|
||||
}
|
||||
|
||||
public static var defaultRules: [Rule] {
|
||||
@ -64,19 +66,28 @@ public extension PureYAML.Validation {
|
||||
return copy
|
||||
}
|
||||
|
||||
/// Removes every rule whose description matches, the identity the validation idiom uses for
|
||||
/// removal. Descriptions are therefore required to be unique and stable across the rule set.
|
||||
public func withoutValidating(_ description: String) -> Self {
|
||||
/// 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.rules.removeAll { $0.description == description }
|
||||
copy.checks.append(AnyCheck(check))
|
||||
return copy
|
||||
}
|
||||
|
||||
/// The descriptions of the active rules, in application order: the fixed point a
|
||||
/// configuration-pin test asserts against, so adding, removing, or rewording a rule changes
|
||||
/// it deliberately.
|
||||
/// 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)
|
||||
rules.map(\.description) + checks.map(\.description)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -93,6 +104,17 @@ extension PureYAML.Validation.Validator {
|
||||
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 {
|
||||
@ -116,4 +138,15 @@ extension PureYAML.Validation.Validator {
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
99
Tests/ValidationCheckMachineryTests.swift
Normal file
99
Tests/ValidationCheckMachineryTests.swift
Normal file
@ -0,0 +1,99 @@
|
||||
@testable import PureYAML
|
||||
import Testing
|
||||
|
||||
@Suite("Type-directed check machinery")
|
||||
struct ValidationCheckMachineryTests {
|
||||
private typealias Check = PureYAML.Validation.Check<PureYAML.Model.Mapping>
|
||||
private typealias AnyCheck = PureYAML.Validation.AnyCheck
|
||||
private typealias Issue = PureYAML.Validation.Issue
|
||||
|
||||
private var mapping: PureYAML.Model.Mapping {
|
||||
.init([.init(key: "a", value: .int(1))])
|
||||
}
|
||||
|
||||
private func alwaysFails(description: String = "always fails") -> Check {
|
||||
Check(description: description) { context in
|
||||
[Issue(severity: .error, reason: "boom", path: context.path)]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: machinery negatives (the dispatch must not fire where it should not)
|
||||
|
||||
@Test("A check fires on its subject type and produces its error")
|
||||
func test_firesOnSubjectType() {
|
||||
let erased = AnyCheck(alwaysFails())
|
||||
#expect(erased.apply(to: mapping, at: .root, in: .null).count == 1)
|
||||
}
|
||||
|
||||
@Test("A check does not fire on a value of another type")
|
||||
func test_doesNotFireOnWrongType() {
|
||||
let erased = AnyCheck(alwaysFails())
|
||||
// A Model.Value (even one wrapping a mapping) is not a Model.Mapping, so nothing fires.
|
||||
#expect(erased.apply(to: PureYAML.Model.Value.mapping(mapping), at: .root, in: .null).isEmpty)
|
||||
#expect(erased.apply(to: PureYAML.Model.Key.string("a"), at: .root, in: .null).isEmpty)
|
||||
}
|
||||
|
||||
@Test("A false `when` predicate yields no errors")
|
||||
func test_falsePredicateYieldsNothing() {
|
||||
let gated = Check(description: "gated", check: { _ in
|
||||
[Issue(severity: .error, reason: "boom", path: .root)]
|
||||
}, when: { _ in false })
|
||||
#expect(AnyCheck(gated).apply(to: mapping, at: .root, in: .null).isEmpty)
|
||||
}
|
||||
|
||||
// MARK: positive control (proving the negatives are not vacuous)
|
||||
|
||||
@Test("The same subject type occurring twice yields two errors")
|
||||
func test_sameTypeTwiceYieldsTwoErrors() {
|
||||
// A document with two mappings; an always-failing Check<Mapping> must fire on both.
|
||||
let document = PureYAML.Model.Value.sequence(.init([
|
||||
.mapping(.init([.init(key: "a", value: .int(1))])),
|
||||
.mapping(.init([.init(key: "b", value: .int(2))])),
|
||||
]))
|
||||
let validator = PureYAML.Validation.Validator.blank.validating(alwaysFails())
|
||||
let issues = validator.collect(document).issues
|
||||
#expect(issues.count == 2)
|
||||
}
|
||||
|
||||
// MARK: the type-directed builtin (failing and a near miss)
|
||||
|
||||
@Test("The type-directed mappingKeysAreUnique builtin catches duplicate keys")
|
||||
func test_builtinCatchesDuplicateKeys() {
|
||||
let validator = PureYAML.Validation.Validator.blank.validating(
|
||||
PureYAML.Validation.BuiltinCheck.mappingKeysAreUnique,
|
||||
)
|
||||
#expect(validator.validationDescriptions == ["Mapping keys are unique"])
|
||||
#expect(throws: PureYAML.Validation.Issue.Collection.self) {
|
||||
try validator.validate(.mapping(.init([
|
||||
.init(key: "a", value: .int(1)),
|
||||
.init(key: "a", value: .int(2)),
|
||||
])))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("The builtin passes a near-miss document whose keys are unique")
|
||||
func test_builtinPassesUniqueKeys() throws {
|
||||
let validator = PureYAML.Validation.Validator.blank.validating(
|
||||
PureYAML.Validation.BuiltinCheck.mappingKeysAreUnique,
|
||||
)
|
||||
// The boundary the rule almost trips: two keys that differ by one character.
|
||||
_ = try validator.validate(.mapping(.init([
|
||||
.init(key: "a", value: .int(1)),
|
||||
.init(key: "b", value: .int(1)),
|
||||
])))
|
||||
}
|
||||
|
||||
@Test("Type-directed dispatch reaches a nested mapping")
|
||||
func test_dispatchReachesNestedMapping() {
|
||||
let validator = PureYAML.Validation.Validator.blank.validating(
|
||||
PureYAML.Validation.BuiltinCheck.mappingKeysAreUnique,
|
||||
)
|
||||
let nested = PureYAML.Model.Value.mapping(.init([
|
||||
.init(key: "outer", value: .mapping(.init([
|
||||
.init(key: "a", value: .int(1)),
|
||||
.init(key: "a", value: .int(2)),
|
||||
]))),
|
||||
]))
|
||||
#expect(!validator.collect(nested).errors.isEmpty)
|
||||
}
|
||||
}
|
||||
@ -81,3 +81,8 @@ PureYAML.Validation.BatchReport excluded diagnostic report data-transfer type
|
||||
PureYAML.Validation.Diagnostic excluded diagnostic data-transfer type
|
||||
PureYAML.Validation.DiagnosticKind excluded closed enum; illegal states unrepresentable
|
||||
PureYAML.Validation.PreflightScanner excluded pre-parse scanner engine; not a validated subject
|
||||
PureYAML.Validation.Validatable excluded marker protocol constraining check subjects; no instances or state of its own
|
||||
PureYAML.Validation.SubjectContext excluded read-only context passed to a type-directed check; not a subject
|
||||
PureYAML.Validation.Check excluded the type-directed validation value (Validation<Subject> role); a rule, not a subject
|
||||
PureYAML.Validation.AnyCheck excluded type-erased check; validation dispatch machinery
|
||||
PureYAML.Validation.BuiltinCheck excluded caseless namespace of built-in checks
|
||||
|
||||
|
Can't render this file because it has a wrong number of fields in line 3.
|
Loading…
Reference in New Issue
Block a user