feat(tagged): carry mapping keys as full nodes
Closes #12 and closes #13. Tagged.Pair stores keyNode instead of a bare string: alias mapping keys parse on the Tagged path (composer resolves them through the anchors table, dumper re-emits the reference with the mandatory space before the colon since a colon is a legal anchor character), key anchors register for later aliases and re-emit, and a key's presentation style survives dump and reparse. key and keyTag remain as read-through computed accessors and the string-key convenience init keeps most call sites source compatible. A style-less hand-built key keeps the plain-when-safe emission convention. The corpus round-trip law compares key nodes structurally; alias-key documents move from rejected to round-tripping.
This commit is contained in:
parent
81e2794c9c
commit
e51df57c35
13
CHANGELOG.md
13
CHANGELOG.md
@ -9,6 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- `Tagged.Pair` now stores its key as a full node (`keyNode`), closing
|
||||
issues #12 and #13: alias mapping keys (`*b : value`, yaml-test-suite
|
||||
E76Z/26DV) parse on the `Tagged` path instead of throwing
|
||||
`expectedScalarKey`, key anchors register and re-emit, and a key's
|
||||
presentation style (a deliberately quoted `"on":`) survives dump and
|
||||
reparse. `key: String` and `keyTag` remain as read-through computed
|
||||
accessors (an alias key resolves to its anchored scalar's text), and
|
||||
the `init(key:keyTag:value:)` convenience still builds plain string
|
||||
keys, so most call sites compile unchanged. A style-less hand-built
|
||||
key keeps the traditional plain-when-safe emission convention rather
|
||||
than inheriting the document scalar policy. The corpus round-trip law
|
||||
now compares key nodes structurally, and alias-key documents move
|
||||
from the rejected column to round-tripping.
|
||||
- Standard-tag typed accessors on `Tagged.Scalar` (issue #11):
|
||||
`timestampValue` parses a `!!timestamp` scalar under the YAML 1.1
|
||||
timestamp grammar into the new pure-Swift `Tagged.Timestamp` (calendar
|
||||
|
||||
@ -63,17 +63,9 @@ extension PureYAML.Tagged.Dumper {
|
||||
let innerPrefix = String(repeating: " ", count: inner)
|
||||
var lines = [prefix + props + "{"]
|
||||
for pair in mapping.pairs {
|
||||
let keyTag = renderTag(pair.keyTag ?? .string)
|
||||
var quotedKey = ""
|
||||
for character in pair.key {
|
||||
switch character {
|
||||
case "\"", "\\":
|
||||
quotedKey += "\\" + String(character)
|
||||
default:
|
||||
quotedKey.append(character)
|
||||
}
|
||||
}
|
||||
lines.append(innerPrefix + "? " + keyTag + " \"" + quotedKey + "\"")
|
||||
var keyLines = canonicalLines(pair.keyNode, indent: inner)
|
||||
keyLines[0] = innerPrefix + "? " + String(keyLines[0].dropFirst(innerPrefix.count))
|
||||
lines.append(contentsOf: keyLines)
|
||||
var valueLines = canonicalLines(pair.value, indent: inner)
|
||||
valueLines[0] = innerPrefix + ": " + String(valueLines[0].dropFirst(innerPrefix.count))
|
||||
valueLines[valueLines.count - 1] += ","
|
||||
|
||||
@ -198,10 +198,7 @@ extension PureYAML.Tagged.Dumper {
|
||||
renderer: PureYAML.Emitting.Dumper,
|
||||
) -> [String] {
|
||||
let prefix = String(repeating: " ", count: indent)
|
||||
var key = renderer.escapeKey(pair.key)
|
||||
if let keyTag = pair.keyTag {
|
||||
key = renderTag(keyTag) + " " + key
|
||||
}
|
||||
let key = renderKey(pair.keyNode, renderer: renderer)
|
||||
if let block = literalBlock(for: pair.value, contentIndent: indent + options.indentWidth) {
|
||||
return [prefix + key + ": " + block.header] + block.content
|
||||
}
|
||||
@ -246,6 +243,35 @@ extension PureYAML.Tagged.Dumper {
|
||||
}
|
||||
}
|
||||
|
||||
/// A mapping key rendered from its node: an alias key emits `*name `
|
||||
/// with a trailing space (`:` is a legal anchor character, so the colon
|
||||
/// must not touch the alias name), a scalar key emits its properties
|
||||
/// and its style-honoring single-line form (block styles on a key fall
|
||||
/// back to quoting inside `renderScalarValue`).
|
||||
func renderKey(
|
||||
_ keyNode: Node,
|
||||
renderer: PureYAML.Emitting.Dumper,
|
||||
) -> String {
|
||||
switch keyNode {
|
||||
case let .alias(anchor, _):
|
||||
return "*" + anchor + " "
|
||||
case let .scalar(scalar):
|
||||
let props = properties(keyNode)
|
||||
// A style-less key keeps the traditional key convention (plain
|
||||
// when safe, quoted otherwise) instead of inheriting the
|
||||
// document's scalar policy, which would quote every key under
|
||||
// the default options.
|
||||
let rendered = scalar.style == nil
|
||||
? renderer.escapeKey(scalar.rawValue)
|
||||
: renderScalarValue(scalar, flow: false, renderer: renderer)
|
||||
return props.isEmpty ? rendered : props + " " + rendered
|
||||
case .mapping, .sequence:
|
||||
// Unreachable from parsing (keys compose as scalars or aliases);
|
||||
// a hand-built collection key renders as its resolved string.
|
||||
return renderer.escapeKey("")
|
||||
}
|
||||
}
|
||||
|
||||
/// The block-scalar form for a scalar node whose style asks for one,
|
||||
/// with the node's properties folded into the header; `nil` for
|
||||
/// non-scalars and for scalars that render inline.
|
||||
@ -311,7 +337,7 @@ extension PureYAML.Tagged.Dumper {
|
||||
case let .mapping(mapping):
|
||||
body = "{" + mapping.pairs
|
||||
.map { pair in
|
||||
renderer.escapeKey(pair.key) + ": " + renderFlow(pair.value, renderer: renderer)
|
||||
renderKey(pair.keyNode, renderer: renderer) + ": " + renderFlow(pair.value, renderer: renderer)
|
||||
}
|
||||
.joined(separator: ", ") + "}"
|
||||
}
|
||||
|
||||
@ -169,9 +169,9 @@ extension PureYAML.Tagged.EventComposer {
|
||||
}
|
||||
var pairs: [PureYAML.Tagged.Pair] = []
|
||||
while current?.isMappingEnd != true {
|
||||
let key = try composeMappingKey()
|
||||
let keyNode = try composeMappingKey()
|
||||
let value = try composeNode()
|
||||
pairs.append(.init(key: key.value, keyTag: key.tag, value: value))
|
||||
pairs.append(.init(keyNode: keyNode, value: value))
|
||||
}
|
||||
_ = try expect("mapping end") { $0.isMappingEnd }
|
||||
return .mapping(.init(
|
||||
@ -182,10 +182,21 @@ extension PureYAML.Tagged.EventComposer {
|
||||
))
|
||||
}
|
||||
|
||||
mutating func composeMappingKey() throws -> ComposedMappingKey {
|
||||
mutating func composeMappingKey() throws -> PureYAML.Tagged.Node {
|
||||
guard let event = current else {
|
||||
throw unexpectedEvent(expected: "mapping key")
|
||||
}
|
||||
if case let .alias(anchor, mark) = event {
|
||||
index += 1
|
||||
guard let resolved = anchors[anchor], case .scalar = resolved.resolved else {
|
||||
throw PureYAML.Parsing.ParseError.undefinedAlias(
|
||||
anchor: anchor,
|
||||
line: mark.line,
|
||||
column: mark.column,
|
||||
)
|
||||
}
|
||||
return .alias(anchor: anchor, node: resolved)
|
||||
}
|
||||
guard case let .scalar(value, anchor, tag, style, mark, _) = event else {
|
||||
let mark = event.mark
|
||||
throw PureYAML.Parsing.ParseError.expectedScalarKey(line: mark.line, column: mark.column)
|
||||
@ -199,16 +210,11 @@ extension PureYAML.Tagged.EventComposer {
|
||||
style: .init(style),
|
||||
)
|
||||
store(.scalar(scalar), anchor: anchor)
|
||||
return ComposedMappingKey(value: value, tag: .normalized(tag))
|
||||
return .scalar(scalar)
|
||||
}
|
||||
}
|
||||
|
||||
extension PureYAML.Tagged.EventComposer {
|
||||
struct ComposedMappingKey {
|
||||
var value: String
|
||||
var tag: PureYAML.Tagged.Tag?
|
||||
}
|
||||
|
||||
var current: PureYAML.Parsing.Event? {
|
||||
guard events.indices.contains(index) else {
|
||||
return nil
|
||||
|
||||
@ -1,17 +1,44 @@
|
||||
public extension PureYAML.Tagged {
|
||||
struct Pair: Equatable, Sendable {
|
||||
public var key: String
|
||||
public var keyTag: Tag?
|
||||
/// The key as a full node, carrying its tag, anchor, presentation
|
||||
/// style, and alias-ness, so anchored, styled, and alias keys
|
||||
/// round-trip instead of collapsing to a bare string (issues #12
|
||||
/// and #13).
|
||||
public var keyNode: Node
|
||||
public var value: Node
|
||||
|
||||
public init(
|
||||
keyNode: Node,
|
||||
value: Node,
|
||||
) {
|
||||
self.keyNode = keyNode
|
||||
self.value = value
|
||||
}
|
||||
|
||||
/// Convenience for the overwhelmingly common plain string key.
|
||||
public init(
|
||||
key: String,
|
||||
keyTag: Tag? = nil,
|
||||
value: Node,
|
||||
) {
|
||||
self.key = key
|
||||
self.keyTag = keyTag
|
||||
self.value = value
|
||||
self.init(
|
||||
keyNode: .scalar(.init(rawValue: key, value: .string(key), tag: keyTag)),
|
||||
value: value,
|
||||
)
|
||||
}
|
||||
|
||||
/// The key's resolved raw text: an alias key reads through to its
|
||||
/// anchored scalar, matching `parse(_:)`'s alias-key semantics.
|
||||
public var key: String {
|
||||
guard case let .scalar(scalar) = keyNode.resolved else {
|
||||
return ""
|
||||
}
|
||||
return scalar.rawValue
|
||||
}
|
||||
|
||||
/// The key's tag, read through an alias to its resolution.
|
||||
public var keyTag: Tag? {
|
||||
keyNode.resolved.tag
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -128,11 +128,8 @@ struct TaggedNodeCorpusRoundTripTests {
|
||||
return "\(path): mapping properties differ (style \(String(describing: lhs.style)) != \(String(describing: rhs.style)))"
|
||||
}
|
||||
for (left, right) in zip(lhs.pairs, rhs.pairs) {
|
||||
if left.key != right.key {
|
||||
return "\(path): key \(left.key.debugDescription) != \(right.key.debugDescription)"
|
||||
}
|
||||
if left.keyTag != right.keyTag {
|
||||
return "\(path).\(left.key): keyTag differs"
|
||||
if let diff = firstDivergence(left.keyNode, right.keyNode, path: "\(path).key(\(left.key))") {
|
||||
return diff
|
||||
}
|
||||
if let diff = firstDivergence(left.value, right.value, path: "\(path).\(left.key)") {
|
||||
return diff
|
||||
|
||||
@ -141,8 +141,14 @@ struct TaggedNodeRoundTripTests {
|
||||
))
|
||||
let tree = PureYAML.Tagged.Node.mapping(.init(
|
||||
pairs: [
|
||||
.init(key: "original", value: shared),
|
||||
.init(key: "reference", value: .alias(anchor: "common", node: shared)),
|
||||
.init(
|
||||
keyNode: .scalar(.init(rawValue: "original", value: .string("original"), style: .plain)),
|
||||
value: shared,
|
||||
),
|
||||
.init(
|
||||
keyNode: .scalar(.init(rawValue: "reference", value: .string("reference"), style: .plain)),
|
||||
value: .alias(anchor: "common", node: shared),
|
||||
),
|
||||
],
|
||||
style: .block,
|
||||
))
|
||||
@ -205,3 +211,55 @@ struct TaggedNodeRoundTripTests {
|
||||
#expect(result.values.count == 1)
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #12/#13 coverage: alias keys, anchored keys, and key presentation
|
||||
/// styles carried on `Tagged.Pair.keyNode`.
|
||||
@Suite("Tagged mapping key nodes round-trip")
|
||||
struct TaggedMappingKeyNodeTests {
|
||||
@Test("An alias mapping key parses and re-emits as a reference")
|
||||
func test_aliasKeyRoundTrips() throws {
|
||||
let source = "&a a: &b b\n*b : *a\n"
|
||||
let parsed = try PureYAML.parseTagged(source)
|
||||
guard case let .mapping(mapping) = parsed else {
|
||||
Issue.record("expected mapping")
|
||||
return
|
||||
}
|
||||
#expect(mapping.pairs[1].keyNode.kind == .alias)
|
||||
#expect(mapping.pairs[1].key == "b")
|
||||
let dumped = PureYAML.dump(parsed)
|
||||
#expect(dumped.contains("*b :"))
|
||||
let reparsed = try PureYAML.parseTagged(dumped)
|
||||
#expect(reparsed == parsed)
|
||||
}
|
||||
|
||||
@Test("The nested alias-key form from the test suite round-trips")
|
||||
func test_nestedAliasKeyRoundTrips() throws {
|
||||
let source = "top1: &x scalar1\ntop3:\n *x : scalar3\n"
|
||||
let parsed = try PureYAML.parseTagged(source)
|
||||
let reparsed = try PureYAML.parseTagged(PureYAML.dump(parsed))
|
||||
#expect(reparsed == parsed)
|
||||
#expect(try parsed.modelValueErasingTags == (PureYAML.parse(source)))
|
||||
}
|
||||
|
||||
@Test("A quoted key keeps its quoting through dump and reparse")
|
||||
func test_quotedKeyStyleRoundTrips() throws {
|
||||
let source = "\"on\": true\nplain: false\n"
|
||||
let parsed = try PureYAML.parseTagged(source)
|
||||
let dumped = PureYAML.dump(parsed)
|
||||
#expect(dumped.contains("\"on\":"))
|
||||
#expect(dumped.contains("plain:"))
|
||||
let reparsed = try PureYAML.parseTagged(dumped)
|
||||
#expect(reparsed == parsed)
|
||||
}
|
||||
|
||||
@Test("An anchored key's anchor survives and resolves for later aliases")
|
||||
func test_anchoredKeyAnchorSurvives() throws {
|
||||
let source = "&k name: 1\nref: *k\n"
|
||||
let parsed = try PureYAML.parseTagged(source)
|
||||
let dumped = PureYAML.dump(parsed)
|
||||
#expect(dumped.contains("&k name:"))
|
||||
#expect(dumped.contains("*k"))
|
||||
let reparsed = try PureYAML.parseTagged(dumped)
|
||||
#expect(reparsed == parsed)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user