Add the declarative combinators the validation idiom composes rules from, as free functions returning closures: comparison operators (==, !=, <, <=, >, >=) lifting a key path and a literal into a predicate; take to dig to a value and run arbitrary logic; all to apply many checks to one context; lift to run child-typed checks against a value reached by key path; and && / || to compose two checks (concatenating their errors, or short-circuiting so errors surface only when both fail). Both the context-rooted and subject-rooted key-path forms are provided so an author can pin the subject type in either clause. A key path is immutable and read-safe but not declared Sendable, so a @Sendable predicate captures it through a small @unchecked Sendable box. lookup is omitted: it resolves references against a component store the generic model does not carry; a consumer with references supplies it. Each combinator has a pure-function test against a hand-built context, per the idiom's requirement to unit test the operators in isolation.
135 lines
5.2 KiB
Swift
135 lines
5.2 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.
|
|
|
|
/// 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, U: Equatable & Sendable>(
|
|
lhs: KeyPath<PureYAML.Validation.SubjectContext<S>, U>,
|
|
rhs: U,
|
|
) -> @Sendable (PureYAML.Validation.SubjectContext<S>) -> 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>,
|
|
rhs: U,
|
|
) -> @Sendable (PureYAML.Validation.SubjectContext<S>) -> 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>,
|
|
rhs: U,
|
|
) -> @Sendable (PureYAML.Validation.SubjectContext<S>) -> 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>,
|
|
rhs: U,
|
|
) -> @Sendable (PureYAML.Validation.SubjectContext<S>) -> 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>,
|
|
rhs: U,
|
|
) -> @Sendable (PureYAML.Validation.SubjectContext<S>) -> 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>,
|
|
rhs: U,
|
|
) -> @Sendable (PureYAML.Validation.SubjectContext<S>) -> 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, U: Sendable>(
|
|
_ keyPath: KeyPath<SubjectContext<S>, U>,
|
|
_ predicate: @escaping @Sendable (U) -> Bool,
|
|
) -> @Sendable (SubjectContext<S>) -> 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>(
|
|
_ keyPath: KeyPath<S, U>,
|
|
_ predicate: @escaping @Sendable (U) -> Bool,
|
|
) -> @Sendable (SubjectContext<S>) -> 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> {
|
|
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>(
|
|
_ keyPath: KeyPath<S, Child>,
|
|
into checks: Check<Child>...,
|
|
) -> Check<S> {
|
|
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>(
|
|
lhs: PureYAML.Validation.Check<S>,
|
|
rhs: PureYAML.Validation.Check<S>,
|
|
) -> PureYAML.Validation.Check<S> {
|
|
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> {
|
|
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
|
|
}
|
|
}
|