PureYAML/Sources/Emitting/Dumper.swift
Mihaela Mihaljevic 8df3f0b8d4 feat(emitting): add canonical mode, document markers, and lineWidth
Completes issue #10. Canonical mode follows libyaml's canonical layout
on both the Model and Tagged dump paths (tags explicit, scalars double
quoted, flow one entry per line, anchors and aliases preserved on the
Tagged path). explicitDocumentStart/End wrap the body in --- and ...
markers. lineWidth wraps overflowing flow collections one entry per
line and folds plain block scalars at single-space boundaries, vetoing
any fold that would manufacture syntax, so values always reparse
unchanged.

Closes #10
2026-07-12 14:13:19 +02:00

235 lines
7.3 KiB
Swift

public extension PureYAML.Emitting {
/// Block-style YAML serializer for ``PureYAML/Model/Value``.
struct Dumper: Sendable {
public var options: Options
public init(options: Options = .default) {
self.options = options
}
public func dump(_ value: PureYAML.Model.Value) -> String {
if options.canonical {
var body = canonicalDocument(value)
if options.explicitDocumentEnd {
body += "...\n"
}
return body
}
var body = switch options.collectionStyle {
case .block:
render(value, indent: 0).joined(separator: "\n") + "\n"
case .flow where options.lineWidth > 0:
renderFlowWrappedDocument(value)
case .flow:
renderFlowDocument(value)
}
if options.explicitDocumentStart {
body = "---\n" + body
}
if options.explicitDocumentEnd {
body += "...\n"
}
return body
}
}
}
extension PureYAML.Emitting.Dumper {
func render(
_ value: PureYAML.Model.Value,
indent: Int,
) -> [String] {
switch value {
case let .mapping(mapping):
renderMapping(mapping, indent: indent)
case let .sequence(values):
renderSequence(values, indent: indent)
default:
renderScalarNode(value, indent: indent)
}
}
func renderSequence(
_ values: [PureYAML.Model.Value],
indent: Int,
) -> [String] {
let prefix = String(repeating: " ", count: indent)
guard !values.isEmpty else {
return [prefix + "[]"]
}
return values.flatMap { value in
if let emptyCollection = renderEmptyCollectionLiteral(value) {
return [prefix + "- " + emptyCollection]
}
switch value {
case .mapping, .sequence:
return [prefix + "-"] + render(value, indent: indent + options.indentWidth)
default:
return renderSequenceScalarItem(value, indent: indent)
}
}
}
func renderFlowDocument(_ value: PureYAML.Model.Value) -> String {
switch value {
case .mapping, .sequence:
renderFlow(value) + "\n"
default:
renderScalarNode(value, indent: 0).joined(separator: "\n") + "\n"
}
}
func renderFlow(_ value: PureYAML.Model.Value) -> String {
switch value {
case let .mapping(mapping):
renderFlowMapping(mapping)
case let .sequence(values):
renderFlowSequence(values)
default:
renderFlowScalar(value)
}
}
func renderFlowSequence(_ values: [PureYAML.Model.Value]) -> String {
let items = values
.map(renderFlow)
.joined(separator: ", ")
return "[\(items)]"
}
func renderEmptyCollectionLiteral(_ value: PureYAML.Model.Value) -> String? {
switch value {
case let .mapping(mapping) where mapping.pairs.isEmpty:
"{}"
case let .sequence(values) where values.isEmpty:
"[]"
default:
nil
}
}
func renderFlowScalar(_ value: PureYAML.Model.Value) -> String {
switch value {
case .null:
"null"
case let .bool(value):
value ? "true" : "false"
case let .int(value):
String(value)
case let .double(value):
String(value)
case let .string(value):
renderFlowString(value)
case .sequence, .mapping:
""
}
}
func renderScalarNode(
_ value: PureYAML.Model.Value,
indent: Int,
) -> [String] {
let prefix = String(repeating: " ", count: indent)
if let lines = renderLiteralBlockString(value, contentIndent: indent + options.indentWidth) {
return [prefix + lines.header] + lines.content
}
let rendered = renderScalar(value)
if let folded = foldedPlainScalarLine(head: prefix, value: value, rendered: rendered, indent: indent) {
return folded
}
return [prefix + rendered]
}
func renderMappingScalarPair(
_ pair: PureYAML.Model.Pair,
indent: Int,
) -> [String] {
let prefix = String(repeating: " ", count: indent)
let key = escapeKey(pair.key)
if let lines = renderLiteralBlockString(pair.value, contentIndent: indent + options.indentWidth) {
return [prefix + key + ": " + lines.header] + lines.content
}
let rendered = renderScalar(pair.value)
let head = prefix + key + ": "
if let folded = foldedPlainScalarLine(head: head, value: pair.value, rendered: rendered, indent: indent) {
return folded
}
return [head + rendered]
}
func renderSequenceScalarItem(
_ value: PureYAML.Model.Value,
indent: Int,
) -> [String] {
let prefix = String(repeating: " ", count: indent)
if let lines = renderLiteralBlockString(value, contentIndent: indent + options.indentWidth) {
return [prefix + "- " + lines.header] + lines.content
}
let rendered = renderScalar(value)
if let folded = foldedPlainScalarLine(head: prefix + "- ", value: value, rendered: rendered, indent: indent) {
return folded
}
return [prefix + "- " + rendered]
}
func renderScalar(_ value: PureYAML.Model.Value) -> String {
switch value {
case .null:
"null"
case let .bool(value):
value ? "true" : "false"
case let .int(value):
String(value)
case let .double(value):
String(value)
case let .string(value):
renderString(value)
case .sequence, .mapping:
""
}
}
func renderString(_ value: String) -> String {
switch options.scalarStyle {
case .quoted:
quote(value)
case .plainWhenSafe:
canRenderPlainString(value) ? value : quote(value)
case .literalBlockWhenMultiline:
quote(value)
}
}
func renderFlowString(_ value: String) -> String {
switch options.scalarStyle {
case .plainWhenSafe:
canRenderFlowPlainString(value) ? value : quote(value)
case .quoted, .literalBlockWhenMultiline:
quote(value)
}
}
func renderLiteralBlockString(
_ value: PureYAML.Model.Value,
contentIndent: Int,
) -> (header: String, content: [String])? {
guard
case let .string(string) = value,
options.scalarStyle == .literalBlockWhenMultiline,
canRenderLiteralBlockString(string)
else {
return nil
}
let keepsTrailingNewline = string.hasSuffix("\n")
let header = keepsTrailingNewline ? "|" : "|-"
let contentSource = keepsTrailingNewline ? String(string.dropLast()) : string
let prefix = String(repeating: " ", count: contentIndent)
let content = contentSource.split(separator: "\n", omittingEmptySubsequences: false)
.map { prefix + String($0) }
return (header, content)
}
}