// 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: @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.ValidationContext) -> Bool { let boxed = SendableKeyPath(keyPath: lhs) return { $0[keyPath: boxed.keyPath] == rhs } } public func != ( lhs: KeyPath, U>, rhs: U, ) -> @Sendable (PureYAML.Validation.ValidationContext) -> Bool { let boxed = SendableKeyPath(keyPath: lhs) return { $0[keyPath: boxed.keyPath] != rhs } } public func > ( lhs: KeyPath, U>, rhs: U, ) -> @Sendable (PureYAML.Validation.ValidationContext) -> Bool { let boxed = SendableKeyPath(keyPath: lhs) return { $0[keyPath: boxed.keyPath] > rhs } } public func >= ( lhs: KeyPath, U>, rhs: U, ) -> @Sendable (PureYAML.Validation.ValidationContext) -> Bool { let boxed = SendableKeyPath(keyPath: lhs) return { $0[keyPath: boxed.keyPath] >= rhs } } public func < ( lhs: KeyPath, U>, rhs: U, ) -> @Sendable (PureYAML.Validation.ValidationContext) -> Bool { let boxed = SendableKeyPath(keyPath: lhs) return { $0[keyPath: boxed.keyPath] < rhs } } public func <= ( lhs: KeyPath, U>, rhs: U, ) -> @Sendable (PureYAML.Validation.ValidationContext) -> 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 (ValidationContext) -> 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 (ValidationContext) -> 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(_ validations: Validation...) -> Validation { 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( _ keyPath: KeyPath, into validations: Validation..., ) -> Validation { 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( _ keyPath: KeyPath, into validations: Validation..., description: String? = nil, ) -> Validation { 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 && ( lhs: PureYAML.Validation.Validation, rhs: PureYAML.Validation.Validation, ) -> PureYAML.Validation.Validation { PureYAML.Validation.Validation(description: "\(lhs.description) and \(rhs.description)") { context in lhs.apply(context) + rhs.apply(context) } } public func || ( lhs: PureYAML.Validation.Validation, rhs: PureYAML.Validation.Validation, ) -> PureYAML.Validation.Validation { 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 } }