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).
159 lines
6.8 KiB
Swift
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
|
|
}
|
|
}
|