From 5c3de786bfa8398323dba65c35e178c1139e63a3 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Fri, 5 Jun 2026 12:23:52 +0200 Subject: [PATCH] feat(coding): add unkeyed typed conversion --- CHANGELOG.md | 2 + README.md | 10 +- Sources/Coding/IndexCodingKey.swift | 19 ++ Sources/Decoding/Decoding.Decoder.swift | 19 +- Sources/Decoding/Decoding.Error.swift | 9 +- .../Decoding/Decoding.KeyedContainer.swift | 16 +- .../Decoding/Decoding.UnkeyedContainer.swift | 160 ++++++++++ Sources/Encoding/Encoding.Encoder.swift | 143 +++------ Sources/Encoding/Encoding.Error.swift | 6 - .../Encoding/Encoding.KeyedContainer.swift | 7 +- .../Encoding/Encoding.UnkeyedContainer.swift | 160 ++++++++++ ...Encoding.UnsupportedUnkeyedContainer.swift | 123 ------- Tests/CodingTests.swift | 29 -- Tests/KeyedCodingTests.swift | 47 +-- Tests/UnkeyedCodingTests.swift | 301 ++++++++++++++++++ docs/DESIGN.md | 12 +- 16 files changed, 728 insertions(+), 335 deletions(-) create mode 100644 Sources/Coding/IndexCodingKey.swift create mode 100644 Sources/Decoding/Decoding.UnkeyedContainer.swift create mode 100644 Sources/Encoding/Encoding.UnkeyedContainer.swift delete mode 100644 Sources/Encoding/Encoding.UnsupportedUnkeyedContainer.swift create mode 100644 Tests/UnkeyedCodingTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index e7b5a26..48f666f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add keyed typed Decodable and Encodable conversion for mapping-backed structs. - Validate YAML values before typed decoding so duplicate keys are rejected deterministically. +- Add unkeyed typed Decodable and Encodable conversion for sequences, including + nested sequences and sequence properties on keyed structs. ### Changed diff --git a/README.md b/README.md index bc4ccce..b503adb 100644 --- a/README.md +++ b/README.md @@ -101,8 +101,8 @@ let yaml = PureYAML.dump(document) try PureYAML.validate(document) ``` -Typed conversion is available for scalar values and keyed mapping-backed -structs: +Typed conversion is available for scalar values, keyed mapping-backed structs, +and unkeyed sequences: ```swift struct Info: Codable { @@ -115,12 +115,14 @@ let info = try PureYAML.decode(Info.self, from: """ title: Example API version: 1 """) +let tags = try PureYAML.decode([String].self, from: """ +- Swift +- YAML +""") let value = try PureYAML.encode(42) let yaml = try PureYAML.encodeToYAML(info) ``` -Unkeyed sequences are still explicit future work; attempts to decode or encode -arrays through typed conversion report exact path-aware errors. Typed decoding runs the default validator before constructing Swift values, so duplicate mapping keys are reported as validation issues instead of silently choosing one value. diff --git a/Sources/Coding/IndexCodingKey.swift b/Sources/Coding/IndexCodingKey.swift new file mode 100644 index 0000000..ab08325 --- /dev/null +++ b/Sources/Coding/IndexCodingKey.swift @@ -0,0 +1,19 @@ +struct IndexCodingKey: CodingKey { + var stringValue: String + var intValue: Int? + + init(index: Int) { + stringValue = "\(index)" + intValue = index + } + + init?(stringValue: String) { + self.stringValue = stringValue + intValue = nil + } + + init?(intValue: Int) { + stringValue = "\(intValue)" + self.intValue = intValue + } +} diff --git a/Sources/Decoding/Decoding.Decoder.swift b/Sources/Decoding/Decoding.Decoder.swift index d484a40..c96efea 100644 --- a/Sources/Decoding/Decoding.Decoder.swift +++ b/Sources/Decoding/Decoding.Decoder.swift @@ -40,9 +40,19 @@ public extension PureYAML.Decoding { public func unkeyedContainer() throws -> any UnkeyedDecodingContainer { try validateIfNeeded() - throw Error.unsupportedContainer( - kind: "unkeyed", - path: PureYAML.Validation.Path(codingPath: codingPath), + guard case let .sequence(values) = value else { + throw Error.typeMismatch( + expected: "sequence", + actual: value.kindDescription, + path: PureYAML.Validation.Path(codingPath: codingPath), + ) + } + + return UnkeyedDecodingContainerImpl( + values: values, + codingPath: codingPath, + userInfo: userInfo, + validatesInput: validatesInput, ) } @@ -52,6 +62,7 @@ public extension PureYAML.Decoding { value: value, codingPath: codingPath, userInfo: userInfo, + validatesInput: validatesInput, ) } @@ -89,6 +100,7 @@ private struct SingleValueContainer: SingleValueDecodingContainer { let value: PureYAML.Model.Value let codingPath: [any CodingKey] let userInfo: [CodingUserInfoKey: Any] + let validatesInput: Bool func decodeNil() -> Bool { value == .null @@ -171,6 +183,7 @@ private struct SingleValueContainer: SingleValueDecodingContainer { value: value, codingPath: codingPath, userInfo: userInfo, + validatesInput: validatesInput, )) } diff --git a/Sources/Decoding/Decoding.Error.swift b/Sources/Decoding/Decoding.Error.swift index 4f79d40..3d6d2a7 100644 --- a/Sources/Decoding/Decoding.Error.swift +++ b/Sources/Decoding/Decoding.Error.swift @@ -14,10 +14,7 @@ public extension PureYAML.Decoding { key: String, path: PureYAML.Validation.Path, ) - case unsupportedContainer( - kind: String, - path: PureYAML.Validation.Path, - ) + case valueNotFound(path: PureYAML.Validation.Path) public var description: String { switch self { @@ -27,8 +24,8 @@ public extension PureYAML.Decoding { "\(type) value is outside PureYAML integer range at \(path)" case let .keyNotFound(key, path): "Missing required key '\(key)' at \(path)" - case let .unsupportedContainer(kind, path): - "Unsupported \(kind) decoding container at \(path)" + case let .valueNotFound(path): + "No YAML value found at \(path)" } } } diff --git a/Sources/Decoding/Decoding.KeyedContainer.swift b/Sources/Decoding/Decoding.KeyedContainer.swift index 7eb1e9a..df4cc0b 100644 --- a/Sources/Decoding/Decoding.KeyedContainer.swift +++ b/Sources/Decoding/Decoding.KeyedContainer.swift @@ -100,9 +100,19 @@ struct KeyedDecodingContainerImpl: KeyedDecodingContainerProtoco func nestedUnkeyedContainer(forKey key: Key) throws -> any UnkeyedDecodingContainer { let value = try value(for: key) try validateIfNeeded(value, forKey: key) - throw PureYAML.Decoding.Error.unsupportedContainer( - kind: "unkeyed", - path: PureYAML.Validation.Path(codingPath: codingPath + [key]), + guard case let .sequence(values) = value else { + throw PureYAML.Decoding.Error.typeMismatch( + expected: "sequence", + actual: value.kindDescription, + path: PureYAML.Validation.Path(codingPath: codingPath + [key]), + ) + } + + return UnkeyedDecodingContainerImpl( + values: values, + codingPath: codingPath + [key], + userInfo: userInfo, + validatesInput: validatesInput, ) } diff --git a/Sources/Decoding/Decoding.UnkeyedContainer.swift b/Sources/Decoding/Decoding.UnkeyedContainer.swift new file mode 100644 index 0000000..4e8bac1 --- /dev/null +++ b/Sources/Decoding/Decoding.UnkeyedContainer.swift @@ -0,0 +1,160 @@ +struct UnkeyedDecodingContainerImpl: UnkeyedDecodingContainer { + let values: [PureYAML.Model.Value] + let codingPath: [any CodingKey] + let userInfo: [CodingUserInfoKey: Any] + let validatesInput: Bool + var currentIndex: Int = 0 + + var count: Int? { + values.count + } + + var isAtEnd: Bool { + currentIndex >= values.count + } + + mutating func decodeNil() throws -> Bool { + let value = try currentValue() + guard value == .null else { + return false + } + currentIndex += 1 + return true + } + + mutating func decode(_: Bool.Type) throws -> Bool { + try decodeSingleValue(Bool.self) + } + + mutating func decode(_: String.Type) throws -> String { + try decodeSingleValue(String.self) + } + + mutating func decode(_: Double.Type) throws -> Double { + try decodeSingleValue(Double.self) + } + + mutating func decode(_: Float.Type) throws -> Float { + try decodeSingleValue(Float.self) + } + + mutating func decode(_: Int.Type) throws -> Int { + try decodeSingleValue(Int.self) + } + + mutating func decode(_: Int8.Type) throws -> Int8 { + try decodeSingleValue(Int8.self) + } + + mutating func decode(_: Int16.Type) throws -> Int16 { + try decodeSingleValue(Int16.self) + } + + mutating func decode(_: Int32.Type) throws -> Int32 { + try decodeSingleValue(Int32.self) + } + + mutating func decode(_: Int64.Type) throws -> Int64 { + try decodeSingleValue(Int64.self) + } + + mutating func decode(_: UInt.Type) throws -> UInt { + try decodeSingleValue(UInt.self) + } + + mutating func decode(_: UInt8.Type) throws -> UInt8 { + try decodeSingleValue(UInt8.self) + } + + mutating func decode(_: UInt16.Type) throws -> UInt16 { + try decodeSingleValue(UInt16.self) + } + + mutating func decode(_: UInt32.Type) throws -> UInt32 { + try decodeSingleValue(UInt32.self) + } + + mutating func decode(_: UInt64.Type) throws -> UInt64 { + try decodeSingleValue(UInt64.self) + } + + mutating func decode(_: Value.Type) throws -> Value { + let value = try Value(from: decoderForCurrentIndex()) + currentIndex += 1 + return value + } + + mutating func nestedContainer( + keyedBy _: NestedKey.Type, + ) throws -> KeyedDecodingContainer { + let index = currentIndex + let value = try currentValue() + guard case let .mapping(mapping) = value else { + throw PureYAML.Decoding.Error.typeMismatch( + expected: "mapping", + actual: value.kindDescription, + path: PureYAML.Validation.Path(codingPath: codingPath + [IndexCodingKey(index: index)]), + ) + } + + currentIndex += 1 + return KeyedDecodingContainer(KeyedDecodingContainerImpl( + mapping: mapping, + codingPath: codingPath + [IndexCodingKey(index: index)], + userInfo: userInfo, + validatesInput: validatesInput, + )) + } + + mutating func nestedUnkeyedContainer() throws -> any UnkeyedDecodingContainer { + let index = currentIndex + let value = try currentValue() + guard case let .sequence(values) = value else { + throw PureYAML.Decoding.Error.typeMismatch( + expected: "sequence", + actual: value.kindDescription, + path: PureYAML.Validation.Path(codingPath: codingPath + [IndexCodingKey(index: index)]), + ) + } + + currentIndex += 1 + return UnkeyedDecodingContainerImpl( + values: values, + codingPath: codingPath + [IndexCodingKey(index: index)], + userInfo: userInfo, + validatesInput: validatesInput, + ) + } + + mutating func superDecoder() throws -> any Swift.Decoder { + let decoder = try decoderForCurrentIndex() + currentIndex += 1 + return decoder + } + + private mutating func decodeSingleValue( + _ type: Value.Type, + ) throws -> Value { + let value = try decoderForCurrentIndex().singleValueContainer().decode(type) + currentIndex += 1 + return value + } + + private func decoderForCurrentIndex() throws -> PureYAML.Decoding.Decoder { + try PureYAML.Decoding.Decoder( + value: currentValue(), + codingPath: codingPath + [IndexCodingKey(index: currentIndex)], + userInfo: userInfo, + validatesInput: validatesInput, + ) + } + + private func currentValue() throws -> PureYAML.Model.Value { + guard currentIndex < values.count else { + throw PureYAML.Decoding.Error.valueNotFound( + path: PureYAML.Validation.Path(codingPath: codingPath + [IndexCodingKey(index: currentIndex)]), + ) + } + return values[currentIndex] + } +} diff --git a/Sources/Encoding/Encoding.Encoder.swift b/Sources/Encoding/Encoding.Encoder.swift index 08b9209..c187b06 100644 --- a/Sources/Encoding/Encoding.Encoder.swift +++ b/Sources/Encoding/Encoding.Encoder.swift @@ -50,11 +50,9 @@ public extension PureYAML.Encoding { } public func unkeyedContainer() -> any UnkeyedEncodingContainer { - storage.fail(.unsupportedContainer( - kind: "unkeyed", - path: PureYAML.Validation.Path(codingPath: codingPath), - )) - return UnsupportedUnkeyedEncodingContainer( + storage.set(.sequence([])) + return UnkeyedEncodingContainerImpl( + storage: storage, codingPath: codingPath, userInfo: userInfo, ) @@ -76,19 +74,24 @@ extension PureYAML.Encoding.Encoder { var error: PureYAML.Encoding.Error? private let parent: Storage? private let parentKey: String? + private let parentIndex: Int? init( parent: Storage? = nil, parentKey: String? = nil, + parentIndex: Int? = nil, ) { self.parent = parent self.parentKey = parentKey + self.parentIndex = parentIndex } func set(_ value: PureYAML.Model.Value) { self.value = value if let parent, let parentKey { parent.set(value, forKey: parentKey) + } else if let parent, let parentIndex { + parent.set(value, atIndex: parentIndex) } } @@ -110,6 +113,35 @@ extension PureYAML.Encoding.Encoder { set(.mapping(mapping)) } + func append(_ value: PureYAML.Model.Value) { + var values: [PureYAML.Model.Value] = if case let .sequence(existing) = self.value { + existing + } else { + [] + } + + values.append(value) + set(.sequence(values)) + } + + func set( + _ value: PureYAML.Model.Value, + atIndex index: Int, + ) { + var values: [PureYAML.Model.Value] = if case let .sequence(existing) = self.value { + existing + } else { + [] + } + + if index == values.count { + values.append(value) + } else if index < values.count { + values[index] = value + } + set(.sequence(values)) + } + func fail(_ error: PureYAML.Encoding.Error) { self.error = error parent?.fail(error) @@ -209,104 +241,3 @@ private struct SingleValueContainer: SingleValueEncodingContainer { ) } } - -struct UnsupportedKeyedEncodingContainer: KeyedEncodingContainerProtocol { - var codingPath: [any CodingKey] - var userInfo: [CodingUserInfoKey: Any] = [:] - - mutating func encodeNil(forKey key: Key) throws { - throw unsupported(key) - } - - mutating func encode(_: Bool, forKey key: Key) throws { - throw unsupported(key) - } - - mutating func encode(_: String, forKey key: Key) throws { - throw unsupported(key) - } - - mutating func encode(_: Double, forKey key: Key) throws { - throw unsupported(key) - } - - mutating func encode(_: Float, forKey key: Key) throws { - throw unsupported(key) - } - - mutating func encode(_: Int, forKey key: Key) throws { - throw unsupported(key) - } - - mutating func encode(_: Int8, forKey key: Key) throws { - throw unsupported(key) - } - - mutating func encode(_: Int16, forKey key: Key) throws { - throw unsupported(key) - } - - mutating func encode(_: Int32, forKey key: Key) throws { - throw unsupported(key) - } - - mutating func encode(_: Int64, forKey key: Key) throws { - throw unsupported(key) - } - - mutating func encode(_: UInt, forKey key: Key) throws { - throw unsupported(key) - } - - mutating func encode(_: UInt8, forKey key: Key) throws { - throw unsupported(key) - } - - mutating func encode(_: UInt16, forKey key: Key) throws { - throw unsupported(key) - } - - mutating func encode(_: UInt32, forKey key: Key) throws { - throw unsupported(key) - } - - mutating func encode(_: UInt64, forKey key: Key) throws { - throw unsupported(key) - } - - mutating func encode(_: some Encodable, forKey key: Key) throws { - throw unsupported(key) - } - - mutating func nestedContainer( - keyedBy _: NestedKey.Type, - forKey key: Key, - ) -> KeyedEncodingContainer { - KeyedEncodingContainer(UnsupportedKeyedEncodingContainer( - codingPath: codingPath + [key], - userInfo: userInfo, - )) - } - - mutating func nestedUnkeyedContainer(forKey key: Key) -> any UnkeyedEncodingContainer { - UnsupportedUnkeyedEncodingContainer( - codingPath: codingPath + [key], - userInfo: userInfo, - ) - } - - mutating func superEncoder() -> any Swift.Encoder { - PureYAML.Encoding.Encoder(codingPath: codingPath, userInfo: userInfo) - } - - mutating func superEncoder(forKey key: Key) -> any Swift.Encoder { - PureYAML.Encoding.Encoder(codingPath: codingPath + [key], userInfo: userInfo) - } - - private func unsupported(_ key: Key) -> PureYAML.Encoding.Error { - .unsupportedContainer( - kind: "keyed", - path: PureYAML.Validation.Path(codingPath: codingPath + [key]), - ) - } -} diff --git a/Sources/Encoding/Encoding.Error.swift b/Sources/Encoding/Encoding.Error.swift index 8ac9271..1a95f42 100644 --- a/Sources/Encoding/Encoding.Error.swift +++ b/Sources/Encoding/Encoding.Error.swift @@ -1,10 +1,6 @@ public extension PureYAML.Encoding { /// Error reported by typed YAML encoding. enum Error: Swift.Error, Equatable, Sendable, CustomStringConvertible { - case unsupportedContainer( - kind: String, - path: PureYAML.Validation.Path, - ) case integerOutOfRange( type: String, path: PureYAML.Validation.Path, @@ -13,8 +9,6 @@ public extension PureYAML.Encoding { public var description: String { switch self { - case let .unsupportedContainer(kind, path): - "Unsupported \(kind) encoding container at \(path)" case let .integerOutOfRange(type, path): "\(type) value is outside PureYAML integer range at \(path)" case let .noValueEncoded(path): diff --git a/Sources/Encoding/Encoding.KeyedContainer.swift b/Sources/Encoding/Encoding.KeyedContainer.swift index 5e1c829..2865e6a 100644 --- a/Sources/Encoding/Encoding.KeyedContainer.swift +++ b/Sources/Encoding/Encoding.KeyedContainer.swift @@ -122,11 +122,8 @@ struct KeyedEncodingContainerImpl: KeyedEncodingContainerProtoco parent: storage, parentKey: key.stringValue, ) - childStorage.fail(.unsupportedContainer( - kind: "unkeyed", - path: PureYAML.Validation.Path(codingPath: codingPath + [key]), - )) - return UnsupportedUnkeyedEncodingContainer( + return UnkeyedEncodingContainerImpl( + storage: childStorage, codingPath: codingPath + [key], userInfo: userInfo, ) diff --git a/Sources/Encoding/Encoding.UnkeyedContainer.swift b/Sources/Encoding/Encoding.UnkeyedContainer.swift new file mode 100644 index 0000000..51d2172 --- /dev/null +++ b/Sources/Encoding/Encoding.UnkeyedContainer.swift @@ -0,0 +1,160 @@ +struct UnkeyedEncodingContainerImpl: UnkeyedEncodingContainer { + let storage: PureYAML.Encoding.Encoder.Storage + var codingPath: [any CodingKey] + var userInfo: [CodingUserInfoKey: Any] + var count: Int { + if case let .sequence(values) = storage.value { + return values.count + } + return 0 + } + + init( + storage: PureYAML.Encoding.Encoder.Storage, + codingPath: [any CodingKey], + userInfo: [CodingUserInfoKey: Any], + ) { + self.storage = storage + self.codingPath = codingPath + self.userInfo = userInfo + if storage.value == nil { + storage.set(.sequence([])) + } + } + + mutating func encodeNil() throws { + storage.append(.null) + } + + mutating func encode(_ value: Bool) throws { + storage.append(.bool(value)) + } + + mutating func encode(_ value: String) throws { + storage.append(.string(value)) + } + + mutating func encode(_ value: Double) throws { + storage.append(.double(value)) + } + + mutating func encode(_ value: Float) throws { + storage.append(.double(Double(value))) + } + + mutating func encode(_ value: Int) throws { + storage.append(.int(value)) + } + + mutating func encode(_ value: Int8) throws { + storage.append(.int(Int(value))) + } + + mutating func encode(_ value: Int16) throws { + storage.append(.int(Int(value))) + } + + mutating func encode(_ value: Int32) throws { + storage.append(.int(Int(value))) + } + + mutating func encode(_ value: Int64) throws { + guard let value = Int(exactly: value) else { + throw integerOutOfRange("Int64") + } + storage.append(.int(value)) + } + + mutating func encode(_ value: UInt) throws { + guard let value = Int(exactly: value) else { + throw integerOutOfRange("UInt") + } + storage.append(.int(value)) + } + + mutating func encode(_ value: UInt8) throws { + storage.append(.int(Int(value))) + } + + mutating func encode(_ value: UInt16) throws { + storage.append(.int(Int(value))) + } + + mutating func encode(_ value: UInt32) throws { + guard let value = Int(exactly: value) else { + throw integerOutOfRange("UInt32") + } + storage.append(.int(value)) + } + + mutating func encode(_ value: UInt64) throws { + guard let value = Int(exactly: value) else { + throw integerOutOfRange("UInt64") + } + storage.append(.int(value)) + } + + mutating func encode(_ value: some Encodable) throws { + let index = count + let childStorage = PureYAML.Encoding.Encoder.Storage( + parent: storage, + parentIndex: index, + ) + let childEncoder = PureYAML.Encoding.Encoder( + codingPath: codingPath + [IndexCodingKey(index: index)], + userInfo: userInfo, + storage: childStorage, + ) + try value.encode(to: childEncoder) + _ = try childEncoder.encodedValue() + } + + mutating func nestedContainer( + keyedBy _: NestedKey.Type, + ) -> KeyedEncodingContainer { + let index = count + let childStorage = PureYAML.Encoding.Encoder.Storage( + parent: storage, + parentIndex: index, + ) + return KeyedEncodingContainer(KeyedEncodingContainerImpl( + storage: childStorage, + codingPath: codingPath + [IndexCodingKey(index: index)], + userInfo: userInfo, + )) + } + + mutating func nestedUnkeyedContainer() -> any UnkeyedEncodingContainer { + let index = count + let childStorage = PureYAML.Encoding.Encoder.Storage( + parent: storage, + parentIndex: index, + ) + return UnkeyedEncodingContainerImpl( + storage: childStorage, + codingPath: codingPath + [IndexCodingKey(index: index)], + userInfo: userInfo, + ) + } + + mutating func superEncoder() -> any Swift.Encoder { + let index = count + storage.append(.null) + let childStorage = PureYAML.Encoding.Encoder.Storage( + parent: storage, + parentIndex: index, + ) + return PureYAML.Encoding.Encoder( + codingPath: codingPath + [IndexCodingKey(index: index)], + userInfo: userInfo, + storage: childStorage, + ) + } + + private func integerOutOfRange(_ type: String) -> PureYAML.Encoding.Error { + .integerOutOfRange( + type: type, + path: PureYAML.Validation.Path(codingPath: codingPath + [IndexCodingKey(index: count)]), + ) + } +} diff --git a/Sources/Encoding/Encoding.UnsupportedUnkeyedContainer.swift b/Sources/Encoding/Encoding.UnsupportedUnkeyedContainer.swift deleted file mode 100644 index a16c794..0000000 --- a/Sources/Encoding/Encoding.UnsupportedUnkeyedContainer.swift +++ /dev/null @@ -1,123 +0,0 @@ -struct UnsupportedUnkeyedEncodingContainer: UnkeyedEncodingContainer { - var codingPath: [any CodingKey] - var userInfo: [CodingUserInfoKey: Any] - var count: Int { - 0 - } - - mutating func encodeNil() throws { - throw unsupported() - } - - mutating func encode(_: Bool) throws { - throw unsupported() - } - - mutating func encode(_: String) throws { - throw unsupported() - } - - mutating func encode(_: Double) throws { - throw unsupported() - } - - mutating func encode(_: Float) throws { - throw unsupported() - } - - mutating func encode(_: Int) throws { - throw unsupported() - } - - mutating func encode(_: Int8) throws { - throw unsupported() - } - - mutating func encode(_: Int16) throws { - throw unsupported() - } - - mutating func encode(_: Int32) throws { - throw unsupported() - } - - mutating func encode(_: Int64) throws { - throw unsupported() - } - - mutating func encode(_: UInt) throws { - throw unsupported() - } - - mutating func encode(_: UInt8) throws { - throw unsupported() - } - - mutating func encode(_: UInt16) throws { - throw unsupported() - } - - mutating func encode(_: UInt32) throws { - throw unsupported() - } - - mutating func encode(_: UInt64) throws { - throw unsupported() - } - - mutating func encode(_: some Encodable) throws { - throw unsupported() - } - - mutating func nestedContainer( - keyedBy _: NestedKey.Type, - ) -> KeyedEncodingContainer { - KeyedEncodingContainer(UnsupportedKeyedEncodingContainer( - codingPath: codingPath + [IndexCodingKey(index: count)], - userInfo: userInfo, - )) - } - - mutating func nestedUnkeyedContainer() -> any UnkeyedEncodingContainer { - UnsupportedUnkeyedEncodingContainer( - codingPath: codingPath + [IndexCodingKey(index: count)], - userInfo: userInfo, - ) - } - - mutating func superEncoder() -> any Swift.Encoder { - PureYAML.Encoding.Encoder( - codingPath: codingPath + [IndexCodingKey(index: count)], - userInfo: userInfo, - ) - } - - private func unsupported() -> PureYAML.Encoding.Error { - .unsupportedContainer( - kind: "unkeyed", - path: PureYAML.Validation.Path( - codingPath: codingPath + [IndexCodingKey(index: count)], - ), - ) - } -} - -private struct IndexCodingKey: CodingKey { - var stringValue: String - var intValue: Int? - - init(index: Int) { - stringValue = "\(index)" - intValue = index - } - - init?(stringValue: String) { - self.stringValue = stringValue - intValue = nil - } - - init?(intValue: Int) { - stringValue = "\(intValue)" - self.intValue = intValue - } -} diff --git a/Tests/CodingTests.swift b/Tests/CodingTests.swift index 218ff9c..d25b4d9 100644 --- a/Tests/CodingTests.swift +++ b/Tests/CodingTests.swift @@ -68,30 +68,6 @@ struct CodingTests { testCase.expectFailure() } - @Test("Unsupported unkeyed decoding container reports exact errors") - func test_unsupportedUnkeyedDecodingContainerReportsExactErrors() { - expectDecodingError( - .unsupportedContainer(kind: "unkeyed", path: .root), - ) { - _ = try PureYAML.decode([Int].self, from: .sequence([.int(1)])) - } - } - - @Test("Unsupported unkeyed encoding containers report exact errors") - func test_unsupportedUnkeyedEncodingContainersReportExactErrors() { - expectEncodingError( - .unsupportedContainer(kind: "unkeyed", path: .init([.index(0)])), - ) { - _ = try PureYAML.encode([1, 2]) - } - - expectEncodingError( - .unsupportedContainer(kind: "unkeyed", path: .root), - ) { - _ = try PureYAML.encode([Int]()) - } - } - @Test("Out of range integer encoding reports exact errors") func test_outOfRangeIntegerEncodingReportsExactErrors() { expectEncodingError( @@ -109,11 +85,6 @@ struct CodingTests { path: .root, ).description == "Expected String at $, found int") - #expect(PureYAML.Encoding.Error.unsupportedContainer( - kind: "keyed", - path: .init([.key("name")]), - ).description == "Unsupported keyed encoding container at $.name") - #expect(PureYAML.Decoding.Error.integerOutOfRange( type: "Int8", path: .root, diff --git a/Tests/KeyedCodingTests.swift b/Tests/KeyedCodingTests.swift index 351aa3f..f6c4358 100644 --- a/Tests/KeyedCodingTests.swift +++ b/Tests/KeyedCodingTests.swift @@ -132,23 +132,11 @@ struct KeyedCodingTests { @Test("Keyed encoding failures report exact errors") func test_keyedEncodingFailuresReportExactErrors() { - expectEncodingError( - .unsupportedContainer(kind: "unkeyed", path: .init([.key("items"), .index(0)])), - ) { - _ = try PureYAML.encode(UnsupportedList(items: [1])) - } - expectEncodingError( .integerOutOfRange(type: "UInt64", path: .init([.key("value")])), ) { _ = try PureYAML.encode(WideInteger(value: UInt64.max)) } - - expectEncodingError( - .unsupportedContainer(kind: "unkeyed", path: .init([.key("items")])), - ) { - _ = try PureYAML.encode(UnsupportedListThenTitle()) - } } @Test("Keyed coding error descriptions are exact") @@ -164,10 +152,9 @@ struct KeyedCodingTests { path: .root, ).description == "Expected mapping at $, found string") - #expect(PureYAML.Encoding.Error.unsupportedContainer( - kind: "unkeyed", - path: .init([.key("items")]), - ).description == "Unsupported unkeyed encoding container at $.items") + #expect(PureYAML.Decoding.Error.valueNotFound( + path: .init([.index(1)]), + ).description == "No YAML value found at $[1]") } } @@ -222,14 +209,6 @@ private let keyedDecodingErrorCases: [KeyedDecodingErrorCase] = [ expected: .typeMismatch(expected: "mapping", actual: "string", path: .root), kind: .titleOnly, ), - .init( - name: "unkeyed property remains unsupported", - value: .mapping(.init([ - .init(key: "items", value: .sequence([.int(1)])), - ])), - expected: .unsupportedContainer(kind: "unkeyed", path: .init([.key("items")])), - kind: .list, - ), ] private struct KeyedArticle: Codable, Equatable { @@ -259,27 +238,10 @@ private struct TitleOnly: Codable, Equatable { var title: String } -private struct UnsupportedList: Codable, Equatable { - var items: [Int] -} - private struct WideInteger: Encodable { var value: UInt64 } -private struct UnsupportedListThenTitle: Encodable { - enum CodingKeys: String, CodingKey { - case items - case title - } - - func encode(to encoder: any Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - _ = container.nestedUnkeyedContainer(forKey: .items) - try container.encode("after", forKey: .title) - } -} - private struct TestCodingKey: CodingKey { var stringValue: String var intValue: Int? @@ -304,7 +266,6 @@ struct KeyedDecodingErrorCase: CustomStringConvertible { enum Kind { case article case titleOnly - case list } var name: String @@ -323,8 +284,6 @@ struct KeyedDecodingErrorCase: CustomStringConvertible { _ = try PureYAML.decode(KeyedArticle.self, from: value) case .titleOnly: _ = try PureYAML.decode(TitleOnly.self, from: value) - case .list: - _ = try PureYAML.decode(UnsupportedList.self, from: value) } recordIssue("expected decoding error") } catch let error as PureYAML.Decoding.Error { diff --git a/Tests/UnkeyedCodingTests.swift b/Tests/UnkeyedCodingTests.swift new file mode 100644 index 0000000..f9ea6a3 --- /dev/null +++ b/Tests/UnkeyedCodingTests.swift @@ -0,0 +1,301 @@ +@testable import PureYAML +import Testing + +@Suite("Unkeyed Coding") +struct UnkeyedCodingTests { + @Test("Decodes unkeyed sequences with scalars optionals nesting and keyed elements") + func test_decodesUnkeyedSequencesWithScalarsOptionalsNestingAndKeyedElements() throws { + #expect(try PureYAML.decode([Int].self, from: .sequence([.int(1), .int(2)])) == [1, 2]) + + let optional = try PureYAML.decode([String?].self, from: .sequence([ + .string("one"), + .null, + .string("three"), + ])) + #expect(optional == ["one", nil, "three"]) + + #expect(try PureYAML.decode([[Int]].self, from: nestedIntegerValue) == [[1, 2], []]) + #expect(try PureYAML.decode([ListedArticle].self, from: .sequence([ + listedArticleValue, + ])) == [listedArticle]) + #expect(try PureYAML.decode(ListedArticle.self, from: listedArticleValue) == listedArticle) + } + + @Test("Parses YAML sequences before unkeyed decoding") + func test_parsesYAMLSequencesBeforeUnkeyedDecoding() throws { + let decoded = try PureYAML.decode([String].self, from: """ + - one + - two + """) + + #expect(decoded == ["one", "two"]) + } + + @Test("Encodes unkeyed sequences as exact value trees and YAML") + func test_encodesUnkeyedSequencesAsExactValueTreesAndYAML() throws { + let optional: [String?] = ["one", nil, "three"] + + #expect(try PureYAML.encode([1, 2]) == .sequence([.int(1), .int(2)])) + #expect(try PureYAML.encode(optional) == .sequence([ + .string("one"), + .null, + .string("three"), + ])) + #expect(try PureYAML.encode([[1, 2], []]) == nestedIntegerValue) + #expect(try PureYAML.encode([listedArticle]) == .sequence([listedArticleValue])) + #expect(try PureYAML.encode(listedArticle) == listedArticleValue) + #expect(try PureYAML.encode([Int]()) == .sequence([])) + #expect(try PureYAML.encodeToYAML([1, 2]) == """ + - 1 + - 2 + + """) + #expect(try PureYAML.encodeToYAML([Int]()) == "\n") + } + + @Test("Unkeyed super encoders reserve distinct indexes before values are written") + func test_unkeyedSuperEncodersReserveDistinctIndexesBeforeValuesAreWritten() throws { + #expect(try PureYAML.encode(SuperEncodedPair()) == .sequence([ + .string("one"), + .string("two"), + ])) + } + + @Test("Unkeyed decoding failures report exact errors", arguments: unkeyedDecodingErrorCases) + func test_unkeyedDecodingFailuresReportExactErrors(testCase: UnkeyedDecodingErrorCase) { + testCase.expectFailure() + } + + @Test("Unkeyed decoding validates before reading values") + func test_unkeyedDecodingValidatesBeforeReadingValues() { + let value = PureYAML.Model.Value.sequence([ + .mapping(.init([ + .init(key: "title", value: .string("First")), + .init(key: "title", value: .string("Second")), + ])), + ]) + + expectValidationError { + _ = try PureYAML.decode([TitleOnlyArticle].self, from: value) + } check: { collection in + #expect(collection.issues == [ + .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") + } + } + + @Test("Direct unkeyed containers report overread paths exactly") + func test_directUnkeyedContainersReportOverreadPathsExactly() throws { + var container = try PureYAML.Decoding.Decoder( + value: .sequence([.int(1)]), + ).unkeyedContainer() + + #expect(try container.decode(Int.self) == 1) + expectDecodingError(.valueNotFound(path: .init([.index(1)]))) { + _ = try container.decode(Int.self) + } + } + + @Test("Unkeyed encoding failures report exact errors") + func test_unkeyedEncodingFailuresReportExactErrors() { + expectEncodingError( + .integerOutOfRange(type: "UInt64", path: .init([.index(0)])), + ) { + _ = try PureYAML.encode([UInt64.max]) + } + + expectEncodingError( + .integerOutOfRange(type: "UInt64", path: .init([.index(0), .key("value")])), + ) { + _ = try PureYAML.encode([WideInteger(value: UInt64.max)]) + } + } + + @Test("Unkeyed coding error descriptions are exact") + func test_unkeyedCodingErrorDescriptionsAreExact() { + #expect(PureYAML.Decoding.Error.valueNotFound( + path: .init([.index(1)]), + ).description == "No YAML value found at $[1]") + + #expect(PureYAML.Decoding.Error.typeMismatch( + expected: "sequence", + actual: "string", + path: .root, + ).description == "Expected sequence at $, found string") + + #expect(PureYAML.Encoding.Error.integerOutOfRange( + type: "UInt64", + path: .init([.index(0)]), + ).description == "UInt64 value is outside PureYAML integer range at $[0]") + } +} + +private let nestedIntegerValue = PureYAML.Model.Value.sequence([ + .sequence([.int(1), .int(2)]), + .sequence([]), +]) + +private let listedArticle = ListedArticle( + title: "Typed YAML", + tags: ["Swift", "YAML"], + ranks: [1, nil, 3], +) + +private let listedArticleValue = PureYAML.Model.Value.mapping(.init([ + .init(key: "title", value: .string("Typed YAML")), + .init(key: "tags", value: .sequence([ + .string("Swift"), + .string("YAML"), + ])), + .init(key: "ranks", value: .sequence([ + .int(1), + .null, + .int(3), + ])), +])) + +private let unkeyedDecodingErrorCases: [UnkeyedDecodingErrorCase] = [ + .init( + name: "root value is not a sequence", + value: .string("not a sequence"), + expected: .typeMismatch(expected: "sequence", actual: "string", path: .root), + kind: .integerArray, + ), + .init( + name: "element has wrong type", + value: .sequence([.int(1), .string("two")]), + expected: .typeMismatch(expected: "Int", actual: "string", path: .init([.index(1)])), + kind: .integerArray, + ), + .init( + name: "nested element has wrong type", + value: .sequence([ + .sequence([.int(1), .bool(false)]), + ]), + expected: .typeMismatch( + expected: "Int", + actual: "bool", + path: .init([.index(0), .index(1)]), + ), + kind: .nestedIntegerArray, + ), + .init( + name: "keyed sequence property has wrong type", + value: .mapping(.init([ + .init(key: "title", value: .string("Typed YAML")), + .init(key: "tags", value: .string("Swift")), + .init(key: "ranks", value: .sequence([])), + ])), + expected: .typeMismatch(expected: "sequence", actual: "string", path: .init([.key("tags")])), + kind: .listedArticle, + ), +] + +private struct ListedArticle: Codable, Equatable { + var title: String + var tags: [String] + var ranks: [Int?] +} + +private struct TitleOnlyArticle: Codable, Equatable { + var title: String +} + +private struct WideInteger: Encodable { + var value: UInt64 +} + +private struct SuperEncodedPair: Encodable { + func encode(to encoder: any Encoder) throws { + var container = encoder.unkeyedContainer() + let first = container.superEncoder() + let second = container.superEncoder() + try "one".encode(to: first) + try "two".encode(to: second) + } +} + +struct UnkeyedDecodingErrorCase: CustomStringConvertible { + enum Kind { + case integerArray + case nestedIntegerArray + case listedArticle + } + + var name: String + var value: PureYAML.Model.Value + var expected: PureYAML.Decoding.Error + var kind: Kind + + var description: String { + name + } + + func expectFailure() { + do { + switch kind { + case .integerArray: + _ = try PureYAML.decode([Int].self, from: value) + case .nestedIntegerArray: + _ = try PureYAML.decode([[Int]].self, from: value) + case .listedArticle: + _ = try PureYAML.decode(ListedArticle.self, from: value) + } + recordIssue("expected decoding error") + } catch let error as PureYAML.Decoding.Error { + #expect(error == expected) + #expect(error.description == expected.description) + } catch { + recordIssue("unexpected error \(error)") + } + } +} + +private func expectDecodingError( + _ expected: PureYAML.Decoding.Error, + operation: () throws -> some Any, +) { + do { + _ = try operation() + recordIssue("expected decoding error") + } catch let error as PureYAML.Decoding.Error { + #expect(error == expected) + #expect(error.description == expected.description) + } catch { + recordIssue("unexpected error \(error)") + } +} + +private func expectEncodingError( + _ expected: PureYAML.Encoding.Error, + operation: () throws -> some Any, +) { + do { + _ = try operation() + recordIssue("expected encoding error") + } catch let error as PureYAML.Encoding.Error { + #expect(error == expected) + #expect(error.description == expected.description) + } catch { + recordIssue("unexpected error \(error)") + } +} + +private func expectValidationError( + operation: () throws -> some Any, + check: (PureYAML.Validation.Issue.Collection) -> Void, +) { + do { + _ = try operation() + recordIssue("expected validation error") + } catch let collection as PureYAML.Validation.Issue.Collection { + check(collection) + } catch { + recordIssue("unexpected error \(error)") + } +} diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 020f11f..a3737c2 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -126,11 +126,11 @@ validation corpus pins exact issue paths, descriptions, severity splits, strict/non-strict behavior, rule traversal order, and duplicate-key diagnostics. Typed conversion supports scalar single-value Decodable and Encodable values, -plus keyed mapping-backed structs with scalar, optional scalar, and nested keyed -struct fields. `PureYAML.decode(_:from:)`, `PureYAML.encode(_:)`, and -`PureYAML.encodeToYAML(_:)` validate input shapes and throw exact path-aware -typed coding errors. Unkeyed containers remain planned work under the typed -conversion roadmap. +keyed mapping-backed structs with scalar, optional scalar, nested keyed struct, +and sequence fields, plus unkeyed sequences with scalar elements, optional +elements, nested sequences, and keyed mapping elements. `PureYAML.decode(_:from:)`, +`PureYAML.encode(_:)`, and `PureYAML.encodeToYAML(_:)` validate input shapes and +throw exact path-aware typed coding errors. Typed decoding runs default validation before Swift `Decodable` construction. That keeps ambiguous YAML states, such as duplicate mapping keys, from being @@ -142,7 +142,7 @@ Compatibility should be added in small, test-backed slices: 1. Tag-aware collection handling and merge keys. 2. Additional built-in validation rules beyond duplicate-key behavior. -3. Unkeyed typed decoding and encoding, then broader Codable compatibility. +3. Broader Codable compatibility beyond scalar, keyed, and unkeyed containers. 4. Yams corpus comparison tests in a separate compatibility suite. The private `PureYAMLResearch` repository may be used to study Yams behavior, but