diff --git a/CHANGELOG.md b/CHANGELOG.md index 42b0b40..8377cae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add `Emitting.Options.indentWidth` and `.sortKeys` (issue #10's first + slice), closing two of the four gaps a feature-parity audit against Yams + found in the emitter (`PureYAMLYamsComparison`'s `FEATURE-PARITY.md`). + `indentWidth` replaces every hardcoded `indent + 2` in `Dumper.swift`/ + `Dumper.Mapping.swift` with `indent + options.indentWidth`; verified with + a round-trip test (dump at a non-default width, reparse, confirm the + value survives) since indentation is purely a rendering choice and must + never affect what a document means. `sortKeys` sorts a mapping's pairs by + their string key before rendering, in both block and flow style, but only + when every key in that mapping is a plain string: a mapping with any + `Model.Key.mapping`/`.sequence` key is left in insertion order regardless + of the option, since there is no well-defined lexicographic ordering + across mixed key kinds to sort by. Off by default, since insertion order + is what makes a duplicate-key-preserving `Mapping` round-trip meaningfully + in the first place. + Deliberately scoped to stop here: `width`-based line wrapping and + canonical mode (the other two gaps the audit found) are each a + correctness surface of their own (real line-folding logic for the former, + a defined canonical form for the latter) and remain open, not silently + dropped from this change's scope. + - Reject raw control characters anywhere in the input, matching libyaml's own reader-level behavior. Found by directly comparing `PureYAML.parse()` against Yams on a 221MB real-world OpenAPI/compose-file corpus (150 diff --git a/Sources/Emitting/Dumper.Mapping.swift b/Sources/Emitting/Dumper.Mapping.swift index 8140949..001da94 100644 --- a/Sources/Emitting/Dumper.Mapping.swift +++ b/Sources/Emitting/Dumper.Mapping.swift @@ -8,11 +8,30 @@ extension PureYAML.Emitting.Dumper { return [prefix + "{}"] } - return mapping.pairs.flatMap { pair in + return orderedPairs(mapping).flatMap { pair in renderMappingPair(pair, indent: indent) } } + /// `mapping.pairs` in insertion order, or sorted by their rendered + /// string-keyed form when `options.sortKeys` is set. Only string keys + /// are sortable this way; a mapping with any non-string key is left in + /// insertion order regardless of the option, since there is no + /// well-defined lexicographic ordering across mixed key kinds + /// (`Model.Key.mapping`/`.sequence`) to sort by. + func orderedPairs(_ mapping: PureYAML.Model.Mapping) -> [PureYAML.Model.Pair] { + guard options.sortKeys, mapping.pairs.allSatisfy({ pair in + if case .string = pair.keyNode { + true + } else { + false + } + }) else { + return mapping.pairs + } + return mapping.pairs.sorted { $0.key < $1.key } + } + func renderMappingPair( _ pair: PureYAML.Model.Pair, indent: Int, @@ -36,7 +55,7 @@ extension PureYAML.Emitting.Dumper { switch pair.value { case .mapping, .sequence: - return [prefix + escapeKey(pair.key) + ":"] + render(pair.value, indent: indent + 2) + return [prefix + escapeKey(pair.key) + ":"] + render(pair.value, indent: indent + options.indentWidth) default: return renderMappingScalarPair(pair, indent: indent) } @@ -54,11 +73,11 @@ extension PureYAML.Emitting.Dumper { return [prefix + "? " + pair.keyNode.flowDescription] + [prefix + ":"] - + render(pair.value, indent: indent + 2) + + render(pair.value, indent: indent + options.indentWidth) } func renderFlowMapping(_ mapping: PureYAML.Model.Mapping) -> String { - let pairs = mapping.pairs + let pairs = orderedPairs(mapping) .map { pair in "\(renderFlowKey(pair.keyNode)): \(renderFlow(pair.value))" } diff --git a/Sources/Emitting/Dumper.swift b/Sources/Emitting/Dumper.swift index e285c81..fa7315c 100644 --- a/Sources/Emitting/Dumper.swift +++ b/Sources/Emitting/Dumper.swift @@ -49,7 +49,7 @@ extension PureYAML.Emitting.Dumper { switch value { case .mapping, .sequence: - return [prefix + "-"] + render(value, indent: indent + 2) + return [prefix + "-"] + render(value, indent: indent + options.indentWidth) default: return renderSequenceScalarItem(value, indent: indent) } @@ -116,7 +116,7 @@ extension PureYAML.Emitting.Dumper { indent: Int, ) -> [String] { let prefix = String(repeating: " ", count: indent) - if let lines = renderLiteralBlockString(value, contentIndent: indent + 2) { + if let lines = renderLiteralBlockString(value, contentIndent: indent + options.indentWidth) { return [prefix + lines.header] + lines.content } return [prefix + renderScalar(value)] @@ -128,7 +128,7 @@ extension PureYAML.Emitting.Dumper { ) -> [String] { let prefix = String(repeating: " ", count: indent) let key = escapeKey(pair.key) - if let lines = renderLiteralBlockString(pair.value, contentIndent: indent + 2) { + if let lines = renderLiteralBlockString(pair.value, contentIndent: indent + options.indentWidth) { return [prefix + key + ": " + lines.header] + lines.content } return [prefix + key + ": " + renderScalar(pair.value)] @@ -139,7 +139,7 @@ extension PureYAML.Emitting.Dumper { indent: Int, ) -> [String] { let prefix = String(repeating: " ", count: indent) - if let lines = renderLiteralBlockString(value, contentIndent: indent + 2) { + if let lines = renderLiteralBlockString(value, contentIndent: indent + options.indentWidth) { return [prefix + "- " + lines.header] + lines.content } return [prefix + "- " + renderScalar(value)] diff --git a/Sources/Emitting/Options.swift b/Sources/Emitting/Options.swift index 3782784..948f8df 100644 --- a/Sources/Emitting/Options.swift +++ b/Sources/Emitting/Options.swift @@ -5,13 +5,33 @@ public extension PureYAML.Emitting { public var scalarStyle: ScalarStyle public var collectionStyle: CollectionStyle + /// Spaces per nesting level in block style. Has no effect on flow + /// style, which has no indentation to control. Must be at least 1: + /// block YAML distinguishes nesting only by indentation, so 0 would + /// make sibling and child content indistinguishable and produce + /// output that cannot round-trip back through the parser. Not + /// enforced at construction (a plain `Int`, matching this struct's + /// existing options), so passing 0 or negative is a caller error + /// that surfaces as unparseable output, not a thrown error. + public var indentWidth: Int + /// Sort mapping keys lexicographically by their rendered (already + /// quoted/escaped) form before emitting, instead of preserving + /// `Model.Mapping`'s insertion order. Off by default: insertion + /// order is what makes round-tripping a duplicate-key-preserving + /// `Mapping` back to itself meaningful, so sorting is opt-in, not + /// the default. + public var sortKeys: Bool public init( scalarStyle: ScalarStyle = .quoted, collectionStyle: CollectionStyle = .block, + indentWidth: Int = 2, + sortKeys: Bool = false, ) { self.scalarStyle = scalarStyle self.collectionStyle = collectionStyle + self.indentWidth = indentWidth + self.sortKeys = sortKeys } } } diff --git a/Tests/EmitterIndentAndSortKeysTests.swift b/Tests/EmitterIndentAndSortKeysTests.swift new file mode 100644 index 0000000..c2070a6 --- /dev/null +++ b/Tests/EmitterIndentAndSortKeysTests.swift @@ -0,0 +1,117 @@ +@testable import PureYAML +import Testing + +/// Coverage for `Emitting.Options.indentWidth` and `.sortKeys` (issue #10's +/// first slice: `width`-based line wrapping and canonical mode are separate, +/// larger correctness surfaces of their own and remain open, not silently +/// dropped). +@Suite("Emitter indent width and sortKeys") +struct EmitterIndentAndSortKeysTests { + @Test("A custom indent width nests block sequences and mappings consistently") + func test_customIndentWidth() throws { + let value = PureYAML.Model.Value.mapping(.init([ + .init(key: "servers", value: .sequence([ + .mapping(.init([ + .init(key: "url", value: .string("/")), + ])), + ])), + ])) + + let dumped = PureYAML.dump(value, options: .init(indentWidth: 4)) + #expect(dumped == """ + servers: + - + url: "/" + + """) + + // Indentation is purely a rendering choice: reparsing must recover + // the identical value regardless of indent width. + let reparsed = try PureYAML.parse(dumped) + #expect(reparsed == value) + } + + @Test("Indent width 1 is the tightest that still round-trips") + func test_indentWidthOne() throws { + let value = PureYAML.Model.Value.mapping(.init([ + .init(key: "a", value: .mapping(.init([ + .init(key: "b", value: .int(1)), + ]))), + ])) + + let dumped = PureYAML.dump(value, options: .init(indentWidth: 1)) + let reparsed = try PureYAML.parse(dumped) + #expect(reparsed == value) + } + + @Test("sortKeys orders string-keyed mapping pairs lexicographically") + func test_sortKeysOrdersStringKeys() { + let value = PureYAML.Model.Value.mapping(.init([ + .init(key: "zebra", value: .int(1)), + .init(key: "apple", value: .int(2)), + .init(key: "mango", value: .int(3)), + ])) + + #expect(PureYAML.dump(value, options: .init(sortKeys: true)) == """ + apple: 2 + mango: 3 + zebra: 1 + + """) + // Off by default: insertion order is preserved unless opted in. + #expect(PureYAML.dump(value) == """ + zebra: 1 + apple: 2 + mango: 3 + + """) + } + + @Test("sortKeys applies to nested mappings and flow mappings too") + func test_sortKeysAppliesToNestedAndFlowMappings() { + let value = PureYAML.Model.Value.mapping(.init([ + .init(key: "outer", value: .mapping(.init([ + .init(key: "z", value: .int(1)), + .init(key: "a", value: .int(2)), + ]))), + ])) + + #expect(PureYAML.dump(value, options: .init(sortKeys: true)) == """ + outer: + a: 2 + z: 1 + + """) + + let flowOptions = PureYAML.Emitting.Options(collectionStyle: .flow, sortKeys: true) + #expect(PureYAML.dump(.mapping(.init([ + .init(key: "z", value: .int(1)), + .init(key: "a", value: .int(2)), + ])), options: flowOptions) == "{\"a\": 2, \"z\": 1}\n") + } + + @Test("sortKeys leaves a mapping with any non-string key in insertion order") + func test_sortKeysSkipsMixedKeyMappings() { + let value = PureYAML.Model.Value.mapping(.init([ + .init(keyNode: .string("zebra"), value: .int(1)), + .init(keyNode: .sequence([.int(1), .int(2)]), value: .int(2)), + .init(keyNode: .string("apple"), value: .int(3)), + ])) + + let sorted = PureYAML.dump(value, options: .init(sortKeys: true)) + let unsorted = PureYAML.dump(value) + #expect(sorted == unsorted) + } + + @Test("sortKeys still round-trips a mapping with duplicate keys") + func test_sortKeysRoundTripsDuplicateKeys() throws { + let value = PureYAML.Model.Value.mapping(.init([ + .init(key: "a", value: .int(1)), + .init(key: "a", value: .int(2)), + ])) + + let dumped = PureYAML.dump(value, options: .init(sortKeys: true)) + let reparsed = try PureYAML.parse(dumped) + #expect(reparsed == value) + } +}