feat(model): support complex mapping keys
This commit is contained in:
parent
9c45419496
commit
697b9cc519
@ -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
|
||||
|
||||
|
||||
24
README.md
24
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:
|
||||
|
||||
@ -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 {
|
||||
|
||||
63
Sources/Emitting/Dumper.Mapping.swift
Normal file
63
Sources/Emitting/Dumper.Mapping.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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
56
Sources/Model/Key.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
74
Sources/Model/Model.FlowDescription.swift
Normal file
74
Sources/Model/Model.FlowDescription.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
51
Sources/Model/Value.Hashable.swift
Normal file
51
Sources/Model/Value.Hashable.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
35
Tests/AnchoredIndentedNodeParsingTests.swift
Normal file
35
Tests/AnchoredIndentedNodeParsingTests.swift
Normal 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])
|
||||
}
|
||||
}
|
||||
54
Tests/ComplexMappingKeyCodableTests.swift
Normal file
54
Tests/ComplexMappingKeyCodableTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
55
Tests/ComplexMappingKeyDumperTests.swift
Normal file
55
Tests/ComplexMappingKeyDumperTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
123
Tests/ComplexMappingKeyParsingTests.swift
Normal file
123
Tests/ComplexMappingKeyParsingTests.swift
Normal 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,
|
||||
))
|
||||
}
|
||||
}
|
||||
10
Tests/ComplexMappingKeyTestSupport.swift
Normal file
10
Tests/ComplexMappingKeyTestSupport.swift
Normal 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
|
||||
}
|
||||
}
|
||||
116
Tests/ComplexMappingKeyValidationTests.swift
Normal file
116
Tests/ComplexMappingKeyValidationTests.swift
Normal 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
|
||||
""")
|
||||
}
|
||||
}
|
||||
@ -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: """
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user