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.
139 lines
5.6 KiB
Swift
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
|
|
}
|
|
}
|