PureYAML/Sources/Emitting/Dumper.Wrap.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

148 lines
5.8 KiB
Swift

extension PureYAML.Emitting.Dumper {
/// Width-aware flow rendering: when a flow collection's single-line
/// form (with its indent) overflows `lineWidth`, it re-renders with one
/// entry per line, recursively, which is equally valid flow YAML. With
/// wrapping disabled or content that fits, the single-line form is
/// unchanged.
func renderFlowWrappedDocument(_ value: PureYAML.Model.Value) -> String {
renderFlowWrappedLines(value, indent: 0).joined(separator: "\n") + "\n"
}
func renderFlowWrappedLines(
_ value: PureYAML.Model.Value,
indent: Int,
) -> [String] {
let prefix = String(repeating: " ", count: indent)
let single = renderFlow(value)
if indent + single.count <= options.lineWidth {
return [prefix + single]
}
switch value {
case let .sequence(values):
guard !values.isEmpty else {
return [prefix + "[]"]
}
var lines = [prefix + "["]
for item in values {
var entry = renderFlowWrappedLines(item, indent: indent + options.indentWidth)
entry[entry.count - 1] += ","
lines.append(contentsOf: entry)
}
lines.append(prefix + "]")
return lines
case let .mapping(mapping):
guard !mapping.pairs.isEmpty else {
return [prefix + "{}"]
}
let inner = indent + options.indentWidth
let innerPrefix = String(repeating: " ", count: inner)
var lines = [prefix + "{"]
for pair in orderedPairs(mapping) {
let key = renderFlowKey(pair.keyNode)
var entry = renderFlowWrappedLines(pair.value, indent: inner)
entry[0] = innerPrefix + key + ": " + String(entry[0].dropFirst(innerPrefix.count))
entry[entry.count - 1] += ","
lines.append(contentsOf: entry)
}
lines.append(prefix + "}")
return lines
default:
// A scalar has no flow break points; an overlong one stays on
// its single line.
return [prefix + single]
}
}
/// Folds an overlong plain scalar line at single-space boundaries onto
/// more-indented continuation lines. Parsing folds each break back to
/// exactly one space, so the value is unchanged. Returns `nil` when
/// wrapping is off, the line fits, the scalar did not render plain, or
/// no safe break point exists.
func foldedPlainScalarLine(
head: String,
value: PureYAML.Model.Value,
rendered: String,
indent: Int,
) -> [String]? {
guard options.lineWidth > 0, head.count + rendered.count > options.lineWidth else {
return nil
}
guard case let .string(string) = value, rendered == string else {
return nil
}
let continuationIndent = indent + options.indentWidth
let continuationPrefix = String(repeating: " ", count: continuationIndent)
let segments = foldSegments(
string,
firstBudget: options.lineWidth - head.count,
continuationBudget: options.lineWidth - continuationIndent,
)
guard segments.count > 1, foldSegmentsAreSafe(segments) else {
return nil
}
var lines = [head + segments[0]]
for segment in segments.dropFirst() {
lines.append(continuationPrefix + segment)
}
return lines
}
/// A fold boundary must not manufacture YAML syntax: a segment ending
/// in `:` would put a colon before a line break (a value indicator), a
/// continuation line starting with `#` would open a comment (the fold
/// break is whitespace), and one starting with `-`, `?`, or `:` before
/// a space (or alone) would read as a block indicator. Any unsafe
/// boundary vetoes the whole fold; the line stays long.
func foldSegmentsAreSafe(_ segments: [String]) -> Bool {
for (index, segment) in segments.enumerated() {
if index < segments.count - 1, segment.hasSuffix(":") {
return false
}
guard index > 0 else {
continue
}
if segment.hasPrefix("#") {
return false
}
if let first = segment.first, "-?:".contains(first) {
let second = segment.dropFirst().first
if second == nil || second == " " {
return false
}
}
}
return true
}
/// Splits at spaces whose neighbors are non-space, so no segment gains
/// a leading or trailing space and every fold restores to exactly one
/// space on reparse. A word longer than the budget stays whole.
func foldSegments(
_ string: String,
firstBudget: Int,
continuationBudget: Int,
) -> [String] {
var segments: [String] = []
var current = ""
var budget = firstBudget
for word in string.split(separator: " ", omittingEmptySubsequences: false) {
if current.isEmpty {
current = String(word)
} else if current.count + 1 + word.count <= budget || word.isEmpty || current.hasSuffix(" ") {
// Empty words mark runs of consecutive spaces: those spaces
// must stay literal (a fold would swallow them), so they
// glue onto the current segment and forbid breaking here.
current += " " + word
} else {
segments.append(current)
current = String(word)
budget = continuationBudget
}
}
if !current.isEmpty {
segments.append(current)
}
return segments
}
}