feat(emitting): add indentWidth and sortKeys emitter options
Closes two of the four emitter gaps a feature-parity audit against Yams found (PureYAMLYamsComparison's FEATURE-PARITY.md): no configurable indentation and no sorted-key output. indentWidth replaces every hardcoded indent + 2 in Dumper.swift/ Dumper.Mapping.swift, verified with round-trip tests since indentation is purely a rendering choice and must never change what a document means. sortKeys sorts a mapping's pairs by their string key in both block and flow style, but only when every key in that mapping is a plain string; a mapping with any complex (mapping/sequence) key stays in insertion order, since there is no well-defined ordering across mixed key kinds. Off by default, since insertion order is what makes a duplicate-key-preserving Mapping round-trip meaningfully in the first place. width-based line wrapping and canonical mode are each a correctness surface of their own and are deliberately left for later, not folded into this.
This commit is contained in:
parent
f9fe2325f7
commit
ec3642bacc
21
CHANGELOG.md
21
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
|
||||
|
||||
@ -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))"
|
||||
}
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
117
Tests/EmitterIndentAndSortKeysTests.swift
Normal file
117
Tests/EmitterIndentAndSortKeysTests.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user