PureYAML/Sources/Validation/ValidationCombinators.swift
Mihaela Mihaljevic bf2dc4ece7 refactor(validation): rename framework to match the validation idiom
The validation module already implemented the composable-rule idiom under
its own names (Check/Issue/SubjectContext) and a value-type Validator with
duplicated traversal machinery per layer. Renamed the core types to the
canonical names (Validation/ValidationError/ValidationErrorCollection/
ValidationContext/BuiltinValidation/AnyValidation), converted Validator to
a reference-type class with the two-tier default-rule split, and genericized
it over both Subject and Document via a new Traversable protocol so the
model and tagged layers share one walk engine instead of two copies. Wired
the JSON-representability rule set into the same shape and closed the two
missing pieces (unwrap combinator, JSON coverage/config-pin tests).
2026-07-12 18:10:00 +02:00

159 lines
6.8 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; `unwrap` errors on a nil
// optional, else runs child checks. `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.ValidationContext<S, D>, U>,
rhs: U,
) -> @Sendable (PureYAML.Validation.ValidationContext<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.ValidationContext<S, D>, U>,
rhs: U,
) -> @Sendable (PureYAML.Validation.ValidationContext<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.ValidationContext<S, D>, U>,
rhs: U,
) -> @Sendable (PureYAML.Validation.ValidationContext<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.ValidationContext<S, D>, U>,
rhs: U,
) -> @Sendable (PureYAML.Validation.ValidationContext<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.ValidationContext<S, D>, U>,
rhs: U,
) -> @Sendable (PureYAML.Validation.ValidationContext<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.ValidationContext<S, D>, U>,
rhs: U,
) -> @Sendable (PureYAML.Validation.ValidationContext<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<ValidationContext<S, D>, U>,
_ predicate: @escaping @Sendable (U) -> Bool,
) -> @Sendable (ValidationContext<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 (ValidationContext<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>(_ validations: Validation<S, D>...) -> Validation<S, D> {
let list = validations
return Validation(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 validations: Validation<Child, D>...,
) -> Validation<S, D> {
let list = validations
let boxed = SendableKeyPath(keyPath: keyPath)
return Validation(description: "child: \(list.map(\.description).joined(separator: ", "))") { context in
let childContext = ValidationContext(
document: context.document,
subject: context.subject[keyPath: boxed.keyPath],
path: context.path,
)
return list.flatMap { $0.apply(childContext) }
}
}
/// Unwraps an optional reached through a key path, erroring if nil, else running child checks
/// against the unwrapped value.
static func unwrap<S, Child: Validatable, D>(
_ keyPath: KeyPath<S, Child?>,
into validations: Validation<Child, D>...,
description: String? = nil,
) -> Validation<S, D> {
let list = validations
let boxed = SendableKeyPath(keyPath: keyPath)
return Validation(description: description ?? "unwraps: \(list.map(\.description).joined(separator: ", "))") { context in
guard let child = context.subject[keyPath: boxed.keyPath] else {
let reason = description.map { "Tried to unwrap but found nil: \($0)" } ?? "Tried to unwrap an optional and found nil"
return [ValidationError(severity: .error, reason: reason, path: context.path)]
}
let childContext = ValidationContext(document: context.document, subject: child, path: context.path)
return list.flatMap { $0.apply(childContext) }
}
}
}
// MARK: composing two checks (&& concatenates errors, || short-circuits)
public func && <S, D>(
lhs: PureYAML.Validation.Validation<S, D>,
rhs: PureYAML.Validation.Validation<S, D>,
) -> PureYAML.Validation.Validation<S, D> {
PureYAML.Validation.Validation(description: "\(lhs.description) and \(rhs.description)") { context in
lhs.apply(context) + rhs.apply(context)
}
}
public func || <S, D>(
lhs: PureYAML.Validation.Validation<S, D>,
rhs: PureYAML.Validation.Validation<S, D>,
) -> PureYAML.Validation.Validation<S, D> {
PureYAML.Validation.Validation(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
}
}