PureYAML/Tests/EmitterFixRegressionTests.swift
Mihaela Mihaljevic 2fcf54684e fix: correct emitter and tagged round-trip and validation bugs
Seven correctness fixes to the recently landed anchors/styles, canonical,
timestamp/binary, and full-node-key work, each reproduced and covered by a
regression test.

- Stream dump doubled the `---` marker under explicitDocumentStart or
  canonical: the single-document body now renders marker-free and the
  stream path owns marker emission, so a two-document stream reparses as
  two documents, not four.
- Tagged flow-mapping keys used block-context safety, so a key like `a,b`
  emitted bare and reparsed as several entries; flow keys now use the
  flow-aware plain-scalar check.
- Plain scalars ending in `:` reparse as an empty-valued mapping key, so
  both the model and tagged emit paths now fall back to quoting them.
- An alias mapping key pointing at a defined non-scalar anchor raised the
  misleading undefinedAlias; it now raises expectedScalarKey, and a truly
  undefined alias key still raises undefinedAlias.
- Tagged duplicate-key detection keyed on source spelling: it now keys on
  the resolved scalar value plus effective tag, so 0x1 and 1 collide while
  1 and "1" do not and custom-tagged keys stay distinct.
- Dumping a tree nested past the emitter limit overflowed the stack.

- Timestamp now accepts a leap-second value of 60, counted as 60 elapsed
  seconds in the epoch math.
- Tagged.Dumper honors sortKeys in its block and canonical paths for
  all-plain-string-key mappings, matching the model dumper.

Nesting-guard design: dump is public and non-throwing across 90+ call
sites, so making it throwing would be a breaking change. A tree nested
deeper than the parser's own maxNestingDepth cannot round-trip through
PureYAML (the parser rejects it), so no faithful emission exists. Each
dump entry point now measures depth iteratively (no recursion, so the
guard itself cannot overflow) and, past the limit, returns a documented,
reparseable marker document that resolves to null. Under the limit the
existing recursive renderers run byte-identically, so parsed trees and
every realistic hand-built tree are unaffected. This mirrors the parser's
enforceNestingLimit, which bounds depth before any recursive consumer.
2026-07-16 16:29:08 +02:00

163 lines
6.8 KiB
Swift

@testable import PureYAML
import Testing
/// Regressions for the round-trip and validation fixes: stream document markers,
/// flow-key safety, trailing-colon plain safety, alias-key error classification,
/// duplicate-key identity, the emitter nesting guard, leap seconds, and tagged
/// `sortKeys`.
@Suite("Emitter and tagged fix regressions")
struct EmitterFixRegressionTests {
// MARK: - Item 1: stream document markers
@Test("A two-document stream stays two documents with explicitDocumentStart")
func test_streamExplicitStartStaysTwoDocuments() throws {
let docs = try PureYAML.parseStream("a: 1\n---\nb: 2\n")
let dumped = PureYAML.dump(docs, options: .init(explicitDocumentStart: true))
let reparsed = try PureYAML.parseStream(dumped)
#expect(reparsed.count == 2)
#expect(reparsed.map(\.value) == docs.map(\.value))
}
@Test("A two-document stream stays two documents in canonical mode")
func test_streamCanonicalStaysTwoDocuments() throws {
let docs = try PureYAML.parseStream("a: 1\n---\nb: 2\n")
let dumped = PureYAML.dump(docs, options: .init(canonical: true))
let reparsed = try PureYAML.parseStream(dumped)
#expect(reparsed.count == 2)
#expect(reparsed.map(\.value) == docs.map(\.value))
}
// MARK: - Item 2: flow-key safety
@Test("A flow mapping key containing a comma round-trips instead of splitting")
func test_flowCommaKeyRoundTrips() throws {
let node = PureYAML.Tagged.Node.mapping(.init(
pairs: [.init(key: "a,b", value: .scalar(.init(rawValue: "1", value: .int(1))))],
style: .flow,
))
let dumped = PureYAML.dump(node)
let reparsed = try PureYAML.parse(dumped)
#expect(reparsed == .mapping(.init([.init(key: "a,b", value: .int(1))])))
}
// MARK: - Item 3: trailing-colon plain safety
@Test("A model scalar ending in a colon falls back to quoting and reparses")
func test_modelTrailingColonQuotes() throws {
let value = PureYAML.Model.Value.mapping(.init([.init(key: "k", value: .string("foo:"))]))
let dumped = PureYAML.dump(value, options: .init(scalarStyle: .plainWhenSafe))
let reparsed = try PureYAML.parse(dumped)
#expect(reparsed == value)
}
@Test("A tagged plain scalar ending in a colon falls back to quoting and reparses")
func test_taggedTrailingColonQuotes() throws {
let node = PureYAML.Tagged.Node.mapping(.init(pairs: [
.init(key: "k", value: .scalar(.init(rawValue: "foo:", value: .string("foo:"), style: .plain))),
]))
let dumped = PureYAML.dump(node)
let reparsed = try PureYAML.parse(dumped)
#expect(reparsed == .mapping(.init([.init(key: "k", value: .string("foo:"))])))
}
// MARK: - Item 4: alias-key error classification
@Test("An undefined alias as a mapping key is an undefinedAlias error")
func test_undefinedAliasKey() throws {
#expect(throws: PureYAML.Parsing.ParseError.self) {
try PureYAML.parseTagged("a: 1\n*missing : v\n")
}
do {
_ = try PureYAML.parseTagged("a: 1\n*missing : v\n")
} catch let error as PureYAML.Parsing.ParseError {
#expect(error.diagnosticCode == "undefinedAlias")
}
}
@Test("A defined non-scalar alias as a mapping key is an expectedScalarKey error")
func test_nonScalarAliasKey() throws {
do {
_ = try PureYAML.parseTagged("a: &s [1, 2]\n*s : v\n")
Issue.record("expected a parse error")
} catch let error as PureYAML.Parsing.ParseError {
#expect(error.diagnosticCode == "expectedScalarKey")
}
}
// MARK: - Item 5: duplicate-key identity
@Test("An int key and a string key spelled the same are not duplicates")
func test_intVersusStringKeyNotDuplicate() throws {
let node = try PureYAML.parseTagged("1: a\n\"1\": b\n")
#expect(PureYAML.Tagged.Validator().collect(node).values.isEmpty)
}
@Test("Two keys resolving to the same int are duplicates")
func test_sameResolvedIntKeysAreDuplicate() throws {
let node = try PureYAML.parseTagged("0x1: a\n1: b\n")
#expect(PureYAML.Tagged.Validator().collect(node).values.count == 1)
}
// MARK: - Item 6: emitter nesting guard
@Test("A model tree nested past the limit yields a reparseable marker, not a crash")
func test_modelDeepTreeMarker() throws {
var value = PureYAML.Model.Value.string("x")
for _ in 0 ..< 200 {
value = .sequence([value])
}
let dumped = PureYAML.dump(value)
#expect(dumped.contains("nesting exceeds"))
#expect(try PureYAML.parse(dumped) == .null)
}
@Test("A tagged tree nested past the limit yields a reparseable marker, not a crash")
func test_taggedDeepTreeMarker() throws {
var node = PureYAML.Tagged.Node.scalar(.init(rawValue: "x", value: .string("x")))
for _ in 0 ..< 200 {
node = .sequence(.init(values: [node]))
}
let dumped = PureYAML.dump(node)
#expect(dumped.contains("nesting exceeds"))
#expect(try PureYAML.parse(dumped) == .null)
}
@Test("A tree at the limit still renders normally")
func test_treeAtLimitStillRenders() throws {
var value = PureYAML.Model.Value.string("x")
for _ in 0 ..< (PureYAML.Emitting.maxNestingDepth - 1) {
value = .sequence([value])
}
let dumped = PureYAML.dump(value)
#expect(!dumped.contains("nesting exceeds"))
#expect(try PureYAML.parse(dumped) == value)
}
// MARK: - Item 7a: leap second
@Test("A leap second value of 60 parses and adds 60 elapsed seconds")
func test_leapSecondParses() throws {
let stamp = try #require(PureYAML.Tagged.Timestamp(yaml: "2005-12-31T23:59:60Z"))
#expect(stamp.second == 60)
let base = try #require(PureYAML.Tagged.Timestamp(yaml: "2005-12-31T23:59:00Z"))
#expect(stamp.secondsSinceEpoch == base.secondsSinceEpoch + 60)
}
// MARK: - Item 7b: tagged sortKeys
@Test("Tagged block dump honors sortKeys for plain string keys")
func test_taggedBlockSortKeys() throws {
let node = try PureYAML.parseTagged("b: 1\na: 2\nc: 3\n")
let dumped = PureYAML.dump(node, options: .init(sortKeys: true))
#expect(dumped == "a: 2\nb: 1\nc: 3\n")
}
@Test("Tagged canonical dump honors sortKeys for plain string keys")
func test_taggedCanonicalSortKeys() throws {
let node = try PureYAML.parseTagged("b: 1\na: 2\n")
let dumped = PureYAML.dump(node, options: .init(sortKeys: true, canonical: true))
let firstKeyLine = dumped.split(separator: "\n").first { $0.contains("? ") }
#expect(firstKeyLine?.contains("\"a\"") == true)
}
}