diff --git a/.github/workflows/pod_lib_lint.yml b/.github/workflows/pod_lib_lint.yml index 205d8c43..a3baf393 100644 --- a/.github/workflows/pod_lib_lint.yml +++ b/.github/workflows/pod_lib_lint.yml @@ -27,7 +27,12 @@ jobs: runs-on: macos-14 env: DEVELOPER_DIR: /Applications/Xcode_15.4.app + strategy: + matrix: + platform: [macOS, iOS, tvOS, visionOS] steps: - uses: actions/checkout@v4 - run: bundle install --path vendor/bundle - - run: bundle exec pod lib lint --verbose + - if: matrix.platform == 'visionOS' + run: xcodebuild -downloadPlatform visionOS + - run: bundle exec pod lib lint --platforms=${{ matrix.platform }} --verbose diff --git a/CHANGELOG.md b/CHANGELOG.md index 82045a5d..e477d0a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,32 @@ ##### Breaking +* None. + +##### Enhancements + +* Yams is able to encode and decode Anchors via YamlAnchorProviding, and + YamlAnchorCoding. + [Adora Lynch](https://github.com/lynchsft) + [#125](https://github.com/jpsim/Yams/issues/125) + +* Yams is able to encode and decode Tags via YamlTagProviding + and YamlTagCoding. + [Adora Lynch](https://github.com/lynchsft) + [#265](https://github.com/jpsim/Yams/issues/265) + +* Yams is able to detect redundant structes and automaticaly + alias them during encoding via RedundancyAliasingStrategy + [Adora Lynch](https://github.com/lynchsft) + +##### Bug Fixes + +* None. + +## 5.2.0 + +##### Breaking + * Swift 5.7 or later is now required to build Yams. [JP Simard](https://github.com/jpsim) diff --git a/Sources/Yams/Anchor.swift b/Sources/Yams/Anchor.swift new file mode 100644 index 00000000..4ed859c0 --- /dev/null +++ b/Sources/Yams/Anchor.swift @@ -0,0 +1,39 @@ +// +// Anchor.swift +// Yams +// +// Created by Adora Lynch on 8/9/24. +// Copyright (c) 2024 Yams. All rights reserved. + +import Foundation + +/// A representation of a YAML tag see: https://yaml.org/spec/1.2.2/ +/// Types interested in Encoding and Decoding Anchors should conform to YamlAnchorProviding and YamlAnchorCoding respectively. +public final class Anchor: RawRepresentable, ExpressibleByStringLiteral, Codable, Hashable { + + /// A CharacterSet containing only characters which are permitted by the underlying cyaml implementation + public static let permittedCharacters = CharacterSet.lowercaseLetters + .union(.uppercaseLetters) + .union(.decimalDigits) + .union(.init(charactersIn: "-_")) + + /// Returns true if and only if `string` contains only characters which are also in `permittedCharacters` + public static func is_cyamlAlpha(_ string: String) -> Bool { + Anchor.permittedCharacters.isSuperset(of: .init(charactersIn: string)) + } + + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(stringLiteral value: String) { + rawValue = value + } +} + +/// Conformance of Anchor to CustomStringConvertible returns `rawValue` as `description` +extension Anchor: CustomStringConvertible { + public var description: String { rawValue } +} diff --git a/Sources/Yams/CMakeLists.txt b/Sources/Yams/CMakeLists.txt index 0633b699..0bababc9 100644 --- a/Sources/Yams/CMakeLists.txt +++ b/Sources/Yams/CMakeLists.txt @@ -1,20 +1,25 @@ add_library(Yams + Anchor.swift Constructor.swift Decoder.swift Emitter.swift Encoder.swift Mark.swift + Node.Alias.swift Node.Mapping.swift Node.Scalar.swift Node.Sequence.swift Node.swift Parser.swift + RedundancyAliasingStrategy.swift Representer.swift Resolver.swift String+Yams.swift Tag.swift - YamlError.swift) + YamlAnchorProviding.swift + YamlError.swift + YamlTagProviding.swift) target_compile_definitions(Yams PRIVATE SWIFT_PACKAGE) target_compile_options(Yams PRIVATE diff --git a/Sources/Yams/Constructor.swift b/Sources/Yams/Constructor.swift index 4120202c..ae56777b 100644 --- a/Sources/Yams/Constructor.swift +++ b/Sources/Yams/Constructor.swift @@ -48,12 +48,14 @@ public final class Constructor { if let method = mappingMap[node.tag.name], let result = method(mapping) { return result } - return [AnyHashable: Any]._construct_mapping(from: mapping) + return [AnyHashable: Any].private_construct_mapping(from: mapping) case .sequence(let sequence): if let method = sequenceMap[node.tag.name], let result = method(sequence) { return result } return [Any].construct_seq(from: sequence) + case .alias: + preconditionFailure("Aliases should be resolved before construction") } } @@ -270,7 +272,7 @@ extension ScalarConstructible where Self: FloatingPoint & SexagesimalConvertible } private extension FixedWidthInteger where Self: SexagesimalConvertible { - static func _construct(from scalar: Node.Scalar) -> Self? { + static func private_construct(from scalar: Node.Scalar) -> Self? { guard scalar.style == .any || scalar.style == .plain else { return nil } @@ -315,7 +317,7 @@ extension Int: ScalarConstructible { /// /// - returns: An instance of `Int`, if one was successfully extracted from the scalar. public static func construct(from scalar: Node.Scalar) -> Int? { - return _construct(from: scalar) + return private_construct(from: scalar) } } @@ -328,7 +330,7 @@ extension UInt: ScalarConstructible { /// /// - returns: An instance of `UInt`, if one was successfully extracted from the scalar. public static func construct(from scalar: Node.Scalar) -> UInt? { - return _construct(from: scalar) + return private_construct(from: scalar) } } @@ -341,7 +343,7 @@ extension Int64: ScalarConstructible { /// /// - returns: An instance of `Int64`, if one was successfully extracted from the scalar. public static func construct(from scalar: Node.Scalar) -> Int64? { - return _construct(from: scalar) + return private_construct(from: scalar) } } @@ -354,7 +356,7 @@ extension UInt64: ScalarConstructible { /// /// - returns: An instance of `UInt64`, if one was successfully extracted from the scalar. public static func construct(from scalar: Node.Scalar) -> UInt64? { - return _construct(from: scalar) + return private_construct(from: scalar) } } @@ -418,12 +420,12 @@ extension Dictionary { /// /// - returns: An instance of `[AnyHashable: Any]`, if one was successfully extracted from the mapping. public static func construct_mapping(from mapping: Node.Mapping) -> [AnyHashable: Any]? { - return _construct_mapping(from: mapping) + return private_construct_mapping(from: mapping) } } private extension Dictionary { - static func _construct_mapping(from mapping: Node.Mapping) -> [AnyHashable: Any] { + static func private_construct_mapping(from mapping: Node.Mapping) -> [AnyHashable: Any] { let mapping = mapping.flatten() // TODO: YAML supports keys other than str. return [AnyHashable: Any]( diff --git a/Sources/Yams/Decoder.swift b/Sources/Yams/Decoder.swift index ae0b7dba..859c4583 100644 --- a/Sources/Yams/Decoder.swift +++ b/Sources/Yams/Decoder.swift @@ -48,8 +48,17 @@ public class YAMLDecoder { from yaml: String, userInfo: [CodingUserInfoKey: Any] = [:]) throws -> T where T: Swift.Decodable { do { - let node = try Parser(yaml: yaml, resolver: Resolver([.merge]), encoding: encoding).singleRoot() ?? "" - return try self.decode(type, from: node, userInfo: userInfo) + let parser = try Parser(yaml: yaml, resolver: Resolver([.merge]), encoding: encoding) + // ^ the parser holds the references to Anchors while parsing, + return try withExtendedLifetime(parser) { + // ^ so we hold an explicit reference to the parser during decoding + let node = try parser.singleRoot() ?? "" + // ^ nodes only have weak references to Anchors (the Anchors would disappear if not held by the parser) + return try self.decode(type, from: node, userInfo: userInfo) + // ^ if the decoded type or contained types are YamlAnchorCoding, + // those types have taken ownership of Anchors. + // Otherwise the Anchors are deallocated when this function exits just like Tag and Mark + } } catch let error as DecodingError { throw error } catch { @@ -129,6 +138,8 @@ private struct _Decoder: Decoder { throw _typeMismatch(at: codingPath, expectation: Node.Scalar.self, reality: mapping) case .sequence(let sequence): throw _typeMismatch(at: codingPath, expectation: Node.Scalar.self, reality: sequence) + case .alias(let alias): + throw _typeMismatch(at: codingPath, expectation: Node.Scalar.self, reality: alias) } } } @@ -140,7 +151,41 @@ private struct _KeyedDecodingContainer: KeyedDecodingContainerPr init(decoder: _Decoder, wrapping mapping: Node.Mapping) { self.decoder = decoder - self.mapping = mapping + + let keys = mapping.keys + + let decodeAnchor: Anchor? + let decodeTag: Tag? + + if let anchor = mapping.anchor, keys.contains(.anchorKeyNode) == false { + decodeAnchor = anchor + } else { + decodeAnchor = nil + } + + if mapping.tag.name != .implicit && keys.contains(.tagKeyNode) == false { + decodeTag = mapping.tag + } else { + decodeTag = nil + } + + switch (decodeAnchor, decodeTag) { + case (nil, nil): + self.mapping = mapping + case (let anchor?, nil): + var mutableMapping = mapping + mutableMapping[.anchorKeyNode] = .scalar(.init(anchor.rawValue)) + self.mapping = mutableMapping + case (nil, let tag?): + var mutableMapping = mapping + mutableMapping[.tagKeyNode] = .scalar(.init(tag.name.rawValue)) + self.mapping = mutableMapping + case let (anchor?, tag?): + var mutableMapping = mapping + mutableMapping[.anchorKeyNode] = .scalar(.init(anchor.rawValue)) + mutableMapping[.tagKeyNode] = .scalar(.init(tag.name.rawValue)) + self.mapping = mutableMapping + } } // MARK: - Swift.KeyedDecodingContainerProtocol Methods @@ -381,3 +426,5 @@ extension YAMLDecoder: TopLevelDecoder { } } #endif + +// swiftlint:disable:this file_length diff --git a/Sources/Yams/Emitter.swift b/Sources/Yams/Emitter.swift index 6eeee9aa..295a9d40 100644 --- a/Sources/Yams/Emitter.swift +++ b/Sources/Yams/Emitter.swift @@ -99,7 +99,8 @@ public func dump( sortKeys: Bool = false, sequenceStyle: Node.Sequence.Style = .any, mappingStyle: Node.Mapping.Style = .any, - newLineScalarStyle: Node.Scalar.Style = .any) throws -> String { + newLineScalarStyle: Node.Scalar.Style = .any, + redundancyAliasingStrategy: RedundancyAliasingStrategy? = nil) throws -> String { return try serialize( node: object.represented(), canonical: canonical, @@ -113,7 +114,8 @@ public func dump( sortKeys: sortKeys, sequenceStyle: sequenceStyle, mappingStyle: mappingStyle, - newLineScalarStyle: newLineScalarStyle + newLineScalarStyle: newLineScalarStyle, + redundancyAliasingStrategy: redundancyAliasingStrategy ) } @@ -148,7 +150,8 @@ public func serialize( sortKeys: Bool = false, sequenceStyle: Node.Sequence.Style = .any, mappingStyle: Node.Mapping.Style = .any, - newLineScalarStyle: Node.Scalar.Style = .any) throws -> String + newLineScalarStyle: Node.Scalar.Style = .any, + redundancyAliasingStrategy: RedundancyAliasingStrategy? = nil) throws -> String where Nodes: Sequence, Nodes.Iterator.Element == Node { let emitter = Emitter( canonical: canonical, @@ -162,7 +165,8 @@ public func serialize( sortKeys: sortKeys, sequenceStyle: sequenceStyle, mappingStyle: mappingStyle, - newLineScalarStyle: newLineScalarStyle + newLineScalarStyle: newLineScalarStyle, + redundancyAliasingStrategy: redundancyAliasingStrategy ) try emitter.open() try nodes.forEach(emitter.serialize) @@ -201,7 +205,8 @@ public func serialize( sortKeys: Bool = false, sequenceStyle: Node.Sequence.Style = .any, mappingStyle: Node.Mapping.Style = .any, - newLineScalarStyle: Node.Scalar.Style = .any) throws -> String { + newLineScalarStyle: Node.Scalar.Style = .any, + redundancyAliasingStrategy: RedundancyAliasingStrategy? = nil) throws -> String { return try serialize( nodes: [node], canonical: canonical, @@ -215,7 +220,8 @@ public func serialize( sortKeys: sortKeys, sequenceStyle: sequenceStyle, mappingStyle: mappingStyle, - newLineScalarStyle: newLineScalarStyle + newLineScalarStyle: newLineScalarStyle, + redundancyAliasingStrategy: redundancyAliasingStrategy ) } @@ -246,10 +252,10 @@ public final class Emitter { public var allowUnicode: Bool = false /// Set the preferred line break. public var lineBreak: LineBreak = .ln - - // internal since we don't know if these should be exposed. - var explicitStart: Bool = false - var explicitEnd: Bool = false + /// Set to emit an explicit document start marker. + public var explicitStart: Bool = false + /// Set to emit an explicit document end marker. + public var explicitEnd: Bool = false /// The `%YAML` directive value or nil. public var version: (major: Int, minor: Int)? @@ -265,6 +271,50 @@ public final class Emitter { /// Set the style for scalars that include newlines public var newLineScalarStyle: Node.Scalar.Style = .any + + /// Redundancy aliasing strategy to use when encoding. Defaults to nil + public var redundancyAliasingStrategy: RedundancyAliasingStrategy? + + /// Create `Emitter.Options` with the specified values. + /// + /// - parameter canonical: Set if the output should be in the "canonical" format described in the YAML + /// specification. + /// - parameter indent: Set the indentation value. + /// - parameter width: Set the preferred line width. -1 means unlimited. + /// - parameter allowUnicode: Set if unescaped non-ASCII characters are allowed. + /// - parameter lineBreak: Set the preferred line break. + /// - parameter explicitStart: Explicit document start `---`. + /// - parameter explicitEnd: Explicit document end `...`. + /// - parameter version: The `%YAML` directive value or nil. + /// - parameter sortKeys: Set if emitter should sort keys in lexicographic order. + /// - parameter sequenceStyle: Set the style for sequences (arrays / lists) + /// - parameter mappingStyle: Set the style for mappings (dictionaries) + /// - parameter newLineScalarStyle: Set the style for newline-containing scalars + /// - parameter redundancyAliasingStrategy: Set the strategy for identifying + /// redundant structures and automatically aliasing them + public init(canonical: Bool = false, indent: Int = 0, width: Int = 0, allowUnicode: Bool = false, + lineBreak: Emitter.LineBreak = .ln, + explicitStart: Bool = false, + explicitEnd: Bool = false, + version: (major: Int, minor: Int)? = nil, + sortKeys: Bool = false, sequenceStyle: Node.Sequence.Style = .any, + mappingStyle: Node.Mapping.Style = .any, + newLineScalarStyle: Node.Scalar.Style = .any, + redundancyAliasingStrategy: RedundancyAliasingStrategy? = nil) { + self.canonical = canonical + self.indent = indent + self.width = width + self.allowUnicode = allowUnicode + self.lineBreak = lineBreak + self.explicitStart = explicitStart + self.explicitEnd = explicitEnd + self.version = version + self.sortKeys = sortKeys + self.sequenceStyle = sequenceStyle + self.mappingStyle = mappingStyle + self.newLineScalarStyle = newLineScalarStyle + self.redundancyAliasingStrategy = redundancyAliasingStrategy + } } /// Configuration options to use when emitting YAML. @@ -288,6 +338,9 @@ public final class Emitter { /// - parameter sortKeys: Set if emitter should sort keys in lexicographic order. /// - parameter sequenceStyle: Set the style for sequences (arrays / lists) /// - parameter mappingStyle: Set the style for mappings (dictionaries) + /// - parameter newLineScalarStyle: Set the style for newline-containing scalars + /// - parameter redundancyAliasingStrategy: Set the strategy for identifying redundant + /// structures and automatically aliasing them public init(canonical: Bool = false, indent: Int = 0, width: Int = 0, @@ -299,7 +352,8 @@ public final class Emitter { sortKeys: Bool = false, sequenceStyle: Node.Sequence.Style = .any, mappingStyle: Node.Mapping.Style = .any, - newLineScalarStyle: Node.Scalar.Style = .any) { + newLineScalarStyle: Node.Scalar.Style = .any, + redundancyAliasingStrategy: RedundancyAliasingStrategy? = nil) { options = Options(canonical: canonical, indent: indent, width: width, @@ -311,7 +365,8 @@ public final class Emitter { sortKeys: sortKeys, sequenceStyle: sequenceStyle, mappingStyle: mappingStyle, - newLineScalarStyle: newLineScalarStyle) + newLineScalarStyle: newLineScalarStyle, + redundancyAliasingStrategy: redundancyAliasingStrategy) // configure emitter yaml_emitter_initialize(&emitter) yaml_emitter_set_output(&self.emitter, { pointer, buffer, size in @@ -413,40 +468,6 @@ public final class Emitter { } } -// MARK: - Options Initializer - -extension Emitter.Options { - /// Create `Emitter.Options` with the specified values. - /// - /// - parameter canonical: Set if the output should be in the "canonical" format described in the YAML - /// specification. - /// - parameter indent: Set the indentation value. - /// - parameter width: Set the preferred line width. -1 means unlimited. - /// - parameter allowUnicode: Set if unescaped non-ASCII characters are allowed. - /// - parameter lineBreak: Set the preferred line break. - /// - parameter explicitStart: Explicit document start `---`. - /// - parameter explicitEnd: Explicit document end `...`. - /// - parameter version: The `%YAML` directive value or nil. - /// - parameter sortKeys: Set if emitter should sort keys in lexicographic order. - /// - parameter sequenceStyle: Set the style for sequences (arrays / lists) - /// - parameter mappingStyle: Set the style for mappings (dictionaries) - public init(canonical: Bool = false, indent: Int = 0, width: Int = 0, allowUnicode: Bool = false, - lineBreak: Emitter.LineBreak = .ln, version: (major: Int, minor: Int)? = nil, - sortKeys: Bool = false, sequenceStyle: Node.Sequence.Style = .any, - mappingStyle: Node.Mapping.Style = .any, newLineScalarStyle: Node.Scalar.Style = .any) { - self.canonical = canonical - self.indent = indent - self.width = width - self.allowUnicode = allowUnicode - self.lineBreak = lineBreak - self.version = version - self.sortKeys = sortKeys - self.sequenceStyle = sequenceStyle - self.mappingStyle = mappingStyle - self.newLineScalarStyle = newLineScalarStyle - } -} - // MARK: Implementation Details extension Emitter { @@ -461,9 +482,18 @@ extension Emitter { case .scalar(let scalar): try serializeScalar(scalar) case .sequence(let sequence): try serializeSequence(sequence) case .mapping(let mapping): try serializeMapping(mapping) + case .alias(let alias): try serializeAlias(alias) } } + private func serializeAlias(_ alias: Node.Alias) throws { + var event = yaml_event_t() + let anchor = alias.anchor.rawValue + yaml_alias_event_initialize(&event, + anchor) + try emit(&event) + } + private func serializeScalar(_ scalar: Node.Scalar) throws { var value = scalar.string.utf8CString, tag = scalar.resolvedTag.name.rawValue.utf8CString let scalarStyle = yaml_scalar_style_t(rawValue: numericCast(scalar.style.rawValue)) @@ -472,7 +502,7 @@ extension Emitter { tag.withUnsafeMutableBytes { tag in yaml_scalar_event_initialize( &event, - nil, + scalar.anchor?.rawValue, tag.baseAddress?.assumingMemoryBound(to: UInt8.self), value.baseAddress?.assumingMemoryBound(to: UInt8.self), Int32(value.count - 1), @@ -492,7 +522,7 @@ extension Emitter { _ = tag.withUnsafeMutableBytes { tag in yaml_sequence_start_event_initialize( &event, - nil, + sequence.anchor?.rawValue, tag.baseAddress?.assumingMemoryBound(to: UInt8.self), implicit, sequenceStyle) @@ -511,7 +541,7 @@ extension Emitter { _ = tag.withUnsafeMutableBytes { tag in yaml_mapping_start_event_initialize( &event, - nil, + mapping.anchor?.rawValue, tag.baseAddress?.assumingMemoryBound(to: UInt8.self), implicit, mappingStyle) diff --git a/Sources/Yams/Encoder.swift b/Sources/Yams/Encoder.swift index fd17bac3..b648ba09 100644 --- a/Sources/Yams/Encoder.swift +++ b/Sources/Yams/Encoder.swift @@ -28,10 +28,17 @@ public class YAMLEncoder { /// - throws: `EncodingError` if something went wrong while encoding. public func encode(_ value: T, userInfo: [CodingUserInfoKey: Any] = [:]) throws -> String { do { - let encoder = _Encoder(userInfo: userInfo, sequenceStyle: options.sequenceStyle, - mappingStyle: options.mappingStyle, newlineScalarStyle: options.newLineScalarStyle) + var finalUserInfo = userInfo + if let aliasingStrategy = options.redundancyAliasingStrategy { + finalUserInfo[.redundancyAliasingStrategyKey] = aliasingStrategy + } + let encoder = _Encoder(userInfo: finalUserInfo, + sequenceStyle: options.sequenceStyle, + mappingStyle: options.mappingStyle, + newlineScalarStyle: options.newLineScalarStyle) var container = encoder.singleValueContainer() try container.encode(value) + try options.redundancyAliasingStrategy?.releaseAnchorReferences() return try serialize(node: encoder.node, options: options) } catch let error as EncodingError { throw error @@ -165,7 +172,15 @@ private struct _KeyedEncodingContainer: KeyedEncodingContainerPr var codingPath: [CodingKey] { return encoder.codingPath } func encodeNil(forKey key: Key) throws { encoder.mapping[key.stringValue] = .null } func encode(_ value: T, forKey key: Key) throws where T: YAMLEncodable { try encoder(for: key).encode(value) } - func encode(_ value: T, forKey key: Key) throws where T: Encodable { try encoder(for: key).encode(value) } + func encode(_ value: T, forKey key: Key) throws where T: Encodable { + if let anchor = value as? Anchor, key.stringValue == Node.anchorKeyNode.string { + encoder.node = encoder.node.setting(anchor: anchor) + } else if let tag = value as? Tag, key.stringValue == Node.tagKeyNode.string { + encoder.node = encoder.node.setting(tag: tag) + } else { + try encoder(for: key).encode(value) + } + } func nestedContainer(keyedBy type: NestedKey.Type, forKey key: Key) -> KeyedEncodingContainer { @@ -225,21 +240,50 @@ extension _Encoder: SingleValueEncodingContainer { func encode(_ value: T) throws where T: YAMLEncodable { assertCanEncodeNewValue() - node = value.box() - if let stringValue = value as? (any StringProtocol), stringValue.contains("\n") { - node.scalar?.style = newlineScalarStyle + try encode(yamlEncodable: value) + } + + private func encode(yamlEncodable encodable: YAMLEncodable) throws { + func encodeNode() { + node = encodable.box() + if let stringValue = encodable as? (any StringProtocol), stringValue.contains("\n") { + node.scalar?.style = newlineScalarStyle + } + } + if let redundancyAliasingStrategy = userInfo[.redundancyAliasingStrategyKey] as? RedundancyAliasingStrategy { + switch try redundancyAliasingStrategy.alias(for: encodable) { + case .none: + encodeNode() + case let .anchor(anchor): + encodeNode() + self.node = self.node.setting(anchor: anchor) + case let .alias(anchor): + self.node = .alias(.init(anchor)) + } + } else { + encodeNode() } } func encode(_ value: T) throws where T: Encodable { assertCanEncodeNewValue() if let encodable = value as? YAMLEncodable { - node = encodable.box() - if let stringValue = value as? (any StringProtocol), stringValue.contains("\n") { - node.scalar?.style = newlineScalarStyle - } + try encode(yamlEncodable: encodable) } else { - try value.encode(to: self) + if let redundancyAliasingStrategy = + userInfo[.redundancyAliasingStrategyKey] as? RedundancyAliasingStrategy { + switch try redundancyAliasingStrategy.alias(for: value) { + case .none: + try value.encode(to: self) + case let .anchor(anchor): + try value.encode(to: self) + self.node = self.node.setting(anchor: anchor) + case let .alias(anchor): + self.node = .alias(.init(anchor)) + } + } else { + try value.encode(to: self) + } } } diff --git a/Sources/Yams/Node.Alias.swift b/Sources/Yams/Node.Alias.swift new file mode 100644 index 00000000..80766eff --- /dev/null +++ b/Sources/Yams/Node.Alias.swift @@ -0,0 +1,56 @@ +// +// Node.Alias.swift +// Yams +// +// Created by Adora Lynch on 8/19/24. +// Copyright (c) 2024 Yams. All rights reserved. +// + +// MARK: Node+Alias + +extension Node { + /// Scalar node. + public struct Alias { + /// The anchor for this alias. + public var anchor: Anchor + /// This node's tag (its type). + public var tag: Tag + /// The location for this node. + public var mark: Mark? + + /// Create a `Node.Alias` using the specified parameters. + /// + /// - parameter tag: This scalar's `Tag`. + /// - parameter mark: This scalar's `Mark`. + public init(_ anchor: Anchor, _ tag: Tag = .implicit, _ mark: Mark? = nil) { + self.anchor = anchor + self.tag = tag + self.mark = mark + } + } +} + +extension Node.Alias: Comparable { + /// :nodoc: + public static func < (lhs: Node.Alias, rhs: Node.Alias) -> Bool { + lhs.anchor.rawValue < rhs.anchor.rawValue + } +} + +extension Node.Alias: Equatable { + /// :nodoc: + public static func == (lhs: Node.Alias, rhs: Node.Alias) -> Bool { + lhs.anchor == rhs.anchor + } +} + +extension Node.Alias: Hashable { + /// :nodoc: + public func hash(into hasher: inout Hasher) { + hasher.combine(anchor) + } +} + +extension Node.Alias: TagResolvable { + static let defaultTagName = Tag.Name.implicit +} diff --git a/Sources/Yams/Node.Mapping.swift b/Sources/Yams/Node.Mapping.swift index a6feafc0..955939b6 100644 --- a/Sources/Yams/Node.Mapping.swift +++ b/Sources/Yams/Node.Mapping.swift @@ -16,6 +16,8 @@ extension Node { public var style: Style /// This mapping's `Mark`. public var mark: Mark? + /// The anchor for this node. + public weak var anchor: Anchor? /// The style to use when emitting a `Mapping`. public enum Style: UInt32 { @@ -33,11 +35,16 @@ extension Node { /// - parameter tag: This mapping's `Tag`. /// - parameter style: The style to use when emitting this `Mapping`. /// - parameter mark: This mapping's `Mark`. - public init(_ pairs: [(Node, Node)], _ tag: Tag = .implicit, _ style: Style = .any, _ mark: Mark? = nil) { + public init(_ pairs: [(Node, Node)], + _ tag: Tag = .implicit, + _ style: Style = .any, + _ mark: Mark? = nil, + _ anchor: Anchor? = nil) { self.pairs = pairs.map { Pair($0.0, $0.1) } self.tag = tag self.style = style self.mark = mark + self.anchor = anchor } } @@ -166,7 +173,7 @@ extension Node.Mapping { index += 1 } } - return Node.Mapping(merge + pairs, tag, style) + return Node.Mapping(merge + pairs, tag, style, nil, anchor) } } diff --git a/Sources/Yams/Node.Scalar.swift b/Sources/Yams/Node.Scalar.swift index 662e8b4c..42ad2037 100644 --- a/Sources/Yams/Node.Scalar.swift +++ b/Sources/Yams/Node.Scalar.swift @@ -23,6 +23,8 @@ extension Node { public var style: Style /// The location for this node. public var mark: Mark? + /// The anchor for this node. + public weak var anchor: Anchor? /// The style to use when emitting a `Scalar`. public enum Style: UInt32 { @@ -48,11 +50,16 @@ extension Node { /// - parameter tag: This scalar's `Tag`. /// - parameter style: The style to use when emitting this `Scalar`. /// - parameter mark: This scalar's `Mark`. - public init(_ string: String, _ tag: Tag = .implicit, _ style: Style = .any, _ mark: Mark? = nil) { + public init(_ string: String, + _ tag: Tag = .implicit, + _ style: Style = .any, + _ mark: Mark? = nil, + _ anchor: Anchor? = nil) { self.string = string self.tag = tag self.style = style self.mark = mark + self.anchor = anchor } } diff --git a/Sources/Yams/Node.Sequence.swift b/Sources/Yams/Node.Sequence.swift index 571c9aee..80f7a40f 100644 --- a/Sources/Yams/Node.Sequence.swift +++ b/Sources/Yams/Node.Sequence.swift @@ -18,6 +18,8 @@ extension Node { public var style: Style /// The location for this node. public var mark: Mark? + /// The anchor for this node. + public weak var anchor: Anchor? /// The style to use when emitting a `Sequence`. public enum Style: UInt32 { @@ -35,11 +37,16 @@ extension Node { /// - parameter tag: This sequence's `Tag`. /// - parameter style: The style to use when emitting this `Sequence`. /// - parameter mark: This sequence's `Mark`. - public init(_ nodes: [Node], _ tag: Tag = .implicit, _ style: Style = .any, _ mark: Mark? = nil) { + public init(_ nodes: [Node], + _ tag: Tag = .implicit, + _ style: Style = .any, + _ mark: Mark? = nil, + _ anchor: Anchor? = nil) { self.nodes = nodes self.tag = tag self.style = style self.mark = mark + self.anchor = anchor } } diff --git a/Sources/Yams/Node.swift b/Sources/Yams/Node.swift index 87728960..c335ee79 100644 --- a/Sources/Yams/Node.swift +++ b/Sources/Yams/Node.swift @@ -16,6 +16,8 @@ public enum Node: Hashable { case mapping(Mapping) /// Sequence node. case sequence(Sequence) + /// Alias node. + case alias(Alias) } extension Node { @@ -24,8 +26,11 @@ extension Node { /// - parameter string: String value for this node. /// - parameter tag: Tag for this node. /// - parameter style: Style to use when emitting this node. - public init(_ string: String, _ tag: Tag = .implicit, _ style: Scalar.Style = .any) { - self = .scalar(.init(string, tag, style)) + public init(_ string: String, + _ tag: Tag = .implicit, + _ style: Scalar.Style = .any, + _ anchor: Anchor? = nil) { + self = .scalar(.init(string, tag, style, nil, anchor)) } /// Create a `Node.mapping` with a sequence of node pairs, tag & scalar style. @@ -33,8 +38,11 @@ extension Node { /// - parameter pairs: Pairs of nodes to use for this node. /// - parameter tag: Tag for this node. /// - parameter style: Style to use when emitting this node. - public init(_ pairs: [(Node, Node)], _ tag: Tag = .implicit, _ style: Mapping.Style = .any) { - self = .mapping(.init(pairs, tag, style)) + public init(_ pairs: [(Node, Node)], + _ tag: Tag = .implicit, + _ style: Mapping.Style = .any, + _ anchor: Anchor? = nil) { + self = .mapping(.init(pairs, tag, style, nil, anchor)) } /// Create a `Node.sequence` with a sequence of nodes, tag & scalar style. @@ -42,8 +50,11 @@ extension Node { /// - parameter nodes: Sequence of nodes to use for this node. /// - parameter tag: Tag for this node. /// - parameter style: Style to use when emitting this node. - public init(_ nodes: [Node], _ tag: Tag = .implicit, _ style: Sequence.Style = .any) { - self = .sequence(.init(nodes, tag, style)) + public init(_ nodes: [Node], + _ tag: Tag = .implicit, + _ style: Sequence.Style = .any, + _ anchor: Anchor? = nil) { + self = .sequence(.init(nodes, tag, style, nil, anchor)) } } @@ -58,6 +69,7 @@ extension Node { case let .scalar(scalar): return scalar.resolvedTag case let .mapping(mapping): return mapping.resolvedTag case let .sequence(sequence): return sequence.resolvedTag + case let .alias(alias): return alias.resolvedTag } } @@ -67,6 +79,17 @@ extension Node { case let .scalar(scalar): return scalar.mark case let .mapping(mapping): return mapping.mark case let .sequence(sequence): return sequence.mark + case let .alias(alias): return alias.mark + } + } + + /// The anchor for this node. + public var anchor: Anchor? { + switch self { + case let .scalar(scalar): return scalar.anchor + case let .mapping(mapping): return mapping.anchor + case let .sequence(sequence): return sequence.anchor + case let .alias(alias): return alias.anchor } } @@ -139,7 +162,7 @@ extension Node { public subscript(node: Node) -> Node? { get { switch self { - case .scalar: return nil + case .scalar, .alias: return nil case let .mapping(mapping): return mapping[node] case let .sequence(sequence): @@ -150,7 +173,7 @@ extension Node { set { guard let newValue = newValue else { return } switch self { - case .scalar: return + case .scalar, .alias: return case .mapping(var mapping): mapping[node] = newValue self = .mapping(mapping) @@ -290,4 +313,38 @@ extension Node { } return false } + + func setting(anchor: Anchor) -> Self { + switch self { + case var .mapping(mapping): + mapping.anchor = anchor + return .mapping(mapping) + case var .sequence(sequence): + sequence.anchor = anchor + return .sequence(sequence) + case var .scalar(scalar): + scalar.anchor = anchor + return .scalar(scalar) + case var .alias(alias): + alias.anchor = anchor + return .alias(alias) + } + } + + func setting(tag: Tag) -> Self { + switch self { + case var .mapping(mapping): + mapping.tag = tag + return .mapping(mapping) + case var .sequence(sequence): + sequence.tag = tag + return .sequence(sequence) + case var .scalar(scalar): + scalar.tag = tag + return .scalar(scalar) + case var .alias(alias): + alias.tag = tag + return .alias(alias) + } + } } diff --git a/Sources/Yams/Parser.swift b/Sources/Yams/Parser.swift index 41a4d74a..46165ace 100644 --- a/Sources/Yams/Parser.swift +++ b/Sources/Yams/Parser.swift @@ -140,7 +140,7 @@ public final class Parser { }() /// The equivalent `Swift.Encoding` value for `self`. - internal var swiftStringEncoding: String.Encoding { + public var swiftStringEncoding: String.Encoding { switch self { case .utf8: return .utf8 @@ -254,7 +254,9 @@ public final class Parser { // MARK: - Private Members - private var anchors = [String: Node]() + private var _anchorMap = [Anchor: Node]() + private var _anchorList = [Anchor]() + private var anchors: [Anchor: Node] { _anchorMap } private var parser = yaml_parser_t() private enum Buffer { @@ -263,6 +265,20 @@ public final class Parser { case utf16(Data) } private var buffer: Buffer + + // MARK: – Pivate Mutators + private func register(anchor: Anchor?, to node: Node) { + if let anchor { + _anchorList.append(anchor) + // We keep a list (not a set) of all anchors encountered + // because yaml anchors are allowed to shadow one another. + // + // The map will keep the latest reference as expected + // but without the list the map will release reference to + // one of the Anchor instances whenever duplicates are encountered. + _anchorMap[anchor] = node + } + } } // MARK: Implementation Details @@ -324,10 +340,13 @@ private extension Parser { } func loadScalar(from event: Event) throws -> Node { - let node = Node.scalar(.init(event.scalarValue, tag(event.scalarTag), event.scalarStyle, event.startMark)) - if let anchor = event.scalarAnchor { - anchors[anchor] = node - } + let anchor = event.scalarAnchor + let node = Node.scalar(.init(event.scalarValue, + tag(event.scalarTag), + event.scalarStyle, + event.startMark, + anchor)) + register(anchor: anchor, to: node) return node } @@ -338,10 +357,13 @@ private extension Parser { array.append(try loadNode(from: event)) event = try parse() } - let node = Node.sequence(.init(array, tag(firstEvent.sequenceTag), event.sequenceStyle, firstEvent.startMark)) - if let anchor = firstEvent.sequenceAnchor { - anchors[anchor] = node - } + let anchor = firstEvent.sequenceAnchor + let node = Node.sequence(.init(array, + tag(firstEvent.sequenceTag), + event.sequenceStyle, + firstEvent.startMark, + anchor)) + register(anchor: anchor, to: node) return node } @@ -355,10 +377,13 @@ private extension Parser { pairs.append((key, value)) event = try parse() } - let node = Node.mapping(.init(pairs, tag(firstEvent.mappingTag), event.mappingStyle, firstEvent.startMark)) - if let anchor = firstEvent.mappingAnchor { - anchors[anchor] = node - } + let anchor = firstEvent.mappingAnchor + let node = Node.mapping(.init(pairs, + tag(firstEvent.mappingTag), + event.mappingStyle, + firstEvent.startMark, + anchor)) + register(anchor: anchor, to: node) return node } @@ -378,13 +403,13 @@ private class Event { } // alias - var aliasAnchor: String? { - return string(from: event.data.alias.anchor) + var aliasAnchor: Anchor? { + return string(from: event.data.alias.anchor).map(Anchor.init(stringLiteral: )) } // scalar - var scalarAnchor: String? { - return string(from: event.data.scalar.anchor) + var scalarAnchor: Anchor? { + return string(from: event.data.scalar.anchor).map(Anchor.init(stringLiteral: )) } var scalarStyle: Node.Scalar.Style { // swiftlint:disable:next force_unwrapping @@ -405,8 +430,8 @@ private class Event { } // sequence - var sequenceAnchor: String? { - return string(from: event.data.sequence_start.anchor) + var sequenceAnchor: Anchor? { + return string(from: event.data.sequence_start.anchor).map(Anchor.init(stringLiteral: )) } var sequenceStyle: Node.Sequence.Style { // swiftlint:disable:next force_unwrapping @@ -418,8 +443,8 @@ private class Event { } // mapping - var mappingAnchor: String? { - return string(from: event.data.scalar.anchor) + var mappingAnchor: Anchor? { + return string(from: event.data.mapping_start.anchor).map(Anchor.init(stringLiteral: )) } var mappingStyle: Node.Mapping.Style { // swiftlint:disable:next force_unwrapping diff --git a/Sources/Yams/RedundancyAliasingStrategy.swift b/Sources/Yams/RedundancyAliasingStrategy.swift new file mode 100644 index 00000000..5e76056c --- /dev/null +++ b/Sources/Yams/RedundancyAliasingStrategy.swift @@ -0,0 +1,120 @@ +// +// RedundancyAliasingStrategy.swift +// Yams +// +// Created by Adora Lynch on 8/15/24. +// Copyright (c) 2024 Yams. All rights reserved. +// + +/// An enum indicating the outcome of a `RedundancyAliasingStrategy` +/// if the strategy returns `anchor` the encoder will encode an Anchor +/// if the strategy returns `alias` the encoder will encode an alias to an anchor which should already have been specified. +/// If the strategy returns none the encoder will encode without an anchor or an alias +public enum RedundancyAliasingOutcome { + case anchor(Anchor) + case alias(Anchor) + case none +} + +/// A class-bound protocol which implements a strategy for detecting aliasable values in a YAML document. +/// Implementations should return RedundancyAliasingOutcome.anchor(...) for the first occurrence of a value. +/// Subsequent occurrences of the same value (where same-ness is defined by the implementation) should +/// return RedundancyAliasingOutcome.alias(...) where the contained Anchor has the same value as the previously +/// returned RedundancyAliasingOutcome.anchor(...). Its the identity of the Anchor values returned that ultimately +/// informs the YAML encoder when to use aliases. +/// N,B. It is essential that implementations release all references to Anchors which are created by this type +/// when releaseAnchorReferences() is called by the Encoder. After this call the implementation will no longer be +/// referenced by the Encoder and will itself be released. +public protocol RedundancyAliasingStrategy: AnyObject { + + /// Implementations should return RedundancyAliasingOutcome.anchor(...) for the first occurrence of a value. + /// Subsequent occurrences of the same value (where same-ness is defined by the implementation) should + /// return RedundancyAliasingOutcome.alias(...) where the contained Anchor has the same value as the previously + /// returned RedundancyAliasingOutcome.anchor(...). Its the identity of the Anchor values returned that ultimately + /// informs the YAML encoder when to use aliases. + func alias(for encodable: any Encodable) throws -> RedundancyAliasingOutcome + + /// It is essential that implementations release all references to Anchors which are created by this type + /// when releaseAnchorReferences() is called by the Encoder. After this call, the implementation will no longer be + /// referenced by the Encoder and will itself be released. + + func releaseAnchorReferences() throws +} + +/// An implementation of RedundancyAliasingStrategy that defines alias-ability by Hashable-Equality. +/// i.e. if two values are Hashable-Equal, they will be aliased in the resultant YML document. +public class HashableAliasingStrategy: RedundancyAliasingStrategy { + private var hashesToAliases: [AnyHashable: Anchor] = [:] + + let uniqueAliasProvider = UniqueAliasProvider() + + public init() {} + + public func alias(for encodable: any Encodable) throws -> RedundancyAliasingOutcome { + guard let hashable = encodable as? any Hashable & Encodable else { + return .none + } + return try alias(for: hashable) + } + + private func alias(for hashable: any Hashable & Encodable) throws -> RedundancyAliasingOutcome { + let anyHashable = AnyHashable(hashable) + if let existing = hashesToAliases[anyHashable] { + return .alias(existing) + } else { + let newAlias = uniqueAliasProvider.uniqueAlias(for: hashable) + hashesToAliases[anyHashable] = newAlias + return .anchor(newAlias) + } + } + + public func releaseAnchorReferences() throws { + hashesToAliases.removeAll() + } +} + +/// An implementation of RedundancyAliasingStrategy that defines alias-ability by the coded representation +/// of the values. i.e. if two values encode to exactly the same, they will be aliased in the resultant YML +/// document even if the values themselves are of different types +public class StrictEncodableAliasingStrategy: RedundancyAliasingStrategy { + private var codedToAliases: [String: Anchor] = [:] + + let uniqueAliasProvider = UniqueAliasProvider() + + public init() {} + + private let encoder = YAMLEncoder() + + public func alias(for encodable: any Encodable) throws -> RedundancyAliasingOutcome { + let coded = try encoder.encode(encodable) + if let existing = codedToAliases[coded] { + return .alias(existing) + } else { + let newAlias = uniqueAliasProvider.uniqueAlias(for: encodable) + codedToAliases[coded] = newAlias + return .anchor(newAlias) + } + } + + public func releaseAnchorReferences() throws { + codedToAliases.removeAll() + } +} + +class UniqueAliasProvider { + private var counter = 0 + + func uniqueAlias(for encodable: any Encodable) -> Anchor { + if let anchorProviding = encodable as? YamlAnchorProviding, + let anchor = anchorProviding.yamlAnchor { + return anchor + } else { + counter += 1 + return Anchor(rawValue: String(counter)) + } + } +} + +extension CodingUserInfoKey { + internal static let redundancyAliasingStrategyKey = Self(rawValue: "redundancyAliasingStrategy")! +} diff --git a/Sources/Yams/Resolver.swift b/Sources/Yams/Resolver.swift index 40abb2da..4cf8d743 100644 --- a/Sources/Yams/Resolver.swift +++ b/Sources/Yams/Resolver.swift @@ -48,6 +48,8 @@ public final class Resolver { return resolveTag(of: mapping) case let .sequence(sequence): return resolveTag(of: sequence) + case let .alias(alias): + return resolveTag(of: alias) } } diff --git a/Sources/Yams/Tag.swift b/Sources/Yams/Tag.swift index fd2e1dbd..47e774a5 100644 --- a/Sources/Yams/Tag.swift +++ b/Sources/Yams/Tag.swift @@ -85,6 +85,24 @@ extension Tag: Hashable { } } +extension Tag: RawRepresentable { + public convenience init?(rawValue: String) { + self.init(stringLiteral: rawValue) + } + public var rawValue: String { + name.rawValue + } +} + +extension Tag: Codable {} + +extension Tag: ExpressibleByStringLiteral { + /// :nodoc: + public convenience init(stringLiteral value: String) { + self.init(.init(rawValue: value)) + } +} + extension Tag.Name: ExpressibleByStringLiteral { /// :nodoc: public init(stringLiteral value: String) { @@ -92,6 +110,8 @@ extension Tag.Name: ExpressibleByStringLiteral { } } +extension Tag.Name: Codable {} + // http://www.yaml.org/spec/1.2/spec.html#Schema extension Tag.Name { // Special diff --git a/Sources/Yams/YamlAnchorProviding.swift b/Sources/Yams/YamlAnchorProviding.swift new file mode 100644 index 00000000..fd2f765d --- /dev/null +++ b/Sources/Yams/YamlAnchorProviding.swift @@ -0,0 +1,45 @@ +// +// YamlAnchorProviding.swift +// Yams +// +// Created by Adora Lynch on 8/15/24. +// Copyright (c) 2024 Yams. All rights reserved. +// + +/// Types that conform to YamlAnchorProviding and Encodable can optionally dictate the name of +/// a yaml anchor when they are encoded with YAMLEncoder +public protocol YamlAnchorProviding { + /// the Anchor to encode with this node or nil + var yamlAnchor: Anchor? { get } +} + +/// YamlAnchorCoding refines YamlAnchorProviding. +/// Types that conform to YamlAnchorCoding and Decodable can decode yaml anchors +/// from source documents into `Anchor` values for reference or modification in memory. +public protocol YamlAnchorCoding: YamlAnchorProviding { + /// the Anchor coded with this node or nil if none is present + var yamlAnchor: Anchor? { get set } +} + +internal extension Node { + static let anchorKeyNode: Self = .scalar(.init(YamlAnchorFunctionNameProvider().getName())) +} + +private final class YamlAnchorFunctionNameProvider: YamlAnchorProviding { + + fileprivate var functionName: StaticString? + + var yamlAnchor: Anchor? { + functionName = #function + return nil + } + + func getName() -> StaticString { + _ = yamlAnchor + return functionName! + } + + func getName() -> String { + String(describing: getName() as StaticString) + } +} diff --git a/Sources/Yams/YamlTagProviding.swift b/Sources/Yams/YamlTagProviding.swift new file mode 100644 index 00000000..7b2b0c44 --- /dev/null +++ b/Sources/Yams/YamlTagProviding.swift @@ -0,0 +1,45 @@ +// +// YamlTagProviding.swift +// Yams +// +// Created by Adora Lynch on 9/5/24. +// Copyright (c) 2024 Yams. All rights reserved. +// + +/// Types that conform to YamlTagProviding and Encodable can optionally dictate the name of +/// a yaml tag when they are encoded with YAMLEncoder +public protocol YamlTagProviding { + /// the Tag to encode with this node or nil + var yamlTag: Tag? { get } +} + +/// YamlTagCoding refines YamlTagProviding. +/// Types that conform to YamlTagCoding and Decodable can decode yaml tags +/// from source documents into `Tag` values for reference or modification in memory. +public protocol YamlTagCoding: YamlTagProviding { + /// the Tag coded with this node or nil if none is present + var yamlTag: Tag? { get set } +} + +internal extension Node { + static let tagKeyNode: Self = .scalar(.init(YamlTagFunctionNameProvider().getName())) +} + +private final class YamlTagFunctionNameProvider: YamlTagProviding { + + fileprivate var functionName: StaticString? + + var yamlTag: Tag? { + functionName = #function + return nil + } + + func getName() -> StaticString { + _ = yamlTag + return functionName! + } + + func getName() -> String { + String(describing: getName() as StaticString) + } +} diff --git a/Tests/YamsTests/AnchorCodingTests.swift b/Tests/YamsTests/AnchorCodingTests.swift new file mode 100644 index 00000000..997ced71 --- /dev/null +++ b/Tests/YamsTests/AnchorCodingTests.swift @@ -0,0 +1,320 @@ +// +// AnchorEncodingTests.swift +// Yams +// +// Created by Adora Lynch on 8/9/24. +// Copyright (c) 2024 Yams. All rights reserved. +// + +import XCTest +import Yams + +class AnchorCodingTests: XCTestCase { + + /// Test the encoding of a yaml anchor using a type that conforms to YamlAnchorProviding + func testYamlAnchorProviding_valuePresent() throws { + let simpleStruct = SimpleWithAnchor(nested: + .init(stringValue: "it's a value"), + intValue: 52) + + _testRoundTrip(of: simpleStruct, + expectedYAML: """ + &simple + nested: + stringValue: it's a value + intValue: 52 + + """ ) // ^ the Yams.Anchor is encoded as a yaml anchor + } + + /// Test the encoding of a a type that does not conform to YamlAnchorProviding but none the less + /// declares a coding member with the same name + func testStringTypeAnchorName_valuePresent() throws { + let simpleStruct = SimpleWithStringTypeAnchorName(nested: .init(stringValue: "it's a value"), + intValue: 52, + yamlAnchor: "but typed as a string") + + _testRoundTrip(of: simpleStruct, + expectedYAML: """ + nested: + stringValue: it's a value + intValue: 52 + yamlAnchor: but typed as a string + + """ ) // ^ the member is _not_ treated as an anchor + } + + /// Nothing interesting happens when a type does not conform to YamlAnchorProviding none the less + /// declares a coding member with the same name but that value is nil + func testStringTypeAnchorName_valueNotPresent() throws { + let expectedStruct = SimpleWithStringTypeAnchorName(nested: .init(stringValue: "it's a value"), + intValue: 52, + yamlAnchor: nil) + _testRoundTrip(of: expectedStruct, + expectedYAML: """ + nested: + stringValue: it's a value + intValue: 52 + + """) + } + + /// This test documents some undesirable behavior, but in an unlikely circumstance. + /// If the decoded type does not conform to YamlAnchorProviding it can still have a coding key called + /// `yamlAnchor` + /// If Yams tries to decode such a type AND the document has a nil value for `yamlAnchor` AND the + /// parent context is a mapping AND that mapping has an actual anchor (in the document) + /// THEN Yams wrongly tries to decode the anchor as the declared type of key `yamlAnchor`. + /// If that declared type can be decoded from a scalar string value (like String and RawRepresentable + /// where RawValue == String) then the decoding will actually succeed. + /// Which effectively injects an unexpected value into the decoded type. + func testStringTypeAnchorName_withAnchorPresent_valueNil() throws { + let expectedStruct = SimpleWithStringTypeAnchorName(nested: .init(stringValue: "it's a value"), + intValue: 52, + yamlAnchor: nil) + let decoder = YAMLDecoder() + let data = """ + &AnActualAnchor + nested: + stringValue: it's a value + intValue: 52 + + """.data(using: decoder.encoding.swiftStringEncoding)! + + let decodedStruct = try decoder.decode(SimpleWithStringTypeAnchorName.self, from: data) + + let fixBulletin = "YESS!!! YOU FIXED IT! See \(#file):\(#line) for explanation." + + // begin assertions of known-but-undesirable behavior + XCTAssertNotEqual(decodedStruct, expectedStruct, fixBulletin) // We wish this was equal + XCTAssertEqual(decodedStruct.yamlAnchor, "AnActualAnchor", fixBulletin) // we wish .yamlAnchor was nil + // end assertions of known-but-undesirable behavior + + // Check the remainder of the properties that the above confusion did not involve + XCTAssertEqual(decodedStruct.nested, expectedStruct.nested) + XCTAssertEqual(decodedStruct.intValue, expectedStruct.intValue) + } +} + +class AnchorAliasingTests: XCTestCase { + + /// CYaml library does not detect identical values and automatically alias them. + func testCyamlDoesNotAutoAlias_noAnchor() throws { + let simpleNoAnchor = SimpleWithoutAnchor(nested: .init(stringValue: "it's a value"), intValue: 52) + let differentTypesOneAnchor = SimplePair(first: simpleNoAnchor, + second: simpleNoAnchor) + + _testRoundTrip(of: differentTypesOneAnchor, + expectedYAML: """ + first: + nested: + stringValue: it's a value + intValue: 52 + second: + nested: + stringValue: it's a value + intValue: 52 + + """ ) + } + + /// CYaml library does not detect identical values and automatically alias them even if the first + /// occurrence has an anchor. + func testCyamlDoesNotAutoAlias_uniqueAnchor() throws { + let simpleStruct = SimpleWithAnchor(nested: .init(stringValue: "it's a value"), intValue: 52) + let simpleNoAnchor = SimpleWithoutAnchor(nested: .init(stringValue: "it's a value"), intValue: 52) + let differentTypesOneAnchor = SimplePair(first: simpleStruct, + second: simpleNoAnchor) + + _testRoundTrip(of: differentTypesOneAnchor, + expectedYAML: """ + first: &simple + nested: + stringValue: it's a value + intValue: 52 + second: + nested: + stringValue: it's a value + intValue: 52 + + """ ) + } + + /// CYaml library does not detect identical values and automatically alias them even if they have identical anchors. + /// This one is not a shortcoming of CYaml. The yaml spec requires that nodes can shadow earlier anchors. + func testCyamlDoesNotAutoAlias_duplicateAnchor() throws { + let simpleStruct = SimpleWithAnchor(nested: .init(stringValue: "it's a value"), intValue: 52) + let duplicatedStructPair = SimplePair(first: simpleStruct, second: simpleStruct) + + _testRoundTrip(of: duplicatedStructPair, + expectedYAML: """ + first: &simple + nested: + stringValue: it's a value + intValue: 52 + second: &simple + nested: + stringValue: it's a value + intValue: 52 + + """ ) + } + + /// If types conform to YamlAnchorProviding and are Hashable-Equal then HashableAliasingStrategy aliases them + func testEncoderAutoAlias_Hashable_duplicateAnchor() throws { + let simpleStruct = SimpleWithAnchor(nested: .init(stringValue: "it's a value"), intValue: 52) + let duplicatedStructArray = [simpleStruct, simpleStruct] + + let options = YAMLEncoder.Options(redundancyAliasingStrategy: HashableAliasingStrategy()) + _testRoundTrip(of: duplicatedStructArray, + with: options, + expectedYAML: """ + - &simple + nested: + stringValue: it's a value + intValue: 52 + - *simple + + """ ) + } + + /// If types do NOT conform to YamlAnchorProviding and are Hashable-Equal then HashableAliasingStrategy aliases them + func testEncoderAutoAlias_Hashable_noAnchors() throws { + let simpleStruct = SimpleWithoutAnchor(nested: .init(stringValue: "it's a value"), intValue: 52) + let duplicatedStructArray = [simpleStruct, simpleStruct] // zero specified anchor + + let options = YAMLEncoder.Options(redundancyAliasingStrategy: HashableAliasingStrategy()) + _testRoundTrip(of: duplicatedStructArray, + with: options, + expectedYAML: """ + - &2 + nested: + stringValue: it's a value + intValue: 52 + - *2 + + """ ) + } + + /// If types conform to YamlAnchorProviding and are NOT Hashable-Equal then + /// HashableAliasingStrategy does not alias them even though their members may still be + /// Hashable-Equal and therefor maybe aliased. + func testEncoderAutoAlias_Hashable_uniqueAnchor() throws { + let differentTypesOneAnchors = SimplePair(first: + SimpleWithAnchor(nested: .init(stringValue: "it's a value"), + intValue: 52), + second: + SimpleWithoutAnchor(nested: .init(stringValue: "it's a value"), + intValue: 52)) + + let options = YAMLEncoder.Options(redundancyAliasingStrategy: HashableAliasingStrategy()) + _testRoundTrip(of: differentTypesOneAnchors, + with: options, + expectedYAML: """ + first: &simple + nested: &2 + stringValue: it's a value + intValue: &4 52 + second: + nested: *2 + intValue: *4 + + """ ) + } + + /// If types conform to YamlAnchorProviding and are NOT Hashable-Equal then + /// HashableAliasingStrategy does not alias them even though their members may still be + /// Hashable-Equal and therefor maybe aliased. + /// Note particularly that the to Simple* values here have exactly the same encoded representation, + /// they're just different types and thus not Hashable-Equal + func testEncoderAutoAlias_Hashable_noAnchor() throws { + let differentTypesNoAnchors = SimplePair(first: + SimpleWithoutAnchor2(nested: .init(stringValue: "it's a value"), + intValue: 52), + second: + SimpleWithoutAnchor(nested: .init(stringValue: "it's a value"), + intValue: 52)) + + let options = YAMLEncoder.Options(redundancyAliasingStrategy: HashableAliasingStrategy()) + _testRoundTrip(of: differentTypesNoAnchors, + with: options, + expectedYAML: """ + first: + nested: &3 + stringValue: it's a value + intValue: &5 52 + second: + nested: *3 + intValue: *5 + + """ ) + } + + /// If types conform to YamlAnchorProviding and have exactly the same encoded representation then + /// StrictEncodableAliasingStrategy alias them even though they are encoded and decoded from + /// different types. + func testEncoderAutoAlias_StrictEncodable_NoAnchors() throws { + let differentTypesNoAnchors = SimplePair(first: + SimpleWithoutAnchor2(nested: .init(stringValue: "it's a value"), + intValue: 52), + second: + SimpleWithoutAnchor(nested: .init(stringValue: "it's a value"), + intValue: 52)) + + var options = YAMLEncoder.Options() + options.redundancyAliasingStrategy = StrictEncodableAliasingStrategy() + _testRoundTrip(of: differentTypesNoAnchors, + with: options, + expectedYAML: """ + first: &2 + nested: + stringValue: it's a value + intValue: 52 + second: *2 + + """ ) + } + + /// A type used to contain values used during testing + private struct SimplePair: Hashable, Codable { + let first: First + let second: Second + } + +} + +// MARK: - Types used for Anchor encoding tests. + +private struct NestedStruct: Codable, Hashable { + let stringValue: String +} +private protocol SimpleProtocol: Codable, Hashable { + // swiftlint:disable unused_declaration + var nested: NestedStruct { get } + // swiftlint:disable unused_declaration + var intValue: Int { get } +} + +private struct SimpleWithAnchor: SimpleProtocol, YamlAnchorProviding { + let nested: NestedStruct + let intValue: Int + var yamlAnchor: Anchor? = "simple" +} + +private struct SimpleWithoutAnchor: SimpleProtocol { + let nested: NestedStruct + let intValue: Int +} + +private struct SimpleWithoutAnchor2: SimpleProtocol { + let nested: NestedStruct + let intValue: Int + // swiftlint:disable unused_declaration + var unrelatedValue: String? +} + +private struct SimpleWithStringTypeAnchorName: SimpleProtocol { + let nested: NestedStruct + let intValue: Int + var yamlAnchor: String? = "StringTypeAnchor" +} diff --git a/Tests/YamsTests/AnchorTolerancesTests.swift b/Tests/YamsTests/AnchorTolerancesTests.swift new file mode 100644 index 00000000..4886a959 --- /dev/null +++ b/Tests/YamsTests/AnchorTolerancesTests.swift @@ -0,0 +1,163 @@ +// +// AnchorTolerancesTests.swift +// Yams +// +// Created by Adora Lynch on 9/18/24. +// Copyright (c) 2024 Yams. All rights reserved. +// + +import XCTest +import Yams + +class AnchorTolerancesTests: XCTestCase { + + struct Example: Codable, Hashable { + var myCustomAnchorDeclaration: Anchor + var extraneousValue: Int + } + + /// Any type that is Encodable and contains an `Anchor`value but with a coding key different from + /// YamlAnchorProviding will not encode to a yaml anchor + /// This may be unexpected + func testAnchorEncoding_undeclaredBehavior() throws { + let expectedYAML = """ + myCustomAnchorDeclaration: I-did-it-myyyyy-way + extraneousValue: 3 + + """ + + let value = Example(myCustomAnchorDeclaration: "I-did-it-myyyyy-way", + extraneousValue: 3) + + let encoder = YAMLEncoder() + let producedYAML = try encoder.encode(value) + XCTAssertEqual(producedYAML, expectedYAML, "Produced YAML not identical to expected YAML.") + } + + /// Any type that is Encodable and contains an `Anchor`value with the same coding key as + /// YamlAnchorProviding will encode to a yaml anchor even though the type does not conform to + /// YamlAnchorProviding + /// This may be unexpected + func testAnchorEncoding_undeclaredBehavior_7() throws { + struct Example: Codable, Hashable { + var yamlAnchor: Anchor + var extraneousValue: Int + } + + let expectedYAML = """ + &I-did-it-myyyyy-way + extraneousValue: 3 + + """ + + let value = Example(yamlAnchor: "I-did-it-myyyyy-way", + extraneousValue: 3) + + let encoder = YAMLEncoder() + let producedYAML = try encoder.encode(value) + XCTAssertEqual(producedYAML, expectedYAML, "Produced YAML not identical to expected YAML.") + } + + /// Any type that is Decodable and contains an `Anchor` value but with a coding key different from + /// YamlAnchorProviding will not decode an anchor from the text representation. + /// In this case a key not found error will be thrown during decoding + /// This may be unexpected + func testAnchorDecoding_undeclaredBehavior_1() throws { + let sourceYAML = """ + &a-different-tag + extraneousValue: 3 + """ + let decoder = YAMLDecoder() + XCTAssertThrowsError(try decoder.decode(Example.self, from: sourceYAML)) + // error is ^^ key not found, "myCustomAnchorDeclaration" + } + + /// Any type that is Decodable and contains an `Anchor` value but with a coding key different from + /// YamlAnchorProviding will not decode an anchor from the text representation. + /// In this case the decoding is successful and the anchor is respected by the parser. + /// This may be unexpected + func testAnchorDecoding_undeclaredBehavior_6() throws { + struct Example: Codable, Hashable { + var myCustomAnchorDeclaration: Anchor? + var extraneousValue: Int + } + let sourceYAML = """ + &a-different-tag + extraneousValue: 3 + + """ + + let expectedValue = Example(myCustomAnchorDeclaration: nil, + extraneousValue: 3) + + let decoder = YAMLDecoder() + let decodedValue = try decoder.decode(Example.self, from: sourceYAML) + XCTAssertEqual(decodedValue, expectedValue, "\(Example.self) did not round-trip to an equal value.") + } + + /// Any type that is Decodable and contains an `Anchor` value with the same coding key as + /// YamlAnchorProviding will decode an anchor from the text representation even though the type does + /// not conform to YamlAnchorCoding + /// This may be unexpected + func testAnchorDecoding_undeclaredBehavior_8() throws { + struct Example: Codable, Hashable { + var yamlAnchor: Anchor? + var extraneousValue: Int + } + let sourceYAML = """ + &a-different-tag + extraneousValue: 3 + + """ + + let expectedValue = Example(yamlAnchor: "a-different-tag", + extraneousValue: 3) + + let decoder = YAMLDecoder() + let decodedValue = try decoder.decode(Example.self, from: sourceYAML) + XCTAssertEqual(decodedValue, expectedValue, "\(Example.self) did not round-trip to an equal value.") + } + + /// Any type that is Decodable and contains an `Anchor` value but with a coding key different from + /// YamlAnchorProviding will not decode an anchor from the text representation. + /// In this case the decoding is successful and the anchor is respected by the parser. + /// This is expected behavior, but in a strange situation. + func testAnchorDecoding_undeclaredBehavior_3() throws { + let sourceYAML = """ + &a-different-tag + extraneousValue: 3 + myCustomAnchorDeclaration: deliver-us-from-evil + + """ + let expectedValue = Example(myCustomAnchorDeclaration: "deliver-us-from-evil", + extraneousValue: 3) + + let decoder = YAMLDecoder() + let decodedValue = try decoder.decode(Example.self, from: sourceYAML) + XCTAssertEqual(decodedValue, expectedValue, "\(Example.self) did not round-trip to an equal value.") + + } + + /// Any type that is Decodable and contains an `Anchor` value but with a coding key different from + /// YamlAnchorProviding will not decode an anchor from the text representation. + /// In this case the decoding is successful even though and the `Anchor` was initialized with + /// unsupported characters. The anchor is respected by the parser. + /// This is expected behavior, but in a strange situation. + func testAnchorDecoding_undeclaredBehavior_2() throws { + let sourceYAML = """ + &a-different-tag + extraneousValue: 3 + myCustomAnchorDeclaration: "deliver us from |()evil" + + """ + + let expectedValue = Example(myCustomAnchorDeclaration: "deliver us from |()evil", + extraneousValue: 3) + + let decoder = YAMLDecoder() + let decodedValue = try decoder.decode(Example.self, from: sourceYAML) + XCTAssertEqual(decodedValue, expectedValue, "\(Example.self) did not round-trip to an equal value.") + + } + +} diff --git a/Tests/YamsTests/CMakeLists.txt b/Tests/YamsTests/CMakeLists.txt index daef104e..8b223cac 100644 --- a/Tests/YamsTests/CMakeLists.txt +++ b/Tests/YamsTests/CMakeLists.txt @@ -1,4 +1,6 @@ add_library(YamsTests + AnchorCodingTests.swift + AnchorTolerancesTests.swift ConstructorTests.swift EmitterTests.swift EncoderTests.swift @@ -9,6 +11,8 @@ add_library(YamsTests ResolverTests.swift SpecTests.swift StringTests.swift + TagCodingTests.swift + TagTolerancesTests.swift TestHelper.swift TopLevelDecoderTests.swift YamlErrorTests.swift) diff --git a/Tests/YamsTests/EncoderTests.swift b/Tests/YamsTests/EncoderTests.swift index e5a06dd2..0d85a3a2 100644 --- a/Tests/YamsTests/EncoderTests.swift +++ b/Tests/YamsTests/EncoderTests.swift @@ -405,35 +405,6 @@ class EncoderTests: XCTestCase { // swiftlint:disable:this type_body_length // MARK: - Helper Functions - private func _testRoundTrip(of value: T, - with options: YAMLEncoder.Options = .init(), - expectedYAML yamlString: String? = nil, - file: StaticString = #file, - line: UInt = #line) where T: Codable, T: Equatable { - do { - let encoder = YAMLEncoder() - encoder.options = options - let producedYAML = try encoder.encode(value) - - if let expectedYAML = yamlString { - XCTAssertEqual(producedYAML, expectedYAML, "Produced YAML not identical to expected YAML.", - file: (file), line: line) - } - - let decoder = YAMLDecoder() - let decoded = try decoder.decode(T.self, from: producedYAML) - XCTAssertEqual(decoded, value, "\(T.self) did not round-trip to an equal value.", - file: (file), line: line) - - } catch let error as EncodingError { - XCTFail("Failed to encode \(T.self) from YAML by error: \(error)", file: (file), line: line) - } catch let error as DecodingError { - XCTFail("Failed to decode \(T.self) from YAML by error: \(error)", file: (file), line: line) - } catch { - XCTFail("Rout trip test of \(T.self) failed with error: \(error)", file: (file), line: line) - } - } - private func _testDecode(of type: T.Type, from string: String, expectedValue value: T?, @@ -468,6 +439,36 @@ class EncoderTests: XCTestCase { // swiftlint:disable:this type_body_length } } +internal func _testRoundTrip(of value: T, + with options: YAMLEncoder.Options = .init(), + expectedYAML yamlString: String? = nil, + file: StaticString = #file, + line: UInt = #line) +where T: Codable, T: Equatable { + do { + let encoder = YAMLEncoder() + encoder.options = options + let producedYAML = try encoder.encode(value) + + if let expectedYAML = yamlString { + XCTAssertEqual(producedYAML, expectedYAML, "Produced YAML not identical to expected YAML.", + file: (file), line: line) + } + + let decoder = YAMLDecoder() + let decoded = try decoder.decode(T.self, from: producedYAML) + XCTAssertEqual(decoded, value, "\(T.self) did not round-trip to an equal value.", + file: (file), line: line) + + } catch let error as EncodingError { + XCTFail("Failed to encode \(T.self) from YAML by error: \(error)", file: (file), line: line) + } catch let error as DecodingError { + XCTFail("Failed to decode \(T.self) from YAML by error: \(error)", file: (file), line: line) + } catch { + XCTFail("Rout trip test of \(T.self) failed with error: \(error)", file: (file), line: line) + } +} + // MARK: - Helper Global Functions public func expectEqual( _ expected: T, _ actual: T, diff --git a/Tests/YamsTests/TagCodingTests.swift b/Tests/YamsTests/TagCodingTests.swift new file mode 100644 index 00000000..6f85828d --- /dev/null +++ b/Tests/YamsTests/TagCodingTests.swift @@ -0,0 +1,260 @@ +// +// TagCodingTests.swift +// Yams +// +// Created by Adora Lynch on 9/18/24. +// Copyright (c) 2024 Yams. All rights reserved. +// + +import XCTest +import Yams + +class TagCodingTests: XCTestCase { + + /// Test the encoding of a yaml tag using a type that conforms to YamlTagProviding + func testYamlTagProviding_valuePresent() throws { + let simpleStruct = SimpleWithTag(nested: .init(stringValue: "it's a value"), intValue: 52) + + _testRoundTrip(of: simpleStruct, + expectedYAML: """ + ! + nested: + stringValue: it's a value + intValue: 52 + + """ ) // ^ the Yams.Tag is encoded as a yaml tag + } + + /// Test the encoding of a a type that does not conform to YamlTagProviding but none the less declares + /// a coding member with the same name + func testStringTypeTagName_valuePresent() throws { + let simpleStruct = SimpleWithStringTypeTagName(nested: .init(stringValue: "it's a value"), + intValue: 52, + yamlTag: "but typed as a string") + + _testRoundTrip(of: simpleStruct, + expectedYAML: """ + nested: + stringValue: it's a value + intValue: 52 + yamlTag: but typed as a string + + """ ) // ^ the member is _not_ treated as an tag + } + + /// Nothing interesting happens when a type does not conform to YamlTagProviding none the less + /// declares a coding member with the same name but that value is nil + func testStringTypeTagName_valueNotPresent() throws { + let expectedStruct = SimpleWithStringTypeTagName(nested: .init(stringValue: "it's a value"), + intValue: 52, + yamlTag: nil) + _testRoundTrip(of: expectedStruct, + expectedYAML: """ + nested: + stringValue: it's a value + intValue: 52 + + """) + } + + /// This test documents some undesirable behavior, but in an unlikely circumstance. + /// If the decoded type does not conform to YamlTagProviding it can still have a coding key called + /// `yamlTag` + /// If Yams tries to decode such a type AND the document has a nil value for `yamlTag` AND the + /// parent context is a mapping AND that mapping has an actual tag (in the document) + /// THEN Yams wrongly tries to decode the tag as the declared type of key `yamlTag`. + /// If that declared type can be decoded from a scalar string value (like String and RawRepresentable + /// where RawValue == String) then the decoding will actually succeed. + /// Which effectively injects an unexpected value into the decoded type. + func testStringTypeTagName_withTagPresent_valueNil() throws { + let expectedStruct = SimpleWithStringTypeTagName(nested: .init(stringValue: "it's a value"), + intValue: 52, + yamlTag: nil) + let decoder = YAMLDecoder() + let data = """ + ! + nested: + stringValue: it's a value + intValue: 52 + + """.data(using: decoder.encoding.swiftStringEncoding)! + + let decodedStruct = try decoder.decode(SimpleWithStringTypeTagName.self, from: data) + + let fixBulletin = "YESS!!! YOU FIXED IT! See \(#file):\(#line) for explanation." + + // begin assertions of known-but-undesirable behavior + XCTAssertNotEqual(decodedStruct, expectedStruct, fixBulletin) // We wish this was equal + XCTAssertEqual(decodedStruct.yamlTag, "An:Actual:Tag", fixBulletin) // we wish .yamlTag was nil + // end assertions of known-but-undesirable behavior + + // Check the remainder of the properties that the above confusion did not involve + XCTAssertEqual(decodedStruct.nested, expectedStruct.nested) + XCTAssertEqual(decodedStruct.intValue, expectedStruct.intValue) + } +} + +class TagWithAnchorCodingTests: XCTestCase { + + /// If types conform to YamlTagProviding and are Hashable-Equal then HashableAliasingStrategy aliases them + func testEncoderAutoAlias_Hashable_duplicateValue_commonTag() throws { + let simpleStruct = SimpleWithTag(nested: .init(stringValue: "it's a value"), intValue: 52) + let duplicatedStructArray = [simpleStruct, simpleStruct] + + let options = YAMLEncoder.Options(redundancyAliasingStrategy: HashableAliasingStrategy()) + _testRoundTrip(of: duplicatedStructArray, + with: options, + expectedYAML: """ + - &2 ! + nested: + stringValue: it's a value + intValue: 52 + - *2 + + """ ) + } + + /// If types conform to YamlTagProviding and are NOT Hashable-Equal then HashableAliasingStrategy + /// does not alias them + /// even though their members may still be Hashable-Equal and therefor maybe aliased. + func testEncoderAutoAlias_Hashable_uniqueTag() throws { + let differentTypesOneTags = SimplePair(first: + SimpleWithTag(nested: .init(stringValue: "it's a value"), + intValue: 52), + second: + SimpleWithoutTag(nested: .init(stringValue: "it's a value"), + intValue: 52)) + + let options = YAMLEncoder.Options(redundancyAliasingStrategy: HashableAliasingStrategy()) + _testRoundTrip(of: differentTypesOneTags, + with: options, + expectedYAML: """ + first: ! + nested: &3 + stringValue: it's a value + intValue: &5 52 + second: + nested: *3 + intValue: *5 + + """ ) + } + + /// If types conform to YamlTagProviding can declare to have the same tag and still be NOT + /// Hashable-Equal then HashableAliasingStrategy does not alias them + /// even though their members may still be Hashable-Equal and therefor maybe aliased. + func testEncoderAutoAlias_Hashable_distinctValues_commonTag() throws { + let differentTypesOneTags = SimplePair(first: + SimpleWithTag(nested: .init(stringValue: "it's a value"), + intValue: 52), + second: + SimpleWithTag2(nested: .init(stringValue: "it's a value"), + intValue: 52)) + + let options = YAMLEncoder.Options(redundancyAliasingStrategy: HashableAliasingStrategy()) + _testRoundTrip(of: differentTypesOneTags, + with: options, + expectedYAML: """ + first: ! + nested: &3 + stringValue: it's a value + intValue: &5 52 + second: ! + nested: *3 + intValue: *5 + + """ ) + } + + /// If different types conform to YamlTagProviding they can declare to have the same tag and further, + /// have exactly the same encoded representation. + /// In thisi case StrictEncodableAliasingStrategy will still alias them even though they are encoded and + /// decoded from different types. + func testEncoderAutoAlias_StrictEncodable_distinctValues_commonTag() throws { + let differentTypesOneTags = SimplePair(first: + SimpleWithTag(nested: .init(stringValue: "it's a value"), + intValue: 52), + second: + SimpleWithTag2(nested: .init(stringValue: "it's a value"), + intValue: 52)) + + var options = YAMLEncoder.Options() + options.redundancyAliasingStrategy = StrictEncodableAliasingStrategy() + _testRoundTrip(of: differentTypesOneTags, + with: options, + expectedYAML: """ + first: &2 ! + nested: + stringValue: it's a value + intValue: 52 + second: *2 + + """ ) + } + + /// If types conform to YamlTagProviding and YamlAnchorProviding, both are respected. + func testEncoderAutoAlias_Hashable_commonTagAndAnchor() throws { + let simpleStruct = SimpleWithTagAndAnchor(nested: .init(stringValue: "it's a value"), intValue: 52) + let duplicatedStructArray = [simpleStruct, simpleStruct] + + let options = YAMLEncoder.Options(redundancyAliasingStrategy: HashableAliasingStrategy()) + _testRoundTrip(of: duplicatedStructArray, + with: options, + expectedYAML: """ + - &simple-Anchor ! + nested: + stringValue: it's a value + intValue: 52 + - *simple-Anchor + + """ ) + } + + /// A type used to contain values used during testing + private struct SimplePair: Hashable, Codable { + let first: First + let second: Second + } + +} +// MARK: - Types used for Tag encoding tests. + +private struct NestedStruct: Codable, Hashable { + let stringValue: String +} +private protocol SimpleProtocol: Codable, Hashable { + // swiftlint:disable unused_declaration + var nested: NestedStruct { get } + // swiftlint:disable unused_declaration + var intValue: Int { get } +} + +private struct SimpleWithTag: SimpleProtocol, YamlTagProviding { + let nested: NestedStruct + let intValue: Int + var yamlTag: Tag? = "simple" +} + +private struct SimpleWithTag2: SimpleProtocol, YamlTagProviding { + let nested: NestedStruct + let intValue: Int + var yamlTag: Tag? = "simple" +} + +private struct SimpleWithoutTag: SimpleProtocol { + let nested: NestedStruct + let intValue: Int +} + +private struct SimpleWithStringTypeTagName: SimpleProtocol { + let nested: NestedStruct + let intValue: Int + var yamlTag: String? = "StringTypeTag" +} + +private struct SimpleWithTagAndAnchor: SimpleProtocol, YamlTagProviding, YamlAnchorProviding { + let nested: NestedStruct + let intValue: Int + var yamlTag: Tag? = "simple:Tag" + var yamlAnchor: Anchor? = "simple-Anchor" +} diff --git a/Tests/YamsTests/TagTolerancesTests.swift b/Tests/YamsTests/TagTolerancesTests.swift new file mode 100644 index 00000000..b91109bb --- /dev/null +++ b/Tests/YamsTests/TagTolerancesTests.swift @@ -0,0 +1,180 @@ +// +// TagTolerancesTests.swift +// Yams +// +// Created by Adora Lynch on 9/18/24. +// Copyright (c) 2024 Yams. All rights reserved. +// + +import XCTest +import Yams + +class TagTolerancesTests: XCTestCase { + + struct Example: Codable, Hashable { + var myCustomTagDeclaration: Tag + var extraneousValue: Int + } + + /// Any type that is Encodable and contains an `Tag`value but with a coding key different from + /// YamlTagProviding will not encode to a yaml tag + /// This may be unexpected + func testTagEncoding_undeclaredBehavior() throws { + let expectedYAML = """ + myCustomTagDeclaration: I-did-it-myyyyy-way + extraneousValue: 3 + + """ + + let value = Example(myCustomTagDeclaration: "I-did-it-myyyyy-way", + extraneousValue: 3) + + let encoder = YAMLEncoder() + let producedYAML = try encoder.encode(value) + XCTAssertEqual(producedYAML, expectedYAML, "Produced YAML not identical to expected YAML.") + } + + /// Any type that is Encodable and contains an `Tag`value with the same coding key as + /// YamlTagProviding will encode to a yaml tag even though the type does not conform to + /// YamlTagProviding + /// This may be unexpected + func testTagEncoding_undeclaredBehavior_7() throws { + struct Example: Codable, Hashable { + var yamlTag: Tag + var extraneousValue: Int + } + let expectedYAML = """ + ! + extraneousValue: 3 + + """ + + let value = Example(yamlTag: "I-did-it-myyyyy-way", + extraneousValue: 3) + + let encoder = YAMLEncoder() + let producedYAML = try encoder.encode(value) + XCTAssertEqual(producedYAML, expectedYAML, "Produced YAML not identical to expected YAML.") + } + + /// Tags are oddly permissive, but some characters do get escaped + /// This may be unexpected + func testTagEncoding_undeclaredBehavior_4() throws { + struct Example: Codable, Hashable, YamlTagProviding { + var yamlTag: Tag? + var extraneousValue: Int + } + + let expectedYAML = """ + ! + extraneousValue: 3 + + """ + + let value = Example(yamlTag: "I-did-it-[]-*-|-!-()way", + extraneousValue: 3) + + let encoder = YAMLEncoder() + let producedYAML = try encoder.encode(value) + XCTAssertEqual(producedYAML, expectedYAML, "Produced YAML not identical to expected YAML.") + } + + /// Any type that is Decodable and contains an `Tag` value but with a coding key different from + /// YamlTagProviding will not decode an tag from the text representation. + /// In this case a key not found error will be thrown during decoding + /// This may be unexpected + func testTagDecoding_undeclaredBehavior_1() throws { + let sourceYAML = """ + ! + extraneousValue: 3 + + """ + let decoder = YAMLDecoder() + XCTAssertThrowsError(try decoder.decode(Example.self, from: sourceYAML)) + // error is ^^ key not found, "myCustomTagDeclaration" + } + + /// Any type that is Decodable and contains an `Tag` value but with a coding key different from + /// YamlTagProviding will not decode an tag from the text representation. + /// This may be unexpected + func testTagDecoding_undeclaredBehavior_6() throws { + struct Example: Codable, Hashable { + var myCustomTagDeclaration: Tag? + var extraneousValue: Int + } + let sourceYAML = """ + ! + extraneousValue: 3 + + """ + + let expectedValue = Example(myCustomTagDeclaration: nil, + extraneousValue: 3) + + let decoder = YAMLDecoder() + let decodedValue = try decoder.decode(Example.self, from: sourceYAML) + XCTAssertEqual(decodedValue, expectedValue, "\(Example.self) did not round-trip to an equal value.") + } + + /// Any type that is Decodable and contains an `Tag` value with the same coding key as YamlTagProviding + /// will decode an tag from the text representatio even though the type does not conform to YamlTagCoding. + /// This may be unexpected + func testTagDecoding_undeclaredBehavior_8() throws { + struct Example: Codable, Hashable { + var yamlTag: Tag? + var extraneousValue: Int + } + let sourceYAML = """ + ! + extraneousValue: 3 + + """ + + let expectedValue = Example(yamlTag: "a-different-tag", + extraneousValue: 3) + + let decoder = YAMLDecoder() + let decodedValue = try decoder.decode(Example.self, from: sourceYAML) + XCTAssertEqual(decodedValue, expectedValue, "\(Example.self) did not round-trip to an equal value.") + } + + /// Any type that is Decodable and contains an `Tag` value but with a coding key different from YamlTagProviding + /// will not decode an tag from the text representation. + /// This is expected behavior, but in a strange situation. + func testTagDecoding_undeclaredBehavior_3() throws { + let sourceYAML = """ + ! + extraneousValue: 3 + myCustomTagDeclaration: deliver-us-from-evil + + """ + let expectedValue = Example(myCustomTagDeclaration: "deliver-us-from-evil", + extraneousValue: 3) + + let decoder = YAMLDecoder() + let decodedValue = try decoder.decode(Example.self, from: sourceYAML) + XCTAssertEqual(decodedValue, expectedValue, "\(Example.self) did not round-trip to an equal value.") + + } + + /// Any type that is Decodable and contains an `Tag` value but with a coding key different from YamlTagProviding + /// will not decode an tag from the text representation. + /// This is expected behavior, but in a strange situation. + func testTagDecoding_undeclaredBehavior_2() throws { + let sourceYAML = """ + ! + extraneousValue: 3 + myCustomTagDeclaration: "deliver us from |()evil" + + """ + + let expectedValue = Example(myCustomTagDeclaration: "deliver us from |()evil", + extraneousValue: 3) + + let decoder = YAMLDecoder() + let decodedValue = try decoder.decode(Example.self, from: sourceYAML) + XCTAssertEqual(decodedValue, expectedValue, "\(Example.self) did not round-trip to an equal value.") + + } + +} diff --git a/Yams.xcodeproj/project.pbxproj b/Yams.xcodeproj/project.pbxproj index a69ec6e4..f6cc6ca1 100644 --- a/Yams.xcodeproj/project.pbxproj +++ b/Yams.xcodeproj/project.pbxproj @@ -34,6 +34,16 @@ 6CF0253A1E9D12680061FB47 /* MarkTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CF025391E9D12680061FB47 /* MarkTests.swift */; }; 6CF6CE091E0E3B1000CB87D4 /* PerformanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CF6CE081E0E3B1000CB87D4 /* PerformanceTests.swift */; }; 8FA807DC24B250EF0082215D /* TopLevelDecoderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FA807DB24B250EF0082215D /* TopLevelDecoderTests.swift */; }; + 8FBD7F7F2CB70C8900271BB9 /* Node.Alias.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FBD7F7B2CB70C8900271BB9 /* Node.Alias.swift */; }; + 8FBD7F802CB70C8900271BB9 /* YamlAnchorProviding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FBD7F7D2CB70C8900271BB9 /* YamlAnchorProviding.swift */; }; + 8FBD7F812CB70C8900271BB9 /* YamlTagProviding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FBD7F7E2CB70C8900271BB9 /* YamlTagProviding.swift */; }; + 8FBD7F822CB70C8900271BB9 /* RedundancyAliasingStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FBD7F7C2CB70C8900271BB9 /* RedundancyAliasingStrategy.swift */; }; + 8FBD7F832CB70C8900271BB9 /* Anchor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FBD7F7A2CB70C8900271BB9 /* Anchor.swift */; }; + 8FBD7F892CB70CFB00271BB9 /* NodeDecoderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FBD7F862CB70CFB00271BB9 /* NodeDecoderTests.swift */; }; + 8FBD7F8A2CB70CFB00271BB9 /* AnchorCodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FBD7F842CB70CFB00271BB9 /* AnchorCodingTests.swift */; }; + 8FBD7F8B2CB70CFB00271BB9 /* AnchorTolerancesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FBD7F852CB70CFB00271BB9 /* AnchorTolerancesTests.swift */; }; + 8FBD7F8C2CB70CFB00271BB9 /* TagTolerancesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FBD7F882CB70CFB00271BB9 /* TagTolerancesTests.swift */; }; + 8FBD7F8D2CB70CFB00271BB9 /* TagCodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FBD7F872CB70CFB00271BB9 /* TagCodingTests.swift */; }; E8EDB8851DE2181B0062268D /* api.c in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_10 /* api.c */; }; E8EDB8871DE2181B0062268D /* emitter.c in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_12 /* emitter.c */; }; E8EDB8891DE2181B0062268D /* parser.c in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_14 /* parser.c */; }; @@ -87,6 +97,16 @@ 6CF6CE071E0E3A5900CB87D4 /* Fixtures */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Fixtures; sourceTree = ""; }; 6CF6CE081E0E3B1000CB87D4 /* PerformanceTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PerformanceTests.swift; sourceTree = ""; }; 8FA807DB24B250EF0082215D /* TopLevelDecoderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopLevelDecoderTests.swift; sourceTree = ""; }; + 8FBD7F7A2CB70C8900271BB9 /* Anchor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Anchor.swift; sourceTree = ""; }; + 8FBD7F7B2CB70C8900271BB9 /* Node.Alias.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Node.Alias.swift; sourceTree = ""; }; + 8FBD7F7C2CB70C8900271BB9 /* RedundancyAliasingStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedundancyAliasingStrategy.swift; sourceTree = ""; }; + 8FBD7F7D2CB70C8900271BB9 /* YamlAnchorProviding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YamlAnchorProviding.swift; sourceTree = ""; }; + 8FBD7F7E2CB70C8900271BB9 /* YamlTagProviding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YamlTagProviding.swift; sourceTree = ""; }; + 8FBD7F842CB70CFB00271BB9 /* AnchorCodingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnchorCodingTests.swift; sourceTree = ""; }; + 8FBD7F852CB70CFB00271BB9 /* AnchorTolerancesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnchorTolerancesTests.swift; sourceTree = ""; }; + 8FBD7F862CB70CFB00271BB9 /* NodeDecoderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeDecoderTests.swift; sourceTree = ""; }; + 8FBD7F872CB70CFB00271BB9 /* TagCodingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagCodingTests.swift; sourceTree = ""; }; + 8FBD7F882CB70CFB00271BB9 /* TagTolerancesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagTolerancesTests.swift; sourceTree = ""; }; OBJ_10 /* api.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = api.c; sourceTree = ""; }; OBJ_12 /* emitter.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = emitter.c; sourceTree = ""; }; OBJ_14 /* parser.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = parser.c; sourceTree = ""; }; @@ -131,21 +151,26 @@ OBJ_21 /* Yams */ = { isa = PBXGroup; children = ( + 8FBD7F7A2CB70C8900271BB9 /* Anchor.swift */, 6C0D2A351E0A934B00C45545 /* Constructor.swift */, 6C4AF31E1EBE14A1008775BC /* Decoder.swift */, 6CE603971E13502E00A13D8D /* Emitter.swift */, 6C788A021EB876C4005386F0 /* Encoder.swift */, 6CF025371E9CF4380061FB47 /* Mark.swift */, 6C6834C71E0281880047B4D1 /* Node.swift */, - 6C0409AB1E607E9900C95D83 /* Node.Scalar.swift */, + 8FBD7F7B2CB70C8900271BB9 /* Node.Alias.swift */, 6C0409A71E602E9A00C95D83 /* Node.Mapping.swift */, + 6C0409AB1E607E9900C95D83 /* Node.Scalar.swift */, 6C0409A91E6033DF00C95D83 /* Node.Sequence.swift */, 6C6834CB1E0283980047B4D1 /* Parser.swift */, + 8FBD7F7C2CB70C8900271BB9 /* RedundancyAliasingStrategy.swift */, 6CC2E33E1E22347B00F62269 /* Representer.swift */, 6C6834D21E02B9760047B4D1 /* Resolver.swift */, 6C4A22061DF8553C002A0206 /* String+Yams.swift */, 6C6834C91E0281D90047B4D1 /* Tag.swift */, + 8FBD7F7D2CB70C8900271BB9 /* YamlAnchorProviding.swift */, OBJ_22 /* YamlError.swift */, + 8FBD7F7E2CB70C8900271BB9 /* YamlTagProviding.swift */, 6CBAEE191E3839500021BF87 /* Yams.h */, ); name = Yams; @@ -164,17 +189,22 @@ OBJ_24 /* YamsTests */ = { isa = PBXGroup; children = ( - 6CF6CE071E0E3A5900CB87D4 /* Fixtures */, + 8FBD7F842CB70CFB00271BB9 /* AnchorCodingTests.swift */, + 8FBD7F852CB70CFB00271BB9 /* AnchorTolerancesTests.swift */, 6C0488ED1E0CBD56006F9F80 /* ConstructorTests.swift */, 6C0A00D41E152D6200222704 /* EmitterTests.swift */, 6C788A001EB87232005386F0 /* EncoderTests.swift */, + 6CF6CE071E0E3A5900CB87D4 /* Fixtures */, 6CF025391E9D12680061FB47 /* MarkTests.swift */, + 8FBD7F862CB70CFB00271BB9 /* NodeDecoderTests.swift */, 6C3C90B81E0FFB6B009DEFE8 /* NodeTests.swift */, 6CF6CE081E0E3B1000CB87D4 /* PerformanceTests.swift */, 6C78C5631E29B1CE0096215F /* RepresenterTests.swift */, 6C6834D41E02BC1F0047B4D1 /* ResolverTests.swift */, 6C6834D01E0297390047B4D1 /* SpecTests.swift */, 6C4A22081DF855BB002A0206 /* StringTests.swift */, + 8FBD7F872CB70CFB00271BB9 /* TagCodingTests.swift */, + 8FBD7F882CB70CFB00271BB9 /* TagTolerancesTests.swift */, 6C0488EB1E0BE113006F9F80 /* TestHelper.swift */, 8FA807DB24B250EF0082215D /* TopLevelDecoderTests.swift */, OBJ_25 /* YamlErrorTests.swift */, @@ -359,6 +389,11 @@ 6C4AF3201EBE1705008775BC /* Decoder.swift in Sources */, 6C0409AC1E607E9900C95D83 /* Node.Scalar.swift in Sources */, 6C4A22071DF8553C002A0206 /* String+Yams.swift in Sources */, + 8FBD7F7F2CB70C8900271BB9 /* Node.Alias.swift in Sources */, + 8FBD7F802CB70C8900271BB9 /* YamlAnchorProviding.swift in Sources */, + 8FBD7F812CB70C8900271BB9 /* YamlTagProviding.swift in Sources */, + 8FBD7F822CB70C8900271BB9 /* RedundancyAliasingStrategy.swift in Sources */, + 8FBD7F832CB70C8900271BB9 /* Anchor.swift in Sources */, E8EDB8891DE2181B0062268D /* parser.c in Sources */, 6CF025381E9CF4380061FB47 /* Mark.swift in Sources */, OBJ_50 /* YamlError.swift in Sources */, @@ -376,6 +411,11 @@ 6CF6CE091E0E3B1000CB87D4 /* PerformanceTests.swift in Sources */, 6C4A22091DF855BB002A0206 /* StringTests.swift in Sources */, 6C0488EC1E0BE113006F9F80 /* TestHelper.swift in Sources */, + 8FBD7F892CB70CFB00271BB9 /* NodeDecoderTests.swift in Sources */, + 8FBD7F8A2CB70CFB00271BB9 /* AnchorCodingTests.swift in Sources */, + 8FBD7F8B2CB70CFB00271BB9 /* AnchorTolerancesTests.swift in Sources */, + 8FBD7F8C2CB70CFB00271BB9 /* TagTolerancesTests.swift in Sources */, + 8FBD7F8D2CB70CFB00271BB9 /* TagCodingTests.swift in Sources */, 6C78C5651E29B27D0096215F /* RepresenterTests.swift in Sources */, 6C3C90B91E0FFB6B009DEFE8 /* NodeTests.swift in Sources */, 6C0A00D51E152D6200222704 /* EmitterTests.swift in Sources */,