refactor(validation): rename framework to match the validation idiom

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).
This commit is contained in:
Mihaela Mihaljevic 2026-07-12 18:10:00 +02:00
parent e1c08260c4
commit bf2dc4ece7
62 changed files with 912 additions and 753 deletions

View File

@ -50,9 +50,9 @@ public extension PureYAML {
@discardableResult
static func validate(
_ value: Model.Value,
using validator: Validation.Validator = .init(),
using validator: Validation.Validator<Model.Value, Model.Value> = .init(),
strict: Bool = true,
) throws -> [Validation.Issue] {
) throws -> [Validation.ValidationError] {
try validator.validate(value, strict: strict)
}
@ -60,7 +60,7 @@ public extension PureYAML {
@discardableResult
static func validate(
_ documents: [Stream.Document],
using validator: Validation.Validator = .init(),
using validator: Validation.Validator<Model.Value, Model.Value> = .init(),
strict: Bool = true,
) throws -> [Stream.Issue] {
try validator.validate(documents, strict: strict)

View File

@ -79,20 +79,20 @@ extension PureYAML.Decoding {
_ value: PureYAML.Model.Value,
codingPath: [any CodingKey],
) throws {
let result = PureYAML.Validation.Validator().collect(value)
guard !result.issues.isEmpty else {
let result = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(value)
guard !result.values.isEmpty else {
return
}
let pathPrefix = PureYAML.Validation.Path(codingPath: codingPath)
let issues = result.issues.map { issue in
PureYAML.Validation.Issue(
severity: issue.severity,
reason: issue.reason,
path: .init(pathPrefix.components + issue.path.components),
let errors = result.values.map { error in
PureYAML.Validation.ValidationError(
severity: error.severity,
reason: error.reason,
path: .init(pathPrefix.components + error.path.components),
)
}
throw PureYAML.Validation.Issue.Collection(issues)
throw PureYAML.Validation.ValidationErrorCollection(errors)
}
}

View File

@ -0,0 +1,26 @@
extension PureYAML.Model.Value: PureYAML.Validation.Traversable {
/// For a mapping: the mapping itself (a `Validation<Mapping, _>` fires on this), and each pair's
/// key node at its own path (so a key-typed check can inspect it, e.g. "is this key a string").
/// Nothing extra for any other case.
public var extraOffers: [(component: PureYAML.Validation.Path.Component?, value: any PureYAML.Validation.Validatable)] {
guard case let .mapping(mapping) = self else {
return []
}
return [(nil, mapping)] + mapping.pairs.map { pair in
(component: pair.keyNode.pathComponent, value: pair.keyNode)
}
}
/// A mapping's children are its pairs' values, keyed by their key node's path component; a
/// sequence's children are its values, keyed by index. A scalar has none.
public var children: [(component: PureYAML.Validation.Path.Component, value: PureYAML.Model.Value)] {
switch self {
case let .mapping(mapping):
mapping.pairs.map { pair in (component: pair.keyNode.pathComponent, value: pair.value) }
case let .sequence(values):
values.indices.map { index in (component: .index(index), value: values[index]) }
case .null, .bool, .int, .double, .string:
[]
}
}
}

View File

@ -1,17 +1,17 @@
import PureYAML
public extension PureYAML.Validation.BuiltinCheck {
public extension PureYAML.Validation.BuiltinValidation {
/// Reports any mapping key that is not a string. JSON object names must be
/// strings, so a YAML complex key (a sequence or mapping used as a key) has no
/// JSON form. PureYAML core already ships ``mappingKeysAreUnique``; this check and
/// ``numbersAreFinite`` complete the JSON-representability set.
static var mappingKeysAreStrings: PureYAML.Validation.Check<PureYAML.Model.Mapping, PureYAML.Model.Value> {
PureYAML.Validation.Check(description: "Mapping keys are strings") { context in
static var mappingKeysAreStrings: PureYAML.Validation.Validation<PureYAML.Model.Mapping, PureYAML.Model.Value> {
PureYAML.Validation.Validation(description: "Mapping keys are strings") { context in
context.subject.pairs.compactMap { pair in
guard pair.keyNode.stringValue == nil else {
return nil
}
return PureYAML.Validation.Issue(
return PureYAML.Validation.ValidationError(
severity: .error,
reason: "Mapping key '\(pair.keyNode.description)' is not a string",
path: context.path.appending(.complexKey(pair.keyNode)),
@ -22,13 +22,13 @@ public extension PureYAML.Validation.BuiltinCheck {
/// Reports any non-finite number (NaN or infinity). JSON has no literal for
/// these, so a value tree carrying one is not representable as JSON.
static var numbersAreFinite: PureYAML.Validation.Check<PureYAML.Model.Value, PureYAML.Model.Value> {
PureYAML.Validation.Check(description: "Numbers are finite") { context in
static var numbersAreFinite: PureYAML.Validation.Validation<PureYAML.Model.Value, PureYAML.Model.Value> {
PureYAML.Validation.Validation(description: "Numbers are finite") { context in
guard case let .double(number) = context.subject, !number.isFinite else {
return []
}
return [
PureYAML.Validation.Issue(
PureYAML.Validation.ValidationError(
severity: .error,
reason: "Number '\(number)' is not finite",
path: context.path,

View File

@ -6,7 +6,7 @@ public extension PureYAML.JSON.Convert {
/// The source text did not parse.
case sourceInvalid(String)
/// The value cannot be represented in the target format.
case notRepresentable([PureYAML.Validation.Issue])
case notRepresentable([PureYAML.Validation.ValidationError])
/// The emitted output did not parse back to the source value.
case roundTripMismatch(PureYAML.JSON.Format)
}

View File

@ -30,7 +30,7 @@ public extension PureYAML.JSON {
/// representability first; the round-trip check is not run here (use
/// ``convert(_:to:pretty:)`` for the fully validated path).
public static func emit(_ value: PureYAML.Model.Value, as target: Format, pretty: Bool = true) throws -> String {
let issues = validator(for: target).collect(value).issues
let issues = validator(for: target).collect(value).values
guard issues.isEmpty else {
throw Failure.notRepresentable(issues)
}
@ -44,7 +44,7 @@ public extension PureYAML.JSON {
/// The validator for a target format. JSON additionally requires string keys
/// and finite numbers; YAML allows complex keys but still forbids duplicates.
public static func validator(for target: Format) -> PureYAML.Validation.Validator {
public static func validator(for target: Format) -> PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value> {
switch target {
case .json:
PureYAML.JSON.Validator.representability

View File

@ -9,22 +9,25 @@ public extension PureYAML.JSON {
///
/// JSON the format tolerates duplicate object names, but a tree that carries two
/// pairs with the same key emits an ambiguous document, so the representability
/// validator rejects duplicates for determinism. Drop ``unique`` from the rule
/// set if you want the permissive reading.
/// validator rejects duplicates for determinism. Drop ``PureYAML/Validation/BuiltinValidation/mappingKeysAreUnique``
/// from the rule set if you want the permissive reading.
enum Validator {
/// The full JSON-representability rule set: unique keys, string keys, finite
/// numbers. Suitable to pass straight to `PureYAML.JSON.Convert`.
public static var representability: PureYAML.Validation.Validator {
PureYAML.Validation.Validator(checks: [
PureYAML.Validation.AnyCheck(PureYAML.Validation.BuiltinCheck.mappingKeysAreUnique),
PureYAML.Validation.AnyCheck(PureYAML.Validation.BuiltinCheck.mappingKeysAreStrings),
PureYAML.Validation.AnyCheck(PureYAML.Validation.BuiltinCheck.numbersAreFinite),
/// numbers. Suitable to pass straight to `PureYAML.JSON.Convert`. Built with
/// the same tiered `Validator` shape every other layer uses, so it carries the
/// same `.blank`/`.validating`/`.withoutValidating`/`.validationDescriptions`
/// machinery (validation-rules.md, principle 10).
public static var representability: PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value> {
PureYAML.Validation.Validator(nonReferenceDefaultValidations: [
PureYAML.Validation.AnyValidation(PureYAML.Validation.BuiltinValidation.mappingKeysAreUnique),
PureYAML.Validation.AnyValidation(PureYAML.Validation.BuiltinValidation.mappingKeysAreStrings),
PureYAML.Validation.AnyValidation(PureYAML.Validation.BuiltinValidation.numbersAreFinite),
])
}
/// Every reason the value cannot be emitted as strict JSON, in document order.
public static func issues(for value: PureYAML.Model.Value) -> [PureYAML.Validation.Issue] {
representability.collect(value).issues
public static func issues(for value: PureYAML.Model.Value) -> [PureYAML.Validation.ValidationError] {
representability.collect(value).values
}
/// Whether the value can be emitted as strict JSON.

View File

@ -2,11 +2,11 @@ public extension PureYAML.Stream {
/// Validation issue with the document index preserved.
struct Issue: Swift.Error, Equatable, Sendable, CustomStringConvertible {
public var documentIndex: Int
public var issue: PureYAML.Validation.Issue
public var issue: PureYAML.Validation.ValidationError
public init(
documentIndex: Int,
issue: PureYAML.Validation.Issue,
issue: PureYAML.Validation.ValidationError,
) {
self.documentIndex = documentIndex
self.issue = issue

View File

@ -1,55 +1,55 @@
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
/// layer's counterpart of `Validation.BuiltinValidation`. Each is a `Validation<_, Node>` on the one
/// shared validation framework, not a copy of the machinery (validation-rules.md, principle 10).
enum BuiltinCheck {
enum BuiltinValidation {
/// Reports each duplicate key within a single tagged mapping. The tagged-layer counterpart of
/// `Validation.BuiltinCheck.mappingKeysAreUnique`; duplicate keys are as invalid in a
/// `Validation.BuiltinValidation.mappingKeysAreUnique`; duplicate keys are as invalid in a
/// tag-preserving tree as in the plain model, and `Tagged.Mapping` keeps every parsed pair.
public static var mappingKeysAreUnique: PureYAML.Validation.Check<Mapping, Node> {
PureYAML.Validation.Check(description: "Mapping keys are unique") { context in
public static var mappingKeysAreUnique: PureYAML.Validation.Validation<Mapping, Node> {
PureYAML.Validation.Validation(description: "Mapping keys are unique") { context in
var seen = Set<String>()
var issues: [PureYAML.Validation.Issue] = []
var errors: [PureYAML.Validation.ValidationError] = []
for pair in context.subject.pairs where !seen.insert(pair.key).inserted {
issues.append(PureYAML.Validation.Issue(
errors.append(PureYAML.Validation.ValidationError(
severity: .error,
reason: "Duplicate mapping key '\(pair.key)'",
path: context.path.appending(.key(pair.key)),
))
}
return issues
return errors
}
}
/// 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] = []
public static var builtInTagsAreSupportedAndKindConsistent: PureYAML.Validation.Validation<Node, Node> {
PureYAML.Validation.Validation(description: "Built-in tags are supported and kind-consistent") { context in
var errors: [PureYAML.Validation.ValidationError] = []
if case let .mapping(mapping) = context.subject {
issues.append(contentsOf: mapping.pairs.compactMap { pair in
errors.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
return errors
}
if let expectedKind = tag.expectedKind, expectedKind != context.subject.kind {
issues.append(
PureYAML.Validation.Issue(
errors.append(
PureYAML.Validation.ValidationError(
severity: .error,
reason: "Tag '\(tag.rawValue)' expects a \(expectedKind) node, found \(context.subject.kind)",
path: context.path,
),
)
return issues
return errors
}
if !tag.isSupportedBuiltInTag {
issues.append(
PureYAML.Validation.Issue(
errors.append(
PureYAML.Validation.ValidationError(
severity: .error,
reason: "Unsupported built-in tag '\(tag.rawValue)'",
path: context.path,
@ -57,7 +57,7 @@ public extension PureYAML.Tagged {
)
}
return issues
return errors
} when: { context in
if context.subject.tag?.isBuiltIn == true {
return true
@ -72,7 +72,7 @@ public extension PureYAML.Tagged {
static func keyTagIssue(
for pair: PureYAML.Tagged.Pair,
at path: PureYAML.Validation.Path,
) -> PureYAML.Validation.Issue? {
) -> PureYAML.Validation.ValidationError? {
guard let tag = pair.keyTag, tag.isBuiltIn else {
return nil
}
@ -80,21 +80,21 @@ public extension PureYAML.Tagged {
return nil
}
if tag == .merge {
return PureYAML.Validation.Issue(
return PureYAML.Validation.ValidationError(
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(
return PureYAML.Validation.ValidationError(
severity: .error,
reason: "Tag '\(tag.rawValue)' expects a scalar mapping key",
path: path,
)
}
guard tag.isSupportedBuiltInTag else {
return PureYAML.Validation.Issue(
return PureYAML.Validation.ValidationError(
severity: .error,
reason: "Unsupported built-in tag '\(tag.rawValue)' on mapping key",
path: path,

View File

@ -0,0 +1,26 @@
extension PureYAML.Tagged.Node: PureYAML.Validation.Traversable {
/// For a mapping: the mapping itself (a `Validation<Mapping, _>` fires on this). Unlike the model
/// layer, key nodes are not offered separately: a tagged mapping's keys are plain `String`, not a
/// `Validatable` key type of their own.
public var extraOffers: [(component: PureYAML.Validation.Path.Component?, value: any PureYAML.Validation.Validatable)] {
guard case let .mapping(mapping) = self else {
return []
}
return [(nil, mapping)]
}
/// A mapping's children are its pairs' values, keyed by their string key; a sequence's children are
/// its values, keyed by index. An alias's aliased subtree was already walked at its anchor's
/// definition site, so recursing here would report every error in that subtree once per reference;
/// a scalar has no children either.
public var children: [(component: PureYAML.Validation.Path.Component, value: PureYAML.Tagged.Node)] {
switch self {
case let .mapping(mapping):
mapping.pairs.map { pair in (component: .key(pair.key), value: pair.value) }
case let .sequence(sequence):
sequence.values.indices.map { index in (component: .index(index), value: sequence.values[index]) }
case .scalar, .alias:
[]
}
}
}

View File

@ -1,136 +1,44 @@
public extension PureYAML.Tagged {
/// 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 checks: [PureYAML.Validation.AnyCheck<Node>]
public init(checks: [PureYAML.Validation.AnyCheck<Node>] = Self.defaultChecks) {
self.checks = checks
}
public static var blank: Self {
Self(checks: [])
}
public static var defaultChecks: [PureYAML.Validation.AnyCheck<Node>] {
[
PureYAML.Validation.AnyCheck(BuiltinCheck.builtInTagsAreSupportedAndKindConsistent),
PureYAML.Validation.AnyCheck(BuiltinCheck.mappingKeysAreUnique),
]
}
@discardableResult
public func validate(
_ node: Node,
strict: Bool = true,
) throws -> [PureYAML.Validation.Issue] {
let result = collect(node)
let failures = strict ? result.issues : result.errors
if !failures.isEmpty {
throw PureYAML.Validation.Issue.Collection(failures)
}
return strict ? [] : result.warnings
}
public func collect(_ node: Node) -> PureYAML.Validation.Result {
var issues: [PureYAML.Validation.Issue] = []
walk(root: node, subject: node, path: .root, issues: &issues)
return PureYAML.Validation.Result(issues)
}
@discardableResult
public func validate(
_ documents: [Document],
strict: Bool = true,
) throws -> [PureYAML.Stream.Issue] {
let result = collect(documents)
let failures = strict ? result.issues : result.errors
if !failures.isEmpty {
throw PureYAML.Stream.Issue.Collection(failures)
}
return strict ? [] : result.warnings
}
public func collect(_ documents: [Document]) -> PureYAML.Stream.Result {
var issues: [PureYAML.Stream.Issue] = []
for document in documents {
let result = collect(document.node)
issues.append(contentsOf: result.issues.map { issue in
PureYAML.Stream.Issue(documentIndex: document.index, issue: issue)
})
}
return PureYAML.Stream.Result(issues)
}
/// 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.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)
}
}
/// The tagged layer's validator: the same shared `PureYAML.Validation.Validator` traversal engine
/// as every other layer (validation-rules.md, principle 10), specialised on `Node` for both the
/// walked subject and the cross-cutting document.
typealias Validator = PureYAML.Validation.Validator<Node, Node>
}
extension PureYAML.Tagged.Validator {
func walk(
root: PureYAML.Tagged.Node,
subject: PureYAML.Tagged.Node,
path: PureYAML.Validation.Path,
issues: inout [PureYAML.Validation.Issue],
) {
// The document is the whole tagged tree; each check filters by runtime type. Offer the node,
// and for a mapping the mapping too, so a `Check<Node, Node>` and a `Check<Mapping, Node>`
// each fire on what they specialise on.
offer(subject, at: path, root: root, issues: &issues)
if case let .mapping(mapping) = subject {
offer(mapping, at: path, root: root, issues: &issues)
}
switch subject {
case let .mapping(mapping):
for pair in mapping.pairs {
walk(
root: root,
subject: pair.value,
path: path.appending(.key(pair.key)),
issues: &issues,
)
}
case let .sequence(sequence):
for index in sequence.values.indices {
walk(
root: root,
subject: sequence.values[index],
path: path.appending(.index(index)),
issues: &issues,
)
}
case .scalar:
break
case .alias:
// The aliased subtree was already walked at its anchor's
// definition site; recursing here would report every issue in
// that subtree once more per reference.
break
}
public extension PureYAML.Validation.Validator where Subject == PureYAML.Tagged.Node, Document == PureYAML.Tagged.Node {
/// The tagged layer's default checks.
static var defaultValidations: [PureYAML.Validation.AnyValidation<PureYAML.Tagged.Node>] {
[
PureYAML.Validation.AnyValidation(PureYAML.Tagged.BuiltinValidation.builtInTagsAreSupportedAndKindConsistent),
PureYAML.Validation.AnyValidation(PureYAML.Tagged.BuiltinValidation.mappingKeysAreUnique),
]
}
func offer(
_ subject: any PureYAML.Validation.Validatable,
at path: PureYAML.Validation.Path,
root: PureYAML.Tagged.Node,
issues: inout [PureYAML.Validation.Issue],
) {
for check in checks {
issues.append(contentsOf: check.apply(to: subject, at: path, in: root))
convenience init() {
self.init(nonReferenceDefaultValidations: Self.defaultValidations)
}
@discardableResult
func validate(
_ documents: [PureYAML.Tagged.Document],
strict: Bool = true,
) throws -> [PureYAML.Stream.Issue] {
let result = collect(documents)
let failures = strict ? result.issues : result.errors
if !failures.isEmpty {
throw PureYAML.Stream.Issue.Collection(failures)
}
return strict ? [] : result.warnings
}
func collect(_ documents: [PureYAML.Tagged.Document]) -> PureYAML.Stream.Result {
var issues: [PureYAML.Stream.Issue] = []
for document in documents {
let result = collect(document.node)
issues.append(contentsOf: result.values.map { error in
PureYAML.Stream.Issue(documentIndex: document.index, issue: error)
})
}
return PureYAML.Stream.Result(issues)
}
}

View File

@ -1,19 +1,19 @@
public extension PureYAML.Validation {
/// A type-erased `Check`, so checks specialised on different subject types live in one list, all
/// A type-erased `Validation`, 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
/// `Validation<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 {
struct AnyValidation<Document: Sendable>: Sendable {
public let description: String
private let applyErased: @Sendable (any Validatable, Path, Document) -> [Issue]
private let applyErased: @Sendable (any Validatable, Path, Document) -> [ValidationError]
public init<Subject: Validatable>(_ check: Check<Subject, Document>) {
description = check.description
public init<Subject: Validatable>(_ validation: Validation<Subject, Document>) {
description = validation.description
applyErased = { subject, path, document in
guard let typed = subject as? Subject, type(of: subject) == Subject.self else {
return []
}
return check.apply(SubjectContext(document: document, subject: typed, path: path))
return validation.apply(ValidationContext(document: document, subject: typed, path: path))
}
}
@ -21,7 +21,7 @@ public extension PureYAML.Validation {
to subject: any Validatable,
at path: Path,
in document: Document,
) -> [Issue] {
) -> [ValidationError] {
applyErased(subject, path, document)
}
}

View File

@ -1,28 +1,28 @@
public extension PureYAML.Validation {
/// The caseless namespace of built-in type-directed checks, referenceable by name.
enum BuiltinCheck {
enum BuiltinValidation {
/// 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, PureYAML.Model.Value> {
Check(description: "Mapping keys are unique") { context in
public static var mappingKeysAreUnique: Validation<PureYAML.Model.Mapping, PureYAML.Model.Value> {
Validation(description: "Mapping keys are unique") { context in
var seen = Set<PureYAML.Model.Key>()
var issues: [Issue] = []
var errors: [ValidationError] = []
for pair in context.subject.pairs where !seen.insert(pair.keyNode).inserted {
issues.append(Issue(
errors.append(ValidationError(
severity: .error,
reason: "Duplicate mapping key '\(pair.keyNode.description)'",
path: context.path.appending(pair.keyNode.pathComponent),
))
}
return issues
return errors
}
}
}
}
extension PureYAML.Model.Key {
/// The path component a duplicate-key issue points at: a plain `.key` for a string key, a
/// The path component a duplicate-key error points at: a plain `.key` for a string key, a
/// `.complexKey` for a sequence or mapping key (which has no string spelling).
var pathComponent: PureYAML.Validation.Path.Component {
switch self {

View File

@ -1,49 +0,0 @@
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. `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, 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, Document>) -> [Issue],
when predicate: @escaping @Sendable (SubjectContext<Subject, Document>) -> Bool = { _ in true },
) {
self.description = description
self.predicate = predicate
self.check = check
}
/// Single-error Bool form: a positive description plus a check whose `false` auto-produces the
/// one issue `"Failed to satisfy: <description>"`. The dominant form.
public init(
description: String,
severity: Severity = .error,
check: @escaping @Sendable (SubjectContext<Subject, Document>) -> Bool,
when predicate: @escaping @Sendable (SubjectContext<Subject, Document>) -> Bool = { _ in true },
) {
self.init(
description: description,
check: { context in
check(context)
? []
: [Issue(severity: severity, reason: "Failed to satisfy: \(description)", path: context.path)]
},
when: predicate,
)
}
/// Gates on the predicate, then runs the check.
public func apply(_ context: SubjectContext<Subject, Document>) -> [Issue] {
guard predicate(context) else {
return []
}
return check(context)
}
}
}

View File

@ -3,7 +3,7 @@ public extension PureYAML {
static func diagnosticValidationReport(
_ yaml: String,
file: String? = nil,
using validator: Validation.Validator = .init(),
using validator: Validation.Validator<Model.Value, Model.Value> = .init(),
preflightScanner: Validation.PreflightScanner = .init(),
) -> Validation.Report {
diagnosticValidationResult(
@ -17,7 +17,7 @@ public extension PureYAML {
/// Validates many YAML inputs with preflight diagnostics for damaged input.
static func diagnosticValidationReports(
_ sources: [Validation.Source],
using validator: Validation.Validator = .init(),
using validator: Validation.Validator<Model.Value, Model.Value> = .init(),
failOnWarnings: Bool = false,
preflightScanner: Validation.PreflightScanner = .init(),
) -> Validation.BatchReport {
@ -41,7 +41,7 @@ public extension PureYAML {
static func parseValidated(
_ yaml: String,
file: String? = nil,
using validator: Validation.Validator = .init(),
using validator: Validation.Validator<Model.Value, Model.Value> = .init(),
preflightScanner: Validation.PreflightScanner = .init(),
) throws -> Model.Value {
let documents = try parseValidatedStream(
@ -57,7 +57,7 @@ public extension PureYAML {
static func parseValidatedStream(
_ yaml: String,
file: String? = nil,
using validator: Validation.Validator = .init(),
using validator: Validation.Validator<Model.Value, Model.Value> = .init(),
preflightScanner: Validation.PreflightScanner = .init(),
) throws -> [Stream.Document] {
let result = diagnosticValidationResult(
@ -82,7 +82,7 @@ private extension PureYAML {
static func diagnosticValidationResult(
_ yaml: String,
file: String?,
using validator: Validation.Validator,
using validator: Validation.Validator<Model.Value, Model.Value>,
preflightScanner: Validation.PreflightScanner,
) -> DiagnosticValidationResult {
var diagnostics = preflightScanner.diagnostics(in: yaml, file: file)

View File

@ -1,14 +0,0 @@
public extension PureYAML.Validation.Issue {
/// Error thrown when validation reports failing issues.
struct Collection: Swift.Error, Equatable, Sendable, CustomStringConvertible {
public var issues: [PureYAML.Validation.Issue]
public init(_ issues: [PureYAML.Validation.Issue]) {
self.issues = issues
}
public var description: String {
issues.map(\.description).joined(separator: "\n")
}
}
}

View File

@ -1,23 +0,0 @@
public extension PureYAML.Validation {
/// Path-aware validation issue.
struct Issue: Swift.Error, Equatable, Sendable, CustomStringConvertible {
public var severity: Severity
public var reason: String
public var path: Path
public init(
severity: Severity,
reason: String,
path: Path = .root,
) {
self.severity = severity
self.reason = reason
self.path = path
}
public var description: String {
let location = path.isRoot ? "root" : path.description
return "\(severity.description): \(reason) at \(location)"
}
}
}

View File

@ -1,18 +1,19 @@
public extension PureYAML.Validation {
/// Collected validation issues.
/// Collected validation errors, the non-throwing outcome accessor beside the throwing
/// `Validator.validate(_:strict:)` gate.
struct Result: Equatable, Sendable {
public var issues: [Issue]
public var values: [ValidationError]
public init(_ issues: [Issue] = []) {
self.issues = issues
public init(_ values: [ValidationError] = []) {
self.values = values
}
public var errors: [Issue] {
issues.filter { $0.severity == .error }
public var errors: [ValidationError] {
values.filter { $0.severity == .error }
}
public var warnings: [Issue] {
issues.filter { $0.severity == .warning }
public var warnings: [ValidationError] {
values.filter { $0.severity == .warning }
}
public var isValid: Bool {

View File

@ -0,0 +1,15 @@
public extension PureYAML.Validation {
/// A tree node a `Validator` can walk. `Validator`'s traversal engine is shared by every layer
/// (validation-rules.md, principle 10); each `Traversable` conformance supplies only that layer's
/// own traversal shape: "each layer defines only its own document, its rules, and its traversal".
protocol Traversable: Validatable {
/// Extra values to offer to type-directed checks at this node, beyond the node itself (e.g. a
/// mapping's own type, or a mapping's key nodes). A `nil` path component offers at this node's
/// own path; a non-nil one offers at that child's path (e.g. a mapping key's location).
var extraOffers: [(component: Path.Component?, value: any Validatable)] { get }
/// This node's children to recurse into, each paired with the path component that reaches it
/// from this node.
var children: [(component: Path.Component, value: Self)] { get }
}
}

View File

@ -1,5 +1,5 @@
public extension PureYAML.Validation {
/// Marker for a value a type-directed validation can be applied to. A `Check<Subject>` fires on
/// Marker for a value a type-directed validation can be applied to. A `Validation<Subject>` fires on
/// `Subject` values wherever they appear in the traversed tree; this protocol constrains the
/// subjects so the erased dispatch can filter by runtime type.
protocol Validatable: Sendable {}

View File

@ -198,7 +198,7 @@ public extension PureYAML {
static func validationReport(
_ yaml: String,
file: String? = nil,
using validator: Validation.Validator = .init(),
using validator: Validation.Validator<Model.Value, Model.Value> = .init(),
) -> Validation.Report {
do {
let documents = try parseStream(yaml)
@ -241,7 +241,7 @@ public extension PureYAML {
/// Validates many YAML inputs without throwing or stopping at the first failure.
static func validationReports(
_ sources: [Validation.Source],
using validator: Validation.Validator = .init(),
using validator: Validation.Validator<Model.Value, Model.Value> = .init(),
failOnWarnings: Bool = false,
) -> Validation.BatchReport {
let reports = sources.map { source in

View File

@ -0,0 +1,49 @@
public extension PureYAML.Validation {
/// A type-directed validation value: 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. `Document` is the type of the validated tree's root, the same
/// for every check a given validator runs.
struct Validation<Subject: Validatable, Document: Sendable>: Sendable {
public let description: String
public let predicate: @Sendable (ValidationContext<Subject, Document>) -> Bool
public let check: @Sendable (ValidationContext<Subject, Document>) -> [ValidationError]
/// Multi-error form: return one error per problem. Use when a subject can fail several ways.
public init(
description: String,
check: @escaping @Sendable (ValidationContext<Subject, Document>) -> [ValidationError],
when predicate: @escaping @Sendable (ValidationContext<Subject, Document>) -> Bool = { _ in true },
) {
self.description = description
self.predicate = predicate
self.check = check
}
/// Single-error Bool form: a positive description plus a check whose `false` auto-produces the
/// one error `"Failed to satisfy: <description>"`. The dominant form.
public init(
description: String,
severity: Severity = .error,
check: @escaping @Sendable (ValidationContext<Subject, Document>) -> Bool,
when predicate: @escaping @Sendable (ValidationContext<Subject, Document>) -> Bool = { _ in true },
) {
self.init(
description: description,
check: { context in
check(context)
? []
: [ValidationError(severity: severity, reason: "Failed to satisfy: \(description)", path: context.path)]
},
when: predicate,
)
}
/// Gates on the predicate, then runs the check.
public func apply(_ context: ValidationContext<Subject, Document>) -> [ValidationError] {
guard predicate(context) else {
return []
}
return check(context)
}
}
}

View File

@ -1,9 +1,10 @@
// 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.
// 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
@ -18,49 +19,49 @@ private struct SendableKeyPath<Root, Value>: @unchecked Sendable {
// MARK: comparison operators lifting a key path and a literal into a predicate
public func == <S, D, U: Equatable & Sendable>(
lhs: KeyPath<PureYAML.Validation.SubjectContext<S, D>, U>,
lhs: KeyPath<PureYAML.Validation.ValidationContext<S, D>, U>,
rhs: U,
) -> @Sendable (PureYAML.Validation.SubjectContext<S, D>) -> Bool {
) -> @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.SubjectContext<S, D>, U>,
lhs: KeyPath<PureYAML.Validation.ValidationContext<S, D>, U>,
rhs: U,
) -> @Sendable (PureYAML.Validation.SubjectContext<S, D>) -> Bool {
) -> @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.SubjectContext<S, D>, U>,
lhs: KeyPath<PureYAML.Validation.ValidationContext<S, D>, U>,
rhs: U,
) -> @Sendable (PureYAML.Validation.SubjectContext<S, D>) -> Bool {
) -> @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.SubjectContext<S, D>, U>,
lhs: KeyPath<PureYAML.Validation.ValidationContext<S, D>, U>,
rhs: U,
) -> @Sendable (PureYAML.Validation.SubjectContext<S, D>) -> Bool {
) -> @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.SubjectContext<S, D>, U>,
lhs: KeyPath<PureYAML.Validation.ValidationContext<S, D>, U>,
rhs: U,
) -> @Sendable (PureYAML.Validation.SubjectContext<S, D>) -> Bool {
) -> @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.SubjectContext<S, D>, U>,
lhs: KeyPath<PureYAML.Validation.ValidationContext<S, D>, U>,
rhs: U,
) -> @Sendable (PureYAML.Validation.SubjectContext<S, D>) -> Bool {
) -> @Sendable (PureYAML.Validation.ValidationContext<S, D>) -> Bool {
let boxed = SendableKeyPath(keyPath: lhs)
return { $0[keyPath: boxed.keyPath] <= rhs }
}
@ -69,9 +70,9 @@ 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<SubjectContext<S, D>, U>,
_ keyPath: KeyPath<ValidationContext<S, D>, U>,
_ predicate: @escaping @Sendable (U) -> Bool,
) -> @Sendable (SubjectContext<S, D>) -> Bool {
) -> @Sendable (ValidationContext<S, D>) -> Bool {
let boxed = SendableKeyPath(keyPath: keyPath)
return { predicate($0[keyPath: boxed.keyPath]) }
}
@ -80,15 +81,15 @@ public extension PureYAML.Validation {
static func take<S, D, U: Sendable>(
_ keyPath: KeyPath<S, U>,
_ predicate: @escaping @Sendable (U) -> Bool,
) -> @Sendable (SubjectContext<S, D>) -> 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>(_ checks: Check<S, D>...) -> Check<S, D> {
let list = checks
return Check(description: "all of: \(list.map(\.description).joined(separator: ", "))") { context in
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) }
}
}
@ -97,12 +98,12 @@ public extension PureYAML.Validation {
/// and document.
static func lift<S, Child: Validatable, D>(
_ keyPath: KeyPath<S, Child>,
into checks: Check<Child, D>...,
) -> Check<S, D> {
let list = checks
into validations: Validation<Child, D>...,
) -> Validation<S, D> {
let list = validations
let boxed = SendableKeyPath(keyPath: keyPath)
return Check(description: "child: \(list.map(\.description).joined(separator: ", "))") { context in
let childContext = SubjectContext(
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,
@ -110,24 +111,43 @@ public extension PureYAML.Validation {
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.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: 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.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
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 []

View File

@ -3,7 +3,7 @@ public extension PureYAML.Validation {
/// 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 {
struct ValidationContext<Subject: Validatable, Document: Sendable>: Sendable {
public var document: Document
public var subject: Subject
public var path: Path

View File

@ -0,0 +1,26 @@
public extension PureYAML.Validation {
/// Path-aware validation error.
struct ValidationError: Swift.Error, Equatable, Sendable, CustomStringConvertible {
public var severity: Severity
public var reason: String
public var path: Path
public init(
severity: Severity,
reason: String,
path: Path = .root,
) {
self.severity = severity
self.reason = reason
self.path = path
}
/// Strips a trailing period from `reason` and renders the location consistently:
/// `"<reason> at root of document"` at the root, `"<reason> at path: <path>"` elsewhere.
/// `severity` is a separate stored property (see `Result.errors`/`.warnings`), not rendered here.
public var description: String {
let reasonText = reason.hasSuffix(".") ? String(reason.dropLast()) : reason
return path.isRoot ? "\(reasonText) at root of document" : "\(reasonText) at path: \(path.description)"
}
}
}

View File

@ -0,0 +1,14 @@
public extension PureYAML.Validation {
/// Error thrown when validation reports failing errors.
struct ValidationErrorCollection: Swift.Error, Equatable, Sendable, CustomStringConvertible {
public var values: [ValidationError]
public init(_ values: [ValidationError]) {
self.values = values
}
public var description: String {
values.map(\.description).joined(separator: "\n")
}
}
}

View File

@ -1,142 +1,166 @@
public extension PureYAML.Validation {
/// Traverses a YAML value tree and applies type-directed checks at each node. The check value, its
/// erasure, and its context all come from the one validation framework (validation-rules.md,
/// principle 10); this layer adds only its document type (`Model.Value`), its built-in checks, and
/// its traversal.
struct Validator: Sendable {
public var checks: [AnyCheck<PureYAML.Model.Value>]
/// Traverses a `Subject` tree and applies type-directed checks at each node. The validation value,
/// its erasure, its context, the combinators, and this traversal engine are all the one shared
/// framework (validation-rules.md, principle 10); each layer supplies only its own `Subject`/
/// `Document` types (through `Traversable`), its built-in checks, and its own traversal shape.
///
/// `@unchecked Sendable`: immutable after `init` (every stored validation list is a `let`), so
/// concurrent reads race on nothing; the erased `AnyValidation` closures are simply not provably
/// `Sendable` to the compiler.
final class Validator<Subject: Traversable, Document: Sendable>: @unchecked Sendable {
fileprivate let nonReferenceDefaultValidations: [AnyValidation<Document>]
fileprivate let referenceDefaultValidations: [AnyValidation<Document>]
fileprivate let customValidations: [AnyValidation<Document>]
public init(checks: [AnyCheck<PureYAML.Model.Value>] = Self.defaultChecks) {
self.checks = checks
public init(
nonReferenceDefaultValidations: [AnyValidation<Document>] = [],
referenceDefaultValidations: [AnyValidation<Document>] = [],
customValidations: [AnyValidation<Document>] = [],
) {
self.nonReferenceDefaultValidations = nonReferenceDefaultValidations
self.referenceDefaultValidations = referenceDefaultValidations
self.customValidations = customValidations
}
public static var blank: Self {
Self(checks: [])
/// A validator with no checks; add your own with `validating(_:)`.
public static var blank: Validator {
Validator()
}
public static var defaultChecks: [AnyCheck<PureYAML.Model.Value>] {
[AnyCheck(BuiltinCheck.mappingKeysAreUnique)]
}
@discardableResult
public func validate(
_ value: PureYAML.Model.Value,
strict: Bool = true,
) throws -> [Issue] {
let result = collect(value)
let failures = strict ? result.issues : result.errors
if !failures.isEmpty {
throw Issue.Collection(failures)
}
return strict ? [] : result.warnings
}
public func collect(_ value: PureYAML.Model.Value) -> Result {
var issues: [Issue] = []
walk(root: value, subject: value, path: .root, issues: &issues)
return Result(issues)
}
@discardableResult
public func validate(
_ documents: [PureYAML.Stream.Document],
strict: Bool = true,
) throws -> [PureYAML.Stream.Issue] {
let result = collect(documents)
let failures = strict ? result.issues : result.errors
if !failures.isEmpty {
throw PureYAML.Stream.Issue.Collection(failures)
}
return strict ? [] : result.warnings
}
public func collect(_ documents: [PureYAML.Stream.Document]) -> PureYAML.Stream.Result {
var issues: [PureYAML.Stream.Issue] = []
for document in documents {
let result = collect(document.value)
issues.append(contentsOf: result.issues.map { issue in
PureYAML.Stream.Issue(documentIndex: document.index, issue: issue)
})
}
return PureYAML.Stream.Result(issues)
}
/// 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 Validatable, PureYAML.Model.Value>) -> Self {
var copy = self
copy.checks.append(AnyCheck(check))
return copy
}
/// Removes every check whose description matches, the identity the validation idiom uses for
/// removal. Descriptions are therefore required to be unique and stable.
public func withoutValidating(_ description: String) -> Self {
var copy = self
copy.checks.removeAll { $0.description == description }
return copy
}
/// The descriptions of the active checks, in application order: the fixed point a
/// configuration-pin test asserts against, so adding, removing, or rewording one changes it
/// deliberately.
/// The descriptions of every active check, in application order.
public var validationDescriptions: [String] {
checks.map(\.description)
validations.map(\.description)
}
/// Descriptions of the non-reference default tier, in order.
public var nonReferenceValidationDescriptions: [String] {
nonReferenceDefaultValidations.map(\.description)
}
/// Descriptions of the reference default tier, in order.
public var referenceValidationDescriptions: [String] {
referenceDefaultValidations.map(\.description)
}
/// Descriptions of the custom tier, in order.
public var customValidationDescriptions: [String] {
customValidations.map(\.description)
}
fileprivate var validations: [AnyValidation<Document>] {
nonReferenceDefaultValidations + referenceDefaultValidations + customValidations
}
/// Returns a validator with `validation` appended to the custom tier.
@discardableResult
public func validating(_ validation: Validation<some Validatable, Document>) -> Validator {
Validator(
nonReferenceDefaultValidations: nonReferenceDefaultValidations,
referenceDefaultValidations: referenceDefaultValidations,
customValidations: customValidations + [AnyValidation(validation)],
)
}
/// Returns a validator with the checks matching `descriptions` removed from every tier, the
/// identity the validation idiom uses for removal. Descriptions are therefore required to be
/// unique and stable.
@discardableResult
public func withoutValidating(_ descriptions: String...) -> Validator {
let drop = Set(descriptions)
func filter(_ tier: [AnyValidation<Document>]) -> [AnyValidation<Document>] {
tier.filter { !drop.contains($0.description) }
}
return Validator(
nonReferenceDefaultValidations: filter(nonReferenceDefaultValidations),
referenceDefaultValidations: filter(referenceDefaultValidations),
customValidations: filter(customValidations),
)
}
public func collect(_ subject: Subject, in document: Document) -> Result {
var errors: [ValidationError] = []
walk(subject, at: .root, in: document, into: &errors)
return Result(errors)
}
@discardableResult
public func validate(
_ subject: Subject,
in document: Document,
strict: Bool = true,
) throws -> [ValidationError] {
let result = collect(subject, in: document)
let failures = strict ? result.values : result.errors
if !failures.isEmpty {
throw ValidationErrorCollection(failures)
}
return strict ? [] : result.warnings
}
private func walk(_ subject: Subject, at path: Path, in document: Document, into errors: inout [ValidationError]) {
// Offer the node itself, then any extra values this layer's Traversable conformance wants
// offered here (a mapping's own type, its key nodes), then recurse into its children.
offer(subject, at: path, in: document, into: &errors)
for (component, value) in subject.extraOffers {
offer(value, at: component.map(path.appending) ?? path, in: document, into: &errors)
}
for (component, child) in subject.children {
walk(child, at: path.appending(component), in: document, into: &errors)
}
}
private func offer(_ value: any Validatable, at path: Path, in document: Document, into errors: inout [ValidationError]) {
for validation in validations {
errors.append(contentsOf: validation.apply(to: value, at: path, in: document))
}
}
}
}
extension PureYAML.Validation.Validator {
func walk(
root: PureYAML.Model.Value,
subject: PureYAML.Model.Value,
path: PureYAML.Validation.Path,
issues: inout [PureYAML.Validation.Issue],
) {
// Offer each type-directed check the values it may specialise on at this node: the value
// itself, and, for a mapping, the mapping and each of its keys. The erased check filters by
// runtime type, so a `Check<Model.Mapping>` ignores the value and key offers, and a
// `Check<Model.Value>` (a whole-tree rule) fires on the value offer.
offer(subject, at: path, root: root, issues: &issues)
if case let .mapping(mapping) = subject {
offer(mapping, at: path, root: root, issues: &issues)
for pair in mapping.pairs {
offer(pair.keyNode, at: path.appending(pair.keyNode.pathComponent), root: root, issues: &issues)
}
}
switch subject {
case let .mapping(mapping):
for pair in mapping.pairs {
walk(
root: root,
subject: pair.value,
path: path.appending(pair.keyNode.pathComponent),
issues: &issues,
)
}
case let .sequence(values):
for index in values.indices {
walk(
root: root,
subject: values[index],
path: path.appending(.index(index)),
issues: &issues,
)
}
case .null, .bool, .int, .double, .string:
break
}
public extension PureYAML.Validation.Validator where Subject == Document {
/// Convenience for the common case where the walked tree's root doubles as the cross-cutting
/// document (every PureYAML layer, today).
func collect(_ subject: Subject) -> PureYAML.Validation.Result {
collect(subject, in: subject)
}
func offer(
_ subject: any PureYAML.Validation.Validatable,
at path: PureYAML.Validation.Path,
root: PureYAML.Model.Value,
issues: inout [PureYAML.Validation.Issue],
) {
for check in checks {
issues.append(contentsOf: check.apply(to: subject, at: path, in: root))
}
@discardableResult
func validate(_ subject: Subject, strict: Bool = true) throws -> [PureYAML.Validation.ValidationError] {
try validate(subject, in: subject, strict: strict)
}
}
public extension PureYAML.Validation.Validator where Subject == PureYAML.Model.Value, Document == PureYAML.Model.Value {
/// The model layer's default checks: schema-independent structural rules.
static var defaultValidations: [PureYAML.Validation.AnyValidation<PureYAML.Model.Value>] {
[PureYAML.Validation.AnyValidation(PureYAML.Validation.BuiltinValidation.mappingKeysAreUnique)]
}
convenience init() {
self.init(nonReferenceDefaultValidations: Self.defaultValidations)
}
@discardableResult
func validate(
_ documents: [PureYAML.Stream.Document],
strict: Bool = true,
) throws -> [PureYAML.Stream.Issue] {
let result = collect(documents)
let failures = strict ? result.issues : result.errors
if !failures.isEmpty {
throw PureYAML.Stream.Issue.Collection(failures)
}
return strict ? [] : result.warnings
}
func collect(_ documents: [PureYAML.Stream.Document]) -> PureYAML.Stream.Result {
var issues: [PureYAML.Stream.Issue] = []
for document in documents {
let result = collect(document.value)
issues.append(contentsOf: result.values.map { error in
PureYAML.Stream.Issue(documentIndex: document.index, issue: error)
})
}
return PureYAML.Stream.Result(issues)
}
}

View File

@ -20,12 +20,12 @@ struct ArbitraryAnyDecisionTests {
mapping.pairs.map { ($0.key, $0.value) },
uniquingKeysWith: { _, second in second },
)
let validationResult = PureYAML.Validation.Validator().collect(value)
let validationResult = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(value)
#expect(mapping.pairs.map(\.key) == ["title", "title", "slug"])
#expect(projectedDictionary.keys.sorted() == ["slug", "title"])
#expect(projectedDictionary["title"] == .string("Second"))
#expect(validationResult.issues == [
#expect(validationResult.values == [
.init(
severity: .error,
reason: "Duplicate mapping key 'title'",

View File

@ -102,14 +102,14 @@ struct CodableCompatibilityTests {
.init(key: "/users", value: .int(2)),
])))
} check: { collection in
#expect(collection.issues == [
#expect(collection.values == [
.init(
severity: .error,
reason: "Duplicate mapping key '/users'",
path: .init([.key("/users")]),
),
])
#expect(collection.description == "error: Duplicate mapping key '/users' at $[\"/users\"]")
#expect(collection.description == "Duplicate mapping key '/users' at path: $[\"/users\"]")
}
}
@ -225,12 +225,12 @@ private func expectEncodingError(
private func expectValidationError(
operation: () throws -> some Any,
check: (PureYAML.Validation.Issue.Collection) -> Void,
check: (PureYAML.Validation.ValidationErrorCollection) -> Void,
) {
do {
_ = try operation()
recordIssue("expected validation error")
} catch let collection as PureYAML.Validation.Issue.Collection {
} catch let collection as PureYAML.Validation.ValidationErrorCollection {
check(collection)
} catch {
recordIssue("unexpected error \(error)")

View File

@ -38,7 +38,7 @@ enum CollectionCompatibilityFixtures {
struct ValidationCase: CustomStringConvertible {
var name: String
var yaml: String
var expectedIssues: [PureYAML.Validation.Issue]
var expectedIssues: [PureYAML.Validation.ValidationError]
var description: String {
name

View File

@ -10,20 +10,20 @@ struct ComplexMappingKeyValidationTests {
.init(keyNode: key, value: .string("first")),
.init(keyNode: key, value: .string("second")),
]))
let expectedIssue = PureYAML.Validation.Issue(
let expectedIssue = PureYAML.Validation.ValidationError(
severity: .error,
reason: "Duplicate mapping key '[\"a\", \"b\"]'",
path: .init([.complexKey(key)]),
)
let result = PureYAML.Validation.Validator().collect(value)
let result = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(value)
#expect(result.issues == [expectedIssue])
#expect(result.issues.map(\.description) == [
"error: Duplicate mapping key '[\"a\", \"b\"]' at $[?[\"a\", \"b\"]]",
#expect(result.values == [expectedIssue])
#expect(result.values.map(\.description) == [
"Duplicate mapping key '[\"a\", \"b\"]' at path: $[?[\"a\", \"b\"]]",
])
expectValidationError(value) { collection in
#expect(collection.issues == [expectedIssue])
#expect(collection.values == [expectedIssue])
#expect(collection.description == expectedIssue.description)
}
}
@ -37,16 +37,16 @@ struct ComplexMappingKeyValidationTests {
.init(keyNode: key, value: .string("first")),
.init(keyNode: key, value: .string("second")),
]))
let expectedIssue = PureYAML.Validation.Issue(
let expectedIssue = PureYAML.Validation.ValidationError(
severity: .error,
reason: "Duplicate mapping key '{\"name\": \"service\"}'",
path: .init([.complexKey(key)]),
)
expectValidationError(value) { collection in
#expect(collection.issues == [expectedIssue])
#expect(collection.values == [expectedIssue])
#expect(collection.description == """
error: Duplicate mapping key '{"name": "service"}' at $[?{"name": "service"}]
Duplicate mapping key '{"name": "service"}' at path: $[?{"name": "service"}]
""")
}
}
@ -58,7 +58,7 @@ struct ComplexMappingKeyValidationTests {
.init(keyNode: key, value: .string("first")),
.init(keyNode: key, value: .string("second")),
]))
let expectedIssue = PureYAML.Validation.Issue(
let expectedIssue = PureYAML.Validation.ValidationError(
severity: .error,
reason: "Duplicate mapping key '[nan]'",
path: .init([.complexKey(key)]),
@ -66,8 +66,8 @@ struct ComplexMappingKeyValidationTests {
#expect(PureYAML.Model.Value.double(.nan) == .double(.nan))
expectValidationError(value) { collection in
#expect(collection.issues == [expectedIssue])
#expect(collection.description == "error: Duplicate mapping key '[nan]' at $[?[nan]]")
#expect(collection.values == [expectedIssue])
#expect(collection.description == "Duplicate mapping key '[nan]' at path: $[?[nan]]")
}
}
@ -82,7 +82,7 @@ struct ComplexMappingKeyValidationTests {
let issues = try PureYAML.validate(value, strict: false)
#expect(issues.isEmpty)
#expect(PureYAML.Validation.Validator().collect(value).issues.isEmpty)
#expect(PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(value).values.isEmpty)
#expect(value.rootMapping?["[\"a\"]"] == .string("scalar"))
#expect(value.rootMapping?[sequenceKey] == .string("complex"))
#expect(value.rootMapping?["a"] == nil)
@ -98,19 +98,19 @@ struct ComplexMappingKeyValidationTests {
.init(key: "mode", value: .string("legacy")),
]))),
]))
let expectedIssue = PureYAML.Validation.Issue(
let expectedIssue = PureYAML.Validation.ValidationError(
severity: .error,
reason: "Legacy mode is not allowed",
path: .init([.complexKey(key), .key("mode")]),
)
let result = PureYAML.Validation.Validator.blank
let result = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>.blank
.validating(legacyModeRule(severity: .error))
.collect(value)
#expect(result.issues == [expectedIssue])
#expect(result.issues.first?.description == """
error: Legacy mode is not allowed at $[?{"name": "service"}].mode
#expect(result.values == [expectedIssue])
#expect(result.values.first?.description == """
Legacy mode is not allowed at path: $[?{"name": "service"}].mode
""")
}
}

View File

@ -110,14 +110,14 @@ struct DocumentationExampleTests {
)
let tagIssues = PureYAML.Tagged.Validator().collect(tagged)
let expectedIssues = [
PureYAML.Validation.Issue(
PureYAML.Validation.ValidationError(
severity: .error,
reason: "Unsupported built-in tag 'tag:yaml.org,2002:timestamp'",
path: .init([.key("value")]),
),
]
#expect(tagIssues.issues == expectedIssues)
#expect(tagIssues.values == expectedIssues)
}
@Test("README tagged constructor example stays executable")
@ -180,20 +180,20 @@ struct DocumentationExampleTests {
.init(key: "title", value: .string("Second")),
]))
let expectedIssues = [
PureYAML.Validation.Issue(
PureYAML.Validation.ValidationError(
severity: .error,
reason: "Duplicate mapping key 'title'",
path: .init([.key("title")]),
),
]
let result = PureYAML.Validation.Validator().collect(document)
let result = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(document)
#expect(try PureYAML.validate(validDocument, strict: false).isEmpty)
#expect(result.errors == expectedIssues)
#expect(result.warnings.isEmpty)
expectValidationFailure(document) { collection in
#expect(collection.issues == expectedIssues)
#expect(collection.description == "error: Duplicate mapping key 'title' at $.title")
#expect(collection.values == expectedIssues)
#expect(collection.description == "Duplicate mapping key 'title' at path: $.title")
}
}
@ -210,12 +210,12 @@ struct DocumentationExampleTests {
func expectValidationFailure(
_ value: PureYAML.Model.Value,
check: (PureYAML.Validation.Issue.Collection) -> Void,
check: (PureYAML.Validation.ValidationErrorCollection) -> Void,
) {
do {
_ = try PureYAML.validate(value)
recordIssue("expected validation failure")
} catch let collection as PureYAML.Validation.Issue.Collection {
} catch let collection as PureYAML.Validation.ValidationErrorCollection {
check(collection)
} catch {
recordIssue("unexpected error \(error)")

View File

@ -116,7 +116,7 @@ struct DownstreamDocumentTests {
#expect(api?["name"] == .string("API"))
#expect(api?["retries"] == .int(3))
#expect(api?["timeoutSeconds"] == .int(30))
#expect(PureYAML.Validation.Validator().collect(.mapping(root)) == .init())
#expect(PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(.mapping(root)) == .init())
}
func requireRootMapping(_ yaml: String) throws -> PureYAML.Model.Mapping {

View File

@ -36,11 +36,11 @@ struct EventStreamingParserTests {
flow: {id: one, id: two}
""")
let issues = PureYAML.Validation.Validator().collect(value).issues
let issues = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(value).values
#expect(issues.map(\.description) == [
"error: Duplicate mapping key 'title' at $.root.title",
"error: Duplicate mapping key 'id' at $.flow.id",
"Duplicate mapping key 'title' at path: $.root.title",
"Duplicate mapping key 'id' at path: $.flow.id",
])
}
}

View File

@ -73,7 +73,7 @@ struct KeyedCodingTests {
.init(key: "title", value: .string("Second")),
]))
let expectedIssues = [
PureYAML.Validation.Issue(
PureYAML.Validation.ValidationError(
severity: .error,
reason: "Duplicate mapping key 'title'",
path: .init([.key("title")]),
@ -81,8 +81,8 @@ struct KeyedCodingTests {
]
expectValidationError(value) { collection in
#expect(collection.issues == expectedIssues)
#expect(collection.description == "error: Duplicate mapping key 'title' at $.title")
#expect(collection.values == expectedIssues)
#expect(collection.description == "Duplicate mapping key 'title' at path: $.title")
}
}
@ -94,7 +94,7 @@ struct KeyedCodingTests {
]))
expectValidationError(value) { collection in
#expect(collection.issues == [
#expect(collection.values == [
.init(
severity: .error,
reason: "Duplicate mapping key 'title'",
@ -114,14 +114,14 @@ struct KeyedCodingTests {
]))
expectValidationError(value) { collection in
#expect(collection.issues == [
#expect(collection.values == [
.init(
severity: .error,
reason: "Duplicate mapping key 'title'",
path: .init([.key("metadata"), .key("title")]),
),
])
#expect(collection.description == "error: Duplicate mapping key 'title' at $.metadata.title")
#expect(collection.description == "Duplicate mapping key 'title' at path: $.metadata.title")
} operation: {
_ = try TitleOnly(from: PureYAML.Decoding.Decoder(
value: value,
@ -312,13 +312,13 @@ private func expectEncodingError(
private func expectValidationError(
_: PureYAML.Model.Value,
check: (PureYAML.Validation.Issue.Collection) -> Void,
check: (PureYAML.Validation.ValidationErrorCollection) -> Void,
operation: () throws -> some Any,
) {
do {
_ = try operation()
recordIssue("expected validation error")
} catch let collection as PureYAML.Validation.Issue.Collection {
} catch let collection as PureYAML.Validation.ValidationErrorCollection {
check(collection)
} catch {
recordIssue("unexpected error \(error)")
@ -327,7 +327,7 @@ private func expectValidationError(
private func expectValidationError(
_ value: PureYAML.Model.Value,
check: (PureYAML.Validation.Issue.Collection) -> Void,
check: (PureYAML.Validation.ValidationErrorCollection) -> Void,
) {
expectValidationError(value, check: check) {
try PureYAML.decode(TitleOnly.self, from: value)

View File

@ -24,7 +24,7 @@ struct MergeKeyCompatibilityTests {
#expect(service?["retries"] == .int(3))
#expect(service?["name"] == .string("API"))
#expect(service?["<<"] == nil)
#expect(PureYAML.Validation.Validator().collect(.mapping(root ?? .init())) == .init())
#expect(PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(.mapping(root ?? .init())) == .init())
}
@Test("Expands merge sequence values with first source precedence and local overrides")
@ -86,20 +86,20 @@ struct MergeKeyCompatibilityTests {
name: Backend
""",
)
let result = PureYAML.Validation.Validator().collect(value)
let result = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(value)
let expectedIssues = [
PureYAML.Validation.Issue(
PureYAML.Validation.ValidationError(
severity: .error,
reason: "Duplicate mapping key 'name'",
path: .init([.key("service"), .key("name")]),
),
]
#expect(result.issues == expectedIssues)
#expect(result.values == expectedIssues)
#expect(result.errors == expectedIssues)
expectValidationError(value) { collection in
#expect(collection.issues == expectedIssues)
#expect(collection.description == "error: Duplicate mapping key 'name' at $.service.name")
#expect(collection.values == expectedIssues)
#expect(collection.description == "Duplicate mapping key 'name' at path: $.service.name")
}
let root = requireMapping(value, "expected root mapping")

View File

@ -12,7 +12,7 @@ struct ParsingCompatibilityTests {
#expect(value == testCase.expected)
expectAbsentRootKeys(testCase.absentRootKeys, in: value)
#expect(PureYAML.Validation.Validator().collect(value) == PureYAML.Validation.Result())
#expect(PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(value) == PureYAML.Validation.Result())
}
@Test(
@ -30,13 +30,13 @@ struct ParsingCompatibilityTests {
)
func test_collectionDuplicateKeyValidation(testCase: CollectionCompatibilityFixtures.ValidationCase) throws {
let value = try PureYAML.parse(testCase.yaml)
let result = PureYAML.Validation.Validator().collect(value)
let result = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(value)
#expect(result.issues == testCase.expectedIssues)
#expect(result.values == testCase.expectedIssues)
#expect(result.errors == testCase.expectedIssues)
#expect(result.warnings.isEmpty)
expectValidationError(value) { collection in
#expect(collection.issues == testCase.expectedIssues)
#expect(collection.values == testCase.expectedIssues)
#expect(collection.description == testCase.expectedIssues.map(\.description).joined(separator: "\n"))
}
}
@ -59,13 +59,13 @@ struct ParsingCompatibilityTests {
#expect(value == testCase.expected)
expectAbsentRootKeys(testCase.absentRootKeys, in: value)
#expect(PureYAML.Validation.Validator().collect(value) == PureYAML.Validation.Result())
#expect(PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(value) == PureYAML.Validation.Result())
}
@Test("Validates direct values for gaps ordinary loaders may collapse")
func test_directUnsupportedGapValueValidation() {
let value = UnsupportedYAMLGapsFixtures.directPreservedValue
let result = PureYAML.Validation.Validator().collect(value)
let result = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(value)
guard case let .mapping(mapping) = value else {
recordIssue("expected direct mapping value")
@ -76,13 +76,13 @@ struct ParsingCompatibilityTests {
#expect(mapping["<<"] == .mapping(.init([
.init(key: "enabled", value: .bool(true)),
])))
#expect(result.issues == UnsupportedYAMLGapsFixtures.directPreservedIssues)
#expect(result.issues.map(\.description) == [
"error: Duplicate mapping key 'title' at $.title",
#expect(result.values == UnsupportedYAMLGapsFixtures.directPreservedIssues)
#expect(result.values.map(\.description) == [
"Duplicate mapping key 'title' at path: $.title",
])
expectValidationError(value) { collection in
#expect(collection.issues == UnsupportedYAMLGapsFixtures.directPreservedIssues)
#expect(collection.description == "error: Duplicate mapping key 'title' at $.title")
#expect(collection.values == UnsupportedYAMLGapsFixtures.directPreservedIssues)
#expect(collection.description == "Duplicate mapping key 'title' at path: $.title")
}
}

View File

@ -0,0 +1,69 @@
import PureYAML
@testable import PureYAMLJSON
import Testing
/// "Coverage is itself a tested law" (validation-rules.md), applied to the JSON-representability
/// validator, the same discipline `ValidationCoverageMetaTests` in the core target applies to the model
/// and tagged validators.
@Suite("JSON representability finding-coverage law")
struct JSONCoverageMetaTests {
private struct Fixture {
let check: PureYAML.Validation.AnyValidation<PureYAML.Model.Value>
let failing: PureYAML.Model.Value
let nearMiss: PureYAML.Model.Value
}
private static let fixtures: [Fixture] = [
.init(
check: .init(PureYAML.Validation.BuiltinValidation.mappingKeysAreUnique),
failing: .mapping(.init([
.init(key: "a", value: .int(1)),
.init(key: "a", value: .int(2)),
])),
nearMiss: .mapping(.init([
.init(key: "a", value: .int(1)),
.init(key: "b", value: .int(2)),
])),
),
.init(
check: .init(PureYAML.Validation.BuiltinValidation.mappingKeysAreStrings),
failing: .mapping(.init([.init(keyNode: .sequence([.int(1)]), value: .int(2))])),
// The boundary: a string key, the only key shape JSON permits.
nearMiss: .mapping(.init([.init(key: "a", value: .int(2))])),
),
.init(
check: .init(PureYAML.Validation.BuiltinValidation.numbersAreFinite),
failing: .mapping(.init([.init(key: "x", value: .double(.infinity))])),
// The boundary: the largest finite double, one step from non-finite.
nearMiss: .mapping(.init([.init(key: "x", value: .double(.greatestFiniteMagnitude))])),
),
]
@Test("The JSON representability validator's descriptions are pinned")
func test_representabilityDescriptionsArePinned() {
#expect(PureYAML.JSON.Validator.representability.validationDescriptions == [
"Mapping keys are unique",
"Mapping keys are strings",
"Numbers are finite",
])
}
@Test("JSON fixtures cover exactly the representability validator's default checks")
func test_fixturesCoverDefaults() {
let ruled = Set(PureYAML.JSON.Validator.representability.validationDescriptions)
let covered = Set(Self.fixtures.map(\.check.description))
#expect(ruled.subtracting(covered).isEmpty, "JSON default checks without fixtures: \(ruled.subtracting(covered).sorted())")
#expect(covered.subtracting(ruled).isEmpty, "JSON fixtures for checks that no longer ship: \(covered.subtracting(ruled).sorted())")
}
@Test("Each JSON check fails its failing fixture and passes its near miss in isolation")
func test_checksFailAndNearMiss() throws {
for fixture in Self.fixtures {
let isolated = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>(nonReferenceDefaultValidations: [fixture.check])
#expect(throws: PureYAML.Validation.ValidationErrorCollection.self, "check '\(fixture.check.description)' did not fail its failing fixture") {
try isolated.validate(fixture.failing)
}
_ = try isolated.validate(fixture.nearMiss)
}
}
}

View File

@ -11,7 +11,12 @@ struct JSONRuleTests {
@Test("mappingKeysAreStrings fails on a complex key")
func test_mappingKeysAreStrings_fails() {
let document = mapping([.init(keyNode: .sequence([.int(1)]), value: .int(2))])
let issues = PureYAML.Validation.Validator(checks: [PureYAML.Validation.AnyCheck(PureYAML.Validation.BuiltinCheck.mappingKeysAreStrings)]).collect(document).issues
let issues = PureYAML.Validation
.Validator<
PureYAML.Model.Value,
PureYAML.Model.Value
>(nonReferenceDefaultValidations: [PureYAML.Validation.AnyValidation(PureYAML.Validation.BuiltinValidation.mappingKeysAreStrings)])
.collect(document).values
#expect(issues.count == 1)
#expect(issues.first?.reason == "Mapping key '[1]' is not a string")
}
@ -19,14 +24,24 @@ struct JSONRuleTests {
@Test("mappingKeysAreStrings passes when every key is a string")
func test_mappingKeysAreStrings_passes() {
let document = mapping([.init(key: "a", value: .int(1))])
let issues = PureYAML.Validation.Validator(checks: [PureYAML.Validation.AnyCheck(PureYAML.Validation.BuiltinCheck.mappingKeysAreStrings)]).collect(document).issues
let issues = PureYAML.Validation
.Validator<
PureYAML.Model.Value,
PureYAML.Model.Value
>(nonReferenceDefaultValidations: [PureYAML.Validation.AnyValidation(PureYAML.Validation.BuiltinValidation.mappingKeysAreStrings)])
.collect(document).values
#expect(issues.isEmpty)
}
@Test("numbersAreFinite fails on infinity at the value's path")
func test_numbersAreFinite_fails() {
let document = mapping([.init(key: "x", value: .double(.infinity))])
let issues = PureYAML.Validation.Validator(checks: [PureYAML.Validation.AnyCheck(PureYAML.Validation.BuiltinCheck.numbersAreFinite)]).collect(document).issues
let issues = PureYAML.Validation
.Validator<
PureYAML.Model.Value,
PureYAML.Model.Value
>(nonReferenceDefaultValidations: [PureYAML.Validation.AnyValidation(PureYAML.Validation.BuiltinValidation.numbersAreFinite)]).collect(document)
.values
#expect(issues.count == 1)
#expect(issues.first?.reason == "Number 'inf' is not finite")
}
@ -34,7 +49,12 @@ struct JSONRuleTests {
@Test("numbersAreFinite passes on a large finite double")
func test_numbersAreFinite_passes() {
let document = mapping([.init(key: "x", value: .double(.greatestFiniteMagnitude))])
let issues = PureYAML.Validation.Validator(checks: [PureYAML.Validation.AnyCheck(PureYAML.Validation.BuiltinCheck.numbersAreFinite)]).collect(document).issues
let issues = PureYAML.Validation
.Validator<
PureYAML.Model.Value,
PureYAML.Model.Value
>(nonReferenceDefaultValidations: [PureYAML.Validation.AnyValidation(PureYAML.Validation.BuiltinValidation.numbersAreFinite)]).collect(document)
.values
#expect(issues.isEmpty)
}

View File

@ -41,7 +41,7 @@ struct RealYAMLCorpusTests {
continue
}
let issues = PureYAML.Validation.Validator().collect(documents)
let issues = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(documents)
let issueDescriptions = issues.issues.map(\.description).joined(separator: "\n")
#expect(issues == PureYAML.Stream.Result(), "\(seed.id): \(issueDescriptions)")
guard seed.expectedRoundTrip == "success" else {

View File

@ -24,7 +24,7 @@ struct RealYAMLFixtureTests {
let value = try PureYAML.parse(fixture.load())
fixture.expectRepresentativeValues(in: value)
#expect(PureYAML.Validation.Validator().collect(value) == PureYAML.Validation.Result())
#expect(PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(value) == PureYAML.Validation.Result())
}
}

View File

@ -232,7 +232,7 @@ struct StreamValidationTests {
),
]
let result = PureYAML.Validation.Validator().collect(documents)
let result = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(documents)
#expect(result.issues == expectedIssues)
#expect(result.errors == expectedIssues)
#expect(result.warnings.isEmpty)
@ -240,8 +240,8 @@ struct StreamValidationTests {
expectStreamValidationError(documents) { collection in
#expect(collection.issues == expectedIssues)
#expect(collection.description == """
document[0]: error: Duplicate mapping key 'title' at $.title
document[1]: error: Duplicate mapping key 'name' at $.routes[0].name
document[0]: Duplicate mapping key 'title' at path: $.title
document[1]: Duplicate mapping key 'name' at path: $.routes[0].name
""")
}
}
@ -258,7 +258,7 @@ struct StreamValidationTests {
""",
)
let result = PureYAML.Validation.Validator().collect(documents)
let result = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(documents)
#expect(result == PureYAML.Stream.Result())
#expect(result.isValid)
#expect(result.errors.isEmpty)
@ -272,7 +272,7 @@ struct StreamValidationTests {
PureYAML.Stream.Document(index: 4, value: .string("legacy")),
PureYAML.Stream.Document(index: 5, value: .string("modern")),
]
let validator = PureYAML.Validation.Validator.blank
let validator = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>.blank
.validating(legacyModeRule(severity: .warning))
let expectedIssues = [
PureYAML.Stream.Issue(
@ -292,7 +292,7 @@ struct StreamValidationTests {
#expect(warnings == expectedIssues)
let modernOnly = [PureYAML.Stream.Document(index: 6, value: .string("modern"))]
#expect(PureYAML.Validation.Validator.blank
#expect(PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>.blank
.validating(legacyModeRule(severity: .warning))
.collect(modernOnly)
.issues
@ -318,7 +318,7 @@ private func expectStreamParseError(
private func expectStreamValidationError(
_ documents: [PureYAML.Stream.Document],
using validator: PureYAML.Validation.Validator = .init(),
using validator: PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value> = .init(),
check: (PureYAML.Stream.Issue.Collection) -> Void,
) {
do {

View File

@ -61,27 +61,27 @@ struct TaggedValidationCompatibilityTests {
""",
)
let expectedIssues = [
PureYAML.Validation.Issue(
PureYAML.Validation.ValidationError(
severity: .error,
reason: "Unsupported built-in tag 'tag:yaml.org,2002:timestamp'",
path: .init([.key("date")]),
),
PureYAML.Validation.Issue(
PureYAML.Validation.ValidationError(
severity: .error,
reason: "Unsupported built-in tag 'tag:yaml.org,2002:binary'",
path: .init([.key("payload")]),
),
PureYAML.Validation.Issue(
PureYAML.Validation.ValidationError(
severity: .error,
reason: "Unsupported built-in tag 'tag:yaml.org,2002:set'",
path: .init([.key("set")]),
),
PureYAML.Validation.Issue(
PureYAML.Validation.ValidationError(
severity: .error,
reason: "Unsupported built-in tag 'tag:yaml.org,2002:omap'",
path: .init([.key("omap")]),
),
PureYAML.Validation.Issue(
PureYAML.Validation.ValidationError(
severity: .error,
reason: "Unsupported built-in tag 'tag:yaml.org,2002:pairs'",
path: .init([.key("pairs")]),
@ -89,10 +89,10 @@ struct TaggedValidationCompatibilityTests {
]
let result = PureYAML.Tagged.Validator().collect(node)
#expect(result.issues == expectedIssues)
#expect(result.values == expectedIssues)
#expect(result.errors == expectedIssues)
expectTaggedValidationError(node) { collection in
#expect(collection.issues == expectedIssues)
#expect(collection.values == expectedIssues)
#expect(collection.description == expectedIssues.map(\.description).joined(separator: "\n"))
}
}
@ -107,24 +107,24 @@ struct TaggedValidationCompatibilityTests {
""",
)
let expectedIssues = [
PureYAML.Validation.Issue(
PureYAML.Validation.ValidationError(
severity: .error,
reason: "Tag 'tag:yaml.org,2002:seq' expects a sequence node, found mapping",
path: .init([.key("seq")]),
),
PureYAML.Validation.Issue(
PureYAML.Validation.ValidationError(
severity: .error,
reason: "Tag 'tag:yaml.org,2002:map' expects a mapping node, found sequence",
path: .init([.key("map")]),
),
PureYAML.Validation.Issue(
PureYAML.Validation.ValidationError(
severity: .error,
reason: "Tag 'tag:yaml.org,2002:str' expects a scalar node, found sequence",
path: .init([.key("scalar")]),
),
]
#expect(PureYAML.Tagged.Validator().collect(node).issues == expectedIssues)
#expect(PureYAML.Tagged.Validator().collect(node).values == expectedIssues)
}
@Test("Validates unsupported and inconsistent mapping key tags exactly")
@ -142,17 +142,17 @@ struct TaggedValidationCompatibilityTests {
""",
)
let expectedIssues = [
PureYAML.Validation.Issue(
PureYAML.Validation.ValidationError(
severity: .error,
reason: "Unsupported built-in tag 'tag:yaml.org,2002:timestamp' on mapping key",
path: .init([.key("2001-01-23")]),
),
PureYAML.Validation.Issue(
PureYAML.Validation.ValidationError(
severity: .error,
reason: "Tag 'tag:yaml.org,2002:seq' expects a scalar mapping key",
path: .init([.key("key")]),
),
PureYAML.Validation.Issue(
PureYAML.Validation.ValidationError(
severity: .error,
reason: "Tag 'tag:yaml.org,2002:merge' is only supported on '<<' mapping keys",
path: .init([.key("not-merge")]),
@ -161,7 +161,7 @@ struct TaggedValidationCompatibilityTests {
let mapping = try requireTaggedMapping(node)
#expect(mapping?.pairs.map(\.key) == ["2001-01-23", "key", "not-merge", "<<"])
#expect(PureYAML.Tagged.Validator().collect(node).issues == expectedIssues)
#expect(PureYAML.Tagged.Validator().collect(node).values == expectedIssues)
}
@Test("Validates mapping node tags and mapping key tags in one deterministic pass")
@ -177,19 +177,19 @@ struct TaggedValidationCompatibilityTests {
tag: .set,
))
let expectedIssues = [
PureYAML.Validation.Issue(
PureYAML.Validation.ValidationError(
severity: .error,
reason: "Unsupported built-in tag 'tag:yaml.org,2002:timestamp' on mapping key",
path: .init([.key("2001-01-23")]),
),
PureYAML.Validation.Issue(
PureYAML.Validation.ValidationError(
severity: .error,
reason: "Unsupported built-in tag 'tag:yaml.org,2002:set'",
path: .root,
),
]
#expect(PureYAML.Tagged.Validator().collect(node).issues == expectedIssues)
#expect(PureYAML.Tagged.Validator().collect(node).values == expectedIssues)
}
@Test("Allows project-specific tags for application validation")
@ -212,14 +212,14 @@ 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.Validation.Check<PureYAML.Tagged.Node, PureYAML.Tagged.Node>(
let localTagsAreWarnings = PureYAML.Validation.Validation<PureYAML.Tagged.Node, PureYAML.Tagged.Node>(
description: "Local tags are warnings",
) { context in
guard context.subject.tag == .init("!Local") else {
return []
}
return [
PureYAML.Validation.Issue(
PureYAML.Validation.ValidationError(
severity: .warning,
reason: "Local application tag requires caller handling",
path: context.path,
@ -227,7 +227,7 @@ struct TaggedValidationCompatibilityTests {
]
}
let expectedIssues = [
PureYAML.Validation.Issue(
PureYAML.Validation.ValidationError(
severity: .warning,
reason: "Local application tag requires caller handling",
path: .init([.key("value")]),
@ -236,10 +236,10 @@ struct TaggedValidationCompatibilityTests {
let validator = PureYAML.Tagged.Validator.blank.validating(localTagsAreWarnings)
#expect(PureYAML.Tagged.Validator.blank.collect(node) == .init())
#expect(validator.collect(node).issues == expectedIssues)
#expect(validator.collect(node).values == expectedIssues)
#expect(try validator.validate(node, strict: false) == expectedIssues)
expectTaggedValidationError(node, using: validator) { collection in
#expect(collection.issues == expectedIssues)
#expect(collection.values == expectedIssues)
}
}
@ -319,12 +319,12 @@ private func requireTaggedScalar(
private func expectTaggedValidationError(
_ node: PureYAML.Tagged.Node,
using validator: PureYAML.Tagged.Validator = .init(),
body: (PureYAML.Validation.Issue.Collection) -> Void,
body: (PureYAML.Validation.ValidationErrorCollection) -> Void,
) {
do {
try validator.validate(node)
recordIssue("expected tagged validation to fail")
} catch let collection as PureYAML.Validation.Issue.Collection {
} catch let collection as PureYAML.Validation.ValidationErrorCollection {
body(collection)
} catch {
recordIssue("expected validation issue collection, got \(error)")

View File

@ -183,7 +183,7 @@ struct TaggedConstructorTests {
.construct(node)
let mapping = requireMapping(value)
let expectedIssues = [
PureYAML.Validation.Issue(
PureYAML.Validation.ValidationError(
severity: .error,
reason: "Duplicate mapping key 'title'",
path: .init([.key("title")]),
@ -193,7 +193,7 @@ struct TaggedConstructorTests {
#expect(mapping?.pairs.map(\.key) == ["title", "title"])
#expect(mapping?.pairs.map(\.value) == [.string("one"), .string("two")])
#expect(value == node.modelValueErasingTags)
#expect(PureYAML.Validation.Validator().collect(value).issues == expectedIssues)
#expect(PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(value).values == expectedIssues)
}
@Test("Constructors do not run tagged validation implicitly")
@ -203,7 +203,7 @@ struct TaggedConstructorTests {
.modelValueErasingTags
.construct(node)
let expectedIssues = [
PureYAML.Validation.Issue(
PureYAML.Validation.ValidationError(
severity: .error,
reason: "Unsupported built-in tag 'tag:yaml.org,2002:timestamp'",
path: .root,
@ -211,7 +211,7 @@ struct TaggedConstructorTests {
]
#expect(value == .string("2001-01-23"))
#expect(PureYAML.Tagged.Validator().collect(node).issues == expectedIssues)
#expect(PureYAML.Tagged.Validator().collect(node).values == expectedIssues)
}
@Test("Tagged construction keeps complex mapping keys out of the tagged layer")

View File

@ -9,14 +9,14 @@ struct TaggedKeyUniquenessTests {
@Test("A duplicate key in a tagged mapping is reported at the key's path")
func test_reportsDuplicateTaggedKey() throws {
let node = try PureYAML.parseTagged("a: 1\na: 2")
#expect(PureYAML.Tagged.Validator().collect(node).issues == [
#expect(PureYAML.Tagged.Validator().collect(node).values == [
.init(
severity: .error,
reason: "Duplicate mapping key 'a'",
path: .init([.key("a")]),
),
])
#expect(throws: PureYAML.Validation.Issue.Collection.self) {
#expect(throws: PureYAML.Validation.ValidationErrorCollection.self) {
try PureYAML.Tagged.Validator().validate(node)
}
}
@ -30,7 +30,7 @@ struct TaggedKeyUniquenessTests {
@Test("A duplicate key nested under a tag is reported at its nested path")
func test_reportsNestedDuplicateTaggedKey() throws {
let node = try PureYAML.parseTagged("outer:\n a: 1\n a: 2")
#expect(PureYAML.Tagged.Validator().collect(node).issues == [
#expect(PureYAML.Tagged.Validator().collect(node).values == [
.init(
severity: .error,
reason: "Duplicate mapping key 'a'",

View File

@ -202,6 +202,6 @@ struct TaggedNodeRoundTripTests {
let source = "a: &dup\n k: 1\n k: 2\nb: *dup\nc: *dup\n"
let node = try PureYAML.parseTagged(source)
let result = PureYAML.Tagged.Validator().collect(node)
#expect(result.issues.count == 1)
#expect(result.values.count == 1)
}
}

View File

@ -101,14 +101,14 @@ struct UnkeyedCodingTests {
expectValidationError {
_ = try PureYAML.decode([TitleOnlyArticle].self, from: value)
} check: { collection in
#expect(collection.issues == [
#expect(collection.values == [
.init(
severity: .error,
reason: "Duplicate mapping key 'title'",
path: .init([.index(0), .key("title")]),
),
])
#expect(collection.description == "error: Duplicate mapping key 'title' at $[0].title")
#expect(collection.description == "Duplicate mapping key 'title' at path: $[0].title")
}
}
@ -311,12 +311,12 @@ private func expectEncodingError(
private func expectValidationError(
operation: () throws -> some Any,
check: (PureYAML.Validation.Issue.Collection) -> Void,
check: (PureYAML.Validation.ValidationErrorCollection) -> Void,
) {
do {
_ = try operation()
recordIssue("expected validation error")
} catch let collection as PureYAML.Validation.Issue.Collection {
} catch let collection as PureYAML.Validation.ValidationErrorCollection {
check(collection)
} catch {
recordIssue("unexpected error \(error)")

View File

@ -11,7 +11,7 @@ struct SequenceFailureFixture: CustomStringConvertible {
enum Expected {
case decoding(PureYAML.Decoding.Error)
case validation([PureYAML.Validation.Issue], String)
case validation([PureYAML.Validation.ValidationError], String)
}
var name: String
@ -43,12 +43,12 @@ struct SequenceFailureFixture: CustomStringConvertible {
}
#expect(error == expected)
#expect(error.description == expected.description)
} catch let collection as PureYAML.Validation.Issue.Collection {
} catch let collection as PureYAML.Validation.ValidationErrorCollection {
guard case let .validation(issues, description) = expected else {
recordIssue("unexpected validation error \(collection)")
return
}
#expect(collection.issues == issues)
#expect(collection.values == issues)
#expect(collection.description == description)
} catch {
recordIssue("unexpected error \(error)")
@ -131,7 +131,7 @@ let sequenceFailureFixtures: [SequenceFailureFixture] = [
path: .init([.index(0), .key("title")]),
),
],
"error: Duplicate mapping key 'title' at $[0].title",
"Duplicate mapping key 'title' at path: $[0].title",
),
),
]

View File

@ -143,7 +143,7 @@ enum UnsupportedYAMLGapsFixtures {
.init(key: "title", value: .string("Second")),
]))
static let directPreservedIssues: [PureYAML.Validation.Issue] = [
static let directPreservedIssues: [PureYAML.Validation.ValidationError] = [
.init(
severity: .error,
reason: "Duplicate mapping key 'title'",

View File

@ -5,38 +5,38 @@ import Testing
struct ValidationBuiltinRuleTests {
@Test("Validates preserved value trees that ordinary YAML loaders may collapse")
func test_validatesPreservedValueTreesThatOrdinaryYAMLLoadersMayCollapse() {
let result = PureYAML.Validation.Validator().collect(preservedDuplicateKeyValue)
let result = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(preservedDuplicateKeyValue)
#expect(result.issues == expectedIssues)
#expect(result.issues.map(\.description) == [
"error: Duplicate mapping key 'title' at $.title",
"error: Duplicate mapping key 'items' at $.items",
"error: Duplicate mapping key '/users' at $.paths[\"/users\"]",
"error: Duplicate mapping key 'name.with.dot' at $.items[0][\"name.with.dot\"]",
#expect(result.values == expectedIssues)
#expect(result.values.map(\.description) == [
"Duplicate mapping key 'title' at path: $.title",
"Duplicate mapping key 'items' at path: $.items",
"Duplicate mapping key '/users' at path: $.paths[\"/users\"]",
"Duplicate mapping key 'name.with.dot' at path: $.items[0][\"name.with.dot\"]",
])
#expect(!result.issues.contains(.init(
#expect(!result.values.contains(.init(
severity: .error,
reason: "Duplicate mapping key 'paths'",
path: .init([.key("paths")]),
)))
expectValidationError(preservedDuplicateKeyValue) { collection in
#expect(collection.issues == expectedIssues)
#expect(collection.values == expectedIssues)
#expect(collection.description == expectedIssues.map(\.description).joined(separator: "\n"))
}
}
@Test("Mapping key uniqueness is scoped to mappings by type-directed dispatch")
func test_mappingKeyUniquenessIsScopedToMappings() {
let validator = PureYAML.Validation.Validator()
let validator = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>()
// A scalar value carries no mapping, so the mapping-keyed check never fires on it.
#expect(validator.collect(.string("subject")).issues.isEmpty)
#expect(validator.collect(.string("subject")).values.isEmpty)
// A mapping with a duplicate key reports exactly one issue, at the key's path.
#expect(validator.collect(.mapping(.init([
.init(key: "title", value: .string("one")),
.init(key: "title", value: .string("two")),
]))).issues == [
]))).values == [
.init(
severity: .error,
reason: "Duplicate mapping key 'title'",
@ -62,7 +62,7 @@ private let preservedDuplicateKeyValue = PureYAML.Model.Value.mapping(.init([
.init(key: "items", value: .sequence([])),
]))
private let expectedIssues: [PureYAML.Validation.Issue] = [
private let expectedIssues: [PureYAML.Validation.ValidationError] = [
.init(
severity: .error,
reason: "Duplicate mapping key 'title'",

View File

@ -1,11 +1,17 @@
@testable import PureYAML
import Testing
/// `Optional<Mapping>` does not conform to `Validatable` in production (an Optional must never
/// satisfy a non-Optional check); this test-only alias and conformance let a machinery test box one
/// into the erased `any Validatable` boundary to prove the runtime `type(of:)` guard rejects it.
private typealias OptionalMapping = PureYAML.Model.Mapping?
extension PureYAML.Model.Mapping?: PureYAML.Validation.Validatable {}
@Suite("Type-directed check machinery")
struct ValidationCheckMachineryTests {
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 typealias Check = PureYAML.Validation.Validation<PureYAML.Model.Mapping, PureYAML.Model.Value>
private typealias AnyValidation = PureYAML.Validation.AnyValidation<PureYAML.Model.Value>
private typealias Issue = PureYAML.Validation.ValidationError
private var mapping: PureYAML.Model.Mapping {
.init([.init(key: "a", value: .int(1))])
@ -21,24 +27,33 @@ struct ValidationCheckMachineryTests {
@Test("A check fires on its subject type and produces its error")
func test_firesOnSubjectType() {
let erased = AnyCheck(alwaysFails())
let erased = AnyValidation(alwaysFails())
#expect(erased.apply(to: mapping, at: .root, in: .null).count == 1)
}
@Test("A check does not fire on a value of another type")
func test_doesNotFireOnWrongType() {
let erased = AnyCheck(alwaysFails())
let erased = AnyValidation(alwaysFails())
// A Model.Value (even one wrapping a mapping) is not a Model.Mapping, so nothing fires.
#expect(erased.apply(to: PureYAML.Model.Value.mapping(mapping), at: .root, in: .null).isEmpty)
#expect(erased.apply(to: PureYAML.Model.Key.string("a"), at: .root, in: .null).isEmpty)
}
@Test("An Optional wrapping the subject type does not satisfy the non-Optional check")
func test_optionalDoesNotSatisfyNonOptionalCheck() {
let erased = AnyValidation(alwaysFails())
let optional: OptionalMapping = mapping
#expect(erased.apply(to: optional, at: .root, in: .null).isEmpty)
// The exact type still fires, so the guard above is not vacuously true.
#expect(erased.apply(to: mapping, at: .root, in: .null).count == 1)
}
@Test("A false `when` predicate yields no errors")
func test_falsePredicateYieldsNothing() {
let gated = Check(description: "gated", check: { _ in
[Issue(severity: .error, reason: "boom", path: .root)]
}, when: { _ in false })
#expect(AnyCheck(gated).apply(to: mapping, at: .root, in: .null).isEmpty)
#expect(AnyValidation(gated).apply(to: mapping, at: .root, in: .null).isEmpty)
}
// MARK: positive control (proving the negatives are not vacuous)
@ -50,8 +65,8 @@ struct ValidationCheckMachineryTests {
.mapping(.init([.init(key: "a", value: .int(1))])),
.mapping(.init([.init(key: "b", value: .int(2))])),
]))
let validator = PureYAML.Validation.Validator.blank.validating(alwaysFails())
let issues = validator.collect(document).issues
let validator = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>.blank.validating(alwaysFails())
let issues = validator.collect(document).values
#expect(issues.count == 2)
}
@ -59,11 +74,11 @@ struct ValidationCheckMachineryTests {
@Test("The type-directed mappingKeysAreUnique builtin catches duplicate keys")
func test_builtinCatchesDuplicateKeys() {
let validator = PureYAML.Validation.Validator.blank.validating(
PureYAML.Validation.BuiltinCheck.mappingKeysAreUnique,
let validator = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>.blank.validating(
PureYAML.Validation.BuiltinValidation.mappingKeysAreUnique,
)
#expect(validator.validationDescriptions == ["Mapping keys are unique"])
#expect(throws: PureYAML.Validation.Issue.Collection.self) {
#expect(throws: PureYAML.Validation.ValidationErrorCollection.self) {
try validator.validate(.mapping(.init([
.init(key: "a", value: .int(1)),
.init(key: "a", value: .int(2)),
@ -73,8 +88,8 @@ struct ValidationCheckMachineryTests {
@Test("The builtin passes a near-miss document whose keys are unique")
func test_builtinPassesUniqueKeys() throws {
let validator = PureYAML.Validation.Validator.blank.validating(
PureYAML.Validation.BuiltinCheck.mappingKeysAreUnique,
let validator = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>.blank.validating(
PureYAML.Validation.BuiltinValidation.mappingKeysAreUnique,
)
// The boundary the rule almost trips: two keys that differ by one character.
_ = try validator.validate(.mapping(.init([
@ -85,8 +100,8 @@ struct ValidationCheckMachineryTests {
@Test("Type-directed dispatch reaches a nested mapping")
func test_dispatchReachesNestedMapping() {
let validator = PureYAML.Validation.Validator.blank.validating(
PureYAML.Validation.BuiltinCheck.mappingKeysAreUnique,
let validator = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>.blank.validating(
PureYAML.Validation.BuiltinValidation.mappingKeysAreUnique,
)
let nested = PureYAML.Model.Value.mapping(.init([
.init(key: "outer", value: .mapping(.init([

View File

@ -5,27 +5,36 @@ private struct Address: PureYAML.Validation.Validatable {
var city: String
}
private struct Nickname: PureYAML.Validation.Validatable {
var value: String
}
private struct Person: PureYAML.Validation.Validatable {
var name: String
var age: Int
var address: Address
var nickname: Nickname?
}
@Suite("Validation combinators")
struct ValidationCombinatorTests {
private typealias Context = PureYAML.Validation.SubjectContext<Person, PureYAML.Model.Value>
private typealias Context = PureYAML.Validation.ValidationContext<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 context(name: String = "Ada", age: Int = 30, city: String = "Lovelace", nickname: String? = nil) -> Context {
.init(
document: .null,
subject: Person(name: name, age: age, address: Address(city: city), nickname: nickname.map(Nickname.init)),
path: .root,
)
}
private func failing(_ description: String) -> PureYAML.Validation.Check<Person, PureYAML.Model.Value> {
private func failing(_ description: String) -> PureYAML.Validation.Validation<Person, PureYAML.Model.Value> {
.init(description: description) { ctx in
[PureYAML.Validation.Issue(severity: .error, reason: description, path: ctx.path)]
[PureYAML.Validation.ValidationError(severity: .error, reason: description, path: ctx.path)]
}
}
private func passing(_ description: String) -> PureYAML.Validation.Check<Person, PureYAML.Model.Value> {
private func passing(_ description: String) -> PureYAML.Validation.Validation<Person, PureYAML.Model.Value> {
.init(description: description) { _ in [] }
}
@ -48,7 +57,7 @@ struct ValidationCombinatorTests {
@Test("A Bool check built from an operator auto-formats its failure")
func test_boolCheckFromOperator() {
let adult = PureYAML.Validation.Check<Person, PureYAML.Model.Value>(
let adult = PureYAML.Validation.Validation<Person, PureYAML.Model.Value>(
description: "Person is an adult",
check: (\Context.subject.age) >= 18,
)
@ -80,7 +89,7 @@ struct ValidationCombinatorTests {
@Test("lift runs child-typed checks against a value reached by key path")
func test_lift() {
let cityNotEmpty = PureYAML.Validation.Check<Address, PureYAML.Model.Value>(
let cityNotEmpty = PureYAML.Validation.Validation<Address, PureYAML.Model.Value>(
description: "City is set",
check: { !$0.subject.city.isEmpty },
)
@ -88,4 +97,24 @@ struct ValidationCombinatorTests {
#expect(lifted.apply(context(city: "Lovelace")).isEmpty)
#expect(lifted.apply(context(city: "")).count == 1)
}
@Test("unwrap errors on nil and otherwise runs child checks against the unwrapped value")
func test_unwrap() {
let notEmpty = PureYAML.Validation.Validation<Nickname, PureYAML.Model.Value>(
description: "Nickname is not empty",
check: { !$0.subject.value.isEmpty },
)
let unwrapped = PureYAML.Validation.unwrap(\Person.nickname, into: notEmpty)
let onNil = unwrapped.apply(context(nickname: nil))
#expect(onNil.count == 1)
#expect(onNil.first?.reason.contains("nil") == true)
let onValid = unwrapped.apply(context(nickname: "Ada"))
#expect(onValid.isEmpty)
let onInvalid = unwrapped.apply(context(nickname: ""))
#expect(onInvalid.count == 1)
#expect(onInvalid.first?.reason == "Failed to satisfy: Nickname is not empty")
}
}

View File

@ -12,19 +12,19 @@ struct ValidationConfigurationTests {
@Test("The blank validator has no rules; the default set is pinned by description")
func test_configurationDescriptionsArePinned() {
#expect(PureYAML.Validation.Validator.blank.validationDescriptions == [])
#expect(PureYAML.Validation.Validator().validationDescriptions == ["Mapping keys are unique"])
#expect(PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>.blank.validationDescriptions == [])
#expect(PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().validationDescriptions == ["Mapping keys are unique"])
}
@Test("Default rule descriptions are unique, the identity withoutValidating removes by")
func test_defaultDescriptionsAreUnique() {
let descriptions = PureYAML.Validation.Validator().validationDescriptions
let descriptions = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().validationDescriptions
#expect(Set(descriptions).count == descriptions.count)
}
@Test("withoutValidating removes a rule by its description")
func test_withoutValidatingRemovesByDescription() throws {
let validator = PureYAML.Validation.Validator().withoutValidating("Mapping keys are unique")
let validator = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().withoutValidating("Mapping keys are unique")
#expect(validator.validationDescriptions == [])
// With the rule removed, a document with duplicate keys validates clean.
_ = try validator.validate(duplicateKeys)
@ -32,8 +32,8 @@ struct ValidationConfigurationTests {
@Test("The default validator still catches what its removable rule guards")
func test_defaultStillValidates() {
#expect(throws: PureYAML.Validation.Issue.Collection.self) {
try PureYAML.Validation.Validator().validate(duplicateKeys)
#expect(throws: PureYAML.Validation.ValidationErrorCollection.self) {
try PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().validate(duplicateKeys)
}
}
}

View File

@ -17,18 +17,18 @@ enum ValidationCorpusSource {
struct ValidationCorpusIssueFixture: CustomStringConvertible {
var name: String
var source: ValidationCorpusSource
var validator: PureYAML.Validation.Validator
var validator: PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>
var strict: Bool
var expectedIssues: [PureYAML.Validation.Issue]
var forbiddenIssues: [PureYAML.Validation.Issue]
var expectedIssues: [PureYAML.Validation.ValidationError]
var forbiddenIssues: [PureYAML.Validation.ValidationError]
init(
name: String,
source: ValidationCorpusSource,
validator: PureYAML.Validation.Validator,
validator: PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>,
strict: Bool = true,
expectedIssues: [PureYAML.Validation.Issue],
forbiddenIssues: [PureYAML.Validation.Issue] = [],
expectedIssues: [PureYAML.Validation.ValidationError],
forbiddenIssues: [PureYAML.Validation.ValidationError] = [],
) {
self.name = name
self.source = source
@ -46,14 +46,14 @@ struct ValidationCorpusIssueFixture: CustomStringConvertible {
struct ValidationCorpusSuccessFixture: CustomStringConvertible {
var name: String
var source: ValidationCorpusSource
var validator: PureYAML.Validation.Validator
var forbiddenIssues: [PureYAML.Validation.Issue]
var validator: PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>
var forbiddenIssues: [PureYAML.Validation.ValidationError]
init(
name: String,
source: ValidationCorpusSource,
validator: PureYAML.Validation.Validator,
forbiddenIssues: [PureYAML.Validation.Issue] = [],
validator: PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>,
forbiddenIssues: [PureYAML.Validation.ValidationError] = [],
) {
self.name = name
self.source = source
@ -66,15 +66,15 @@ struct ValidationCorpusSuccessFixture: CustomStringConvertible {
}
}
func requiredRootStringKeyRule(_ key: String) -> PureYAML.Validation.Check<PureYAML.Model.Value, PureYAML.Model.Value> {
PureYAML.Validation.Check<PureYAML.Model.Value, PureYAML.Model.Value>(description: "Root requires string key '\(key)'") { context in
func requiredRootStringKeyRule(_ key: String) -> PureYAML.Validation.Validation<PureYAML.Model.Value, PureYAML.Model.Value> {
PureYAML.Validation.Validation<PureYAML.Model.Value, PureYAML.Model.Value>(description: "Root requires string key '\(key)'") { context in
guard case let .mapping(mapping) = context.subject else {
return []
}
guard let value = mapping[key] else {
return [
PureYAML.Validation.Issue(
PureYAML.Validation.ValidationError(
severity: .error,
reason: "Required string key '\(key)' is missing",
path: context.path.appending(.key(key)),
@ -84,7 +84,7 @@ func requiredRootStringKeyRule(_ key: String) -> PureYAML.Validation.Check<PureY
guard case .string = value else {
return [
PureYAML.Validation.Issue(
PureYAML.Validation.ValidationError(
severity: .error,
reason: "Required string key '\(key)' must be a string",
path: context.path.appending(.key(key)),
@ -101,8 +101,8 @@ func requiredRootStringKeyRule(_ key: String) -> PureYAML.Validation.Check<PureY
func forbiddenMappingKeyRule(
_ key: String,
severity: PureYAML.Validation.Severity,
) -> PureYAML.Validation.Check<PureYAML.Model.Value, PureYAML.Model.Value> {
PureYAML.Validation.Check<PureYAML.Model.Value, PureYAML.Model.Value>(description: "Mapping forbids key '\(key)'") { context in
) -> PureYAML.Validation.Validation<PureYAML.Model.Value, PureYAML.Model.Value> {
PureYAML.Validation.Validation<PureYAML.Model.Value, PureYAML.Model.Value>(description: "Mapping forbids key '\(key)'") { context in
guard case let .mapping(mapping) = context.subject,
mapping[key] != nil
else {
@ -110,7 +110,7 @@ func forbiddenMappingKeyRule(
}
return [
PureYAML.Validation.Issue(
PureYAML.Validation.ValidationError(
severity: severity,
reason: "Forbidden key '\(key)' is present",
path: context.path.appending(.key(key)),
@ -124,8 +124,8 @@ func forbiddenMappingKeyRule(
}
}
func uniqueRouteNamesRule() -> PureYAML.Validation.Check<PureYAML.Model.Value, PureYAML.Model.Value> {
PureYAML.Validation.Check<PureYAML.Model.Value, PureYAML.Model.Value>(description: "Route names are unique") { context in
func uniqueRouteNamesRule() -> PureYAML.Validation.Validation<PureYAML.Model.Value, PureYAML.Model.Value> {
PureYAML.Validation.Validation<PureYAML.Model.Value, PureYAML.Model.Value>(description: "Route names are unique") { context in
guard case let .mapping(root) = context.subject,
case let .sequence(routes)? = root["routes"]
else {
@ -133,7 +133,7 @@ func uniqueRouteNamesRule() -> PureYAML.Validation.Check<PureYAML.Model.Value, P
}
var seen = Set<String>()
var issues: [PureYAML.Validation.Issue] = []
var issues: [PureYAML.Validation.ValidationError] = []
for index in routes.indices {
guard case let .mapping(route) = routes[index],
case let .string(name)? = route["name"]

View File

@ -15,7 +15,7 @@ struct ValidationCorpusTests {
#expect(try PureYAML.validate(value, using: testCase.validator).isEmpty)
for forbiddenIssue in testCase.forbiddenIssues {
#expect(!result.issues.contains(forbiddenIssue))
#expect(!result.values.contains(forbiddenIssue))
}
}
@ -24,15 +24,15 @@ struct ValidationCorpusTests {
let value = try testCase.source.load()
let result = testCase.validator.collect(value)
#expect(result.issues == testCase.expectedIssues)
#expect(result.issues.map(\.description) == testCase.expectedIssues.map(\.description))
#expect(result.values == testCase.expectedIssues)
#expect(result.values.map(\.description) == testCase.expectedIssues.map(\.description))
for forbiddenIssue in testCase.forbiddenIssues {
#expect(!result.issues.contains(forbiddenIssue))
#expect(!result.values.contains(forbiddenIssue))
}
expectValidationError(value, using: testCase.validator, strict: testCase.strict) { collection in
#expect(collection.issues == testCase.expectedIssues)
#expect(collection.values == testCase.expectedIssues)
#expect(collection.description == testCase.expectedIssues.map(\.description).joined(separator: "\n"))
}
}
@ -66,24 +66,24 @@ struct ValidationCorpusTests {
- mode: legacy
""",
)
let validator = PureYAML.Validation.Validator.blank
let validator = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>.blank
.validating(traceRule(label: "first"))
.validating(forbiddenMappingKeyRule("mode", severity: .warning))
.validating(legacyModeRule(severity: .error))
let result = validator.collect(value)
#expect(result.issues.map(\.description) == [
"warning: first at root at root",
"warning: first at $.root at $.root",
"warning: Forbidden key 'mode' is present at $.root.mode",
"warning: first at $.root.mode at $.root.mode",
"error: Legacy mode is not allowed at $.root.mode",
"warning: first at $.list at $.list",
"warning: first at $.list[0] at $.list[0]",
"warning: Forbidden key 'mode' is present at $.list[0].mode",
"warning: first at $.list[0].mode at $.list[0].mode",
"error: Legacy mode is not allowed at $.list[0].mode",
#expect(result.values.map(\.description) == [
"first at root at root of document",
"first at $.root at path: $.root",
"Forbidden key 'mode' is present at path: $.root.mode",
"first at $.root.mode at path: $.root.mode",
"Legacy mode is not allowed at path: $.root.mode",
"first at $.list at path: $.list",
"first at $.list[0] at path: $.list[0]",
"Forbidden key 'mode' is present at path: $.list[0].mode",
"first at $.list[0].mode at path: $.list[0].mode",
"Legacy mode is not allowed at path: $.list[0].mode",
])
}
}

View File

@ -10,14 +10,14 @@ import Testing
@Suite("Validation finding-coverage law")
struct ValidationCoverageMetaTests {
private struct ModelFixture {
let check: PureYAML.Validation.AnyCheck<PureYAML.Model.Value>
let check: PureYAML.Validation.AnyValidation<PureYAML.Model.Value>
let failing: PureYAML.Model.Value
let nearMiss: PureYAML.Model.Value
}
private static let modelFixtures: [ModelFixture] = [
.init(
check: .init(PureYAML.Validation.BuiltinCheck.mappingKeysAreUnique),
check: .init(PureYAML.Validation.BuiltinValidation.mappingKeysAreUnique),
failing: .mapping(.init([
.init(key: "a", value: .int(1)),
.init(key: "a", value: .int(2)),
@ -31,14 +31,14 @@ struct ValidationCoverageMetaTests {
]
private struct TaggedFixture {
let check: PureYAML.Validation.AnyCheck<PureYAML.Tagged.Node>
let check: PureYAML.Validation.AnyValidation<PureYAML.Tagged.Node>
let failing: PureYAML.Tagged.Node
let nearMiss: PureYAML.Tagged.Node
}
private static let taggedFixtures: [TaggedFixture] = [
.init(
check: .init(PureYAML.Tagged.BuiltinCheck.mappingKeysAreUnique),
check: .init(PureYAML.Tagged.BuiltinValidation.mappingKeysAreUnique),
failing: .mapping(.init(pairs: [
.init(key: "a", value: .scalar(.init(rawValue: "1", value: .int(1)))),
.init(key: "a", value: .scalar(.init(rawValue: "2", value: .int(2)))),
@ -49,7 +49,7 @@ struct ValidationCoverageMetaTests {
])),
),
.init(
check: .init(PureYAML.Tagged.BuiltinCheck.builtInTagsAreSupportedAndKindConsistent),
check: .init(PureYAML.Tagged.BuiltinValidation.builtInTagsAreSupportedAndKindConsistent),
// A built-in tag on the wrong node kind: !!map applied to a scalar.
failing: .scalar(.init(rawValue: "x", value: .string("x"), tag: .mapping)),
// The boundary: a built-in tag that is supported and kind-consistent, !!str on a scalar.
@ -59,7 +59,7 @@ struct ValidationCoverageMetaTests {
@Test("Model fixtures cover exactly the model validator's default checks")
func test_modelFixturesCoverDefaults() {
let ruled = Set(PureYAML.Validation.Validator().validationDescriptions)
let ruled = Set(PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().validationDescriptions)
let covered = Set(Self.modelFixtures.map(\.check.description))
#expect(ruled.subtracting(covered).isEmpty, "model default checks without fixtures: \(ruled.subtracting(covered).sorted())")
#expect(covered.subtracting(ruled).isEmpty, "model fixtures for checks that no longer ship: \(covered.subtracting(ruled).sorted())")
@ -68,8 +68,8 @@ struct ValidationCoverageMetaTests {
@Test("Each model check fails its failing fixture and passes its near miss in isolation")
func test_modelChecksFailAndNearMiss() throws {
for fixture in Self.modelFixtures {
let isolated = PureYAML.Validation.Validator(checks: [fixture.check])
#expect(throws: PureYAML.Validation.Issue.Collection.self, "check '\(fixture.check.description)' did not fail its failing fixture") {
let isolated = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>(nonReferenceDefaultValidations: [fixture.check])
#expect(throws: PureYAML.Validation.ValidationErrorCollection.self, "check '\(fixture.check.description)' did not fail its failing fixture") {
try isolated.validate(fixture.failing)
}
_ = try isolated.validate(fixture.nearMiss)
@ -87,8 +87,8 @@ struct ValidationCoverageMetaTests {
@Test("Each tagged check fails its failing fixture and passes its near miss in isolation")
func test_taggedChecksFailAndNearMiss() throws {
for fixture in Self.taggedFixtures {
let isolated = PureYAML.Tagged.Validator(checks: [fixture.check])
#expect(throws: PureYAML.Validation.Issue.Collection.self, "check '\(fixture.check.description)' did not fail its failing fixture") {
let isolated = PureYAML.Tagged.Validator(nonReferenceDefaultValidations: [fixture.check])
#expect(throws: PureYAML.Validation.ValidationErrorCollection.self, "check '\(fixture.check.description)' did not fail its failing fixture") {
try isolated.validate(fixture.failing)
}
_ = try isolated.validate(fixture.nearMiss)

View File

@ -9,13 +9,13 @@ struct ValidationFailureFixtureTests {
let value = try testCase.loadValue()
let result = testCase.validator.collect(value)
#expect(result.issues == testCase.expectedIssues)
#expect(result.issues.map(\.description) == testCase.expectedDescriptions)
#expect(result.values == testCase.expectedIssues)
#expect(result.values.map(\.description) == testCase.expectedDescriptions)
#expect(result.descriptionLines == testCase.expectedDescriptions)
#expect(!result.isValid == testCase.expectedIssues.contains { $0.severity == .error })
expectValidationError(value, using: testCase.validator) { collection in
#expect(collection.issues == testCase.expectedIssues)
#expect(collection.values == testCase.expectedIssues)
#expect(collection.description == testCase.expectedDescriptions.joined(separator: "\n"))
}
}
@ -55,8 +55,8 @@ let validationFailureFixtures: [ValidationFailureFixture] = [
),
],
expectedDescriptions: [
"error: Duplicate mapping key 'title' at $.title",
"error: Duplicate mapping key '/users' at $.paths[\"/users\"]",
"Duplicate mapping key 'title' at path: $.title",
"Duplicate mapping key '/users' at path: $.paths[\"/users\"]",
],
),
.init(
@ -78,8 +78,8 @@ let validationFailureFixtures: [ValidationFailureFixture] = [
),
],
expectedDescriptions: [
"error: Required string key 'title' is missing at $.title",
"error: Forbidden key 'draft' is present at $.metadata.draft",
"Required string key 'title' is missing at path: $.title",
"Forbidden key 'draft' is present at path: $.metadata.draft",
],
),
.init(
@ -99,8 +99,8 @@ let validationFailureFixtures: [ValidationFailureFixture] = [
),
],
expectedDescriptions: [
"warning: Forbidden key 'deprecated' is present at $.deprecated",
"warning: Forbidden key 'deprecated' is present at $.nested.deprecated",
"Forbidden key 'deprecated' is present at path: $.deprecated",
"Forbidden key 'deprecated' is present at path: $.nested.deprecated",
],
),
]
@ -108,8 +108,8 @@ let validationFailureFixtures: [ValidationFailureFixture] = [
struct ValidationFailureFixture: CustomStringConvertible {
var name: String
var fileName: String
var validator: PureYAML.Validation.Validator
var expectedIssues: [PureYAML.Validation.Issue]
var validator: PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>
var expectedIssues: [PureYAML.Validation.ValidationError]
var expectedDescriptions: [String]
var description: String {
@ -129,6 +129,6 @@ struct ValidationFailureFixture: CustomStringConvertible {
private extension PureYAML.Validation.Result {
var descriptionLines: [String] {
issues.map(\.description)
values.map(\.description)
}
}

View File

@ -12,10 +12,10 @@ struct ValidationModeTests {
mode: legacy
""",
)
let validator = PureYAML.Validation.Validator.blank.validating(legacyModeRule(severity: .error))
let validator = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>.blank.validating(legacyModeRule(severity: .error))
expectValidationError(value, using: validator) { collection in
#expect(collection.issues == [
#expect(collection.values == [
.init(
severity: .error,
reason: "Legacy mode is not allowed",
@ -33,10 +33,10 @@ struct ValidationModeTests {
@Test("Strict validation treats warnings as failures")
func test_strictValidationTreatsWarningsAsFailures() throws {
let value = try PureYAML.parse("mode: legacy")
let validator = PureYAML.Validation.Validator.blank.validating(legacyModeRule(severity: .warning))
let validator = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>.blank.validating(legacyModeRule(severity: .warning))
expectValidationError(value, using: validator) { collection in
#expect(collection.issues == [
#expect(collection.values == [
.init(
severity: .warning,
reason: "Legacy mode is not allowed",
@ -49,12 +49,12 @@ struct ValidationModeTests {
@Test("Non-strict validation returns warnings")
func test_nonStrictValidationReturnsWarnings() throws {
let value = try PureYAML.parse("mode: legacy")
let validator = PureYAML.Validation.Validator.blank.validating(legacyModeRule(severity: .warning))
let validator = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>.blank.validating(legacyModeRule(severity: .warning))
let warnings = try PureYAML.validate(value, using: validator, strict: false)
#expect(warnings == [
PureYAML.Validation.Issue(
PureYAML.Validation.ValidationError(
severity: .warning,
reason: "Legacy mode is not allowed",
path: .init([.key("mode")]),
@ -71,18 +71,18 @@ struct ValidationModeTests {
mode: legacy
""",
)
let validator = PureYAML.Validation.Validator()
let validator = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>()
.validating(legacyModeRule(severity: .warning))
expectValidationError(value, using: validator, strict: false) { collection in
#expect(collection.issues == [
#expect(collection.values == [
.init(
severity: .error,
reason: "Duplicate mapping key 'title'",
path: .init([.key("title")]),
),
])
#expect(!collection.issues.contains(.init(
#expect(!collection.values.contains(.init(
severity: .warning,
reason: "Legacy mode is not allowed",
path: .init([.key("mode")]),
@ -100,40 +100,40 @@ struct ValidationModeTests {
- mode: legacy
""",
)
let validator = PureYAML.Validation.Validator.blank
let validator = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>.blank
.validating(traceRule(label: "first"))
.validating(traceRule(label: "second"))
.validating(legacyModeRule(severity: .warning))
let result = validator.collect(value)
#expect(result.issues.map(\.description) == [
"warning: first at root at root",
"warning: second at root at root",
"warning: first at $.root at $.root",
"warning: second at $.root at $.root",
"warning: first at $.root.mode at $.root.mode",
"warning: second at $.root.mode at $.root.mode",
"warning: Legacy mode is not allowed at $.root.mode",
"warning: first at $.list at $.list",
"warning: second at $.list at $.list",
"warning: first at $.list[0] at $.list[0]",
"warning: second at $.list[0] at $.list[0]",
"warning: first at $.list[0].mode at $.list[0].mode",
"warning: second at $.list[0].mode at $.list[0].mode",
"warning: Legacy mode is not allowed at $.list[0].mode",
#expect(result.values.map(\.description) == [
"first at root at root of document",
"second at root at root of document",
"first at $.root at path: $.root",
"second at $.root at path: $.root",
"first at $.root.mode at path: $.root.mode",
"second at $.root.mode at path: $.root.mode",
"Legacy mode is not allowed at path: $.root.mode",
"first at $.list at path: $.list",
"second at $.list at path: $.list",
"first at $.list[0] at path: $.list[0]",
"second at $.list[0] at path: $.list[0]",
"first at $.list[0].mode at path: $.list[0].mode",
"second at $.list[0].mode at path: $.list[0].mode",
"Legacy mode is not allowed at path: $.list[0].mode",
])
}
@Test("Validation rule predicate prevents rule from running")
func test_validationRulePredicatePreventsRuleFromRunning() throws {
let value = try PureYAML.parse("mode: legacy")
let validator = PureYAML.Validation.Validator.blank.validating(
PureYAML.Validation.Check<PureYAML.Model.Value, PureYAML.Model.Value>(
let validator = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>.blank.validating(
PureYAML.Validation.Validation<PureYAML.Model.Value, PureYAML.Model.Value>(
description: "Never runs",
check: { context in
[
PureYAML.Validation.Issue(
PureYAML.Validation.ValidationError(
severity: .error,
reason: "Should not appear",
path: context.path,
@ -160,12 +160,12 @@ struct ValidationModeTests {
label: legacy
""",
)
let validator = PureYAML.Validation.Validator.blank.validating(
PureYAML.Validation.Check<PureYAML.Model.Value, PureYAML.Model.Value>(
let validator = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>.blank.validating(
PureYAML.Validation.Validation<PureYAML.Model.Value, PureYAML.Model.Value>(
description: "Legacy mode is not allowed",
check: { context in
[
PureYAML.Validation.Issue(
PureYAML.Validation.ValidationError(
severity: .error,
reason: "Legacy mode is not allowed",
path: context.path,
@ -181,14 +181,14 @@ struct ValidationModeTests {
let result = validator.collect(value)
#expect(result.issues == [
#expect(result.values == [
.init(
severity: .error,
reason: "Legacy mode is not allowed",
path: .init([.key("nested"), .key("mode")]),
),
])
#expect(!result.issues.contains(.init(
#expect(!result.values.contains(.init(
severity: .error,
reason: "Legacy mode is not allowed",
path: .init([.key("nested"), .key("label")]),
@ -206,8 +206,8 @@ struct ValidationModeTests {
- label: test-ignored
""",
)
let validator = PureYAML.Validation.Validator.blank.validating(
PureYAML.Validation.Check<PureYAML.Model.Value, PureYAML.Model.Value>(
let validator = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>.blank.validating(
PureYAML.Validation.Validation<PureYAML.Model.Value, PureYAML.Model.Value>(
description: "Route names use the root prefix",
check: { context in
guard case let .mapping(root) = context.document,
@ -216,7 +216,7 @@ struct ValidationModeTests {
name.hasPrefix("\(prefix)-")
else {
return [
PureYAML.Validation.Issue(
PureYAML.Validation.ValidationError(
severity: .error,
reason: "Route name does not use root prefix",
path: context.path,
@ -233,14 +233,14 @@ struct ValidationModeTests {
let result = validator.collect(value)
#expect(result.issues == [
#expect(result.values == [
.init(
severity: .error,
reason: "Route name does not use root prefix",
path: .init([.key("routes"), .index(1), .key("name")]),
),
])
#expect(!result.issues.contains(.init(
#expect(!result.values.contains(.init(
severity: .error,
reason: "Route name does not use root prefix",
path: .init([.key("routes"), .index(2), .key("label")]),
@ -255,8 +255,8 @@ struct ValidationModeTests {
state: draft
""",
)
let validator = PureYAML.Validation.Validator.blank.validating(
PureYAML.Validation.Check<PureYAML.Model.Value, PureYAML.Model.Value>(
let validator = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>.blank.validating(
PureYAML.Validation.Validation<PureYAML.Model.Value, PureYAML.Model.Value>(
description: "State is published",
check: { context in context.subject == .string("published") },
when: { context in context.path.components.last == .key("state") },
@ -264,7 +264,7 @@ struct ValidationModeTests {
)
expectValidationError(value, using: validator) { collection in
#expect(collection.issues == [
#expect(collection.values == [
.init(
severity: .error,
reason: "Failed to satisfy: State is published",

View File

@ -5,8 +5,8 @@ import Testing
struct ValidationModelTests {
@Test("Validation result separates errors and warnings")
func test_validationResultSeparatesErrorsAndWarnings() {
let error = PureYAML.Validation.Issue(severity: .error, reason: "error")
let warning = PureYAML.Validation.Issue(severity: .warning, reason: "warning")
let error = PureYAML.Validation.ValidationError(severity: .error, reason: "error")
let warning = PureYAML.Validation.ValidationError(severity: .warning, reason: "warning")
let result = PureYAML.Validation.Result([error, warning])
#expect(result.isValid == false)
@ -67,27 +67,27 @@ struct ValidationModelTests {
@Test("Validation issue descriptions include severity reason and path")
func test_validationIssueDescriptionsIncludeSeverityReasonAndPath() {
let issue = PureYAML.Validation.Issue(
let issue = PureYAML.Validation.ValidationError(
severity: .error,
reason: "Duplicate mapping key 'title'",
path: .init([.key("title")]),
)
#expect(issue.description == "error: Duplicate mapping key 'title' at $.title")
#expect(issue.description == "Duplicate mapping key 'title' at path: $.title")
}
@Test("Validation issue collection describes all failures")
func test_validationIssueCollectionDescription() {
let collection = PureYAML.Validation.Issue.Collection([
let collection = PureYAML.Validation.ValidationErrorCollection([
.init(severity: .error, reason: "first", path: .root),
.init(severity: .warning, reason: "second", path: .init([.key("name")])),
])
#expect(collection.description == """
error: first at root
warning: second at $.name
first at root of document
second at path: $.name
""")
#expect(collection.description.contains("error: first"))
#expect(collection.description.contains("first"))
#expect(!collection.description.contains("info:"))
}
}

View File

@ -56,8 +56,8 @@ struct ValidationReportTests {
@Test("Warning reports stay visible without making the report invalid")
func test_warningReportsStayVisibleWithoutMakingReportInvalid() {
let validator = PureYAML.Validation.Validator.blank.validating(
PureYAML.Validation.Check<PureYAML.Model.Value, PureYAML.Model.Value>(
let validator = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>.blank.validating(
PureYAML.Validation.Validation<PureYAML.Model.Value, PureYAML.Model.Value>(
description: "Mode is not legacy",
severity: .warning,
check: { context in context.subject != .string("legacy") },
@ -84,8 +84,8 @@ struct ValidationReportTests {
@Test("Batch reports collect every source without stopping at the first failure")
func test_batchReportsCollectEverySourceWithoutStoppingAtFirstFailure() {
let validator = PureYAML.Validation.Validator().validating(
PureYAML.Validation.Check<PureYAML.Model.Value, PureYAML.Model.Value>(
let validator = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().validating(
PureYAML.Validation.Validation<PureYAML.Model.Value, PureYAML.Model.Value>(
description: "Mode is not legacy",
severity: .warning,
check: { context in context.subject != .string("legacy") },
@ -130,8 +130,8 @@ struct ValidationReportTests {
@Test("Batch reports can treat warnings as failures")
func test_batchReportsCanTreatWarningsAsFailures() {
let validator = PureYAML.Validation.Validator.blank.validating(
PureYAML.Validation.Check<PureYAML.Model.Value, PureYAML.Model.Value>(
let validator = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>.blank.validating(
PureYAML.Validation.Validation<PureYAML.Model.Value, PureYAML.Model.Value>(
description: "Mode is not legacy",
severity: .warning,
check: { context in context.subject != .string("legacy") },

View File

@ -13,7 +13,7 @@ struct ValidationValidDocumentCase: CustomStringConvertible {
struct ValidationDuplicateKeyCase: CustomStringConvertible {
var name: String
var yaml: String
var expectedIssues: [PureYAML.Validation.Issue]
var expectedIssues: [PureYAML.Validation.ValidationError]
var description: String {
name
@ -31,13 +31,13 @@ struct ValidationPathCase: CustomStringConvertible {
}
}
func legacyModeRule(severity: PureYAML.Validation.Severity) -> PureYAML.Validation.Check<PureYAML.Model.Value, PureYAML.Model.Value> {
PureYAML.Validation.Check<PureYAML.Model.Value, PureYAML.Model.Value>(description: "Mode must not be legacy") { context in
func legacyModeRule(severity: PureYAML.Validation.Severity) -> PureYAML.Validation.Validation<PureYAML.Model.Value, PureYAML.Model.Value> {
PureYAML.Validation.Validation<PureYAML.Model.Value, PureYAML.Model.Value>(description: "Mode must not be legacy") { context in
guard case .string("legacy") = context.subject else {
return []
}
return [
PureYAML.Validation.Issue(
PureYAML.Validation.ValidationError(
severity: severity,
reason: "Legacy mode is not allowed",
path: context.path,
@ -46,10 +46,10 @@ func legacyModeRule(severity: PureYAML.Validation.Severity) -> PureYAML.Validati
}
}
func traceRule(label: String) -> PureYAML.Validation.Check<PureYAML.Model.Value, PureYAML.Model.Value> {
PureYAML.Validation.Check<PureYAML.Model.Value, PureYAML.Model.Value>(description: "Trace \(label)") { context in
func traceRule(label: String) -> PureYAML.Validation.Validation<PureYAML.Model.Value, PureYAML.Model.Value> {
PureYAML.Validation.Validation<PureYAML.Model.Value, PureYAML.Model.Value>(description: "Trace \(label)") { context in
[
PureYAML.Validation.Issue(
PureYAML.Validation.ValidationError(
severity: .warning,
reason: "\(label) at \(context.path.isRoot ? "root" : context.path.description)",
path: context.path,
@ -60,14 +60,14 @@ func traceRule(label: String) -> PureYAML.Validation.Check<PureYAML.Model.Value,
func expectValidationError(
_ value: PureYAML.Model.Value,
using validator: PureYAML.Validation.Validator = .init(),
using validator: PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value> = .init(),
strict: Bool = true,
check: (PureYAML.Validation.Issue.Collection) -> Void,
check: (PureYAML.Validation.ValidationErrorCollection) -> Void,
) {
do {
try PureYAML.validate(value, using: validator, strict: strict)
recordIssue("expected validation failure")
} catch let collection as PureYAML.Validation.Issue.Collection {
} catch let collection as PureYAML.Validation.ValidationErrorCollection {
check(collection)
} catch {
recordIssue("unexpected error \(error)")

View File

@ -46,7 +46,7 @@ struct ValidationTests {
])
func test_validDocumentsProduceNoDiagnostics(testCase: ValidationValidDocumentCase) throws {
let value = try PureYAML.parse(testCase.yaml)
let validator = PureYAML.Validation.Validator()
let validator = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>()
let result = validator.collect(value)
#expect(result == PureYAML.Validation.Result())
@ -167,13 +167,13 @@ struct ValidationTests {
])
func test_duplicateMappingKeyDiagnostics(testCase: ValidationDuplicateKeyCase) throws {
let value = try PureYAML.parse(testCase.yaml)
let result = PureYAML.Validation.Validator().collect(value)
let result = PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().collect(value)
#expect(result.issues == testCase.expectedIssues)
#expect(result.values == testCase.expectedIssues)
#expect(result.errors == testCase.expectedIssues)
#expect(result.warnings.isEmpty)
expectValidationError(value) { collection in
#expect(collection.issues == testCase.expectedIssues)
#expect(collection.values == testCase.expectedIssues)
#expect(collection.description == testCase.expectedIssues.map(\.description).joined(separator: "\n"))
}
}
@ -190,10 +190,10 @@ struct ValidationTests {
@Test("Default validator exposes exact built-in check descriptions")
func test_defaultValidatorExposesExactBuiltInCheckDescriptions() {
#expect(PureYAML.Validation.Validator.defaultChecks.map(\.description) == [
#expect(PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>.defaultValidations.map(\.description) == [
"Mapping keys are unique",
])
#expect(PureYAML.Validation.Validator().validationDescriptions == [
#expect(PureYAML.Validation.Validator<PureYAML.Model.Value, PureYAML.Model.Value>().validationDescriptions == [
"Mapping keys are unique",
])
}