refactor(validation): fold the tagged validator onto one framework

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.
This commit is contained in:
Mihaela M 2026-06-28 10:41:10 +02:00
parent f901a3f04c
commit fc1a6db1dc
17 changed files with 213 additions and 234 deletions

View File

@ -15,9 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
fixtures or any fixture for a rule that no longer ships. Each rule is also exercised in isolation,
failing its failing fixture and passing its near miss. A planted orphan fixture was confirmed to
fail the meta-test.
- 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
- Type-directed validation, the core of the validation-rules idiom: a `Check<Subject, Document>`
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
@ -53,6 +53,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
scalar resolution, block-scalar indentation) which parse-level cannot see. This
turns "best YAML library" from an assertion into a tracked number.
### Changed
- The tagged-node validator no longer carries its own copy of the validation machinery. The
type-directed `Check`, its erasure `AnyCheck`, and its `SubjectContext` are now generic over both
the subject and the document (the validated tree's root type), so one framework serves both layers:
the model tree validates with a `Model.Value` document, the tagged tree with a `Tagged.Node`
document. `Tagged.Rule` and the tagged validation `Context` are removed; the tagged validator keeps
only its own document type, its built-in check (`Tagged.BuiltinCheck`), and its traversal. This is
the one-framework, no-per-layer-duplication discipline of the validation-rules idiom (principle 10);
the tagged validator's public surface (`validate`, `collect`, `blank`, `validating`) is unchanged
except that `validating` now takes a `Check<Tagged.Node, Tagged.Node>` instead of a `Tagged.Rule`.
### Fixed
- Content after a document-end marker (`...`) now begins a new bare document. A stream such as

View File

@ -0,0 +1,88 @@
public extension PureYAML.Tagged {
/// The caseless namespace of built-in type-directed checks for the tagged-node tree, the tagged
/// layer's counterpart of `Validation.BuiltinCheck`. Each is a `Check<Node, Node>` on the one
/// shared validation framework, not a copy of the machinery (validation-rules.md, principle 10).
enum BuiltinCheck {
/// Reports unsupported built-in tags and built-in tags applied to the wrong node kind, on the
/// node itself and on each mapping key.
public static var builtInTagsAreSupportedAndKindConsistent: PureYAML.Validation.Check<Node, Node> {
PureYAML.Validation.Check(description: "Built-in tags are supported and kind-consistent") { context in
var issues: [PureYAML.Validation.Issue] = []
if case let .mapping(mapping) = context.subject {
issues.append(contentsOf: mapping.pairs.compactMap { pair in
keyTagIssue(for: pair, at: context.path.appending(.key(pair.key)))
})
}
guard let tag = context.subject.tag, tag.isBuiltIn else {
return issues
}
if let expectedKind = tag.expectedKind, expectedKind != context.subject.kind {
issues.append(
PureYAML.Validation.Issue(
severity: .error,
reason: "Tag '\(tag.rawValue)' expects a \(expectedKind) node, found \(context.subject.kind)",
path: context.path,
),
)
return issues
}
if !tag.isSupportedBuiltInTag {
issues.append(
PureYAML.Validation.Issue(
severity: .error,
reason: "Unsupported built-in tag '\(tag.rawValue)'",
path: context.path,
),
)
}
return issues
} when: { context in
if context.subject.tag?.isBuiltIn == true {
return true
}
if case let .mapping(mapping) = context.subject {
return mapping.pairs.contains { $0.keyTag?.isBuiltIn == true }
}
return false
}
}
static func keyTagIssue(
for pair: PureYAML.Tagged.Pair,
at path: PureYAML.Validation.Path,
) -> PureYAML.Validation.Issue? {
guard let tag = pair.keyTag, tag.isBuiltIn else {
return nil
}
if tag == .merge, pair.key == "<<" {
return nil
}
if tag == .merge {
return PureYAML.Validation.Issue(
severity: .error,
reason: "Tag '\(tag.rawValue)' is only supported on '<<' mapping keys",
path: path,
)
}
if let expectedKind = tag.expectedKind, expectedKind != .scalar {
return PureYAML.Validation.Issue(
severity: .error,
reason: "Tag '\(tag.rawValue)' expects a scalar mapping key",
path: path,
)
}
guard tag.isSupportedBuiltInTag else {
return PureYAML.Validation.Issue(
severity: .error,
reason: "Unsupported built-in tag '\(tag.rawValue)' on mapping key",
path: path,
)
}
return nil
}
}
}

View File

@ -1,18 +0,0 @@
public extension PureYAML.Tagged {
/// Context passed to a tagged-node validation rule.
struct Context: Sendable {
public var root: Node
public var subject: Node
public var path: PureYAML.Validation.Path
public init(
root: Node,
subject: Node,
path: PureYAML.Validation.Path,
) {
self.root = root
self.subject = subject
self.path = path
}
}
}

View File

@ -1,82 +0,0 @@
public extension PureYAML.Tagged.Rule {
/// Reports unsupported built-in tags and built-in tags applied to the wrong node kind.
static var builtInTagsAreSupportedAndKindConsistent: Self {
Self(description: "Built-in tags are supported and kind-consistent") { context in
var issues: [PureYAML.Validation.Issue] = []
if case let .mapping(mapping) = context.subject {
issues.append(contentsOf: mapping.pairs.compactMap { pair in
keyTagIssue(for: pair, at: context.path.appending(.key(pair.key)))
})
}
guard let tag = context.subject.tag, tag.isBuiltIn else {
return issues
}
if let expectedKind = tag.expectedKind, expectedKind != context.subject.kind {
issues.append(
PureYAML.Validation.Issue(
severity: .error,
reason: "Tag '\(tag.rawValue)' expects a \(expectedKind) node, found \(context.subject.kind)",
path: context.path,
),
)
return issues
}
if !tag.isSupportedBuiltInTag {
issues.append(
PureYAML.Validation.Issue(
severity: .error,
reason: "Unsupported built-in tag '\(tag.rawValue)'",
path: context.path,
),
)
}
return issues
} when: { context in
if context.subject.tag?.isBuiltIn == true {
return true
}
if case let .mapping(mapping) = context.subject {
return mapping.pairs.contains { $0.keyTag?.isBuiltIn == true }
}
return false
}
}
static func keyTagIssue(
for pair: PureYAML.Tagged.Pair,
at path: PureYAML.Validation.Path,
) -> PureYAML.Validation.Issue? {
guard let tag = pair.keyTag, tag.isBuiltIn else {
return nil
}
if tag == .merge, pair.key == "<<" {
return nil
}
if tag == .merge {
return PureYAML.Validation.Issue(
severity: .error,
reason: "Tag '\(tag.rawValue)' is only supported on '<<' mapping keys",
path: path,
)
}
if let expectedKind = tag.expectedKind, expectedKind != .scalar {
return PureYAML.Validation.Issue(
severity: .error,
reason: "Tag '\(tag.rawValue)' expects a scalar mapping key",
path: path,
)
}
guard tag.isSupportedBuiltInTag else {
return PureYAML.Validation.Issue(
severity: .error,
reason: "Unsupported built-in tag '\(tag.rawValue)' on mapping key",
path: path,
)
}
return nil
}
}

View File

@ -1,48 +0,0 @@
public extension PureYAML.Tagged {
/// A validation rule for tag-preserving YAML nodes.
struct Rule: Sendable {
public let description: String
public let predicate: @Sendable (Context) -> Bool
public let check: @Sendable (Context) -> [PureYAML.Validation.Issue]
public init(
description: String,
check: @escaping @Sendable (Context) -> [PureYAML.Validation.Issue],
when predicate: @escaping @Sendable (Context) -> Bool = { _ in true },
) {
self.description = description
self.predicate = predicate
self.check = check
}
public init(
description: String,
severity: PureYAML.Validation.Severity = .error,
check: @escaping @Sendable (Context) -> Bool,
when predicate: @escaping @Sendable (Context) -> Bool = { _ in true },
) {
self.init(
description: description,
check: { context in
check(context)
? []
: [
PureYAML.Validation.Issue(
severity: severity,
reason: "Failed to satisfy: \(description)",
path: context.path,
),
]
},
when: predicate,
)
}
public func apply(_ context: Context) -> [PureYAML.Validation.Issue] {
guard predicate(context) else {
return []
}
return check(context)
}
}
}

View File

@ -1,18 +1,21 @@
public extension PureYAML.Tagged {
/// Traverses a tag-preserving YAML node tree and applies validation rules.
/// Traverses a tag-preserving YAML node tree and applies type-directed checks. The checks, their
/// erasure, and their context all come from the one validation framework: this layer adds only its
/// own document type (`Node`), its built-in check, and its traversal (validation-rules.md,
/// principle 10).
struct Validator: Sendable {
public var rules: [Rule]
public var checks: [PureYAML.Validation.AnyCheck<Node>]
public init(rules: [Rule] = Self.defaultRules) {
self.rules = rules
public init(checks: [PureYAML.Validation.AnyCheck<Node>] = Self.defaultChecks) {
self.checks = checks
}
public static var blank: Self {
Self(rules: [])
Self(checks: [])
}
public static var defaultRules: [Rule] {
[.builtInTagsAreSupportedAndKindConsistent]
public static var defaultChecks: [PureYAML.Validation.AnyCheck<Node>] {
[PureYAML.Validation.AnyCheck(BuiltinCheck.builtInTagsAreSupportedAndKindConsistent)]
}
@discardableResult
@ -58,11 +61,18 @@ public extension PureYAML.Tagged {
return PureYAML.Stream.Result(issues)
}
public func validating(_ rule: Rule) -> Self {
/// Adds a type-directed check, which fires only on the `Node` values it specialises on
/// wherever they occur in the tree.
public func validating(_ check: PureYAML.Validation.Check<Node, Node>) -> Self {
var copy = self
copy.rules.append(rule)
copy.checks.append(PureYAML.Validation.AnyCheck(check))
return copy
}
/// The descriptions of the active checks, in application order: the configuration fixed point.
public var validationDescriptions: [String] {
checks.map(\.description)
}
}
}
@ -73,9 +83,10 @@ extension PureYAML.Tagged.Validator {
path: PureYAML.Validation.Path,
issues: inout [PureYAML.Validation.Issue],
) {
let context = PureYAML.Tagged.Context(root: root, subject: subject, path: path)
for rule in rules {
issues.append(contentsOf: rule.apply(context))
// The document is the whole tagged tree; each check filters by runtime type, so a
// `Check<Node, Node>` fires on every node offered here.
for check in checks {
issues.append(contentsOf: check.apply(to: subject, at: path, in: root))
}
switch subject {

View File

@ -1,12 +1,13 @@
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 {
/// A type-erased `Check`, so checks specialised on different subject types live in one list, all
/// sharing one `Document` (the validated tree's root type). It filters by runtime type: a
/// `Check<Subject, Document>` 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<Document: Sendable>: Sendable {
public let description: String
private let applyErased: @Sendable (any Validatable, Path, PureYAML.Model.Value) -> [Issue]
private let applyErased: @Sendable (any Validatable, Path, Document) -> [Issue]
public init<Subject: Validatable>(_ check: Check<Subject>) {
public init<Subject: Validatable>(_ check: Check<Subject, Document>) {
description = check.description
applyErased = { subject, path, document in
guard let typed = subject as? Subject, type(of: subject) == Subject.self else {
@ -19,7 +20,7 @@ public extension PureYAML.Validation {
public func apply(
to subject: any Validatable,
at path: Path,
in document: PureYAML.Model.Value,
in document: Document,
) -> [Issue] {
applyErased(subject, path, document)
}

View File

@ -4,7 +4,7 @@ public extension PureYAML.Validation {
/// 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> {
public static var mappingKeysAreUnique: Check<PureYAML.Model.Mapping, PureYAML.Model.Value> {
Check(description: "Mapping keys are unique") { context in
var seen = Set<PureYAML.Model.Key>()
var issues: [Issue] = []

View File

@ -4,6 +4,10 @@
// their errors); `all` and `lift` apply many checks to one or a child value. `lookup` is omitted: it
// resolves references against a component store, which the generic model does not carry; a consumer
// with references supplies it over its own document.
//
// Each combinator is generic over both the subject `S` and the document `D` (the validated tree's
// root type), so the same algebra serves the model layer (`D == Model.Value`) and the tagged layer
// (`D == Tagged.Node`).
/// A key path is immutable and safe to read concurrently, but the standard library does not declare
/// it `Sendable`, so wrap one captured by a `@Sendable` predicate closure.
@ -13,50 +17,50 @@ private struct SendableKeyPath<Root, Value>: @unchecked Sendable {
// MARK: comparison operators lifting a key path and a literal into a predicate
public func == <S, U: Equatable & Sendable>(
lhs: KeyPath<PureYAML.Validation.SubjectContext<S>, U>,
public func == <S, D, U: Equatable & Sendable>(
lhs: KeyPath<PureYAML.Validation.SubjectContext<S, D>, U>,
rhs: U,
) -> @Sendable (PureYAML.Validation.SubjectContext<S>) -> Bool {
) -> @Sendable (PureYAML.Validation.SubjectContext<S, D>) -> Bool {
let boxed = SendableKeyPath(keyPath: lhs)
return { $0[keyPath: boxed.keyPath] == rhs }
}
public func != <S, U: Equatable & Sendable>(
lhs: KeyPath<PureYAML.Validation.SubjectContext<S>, U>,
public func != <S, D, U: Equatable & Sendable>(
lhs: KeyPath<PureYAML.Validation.SubjectContext<S, D>, U>,
rhs: U,
) -> @Sendable (PureYAML.Validation.SubjectContext<S>) -> Bool {
) -> @Sendable (PureYAML.Validation.SubjectContext<S, D>) -> Bool {
let boxed = SendableKeyPath(keyPath: lhs)
return { $0[keyPath: boxed.keyPath] != rhs }
}
public func > <S, U: Comparable & Sendable>(
lhs: KeyPath<PureYAML.Validation.SubjectContext<S>, U>,
public func > <S, D, U: Comparable & Sendable>(
lhs: KeyPath<PureYAML.Validation.SubjectContext<S, D>, U>,
rhs: U,
) -> @Sendable (PureYAML.Validation.SubjectContext<S>) -> Bool {
) -> @Sendable (PureYAML.Validation.SubjectContext<S, D>) -> Bool {
let boxed = SendableKeyPath(keyPath: lhs)
return { $0[keyPath: boxed.keyPath] > rhs }
}
public func >= <S, U: Comparable & Sendable>(
lhs: KeyPath<PureYAML.Validation.SubjectContext<S>, U>,
public func >= <S, D, U: Comparable & Sendable>(
lhs: KeyPath<PureYAML.Validation.SubjectContext<S, D>, U>,
rhs: U,
) -> @Sendable (PureYAML.Validation.SubjectContext<S>) -> Bool {
) -> @Sendable (PureYAML.Validation.SubjectContext<S, D>) -> Bool {
let boxed = SendableKeyPath(keyPath: lhs)
return { $0[keyPath: boxed.keyPath] >= rhs }
}
public func < <S, U: Comparable & Sendable>(
lhs: KeyPath<PureYAML.Validation.SubjectContext<S>, U>,
public func < <S, D, U: Comparable & Sendable>(
lhs: KeyPath<PureYAML.Validation.SubjectContext<S, D>, U>,
rhs: U,
) -> @Sendable (PureYAML.Validation.SubjectContext<S>) -> Bool {
) -> @Sendable (PureYAML.Validation.SubjectContext<S, D>) -> Bool {
let boxed = SendableKeyPath(keyPath: lhs)
return { $0[keyPath: boxed.keyPath] < rhs }
}
public func <= <S, U: Comparable & Sendable>(
lhs: KeyPath<PureYAML.Validation.SubjectContext<S>, U>,
public func <= <S, D, U: Comparable & Sendable>(
lhs: KeyPath<PureYAML.Validation.SubjectContext<S, D>, U>,
rhs: U,
) -> @Sendable (PureYAML.Validation.SubjectContext<S>) -> Bool {
) -> @Sendable (PureYAML.Validation.SubjectContext<S, D>) -> Bool {
let boxed = SendableKeyPath(keyPath: lhs)
return { $0[keyPath: boxed.keyPath] <= rhs }
}
@ -64,25 +68,25 @@ public func <= <S, U: Comparable & Sendable>(
public extension PureYAML.Validation {
/// Digs to a value through a key path on the context and runs arbitrary logic, the escape hatch
/// beyond equality.
static func take<S, U: Sendable>(
_ keyPath: KeyPath<SubjectContext<S>, U>,
static func take<S, D, U: Sendable>(
_ keyPath: KeyPath<SubjectContext<S, D>, U>,
_ predicate: @escaping @Sendable (U) -> Bool,
) -> @Sendable (SubjectContext<S>) -> Bool {
) -> @Sendable (SubjectContext<S, D>) -> Bool {
let boxed = SendableKeyPath(keyPath: keyPath)
return { predicate($0[keyPath: boxed.keyPath]) }
}
/// `take` rooted through the subject, so an author can write `take(\.someChild) { ... }`.
static func take<S, U: Sendable>(
static func take<S, D, U: Sendable>(
_ keyPath: KeyPath<S, U>,
_ predicate: @escaping @Sendable (U) -> Bool,
) -> @Sendable (SubjectContext<S>) -> Bool {
) -> @Sendable (SubjectContext<S, D>) -> Bool {
let boxed = SendableKeyPath(keyPath: keyPath)
return { predicate($0.subject[keyPath: boxed.keyPath]) }
}
/// Applies every check to the same context, concatenating their errors.
static func all<S>(_ checks: Check<S>...) -> Check<S> {
static func all<S, D>(_ checks: Check<S, D>...) -> Check<S, D> {
let list = checks
return Check(description: "all of: \(list.map(\.description).joined(separator: ", "))") { context in
list.flatMap { $0.apply(context) }
@ -91,10 +95,10 @@ public extension PureYAML.Validation {
/// Runs child-typed checks against a value reached through a key path, keeping the parent's path
/// and document.
static func lift<S, Child: Validatable>(
static func lift<S, Child: Validatable, D>(
_ keyPath: KeyPath<S, Child>,
into checks: Check<Child>...,
) -> Check<S> {
into checks: Check<Child, D>...,
) -> Check<S, D> {
let list = checks
let boxed = SendableKeyPath(keyPath: keyPath)
return Check(description: "child: \(list.map(\.description).joined(separator: ", "))") { context in
@ -110,19 +114,19 @@ public extension PureYAML.Validation {
// MARK: composing two checks (&& concatenates errors, || short-circuits)
public func && <S>(
lhs: PureYAML.Validation.Check<S>,
rhs: PureYAML.Validation.Check<S>,
) -> PureYAML.Validation.Check<S> {
public func && <S, D>(
lhs: PureYAML.Validation.Check<S, D>,
rhs: PureYAML.Validation.Check<S, D>,
) -> PureYAML.Validation.Check<S, D> {
PureYAML.Validation.Check(description: "\(lhs.description) and \(rhs.description)") { context in
lhs.apply(context) + rhs.apply(context)
}
}
public func || <S>(
lhs: PureYAML.Validation.Check<S>,
rhs: PureYAML.Validation.Check<S>,
) -> PureYAML.Validation.Check<S> {
public func || <S, D>(
lhs: PureYAML.Validation.Check<S, D>,
rhs: PureYAML.Validation.Check<S, D>,
) -> PureYAML.Validation.Check<S, D> {
PureYAML.Validation.Check(description: "\(lhs.description) or \(rhs.description)") { context in
let left = lhs.apply(context)
guard !left.isEmpty else {

View File

@ -1,17 +1,18 @@
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 {
/// applied only to values of type `Subject`, anywhere they occur in the traversed tree. `Document`
/// is the type of the validated tree's root, the same for every check a given validator runs.
struct Check<Subject: Validatable, Document: Sendable>: Sendable {
public let description: String
public let predicate: @Sendable (SubjectContext<Subject>) -> Bool
public let check: @Sendable (SubjectContext<Subject>) -> [Issue]
public let predicate: @Sendable (SubjectContext<Subject, Document>) -> Bool
public let check: @Sendable (SubjectContext<Subject, Document>) -> [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 },
check: @escaping @Sendable (SubjectContext<Subject, Document>) -> [Issue],
when predicate: @escaping @Sendable (SubjectContext<Subject, Document>) -> Bool = { _ in true },
) {
self.description = description
self.predicate = predicate
@ -23,8 +24,8 @@ public extension PureYAML.Validation {
public init(
description: String,
severity: Severity = .error,
check: @escaping @Sendable (SubjectContext<Subject>) -> Bool,
when predicate: @escaping @Sendable (SubjectContext<Subject>) -> Bool = { _ in true },
check: @escaping @Sendable (SubjectContext<Subject, Document>) -> Bool,
when predicate: @escaping @Sendable (SubjectContext<Subject, Document>) -> Bool = { _ in true },
) {
self.init(
description: description,
@ -38,7 +39,7 @@ public extension PureYAML.Validation {
}
/// Gates on the predicate, then runs the check.
public func apply(_ context: SubjectContext<Subject>) -> [Issue] {
public func apply(_ context: SubjectContext<Subject, Document>) -> [Issue] {
guard predicate(context) else {
return []
}

View File

@ -1,13 +1,15 @@
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
/// checks), the current `subject` of the check's type, and the `path` to where it sits. `Document`
/// is the type of the validated tree's root, so each layer carries a truthful document: `Model.Value`
/// for the model tree, `Tagged.Node` for the tagged tree.
struct SubjectContext<Subject: Validatable, Document: Sendable>: Sendable {
public var document: Document
public var subject: Subject
public var path: Path
public init(
document: PureYAML.Model.Value,
document: Document,
subject: Subject,
path: Path,
) {

View File

@ -8,3 +8,4 @@ public extension PureYAML.Validation {
extension PureYAML.Model.Value: PureYAML.Validation.Validatable {}
extension PureYAML.Model.Mapping: PureYAML.Validation.Validatable {}
extension PureYAML.Model.Key: PureYAML.Validation.Validatable {}
extension PureYAML.Tagged.Node: PureYAML.Validation.Validatable {}

View File

@ -2,9 +2,9 @@ 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 var checks: [AnyCheck<PureYAML.Model.Value>]
public init(rules: [Rule] = Self.defaultRules, checks: [AnyCheck] = []) {
public init(rules: [Rule] = Self.defaultRules, checks: [AnyCheck<PureYAML.Model.Value>] = []) {
self.rules = rules
self.checks = checks
}
@ -68,7 +68,7 @@ public extension PureYAML.Validation {
/// 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 {
public func validating(_ check: Check<some Validatable, PureYAML.Model.Value>) -> Self {
var copy = self
copy.checks.append(AnyCheck(check))
return copy

View File

@ -210,7 +210,9 @@ struct TaggedValidationCompatibilityTests {
@Test("Custom tagged validation rules inspect application tags exactly")
func test_customTaggedValidationRulesInspectApplicationTagsExactly() throws {
let node = try PureYAML.parseTagged("value: !Local one")
let localTagsAreWarnings = PureYAML.Tagged.Rule(description: "Local tags are warnings") { context in
let localTagsAreWarnings = PureYAML.Validation.Check<PureYAML.Tagged.Node, PureYAML.Tagged.Node>(
description: "Local tags are warnings",
) { context in
guard context.subject.tag == .init("!Local") else {
return []
}

View File

@ -3,8 +3,8 @@ 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 Check = PureYAML.Validation.Check<PureYAML.Model.Mapping, PureYAML.Model.Value>
private typealias AnyCheck = PureYAML.Validation.AnyCheck<PureYAML.Model.Value>
private typealias Issue = PureYAML.Validation.Issue
private var mapping: PureYAML.Model.Mapping {

View File

@ -13,19 +13,19 @@ private struct Person: PureYAML.Validation.Validatable {
@Suite("Validation combinators")
struct ValidationCombinatorTests {
private typealias Context = PureYAML.Validation.SubjectContext<Person>
private typealias Context = PureYAML.Validation.SubjectContext<Person, PureYAML.Model.Value>
private func context(name: String = "Ada", age: Int = 30, city: String = "Lovelace") -> Context {
.init(document: .null, subject: Person(name: name, age: age, address: Address(city: city)), path: .root)
}
private func failing(_ description: String) -> PureYAML.Validation.Check<Person> {
private func failing(_ description: String) -> PureYAML.Validation.Check<Person, PureYAML.Model.Value> {
.init(description: description) { ctx in
[PureYAML.Validation.Issue(severity: .error, reason: description, path: ctx.path)]
}
}
private func passing(_ description: String) -> PureYAML.Validation.Check<Person> {
private func passing(_ description: String) -> PureYAML.Validation.Check<Person, PureYAML.Model.Value> {
.init(description: description) { _ in [] }
}
@ -41,14 +41,17 @@ struct ValidationCombinatorTests {
@Test("take digs to a value and runs arbitrary logic")
func test_take() {
let cityIsSet = PureYAML.Validation.take(\Person.address) { !$0.city.isEmpty }
let cityIsSet: @Sendable (Context) -> Bool = PureYAML.Validation.take(\Person.address) { !$0.city.isEmpty }
#expect(cityIsSet(context(city: "Lovelace")) == true)
#expect(cityIsSet(context(city: "")) == false)
}
@Test("A Bool check built from an operator auto-formats its failure")
func test_boolCheckFromOperator() {
let adult = PureYAML.Validation.Check<Person>(description: "Person is an adult", check: (\Context.subject.age) >= 18)
let adult = PureYAML.Validation.Check<Person, PureYAML.Model.Value>(
description: "Person is an adult",
check: (\Context.subject.age) >= 18,
)
#expect(adult.apply(context(age: 30)).isEmpty)
let issues = adult.apply(context(age: 10))
#expect(issues.count == 1)
@ -77,7 +80,10 @@ struct ValidationCombinatorTests {
@Test("lift runs child-typed checks against a value reached by key path")
func test_lift() {
let cityNotEmpty = PureYAML.Validation.Check<Address>(description: "City is set", check: { !$0.subject.city.isEmpty })
let cityNotEmpty = PureYAML.Validation.Check<Address, PureYAML.Model.Value>(
description: "City is set",
check: { !$0.subject.city.isEmpty },
)
let lifted = PureYAML.Validation.lift(\Person.address, into: cityNotEmpty)
#expect(lifted.apply(context(city: "Lovelace")).isEmpty)
#expect(lifted.apply(context(city: "")).count == 1)

View File

@ -56,12 +56,11 @@ PureYAML.Tagged.Sequence excluded data-transfer ordered nodes
PureYAML.Tagged.Tag excluded data-transfer tag identifier
PureYAML.Tagged.NodeKind excluded closed enum; illegal states unrepresentable
PureYAML.Tagged.Document excluded wraps a validated Tagged.Node
PureYAML.Tagged.Context excluded construction context data-transfer type
PureYAML.Tagged.ConstructionContext excluded construction context data-transfer type
PureYAML.Tagged.ConstructionError excluded error enum; data-transfer
PureYAML.Tagged.Constructor excluded tag-construction engine; not a validated subject
PureYAML.Tagged.Rule excluded validation machinery; duplicates Validation.Rule, slated for de-dup (principle 10)
PureYAML.Tagged.Validator excluded validation machinery; duplicates Validation.Validator, slated for de-dup (principle 10)
PureYAML.Tagged.BuiltinCheck excluded caseless namespace of the tagged layer's built-in checks
PureYAML.Tagged.Validator excluded tagged-tree traversal over the shared Check framework; not a validated subject
# --- validation framework's own types ---
PureYAML.Validation.Validator excluded validation machinery; not a validated subject

Can't render this file because it has a wrong number of fields in line 3.