feat(codable): broaden compatibility coverage

This commit is contained in:
Mihaela Mihaljevic 2026-06-05 12:49:20 +02:00
parent 351af24731
commit 29ecce451a
9 changed files with 613 additions and 33 deletions

View File

@ -16,6 +16,7 @@ For code changes, load at minimum:
- `docs/rules/testing.md`
- `docs/rules/verification.md`
- `docs/rules/commits.md`
- `docs/rules/research-compatibility.md`
## What PureYAML Is
@ -35,6 +36,10 @@ Do not copy C code or upstream Yams source into this public package.
Keep attribution in `ATTRIBUTION.md` accurate when compatibility work references
Yams or CYaml behavior.
Compatibility work must start from that research source. Before changing parser,
emitter, validation, or typed conversion behavior, inspect the matching
PureYAMLResearch source or fixture and use it only to define behavior and tests.
## Namespace Rules
Every public type lives under the `PureYAML` namespace tree and mirrors its

View File

@ -58,6 +58,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
nested sequences and sequence properties on keyed structs.
- Add a fixture-backed sequence typed-conversion test corpus and a
forbidden-pattern verification gate for package, source, and test contracts.
- Add broader Codable compatibility coverage for dynamic mapping keys, nested
container round trips, keyed super coders, default `super` coders, and
deterministic unsupported-shape errors.
- Add an explicit research-compatibility rule requiring parser, emitter,
validation, and typed conversion work to start from PureYAMLResearch behavior
without copying implementation code.
### Changed
@ -66,3 +72,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Harden duplicate-key validation to use set membership while preserving exact
diagnostics.
- Make validation rules immutable after construction.
- Preserve keyed super-encoder field order even when delayed child encoders are
written in a different order than they were requested.
- Make keyed default `superEncoder()` and `superDecoder()` use the standard
`super` mapping key instead of the current mapping.

View File

@ -47,7 +47,7 @@ flowchart TB
SuperEpic8["#8 Parser Replacement Roadmap"]:::epic
Epic1["#1 Pure Swift Parse Core"]:::done
Epic9["#9 Deterministic Emitter Core"]:::done
Epic10["#10 Typed Decoding and Encoding"]:::todo
Epic10["#10 Typed Decoding and Encoding"]:::done
Epic11["#11 YAML Compatibility Corpus"]:::todo
Epic12["#12 Usage and Migration Docs"]:::todo
Epic13["#13 Release Readiness"]:::todo
@ -61,24 +61,7 @@ flowchart TB
#9 Deterministic Emitter Core is complete in main.
#10 Typed Decoding and Encoding is active:
```mermaid
flowchart TB
classDef done fill:#ddf9e4,stroke:#34c759,color:#111827
classDef review fill:#fff7d6,stroke:#ffcc00,color:#111827
classDef epic fill:#f2e5ff,stroke:#af52de,color:#111827
classDef todo fill:#f2f4f7,stroke:#8e8e93,color:#111827
Epic10["#10 Typed Decoding and Encoding"]:::epic
Scalar20["#20 Scalar Typed Conversion"]:::done
Keyed21["#21 Keyed Typed Conversion"]:::done
Unkeyed22["#22 Unkeyed Typed Conversion"]:::done
Compat23["#23 Broader Codable Compatibility"]:::todo
Epic10 --> Scalar20
Scalar20 --> Keyed21
Keyed21 --> Unkeyed22
Unkeyed22 --> Compat23
```
#10 Typed Decoding and Encoding is complete in main.
## Status
@ -121,7 +104,8 @@ try PureYAML.validate(document)
```
Typed conversion is available for scalar values, keyed mapping-backed structs,
and unkeyed sequences:
dictionary-like string mappings, unkeyed sequences, nested containers, and
keyed super coders:
```swift
struct Info: Codable {

View File

@ -0,0 +1,18 @@
struct SuperCodingKey: CodingKey {
var stringValue: String
var intValue: Int?
init() {
stringValue = "super"
intValue = nil
}
init?(stringValue: String) {
self.stringValue = stringValue
intValue = nil
}
init?(intValue _: Int) {
nil
}
}

View File

@ -117,23 +117,18 @@ struct KeyedDecodingContainerImpl<Key: CodingKey>: KeyedDecodingContainerProtoco
}
func superDecoder() throws -> any Swift.Decoder {
PureYAML.Decoding.Decoder(
value: .mapping(mapping),
codingPath: codingPath,
userInfo: userInfo,
validatesInput: validatesInput,
)
try decoder(for: SuperCodingKey())
}
func superDecoder(forKey key: Key) throws -> any Swift.Decoder {
try decoder(for: key)
}
private func singleValue(for key: Key) throws -> any SingleValueDecodingContainer {
private func singleValue(for key: any CodingKey) throws -> any SingleValueDecodingContainer {
try decoder(for: key).singleValueContainer()
}
private func decoder(for key: Key) throws -> PureYAML.Decoding.Decoder {
private func decoder(for key: any CodingKey) throws -> PureYAML.Decoding.Decoder {
try PureYAML.Decoding.Decoder(
value: value(for: key),
codingPath: codingPath + [key],
@ -142,7 +137,7 @@ struct KeyedDecodingContainerImpl<Key: CodingKey>: KeyedDecodingContainerProtoco
)
}
private func value(for key: Key) throws -> PureYAML.Model.Value {
private func value(for key: any CodingKey) throws -> PureYAML.Model.Value {
guard let value = mapping[key.stringValue] else {
throw PureYAML.Decoding.Error.keyNotFound(
key: key.stringValue,
@ -154,7 +149,7 @@ struct KeyedDecodingContainerImpl<Key: CodingKey>: KeyedDecodingContainerProtoco
private func validateIfNeeded(
_ value: PureYAML.Model.Value,
forKey key: Key,
forKey key: any CodingKey,
) throws {
if validatesInput {
try PureYAML.Decoding.validate(value, codingPath: codingPath + [key])

View File

@ -130,14 +130,21 @@ struct KeyedEncodingContainerImpl<Key: CodingKey>: KeyedEncodingContainerProtoco
}
mutating func superEncoder() -> any Swift.Encoder {
PureYAML.Encoding.Encoder(
codingPath: codingPath,
let key = SuperCodingKey()
storage.set(.null, forKey: key.stringValue)
let childStorage = PureYAML.Encoding.Encoder.Storage(
parent: storage,
parentKey: key.stringValue,
)
return PureYAML.Encoding.Encoder(
codingPath: codingPath + [key],
userInfo: userInfo,
storage: storage,
storage: childStorage,
)
}
mutating func superEncoder(forKey key: Key) -> any Swift.Encoder {
storage.set(.null, forKey: key.stringValue)
let childStorage = PureYAML.Encoding.Encoder.Storage(
parent: storage,
parentKey: key.stringValue,

View File

@ -0,0 +1,269 @@
@testable import PureYAML
let dynamicSettingsValue = PureYAML.Model.Value.mapping(.init([
.init(key: "/users", value: .int(2)),
.init(key: "name.with.dot", value: .int(3)),
]))
let nestedCompatibilityValue = PureYAML.Model.Value.mapping(.init([
.init(key: "title", value: .string("Typed YAML")),
.init(key: "metadata", value: .mapping(.init([
.init(key: "author", value: .string("Mihaela")),
.init(key: "rating", value: .int(5)),
]))),
.init(key: "sections", value: .sequence([
.mapping(.init([
.init(key: "heading", value: .string("Intro")),
.init(key: "items", value: .sequence([
.string("one"),
.string("two"),
])),
])),
.mapping(.init([
.init(key: "heading", value: .string("Outro")),
.init(key: "items", value: .sequence([])),
])),
])),
]))
let nestedCompatibilityFixture = NestedCompatibilityDocument(
title: "Typed YAML",
metadata: .init(author: "Mihaela", rating: 5),
sections: [
.init(heading: "Intro", items: ["one", "two"]),
.init(heading: "Outro", items: []),
],
)
let codingPathProbeValue = PureYAML.Model.Value.mapping(.init([
.init(key: "payload", value: .string("child")),
.init(key: "metadata", value: .mapping(.init([
.init(key: "author", value: .string("Mihaela")),
]))),
.init(key: "items", value: .sequence([
.mapping(.init([
.init(key: "author", value: .string("Nested")),
])),
])),
]))
struct DynamicSettings: Codable, Equatable {
struct Entry: Equatable {
var key: String
var value: Int
}
var entries: [Entry]
init(entries: [Entry]) {
self.entries = entries
}
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: DynamicCodingKey.self)
entries = try container.allKeys.map { key in
try Entry(
key: key.stringValue,
value: container.decode(Int.self, forKey: key),
)
}
}
func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: DynamicCodingKey.self)
for entry in entries {
try container.encode(
entry.value,
forKey: DynamicCodingKey(entry.key),
)
}
}
}
struct NestedCompatibilityDocument: Codable, Equatable {
var title: String
var metadata: NestedCompatibilityMetadata
var sections: [NestedCompatibilitySection]
}
struct NestedCompatibilityMetadata: Codable, Equatable {
var author: String
var rating: Int
}
struct NestedCompatibilitySection: Codable, Equatable {
var heading: String
var items: [String]
}
struct SuperCodedPayload: Codable, Equatable {
var value: String
}
struct KeyedSuperBox: Codable, Equatable {
enum CodingKeys: String, CodingKey {
case payload
}
var payload: SuperCodedPayload
init(payload: SuperCodedPayload) {
self.payload = payload
}
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
payload = try SuperCodedPayload(
from: container.superDecoder(forKey: .payload),
)
}
func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try payload.encode(to: container.superEncoder(forKey: .payload))
}
}
struct KeyedDefaultSuperBox: Codable, Equatable {
var payload: SuperCodedPayload
init(payload: SuperCodedPayload) {
self.payload = payload
}
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingPathProbeTopKey.self)
let superDecoder = try container.superDecoder()
expectCodingPath(superDecoder.codingPath, strings: ["super"], intValues: [nil])
payload = try SuperCodedPayload(from: superDecoder)
}
func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingPathProbeTopKey.self)
let superEncoder = container.superEncoder()
expectCodingPath(superEncoder.codingPath, strings: ["super"], intValues: [nil])
try payload.encode(to: superEncoder)
}
}
struct DelayedKeyedSuperEncoders: Encodable {
enum CodingKeys: String, CodingKey {
case first
case second
}
func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
let first = container.superEncoder(forKey: .first)
let second = container.superEncoder(forKey: .second)
try "two".encode(to: second)
try "one".encode(to: first)
}
}
struct MissingPayloadBox: Decodable {
enum CodingKeys: String, CodingKey {
case payload
}
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
_ = try container.superDecoder(forKey: .payload)
}
}
struct MissingDefaultSuperBox: Decodable {
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingPathProbeTopKey.self)
_ = try container.superDecoder()
}
}
struct NoValueEncoded: Encodable {
func encode(to _: any Encoder) throws {}
}
struct KeyedCodingPathEncodingProbe: Encodable {
func encode(to encoder: any Encoder) throws {
expectCodingPath(encoder.codingPath, strings: [], intValues: [])
var container = encoder.container(keyedBy: CodingPathProbeTopKey.self)
expectCodingPath(container.codingPath, strings: [], intValues: [])
let superEncoder = container.superEncoder(forKey: .payload)
expectCodingPath(superEncoder.codingPath, strings: ["payload"], intValues: [nil])
var metadata = container.nestedContainer(
keyedBy: CodingPathProbeNestedKey.self,
forKey: .metadata,
)
expectCodingPath(metadata.codingPath, strings: ["metadata"], intValues: [nil])
try metadata.encode("Mihaela", forKey: .author)
var items = container.nestedUnkeyedContainer(forKey: .items)
expectCodingPath(items.codingPath, strings: ["items"], intValues: [nil])
var item = items.nestedContainer(keyedBy: CodingPathProbeNestedKey.self)
expectCodingPath(item.codingPath, strings: ["items", "0"], intValues: [nil, 0])
try item.encode("Nested", forKey: .author)
try "child".encode(to: superEncoder)
}
}
struct KeyedCodingPathDecodingProbe: Decodable {
init(from decoder: any Decoder) throws {
expectCodingPath(decoder.codingPath, strings: [], intValues: [])
let container = try decoder.container(keyedBy: CodingPathProbeTopKey.self)
expectCodingPath(container.codingPath, strings: [], intValues: [])
let superDecoder = try container.superDecoder(forKey: .payload)
expectCodingPath(superDecoder.codingPath, strings: ["payload"], intValues: [nil])
_ = try superDecoder.singleValueContainer().decode(String.self)
let metadata = try container.nestedContainer(
keyedBy: CodingPathProbeNestedKey.self,
forKey: .metadata,
)
expectCodingPath(metadata.codingPath, strings: ["metadata"], intValues: [nil])
_ = try metadata.decode(String.self, forKey: .author)
var items = try container.nestedUnkeyedContainer(forKey: .items)
expectCodingPath(items.codingPath, strings: ["items"], intValues: [nil])
let item = try items.nestedContainer(keyedBy: CodingPathProbeNestedKey.self)
expectCodingPath(item.codingPath, strings: ["items", "0"], intValues: [nil, 0])
_ = try item.decode(String.self, forKey: .author)
}
}
enum CodingPathProbeTopKey: String, CodingKey {
case payload
case metadata
case items
}
enum CodingPathProbeNestedKey: String, CodingKey {
case author
}
struct DynamicCodingKey: CodingKey {
var stringValue: String
var intValue: Int?
init(_ stringValue: String) {
self.stringValue = stringValue
intValue = nil
}
init?(stringValue: String) {
self.stringValue = stringValue
intValue = nil
}
init?(intValue: Int) {
stringValue = "\(intValue)"
self.intValue = intValue
}
}

View File

@ -0,0 +1,247 @@
@testable import PureYAML
import Testing
@Suite("Codable Compatibility")
struct CodableCompatibilityTests {
@Test("Dictionary-like dynamic keys decode from ordered mappings")
func test_dictionaryLikeDynamicKeysDecodeFromOrderedMappings() throws {
let decoded = try PureYAML.decode(
DynamicSettings.self,
from: dynamicSettingsValue,
)
#expect(decoded == DynamicSettings(entries: [
.init(key: "/users", value: 2),
.init(key: "name.with.dot", value: 3),
]))
}
@Test("Dictionary-like dynamic keys encode exact values and YAML")
func test_dictionaryLikeDynamicKeysEncodeExactValuesAndYAML() throws {
let settings = DynamicSettings(entries: [
.init(key: "/users", value: 2),
.init(key: "name.with.dot", value: 3),
])
let yaml = try PureYAML.encodeToYAML(settings)
#expect(try PureYAML.encode(settings) == dynamicSettingsValue)
#expect(yaml == """
/users: 2
name.with.dot: 3
""")
#expect(yaml.contains("/users: 2"))
#expect(yaml.contains("name.with.dot: 3"))
#expect(!yaml.contains("\"name.with.dot\""))
#expect(!yaml.contains("null"))
}
@Test("Dictionary values decode where YAML mapping semantics allow them")
func test_dictionaryValuesDecodeWhereYAMLSemanticsAllowThem() throws {
let decoded = try PureYAML.decode(
[String: Int].self,
from: .mapping(.init([
.init(key: "count", value: .int(2)),
])),
)
#expect(decoded == ["count": 2])
}
@Test("Nested keyed unkeyed and single-value interactions round trip exactly")
func test_nestedKeyedUnkeyedAndSingleValueInteractionsRoundTripExactly() throws {
let decoded = try PureYAML.decode(
NestedCompatibilityDocument.self,
from: nestedCompatibilityValue,
)
let yaml = try PureYAML.encodeToYAML(nestedCompatibilityFixture)
#expect(decoded == nestedCompatibilityFixture)
#expect(try PureYAML.encode(nestedCompatibilityFixture) == nestedCompatibilityValue)
#expect(yaml == """
title: "Typed YAML"
metadata:
author: "Mihaela"
rating: 5
sections:
-
heading: "Intro"
items:
- "one"
- "two"
-
heading: "Outro"
items:
""")
#expect(yaml.contains("sections:"))
#expect(!yaml.contains("summary:"))
}
@Test("Dynamic-key decoding reports exact punctuation paths")
func test_dynamicKeyDecodingReportsExactPunctuationPaths() {
expectDecodingError(
.typeMismatch(
expected: "Int",
actual: "string",
path: .init([.key("/users")]),
),
) {
_ = try PureYAML.decode(DynamicSettings.self, from: .mapping(.init([
.init(key: "/users", value: .string("two")),
])))
}
}
@Test("Dynamic-key decoding validates duplicate punctuation keys")
func test_dynamicKeyDecodingValidatesDuplicatePunctuationKeys() {
expectValidationError {
_ = try PureYAML.decode(DynamicSettings.self, from: .mapping(.init([
.init(key: "/users", value: .int(1)),
.init(key: "/users", value: .int(2)),
])))
} check: { collection in
#expect(collection.issues == [
.init(
severity: .error,
reason: "Duplicate mapping key '/users'",
path: .init([.key("/users")]),
),
])
#expect(collection.description == "error: Duplicate mapping key '/users' at $[\"/users\"]")
}
}
@Test("Keyed super decoders and encoders preserve exact payloads")
func test_keyedSuperDecodersAndEncodersPreserveExactPayloads() throws {
let value = PureYAML.Model.Value.mapping(.init([
.init(key: "payload", value: .mapping(.init([
.init(key: "value", value: .string("child")),
]))),
]))
let box = KeyedSuperBox(payload: .init(value: "child"))
#expect(try PureYAML.decode(KeyedSuperBox.self, from: value) == box)
#expect(try PureYAML.encode(box) == value)
#expect(try PureYAML.encodeToYAML(box) == """
payload:
value: "child"
""")
}
@Test("Keyed default super coders use the super key")
func test_keyedDefaultSuperCodersUseTheSuperKey() throws {
let value = PureYAML.Model.Value.mapping(.init([
.init(key: "super", value: .mapping(.init([
.init(key: "value", value: .string("child")),
]))),
]))
let box = KeyedDefaultSuperBox(payload: .init(value: "child"))
#expect(try PureYAML.decode(KeyedDefaultSuperBox.self, from: value) == box)
#expect(try PureYAML.encode(box) == value)
#expect(try PureYAML.encodeToYAML(box) == """
super:
value: "child"
""")
}
@Test("Keyed super encoders reserve distinct keys before values are written")
func test_keyedSuperEncodersReserveDistinctKeysBeforeValuesAreWritten() throws {
#expect(try PureYAML.encode(DelayedKeyedSuperEncoders()) == .mapping(.init([
.init(key: "first", value: .string("one")),
.init(key: "second", value: .string("two")),
])))
}
@Test("Nested coders expose exact coding paths")
func test_nestedCodersExposeExactCodingPaths() throws {
#expect(try PureYAML.encode(KeyedCodingPathEncodingProbe()) == codingPathProbeValue)
_ = try PureYAML.decode(
KeyedCodingPathDecodingProbe.self,
from: codingPathProbeValue,
)
}
@Test("Unsupported missing super decoder payload reports exact errors")
func test_unsupportedMissingSuperDecoderPayloadReportsExactErrors() {
expectDecodingError(
.keyNotFound(key: "payload", path: .init([.key("payload")])),
) {
_ = try PureYAML.decode(MissingPayloadBox.self, from: .mapping(.init()))
}
}
@Test("Unsupported missing default super decoder reports exact errors")
func test_unsupportedMissingDefaultSuperDecoderReportsExactErrors() {
expectDecodingError(
.keyNotFound(key: "super", path: .init([.key("super")])),
) {
_ = try PureYAML.decode(MissingDefaultSuperBox.self, from: .mapping(.init()))
}
}
@Test("Unsupported empty encodable reports exact errors")
func test_unsupportedEmptyEncodableReportsExactErrors() {
expectEncodingError(.noValueEncoded(path: .root)) {
_ = try PureYAML.encode(NoValueEncoded())
}
}
}
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)")
}
}
func expectCodingPath(
_ path: [any CodingKey],
strings: [String],
intValues: [Int?],
) {
#expect(path.map(\.stringValue) == strings)
#expect(path.map(\.intValue) == intValues)
}

View File

@ -0,0 +1,45 @@
# Research Compatibility Rules
PureYAML replaces behavior that is currently studied in the private
`PureYAMLResearch` repository. Compatibility work must be research-led and
implementation-independent.
## Mandatory Start Point
Before changing parser, scanner, composer, emitter, validation, or typed
Codable conversion behavior:
- inspect the matching source, tests, or fixtures in `PureYAMLResearch`;
- identify the observable behavior PureYAML should match or deliberately reject;
- write PureYAML tests that pin that behavior before or with the implementation;
- keep all public-package code original and dependency-free.
## Copying Boundary
Do not copy source from Yams, CYaml, libyaml, or research-only references into
`Sources/`. Use the research repository for behavior discovery only.
Allowed:
- exact behavioral assertions in PureYAML tests;
- attribution in `ATTRIBUTION.md` and design docs;
- small independently written fixtures that describe input and expected output.
Forbidden:
- copied parser, scanner, emitter, or encoder implementation code;
- C files, generated parser files, or external SwiftPM dependencies;
- compatibility claims that were not checked against the research source or a
deliberate PureYAML design decision.
## Test Contract
Every compatibility test must pin at least one of:
- exact parsed value tree;
- exact emitted YAML;
- exact decoded Swift value;
- exact encoded value tree;
- exact validation issue;
- exact path-aware decoding or encoding error;
- exact required absence check for fields that must not appear.