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.
This commit is contained in:
Mihaela Mihaljevic 2026-07-16 16:27:52 +02:00
parent e51df57c35
commit 2fcf54684e
14 changed files with 428 additions and 31 deletions

View File

@ -399,6 +399,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`Tagged.Validator` walks each anchored subtree once at its definition
site and does not re-walk it per alias reference.
### Fixed
- Stream dump no longer doubles the `---` document marker under
`explicitDocumentStart` or `canonical`. The stream path now owns marker
emission and renders each document body marker-free, so a two-document
stream reparses as two documents instead of four (an empty document
ahead of each real one).
- `Tagged` flow-mapping keys now use flow-aware plain-scalar safety, so a
key containing a flow delimiter (`a,b`, `[`, `{`) is quoted instead of
emitted bare, which previously reparsed as several entries.
- Plain scalars ending in `:` now fall back to quoting on both the model
and `Tagged` emit paths. A trailing colon reparses as a mapping key
with an empty value, so the emitter must never leave one plain.
- A mapping key that is an alias to a defined but non-scalar anchor now
raises `expectedScalarKey` instead of the misleading `undefinedAlias`;
a genuinely undefined alias key still raises `undefinedAlias`.
- `Tagged` duplicate-key detection now keys on the resolved scalar value
plus effective tag rather than the source spelling, so `0x1` and `1`
(both `int(1)`) are reported as duplicates while `1` (`int`) and `"1"`
(`str`) are not, and custom-tagged keys stay distinct.
- Dumping a value nested deeper than the emitter's nesting limit (the
same bound the parser enforces) now returns a documented, reparseable
marker document instead of overflowing the stack. Such a value cannot
round-trip through PureYAML's own parser, so it is refused rather than
emitted as YAML the parser would reject.
- `Tagged.Timestamp` accepts a leap-second value of `60` per the YAML 1.1
timestamp grammar, treating it as 60 elapsed seconds in the epoch math.
- `Tagged.Dumper` honors `sortKeys` in its block and canonical paths for
mappings whose keys are all plain, untagged, unanchored string scalars,
matching the model dumper's `orderedPairs` rule.
## [0.2.0] - 2026-07-05
### Changed

View File

@ -6,7 +6,13 @@ extension PureYAML.Emitting.Dumper {
/// form. Tags derive from each value's case, the same resolution the
/// parser applies in reverse.
func canonicalDocument(_ value: PureYAML.Model.Value) -> String {
"---\n" + canonicalLines(value, indent: 0).joined(separator: "\n") + "\n"
"---\n" + canonicalBody(value)
}
/// The canonical body without the leading `---` marker, so the marker is
/// owned by the single-document and stream paths, not baked into the body.
func canonicalBody(_ value: PureYAML.Model.Value) -> String {
canonicalLines(value, indent: 0).joined(separator: "\n") + "\n"
}
func canonicalLines(

View File

@ -0,0 +1,77 @@
extension PureYAML.Emitting {
/// The deepest collection nesting the recursive renderers walk before a
/// value is refused. It matches the parser's ``PureYAML/Parsing/Parser/maxNestingDepth``
/// exactly: a value nested deeper than the parser accepts can never round-trip
/// through PureYAML, so emitting it would produce YAML the paired parser
/// rejects. Parsed trees are always within this bound, so real round-trips
/// are unaffected.
static var maxNestingDepth: Int {
PureYAML.Parsing.Parser.maxNestingDepth
}
/// The reparseable marker returned for a value nested past ``maxNestingDepth``.
/// It is a valid YAML document that resolves to `null`, with a comment that
/// names the refused limit, so a caller never receives a crash or silent
/// truncation.
static var nestingLimitDocument: String {
"# value nesting exceeds the emitter limit of \(maxNestingDepth); not serialized\nnull\n"
}
/// Iteratively (no recursion, so this cannot itself overflow) reports whether
/// a model value nests collections deeper than ``maxNestingDepth``. Complex
/// keys count toward depth like values; scalars are leaves.
static func exceedsNestingLimit(_ value: PureYAML.Model.Value) -> Bool {
var stack: [(value: PureYAML.Model.Value, depth: Int)] = [(value, 1)]
while let (node, depth) = stack.popLast() {
switch node {
case let .sequence(values):
if depth > maxNestingDepth {
return true
}
for child in values {
stack.append((child, depth + 1))
}
case let .mapping(mapping):
if depth > maxNestingDepth {
return true
}
for pair in mapping.pairs {
stack.append((pair.value, depth + 1))
stack.append((pair.keyNode.value, depth + 1))
}
case .null, .bool, .int, .double, .string:
continue
}
}
return false
}
/// Iteratively reports whether a tagged node nests collections deeper than
/// ``maxNestingDepth``. An alias renders as a `*name` reference, not an
/// expanded subtree, so it is a leaf here and its aliased node is not walked.
static func exceedsNestingLimit(_ node: PureYAML.Tagged.Node) -> Bool {
var stack: [(node: PureYAML.Tagged.Node, depth: Int)] = [(node, 1)]
while let (current, depth) = stack.popLast() {
switch current {
case let .sequence(sequence):
if depth > maxNestingDepth {
return true
}
for child in sequence.values {
stack.append((child, depth + 1))
}
case let .mapping(mapping):
if depth > maxNestingDepth {
return true
}
for pair in mapping.pairs {
stack.append((pair.value, depth + 1))
stack.append((pair.keyNode, depth + 1))
}
case .scalar, .alias:
continue
}
}
return false
}
}

View File

@ -100,6 +100,12 @@ extension PureYAML.Emitting.Dumper {
guard let first = value.first, !isPlainScalarIndicator(first) else {
return false
}
// A trailing `:` reparses as a mapping key with an empty value, so a
// scalar that ends in one cannot render plain even though no `:` is
// followed by a space inside it.
guard value.last != ":" else {
return false
}
return !containsColonSpace(value) && !value.contains("#")
}

View File

@ -1,9 +1,18 @@
public extension PureYAML.Emitting.Dumper {
/// Serializes indexed YAML stream documents with explicit document starts.
extension PureYAML.Emitting.Dumper {
/// Serializes a stream of documents. The stream path owns the document
/// markers: it emits one `---` start per document and, when
/// `explicitDocumentEnd` is set, one `...` end per document. Each document
/// body is rendered marker-free so `explicitDocumentStart` and `canonical`
/// cannot stack a second `---` onto the separator, which previously turned
/// every document into two (an empty one plus the real one) on reparse.
func dump(_ documents: [PureYAML.Stream.Document]) -> String {
documents
.map { document in
"---\n" + dump(document.value)
let body = PureYAML.Emitting.exceedsNestingLimit(document.value)
? PureYAML.Emitting.nestingLimitDocument
: documentBody(document.value)
let end = options.explicitDocumentEnd ? "...\n" : ""
return "---\n" + body + end
}
.joined()
}

View File

@ -7,15 +7,32 @@ public extension PureYAML.Emitting {
self.options = options
}
/// Serializes one document. A value nested deeper than the parser's own
/// ``PureYAML/Parsing/Parser/maxNestingDepth`` cannot round-trip through
/// PureYAML (the parser rejects it), and the recursive renderers would
/// overflow the stack on it, so such a value is refused with a
/// documented, reparseable marker document instead of crashing.
public func dump(_ value: PureYAML.Model.Value) -> String {
if options.canonical {
var body = canonicalDocument(value)
if options.explicitDocumentEnd {
body += "...\n"
}
return body
guard !PureYAML.Emitting.exceedsNestingLimit(value) else {
return PureYAML.Emitting.nestingLimitDocument
}
var body = switch options.collectionStyle {
var body = documentBody(value)
if options.canonical || options.explicitDocumentStart {
body = "---\n" + body
}
if options.explicitDocumentEnd {
body += "...\n"
}
return body
}
/// The document body with no leading `---` or trailing `...` markers, so
/// the single-document and stream paths can each own marker emission.
func documentBody(_ value: PureYAML.Model.Value) -> String {
if options.canonical {
return canonicalBody(value)
}
return switch options.collectionStyle {
case .block:
render(value, indent: 0).joined(separator: "\n") + "\n"
case .flow where options.lineWidth > 0:
@ -23,13 +40,6 @@ public extension PureYAML.Emitting {
case .flow:
renderFlowDocument(value)
}
if options.explicitDocumentStart {
body = "---\n" + body
}
if options.explicitDocumentEnd {
body += "...\n"
}
return body
}
}
}

View File

@ -8,9 +8,9 @@ public extension PureYAML.Tagged {
/// tag-preserving tree as in the plain model, and `Tagged.Mapping` keeps every parsed pair.
public static var mappingKeysAreUnique: PureYAML.Validation.Validation<Mapping, Node> {
PureYAML.Validation.Validation(description: "Mapping keys are unique") { context in
var seen = Set<String>()
var seen = Set<PureYAML.Tagged.MappingKeyIdentity>()
var errors: [PureYAML.Validation.ValidationError] = []
for pair in context.subject.pairs where !seen.insert(pair.key).inserted {
for pair in context.subject.pairs where !seen.insert(keyIdentity(of: pair)).inserted {
errors.append(PureYAML.Validation.ValidationError(
severity: .error,
reason: "Duplicate mapping key '\(pair.key)'",
@ -21,6 +21,36 @@ public extension PureYAML.Tagged {
}
}
static func keyIdentity(of pair: PureYAML.Tagged.Pair) -> PureYAML.Tagged.MappingKeyIdentity {
let node = pair.keyNode.resolved
guard case let .scalar(scalar) = node else {
// Collection keys do not arise from parsing; fall back to the
// resolved key text so equal spellings still collide.
return PureYAML.Tagged.MappingKeyIdentity(value: .string(pair.key), tag: node.tag?.rawValue)
}
return PureYAML.Tagged.MappingKeyIdentity(value: scalar.value, tag: effectiveTag(of: scalar)?.rawValue)
}
static func effectiveTag(of scalar: PureYAML.Tagged.Scalar) -> PureYAML.Tagged.Tag? {
if let tag = scalar.tag {
return tag
}
switch scalar.value {
case .null:
return .null
case .bool:
return .bool
case .int:
return .int
case .double:
return .float
case .string:
return .string
case .sequence, .mapping:
return nil
}
}
/// Reports unsupported built-in tags and built-in tags applied to the wrong node kind, on the
/// node itself and on each mapping key.
public static var builtInTagsAreSupportedAndKindConsistent: PureYAML.Validation.Validation<Node, Node> {

View File

@ -62,7 +62,7 @@ extension PureYAML.Tagged.Dumper {
let inner = indent + options.indentWidth
let innerPrefix = String(repeating: " ", count: inner)
var lines = [prefix + props + "{"]
for pair in mapping.pairs {
for pair in orderedPairs(mapping) {
var keyLines = canonicalLines(pair.keyNode, indent: inner)
keyLines[0] = innerPrefix + "? " + String(keyLines[0].dropFirst(innerPrefix.count))
lines.append(contentsOf: keyLines)

View File

@ -121,6 +121,12 @@ extension PureYAML.Tagged.Dumper {
guard !string.contains("\n"), !string.contains("\r"), !string.contains("\t") else {
return false
}
// A trailing `:` reparses as a mapping key with an empty value, so a
// scalar that ends in one is not plain-safe even when no interior `:`
// is followed by a space.
guard string.last != ":" else {
return false
}
guard let first = string.first else {
return false
}

View File

@ -16,6 +16,9 @@ public extension PureYAML.Tagged {
}
public func dump(_ node: PureYAML.Tagged.Node) -> String {
guard !PureYAML.Emitting.exceedsNestingLimit(node) else {
return PureYAML.Emitting.nestingLimitDocument
}
if options.canonical {
var body = canonicalDocument(node)
if options.explicitDocumentEnd {
@ -186,7 +189,7 @@ extension PureYAML.Tagged.Dumper {
guard !mapping.pairs.isEmpty else {
return props.isEmpty ? [prefix + "{}"] : [prefix + props + " {}"]
}
for pair in mapping.pairs {
for pair in orderedPairs(mapping) {
lines.append(contentsOf: renderBlockPair(pair, indent: indent, renderer: renderer))
}
return lines
@ -198,7 +201,7 @@ extension PureYAML.Tagged.Dumper {
renderer: PureYAML.Emitting.Dumper,
) -> [String] {
let prefix = String(repeating: " ", count: indent)
let key = renderKey(pair.keyNode, renderer: renderer)
let key = renderKey(pair.keyNode, flow: false, renderer: renderer)
if let block = literalBlock(for: pair.value, contentIndent: indent + options.indentWidth) {
return [prefix + key + ": " + block.header] + block.content
}
@ -250,6 +253,7 @@ extension PureYAML.Tagged.Dumper {
/// back to quoting inside `renderScalarValue`).
func renderKey(
_ keyNode: Node,
flow: Bool,
renderer: PureYAML.Emitting.Dumper,
) -> String {
switch keyNode {
@ -260,10 +264,18 @@ extension PureYAML.Tagged.Dumper {
// 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)
// the default options. In a flow mapping the safety check must
// also reject flow delimiters (`,{}[]`), or a key like `a,b`
// would emit unquoted and reparse as two entries.
let rendered: String
if scalar.style == nil {
let plainSafe = flow
? renderer.canRenderFlowPlainString(scalar.rawValue)
: renderer.canRenderPlainString(scalar.rawValue)
rendered = plainSafe ? scalar.rawValue : renderer.quote(scalar.rawValue)
} else {
rendered = renderScalarValue(scalar, flow: flow, renderer: renderer)
}
return props.isEmpty ? rendered : props + " " + rendered
case .mapping, .sequence:
// Unreachable from parsing (keys compose as scalars or aliases);
@ -335,9 +347,9 @@ extension PureYAML.Tagged.Dumper {
.map { renderFlow($0, renderer: renderer) }
.joined(separator: ", ") + "]"
case let .mapping(mapping):
body = "{" + mapping.pairs
body = "{" + orderedPairs(mapping)
.map { pair in
renderKey(pair.keyNode, renderer: renderer) + ": " + renderFlow(pair.value, renderer: renderer)
renderKey(pair.keyNode, flow: true, renderer: renderer) + ": " + renderFlow(pair.value, renderer: renderer)
}
.joined(separator: ", ") + "}"
}
@ -351,3 +363,33 @@ extension PureYAML.Tagged.Dumper {
renderInline(node, renderer: renderer)
}
}
// MARK: - Key ordering
extension PureYAML.Tagged.Dumper {
/// `mapping.pairs` in insertion order, or sorted by their plain string key
/// when `options.sortKeys` is set and every key is a plain, untagged,
/// unanchored string scalar. Any anchored, tagged, styled, alias, or
/// non-string key leaves the mapping in insertion order: there is no
/// well-defined lexicographic order across those, and reordering an
/// anchored key would move an anchor definition relative to its scalar.
/// Mirrors the model dumper's `orderedPairs` rule.
func orderedPairs(_ mapping: PureYAML.Tagged.Mapping) -> [PureYAML.Tagged.Pair] {
guard options.sortKeys, mapping.pairs.allSatisfy(isPlainStringKey) else {
return mapping.pairs
}
return mapping.pairs.sorted { $0.key < $1.key }
}
func isPlainStringKey(_ pair: PureYAML.Tagged.Pair) -> Bool {
guard case let .scalar(scalar) = pair.keyNode else {
return false
}
guard case .string = scalar.value else {
return false
}
return scalar.anchor == nil
&& scalar.tag == nil
&& (scalar.style == .plain || scalar.style == nil)
}
}

View File

@ -188,13 +188,19 @@ extension PureYAML.Tagged.EventComposer {
}
if case let .alias(anchor, mark) = event {
index += 1
guard let resolved = anchors[anchor], case .scalar = resolved.resolved else {
guard let resolved = anchors[anchor] else {
throw PureYAML.Parsing.ParseError.undefinedAlias(
anchor: anchor,
line: mark.line,
column: mark.column,
)
}
// A defined anchor that resolves to a collection cannot serve as a
// mapping key here (this layer keys mappings on scalars), so it is
// an expected-scalar-key error, not an undefined-alias error.
guard case .scalar = resolved.resolved else {
throw PureYAML.Parsing.ParseError.expectedScalarKey(line: mark.line, column: mark.column)
}
return .alias(anchor: anchor, node: resolved)
}
guard case let .scalar(value, anchor, tag, style, mark, _) = event else {

View File

@ -0,0 +1,12 @@
extension PureYAML.Tagged {
/// Key identity for duplicate detection: the resolved scalar value plus the
/// key's effective tag (its carried tag, or the implicit tag of the resolved
/// value). Two keys are the same key iff both match. Using the resolved value
/// rather than the source spelling makes `0x1` and `1` (both `int(1)`)
/// collide while `1` (`int`) and `"1"` (`str`) do not, and keeps
/// custom-tagged keys (`!foo 1` vs `!bar 1`) distinct.
struct MappingKeyIdentity: Hashable {
var value: PureYAML.Model.Value
var tag: String?
}
}

View File

@ -122,7 +122,7 @@ extension PureYAML.Tagged.Timestamp {
cursor.take(":"),
let minute = cursor.takeDigits(exactly: 2), minute <= 59,
cursor.take(":"),
let second = cursor.takeDigits(exactly: 2), second <= 59
let second = cursor.takeDigits(exactly: 2), second <= 60
else {
return nil
}

View File

@ -0,0 +1,162 @@
@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)
}
}