feat(model): support complex mapping keys

This commit is contained in:
Mihaela Mihaljevic 2026-06-05 16:08:45 +02:00
parent 9c45419496
commit 697b9cc519
31 changed files with 807 additions and 99 deletions

View File

@ -21,6 +21,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add `PureYAML.parseTagged(_:)` and `PureYAML.parseTaggedStream(_:)` for
tag-preserving node trees, plus tagged validation rules for unsupported
built-in tags and tags applied to the wrong node kind.
- Add first-class complex mapping keys through `Model.Pair.keyNode`, including
parsing, deterministic validation paths, duplicate-key checks, and block/flow
dumping for sequence and mapping keys.
### Changed
@ -31,6 +34,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Keep Foundation-backed tags such as `!!timestamp` and `!!binary` as ordinary
model values in `parse(_:)`; callers that need tag diagnostics can use the
tagged parser and validator explicitly.
- Preserve anchors and tags on mapping values and sequence items whose actual
node starts on the following indented line.
## [0.1.0] - 2026-06-05

View File

@ -45,11 +45,11 @@ This repository has completed readiness for the first tagged release, version
0.1.0. Current main includes block mappings, block sequences, ordered mappings,
common scalars, quoted strings, comments, flow collections, literal and folded
block scalars, anchors, aliases, YAML directives, document markers, explicit
built-in scalar tags, merge-key expansion, multi-document stream parsing, and a
matching dumper with block and flow output policies. It also includes path-aware
validation for structural YAML checks such as duplicate mapping keys, plus
stream validation that preserves document indexes. Callers that need explicit
tag metadata can use `PureYAML.parseTagged(_:)` or
built-in scalar tags, merge-key expansion, complex mapping keys,
multi-document stream parsing, and a matching dumper with block and flow output
policies. It also includes path-aware validation for structural YAML checks such
as duplicate mapping keys, plus stream validation that preserves document
indexes. Callers that need explicit tag metadata can use `PureYAML.parseTagged(_:)` or
`PureYAML.parseTaggedStream(_:)` and run tagged validation over the preserved
source-shaped node tree.
@ -58,11 +58,11 @@ recognizes anchors, aliases, tags, flow collections, and block scalar styles fro
scanner tokens, and the public value parser composes those events into
`PureYAML.Model.Value`. The tagged parser preserves explicit scalar and
collection tags without adding Foundation-backed constructors; use normal
parsing when semantic merge-key expansion is required. Complex mapping
keys, directives beyond the selected compatibility subset, automatic
`Data`/`Date` construction, and custom decoding are planned work. Unsupported
gaps are pinned by executable tests so they produce exact errors, exact
validation issues, or exact fallback value trees instead of silent parser drift.
parsing when semantic merge-key expansion is required. Directives beyond the
selected compatibility subset, automatic `Data`/`Date` construction, and custom
decoding are planned work. Unsupported gaps are pinned by executable tests so
they produce exact errors, exact validation issues, or exact fallback value
trees instead of silent parser drift.
## Attribution
@ -154,6 +154,10 @@ 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.
Complex mapping keys are available through `PureYAML.Model.Pair.keyNode`.
String-keyed lookup and keyed `Codable` remain string-only, while model
inspection, validation, and dumping preserve sequence and mapping keys.
Emitter options are explicit. The default is deterministic block-style output
with quoted strings. Callers can opt into conservative plain strings, safe
literal block scalars for multiline strings, and compact flow collections:

View File

@ -5,7 +5,9 @@ struct KeyedDecodingContainerImpl<Key: CodingKey>: KeyedDecodingContainerProtoco
let validatesInput: Bool
var allKeys: [Key] {
mapping.pairs.compactMap { Key(stringValue: $0.key) }
mapping.pairs.compactMap { pair in
pair.keyNode.stringValue.flatMap(Key.init(stringValue:))
}
}
func contains(_ key: Key) -> Bool {

View File

@ -0,0 +1,63 @@
extension PureYAML.Emitting.Dumper {
func renderMapping(
_ mapping: PureYAML.Model.Mapping,
indent: Int,
) -> [String] {
mapping.pairs.flatMap { pair in
renderMappingPair(pair, indent: indent)
}
}
func renderMappingPair(
_ pair: PureYAML.Model.Pair,
indent: Int,
) -> [String] {
switch pair.keyNode {
case .mapping, .sequence:
renderComplexMappingPair(pair, indent: indent)
case .string:
renderStringKeyedMappingPair(pair, indent: indent)
}
}
func renderStringKeyedMappingPair(
_ pair: PureYAML.Model.Pair,
indent: Int,
) -> [String] {
let prefix = String(repeating: " ", count: indent)
switch pair.value {
case .mapping, .sequence:
return [prefix + escapeKey(pair.key) + ":"] + render(pair.value, indent: indent + 2)
default:
return renderMappingScalarPair(pair, indent: indent)
}
}
func renderComplexMappingPair(
_ pair: PureYAML.Model.Pair,
indent: Int,
) -> [String] {
let prefix = String(repeating: " ", count: indent)
return [prefix + "? " + pair.keyNode.flowDescription]
+ [prefix + ":"]
+ render(pair.value, indent: indent + 2)
}
func renderFlowMapping(_ mapping: PureYAML.Model.Mapping) -> String {
let pairs = mapping.pairs
.map { pair in
"\(renderFlowKey(pair.keyNode)): \(renderFlow(pair.value))"
}
.joined(separator: ", ")
return "{\(pairs)}"
}
func renderFlowKey(_ key: PureYAML.Model.Key) -> String {
switch key {
case .mapping, .sequence:
"? \(renderFlow(key.value))"
case let .string(value):
quote(value)
}
}
}

View File

@ -33,21 +33,6 @@ extension PureYAML.Emitting.Dumper {
}
}
func renderMapping(
_ mapping: PureYAML.Model.Mapping,
indent: Int,
) -> [String] {
let prefix = String(repeating: " ", count: indent)
return mapping.pairs.flatMap { pair in
switch pair.value {
case .mapping, .sequence:
[prefix + escapeKey(pair.key) + ":"] + render(pair.value, indent: indent + 2)
default:
renderMappingScalarPair(pair, indent: indent)
}
}
}
func renderSequence(
_ values: [PureYAML.Model.Value],
indent: Int,
@ -83,15 +68,6 @@ extension PureYAML.Emitting.Dumper {
}
}
func renderFlowMapping(_ mapping: PureYAML.Model.Mapping) -> String {
let pairs = mapping.pairs
.map { pair in
"\(quote(pair.key)): \(renderFlow(pair.value))"
}
.joined(separator: ", ")
return "{\(pairs)}"
}
func renderFlowSequence(_ values: [PureYAML.Model.Value]) -> String {
let items = values
.map(renderFlow)

View File

@ -105,7 +105,7 @@ extension PureYAML.Encoding.Encoder {
.init()
}
if let index = mapping.pairs.firstIndex(where: { $0.key == key }) {
if let index = mapping.pairs.firstIndex(where: { $0.keyNode == .string(key) }) {
mapping.pairs[index] = .init(key: key, value: value)
} else {
mapping.pairs.append(.init(key: key, value: value))

56
Sources/Model/Key.swift Normal file
View File

@ -0,0 +1,56 @@
public extension PureYAML.Model {
/// YAML mapping key node.
enum Key: Equatable, Hashable, Sendable, CustomStringConvertible {
case string(String)
case sequence([Value])
case mapping(Mapping)
public init(value: Value) {
switch value {
case .null:
self = .string("null")
case let .bool(value):
self = .string(value ? "true" : "false")
case let .double(value):
self = .string(String(value))
case let .int(value):
self = .string(String(value))
case let .mapping(value):
self = .mapping(value)
case let .sequence(value):
self = .sequence(value)
case let .string(value):
self = .string(value)
}
}
public var stringValue: String? {
guard case let .string(value) = self else {
return nil
}
return value
}
public var value: Value {
switch self {
case let .mapping(value):
.mapping(value)
case let .sequence(value):
.sequence(value)
case let .string(value):
.string(value)
}
}
public var description: String {
switch self {
case let .mapping(value):
value.flowDescription
case let .sequence(value):
value.flowDescription
case let .string(value):
value
}
}
}
}

View File

@ -1,6 +1,6 @@
public extension PureYAML.Model {
/// Ordered YAML mapping.
struct Mapping: Equatable, Sendable {
struct Mapping: Equatable, Hashable, Sendable {
public var pairs: [Pair]
public init(_ pairs: [Pair] = []) {
@ -8,7 +8,11 @@ public extension PureYAML.Model {
}
public subscript(key: String) -> Value? {
pairs.first { $0.key == key }?.value
pairs.first { $0.keyNode == .string(key) }?.value
}
public subscript(keyNode: Key) -> Value? {
pairs.first { $0.keyNode == keyNode }?.value
}
}
}

View File

@ -0,0 +1,74 @@
extension PureYAML.Model.Value {
var flowDescription: String {
switch self {
case .null:
"null"
case let .bool(value):
value ? "true" : "false"
case let .double(value):
String(value)
case let .int(value):
String(value)
case let .mapping(value):
value.flowDescription
case let .sequence(value):
value.flowDescription
case let .string(value):
value.quotedFlowDescription
}
}
}
extension PureYAML.Model.Mapping {
var flowDescription: String {
let pairs = pairs
.map { pair in
"\(pair.keyNode.flowDescription): \(pair.value.flowDescription)"
}
.joined(separator: ", ")
return "{\(pairs)}"
}
}
extension PureYAML.Model.Key {
var flowDescription: String {
switch self {
case let .mapping(value):
value.flowDescription
case let .sequence(value):
value.flowDescription
case let .string(value):
value.quotedFlowDescription
}
}
}
extension [PureYAML.Model.Value] {
var flowDescription: String {
"[\(map(\.flowDescription).joined(separator: ", "))]"
}
}
private extension String {
var quotedFlowDescription: String {
var output = "\""
for character in self {
switch character {
case "\"":
output += "\\\""
case "\\":
output += "\\\\"
case "\n":
output += "\\n"
case "\r":
output += "\\r"
case "\t":
output += "\\t"
default:
output.append(character)
}
}
output += "\""
return output
}
}

View File

@ -1,14 +1,27 @@
public extension PureYAML.Model {
/// One key-value entry in an ordered YAML mapping.
struct Pair: Equatable, Sendable {
public var key: String
struct Pair: Equatable, Hashable, Sendable {
public var keyNode: Key
public var value: Value
public var key: String {
get { keyNode.stringValue ?? keyNode.description }
set { keyNode = .string(newValue) }
}
public init(
key: String,
value: Value,
) {
self.key = key
keyNode = .string(key)
self.value = value
}
public init(
keyNode: Key,
value: Value,
) {
self.keyNode = keyNode
self.value = value
}
}

View File

@ -0,0 +1,51 @@
public extension PureYAML.Model.Value {
static func == (lhs: Self, rhs: Self) -> Bool {
switch (lhs, rhs) {
case (.null, .null):
true
case let (.bool(lhs), .bool(rhs)):
lhs == rhs
case let (.double(lhs), .double(rhs)):
lhs == rhs || (lhs.isNaN && rhs.isNaN)
case let (.int(lhs), .int(rhs)):
lhs == rhs
case let (.mapping(lhs), .mapping(rhs)):
lhs == rhs
case let (.sequence(lhs), .sequence(rhs)):
lhs == rhs
case let (.string(lhs), .string(rhs)):
lhs == rhs
default:
false
}
}
func hash(into hasher: inout Hasher) {
switch self {
case .null:
hasher.combine(0)
case let .bool(value):
hasher.combine(1)
hasher.combine(value)
case let .double(value):
hasher.combine(2)
if value.isNaN {
hasher.combine("nan")
} else {
hasher.combine(value)
}
case let .int(value):
hasher.combine(3)
hasher.combine(value)
case let .mapping(value):
hasher.combine(4)
hasher.combine(value)
case let .sequence(value):
hasher.combine(5)
hasher.combine(value)
case let .string(value):
hasher.combine(6)
hasher.combine(value)
}
}
}

View File

@ -1,6 +1,6 @@
public extension PureYAML.Model {
/// Lossless-enough YAML value tree for the first parser milestone.
enum Value: Equatable, Sendable {
enum Value: Equatable, Hashable, Sendable {
case null
case bool(Bool)
case int(Int)

View File

@ -1,12 +1,16 @@
extension PureYAML.Parsing.EventComposer {
struct ComposedMappingKey {
var value: String
var keyNode: PureYAML.Model.Key
var scalarValue: String?
var tag: String?
var style: PureYAML.Parsing.ScalarStyle
var style: PureYAML.Parsing.ScalarStyle?
var isMergeKey: Bool {
tag == "tag:yaml.org,2002:merge"
|| (tag == nil && style == .plain && value == "<<")
guard let scalarValue else {
return false
}
return tag == "tag:yaml.org,2002:merge"
|| (tag == nil && style == .plain && scalarValue == "<<")
}
}
@ -79,19 +83,19 @@ extension PureYAML.Parsing.EventComposer {
return localPairs
}
let localKeys = Set(localPairs.map(\.key))
var inheritedKeys = Set<String>()
let localKeys = Set(localPairs.map(\.keyNode))
var inheritedKeys = Set<PureYAML.Model.Key>()
var inheritedPairs: [PureYAML.Model.Pair] = []
for source in mergeSources {
var sourceKeys = Set<String>()
var sourceKeys = Set<PureYAML.Model.Key>()
for pair in source {
guard !localKeys.contains(pair.key),
!inheritedKeys.contains(pair.key)
guard !localKeys.contains(pair.keyNode),
!inheritedKeys.contains(pair.keyNode)
else {
continue
}
inheritedPairs.append(pair)
sourceKeys.insert(pair.key)
sourceKeys.insert(pair.keyNode)
}
inheritedKeys.formUnion(sourceKeys)
}

View File

@ -145,7 +145,7 @@ extension PureYAML.Parsing.EventComposer {
try mergeSources.append(contentsOf: composeMergeSources())
} else {
let value = try composeNode()
localPairs.append(PureYAML.Model.Pair(key: key.value, value: value))
localPairs.append(PureYAML.Model.Pair(keyNode: key.keyNode, value: value))
}
}
_ = try expect("mapping end") { $0.isMappingEnd }
@ -158,13 +158,19 @@ extension PureYAML.Parsing.EventComposer {
throw unexpectedEvent(expected: "mapping key")
}
guard case let .scalar(value, anchor, tag, style, _) = event else {
let mark = event.mark
throw PureYAML.Parsing.ParseError.expectedScalarKey(line: mark.line, column: mark.column)
let value = try composeNode()
return ComposedMappingKey(
keyNode: PureYAML.Model.Key(value: value),
scalarValue: nil,
tag: nil,
style: nil,
)
}
index += 1
store(.string(value), anchor: anchor)
return ComposedMappingKey(
value: value,
keyNode: .string(value),
scalarValue: value,
tag: scalarParser.normalizedTag(tag),
style: style,
)

View File

@ -7,5 +7,11 @@ extension PureYAML.Parsing {
static var none: Self {
Self(anchor: nil, tag: nil, mark: nil)
}
mutating func mergeUnset(from other: Self) {
anchor = anchor ?? other.anchor
tag = tag ?? other.tag
mark = mark ?? other.mark
}
}
}

View File

@ -30,13 +30,20 @@ extension PureYAML.Parsing.TokenEventParser {
endMark: inout PureYAML.Parsing.Mark,
) throws {
if hasNodeOnSameLine(after: entry) {
let properties = consumeProperties()
if cursor.current?.kind.isIndent == true {
_ = try expect("indent") { $0.isIndent }
endMark = try parseNode(properties: properties).endMark
_ = try expect("dedent") { $0.isDedent }
return
}
if cursor.current?.kind.isMappingKey == true {
endMark = try parseBlockMapping(
properties: .none,
properties: properties,
includeIndentedContinuation: true,
).endMark
} else {
endMark = try parseNode().endMark
endMark = try parseNode(properties: properties).endMark
}
} else if cursor.current?.kind.isIndent == true {
_ = try expect("indent") { $0.isIndent }

View File

@ -9,14 +9,15 @@ extension PureYAML.Parsing.TokenEventParser {
return false
}
let properties = consumeProperties()
if cursor.current?.kind.isIndent == true {
_ = try expect("indent") { $0.isIndent }
let result = try parseNode()
let result = try parseNode(properties: properties)
_ = try expect("dedent") { $0.isDedent }
return result
}
if hasNodeOnSameLine(after: valueIndicator) {
return try parseNode()
if properties != .none || hasNodeOnSameLine(after: valueIndicator) {
return try parseNode(properties: properties)
}
let mark = valueIndicator.endMark

View File

@ -95,6 +95,15 @@ extension PureYAML.Parsing.TokenEventParser {
mutating func parseNode() throws -> PureYAML.Parsing.NodeResult {
let properties = consumeProperties()
return try parseNode(properties: properties)
}
mutating func parseNode(
properties initialProperties: PureYAML.Parsing.NodeProperties,
) throws -> PureYAML.Parsing.NodeResult {
var properties = initialProperties
properties.mergeUnset(from: consumeProperties())
guard let token = cursor.current, !token.kind.isTerminator else {
let mark = properties.mark ?? cursor.current?.mark ?? cursor.previous?.endMark ?? .start
throw PureYAML.Parsing.ParseError.expectedNode(line: mark.line, column: mark.column)

View File

@ -2,10 +2,13 @@ public extension PureYAML.Validation.Path {
/// One path step inside a YAML document.
enum Component: Equatable, Sendable, CustomStringConvertible {
case key(String)
case complexKey(PureYAML.Model.Key)
case index(Int)
public var description: String {
switch self {
case let .complexKey(key):
"[?\(key.flowDescription)]"
case let .key(key):
key.isDotPathSafeKey ? ".\(key)" : "[\"\(key.escapedPathKey)\"]"
case let .index(index):

View File

@ -6,16 +6,16 @@ public extension PureYAML.Validation.Rule {
return []
}
var seen = Set<String>()
var seen = Set<PureYAML.Model.Key>()
var issues: [PureYAML.Validation.Issue] = []
for pair in mapping.pairs {
if seen.insert(pair.key).inserted {
if seen.insert(pair.keyNode).inserted {
continue
} else {
issues.append(PureYAML.Validation.Issue(
severity: .error,
reason: "Duplicate mapping key '\(pair.key)'",
path: context.path.appending(.key(pair.key)),
reason: "Duplicate mapping key '\(pair.keyNode.description)'",
path: context.path.appending(pair.keyNode.pathComponent),
))
}
}
@ -28,3 +28,14 @@ public extension PureYAML.Validation.Rule {
}
}
}
extension PureYAML.Model.Key {
var pathComponent: PureYAML.Validation.Path.Component {
switch self {
case let .string(value):
.key(value)
case .mapping, .sequence:
.complexKey(self)
}
}
}

View File

@ -84,7 +84,7 @@ extension PureYAML.Validation.Validator {
walk(
root: root,
subject: pair.value,
path: path.appending(.key(pair.key)),
path: path.appending(pair.keyNode.pathComponent),
issues: &issues,
)
}

View File

@ -0,0 +1,35 @@
@testable import PureYAML
import Testing
@Suite("Anchored Indented Node Parsing")
struct AnchoredIndentedNodeParsingTests {
@Test("Resolves anchors on indented mapping and sequence nodes")
func test_anchorsOnIndentedMappingAndSequenceNodes() throws {
let value = try PureYAML.parse(
"""
mapping: &mapping
name: Example
sequence:
- &item
title: First
- *item
alias: *mapping
""",
)
let root = requireMapping(value)
#expect(root?["mapping"] == root?["alias"])
guard
case let .sequence(values)? = root?["sequence"],
values.count == 2,
case let .mapping(first) = values[0],
case let .mapping(second) = values[1]
else {
recordIssue("expected anchored sequence item mapping")
return
}
#expect(first["title"] == .string("First"))
#expect(second["title"] == .string("First"))
#expect(values[0] == values[1])
}
}

View File

@ -0,0 +1,54 @@
@testable import PureYAML
import Testing
@Suite("Complex Mapping Key Codable")
struct ComplexMappingKeyCodableTests {
@Test("Keeps keyed Codable string-only for documents with complex keys")
func test_keepsKeyedCodableStringOnlyForDocumentsWithComplexKeys() throws {
let value = PureYAML.Model.Value.mapping(.init([
.init(keyNode: .sequence([.string("ignored")]), value: .int(1)),
.init(key: "title", value: .string("Visible")),
]))
let decoded = try PureYAML.decode(CodingKeyListProbe.self, from: value)
#expect(decoded.keys == ["title"])
#expect(try PureYAML.decode(TitleOnly.self, from: value) == .init(title: "Visible"))
do {
_ = try PureYAML.decode(ComplexKeyProbe.self, from: value)
recordIssue("expected key-not-found error")
} catch let error as PureYAML.Decoding.Error {
#expect(error == .keyNotFound(
key: "[\"ignored\"]",
path: .init([.key("[\"ignored\"]")]),
))
#expect(error.description == """
Missing required key '[\"ignored\"]' at $["[\\\"ignored\\\"]"]
""")
}
}
}
private struct TitleOnly: Decodable, Equatable {
var title: String
}
private struct CodingKeyListProbe: Decodable, Equatable {
var keys: [String]
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: DynamicCodingKey.self)
keys = container.allKeys.map(\.stringValue)
}
}
private struct ComplexKeyProbe: Decodable {
enum CodingKeys: String, CodingKey {
case ignored = "[\"ignored\"]"
}
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
_ = try container.decode(Int.self, forKey: .ignored)
}
}

View File

@ -0,0 +1,55 @@
@testable import PureYAML
import Testing
@Suite("Complex Mapping Key Dumper")
struct ComplexMappingKeyDumperTests {
@Test("Dumps block complex keys with deterministic explicit key syntax")
func test_dumpsBlockComplexKeysWithDeterministicExplicitKeySyntax() throws {
let value = PureYAML.Model.Value.mapping(.init([
.init(
keyNode: .sequence([.string("Detroit Tigers"), .string("Chicago Cubs")]),
value: .sequence([.string("2001-07-23")]),
),
.init(
keyNode: .mapping(.init([
.init(key: "name", value: .string("Example")),
])),
value: .string("active"),
),
]))
let yaml = PureYAML.dump(value)
#expect(yaml == """
? ["Detroit Tigers", "Chicago Cubs"]
:
- "2001-07-23"
? {"name": "Example"}
:
"active"
""")
#expect(!yaml.contains("? [Detroit Tigers"))
#expect(try PureYAML.parse(yaml) == value)
}
@Test("Dumps flow complex keys with deterministic explicit key syntax")
func test_dumpsFlowComplexKeysWithDeterministicExplicitKeySyntax() throws {
let value = PureYAML.Model.Value.mapping(.init([
.init(
keyNode: .sequence([.string("a"), .string("b")]),
value: .mapping(.init([
.init(key: "mode", value: .string("active")),
])),
),
]))
let options = PureYAML.Emitting.Options(collectionStyle: .flow)
let yaml = PureYAML.dump(value, options: options)
#expect(yaml == """
{? ["a", "b"]: {"mode": "active"}}
""")
#expect(!yaml.contains("\n -"))
#expect(try PureYAML.parse(yaml) == value)
}
}

View File

@ -0,0 +1,123 @@
@testable import PureYAML
import Testing
@Suite("Complex Mapping Key Parsing")
struct ComplexMappingKeyParsingTests {
@Test("Parses block sequence keys as first-class key nodes")
func test_parsesBlockSequenceKeysAsFirstClassKeyNodes() throws {
let value = try PureYAML.parse("""
? [Detroit Tigers, Chicago Cubs]
:
- 2001-07-23
""")
let expectedKey = PureYAML.Model.Key.sequence([
.string("Detroit Tigers"),
.string("Chicago Cubs"),
])
#expect(value == .mapping(.init([
.init(
keyNode: expectedKey,
value: .sequence([.string("2001-07-23")]),
),
])))
#expect(value.rootMapping?.pairs.first?.keyNode == expectedKey)
#expect(value.rootMapping?.pairs.first?.key == "[\"Detroit Tigers\", \"Chicago Cubs\"]")
#expect(value.rootMapping?["Detroit Tigers"] == nil)
#expect(value.rootMapping?[expectedKey] == .sequence([.string("2001-07-23")]))
}
@Test("Parses block mapping keys as first-class key nodes")
func test_parsesBlockMappingKeysAsFirstClassKeyNodes() throws {
let value = try PureYAML.parse("""
? {name: Example, version: 1}
: active
""")
let expectedKey = PureYAML.Model.Key.mapping(.init([
.init(key: "name", value: .string("Example")),
.init(key: "version", value: .int(1)),
]))
#expect(value == .mapping(.init([
.init(keyNode: expectedKey, value: .string("active")),
])))
#expect(value.rootMapping?.pairs.first?.keyNode.description == """
{"name": "Example", "version": 1}
""")
#expect(value.rootMapping?["name"] == nil)
#expect(value.rootMapping?[expectedKey] == .string("active"))
}
@Test("Keeps scalar mapping keys string-keyed even when tags look typed")
func test_keepsScalarMappingKeysStringKeyedEvenWhenTagsLookTyped() throws {
let value = try PureYAML.parse("""
? !!int 1
: integer-looking
? true
: boolean-looking
""")
#expect(value == .mapping(.init([
.init(key: "1", value: .string("integer-looking")),
.init(key: "true", value: .string("boolean-looking")),
])))
#expect(value.rootMapping?["1"] == .string("integer-looking"))
#expect(value.rootMapping?["true"] == .string("boolean-looking"))
#expect(value.rootMapping?[.sequence([.string("1")])] == nil)
}
@Test("Parses alias-backed complex keys without string coercion")
func test_parsesAliasBackedComplexKeysWithoutStringCoercion() throws {
let value = try PureYAML.parse("""
pair: &pair [a, b]
? *pair
: value
""")
let key = PureYAML.Model.Key.sequence([.string("a"), .string("b")])
#expect(value == .mapping(.init([
.init(key: "pair", value: .sequence([.string("a"), .string("b")])),
.init(keyNode: key, value: .string("value")),
])))
#expect(value.rootMapping?["pair"] == .sequence([.string("a"), .string("b")]))
#expect(value.rootMapping?[key] == .string("value"))
#expect(value.rootMapping?["[\"a\", \"b\"]"] == nil)
}
@Test("Expands merged mappings with complex-key local overrides")
func test_expandsMergedMappingsWithComplexKeyLocalOverrides() throws {
let value = try PureYAML.parse("""
defaults: &defaults
? [a, b]
: inherited
name: inherited-name
service:
<<: *defaults
? [a, b]
: local
""")
let service = value.rootMapping?["service"]
let key = PureYAML.Model.Key.sequence([.string("a"), .string("b")])
#expect(service == .mapping(.init([
.init(key: "name", value: .string("inherited-name")),
.init(keyNode: key, value: .string("local")),
])))
#expect(try PureYAML.validate(value, strict: false).isEmpty)
#expect(service?.rootMapping?[key] == .string("local"))
#expect(service?.rootMapping?["[\"a\", \"b\"]"] == nil)
}
@Test("Rejects complex keys that are not followed by mapping values")
func test_rejectsComplexKeysThatAreNotFollowedByMappingValues() {
expectParseError("""
? [a, b]
- invalid
""", .unexpectedToken(
expected: "mapping value",
actual: "blockEntry",
line: 2,
column: 1,
))
}
}

View File

@ -0,0 +1,10 @@
@testable import PureYAML
extension PureYAML.Model.Value {
var rootMapping: PureYAML.Model.Mapping? {
guard case let .mapping(mapping) = self else {
return nil
}
return mapping
}
}

View File

@ -0,0 +1,116 @@
@testable import PureYAML
import Testing
@Suite("Complex Mapping Key Validation")
struct ComplexMappingKeyValidationTests {
@Test("Validates duplicate complex keys with exact diagnostics")
func test_validatesDuplicateComplexKeysWithExactDiagnostics() {
let key = PureYAML.Model.Key.sequence([.string("a"), .string("b")])
let value = PureYAML.Model.Value.mapping(.init([
.init(keyNode: key, value: .string("first")),
.init(keyNode: key, value: .string("second")),
]))
let expectedIssue = PureYAML.Validation.Issue(
severity: .error,
reason: "Duplicate mapping key '[\"a\", \"b\"]'",
path: .init([.complexKey(key)]),
)
let result = PureYAML.Validation.Validator().collect(value)
#expect(result.issues == [expectedIssue])
#expect(result.issues.map(\.description) == [
"error: Duplicate mapping key '[\"a\", \"b\"]' at $[?[\"a\", \"b\"]]",
])
expectValidationError(value) { collection in
#expect(collection.issues == [expectedIssue])
#expect(collection.description == expectedIssue.description)
}
}
@Test("Validates duplicate complex mapping keys with exact diagnostics")
func test_validatesDuplicateComplexMappingKeysWithExactDiagnostics() {
let key = PureYAML.Model.Key.mapping(.init([
.init(key: "name", value: .string("service")),
]))
let value = PureYAML.Model.Value.mapping(.init([
.init(keyNode: key, value: .string("first")),
.init(keyNode: key, value: .string("second")),
]))
let expectedIssue = PureYAML.Validation.Issue(
severity: .error,
reason: "Duplicate mapping key '{\"name\": \"service\"}'",
path: .init([.complexKey(key)]),
)
expectValidationError(value) { collection in
#expect(collection.issues == [expectedIssue])
#expect(collection.description == """
error: Duplicate mapping key '{"name": "service"}' at $[?{"name": "service"}]
""")
}
}
@Test("Validates duplicate complex keys containing NaN deterministically")
func test_validatesDuplicateComplexKeysContainingNanDeterministically() {
let key = PureYAML.Model.Key.sequence([.double(.nan)])
let value = PureYAML.Model.Value.mapping(.init([
.init(keyNode: key, value: .string("first")),
.init(keyNode: key, value: .string("second")),
]))
let expectedIssue = PureYAML.Validation.Issue(
severity: .error,
reason: "Duplicate mapping key '[nan]'",
path: .init([.complexKey(key)]),
)
#expect(PureYAML.Model.Value.double(.nan) == .double(.nan))
expectValidationError(value) { collection in
#expect(collection.issues == [expectedIssue])
#expect(collection.description == "error: Duplicate mapping key '[nan]' at $[?[nan]]")
}
}
@Test("Does not collapse scalar strings and complex keys that render alike")
func test_doesNotCollapseScalarStringsAndComplexKeysThatRenderAlike() throws {
let sequenceKey = PureYAML.Model.Key.sequence([.string("a")])
let value = PureYAML.Model.Value.mapping(.init([
.init(key: "[\"a\"]", value: .string("scalar")),
.init(keyNode: sequenceKey, value: .string("complex")),
]))
let issues = try PureYAML.validate(value, strict: false)
#expect(issues.isEmpty)
#expect(PureYAML.Validation.Validator().collect(value).issues.isEmpty)
#expect(value.rootMapping?["[\"a\"]"] == .string("scalar"))
#expect(value.rootMapping?[sequenceKey] == .string("complex"))
#expect(value.rootMapping?["a"] == nil)
}
@Test("Traverses values under complex keys with exact paths")
func test_traversesValuesUnderComplexKeysWithExactPaths() {
let key = PureYAML.Model.Key.mapping(.init([
.init(key: "name", value: .string("service")),
]))
let value = PureYAML.Model.Value.mapping(.init([
.init(keyNode: key, value: .mapping(.init([
.init(key: "mode", value: .string("legacy")),
]))),
]))
let expectedIssue = PureYAML.Validation.Issue(
severity: .error,
reason: "Legacy mode is not allowed",
path: .init([.complexKey(key), .key("mode")]),
)
let result = PureYAML.Validation.Validator.blank
.validating(legacyModeRule(severity: .error))
.collect(value)
#expect(result.issues == [expectedIssue])
#expect(result.issues.first?.description == """
error: Legacy mode is not allowed at $[?{"name": "service"}].mode
""")
}
}

View File

@ -57,25 +57,6 @@ enum UnsupportedYAMLGapsFixtures {
expected: .unsupportedMultiDocumentStream(line: 3),
expectedDescription: "multi-document streams are not supported at line 3",
),
ParseErrorCase(
name: "flow sequence mapping key",
yaml: """
? [Detroit Tigers, Chicago Cubs]
:
- 2001-07-23
""",
expected: .expectedScalarKey(line: 1, column: 3),
expectedDescription: "expected a scalar mapping key at line 1, column 3",
),
ParseErrorCase(
name: "flow mapping key",
yaml: """
? {name: Example}
: value
""",
expected: .expectedScalarKey(line: 1, column: 3),
expectedDescription: "expected a scalar mapping key at line 1, column 3",
),
ParseErrorCase(
name: "unknown directive",
yaml: """

View File

@ -101,6 +101,7 @@ The initial parser supports:
- literal and folded block scalars
- anchors and aliases
- merge-key expansion for mappings and sequences of mappings
- complex mapping keys as sequence or mapping values
- YAML directives, document markers, and multi-document streams
- explicit built-in scalar tags for strings, integers, floats, booleans, and
nulls
@ -142,12 +143,29 @@ normal parsing is the semantic surface that expands merge keys into
unsupported built-in tags and built-in tags applied to the wrong node kind with
the same path and stream issue types used by the main validator.
Mappings are ordered pairs. String keys remain the common path through
`Model.Pair.key`, `Model.Mapping` string lookup, validation dot/bracket paths,
and keyed `Codable`. Complex keys are represented by `Model.Key` and stored on
each pair as `Model.Pair.keyNode`. Sequence and mapping keys stay visible for
validation, dumping, and model inspection; they are not silently flattened into
Swift dictionary keys. This mirrors the reference behavior that treats mapping
keys as nodes, while keeping PureYAML's public typed-conversion boundary
string-keyed and dependency-free.
The complex-key path contract is deterministic. Simple string keys keep the
existing `.key` path rendering. Sequence and mapping keys use an explicit
complex-key path component whose description is derived from the same stable
flow YAML representation used by duplicate-key diagnostics. That keeps paths
unambiguous even for YAML states that ordinary loaders collapse or cannot
represent in native dictionaries.
The dumper emits deterministic YAML from the model. The default policy is
block-style collections with quoted strings. `Emitting.Options` can opt into
conservative plain scalars, safe literal block scalars for multiline strings,
and flow-style collections. Literal block scalar emission is intentionally
limited to strings whose lines round-trip through the current parser; other
multiline strings stay quoted. Flow collections always use inline scalars.
Complex mapping keys emit as explicit YAML keys in block or flow style.
The validator traverses parsed YAML values and reports path-aware issues. The
default rule rejects duplicate mapping keys. Callers can compose custom rules on
@ -162,9 +180,12 @@ so callers keep both the document index and the exact path inside that document.
Typed conversion supports scalar single-value Decodable and Encodable values,
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.
elements, nested sequences, and keyed mapping elements. Keyed `Codable` is
deliberately string-keyed: complex mapping keys remain available in
`Model.Value`, but do not appear in `allKeys`, `contains(_:)`, or keyed property
lookup. `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
@ -177,10 +198,10 @@ must stay in `PureYAML.Model.Value`; known YAML should use typed `Codable`.
This is a deterministic validation decision. A Swift dictionary-shaped value
cannot preserve all YAML mapping entries when keys repeat, cannot represent
complex mapping keys with the current public model, and makes tag normalization
ambiguous. Merge keys are handled explicitly by the parser instead of being
hidden inside an arbitrary-value projection. The research constructor also
includes Foundation-specific scalar conversions, which would break the
complex mapping keys without a caller-owned key policy, and makes tag
normalization ambiguous. Merge keys are handled explicitly by the parser instead
of being hidden inside an arbitrary-value projection. The research constructor
also includes Foundation-specific scalar conversions, which would break the
dependency-free and WASM-compatible library boundary.
Application code may project `Model.Value` or `Tagged.Node` into JSON-like or
@ -192,12 +213,11 @@ data-loss and constructor policy explicitly.
Compatibility should be added in small, test-backed slices:
1. Multi-document stream dumping.
2. First-class complex mapping keys.
3. Explicit constructor APIs for caller-owned tagged conversion if a
2. Explicit constructor APIs for caller-owned tagged conversion if a
dependency-free design proves worthwhile (#39).
4. Additional built-in validation rules beyond duplicate-key behavior.
5. Broader Codable compatibility beyond scalar, keyed, and unkeyed containers.
6. Yams corpus comparison tests in a separate compatibility suite.
3. Additional built-in validation rules beyond duplicate-key behavior.
4. Broader Codable compatibility beyond scalar, keyed, and unkeyed containers.
5. Yams corpus comparison tests in a separate compatibility suite.
The private `PureYAMLResearch` repository may be used to study Yams behavior, but
the public implementation must be written in Swift and must not copy C parser

View File

@ -76,6 +76,7 @@ add a fixture beside the closest existing suite.
| Built-in scalar tags | `!!str`, `!!int`, `!!float`, `!!bool`, and `!!null` are applied when valid. | `ScalarCompatibilityFixtures` |
| Tag-preserving parsing | `parseTagged(_:)` and `parseTaggedStream(_:)` preserve explicit scalar and collection tags for compatibility analysis. | `TaggedCompatibilityTests` |
| Tagged validation | Unsupported built-in tags and built-in tags applied to the wrong node kind are reported with exact paths. Project-specific tags are allowed by default. | `TaggedCompatibilityTests` |
| Complex mapping keys | Sequence and mapping keys parse into `Model.Pair.keyNode`, validate with exact paths, and dump as explicit YAML keys. String-keyed APIs remain string-keyed. | `ComplexMappingKeyParsingTests`, `ComplexMappingKeyValidationTests`, `ComplexMappingKeyDumperTests`, `ComplexMappingKeyCodableTests` |
| Emitting | Deterministic block output by default, with opt-in flow collections and conservative literal blocks. | `DumperTests`, `EmitterCorpusTests` |
| Typed conversion | Scalar, keyed, unkeyed, nested, sequence, dynamic-key, and super-coder cases are covered. | `CodingTests`, `CodableCompatibilityTests` |
| Validation | Duplicate mapping keys are reported with exact issue paths. | `ValidationTests`, `ValidationModelTests` |
@ -90,7 +91,6 @@ explicit fallback value tree. This avoids silent compatibility drift.
|---|---|---|
| Passing a multi-document stream to `PureYAML.parse(_:)` | Throws `unsupportedMultiDocumentStream`; use `PureYAML.parseStream(_:)` when multiple documents are expected. | `UnsupportedYAMLGapsFixtures.parseErrors`, `StreamParsingTests` |
| Content after explicit document end | Throws `unsupportedMultiDocumentStream`; trailing comments after `...` remain non-content. | `UnsupportedYAMLGapsFixtures` |
| Complex mapping keys such as `? [a, b]` or `? {name: Example}` | Throws `expectedScalarKey`. | `UnsupportedYAMLGapsFixtures.parseErrors` |
| Unknown directives such as `%FOO` | Throws `unsupportedDirective`. | `UnsupportedYAMLGapsFixtures.parseErrors` |
| `%TAG` after content or after document start | Throws `unsupportedDirective`. | `UnsupportedYAMLGapsFixtures.parseErrors` |
| Undefined aliases | Throws `undefinedAlias`. | `CollectionCompatibilityFixtures.parseErrors` |
@ -118,7 +118,6 @@ explicit fallback value tree. This avoids silent compatibility drift.
Do not migrate a caller yet if it requires:
- multi-document stream dumping;
- complex mapping keys as first-class keys;
- binary or timestamp conversion into Foundation types;
- arbitrary `Any` load and dump APIs;
- custom scalar constructors or resolvers;

View File

@ -160,6 +160,26 @@ includes:
Typed decoding runs the default validator first. Duplicate mapping keys are
reported before Swift `Decodable` construction starts.
Complex mapping keys are supported in the model layer and can be parsed,
validated, and dumped:
```swift
let value = try PureYAML.parse("""
? [Detroit Tigers, Chicago Cubs]
:
- 2001-07-23
""")
if case let .mapping(mapping) = value {
let firstKey = mapping.pairs.first?.keyNode
}
```
Keyed `Codable` remains string-keyed. Sequence and mapping keys stay visible in
`PureYAML.Model.Value`, but are skipped by `KeyedDecodingContainer.allKeys` and
cannot be read as Swift coding keys. Inspect `Model.Value` directly when a
caller needs those keys.
## Emitting Options
The default dumper emits deterministic block-style YAML with quoted strings.