feat(coding): add unkeyed typed conversion

This commit is contained in:
Mihaela Mihaljevic 2026-06-05 12:23:52 +02:00
parent f80d7460ea
commit 5c3de786bf
16 changed files with 728 additions and 335 deletions

View File

@ -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

View File

@ -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.

View File

@ -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
}
}

View File

@ -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,
))
}

View File

@ -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)"
}
}
}

View File

@ -100,9 +100,19 @@ struct KeyedDecodingContainerImpl<Key: CodingKey>: 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,
)
}

View File

@ -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: Decodable>(_: Value.Type) throws -> Value {
let value = try Value(from: decoderForCurrentIndex())
currentIndex += 1
return value
}
mutating func nestedContainer<NestedKey: CodingKey>(
keyedBy _: NestedKey.Type,
) throws -> KeyedDecodingContainer<NestedKey> {
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<NestedKey>(
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<Value: Decodable>(
_ 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]
}
}

View File

@ -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<Key: CodingKey>: 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<NestedKey: CodingKey>(
keyedBy _: NestedKey.Type,
forKey key: Key,
) -> KeyedEncodingContainer<NestedKey> {
KeyedEncodingContainer(UnsupportedKeyedEncodingContainer<NestedKey>(
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]),
)
}
}

View File

@ -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):

View File

@ -122,11 +122,8 @@ struct KeyedEncodingContainerImpl<Key: CodingKey>: 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,
)

View File

@ -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<NestedKey: CodingKey>(
keyedBy _: NestedKey.Type,
) -> KeyedEncodingContainer<NestedKey> {
let index = count
let childStorage = PureYAML.Encoding.Encoder.Storage(
parent: storage,
parentIndex: index,
)
return KeyedEncodingContainer(KeyedEncodingContainerImpl<NestedKey>(
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)]),
)
}
}

View File

@ -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<NestedKey: CodingKey>(
keyedBy _: NestedKey.Type,
) -> KeyedEncodingContainer<NestedKey> {
KeyedEncodingContainer(UnsupportedKeyedEncodingContainer<NestedKey>(
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
}
}

View File

@ -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,

View File

@ -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 {

View File

@ -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)")
}
}

View File

@ -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