refactor(validation): fold the tagged validator onto one framework
The tagged-node validator carried its own copy of the validation machinery: a Tagged.Rule cloning Validation's check type and a Tagged.Context cloning its context, differing only in subject type. That is the per-layer duplication the validation idiom exists to prevent (principle 10). Generalise the type-directed framework over both the subject and the document: SubjectContext, Check, and AnyCheck now take a Document type parameter for the validated tree's root. The model layer validates with a Model.Value document, the tagged layer with a Tagged.Node document, each truthful rather than forcing one root type to stand in for the other. The tagged layer keeps only what is genuinely its own: the Node document type, its built-in check (now Tagged.BuiltinCheck, a Check<Node, Node>), and its traversal. Tagged.Rule and the tagged Context are removed. The validator surface is unchanged but validating now takes a Check<Node, Node>. All 399 tests pass; the coverage gate reconciles to 62 public types.
This commit is contained in:
parent
f901a3f04c
commit
fc1a6db1dc
18
CHANGELOG.md
18
CHANGELOG.md
@ -15,9 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
fixtures or any fixture for a rule that no longer ships. Each rule is also exercised in isolation,
|
||||
failing its failing fixture and passing its near miss. A planted orphan fixture was confirmed to
|
||||
fail the meta-test.
|
||||
- Type-directed validation, the core of the validation-rules idiom: a `Check<Subject>` carries a
|
||||
positive description, a `when` predicate, and a Bool or multi-error check, and fires only on
|
||||
values of its `Subject` type wherever they occur in the tree. `AnyCheck` erases the subject type
|
||||
- Type-directed validation, the core of the validation-rules idiom: a `Check<Subject, Document>`
|
||||
carries a positive description, a `when` predicate, and a Bool or multi-error check, and fires only
|
||||
on values of its `Subject` type wherever they occur in the tree. `AnyCheck` erases the subject type
|
||||
and dispatches by runtime type, guarding against an optional or a wrong type matching. The
|
||||
validator gains a `checks` list and a `validating(_:)` overload for checks, and the traversal
|
||||
offers each node the values it may specialise on (the value, and for a mapping the mapping and
|
||||
@ -53,6 +53,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
scalar resolution, block-scalar indentation) which parse-level cannot see. This
|
||||
turns "best YAML library" from an assertion into a tracked number.
|
||||
|
||||
### Changed
|
||||
|
||||
- The tagged-node validator no longer carries its own copy of the validation machinery. The
|
||||
type-directed `Check`, its erasure `AnyCheck`, and its `SubjectContext` are now generic over both
|
||||
the subject and the document (the validated tree's root type), so one framework serves both layers:
|
||||
the model tree validates with a `Model.Value` document, the tagged tree with a `Tagged.Node`
|
||||
document. `Tagged.Rule` and the tagged validation `Context` are removed; the tagged validator keeps
|
||||
only its own document type, its built-in check (`Tagged.BuiltinCheck`), and its traversal. This is
|
||||
the one-framework, no-per-layer-duplication discipline of the validation-rules idiom (principle 10);
|
||||
the tagged validator's public surface (`validate`, `collect`, `blank`, `validating`) is unchanged
|
||||
except that `validating` now takes a `Check<Tagged.Node, Tagged.Node>` instead of a `Tagged.Rule`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Content after a document-end marker (`...`) now begins a new bare document. A stream such as
|
||||
|
||||
88
Sources/Tagged/Tagged.Check.Builtins.swift
Normal file
88
Sources/Tagged/Tagged.Check.Builtins.swift
Normal file
@ -0,0 +1,88 @@
|
||||
public extension PureYAML.Tagged {
|
||||
/// The caseless namespace of built-in type-directed checks for the tagged-node tree, the tagged
|
||||
/// layer's counterpart of `Validation.BuiltinCheck`. Each is a `Check<Node, Node>` on the one
|
||||
/// shared validation framework, not a copy of the machinery (validation-rules.md, principle 10).
|
||||
enum BuiltinCheck {
|
||||
/// Reports unsupported built-in tags and built-in tags applied to the wrong node kind, on the
|
||||
/// node itself and on each mapping key.
|
||||
public static var builtInTagsAreSupportedAndKindConsistent: PureYAML.Validation.Check<Node, Node> {
|
||||
PureYAML.Validation.Check(description: "Built-in tags are supported and kind-consistent") { context in
|
||||
var issues: [PureYAML.Validation.Issue] = []
|
||||
if case let .mapping(mapping) = context.subject {
|
||||
issues.append(contentsOf: mapping.pairs.compactMap { pair in
|
||||
keyTagIssue(for: pair, at: context.path.appending(.key(pair.key)))
|
||||
})
|
||||
}
|
||||
|
||||
guard let tag = context.subject.tag, tag.isBuiltIn else {
|
||||
return issues
|
||||
}
|
||||
|
||||
if let expectedKind = tag.expectedKind, expectedKind != context.subject.kind {
|
||||
issues.append(
|
||||
PureYAML.Validation.Issue(
|
||||
severity: .error,
|
||||
reason: "Tag '\(tag.rawValue)' expects a \(expectedKind) node, found \(context.subject.kind)",
|
||||
path: context.path,
|
||||
),
|
||||
)
|
||||
return issues
|
||||
}
|
||||
|
||||
if !tag.isSupportedBuiltInTag {
|
||||
issues.append(
|
||||
PureYAML.Validation.Issue(
|
||||
severity: .error,
|
||||
reason: "Unsupported built-in tag '\(tag.rawValue)'",
|
||||
path: context.path,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return issues
|
||||
} when: { context in
|
||||
if context.subject.tag?.isBuiltIn == true {
|
||||
return true
|
||||
}
|
||||
if case let .mapping(mapping) = context.subject {
|
||||
return mapping.pairs.contains { $0.keyTag?.isBuiltIn == true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
static func keyTagIssue(
|
||||
for pair: PureYAML.Tagged.Pair,
|
||||
at path: PureYAML.Validation.Path,
|
||||
) -> PureYAML.Validation.Issue? {
|
||||
guard let tag = pair.keyTag, tag.isBuiltIn else {
|
||||
return nil
|
||||
}
|
||||
if tag == .merge, pair.key == "<<" {
|
||||
return nil
|
||||
}
|
||||
if tag == .merge {
|
||||
return PureYAML.Validation.Issue(
|
||||
severity: .error,
|
||||
reason: "Tag '\(tag.rawValue)' is only supported on '<<' mapping keys",
|
||||
path: path,
|
||||
)
|
||||
}
|
||||
if let expectedKind = tag.expectedKind, expectedKind != .scalar {
|
||||
return PureYAML.Validation.Issue(
|
||||
severity: .error,
|
||||
reason: "Tag '\(tag.rawValue)' expects a scalar mapping key",
|
||||
path: path,
|
||||
)
|
||||
}
|
||||
guard tag.isSupportedBuiltInTag else {
|
||||
return PureYAML.Validation.Issue(
|
||||
severity: .error,
|
||||
reason: "Unsupported built-in tag '\(tag.rawValue)' on mapping key",
|
||||
path: path,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,18 +0,0 @@
|
||||
public extension PureYAML.Tagged {
|
||||
/// Context passed to a tagged-node validation rule.
|
||||
struct Context: Sendable {
|
||||
public var root: Node
|
||||
public var subject: Node
|
||||
public var path: PureYAML.Validation.Path
|
||||
|
||||
public init(
|
||||
root: Node,
|
||||
subject: Node,
|
||||
path: PureYAML.Validation.Path,
|
||||
) {
|
||||
self.root = root
|
||||
self.subject = subject
|
||||
self.path = path
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,82 +0,0 @@
|
||||
public extension PureYAML.Tagged.Rule {
|
||||
/// Reports unsupported built-in tags and built-in tags applied to the wrong node kind.
|
||||
static var builtInTagsAreSupportedAndKindConsistent: Self {
|
||||
Self(description: "Built-in tags are supported and kind-consistent") { context in
|
||||
var issues: [PureYAML.Validation.Issue] = []
|
||||
if case let .mapping(mapping) = context.subject {
|
||||
issues.append(contentsOf: mapping.pairs.compactMap { pair in
|
||||
keyTagIssue(for: pair, at: context.path.appending(.key(pair.key)))
|
||||
})
|
||||
}
|
||||
|
||||
guard let tag = context.subject.tag, tag.isBuiltIn else {
|
||||
return issues
|
||||
}
|
||||
|
||||
if let expectedKind = tag.expectedKind, expectedKind != context.subject.kind {
|
||||
issues.append(
|
||||
PureYAML.Validation.Issue(
|
||||
severity: .error,
|
||||
reason: "Tag '\(tag.rawValue)' expects a \(expectedKind) node, found \(context.subject.kind)",
|
||||
path: context.path,
|
||||
),
|
||||
)
|
||||
return issues
|
||||
}
|
||||
|
||||
if !tag.isSupportedBuiltInTag {
|
||||
issues.append(
|
||||
PureYAML.Validation.Issue(
|
||||
severity: .error,
|
||||
reason: "Unsupported built-in tag '\(tag.rawValue)'",
|
||||
path: context.path,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return issues
|
||||
} when: { context in
|
||||
if context.subject.tag?.isBuiltIn == true {
|
||||
return true
|
||||
}
|
||||
if case let .mapping(mapping) = context.subject {
|
||||
return mapping.pairs.contains { $0.keyTag?.isBuiltIn == true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
static func keyTagIssue(
|
||||
for pair: PureYAML.Tagged.Pair,
|
||||
at path: PureYAML.Validation.Path,
|
||||
) -> PureYAML.Validation.Issue? {
|
||||
guard let tag = pair.keyTag, tag.isBuiltIn else {
|
||||
return nil
|
||||
}
|
||||
if tag == .merge, pair.key == "<<" {
|
||||
return nil
|
||||
}
|
||||
if tag == .merge {
|
||||
return PureYAML.Validation.Issue(
|
||||
severity: .error,
|
||||
reason: "Tag '\(tag.rawValue)' is only supported on '<<' mapping keys",
|
||||
path: path,
|
||||
)
|
||||
}
|
||||
if let expectedKind = tag.expectedKind, expectedKind != .scalar {
|
||||
return PureYAML.Validation.Issue(
|
||||
severity: .error,
|
||||
reason: "Tag '\(tag.rawValue)' expects a scalar mapping key",
|
||||
path: path,
|
||||
)
|
||||
}
|
||||
guard tag.isSupportedBuiltInTag else {
|
||||
return PureYAML.Validation.Issue(
|
||||
severity: .error,
|
||||
reason: "Unsupported built-in tag '\(tag.rawValue)' on mapping key",
|
||||
path: path,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@ -1,48 +0,0 @@
|
||||
public extension PureYAML.Tagged {
|
||||
/// A validation rule for tag-preserving YAML nodes.
|
||||
struct Rule: Sendable {
|
||||
public let description: String
|
||||
public let predicate: @Sendable (Context) -> Bool
|
||||
public let check: @Sendable (Context) -> [PureYAML.Validation.Issue]
|
||||
|
||||
public init(
|
||||
description: String,
|
||||
check: @escaping @Sendable (Context) -> [PureYAML.Validation.Issue],
|
||||
when predicate: @escaping @Sendable (Context) -> Bool = { _ in true },
|
||||
) {
|
||||
self.description = description
|
||||
self.predicate = predicate
|
||||
self.check = check
|
||||
}
|
||||
|
||||
public init(
|
||||
description: String,
|
||||
severity: PureYAML.Validation.Severity = .error,
|
||||
check: @escaping @Sendable (Context) -> Bool,
|
||||
when predicate: @escaping @Sendable (Context) -> Bool = { _ in true },
|
||||
) {
|
||||
self.init(
|
||||
description: description,
|
||||
check: { context in
|
||||
check(context)
|
||||
? []
|
||||
: [
|
||||
PureYAML.Validation.Issue(
|
||||
severity: severity,
|
||||
reason: "Failed to satisfy: \(description)",
|
||||
path: context.path,
|
||||
),
|
||||
]
|
||||
},
|
||||
when: predicate,
|
||||
)
|
||||
}
|
||||
|
||||
public func apply(_ context: Context) -> [PureYAML.Validation.Issue] {
|
||||
guard predicate(context) else {
|
||||
return []
|
||||
}
|
||||
return check(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,18 +1,21 @@
|
||||
public extension PureYAML.Tagged {
|
||||
/// Traverses a tag-preserving YAML node tree and applies validation rules.
|
||||
/// Traverses a tag-preserving YAML node tree and applies type-directed checks. The checks, their
|
||||
/// erasure, and their context all come from the one validation framework: this layer adds only its
|
||||
/// own document type (`Node`), its built-in check, and its traversal (validation-rules.md,
|
||||
/// principle 10).
|
||||
struct Validator: Sendable {
|
||||
public var rules: [Rule]
|
||||
public var checks: [PureYAML.Validation.AnyCheck<Node>]
|
||||
|
||||
public init(rules: [Rule] = Self.defaultRules) {
|
||||
self.rules = rules
|
||||
public init(checks: [PureYAML.Validation.AnyCheck<Node>] = Self.defaultChecks) {
|
||||
self.checks = checks
|
||||
}
|
||||
|
||||
public static var blank: Self {
|
||||
Self(rules: [])
|
||||
Self(checks: [])
|
||||
}
|
||||
|
||||
public static var defaultRules: [Rule] {
|
||||
[.builtInTagsAreSupportedAndKindConsistent]
|
||||
public static var defaultChecks: [PureYAML.Validation.AnyCheck<Node>] {
|
||||
[PureYAML.Validation.AnyCheck(BuiltinCheck.builtInTagsAreSupportedAndKindConsistent)]
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
@ -58,11 +61,18 @@ public extension PureYAML.Tagged {
|
||||
return PureYAML.Stream.Result(issues)
|
||||
}
|
||||
|
||||
public func validating(_ rule: Rule) -> Self {
|
||||
/// Adds a type-directed check, which fires only on the `Node` values it specialises on
|
||||
/// wherever they occur in the tree.
|
||||
public func validating(_ check: PureYAML.Validation.Check<Node, Node>) -> Self {
|
||||
var copy = self
|
||||
copy.rules.append(rule)
|
||||
copy.checks.append(PureYAML.Validation.AnyCheck(check))
|
||||
return copy
|
||||
}
|
||||
|
||||
/// The descriptions of the active checks, in application order: the configuration fixed point.
|
||||
public var validationDescriptions: [String] {
|
||||
checks.map(\.description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -73,9 +83,10 @@ extension PureYAML.Tagged.Validator {
|
||||
path: PureYAML.Validation.Path,
|
||||
issues: inout [PureYAML.Validation.Issue],
|
||||
) {
|
||||
let context = PureYAML.Tagged.Context(root: root, subject: subject, path: path)
|
||||
for rule in rules {
|
||||
issues.append(contentsOf: rule.apply(context))
|
||||
// The document is the whole tagged tree; each check filters by runtime type, so a
|
||||
// `Check<Node, Node>` fires on every node offered here.
|
||||
for check in checks {
|
||||
issues.append(contentsOf: check.apply(to: subject, at: path, in: root))
|
||||
}
|
||||
|
||||
switch subject {
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
public extension PureYAML.Validation {
|
||||
/// A type-erased `Check`, so checks specialised on different subject types live in one list. It
|
||||
/// filters by runtime type: a `Check<Subject>` fires only on a `Subject` value, never on an
|
||||
/// optional of it (the `type(of:)` guard) or a value of any other type.
|
||||
struct AnyCheck: Sendable {
|
||||
/// A type-erased `Check`, so checks specialised on different subject types live in one list, all
|
||||
/// sharing one `Document` (the validated tree's root type). It filters by runtime type: a
|
||||
/// `Check<Subject, Document>` fires only on a `Subject` value, never on an optional of it (the
|
||||
/// `type(of:)` guard) or a value of any other type.
|
||||
struct AnyCheck<Document: Sendable>: Sendable {
|
||||
public let description: String
|
||||
private let applyErased: @Sendable (any Validatable, Path, PureYAML.Model.Value) -> [Issue]
|
||||
private let applyErased: @Sendable (any Validatable, Path, Document) -> [Issue]
|
||||
|
||||
public init<Subject: Validatable>(_ check: Check<Subject>) {
|
||||
public init<Subject: Validatable>(_ check: Check<Subject, Document>) {
|
||||
description = check.description
|
||||
applyErased = { subject, path, document in
|
||||
guard let typed = subject as? Subject, type(of: subject) == Subject.self else {
|
||||
@ -19,7 +20,7 @@ public extension PureYAML.Validation {
|
||||
public func apply(
|
||||
to subject: any Validatable,
|
||||
at path: Path,
|
||||
in document: PureYAML.Model.Value,
|
||||
in document: Document,
|
||||
) -> [Issue] {
|
||||
applyErased(subject, path, document)
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ public extension PureYAML.Validation {
|
||||
/// Reports each duplicate key within a single mapping. The type-directed counterpart of the
|
||||
/// legacy `Rule.mappingKeysAreUnique`; fires on `Model.Mapping` values directly rather than
|
||||
/// switching on a `Model.Value`.
|
||||
public static var mappingKeysAreUnique: Check<PureYAML.Model.Mapping> {
|
||||
public static var mappingKeysAreUnique: Check<PureYAML.Model.Mapping, PureYAML.Model.Value> {
|
||||
Check(description: "Mapping keys are unique") { context in
|
||||
var seen = Set<PureYAML.Model.Key>()
|
||||
var issues: [Issue] = []
|
||||
|
||||
@ -4,6 +4,10 @@
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
@ -13,50 +17,50 @@ private struct SendableKeyPath<Root, Value>: @unchecked Sendable {
|
||||
|
||||
// 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>,
|
||||
public func == <S, D, U: Equatable & Sendable>(
|
||||
lhs: KeyPath<PureYAML.Validation.SubjectContext<S, D>, U>,
|
||||
rhs: U,
|
||||
) -> @Sendable (PureYAML.Validation.SubjectContext<S>) -> Bool {
|
||||
) -> @Sendable (PureYAML.Validation.SubjectContext<S, D>) -> 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>,
|
||||
public func != <S, D, U: Equatable & Sendable>(
|
||||
lhs: KeyPath<PureYAML.Validation.SubjectContext<S, D>, U>,
|
||||
rhs: U,
|
||||
) -> @Sendable (PureYAML.Validation.SubjectContext<S>) -> Bool {
|
||||
) -> @Sendable (PureYAML.Validation.SubjectContext<S, D>) -> 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>,
|
||||
public func > <S, D, U: Comparable & Sendable>(
|
||||
lhs: KeyPath<PureYAML.Validation.SubjectContext<S, D>, U>,
|
||||
rhs: U,
|
||||
) -> @Sendable (PureYAML.Validation.SubjectContext<S>) -> Bool {
|
||||
) -> @Sendable (PureYAML.Validation.SubjectContext<S, D>) -> 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>,
|
||||
public func >= <S, D, U: Comparable & Sendable>(
|
||||
lhs: KeyPath<PureYAML.Validation.SubjectContext<S, D>, U>,
|
||||
rhs: U,
|
||||
) -> @Sendable (PureYAML.Validation.SubjectContext<S>) -> Bool {
|
||||
) -> @Sendable (PureYAML.Validation.SubjectContext<S, D>) -> 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>,
|
||||
public func < <S, D, U: Comparable & Sendable>(
|
||||
lhs: KeyPath<PureYAML.Validation.SubjectContext<S, D>, U>,
|
||||
rhs: U,
|
||||
) -> @Sendable (PureYAML.Validation.SubjectContext<S>) -> Bool {
|
||||
) -> @Sendable (PureYAML.Validation.SubjectContext<S, D>) -> 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>,
|
||||
public func <= <S, D, U: Comparable & Sendable>(
|
||||
lhs: KeyPath<PureYAML.Validation.SubjectContext<S, D>, U>,
|
||||
rhs: U,
|
||||
) -> @Sendable (PureYAML.Validation.SubjectContext<S>) -> Bool {
|
||||
) -> @Sendable (PureYAML.Validation.SubjectContext<S, D>) -> Bool {
|
||||
let boxed = SendableKeyPath(keyPath: lhs)
|
||||
return { $0[keyPath: boxed.keyPath] <= rhs }
|
||||
}
|
||||
@ -64,25 +68,25 @@ public func <= <S, U: Comparable & Sendable>(
|
||||
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>,
|
||||
static func take<S, D, U: Sendable>(
|
||||
_ keyPath: KeyPath<SubjectContext<S, D>, U>,
|
||||
_ predicate: @escaping @Sendable (U) -> Bool,
|
||||
) -> @Sendable (SubjectContext<S>) -> Bool {
|
||||
) -> @Sendable (SubjectContext<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, U: Sendable>(
|
||||
static func take<S, D, U: Sendable>(
|
||||
_ keyPath: KeyPath<S, U>,
|
||||
_ predicate: @escaping @Sendable (U) -> Bool,
|
||||
) -> @Sendable (SubjectContext<S>) -> Bool {
|
||||
) -> @Sendable (SubjectContext<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>(_ checks: Check<S>...) -> Check<S> {
|
||||
static func all<S, D>(_ checks: Check<S, D>...) -> Check<S, D> {
|
||||
let list = checks
|
||||
return Check(description: "all of: \(list.map(\.description).joined(separator: ", "))") { context in
|
||||
list.flatMap { $0.apply(context) }
|
||||
@ -91,10 +95,10 @@ public extension PureYAML.Validation {
|
||||
|
||||
/// 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>(
|
||||
static func lift<S, Child: Validatable, D>(
|
||||
_ keyPath: KeyPath<S, Child>,
|
||||
into checks: Check<Child>...,
|
||||
) -> Check<S> {
|
||||
into checks: Check<Child, D>...,
|
||||
) -> Check<S, D> {
|
||||
let list = checks
|
||||
let boxed = SendableKeyPath(keyPath: keyPath)
|
||||
return Check(description: "child: \(list.map(\.description).joined(separator: ", "))") { context in
|
||||
@ -110,19 +114,19 @@ public extension PureYAML.Validation {
|
||||
|
||||
// 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> {
|
||||
public func && <S, D>(
|
||||
lhs: PureYAML.Validation.Check<S, D>,
|
||||
rhs: PureYAML.Validation.Check<S, D>,
|
||||
) -> PureYAML.Validation.Check<S, D> {
|
||||
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> {
|
||||
public func || <S, D>(
|
||||
lhs: PureYAML.Validation.Check<S, D>,
|
||||
rhs: PureYAML.Validation.Check<S, D>,
|
||||
) -> PureYAML.Validation.Check<S, D> {
|
||||
PureYAML.Validation.Check(description: "\(lhs.description) or \(rhs.description)") { context in
|
||||
let left = lhs.apply(context)
|
||||
guard !left.isEmpty else {
|
||||
|
||||
@ -1,17 +1,18 @@
|
||||
public extension PureYAML.Validation {
|
||||
/// A type-directed validation value (the `Validation<Subject>` of the validation idiom): a
|
||||
/// positive `description`, a `predicate` that gates application, and a multi-error `check`. It is
|
||||
/// applied only to values of type `Subject`, anywhere they occur in the traversed tree.
|
||||
struct Check<Subject: Validatable>: Sendable {
|
||||
/// applied only to values of type `Subject`, anywhere they occur in the traversed tree. `Document`
|
||||
/// is the type of the validated tree's root, the same for every check a given validator runs.
|
||||
struct Check<Subject: Validatable, Document: Sendable>: Sendable {
|
||||
public let description: String
|
||||
public let predicate: @Sendable (SubjectContext<Subject>) -> Bool
|
||||
public let check: @Sendable (SubjectContext<Subject>) -> [Issue]
|
||||
public let predicate: @Sendable (SubjectContext<Subject, Document>) -> Bool
|
||||
public let check: @Sendable (SubjectContext<Subject, Document>) -> [Issue]
|
||||
|
||||
/// Multi-error form: return one issue per problem. Use when a subject can fail several ways.
|
||||
public init(
|
||||
description: String,
|
||||
check: @escaping @Sendable (SubjectContext<Subject>) -> [Issue],
|
||||
when predicate: @escaping @Sendable (SubjectContext<Subject>) -> Bool = { _ in true },
|
||||
check: @escaping @Sendable (SubjectContext<Subject, Document>) -> [Issue],
|
||||
when predicate: @escaping @Sendable (SubjectContext<Subject, Document>) -> Bool = { _ in true },
|
||||
) {
|
||||
self.description = description
|
||||
self.predicate = predicate
|
||||
@ -23,8 +24,8 @@ public extension PureYAML.Validation {
|
||||
public init(
|
||||
description: String,
|
||||
severity: Severity = .error,
|
||||
check: @escaping @Sendable (SubjectContext<Subject>) -> Bool,
|
||||
when predicate: @escaping @Sendable (SubjectContext<Subject>) -> Bool = { _ in true },
|
||||
check: @escaping @Sendable (SubjectContext<Subject, Document>) -> Bool,
|
||||
when predicate: @escaping @Sendable (SubjectContext<Subject, Document>) -> Bool = { _ in true },
|
||||
) {
|
||||
self.init(
|
||||
description: description,
|
||||
@ -38,7 +39,7 @@ public extension PureYAML.Validation {
|
||||
}
|
||||
|
||||
/// Gates on the predicate, then runs the check.
|
||||
public func apply(_ context: SubjectContext<Subject>) -> [Issue] {
|
||||
public func apply(_ context: SubjectContext<Subject, Document>) -> [Issue] {
|
||||
guard predicate(context) else {
|
||||
return []
|
||||
}
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
public extension PureYAML.Validation {
|
||||
/// The read-only bundle a type-directed check receives: the whole `document` (for cross-cutting
|
||||
/// checks), the current `subject` of the check's type, and the `path` to where it sits.
|
||||
struct SubjectContext<Subject: Validatable>: Sendable {
|
||||
public var document: PureYAML.Model.Value
|
||||
/// checks), the current `subject` of the check's type, and the `path` to where it sits. `Document`
|
||||
/// is the type of the validated tree's root, so each layer carries a truthful document: `Model.Value`
|
||||
/// for the model tree, `Tagged.Node` for the tagged tree.
|
||||
struct SubjectContext<Subject: Validatable, Document: Sendable>: Sendable {
|
||||
public var document: Document
|
||||
public var subject: Subject
|
||||
public var path: Path
|
||||
|
||||
public init(
|
||||
document: PureYAML.Model.Value,
|
||||
document: Document,
|
||||
subject: Subject,
|
||||
path: Path,
|
||||
) {
|
||||
|
||||
@ -8,3 +8,4 @@ public extension PureYAML.Validation {
|
||||
extension PureYAML.Model.Value: PureYAML.Validation.Validatable {}
|
||||
extension PureYAML.Model.Mapping: PureYAML.Validation.Validatable {}
|
||||
extension PureYAML.Model.Key: PureYAML.Validation.Validatable {}
|
||||
extension PureYAML.Tagged.Node: PureYAML.Validation.Validatable {}
|
||||
|
||||
@ -2,9 +2,9 @@ public extension PureYAML.Validation {
|
||||
/// Traverses a YAML value tree and applies validation rules at each node.
|
||||
struct Validator: Sendable {
|
||||
public var rules: [Rule]
|
||||
public var checks: [AnyCheck]
|
||||
public var checks: [AnyCheck<PureYAML.Model.Value>]
|
||||
|
||||
public init(rules: [Rule] = Self.defaultRules, checks: [AnyCheck] = []) {
|
||||
public init(rules: [Rule] = Self.defaultRules, checks: [AnyCheck<PureYAML.Model.Value>] = []) {
|
||||
self.rules = rules
|
||||
self.checks = checks
|
||||
}
|
||||
@ -68,7 +68,7 @@ public extension PureYAML.Validation {
|
||||
|
||||
/// Adds a type-directed check, which fires only on values of its `Subject` type wherever they
|
||||
/// occur in the tree.
|
||||
public func validating(_ check: Check<some Any>) -> Self {
|
||||
public func validating(_ check: Check<some Validatable, PureYAML.Model.Value>) -> Self {
|
||||
var copy = self
|
||||
copy.checks.append(AnyCheck(check))
|
||||
return copy
|
||||
|
||||
@ -210,7 +210,9 @@ struct TaggedValidationCompatibilityTests {
|
||||
@Test("Custom tagged validation rules inspect application tags exactly")
|
||||
func test_customTaggedValidationRulesInspectApplicationTagsExactly() throws {
|
||||
let node = try PureYAML.parseTagged("value: !Local one")
|
||||
let localTagsAreWarnings = PureYAML.Tagged.Rule(description: "Local tags are warnings") { context in
|
||||
let localTagsAreWarnings = PureYAML.Validation.Check<PureYAML.Tagged.Node, PureYAML.Tagged.Node>(
|
||||
description: "Local tags are warnings",
|
||||
) { context in
|
||||
guard context.subject.tag == .init("!Local") else {
|
||||
return []
|
||||
}
|
||||
|
||||
@ -3,8 +3,8 @@ import Testing
|
||||
|
||||
@Suite("Type-directed check machinery")
|
||||
struct ValidationCheckMachineryTests {
|
||||
private typealias Check = PureYAML.Validation.Check<PureYAML.Model.Mapping>
|
||||
private typealias AnyCheck = PureYAML.Validation.AnyCheck
|
||||
private typealias Check = PureYAML.Validation.Check<PureYAML.Model.Mapping, PureYAML.Model.Value>
|
||||
private typealias AnyCheck = PureYAML.Validation.AnyCheck<PureYAML.Model.Value>
|
||||
private typealias Issue = PureYAML.Validation.Issue
|
||||
|
||||
private var mapping: PureYAML.Model.Mapping {
|
||||
|
||||
@ -13,19 +13,19 @@ private struct Person: PureYAML.Validation.Validatable {
|
||||
|
||||
@Suite("Validation combinators")
|
||||
struct ValidationCombinatorTests {
|
||||
private typealias Context = PureYAML.Validation.SubjectContext<Person>
|
||||
private typealias Context = PureYAML.Validation.SubjectContext<Person, PureYAML.Model.Value>
|
||||
|
||||
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<Person> {
|
||||
private func failing(_ description: String) -> PureYAML.Validation.Check<Person, PureYAML.Model.Value> {
|
||||
.init(description: description) { ctx in
|
||||
[PureYAML.Validation.Issue(severity: .error, reason: description, path: ctx.path)]
|
||||
}
|
||||
}
|
||||
|
||||
private func passing(_ description: String) -> PureYAML.Validation.Check<Person> {
|
||||
private func passing(_ description: String) -> PureYAML.Validation.Check<Person, PureYAML.Model.Value> {
|
||||
.init(description: description) { _ in [] }
|
||||
}
|
||||
|
||||
@ -41,14 +41,17 @@ struct ValidationCombinatorTests {
|
||||
|
||||
@Test("take digs to a value and runs arbitrary logic")
|
||||
func test_take() {
|
||||
let cityIsSet = PureYAML.Validation.take(\Person.address) { !$0.city.isEmpty }
|
||||
let cityIsSet: @Sendable (Context) -> Bool = 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<Person>(description: "Person is an adult", check: (\Context.subject.age) >= 18)
|
||||
let adult = PureYAML.Validation.Check<Person, PureYAML.Model.Value>(
|
||||
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)
|
||||
@ -77,7 +80,10 @@ struct ValidationCombinatorTests {
|
||||
|
||||
@Test("lift runs child-typed checks against a value reached by key path")
|
||||
func test_lift() {
|
||||
let cityNotEmpty = PureYAML.Validation.Check<Address>(description: "City is set", check: { !$0.subject.city.isEmpty })
|
||||
let cityNotEmpty = PureYAML.Validation.Check<Address, PureYAML.Model.Value>(
|
||||
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)
|
||||
|
||||
@ -56,12 +56,11 @@ PureYAML.Tagged.Sequence excluded data-transfer ordered nodes
|
||||
PureYAML.Tagged.Tag excluded data-transfer tag identifier
|
||||
PureYAML.Tagged.NodeKind excluded closed enum; illegal states unrepresentable
|
||||
PureYAML.Tagged.Document excluded wraps a validated Tagged.Node
|
||||
PureYAML.Tagged.Context excluded construction context data-transfer type
|
||||
PureYAML.Tagged.ConstructionContext excluded construction context data-transfer type
|
||||
PureYAML.Tagged.ConstructionError excluded error enum; data-transfer
|
||||
PureYAML.Tagged.Constructor excluded tag-construction engine; not a validated subject
|
||||
PureYAML.Tagged.Rule excluded validation machinery; duplicates Validation.Rule, slated for de-dup (principle 10)
|
||||
PureYAML.Tagged.Validator excluded validation machinery; duplicates Validation.Validator, slated for de-dup (principle 10)
|
||||
PureYAML.Tagged.BuiltinCheck excluded caseless namespace of the tagged layer's built-in checks
|
||||
PureYAML.Tagged.Validator excluded tagged-tree traversal over the shared Check framework; not a validated subject
|
||||
|
||||
# --- validation framework's own types ---
|
||||
PureYAML.Validation.Validator excluded validation machinery; not a validated subject
|
||||
|
||||
|
Can't render this file because it has a wrong number of fields in line 3.
|
Loading…
Reference in New Issue
Block a user