diff --git a/CHANGELOG.md b/CHANGELOG.md index ecde8cb..630f0a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Sources/Emitting/Dumper.Canonical.swift b/Sources/Emitting/Dumper.Canonical.swift index 8726fa3..af5f9a5 100644 --- a/Sources/Emitting/Dumper.Canonical.swift +++ b/Sources/Emitting/Dumper.Canonical.swift @@ -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( diff --git a/Sources/Emitting/Dumper.NestingLimit.swift b/Sources/Emitting/Dumper.NestingLimit.swift new file mode 100644 index 0000000..b7a2465 --- /dev/null +++ b/Sources/Emitting/Dumper.NestingLimit.swift @@ -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 + } +} diff --git a/Sources/Emitting/Dumper.ScalarAnalysis.swift b/Sources/Emitting/Dumper.ScalarAnalysis.swift index 8e603cc..097b39a 100644 --- a/Sources/Emitting/Dumper.ScalarAnalysis.swift +++ b/Sources/Emitting/Dumper.ScalarAnalysis.swift @@ -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("#") } diff --git a/Sources/Emitting/Dumper.Stream.swift b/Sources/Emitting/Dumper.Stream.swift index efd5ee5..428b679 100644 --- a/Sources/Emitting/Dumper.Stream.swift +++ b/Sources/Emitting/Dumper.Stream.swift @@ -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() } diff --git a/Sources/Emitting/Dumper.swift b/Sources/Emitting/Dumper.swift index 0ad98ac..571ac57 100644 --- a/Sources/Emitting/Dumper.swift +++ b/Sources/Emitting/Dumper.swift @@ -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 } } } diff --git a/Sources/Tagged/Tagged.BuiltinValidation.swift b/Sources/Tagged/Tagged.BuiltinValidation.swift index 9a5a43a..b35004d 100644 --- a/Sources/Tagged/Tagged.BuiltinValidation.swift +++ b/Sources/Tagged/Tagged.BuiltinValidation.swift @@ -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 { PureYAML.Validation.Validation(description: "Mapping keys are unique") { context in - var seen = Set() + var seen = Set() 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 { diff --git a/Sources/Tagged/Tagged.Dumper.Canonical.swift b/Sources/Tagged/Tagged.Dumper.Canonical.swift index ae2bd1b..ed40906 100644 --- a/Sources/Tagged/Tagged.Dumper.Canonical.swift +++ b/Sources/Tagged/Tagged.Dumper.Canonical.swift @@ -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) diff --git a/Sources/Tagged/Tagged.Dumper.Scalars.swift b/Sources/Tagged/Tagged.Dumper.Scalars.swift index d045a16..29bc56f 100644 --- a/Sources/Tagged/Tagged.Dumper.Scalars.swift +++ b/Sources/Tagged/Tagged.Dumper.Scalars.swift @@ -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 } diff --git a/Sources/Tagged/Tagged.Dumper.swift b/Sources/Tagged/Tagged.Dumper.swift index 7353196..422f42e 100644 --- a/Sources/Tagged/Tagged.Dumper.swift +++ b/Sources/Tagged/Tagged.Dumper.swift @@ -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) + } +} diff --git a/Sources/Tagged/Tagged.EventComposer.swift b/Sources/Tagged/Tagged.EventComposer.swift index cbaca99..1bbc792 100644 --- a/Sources/Tagged/Tagged.EventComposer.swift +++ b/Sources/Tagged/Tagged.EventComposer.swift @@ -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 { diff --git a/Sources/Tagged/Tagged.MappingKeyIdentity.swift b/Sources/Tagged/Tagged.MappingKeyIdentity.swift new file mode 100644 index 0000000..2db8c13 --- /dev/null +++ b/Sources/Tagged/Tagged.MappingKeyIdentity.swift @@ -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? + } +} diff --git a/Sources/Tagged/Tagged.Timestamp.swift b/Sources/Tagged/Tagged.Timestamp.swift index 7ab0087..60f222b 100644 --- a/Sources/Tagged/Tagged.Timestamp.swift +++ b/Sources/Tagged/Tagged.Timestamp.swift @@ -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 } diff --git a/Tests/EmitterFixRegressionTests.swift b/Tests/EmitterFixRegressionTests.swift new file mode 100644 index 0000000..96ac34f --- /dev/null +++ b/Tests/EmitterFixRegressionTests.swift @@ -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) + } +}