diff --git a/CHANGELOG.md b/CHANGELOG.md index 057813f..c26b99a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index a45e06e..7fe1524 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/Sources/Decoding/Decoding.KeyedContainer.swift b/Sources/Decoding/Decoding.KeyedContainer.swift index 5c8aeae..9ffb032 100644 --- a/Sources/Decoding/Decoding.KeyedContainer.swift +++ b/Sources/Decoding/Decoding.KeyedContainer.swift @@ -5,7 +5,9 @@ struct KeyedDecodingContainerImpl: 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 { diff --git a/Sources/Emitting/Dumper.Mapping.swift b/Sources/Emitting/Dumper.Mapping.swift new file mode 100644 index 0000000..a5a38d5 --- /dev/null +++ b/Sources/Emitting/Dumper.Mapping.swift @@ -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) + } + } +} diff --git a/Sources/Emitting/Dumper.swift b/Sources/Emitting/Dumper.swift index 9e20870..6de3d37 100644 --- a/Sources/Emitting/Dumper.swift +++ b/Sources/Emitting/Dumper.swift @@ -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) diff --git a/Sources/Encoding/Encoding.Encoder.swift b/Sources/Encoding/Encoding.Encoder.swift index c187b06..9b99a63 100644 --- a/Sources/Encoding/Encoding.Encoder.swift +++ b/Sources/Encoding/Encoding.Encoder.swift @@ -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)) diff --git a/Sources/Model/Key.swift b/Sources/Model/Key.swift new file mode 100644 index 0000000..4256c4d --- /dev/null +++ b/Sources/Model/Key.swift @@ -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 + } + } + } +} diff --git a/Sources/Model/Mapping.swift b/Sources/Model/Mapping.swift index 8588c2a..fe6dcd1 100644 --- a/Sources/Model/Mapping.swift +++ b/Sources/Model/Mapping.swift @@ -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 } } } diff --git a/Sources/Model/Model.FlowDescription.swift b/Sources/Model/Model.FlowDescription.swift new file mode 100644 index 0000000..9a67e97 --- /dev/null +++ b/Sources/Model/Model.FlowDescription.swift @@ -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 + } +} diff --git a/Sources/Model/Pair.swift b/Sources/Model/Pair.swift index 8e960d9..60c32b4 100644 --- a/Sources/Model/Pair.swift +++ b/Sources/Model/Pair.swift @@ -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 } } diff --git a/Sources/Model/Value.Hashable.swift b/Sources/Model/Value.Hashable.swift new file mode 100644 index 0000000..c685a8b --- /dev/null +++ b/Sources/Model/Value.Hashable.swift @@ -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) + } + } +} diff --git a/Sources/Model/Value.swift b/Sources/Model/Value.swift index d7f06f2..e77ffa3 100644 --- a/Sources/Model/Value.swift +++ b/Sources/Model/Value.swift @@ -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) diff --git a/Sources/Parsing/EventComposer.Merge.swift b/Sources/Parsing/EventComposer.Merge.swift index a7ad0a1..baabc6d 100644 --- a/Sources/Parsing/EventComposer.Merge.swift +++ b/Sources/Parsing/EventComposer.Merge.swift @@ -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() + let localKeys = Set(localPairs.map(\.keyNode)) + var inheritedKeys = Set() var inheritedPairs: [PureYAML.Model.Pair] = [] for source in mergeSources { - var sourceKeys = Set() + var sourceKeys = Set() 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) } diff --git a/Sources/Parsing/EventComposer.swift b/Sources/Parsing/EventComposer.swift index c5fdcf9..24d88f4 100644 --- a/Sources/Parsing/EventComposer.swift +++ b/Sources/Parsing/EventComposer.swift @@ -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, ) diff --git a/Sources/Parsing/NodeProperties.swift b/Sources/Parsing/NodeProperties.swift index ff94a78..f53a175 100644 --- a/Sources/Parsing/NodeProperties.swift +++ b/Sources/Parsing/NodeProperties.swift @@ -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 + } } } diff --git a/Sources/Parsing/TokenEventParser.Block.swift b/Sources/Parsing/TokenEventParser.Block.swift index 3307a33..8fab4ee 100644 --- a/Sources/Parsing/TokenEventParser.Block.swift +++ b/Sources/Parsing/TokenEventParser.Block.swift @@ -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 } diff --git a/Sources/Parsing/TokenEventParser.Helpers.swift b/Sources/Parsing/TokenEventParser.Helpers.swift index 27fa8e3..e955b61 100644 --- a/Sources/Parsing/TokenEventParser.Helpers.swift +++ b/Sources/Parsing/TokenEventParser.Helpers.swift @@ -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 diff --git a/Sources/Parsing/TokenEventParser.swift b/Sources/Parsing/TokenEventParser.swift index 70494a2..1ad3344 100644 --- a/Sources/Parsing/TokenEventParser.swift +++ b/Sources/Parsing/TokenEventParser.swift @@ -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) diff --git a/Sources/Validation/Path.Component.swift b/Sources/Validation/Path.Component.swift index 2038a6f..6a26a93 100644 --- a/Sources/Validation/Path.Component.swift +++ b/Sources/Validation/Path.Component.swift @@ -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): diff --git a/Sources/Validation/Rule.Builtins.swift b/Sources/Validation/Rule.Builtins.swift index 4bb0b1a..e0e06e6 100644 --- a/Sources/Validation/Rule.Builtins.swift +++ b/Sources/Validation/Rule.Builtins.swift @@ -6,16 +6,16 @@ public extension PureYAML.Validation.Rule { return [] } - var seen = Set() + var seen = Set() 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) + } + } +} diff --git a/Sources/Validation/Validator.swift b/Sources/Validation/Validator.swift index 47d4116..1d524d8 100644 --- a/Sources/Validation/Validator.swift +++ b/Sources/Validation/Validator.swift @@ -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, ) } diff --git a/Tests/AnchoredIndentedNodeParsingTests.swift b/Tests/AnchoredIndentedNodeParsingTests.swift new file mode 100644 index 0000000..c7b7929 --- /dev/null +++ b/Tests/AnchoredIndentedNodeParsingTests.swift @@ -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]) + } +} diff --git a/Tests/ComplexMappingKeyCodableTests.swift b/Tests/ComplexMappingKeyCodableTests.swift new file mode 100644 index 0000000..42c2c35 --- /dev/null +++ b/Tests/ComplexMappingKeyCodableTests.swift @@ -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) + } +} diff --git a/Tests/ComplexMappingKeyDumperTests.swift b/Tests/ComplexMappingKeyDumperTests.swift new file mode 100644 index 0000000..283090b --- /dev/null +++ b/Tests/ComplexMappingKeyDumperTests.swift @@ -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) + } +} diff --git a/Tests/ComplexMappingKeyParsingTests.swift b/Tests/ComplexMappingKeyParsingTests.swift new file mode 100644 index 0000000..628bcde --- /dev/null +++ b/Tests/ComplexMappingKeyParsingTests.swift @@ -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, + )) + } +} diff --git a/Tests/ComplexMappingKeyTestSupport.swift b/Tests/ComplexMappingKeyTestSupport.swift new file mode 100644 index 0000000..7989f3e --- /dev/null +++ b/Tests/ComplexMappingKeyTestSupport.swift @@ -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 + } +} diff --git a/Tests/ComplexMappingKeyValidationTests.swift b/Tests/ComplexMappingKeyValidationTests.swift new file mode 100644 index 0000000..a4a11ae --- /dev/null +++ b/Tests/ComplexMappingKeyValidationTests.swift @@ -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 + """) + } +} diff --git a/Tests/UnsupportedYAMLGapsFixtures.swift b/Tests/UnsupportedYAMLGapsFixtures.swift index f8bbc16..e1bbe58 100644 --- a/Tests/UnsupportedYAMLGapsFixtures.swift +++ b/Tests/UnsupportedYAMLGapsFixtures.swift @@ -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: """ diff --git a/docs/DESIGN.md b/docs/DESIGN.md index b202605..c33a609 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -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 diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 65de775..5e8b1f7 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -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; diff --git a/docs/USAGE.md b/docs/USAGE.md index 994a38a..64d9d23 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -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.