diff --git a/Sources/API.swift b/Sources/API.swift index 55a8cf2..faa88d3 100644 --- a/Sources/API.swift +++ b/Sources/API.swift @@ -50,9 +50,9 @@ public extension PureYAML { @discardableResult static func validate( _ value: Model.Value, - using validator: Validation.Validator = .init(), + using validator: Validation.Validator = .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 = .init(), strict: Bool = true, ) throws -> [Stream.Issue] { try validator.validate(documents, strict: strict) diff --git a/Sources/Decoding/Decoding.Decoder.swift b/Sources/Decoding/Decoding.Decoder.swift index c96efea..b2ad9f2 100644 --- a/Sources/Decoding/Decoding.Decoder.swift +++ b/Sources/Decoding/Decoding.Decoder.swift @@ -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().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) } } diff --git a/Sources/Model/Value.Traversable.swift b/Sources/Model/Value.Traversable.swift new file mode 100644 index 0000000..fd0ee0d --- /dev/null +++ b/Sources/Model/Value.Traversable.swift @@ -0,0 +1,26 @@ +extension PureYAML.Model.Value: PureYAML.Validation.Traversable { + /// For a mapping: the mapping itself (a `Validation` 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: + [] + } + } +} diff --git a/Sources/PureYAMLJSON/JSON.Check.Builtins.swift b/Sources/PureYAMLJSON/JSON.BuiltinValidation.swift similarity index 70% rename from Sources/PureYAMLJSON/JSON.Check.Builtins.swift rename to Sources/PureYAMLJSON/JSON.BuiltinValidation.swift index 9745817..b0b2897 100644 --- a/Sources/PureYAMLJSON/JSON.Check.Builtins.swift +++ b/Sources/PureYAMLJSON/JSON.BuiltinValidation.swift @@ -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.Validation.Check(description: "Mapping keys are strings") { context in + static var mappingKeysAreStrings: PureYAML.Validation.Validation { + 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.Validation.Check(description: "Numbers are finite") { context in + static var numbersAreFinite: PureYAML.Validation.Validation { + 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, diff --git a/Sources/PureYAMLJSON/JSON.Convert.Failure.swift b/Sources/PureYAMLJSON/JSON.Convert.Failure.swift index 35680ee..52d4ea3 100644 --- a/Sources/PureYAMLJSON/JSON.Convert.Failure.swift +++ b/Sources/PureYAMLJSON/JSON.Convert.Failure.swift @@ -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) } diff --git a/Sources/PureYAMLJSON/JSON.Convert.swift b/Sources/PureYAMLJSON/JSON.Convert.swift index c5db929..e30a85e 100644 --- a/Sources/PureYAMLJSON/JSON.Convert.swift +++ b/Sources/PureYAMLJSON/JSON.Convert.swift @@ -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 { switch target { case .json: PureYAML.JSON.Validator.representability diff --git a/Sources/PureYAMLJSON/JSON.Validator.swift b/Sources/PureYAMLJSON/JSON.Validator.swift index a38d302..35895ab 100644 --- a/Sources/PureYAMLJSON/JSON.Validator.swift +++ b/Sources/PureYAMLJSON/JSON.Validator.swift @@ -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.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. diff --git a/Sources/Stream/Stream.Issue.swift b/Sources/Stream/Stream.Issue.swift index a5fd923..055b8f4 100644 --- a/Sources/Stream/Stream.Issue.swift +++ b/Sources/Stream/Stream.Issue.swift @@ -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 diff --git a/Sources/Tagged/Tagged.Check.Builtins.swift b/Sources/Tagged/Tagged.BuiltinValidation.swift similarity index 72% rename from Sources/Tagged/Tagged.Check.Builtins.swift rename to Sources/Tagged/Tagged.BuiltinValidation.swift index 59cc676..9a5a43a 100644 --- a/Sources/Tagged/Tagged.Check.Builtins.swift +++ b/Sources/Tagged/Tagged.BuiltinValidation.swift @@ -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` 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 { - PureYAML.Validation.Check(description: "Mapping keys are unique") { context in + public static var mappingKeysAreUnique: PureYAML.Validation.Validation { + PureYAML.Validation.Validation(description: "Mapping keys are unique") { context in var seen = Set() - 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 { - 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 { + 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, diff --git a/Sources/Tagged/Tagged.Node.Traversable.swift b/Sources/Tagged/Tagged.Node.Traversable.swift new file mode 100644 index 0000000..fa39b05 --- /dev/null +++ b/Sources/Tagged/Tagged.Node.Traversable.swift @@ -0,0 +1,26 @@ +extension PureYAML.Tagged.Node: PureYAML.Validation.Traversable { + /// For a mapping: the mapping itself (a `Validation` 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: + [] + } + } +} diff --git a/Sources/Tagged/Tagged.Validator.swift b/Sources/Tagged/Tagged.Validator.swift index 3e72425..09a8392 100644 --- a/Sources/Tagged/Tagged.Validator.swift +++ b/Sources/Tagged/Tagged.Validator.swift @@ -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] - - public init(checks: [PureYAML.Validation.AnyCheck] = Self.defaultChecks) { - self.checks = checks - } - - public static var blank: Self { - Self(checks: []) - } - - public static var defaultChecks: [PureYAML.Validation.AnyCheck] { - [ - 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) -> 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 } -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` and a `Check` - // 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.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) } } diff --git a/Sources/Validation/AnyCheck.swift b/Sources/Validation/AnyValidation.swift similarity index 55% rename from Sources/Validation/AnyCheck.swift rename to Sources/Validation/AnyValidation.swift index 92d5d87..8de2835 100644 --- a/Sources/Validation/AnyCheck.swift +++ b/Sources/Validation/AnyValidation.swift @@ -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` fires only on a `Subject` value, never on an optional of it (the + /// `Validation` fires only on a `Subject` value, never on an optional of it (the /// `type(of:)` guard) or a value of any other type. - struct AnyCheck: Sendable { + struct AnyValidation: 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(_ check: Check) { - description = check.description + public init(_ validation: Validation) { + 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) } } diff --git a/Sources/Validation/Check.Builtins.swift b/Sources/Validation/BuiltinValidation.swift similarity index 74% rename from Sources/Validation/Check.Builtins.swift rename to Sources/Validation/BuiltinValidation.swift index 706d6b5..29f56df 100644 --- a/Sources/Validation/Check.Builtins.swift +++ b/Sources/Validation/BuiltinValidation.swift @@ -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 { - Check(description: "Mapping keys are unique") { context in + public static var mappingKeysAreUnique: Validation { + Validation(description: "Mapping keys are unique") { context in var seen = Set() - 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 { diff --git a/Sources/Validation/Check.swift b/Sources/Validation/Check.swift deleted file mode 100644 index 31ab740..0000000 --- a/Sources/Validation/Check.swift +++ /dev/null @@ -1,49 +0,0 @@ -public extension PureYAML.Validation { - /// A type-directed validation value (the `Validation` 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: Sendable { - public let description: String - public let predicate: @Sendable (SubjectContext) -> Bool - public let check: @Sendable (SubjectContext) -> [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) -> [Issue], - when predicate: @escaping @Sendable (SubjectContext) -> 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: "`. The dominant form. - public init( - description: String, - severity: Severity = .error, - check: @escaping @Sendable (SubjectContext) -> Bool, - when predicate: @escaping @Sendable (SubjectContext) -> 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) -> [Issue] { - guard predicate(context) else { - return [] - } - return check(context) - } - } -} diff --git a/Sources/Validation/DiagnosticValidation.swift b/Sources/Validation/DiagnosticValidation.swift index f0569ee..8b4d33b 100644 --- a/Sources/Validation/DiagnosticValidation.swift +++ b/Sources/Validation/DiagnosticValidation.swift @@ -3,7 +3,7 @@ public extension PureYAML { static func diagnosticValidationReport( _ yaml: String, file: String? = nil, - using validator: Validation.Validator = .init(), + using validator: Validation.Validator = .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 = .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 = .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 = .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, preflightScanner: Validation.PreflightScanner, ) -> DiagnosticValidationResult { var diagnostics = preflightScanner.diagnostics(in: yaml, file: file) diff --git a/Sources/Validation/Issue.Collection.swift b/Sources/Validation/Issue.Collection.swift deleted file mode 100644 index 914d6fe..0000000 --- a/Sources/Validation/Issue.Collection.swift +++ /dev/null @@ -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") - } - } -} diff --git a/Sources/Validation/Issue.swift b/Sources/Validation/Issue.swift deleted file mode 100644 index c5c865c..0000000 --- a/Sources/Validation/Issue.swift +++ /dev/null @@ -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)" - } - } -} diff --git a/Sources/Validation/Result.swift b/Sources/Validation/Result.swift index 0ea4933..2f60285 100644 --- a/Sources/Validation/Result.swift +++ b/Sources/Validation/Result.swift @@ -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 { diff --git a/Sources/Validation/Traversable.swift b/Sources/Validation/Traversable.swift new file mode 100644 index 0000000..9e3a42b --- /dev/null +++ b/Sources/Validation/Traversable.swift @@ -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 } + } +} diff --git a/Sources/Validation/Validatable.swift b/Sources/Validation/Validatable.swift index 799659e..24eca56 100644 --- a/Sources/Validation/Validatable.swift +++ b/Sources/Validation/Validatable.swift @@ -1,5 +1,5 @@ public extension PureYAML.Validation { - /// Marker for a value a type-directed validation can be applied to. A `Check` fires on + /// Marker for a value a type-directed validation can be applied to. A `Validation` 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 {} diff --git a/Sources/Validation/Validation.Report.swift b/Sources/Validation/Validation.Report.swift index 8a4ff6e..37aa8b8 100644 --- a/Sources/Validation/Validation.Report.swift +++ b/Sources/Validation/Validation.Report.swift @@ -198,7 +198,7 @@ public extension PureYAML { static func validationReport( _ yaml: String, file: String? = nil, - using validator: Validation.Validator = .init(), + using validator: Validation.Validator = .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 = .init(), failOnWarnings: Bool = false, ) -> Validation.BatchReport { let reports = sources.map { source in diff --git a/Sources/Validation/Validation.swift b/Sources/Validation/Validation.swift new file mode 100644 index 0000000..5ba411a --- /dev/null +++ b/Sources/Validation/Validation.swift @@ -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: Sendable { + public let description: String + public let predicate: @Sendable (ValidationContext) -> Bool + public let check: @Sendable (ValidationContext) -> [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) -> [ValidationError], + when predicate: @escaping @Sendable (ValidationContext) -> 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: "`. The dominant form. + public init( + description: String, + severity: Severity = .error, + check: @escaping @Sendable (ValidationContext) -> Bool, + when predicate: @escaping @Sendable (ValidationContext) -> 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) -> [ValidationError] { + guard predicate(context) else { + return [] + } + return check(context) + } + } +} diff --git a/Sources/Validation/Check.Combinators.swift b/Sources/Validation/ValidationCombinators.swift similarity index 52% rename from Sources/Validation/Check.Combinators.swift rename to Sources/Validation/ValidationCombinators.swift index 82c0f76..89662a7 100644 --- a/Sources/Validation/Check.Combinators.swift +++ b/Sources/Validation/ValidationCombinators.swift @@ -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: @unchecked Sendable { // MARK: comparison operators lifting a key path and a literal into a predicate public func == ( - lhs: KeyPath, U>, + lhs: KeyPath, U>, rhs: U, -) -> @Sendable (PureYAML.Validation.SubjectContext) -> Bool { +) -> @Sendable (PureYAML.Validation.ValidationContext) -> Bool { let boxed = SendableKeyPath(keyPath: lhs) return { $0[keyPath: boxed.keyPath] == rhs } } public func != ( - lhs: KeyPath, U>, + lhs: KeyPath, U>, rhs: U, -) -> @Sendable (PureYAML.Validation.SubjectContext) -> Bool { +) -> @Sendable (PureYAML.Validation.ValidationContext) -> Bool { let boxed = SendableKeyPath(keyPath: lhs) return { $0[keyPath: boxed.keyPath] != rhs } } public func > ( - lhs: KeyPath, U>, + lhs: KeyPath, U>, rhs: U, -) -> @Sendable (PureYAML.Validation.SubjectContext) -> Bool { +) -> @Sendable (PureYAML.Validation.ValidationContext) -> Bool { let boxed = SendableKeyPath(keyPath: lhs) return { $0[keyPath: boxed.keyPath] > rhs } } public func >= ( - lhs: KeyPath, U>, + lhs: KeyPath, U>, rhs: U, -) -> @Sendable (PureYAML.Validation.SubjectContext) -> Bool { +) -> @Sendable (PureYAML.Validation.ValidationContext) -> Bool { let boxed = SendableKeyPath(keyPath: lhs) return { $0[keyPath: boxed.keyPath] >= rhs } } public func < ( - lhs: KeyPath, U>, + lhs: KeyPath, U>, rhs: U, -) -> @Sendable (PureYAML.Validation.SubjectContext) -> Bool { +) -> @Sendable (PureYAML.Validation.ValidationContext) -> Bool { let boxed = SendableKeyPath(keyPath: lhs) return { $0[keyPath: boxed.keyPath] < rhs } } public func <= ( - lhs: KeyPath, U>, + lhs: KeyPath, U>, rhs: U, -) -> @Sendable (PureYAML.Validation.SubjectContext) -> Bool { +) -> @Sendable (PureYAML.Validation.ValidationContext) -> 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( - _ keyPath: KeyPath, U>, + _ keyPath: KeyPath, U>, _ predicate: @escaping @Sendable (U) -> Bool, - ) -> @Sendable (SubjectContext) -> Bool { + ) -> @Sendable (ValidationContext) -> Bool { let boxed = SendableKeyPath(keyPath: keyPath) return { predicate($0[keyPath: boxed.keyPath]) } } @@ -80,15 +81,15 @@ public extension PureYAML.Validation { static func take( _ keyPath: KeyPath, _ predicate: @escaping @Sendable (U) -> Bool, - ) -> @Sendable (SubjectContext) -> Bool { + ) -> @Sendable (ValidationContext) -> Bool { let boxed = SendableKeyPath(keyPath: keyPath) return { predicate($0.subject[keyPath: boxed.keyPath]) } } /// Applies every check to the same context, concatenating their errors. - static func all(_ checks: Check...) -> Check { - let list = checks - return Check(description: "all of: \(list.map(\.description).joined(separator: ", "))") { context in + static func all(_ validations: Validation...) -> Validation { + let list = validations + return Validation(description: "all of: \(list.map(\.description).joined(separator: ", "))") { context in list.flatMap { $0.apply(context) } } } @@ -97,12 +98,12 @@ public extension PureYAML.Validation { /// and document. static func lift( _ keyPath: KeyPath, - into checks: Check..., - ) -> Check { - let list = checks + into validations: Validation..., + ) -> Validation { + 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( + _ keyPath: KeyPath, + into validations: Validation..., + description: String? = nil, + ) -> Validation { + let list = validations + let boxed = SendableKeyPath(keyPath: keyPath) + return Validation(description: description ?? "unwraps: \(list.map(\.description).joined(separator: ", "))") { context in + guard let child = context.subject[keyPath: boxed.keyPath] else { + let reason = description.map { "Tried to unwrap but found nil: \($0)" } ?? "Tried to unwrap an optional and found nil" + return [ValidationError(severity: .error, reason: reason, path: context.path)] + } + let childContext = ValidationContext(document: context.document, subject: child, path: context.path) + return list.flatMap { $0.apply(childContext) } + } + } } // MARK: composing two checks (&& concatenates errors, || short-circuits) public func && ( - lhs: PureYAML.Validation.Check, - rhs: PureYAML.Validation.Check, -) -> PureYAML.Validation.Check { - PureYAML.Validation.Check(description: "\(lhs.description) and \(rhs.description)") { context in + lhs: PureYAML.Validation.Validation, + rhs: PureYAML.Validation.Validation, +) -> PureYAML.Validation.Validation { + PureYAML.Validation.Validation(description: "\(lhs.description) and \(rhs.description)") { context in lhs.apply(context) + rhs.apply(context) } } public func || ( - lhs: PureYAML.Validation.Check, - rhs: PureYAML.Validation.Check, -) -> PureYAML.Validation.Check { - PureYAML.Validation.Check(description: "\(lhs.description) or \(rhs.description)") { context in + lhs: PureYAML.Validation.Validation, + rhs: PureYAML.Validation.Validation, +) -> PureYAML.Validation.Validation { + PureYAML.Validation.Validation(description: "\(lhs.description) or \(rhs.description)") { context in let left = lhs.apply(context) guard !left.isEmpty else { return [] diff --git a/Sources/Validation/SubjectContext.swift b/Sources/Validation/ValidationContext.swift similarity index 90% rename from Sources/Validation/SubjectContext.swift rename to Sources/Validation/ValidationContext.swift index fc25021..482a2b7 100644 --- a/Sources/Validation/SubjectContext.swift +++ b/Sources/Validation/ValidationContext.swift @@ -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: Sendable { + struct ValidationContext: Sendable { public var document: Document public var subject: Subject public var path: Path diff --git a/Sources/Validation/ValidationError.swift b/Sources/Validation/ValidationError.swift new file mode 100644 index 0000000..127b459 --- /dev/null +++ b/Sources/Validation/ValidationError.swift @@ -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: + /// `" at root of document"` at the root, `" at 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)" + } + } +} diff --git a/Sources/Validation/ValidationErrorCollection.swift b/Sources/Validation/ValidationErrorCollection.swift new file mode 100644 index 0000000..a7cbfb9 --- /dev/null +++ b/Sources/Validation/ValidationErrorCollection.swift @@ -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") + } + } +} diff --git a/Sources/Validation/Validator.swift b/Sources/Validation/Validator.swift index b9526e3..6c7234e 100644 --- a/Sources/Validation/Validator.swift +++ b/Sources/Validation/Validator.swift @@ -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] + /// 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: @unchecked Sendable { + fileprivate let nonReferenceDefaultValidations: [AnyValidation] + fileprivate let referenceDefaultValidations: [AnyValidation] + fileprivate let customValidations: [AnyValidation] - public init(checks: [AnyCheck] = Self.defaultChecks) { - self.checks = checks + public init( + nonReferenceDefaultValidations: [AnyValidation] = [], + referenceDefaultValidations: [AnyValidation] = [], + customValidations: [AnyValidation] = [], + ) { + 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] { - [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) -> 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] { + nonReferenceDefaultValidations + referenceDefaultValidations + customValidations + } + + /// Returns a validator with `validation` appended to the custom tier. + @discardableResult + public func validating(_ validation: Validation) -> 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]) -> [AnyValidation] { + 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` ignores the value and key offers, and a - // `Check` (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.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) } } diff --git a/Tests/ArbitraryAnyDecisionTests.swift b/Tests/ArbitraryAnyDecisionTests.swift index 172f040..fcf5fb5 100644 --- a/Tests/ArbitraryAnyDecisionTests.swift +++ b/Tests/ArbitraryAnyDecisionTests.swift @@ -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().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'", diff --git a/Tests/CodableCompatibilityTests.swift b/Tests/CodableCompatibilityTests.swift index 36f0600..1085e52 100644 --- a/Tests/CodableCompatibilityTests.swift +++ b/Tests/CodableCompatibilityTests.swift @@ -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)") diff --git a/Tests/CollectionCompatibilityFixtures.swift b/Tests/CollectionCompatibilityFixtures.swift index 3ef63df..e853fb0 100644 --- a/Tests/CollectionCompatibilityFixtures.swift +++ b/Tests/CollectionCompatibilityFixtures.swift @@ -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 diff --git a/Tests/ComplexMappingKeyValidationTests.swift b/Tests/ComplexMappingKeyValidationTests.swift index a4a11ae..7ec9653 100644 --- a/Tests/ComplexMappingKeyValidationTests.swift +++ b/Tests/ComplexMappingKeyValidationTests.swift @@ -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().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().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.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 """) } } diff --git a/Tests/DocumentationExampleTests.swift b/Tests/DocumentationExampleTests.swift index 7b7c929..7e68d79 100644 --- a/Tests/DocumentationExampleTests.swift +++ b/Tests/DocumentationExampleTests.swift @@ -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().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)") diff --git a/Tests/DownstreamDocumentTests.swift b/Tests/DownstreamDocumentTests.swift index 937b1ad..8600b0e 100644 --- a/Tests/DownstreamDocumentTests.swift +++ b/Tests/DownstreamDocumentTests.swift @@ -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().collect(.mapping(root)) == .init()) } func requireRootMapping(_ yaml: String) throws -> PureYAML.Model.Mapping { diff --git a/Tests/EventStreamingParserTests.swift b/Tests/EventStreamingParserTests.swift index e357e26..e07bd5b 100644 --- a/Tests/EventStreamingParserTests.swift +++ b/Tests/EventStreamingParserTests.swift @@ -36,11 +36,11 @@ struct EventStreamingParserTests { flow: {id: one, id: two} """) - let issues = PureYAML.Validation.Validator().collect(value).issues + let issues = PureYAML.Validation.Validator().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", ]) } } diff --git a/Tests/KeyedCodingTests.swift b/Tests/KeyedCodingTests.swift index f6c4358..38be280 100644 --- a/Tests/KeyedCodingTests.swift +++ b/Tests/KeyedCodingTests.swift @@ -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) diff --git a/Tests/MergeKeyCompatibilityTests.swift b/Tests/MergeKeyCompatibilityTests.swift index a9a5095..322094a 100644 --- a/Tests/MergeKeyCompatibilityTests.swift +++ b/Tests/MergeKeyCompatibilityTests.swift @@ -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().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().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") diff --git a/Tests/ParsingCompatibilityTests.swift b/Tests/ParsingCompatibilityTests.swift index 5903735..fc4cda5 100644 --- a/Tests/ParsingCompatibilityTests.swift +++ b/Tests/ParsingCompatibilityTests.swift @@ -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().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().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().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().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") } } diff --git a/Tests/PureYAMLJSONTests/JSONCoverageMetaTests.swift b/Tests/PureYAMLJSONTests/JSONCoverageMetaTests.swift new file mode 100644 index 0000000..9b882f7 --- /dev/null +++ b/Tests/PureYAMLJSONTests/JSONCoverageMetaTests.swift @@ -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 + 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(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) + } + } +} diff --git a/Tests/PureYAMLJSONTests/JSONRuleTests.swift b/Tests/PureYAMLJSONTests/JSONRuleTests.swift index 90ed520..faa0954 100644 --- a/Tests/PureYAMLJSONTests/JSONRuleTests.swift +++ b/Tests/PureYAMLJSONTests/JSONRuleTests.swift @@ -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) } diff --git a/Tests/RealYAMLCorpusTests.swift b/Tests/RealYAMLCorpusTests.swift index 261ff35..0b81bd8 100644 --- a/Tests/RealYAMLCorpusTests.swift +++ b/Tests/RealYAMLCorpusTests.swift @@ -41,7 +41,7 @@ struct RealYAMLCorpusTests { continue } - let issues = PureYAML.Validation.Validator().collect(documents) + let issues = PureYAML.Validation.Validator().collect(documents) let issueDescriptions = issues.issues.map(\.description).joined(separator: "\n") #expect(issues == PureYAML.Stream.Result(), "\(seed.id): \(issueDescriptions)") guard seed.expectedRoundTrip == "success" else { diff --git a/Tests/RealYAMLFixtureTests.swift b/Tests/RealYAMLFixtureTests.swift index 2f87137..bffbf9a 100644 --- a/Tests/RealYAMLFixtureTests.swift +++ b/Tests/RealYAMLFixtureTests.swift @@ -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().collect(value) == PureYAML.Validation.Result()) } } diff --git a/Tests/StreamParsingTests.swift b/Tests/StreamParsingTests.swift index 077e968..621f1fc 100644 --- a/Tests/StreamParsingTests.swift +++ b/Tests/StreamParsingTests.swift @@ -232,7 +232,7 @@ struct StreamValidationTests { ), ] - let result = PureYAML.Validation.Validator().collect(documents) + let result = PureYAML.Validation.Validator().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().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.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.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 = .init(), check: (PureYAML.Stream.Issue.Collection) -> Void, ) { do { diff --git a/Tests/TaggedCompatibilityTests.swift b/Tests/TaggedCompatibilityTests.swift index d690da9..74fc130 100644 --- a/Tests/TaggedCompatibilityTests.swift +++ b/Tests/TaggedCompatibilityTests.swift @@ -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( + let localTagsAreWarnings = PureYAML.Validation.Validation( 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)") diff --git a/Tests/TaggedConstructorTests.swift b/Tests/TaggedConstructorTests.swift index d31531b..2c5517d 100644 --- a/Tests/TaggedConstructorTests.swift +++ b/Tests/TaggedConstructorTests.swift @@ -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().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") diff --git a/Tests/TaggedKeyUniquenessTests.swift b/Tests/TaggedKeyUniquenessTests.swift index 467937c..77928e4 100644 --- a/Tests/TaggedKeyUniquenessTests.swift +++ b/Tests/TaggedKeyUniquenessTests.swift @@ -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'", diff --git a/Tests/TaggedNodeRoundTripTests.swift b/Tests/TaggedNodeRoundTripTests.swift index 93f3c23..b6e212d 100644 --- a/Tests/TaggedNodeRoundTripTests.swift +++ b/Tests/TaggedNodeRoundTripTests.swift @@ -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) } } diff --git a/Tests/UnkeyedCodingTests.swift b/Tests/UnkeyedCodingTests.swift index 4a83d30..5c0d7cb 100644 --- a/Tests/UnkeyedCodingTests.swift +++ b/Tests/UnkeyedCodingTests.swift @@ -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)") diff --git a/Tests/UnkeyedFailureFixtures.swift b/Tests/UnkeyedFailureFixtures.swift index fb47be7..29111b4 100644 --- a/Tests/UnkeyedFailureFixtures.swift +++ b/Tests/UnkeyedFailureFixtures.swift @@ -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", ), ), ] diff --git a/Tests/UnsupportedYAMLGapsFixtures.swift b/Tests/UnsupportedYAMLGapsFixtures.swift index cd54f5e..3ffcd3c 100644 --- a/Tests/UnsupportedYAMLGapsFixtures.swift +++ b/Tests/UnsupportedYAMLGapsFixtures.swift @@ -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'", diff --git a/Tests/ValidationBuiltinRuleTests.swift b/Tests/ValidationBuiltinRuleTests.swift index e4b7829..3bbbad3 100644 --- a/Tests/ValidationBuiltinRuleTests.swift +++ b/Tests/ValidationBuiltinRuleTests.swift @@ -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().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() // 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'", diff --git a/Tests/ValidationCheckMachineryTests.swift b/Tests/ValidationCheckMachineryTests.swift index 2c26b35..2f59347 100644 --- a/Tests/ValidationCheckMachineryTests.swift +++ b/Tests/ValidationCheckMachineryTests.swift @@ -1,11 +1,17 @@ @testable import PureYAML import Testing +/// `Optional` 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 - private typealias AnyCheck = PureYAML.Validation.AnyCheck - private typealias Issue = PureYAML.Validation.Issue + private typealias Check = PureYAML.Validation.Validation + private typealias AnyValidation = PureYAML.Validation.AnyValidation + 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.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.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.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.blank.validating( + PureYAML.Validation.BuiltinValidation.mappingKeysAreUnique, ) let nested = PureYAML.Model.Value.mapping(.init([ .init(key: "outer", value: .mapping(.init([ diff --git a/Tests/ValidationCombinatorTests.swift b/Tests/ValidationCombinatorTests.swift index 930007d..0354332 100644 --- a/Tests/ValidationCombinatorTests.swift +++ b/Tests/ValidationCombinatorTests.swift @@ -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 + private typealias Context = PureYAML.Validation.ValidationContext - 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 { + private func failing(_ description: String) -> PureYAML.Validation.Validation { .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 { + private func passing(_ description: String) -> PureYAML.Validation.Validation { .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( + let adult = PureYAML.Validation.Validation( 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( + let cityNotEmpty = PureYAML.Validation.Validation( 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( + 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") + } } diff --git a/Tests/ValidationConfigurationTests.swift b/Tests/ValidationConfigurationTests.swift index ac3265e..3d32d08 100644 --- a/Tests/ValidationConfigurationTests.swift +++ b/Tests/ValidationConfigurationTests.swift @@ -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.blank.validationDescriptions == []) + #expect(PureYAML.Validation.Validator().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().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().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().validate(duplicateKeys) } } } diff --git a/Tests/ValidationCorpusSupport.swift b/Tests/ValidationCorpusSupport.swift index 4b503e9..d8d4453 100644 --- a/Tests/ValidationCorpusSupport.swift +++ b/Tests/ValidationCorpusSupport.swift @@ -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 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, 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 + var forbiddenIssues: [PureYAML.Validation.ValidationError] init( name: String, source: ValidationCorpusSource, - validator: PureYAML.Validation.Validator, - forbiddenIssues: [PureYAML.Validation.Issue] = [], + validator: PureYAML.Validation.Validator, + 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.Validation.Check(description: "Root requires string key '\(key)'") { context in +func requiredRootStringKeyRule(_ key: String) -> PureYAML.Validation.Validation { + PureYAML.Validation.Validation(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 PureYAML.Validation.Check PureYAML.Validation.Check { - PureYAML.Validation.Check(description: "Mapping forbids key '\(key)'") { context in +) -> PureYAML.Validation.Validation { + PureYAML.Validation.Validation(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.Validation.Check(description: "Route names are unique") { context in +func uniqueRouteNamesRule() -> PureYAML.Validation.Validation { + PureYAML.Validation.Validation(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() - 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"] diff --git a/Tests/ValidationCorpusTests.swift b/Tests/ValidationCorpusTests.swift index a27ac1f..041e3cb 100644 --- a/Tests/ValidationCorpusTests.swift +++ b/Tests/ValidationCorpusTests.swift @@ -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.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", ]) } } diff --git a/Tests/ValidationCoverageMetaTests.swift b/Tests/ValidationCoverageMetaTests.swift index 3300d52..7359450 100644 --- a/Tests/ValidationCoverageMetaTests.swift +++ b/Tests/ValidationCoverageMetaTests.swift @@ -10,14 +10,14 @@ import Testing @Suite("Validation finding-coverage law") struct ValidationCoverageMetaTests { private struct ModelFixture { - let check: PureYAML.Validation.AnyCheck + let check: PureYAML.Validation.AnyValidation 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 + let check: PureYAML.Validation.AnyValidation 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().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(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) diff --git a/Tests/ValidationFailureFixtureTests.swift b/Tests/ValidationFailureFixtureTests.swift index 953ebb6..715afde 100644 --- a/Tests/ValidationFailureFixtureTests.swift +++ b/Tests/ValidationFailureFixtureTests.swift @@ -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 + 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) } } diff --git a/Tests/ValidationModeTests.swift b/Tests/ValidationModeTests.swift index 363af26..1672577 100644 --- a/Tests/ValidationModeTests.swift +++ b/Tests/ValidationModeTests.swift @@ -12,10 +12,10 @@ struct ValidationModeTests { mode: legacy """, ) - let validator = PureYAML.Validation.Validator.blank.validating(legacyModeRule(severity: .error)) + let validator = PureYAML.Validation.Validator.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.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.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() .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.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( + let validator = PureYAML.Validation.Validator.blank.validating( + PureYAML.Validation.Validation( 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( + let validator = PureYAML.Validation.Validator.blank.validating( + PureYAML.Validation.Validation( 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( + let validator = PureYAML.Validation.Validator.blank.validating( + PureYAML.Validation.Validation( 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( + let validator = PureYAML.Validation.Validator.blank.validating( + PureYAML.Validation.Validation( 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", diff --git a/Tests/ValidationModelTests.swift b/Tests/ValidationModelTests.swift index ed8fc06..b01f972 100644 --- a/Tests/ValidationModelTests.swift +++ b/Tests/ValidationModelTests.swift @@ -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:")) } } diff --git a/Tests/ValidationReportTests.swift b/Tests/ValidationReportTests.swift index e69a92c..482346e 100644 --- a/Tests/ValidationReportTests.swift +++ b/Tests/ValidationReportTests.swift @@ -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( + let validator = PureYAML.Validation.Validator.blank.validating( + PureYAML.Validation.Validation( 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( + let validator = PureYAML.Validation.Validator().validating( + PureYAML.Validation.Validation( 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( + let validator = PureYAML.Validation.Validator.blank.validating( + PureYAML.Validation.Validation( description: "Mode is not legacy", severity: .warning, check: { context in context.subject != .string("legacy") }, diff --git a/Tests/ValidationTestSupport.swift b/Tests/ValidationTestSupport.swift index 6bcd15e..24aee15 100644 --- a/Tests/ValidationTestSupport.swift +++ b/Tests/ValidationTestSupport.swift @@ -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.Validation.Check(description: "Mode must not be legacy") { context in +func legacyModeRule(severity: PureYAML.Validation.Severity) -> PureYAML.Validation.Validation { + PureYAML.Validation.Validation(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.Validation.Check(description: "Trace \(label)") { context in +func traceRule(label: String) -> PureYAML.Validation.Validation { + PureYAML.Validation.Validation(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 = .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)") diff --git a/Tests/ValidationTests.swift b/Tests/ValidationTests.swift index 8d61cd1..480743d 100644 --- a/Tests/ValidationTests.swift +++ b/Tests/ValidationTests.swift @@ -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() 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().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.defaultValidations.map(\.description) == [ "Mapping keys are unique", ]) - #expect(PureYAML.Validation.Validator().validationDescriptions == [ + #expect(PureYAML.Validation.Validator().validationDescriptions == [ "Mapping keys are unique", ]) }