PureYAML/Sources/Validation/Check.Combinators.swift
Mihaela M fc1a6db1dc 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.
2026-06-28 10:41:10 +02:00

139 lines
5.6 KiB
Swift

// The combinator algebra for type-directed checks (validation-rules.md, "The combinator algebra").
// Declarative building blocks composed by value: comparison operators and `take` lift a subject or
// context value into a Bool predicate; `&&` / `||` compose checks (concatenating or short-circuiting
// 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.
private struct SendableKeyPath<Root, Value>: @unchecked Sendable {
let keyPath: KeyPath<Root, Value>
}
// MARK: comparison operators lifting a key path and a literal into a predicate
public func == <S, D, U: Equatable & Sendable>(
lhs: KeyPath<PureYAML.Validation.SubjectContext<S, D>, U>,
rhs: U,
) -> @Sendable (PureYAML.Validation.SubjectContext<S, D>) -> Bool {
let boxed = SendableKeyPath(keyPath: lhs)
return { $0[keyPath: boxed.keyPath] == rhs }
}
public func != <S, D, U: Equatable & Sendable>(
lhs: KeyPath<PureYAML.Validation.SubjectContext<S, D>, U>,
rhs: U,
) -> @Sendable (PureYAML.Validation.SubjectContext<S, D>) -> Bool {
let boxed = SendableKeyPath(keyPath: lhs)
return { $0[keyPath: boxed.keyPath] != rhs }
}
public func > <S, D, U: Comparable & Sendable>(
lhs: KeyPath<PureYAML.Validation.SubjectContext<S, D>, U>,
rhs: U,
) -> @Sendable (PureYAML.Validation.SubjectContext<S, D>) -> Bool {
let boxed = SendableKeyPath(keyPath: lhs)
return { $0[keyPath: boxed.keyPath] > rhs }
}
public func >= <S, D, U: Comparable & Sendable>(
lhs: KeyPath<PureYAML.Validation.SubjectContext<S, D>, U>,
rhs: U,
) -> @Sendable (PureYAML.Validation.SubjectContext<S, D>) -> Bool {
let boxed = SendableKeyPath(keyPath: lhs)
return { $0[keyPath: boxed.keyPath] >= rhs }
}
public func < <S, D, U: Comparable & Sendable>(
lhs: KeyPath<PureYAML.Validation.SubjectContext<S, D>, U>,
rhs: U,
) -> @Sendable (PureYAML.Validation.SubjectContext<S, D>) -> Bool {
let boxed = SendableKeyPath(keyPath: lhs)
return { $0[keyPath: boxed.keyPath] < rhs }
}
public func <= <S, D, U: Comparable & Sendable>(
lhs: KeyPath<PureYAML.Validation.SubjectContext<S, D>, U>,
rhs: U,
) -> @Sendable (PureYAML.Validation.SubjectContext<S, D>) -> Bool {
let boxed = SendableKeyPath(keyPath: lhs)
return { $0[keyPath: boxed.keyPath] <= rhs }
}
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, D, U: Sendable>(
_ keyPath: KeyPath<SubjectContext<S, D>, U>,
_ predicate: @escaping @Sendable (U) -> 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, D, U: Sendable>(
_ keyPath: KeyPath<S, U>,
_ predicate: @escaping @Sendable (U) -> 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, 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) }
}
}
/// 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, D>(
_ keyPath: KeyPath<S, Child>,
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
let childContext = SubjectContext(
document: context.document,
subject: context.subject[keyPath: boxed.keyPath],
path: context.path,
)
return list.flatMap { $0.apply(childContext) }
}
}
}
// MARK: composing two checks (&& concatenates errors, || short-circuits)
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, 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 {
return []
}
let right = rhs.apply(context)
return right.isEmpty ? [] : left + right
}
}