From 1915a00fdef99862ee8b3a40214d4b7f8d8ab88e Mon Sep 17 00:00:00 2001 From: Mihaela M Date: Sun, 28 Jun 2026 09:27:17 +0200 Subject: [PATCH] feat(validation): add the combinator algebra for checks 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. --- Sources/Validation/Check.Combinators.swift | 134 +++++++++++++++++++++ Tests/ValidationCombinatorTests.swift | 85 +++++++++++++ 2 files changed, 219 insertions(+) create mode 100644 Sources/Validation/Check.Combinators.swift create mode 100644 Tests/ValidationCombinatorTests.swift diff --git a/Sources/Validation/Check.Combinators.swift b/Sources/Validation/Check.Combinators.swift new file mode 100644 index 0000000..db2b3aa --- /dev/null +++ b/Sources/Validation/Check.Combinators.swift @@ -0,0 +1,134 @@ +// 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: @unchecked Sendable { + let keyPath: KeyPath +} + +// MARK: comparison operators lifting a key path and a literal into a predicate + +public func == ( + lhs: KeyPath, U>, + rhs: U, +) -> @Sendable (PureYAML.Validation.SubjectContext) -> Bool { + let boxed = SendableKeyPath(keyPath: lhs) + return { $0[keyPath: boxed.keyPath] == rhs } +} + +public func != ( + lhs: KeyPath, U>, + rhs: U, +) -> @Sendable (PureYAML.Validation.SubjectContext) -> Bool { + let boxed = SendableKeyPath(keyPath: lhs) + return { $0[keyPath: boxed.keyPath] != rhs } +} + +public func > ( + lhs: KeyPath, U>, + rhs: U, +) -> @Sendable (PureYAML.Validation.SubjectContext) -> Bool { + let boxed = SendableKeyPath(keyPath: lhs) + return { $0[keyPath: boxed.keyPath] > rhs } +} + +public func >= ( + lhs: KeyPath, U>, + rhs: U, +) -> @Sendable (PureYAML.Validation.SubjectContext) -> Bool { + let boxed = SendableKeyPath(keyPath: lhs) + return { $0[keyPath: boxed.keyPath] >= rhs } +} + +public func < ( + lhs: KeyPath, U>, + rhs: U, +) -> @Sendable (PureYAML.Validation.SubjectContext) -> Bool { + let boxed = SendableKeyPath(keyPath: lhs) + return { $0[keyPath: boxed.keyPath] < rhs } +} + +public func <= ( + lhs: KeyPath, U>, + rhs: U, +) -> @Sendable (PureYAML.Validation.SubjectContext) -> 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( + _ keyPath: KeyPath, U>, + _ predicate: @escaping @Sendable (U) -> Bool, + ) -> @Sendable (SubjectContext) -> 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( + _ keyPath: KeyPath, + _ predicate: @escaping @Sendable (U) -> Bool, + ) -> @Sendable (SubjectContext) -> 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(_ checks: Check...) -> Check { + 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( + _ keyPath: KeyPath, + into checks: Check..., + ) -> Check { + 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 && ( + lhs: PureYAML.Validation.Check, + rhs: PureYAML.Validation.Check, +) -> PureYAML.Validation.Check { + PureYAML.Validation.Check(description: "\(lhs.description) and \(rhs.description)") { context in + lhs.apply(context) + rhs.apply(context) + } +} + +public func || ( + lhs: PureYAML.Validation.Check, + rhs: PureYAML.Validation.Check, +) -> PureYAML.Validation.Check { + 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 + } +} diff --git a/Tests/ValidationCombinatorTests.swift b/Tests/ValidationCombinatorTests.swift new file mode 100644 index 0000000..5c8f14a --- /dev/null +++ b/Tests/ValidationCombinatorTests.swift @@ -0,0 +1,85 @@ +@testable import PureYAML +import Testing + +private struct Address: PureYAML.Validation.Validatable { + var city: String +} + +private struct Person: PureYAML.Validation.Validatable { + var name: String + var age: Int + var address: Address +} + +@Suite("Validation combinators") +struct ValidationCombinatorTests { + private typealias Context = PureYAML.Validation.SubjectContext + + private func context(name: String = "Ada", age: Int = 30, city: String = "Lovelace") -> Context { + .init(document: .null, subject: Person(name: name, age: age, address: Address(city: city)), path: .root) + } + + private func failing(_ description: String) -> PureYAML.Validation.Check { + .init(description: description) { ctx in + [PureYAML.Validation.Issue(severity: .error, reason: description, path: ctx.path)] + } + } + + private func passing(_ description: String) -> PureYAML.Validation.Check { + .init(description: description) { _ in [] } + } + + @Test("Comparison operators lift a key path and a literal into a predicate") + func test_comparisonOperators() { + let isAdult = (\Context.subject.age) >= 18 + #expect(isAdult(context(age: 30)) == true) + #expect(isAdult(context(age: 17)) == false) + let named = (\Context.subject.name) == "Ada" + #expect(named(context(name: "Ada")) == true) + #expect(named(context(name: "Bob")) == false) + } + + @Test("take digs to a value and runs arbitrary logic") + func test_take() { + let cityIsSet = PureYAML.Validation.take(\Person.address) { !$0.city.isEmpty } + #expect(cityIsSet(context(city: "Lovelace")) == true) + #expect(cityIsSet(context(city: "")) == false) + } + + @Test("A Bool check built from an operator auto-formats its failure") + func test_boolCheckFromOperator() { + let adult = PureYAML.Validation.Check(description: "Person is an adult", check: (\Context.subject.age) >= 18) + #expect(adult.apply(context(age: 30)).isEmpty) + let issues = adult.apply(context(age: 10)) + #expect(issues.count == 1) + #expect(issues.first?.reason == "Failed to satisfy: Person is an adult") + } + + @Test("all applies every check to the same context, concatenating errors") + func test_all() { + let combined = PureYAML.Validation.all(failing("one"), passing("ok"), failing("two")) + #expect(combined.apply(context()).map(\.reason) == ["one", "two"]) + } + + @Test("&& concatenates both error lists") + func test_andConcatenates() { + let combined = failing("a") && failing("b") + #expect(combined.apply(context()).map(\.reason) == ["a", "b"]) + #expect((passing("a") && passing("b")).apply(context()).isEmpty) + } + + @Test("|| short-circuits and errors only when both fail") + func test_orShortCircuits() { + #expect((passing("a") || failing("b")).apply(context()).isEmpty) + #expect((failing("a") || passing("b")).apply(context()).isEmpty) + #expect((failing("a") || failing("b")).apply(context()).map(\.reason) == ["a", "b"]) + } + + @Test("lift runs child-typed checks against a value reached by key path") + func test_lift() { + let cityNotEmpty = PureYAML.Validation.Check
(description: "City is set", check: { !$0.subject.city.isEmpty }) + let lifted = PureYAML.Validation.lift(\Person.address, into: cityNotEmpty) + #expect(lifted.apply(context(city: "Lovelace")).isEmpty) + #expect(lifted.apply(context(city: "")).count == 1) + } +}