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:
Mihaela Mihaljevic 2026-07-10 17:49:44 +02:00
parent f9fe2325f7
commit ec3642bacc
5 changed files with 185 additions and 8 deletions

View File

@ -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

View File

@ -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))"
}

View File

@ -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)]

View File

@ -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
}
}
}

View 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)
}
}