feat(codable): broaden compatibility coverage
This commit is contained in:
parent
351af24731
commit
29ecce451a
@ -16,6 +16,7 @@ For code changes, load at minimum:
|
|||||||
- `docs/rules/testing.md`
|
- `docs/rules/testing.md`
|
||||||
- `docs/rules/verification.md`
|
- `docs/rules/verification.md`
|
||||||
- `docs/rules/commits.md`
|
- `docs/rules/commits.md`
|
||||||
|
- `docs/rules/research-compatibility.md`
|
||||||
|
|
||||||
## What PureYAML Is
|
## 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
|
Keep attribution in `ATTRIBUTION.md` accurate when compatibility work references
|
||||||
Yams or CYaml behavior.
|
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
|
## Namespace Rules
|
||||||
|
|
||||||
Every public type lives under the `PureYAML` namespace tree and mirrors its
|
Every public type lives under the `PureYAML` namespace tree and mirrors its
|
||||||
|
|||||||
10
CHANGELOG.md
10
CHANGELOG.md
@ -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.
|
nested sequences and sequence properties on keyed structs.
|
||||||
- Add a fixture-backed sequence typed-conversion test corpus and a
|
- Add a fixture-backed sequence typed-conversion test corpus and a
|
||||||
forbidden-pattern verification gate for package, source, and test contracts.
|
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
|
### 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
|
- Harden duplicate-key validation to use set membership while preserving exact
|
||||||
diagnostics.
|
diagnostics.
|
||||||
- Make validation rules immutable after construction.
|
- 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.
|
||||||
|
|||||||
24
README.md
24
README.md
@ -47,7 +47,7 @@ flowchart TB
|
|||||||
SuperEpic8["#8 Parser Replacement Roadmap"]:::epic
|
SuperEpic8["#8 Parser Replacement Roadmap"]:::epic
|
||||||
Epic1["#1 Pure Swift Parse Core"]:::done
|
Epic1["#1 Pure Swift Parse Core"]:::done
|
||||||
Epic9["#9 Deterministic Emitter 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
|
Epic11["#11 YAML Compatibility Corpus"]:::todo
|
||||||
Epic12["#12 Usage and Migration Docs"]:::todo
|
Epic12["#12 Usage and Migration Docs"]:::todo
|
||||||
Epic13["#13 Release Readiness"]:::todo
|
Epic13["#13 Release Readiness"]:::todo
|
||||||
@ -61,24 +61,7 @@ flowchart TB
|
|||||||
|
|
||||||
#9 Deterministic Emitter Core is complete in main.
|
#9 Deterministic Emitter Core is complete in main.
|
||||||
|
|
||||||
#10 Typed Decoding and Encoding is active:
|
#10 Typed Decoding and Encoding is complete in main.
|
||||||
|
|
||||||
```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
|
|
||||||
```
|
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
@ -121,7 +104,8 @@ try PureYAML.validate(document)
|
|||||||
```
|
```
|
||||||
|
|
||||||
Typed conversion is available for scalar values, keyed mapping-backed structs,
|
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
|
```swift
|
||||||
struct Info: Codable {
|
struct Info: Codable {
|
||||||
|
|||||||
18
Sources/Coding/SuperCodingKey.swift
Normal file
18
Sources/Coding/SuperCodingKey.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -117,23 +117,18 @@ struct KeyedDecodingContainerImpl<Key: CodingKey>: KeyedDecodingContainerProtoco
|
|||||||
}
|
}
|
||||||
|
|
||||||
func superDecoder() throws -> any Swift.Decoder {
|
func superDecoder() throws -> any Swift.Decoder {
|
||||||
PureYAML.Decoding.Decoder(
|
try decoder(for: SuperCodingKey())
|
||||||
value: .mapping(mapping),
|
|
||||||
codingPath: codingPath,
|
|
||||||
userInfo: userInfo,
|
|
||||||
validatesInput: validatesInput,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func superDecoder(forKey key: Key) throws -> any Swift.Decoder {
|
func superDecoder(forKey key: Key) throws -> any Swift.Decoder {
|
||||||
try decoder(for: key)
|
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()
|
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(
|
try PureYAML.Decoding.Decoder(
|
||||||
value: value(for: key),
|
value: value(for: key),
|
||||||
codingPath: codingPath + [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 {
|
guard let value = mapping[key.stringValue] else {
|
||||||
throw PureYAML.Decoding.Error.keyNotFound(
|
throw PureYAML.Decoding.Error.keyNotFound(
|
||||||
key: key.stringValue,
|
key: key.stringValue,
|
||||||
@ -154,7 +149,7 @@ struct KeyedDecodingContainerImpl<Key: CodingKey>: KeyedDecodingContainerProtoco
|
|||||||
|
|
||||||
private func validateIfNeeded(
|
private func validateIfNeeded(
|
||||||
_ value: PureYAML.Model.Value,
|
_ value: PureYAML.Model.Value,
|
||||||
forKey key: Key,
|
forKey key: any CodingKey,
|
||||||
) throws {
|
) throws {
|
||||||
if validatesInput {
|
if validatesInput {
|
||||||
try PureYAML.Decoding.validate(value, codingPath: codingPath + [key])
|
try PureYAML.Decoding.validate(value, codingPath: codingPath + [key])
|
||||||
|
|||||||
@ -130,14 +130,21 @@ struct KeyedEncodingContainerImpl<Key: CodingKey>: KeyedEncodingContainerProtoco
|
|||||||
}
|
}
|
||||||
|
|
||||||
mutating func superEncoder() -> any Swift.Encoder {
|
mutating func superEncoder() -> any Swift.Encoder {
|
||||||
PureYAML.Encoding.Encoder(
|
let key = SuperCodingKey()
|
||||||
codingPath: codingPath,
|
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,
|
userInfo: userInfo,
|
||||||
storage: storage,
|
storage: childStorage,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
mutating func superEncoder(forKey key: Key) -> any Swift.Encoder {
|
mutating func superEncoder(forKey key: Key) -> any Swift.Encoder {
|
||||||
|
storage.set(.null, forKey: key.stringValue)
|
||||||
let childStorage = PureYAML.Encoding.Encoder.Storage(
|
let childStorage = PureYAML.Encoding.Encoder.Storage(
|
||||||
parent: storage,
|
parent: storage,
|
||||||
parentKey: key.stringValue,
|
parentKey: key.stringValue,
|
||||||
|
|||||||
269
Tests/CodableCompatibilityFixtures.swift
Normal file
269
Tests/CodableCompatibilityFixtures.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
247
Tests/CodableCompatibilityTests.swift
Normal file
247
Tests/CodableCompatibilityTests.swift
Normal 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)
|
||||||
|
}
|
||||||
45
docs/rules/research-compatibility.md
Normal file
45
docs/rules/research-compatibility.md
Normal 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.
|
||||||
Loading…
Reference in New Issue
Block a user