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
This commit is contained in:
parent
58872248d5
commit
8df3f0b8d4
16
CHANGELOG.md
16
CHANGELOG.md
@ -9,6 +9,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- `Emitting.Options` gains `canonical`, `explicitDocumentStart`,
|
||||
`explicitDocumentEnd`, and `lineWidth`, completing issue #10 (the
|
||||
earlier `indentWidth`/`sortKeys` slice deliberately deferred these).
|
||||
Canonical mode emits the YAML-specification canonical form matching
|
||||
libyaml's layout: explicit `---`, an explicit tag on every node
|
||||
(carried tags on the `Tagged` path, the value's implicit tag
|
||||
otherwise), double-quoted scalars, and flow collections with one
|
||||
`? key : value,` entry per line; anchors and alias references survive
|
||||
on the `Tagged` path. `lineWidth` wraps flow collections one entry per
|
||||
line when the single-line form overflows, and folds plain block
|
||||
scalars at single-space boundaries onto more-indented continuation
|
||||
lines; a fold that would manufacture syntax (a segment ending in `:`,
|
||||
a continuation starting with `#` or an indicator) or disturb
|
||||
consecutive spaces is vetoed and the line stays long, so the value
|
||||
always survives reparse. Gated by golden-output tests plus
|
||||
reparse-equivalence tests for every option.
|
||||
- Anchors, aliases, and per-node presentation styles now survive the
|
||||
`Tagged` tree (issue #9, the feature-parity audit's biggest blocker to
|
||||
switching from Yams). `Tagged.Scalar`/`.Sequence`/`.Mapping` gain
|
||||
|
||||
87
Sources/Emitting/Dumper.Canonical.swift
Normal file
87
Sources/Emitting/Dumper.Canonical.swift
Normal file
@ -0,0 +1,87 @@
|
||||
extension PureYAML.Emitting.Dumper {
|
||||
/// The YAML-specification canonical form, matching libyaml's canonical
|
||||
/// emitter layout: an explicit `---` document start, an explicit tag on
|
||||
/// every node, double-quoted scalars, and flow collections with one
|
||||
/// entry per line, mapping entries in the explicit `? key : value,`
|
||||
/// form. Tags derive from each value's case, the same resolution the
|
||||
/// parser applies in reverse.
|
||||
func canonicalDocument(_ value: PureYAML.Model.Value) -> String {
|
||||
"---\n" + canonicalLines(value, indent: 0).joined(separator: "\n") + "\n"
|
||||
}
|
||||
|
||||
func canonicalLines(
|
||||
_ value: PureYAML.Model.Value,
|
||||
indent: Int,
|
||||
) -> [String] {
|
||||
let prefix = String(repeating: " ", count: indent)
|
||||
switch value {
|
||||
case let .sequence(values):
|
||||
return canonicalSequenceLines(values, indent: indent)
|
||||
case let .mapping(mapping):
|
||||
return canonicalMappingLines(mapping, indent: indent)
|
||||
default:
|
||||
return [prefix + canonicalScalar(value)]
|
||||
}
|
||||
}
|
||||
|
||||
func canonicalSequenceLines(
|
||||
_ values: [PureYAML.Model.Value],
|
||||
indent: Int,
|
||||
) -> [String] {
|
||||
let prefix = String(repeating: " ", count: indent)
|
||||
guard !values.isEmpty else {
|
||||
return [prefix + "!!seq []"]
|
||||
}
|
||||
var lines = [prefix + "!!seq ["]
|
||||
for value in values {
|
||||
var entry = canonicalLines(value, indent: indent + options.indentWidth)
|
||||
entry[entry.count - 1] += ","
|
||||
lines.append(contentsOf: entry)
|
||||
}
|
||||
lines.append(prefix + "]")
|
||||
return lines
|
||||
}
|
||||
|
||||
func canonicalMappingLines(
|
||||
_ mapping: PureYAML.Model.Mapping,
|
||||
indent: Int,
|
||||
) -> [String] {
|
||||
let prefix = String(repeating: " ", count: indent)
|
||||
guard !mapping.pairs.isEmpty else {
|
||||
return [prefix + "!!map {}"]
|
||||
}
|
||||
let inner = indent + options.indentWidth
|
||||
let innerPrefix = String(repeating: " ", count: inner)
|
||||
var lines = [prefix + "!!map {"]
|
||||
for pair in orderedPairs(mapping) {
|
||||
var keyLines = canonicalLines(pair.keyNode.value, indent: inner)
|
||||
keyLines[0] = innerPrefix + "? " + String(keyLines[0].dropFirst(innerPrefix.count))
|
||||
lines.append(contentsOf: keyLines)
|
||||
var valueLines = canonicalLines(pair.value, indent: inner)
|
||||
valueLines[0] = innerPrefix + ": " + String(valueLines[0].dropFirst(innerPrefix.count))
|
||||
valueLines[valueLines.count - 1] += ","
|
||||
lines.append(contentsOf: valueLines)
|
||||
}
|
||||
lines.append(prefix + "}")
|
||||
return lines
|
||||
}
|
||||
|
||||
/// A canonical scalar: the value's implicit tag made explicit, content
|
||||
/// double-quoted so no resolution ambiguity survives.
|
||||
func canonicalScalar(_ value: PureYAML.Model.Value) -> String {
|
||||
switch value {
|
||||
case .null:
|
||||
"!!null \"null\""
|
||||
case let .bool(flag):
|
||||
"!!bool \"\(flag ? "true" : "false")\""
|
||||
case let .int(number):
|
||||
"!!int \"\(number)\""
|
||||
case let .double(number):
|
||||
"!!float \"\(number)\""
|
||||
case let .string(string):
|
||||
"!!str " + quote(string)
|
||||
case .sequence, .mapping:
|
||||
""
|
||||
}
|
||||
}
|
||||
}
|
||||
181
Sources/Emitting/Dumper.ScalarAnalysis.swift
Normal file
181
Sources/Emitting/Dumper.ScalarAnalysis.swift
Normal file
@ -0,0 +1,181 @@
|
||||
// MARK: - Scalar analysis
|
||||
|
||||
extension PureYAML.Emitting.Dumper {
|
||||
func canRenderLiteralBlockString(_ value: String) -> Bool {
|
||||
guard value.contains("\n"), !value.contains("\r"), !value.contains("\t") else {
|
||||
return false
|
||||
}
|
||||
|
||||
let contentSource = value.hasSuffix("\n") ? String(value.dropLast()) : value
|
||||
guard !contentSource.isEmpty else {
|
||||
return false
|
||||
}
|
||||
|
||||
return contentSource
|
||||
.split(separator: "\n", omittingEmptySubsequences: false)
|
||||
.allSatisfy(canRenderLiteralBlockLine)
|
||||
}
|
||||
|
||||
func canRenderLiteralBlockLine(_ line: Substring) -> Bool {
|
||||
guard !line.isEmpty, line.first?.isWhitespace == false, line.last?.isWhitespace == false else {
|
||||
return false
|
||||
}
|
||||
let value = String(line)
|
||||
guard !containsCommentStart(value), !containsMappingSeparator(value) else {
|
||||
return false
|
||||
}
|
||||
guard let first = line.first else {
|
||||
return false
|
||||
}
|
||||
return canRenderLiteralBlockLineStarting(with: first, line: line)
|
||||
}
|
||||
|
||||
func canRenderLiteralBlockLineStarting(
|
||||
with first: Character,
|
||||
line: Substring,
|
||||
) -> Bool {
|
||||
switch first {
|
||||
case "#", ",", "[", "]", "{", "}", "&", "*", "!", "|", ">", "'", "\"", "`":
|
||||
false
|
||||
case "-":
|
||||
!hasOnlyIndicator(first, line: line) && line.dropFirst().first != " "
|
||||
case "?":
|
||||
!hasOnlyIndicator(first, line: line) && line.dropFirst().first != " "
|
||||
case ":":
|
||||
!hasOnlyIndicator(first, line: line) && line.dropFirst().first != " "
|
||||
default:
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
func hasOnlyIndicator(
|
||||
_ indicator: Character,
|
||||
line: Substring,
|
||||
) -> Bool {
|
||||
line.count == 1 && line.first == indicator
|
||||
}
|
||||
|
||||
func containsCommentStart(_ value: String) -> Bool {
|
||||
var previousWasWhitespace = true
|
||||
for character in value {
|
||||
if character == "#", previousWasWhitespace {
|
||||
return true
|
||||
}
|
||||
previousWasWhitespace = character.isWhitespace
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsMappingSeparator(_ value: String) -> Bool {
|
||||
var previousWasColon = false
|
||||
for character in value {
|
||||
if previousWasColon, isMappingSeparatorBoundary(character) {
|
||||
return true
|
||||
}
|
||||
previousWasColon = character == ":"
|
||||
}
|
||||
return previousWasColon
|
||||
}
|
||||
|
||||
func isMappingSeparatorBoundary(_ character: Character) -> Bool {
|
||||
character.isWhitespace
|
||||
|| character == ","
|
||||
|| character == "]"
|
||||
|| character == "}"
|
||||
}
|
||||
|
||||
func canRenderPlainString(_ value: String) -> Bool {
|
||||
guard !value.isEmpty else {
|
||||
return false
|
||||
}
|
||||
guard value.first?.isWhitespace == false, value.last?.isWhitespace == false else {
|
||||
return false
|
||||
}
|
||||
guard !value.contains("\n"), !value.contains("\r"), !value.contains("\t") else {
|
||||
return false
|
||||
}
|
||||
guard !isReservedPlainScalar(value), !isNumberLikePlainScalar(value) else {
|
||||
return false
|
||||
}
|
||||
guard let first = value.first, !isPlainScalarIndicator(first) else {
|
||||
return false
|
||||
}
|
||||
return !containsColonSpace(value) && !value.contains("#")
|
||||
}
|
||||
|
||||
func canRenderFlowPlainString(_ value: String) -> Bool {
|
||||
canRenderPlainString(value) && !containsFlowDelimiter(value)
|
||||
}
|
||||
|
||||
func containsFlowDelimiter(_ value: String) -> Bool {
|
||||
value.contains(",")
|
||||
|| value.contains("[")
|
||||
|| value.contains("]")
|
||||
|| value.contains("{")
|
||||
|| value.contains("}")
|
||||
}
|
||||
|
||||
func containsColonSpace(_ value: String) -> Bool {
|
||||
var previousWasColon = false
|
||||
for character in value {
|
||||
if previousWasColon, character == " " {
|
||||
return true
|
||||
}
|
||||
previousWasColon = character == ":"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isReservedPlainScalar(_ value: String) -> Bool {
|
||||
switch value {
|
||||
case "~", "null", "Null", "NULL", "true", "True", "TRUE", "false", "False", "FALSE":
|
||||
true
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
func isNumberLikePlainScalar(_ value: String) -> Bool {
|
||||
if Int(value) != nil, !value.contains(".") {
|
||||
return true
|
||||
}
|
||||
if value.contains(".") || value.contains("e") || value.contains("E") {
|
||||
return Double(value) != nil
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isPlainScalarIndicator(_ character: Character) -> Bool {
|
||||
"-?:,[]{}#&*!|>'\"%@`".contains(character)
|
||||
}
|
||||
|
||||
func escapeKey(_ key: String) -> String {
|
||||
canRenderPlainString(key) ? key : quote(key)
|
||||
}
|
||||
|
||||
func quote(_ value: String) -> String {
|
||||
// Iterate over unicode scalars, not Characters: a CR+LF pair forms a
|
||||
// single extended grapheme cluster in Swift, so a Character-level switch
|
||||
// would match neither "\r" nor "\n" and emit the line break unescaped,
|
||||
// which does not round-trip through the folding parser.
|
||||
var output = "\""
|
||||
for scalar in value.unicodeScalars {
|
||||
switch scalar {
|
||||
case "\"":
|
||||
output += "\\\""
|
||||
case "\\":
|
||||
output += "\\\\"
|
||||
case "\n":
|
||||
output += "\\n"
|
||||
case "\r":
|
||||
output += "\\r"
|
||||
case "\t":
|
||||
output += "\\t"
|
||||
default:
|
||||
output.unicodeScalars.append(scalar)
|
||||
}
|
||||
}
|
||||
output += "\""
|
||||
return output
|
||||
}
|
||||
}
|
||||
147
Sources/Emitting/Dumper.Wrap.swift
Normal file
147
Sources/Emitting/Dumper.Wrap.swift
Normal file
@ -0,0 +1,147 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -8,12 +8,28 @@ public extension PureYAML.Emitting {
|
||||
}
|
||||
|
||||
public func dump(_ value: PureYAML.Model.Value) -> String {
|
||||
switch options.collectionStyle {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -119,7 +135,11 @@ extension PureYAML.Emitting.Dumper {
|
||||
if let lines = renderLiteralBlockString(value, contentIndent: indent + options.indentWidth) {
|
||||
return [prefix + lines.header] + lines.content
|
||||
}
|
||||
return [prefix + renderScalar(value)]
|
||||
let rendered = renderScalar(value)
|
||||
if let folded = foldedPlainScalarLine(head: prefix, value: value, rendered: rendered, indent: indent) {
|
||||
return folded
|
||||
}
|
||||
return [prefix + rendered]
|
||||
}
|
||||
|
||||
func renderMappingScalarPair(
|
||||
@ -131,7 +151,12 @@ extension PureYAML.Emitting.Dumper {
|
||||
if let lines = renderLiteralBlockString(pair.value, contentIndent: indent + options.indentWidth) {
|
||||
return [prefix + key + ": " + lines.header] + lines.content
|
||||
}
|
||||
return [prefix + key + ": " + renderScalar(pair.value)]
|
||||
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(
|
||||
@ -142,7 +167,11 @@ extension PureYAML.Emitting.Dumper {
|
||||
if let lines = renderLiteralBlockString(value, contentIndent: indent + options.indentWidth) {
|
||||
return [prefix + "- " + lines.header] + lines.content
|
||||
}
|
||||
return [prefix + "- " + renderScalar(value)]
|
||||
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 {
|
||||
@ -202,182 +231,4 @@ extension PureYAML.Emitting.Dumper {
|
||||
.map { prefix + String($0) }
|
||||
return (header, content)
|
||||
}
|
||||
|
||||
func canRenderLiteralBlockString(_ value: String) -> Bool {
|
||||
guard value.contains("\n"), !value.contains("\r"), !value.contains("\t") else {
|
||||
return false
|
||||
}
|
||||
|
||||
let contentSource = value.hasSuffix("\n") ? String(value.dropLast()) : value
|
||||
guard !contentSource.isEmpty else {
|
||||
return false
|
||||
}
|
||||
|
||||
return contentSource
|
||||
.split(separator: "\n", omittingEmptySubsequences: false)
|
||||
.allSatisfy(canRenderLiteralBlockLine)
|
||||
}
|
||||
|
||||
func canRenderLiteralBlockLine(_ line: Substring) -> Bool {
|
||||
guard !line.isEmpty, line.first?.isWhitespace == false, line.last?.isWhitespace == false else {
|
||||
return false
|
||||
}
|
||||
let value = String(line)
|
||||
guard !containsCommentStart(value), !containsMappingSeparator(value) else {
|
||||
return false
|
||||
}
|
||||
guard let first = line.first else {
|
||||
return false
|
||||
}
|
||||
return canRenderLiteralBlockLineStarting(with: first, line: line)
|
||||
}
|
||||
|
||||
func canRenderLiteralBlockLineStarting(
|
||||
with first: Character,
|
||||
line: Substring,
|
||||
) -> Bool {
|
||||
switch first {
|
||||
case "#", ",", "[", "]", "{", "}", "&", "*", "!", "|", ">", "'", "\"", "`":
|
||||
false
|
||||
case "-":
|
||||
!hasOnlyIndicator(first, line: line) && line.dropFirst().first != " "
|
||||
case "?":
|
||||
!hasOnlyIndicator(first, line: line) && line.dropFirst().first != " "
|
||||
case ":":
|
||||
!hasOnlyIndicator(first, line: line) && line.dropFirst().first != " "
|
||||
default:
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
func hasOnlyIndicator(
|
||||
_ indicator: Character,
|
||||
line: Substring,
|
||||
) -> Bool {
|
||||
line.count == 1 && line.first == indicator
|
||||
}
|
||||
|
||||
func containsCommentStart(_ value: String) -> Bool {
|
||||
var previousWasWhitespace = true
|
||||
for character in value {
|
||||
if character == "#", previousWasWhitespace {
|
||||
return true
|
||||
}
|
||||
previousWasWhitespace = character.isWhitespace
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsMappingSeparator(_ value: String) -> Bool {
|
||||
var previousWasColon = false
|
||||
for character in value {
|
||||
if previousWasColon, isMappingSeparatorBoundary(character) {
|
||||
return true
|
||||
}
|
||||
previousWasColon = character == ":"
|
||||
}
|
||||
return previousWasColon
|
||||
}
|
||||
|
||||
func isMappingSeparatorBoundary(_ character: Character) -> Bool {
|
||||
character.isWhitespace
|
||||
|| character == ","
|
||||
|| character == "]"
|
||||
|| character == "}"
|
||||
}
|
||||
|
||||
func canRenderPlainString(_ value: String) -> Bool {
|
||||
guard !value.isEmpty else {
|
||||
return false
|
||||
}
|
||||
guard value.first?.isWhitespace == false, value.last?.isWhitespace == false else {
|
||||
return false
|
||||
}
|
||||
guard !value.contains("\n"), !value.contains("\r"), !value.contains("\t") else {
|
||||
return false
|
||||
}
|
||||
guard !isReservedPlainScalar(value), !isNumberLikePlainScalar(value) else {
|
||||
return false
|
||||
}
|
||||
guard let first = value.first, !isPlainScalarIndicator(first) else {
|
||||
return false
|
||||
}
|
||||
return !containsColonSpace(value) && !value.contains("#")
|
||||
}
|
||||
|
||||
func canRenderFlowPlainString(_ value: String) -> Bool {
|
||||
canRenderPlainString(value) && !containsFlowDelimiter(value)
|
||||
}
|
||||
|
||||
func containsFlowDelimiter(_ value: String) -> Bool {
|
||||
value.contains(",")
|
||||
|| value.contains("[")
|
||||
|| value.contains("]")
|
||||
|| value.contains("{")
|
||||
|| value.contains("}")
|
||||
}
|
||||
|
||||
func containsColonSpace(_ value: String) -> Bool {
|
||||
var previousWasColon = false
|
||||
for character in value {
|
||||
if previousWasColon, character == " " {
|
||||
return true
|
||||
}
|
||||
previousWasColon = character == ":"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isReservedPlainScalar(_ value: String) -> Bool {
|
||||
switch value {
|
||||
case "~", "null", "Null", "NULL", "true", "True", "TRUE", "false", "False", "FALSE":
|
||||
true
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
func isNumberLikePlainScalar(_ value: String) -> Bool {
|
||||
if Int(value) != nil, !value.contains(".") {
|
||||
return true
|
||||
}
|
||||
if value.contains(".") || value.contains("e") || value.contains("E") {
|
||||
return Double(value) != nil
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isPlainScalarIndicator(_ character: Character) -> Bool {
|
||||
"-?:,[]{}#&*!|>'\"%@`".contains(character)
|
||||
}
|
||||
|
||||
func escapeKey(_ key: String) -> String {
|
||||
canRenderPlainString(key) ? key : quote(key)
|
||||
}
|
||||
|
||||
func quote(_ value: String) -> String {
|
||||
// Iterate over unicode scalars, not Characters: a CR+LF pair forms a
|
||||
// single extended grapheme cluster in Swift, so a Character-level switch
|
||||
// would match neither "\r" nor "\n" and emit the line break unescaped,
|
||||
// which does not round-trip through the folding parser.
|
||||
var output = "\""
|
||||
for scalar in value.unicodeScalars {
|
||||
switch scalar {
|
||||
case "\"":
|
||||
output += "\\\""
|
||||
case "\\":
|
||||
output += "\\\\"
|
||||
case "\n":
|
||||
output += "\\n"
|
||||
case "\r":
|
||||
output += "\\r"
|
||||
case "\t":
|
||||
output += "\\t"
|
||||
default:
|
||||
output.unicodeScalars.append(scalar)
|
||||
}
|
||||
}
|
||||
output += "\""
|
||||
return output
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,17 +21,46 @@ public extension PureYAML.Emitting {
|
||||
/// `Mapping` back to itself meaningful, so sorting is opt-in, not
|
||||
/// the default.
|
||||
public var sortKeys: Bool
|
||||
/// Emit the YAML-specification canonical form: an explicit `---`
|
||||
/// document start, an explicit `!!tag` on every node, double-quoted
|
||||
/// scalars, and flow collections with one `? key : value,` entry
|
||||
/// per line. When set, `scalarStyle`, `collectionStyle`, and
|
||||
/// `lineWidth` are ignored; `indentWidth` and `sortKeys` still
|
||||
/// apply. Off by default.
|
||||
public var canonical: Bool
|
||||
/// Emit an explicit `---` document start marker. Implied by
|
||||
/// `canonical`. Off by default.
|
||||
public var explicitDocumentStart: Bool
|
||||
/// Emit an explicit `...` document end marker. Off by default.
|
||||
public var explicitDocumentEnd: Bool
|
||||
/// The preferred maximum line width. `0` (the default) disables
|
||||
/// wrapping. When positive, a flow collection whose single-line
|
||||
/// form would overflow the width re-renders with one entry per
|
||||
/// line, and a plain block scalar folds at spaces onto
|
||||
/// more-indented continuation lines; folding restores each break
|
||||
/// to the same single space on reparse, so the value is unchanged.
|
||||
/// Content with no legal break point (a single overlong word, a
|
||||
/// quoted or block scalar) stays on one line.
|
||||
public var lineWidth: Int
|
||||
|
||||
public init(
|
||||
scalarStyle: ScalarStyle = .quoted,
|
||||
collectionStyle: CollectionStyle = .block,
|
||||
indentWidth: Int = 2,
|
||||
sortKeys: Bool = false,
|
||||
canonical: Bool = false,
|
||||
explicitDocumentStart: Bool = false,
|
||||
explicitDocumentEnd: Bool = false,
|
||||
lineWidth: Int = 0,
|
||||
) {
|
||||
self.scalarStyle = scalarStyle
|
||||
self.collectionStyle = collectionStyle
|
||||
self.indentWidth = indentWidth
|
||||
self.sortKeys = sortKeys
|
||||
self.canonical = canonical
|
||||
self.explicitDocumentStart = explicitDocumentStart
|
||||
self.explicitDocumentEnd = explicitDocumentEnd
|
||||
self.lineWidth = lineWidth
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
140
Sources/Tagged/Tagged.Dumper.Canonical.swift
Normal file
140
Sources/Tagged/Tagged.Dumper.Canonical.swift
Normal file
@ -0,0 +1,140 @@
|
||||
// MARK: - Canonical form
|
||||
|
||||
extension PureYAML.Tagged.Dumper {
|
||||
/// The YAML-specification canonical form over a tagged tree: an
|
||||
/// explicit `---` document start, every node's tag explicit (the
|
||||
/// carried tag when present, the value's implicit tag otherwise),
|
||||
/// double-quoted scalar content, flow collections one entry per line,
|
||||
/// and anchors and alias references preserved. Canonical normalizes
|
||||
/// scalar spellings: content renders from the resolved value, not the
|
||||
/// source rawValue.
|
||||
func canonicalDocument(_ node: PureYAML.Tagged.Node) -> String {
|
||||
"---\n" + canonicalLines(node, indent: 0).joined(separator: "\n") + "\n"
|
||||
}
|
||||
|
||||
func canonicalLines(
|
||||
_ node: PureYAML.Tagged.Node,
|
||||
indent: Int,
|
||||
) -> [String] {
|
||||
let prefix = String(repeating: " ", count: indent)
|
||||
switch node {
|
||||
case let .alias(anchor, _):
|
||||
return [prefix + "*" + anchor]
|
||||
case let .scalar(scalar):
|
||||
return [prefix + canonicalProperties(node) + canonicalScalarContent(scalar)]
|
||||
case let .sequence(sequence):
|
||||
return canonicalSequenceLines(sequence, node: node, indent: indent)
|
||||
case let .mapping(mapping):
|
||||
return canonicalMappingLines(mapping, node: node, indent: indent)
|
||||
}
|
||||
}
|
||||
|
||||
func canonicalSequenceLines(
|
||||
_ sequence: PureYAML.Tagged.Sequence,
|
||||
node: PureYAML.Tagged.Node,
|
||||
indent: Int,
|
||||
) -> [String] {
|
||||
let prefix = String(repeating: " ", count: indent)
|
||||
let props = canonicalProperties(node, fallback: .sequence)
|
||||
guard !sequence.values.isEmpty else {
|
||||
return [prefix + props + "[]"]
|
||||
}
|
||||
var lines = [prefix + props + "["]
|
||||
for value in sequence.values {
|
||||
var entry = canonicalLines(value, indent: indent + options.indentWidth)
|
||||
entry[entry.count - 1] += ","
|
||||
lines.append(contentsOf: entry)
|
||||
}
|
||||
lines.append(prefix + "]")
|
||||
return lines
|
||||
}
|
||||
|
||||
func canonicalMappingLines(
|
||||
_ mapping: PureYAML.Tagged.Mapping,
|
||||
node: PureYAML.Tagged.Node,
|
||||
indent: Int,
|
||||
) -> [String] {
|
||||
let prefix = String(repeating: " ", count: indent)
|
||||
let props = canonicalProperties(node, fallback: .mapping)
|
||||
guard !mapping.pairs.isEmpty else {
|
||||
return [prefix + props + "{}"]
|
||||
}
|
||||
let inner = indent + options.indentWidth
|
||||
let innerPrefix = String(repeating: " ", count: inner)
|
||||
var lines = [prefix + props + "{"]
|
||||
for pair in mapping.pairs {
|
||||
let keyTag = renderTag(pair.keyTag ?? .string)
|
||||
var quotedKey = ""
|
||||
for character in pair.key {
|
||||
switch character {
|
||||
case "\"", "\\":
|
||||
quotedKey += "\\" + String(character)
|
||||
default:
|
||||
quotedKey.append(character)
|
||||
}
|
||||
}
|
||||
lines.append(innerPrefix + "? " + keyTag + " \"" + quotedKey + "\"")
|
||||
var valueLines = canonicalLines(pair.value, indent: inner)
|
||||
valueLines[0] = innerPrefix + ": " + String(valueLines[0].dropFirst(innerPrefix.count))
|
||||
valueLines[valueLines.count - 1] += ","
|
||||
lines.append(contentsOf: valueLines)
|
||||
}
|
||||
lines.append(prefix + "}")
|
||||
return lines
|
||||
}
|
||||
|
||||
/// The canonical `&anchor !tag ` prefix: anchor first, then the carried
|
||||
/// tag, or the value's implicit tag made explicit when none is carried.
|
||||
func canonicalProperties(
|
||||
_ node: PureYAML.Tagged.Node,
|
||||
fallback: PureYAML.Tagged.Tag? = nil,
|
||||
) -> String {
|
||||
var parts: [String] = []
|
||||
if let anchor = node.anchor, node.kind != .alias {
|
||||
parts.append("&" + anchor)
|
||||
}
|
||||
if let tag = node.tag ?? fallback ?? implicitTag(of: node) {
|
||||
parts.append(renderTag(tag))
|
||||
}
|
||||
return parts.joined(separator: " ") + " "
|
||||
}
|
||||
|
||||
func implicitTag(of node: PureYAML.Tagged.Node) -> PureYAML.Tagged.Tag? {
|
||||
guard case let .scalar(scalar) = node else {
|
||||
return nil
|
||||
}
|
||||
switch scalar.value {
|
||||
case .null:
|
||||
return .null
|
||||
case .bool:
|
||||
return .bool
|
||||
case .int:
|
||||
return .int
|
||||
case .double:
|
||||
return .float
|
||||
case .string:
|
||||
return .string
|
||||
case .sequence, .mapping:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical scalar content: always double-quoted, rendered from the
|
||||
/// resolved value so `NO` normalizes to `"false"` under its bool tag.
|
||||
func canonicalScalarContent(_ scalar: PureYAML.Tagged.Scalar) -> String {
|
||||
switch scalar.value {
|
||||
case .null:
|
||||
"\"null\""
|
||||
case let .bool(flag):
|
||||
"\"\(flag ? "true" : "false")\""
|
||||
case let .int(number):
|
||||
"\"\(number)\""
|
||||
case let .double(number):
|
||||
"\"\(number)\""
|
||||
case let .string(string):
|
||||
PureYAML.Emitting.Dumper(options: options).quote(string)
|
||||
case .sequence, .mapping:
|
||||
"\"\""
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -16,12 +16,27 @@ public extension PureYAML.Tagged {
|
||||
}
|
||||
|
||||
public func dump(_ node: PureYAML.Tagged.Node) -> String {
|
||||
let scalarRenderer = PureYAML.Emitting.Dumper(options: options)
|
||||
if isFlowRendered(node) {
|
||||
return renderFlow(node, renderer: scalarRenderer) + "\n"
|
||||
if options.canonical {
|
||||
var body = canonicalDocument(node)
|
||||
if options.explicitDocumentEnd {
|
||||
body += "...\n"
|
||||
}
|
||||
return body
|
||||
}
|
||||
return renderBlock(node, indent: 0, renderer: scalarRenderer)
|
||||
.joined(separator: "\n") + "\n"
|
||||
let scalarRenderer = PureYAML.Emitting.Dumper(options: options)
|
||||
var body = if isFlowRendered(node) {
|
||||
renderFlow(node, renderer: scalarRenderer) + "\n"
|
||||
} else {
|
||||
renderBlock(node, indent: 0, renderer: scalarRenderer)
|
||||
.joined(separator: "\n") + "\n"
|
||||
}
|
||||
if options.explicitDocumentStart {
|
||||
body = "---\n" + body
|
||||
}
|
||||
if options.explicitDocumentEnd {
|
||||
body += "...\n"
|
||||
}
|
||||
return body
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
161
Tests/EmitterCanonicalAndWidthTests.swift
Normal file
161
Tests/EmitterCanonicalAndWidthTests.swift
Normal file
@ -0,0 +1,161 @@
|
||||
@testable import PureYAML
|
||||
import Testing
|
||||
|
||||
@Suite("Canonical mode, document markers, and line width")
|
||||
struct EmitterCanonicalAndWidthTests {
|
||||
@Test("Canonical form emits explicit tags, quotes, and flow entries per line")
|
||||
func test_canonicalGoldenOutput() throws {
|
||||
let value = try PureYAML.parse("name: config\ncount: 3\nitems: [a, b]\n")
|
||||
let dumped = PureYAML.dump(value, options: .init(canonical: true))
|
||||
#expect(dumped == """
|
||||
---
|
||||
!!map {
|
||||
? !!str "name"
|
||||
: !!str "config",
|
||||
? !!str "count"
|
||||
: !!int "3",
|
||||
? !!str "items"
|
||||
: !!seq [
|
||||
!!str "a",
|
||||
!!str "b",
|
||||
],
|
||||
}
|
||||
|
||||
""")
|
||||
}
|
||||
|
||||
@Test("Canonical output reparses to the identical value")
|
||||
func test_canonicalReparsesToSameValue() throws {
|
||||
let source = """
|
||||
server: web-1
|
||||
replicas: 2
|
||||
healthy: true
|
||||
threshold: 0.75
|
||||
fallback: null
|
||||
tags: [prod, eu]
|
||||
limits:
|
||||
memory: 512
|
||||
"""
|
||||
let value = try PureYAML.parse(source)
|
||||
let canonical = PureYAML.dump(value, options: .init(canonical: true))
|
||||
let reparsed = try PureYAML.parse(canonical)
|
||||
#expect(reparsed == value)
|
||||
}
|
||||
|
||||
@Test("Canonical scalar document and empty collections")
|
||||
func test_canonicalScalarAndEmpties() {
|
||||
#expect(PureYAML.dump(.string("x"), options: .init(canonical: true)) == "---\n!!str \"x\"\n")
|
||||
#expect(PureYAML.dump(.mapping(.init(pairs: [])), options: .init(canonical: true)) == "---\n!!map {}\n")
|
||||
#expect(PureYAML.dump(.sequence([]), options: .init(canonical: true)) == "---\n!!seq []\n")
|
||||
}
|
||||
|
||||
@Test("Canonical tagged tree keeps anchors, aliases, and carried tags")
|
||||
func test_canonicalTaggedTree() throws {
|
||||
let node = try PureYAML.parseTagged("base: &b !Custom widget\nref: *b\n")
|
||||
let canonical = PureYAML.dump(node, options: .init(canonical: true))
|
||||
#expect(canonical == """
|
||||
---
|
||||
!!map {
|
||||
? !!str "base"
|
||||
: &b !<!Custom> "widget",
|
||||
? !!str "ref"
|
||||
: *b,
|
||||
}
|
||||
|
||||
""")
|
||||
let reparsed = try PureYAML.parseTagged(canonical)
|
||||
#expect(reparsed.modelValueErasingTags == node.modelValueErasingTags)
|
||||
guard case let .mapping(mapping) = reparsed else {
|
||||
Issue.record("expected mapping")
|
||||
return
|
||||
}
|
||||
#expect(mapping["ref"]?.kind == .alias)
|
||||
#expect(mapping["base"]?.tag == .init("!Custom"))
|
||||
}
|
||||
|
||||
@Test("Explicit document markers wrap the body")
|
||||
func test_explicitDocumentMarkers() throws {
|
||||
let value = PureYAML.Model.Value.mapping(.init([.init(key: "a", value: .int(1))]))
|
||||
let both = PureYAML.dump(value, options: .init(explicitDocumentStart: true, explicitDocumentEnd: true))
|
||||
#expect(both == "---\na: 1\n...\n")
|
||||
let startOnly = PureYAML.dump(value, options: .init(explicitDocumentStart: true))
|
||||
#expect(startOnly == "---\na: 1\n")
|
||||
let reparsed = try PureYAML.parse(both)
|
||||
#expect(reparsed == value)
|
||||
}
|
||||
|
||||
@Test("Line width folds long plain scalars and the value survives")
|
||||
func test_lineWidthFoldsPlainScalars() throws {
|
||||
let long = "the quick brown fox jumps over the lazy dog and keeps going well past any reasonable width"
|
||||
let value = PureYAML.Model.Value.mapping(.init([.init(key: "sentence", value: .string(long))]))
|
||||
let options = PureYAML.Emitting.Options(scalarStyle: .plainWhenSafe, lineWidth: 40)
|
||||
let dumped = PureYAML.dump(value, options: options)
|
||||
#expect(dumped.split(separator: "\n").count > 1)
|
||||
#expect(dumped.split(separator: "\n").allSatisfy { $0.count <= 40 })
|
||||
let reparsed = try PureYAML.parse(dumped)
|
||||
#expect(reparsed == value)
|
||||
}
|
||||
|
||||
@Test("Line width leaves quoted scalars and unbreakable words alone")
|
||||
func test_lineWidthLeavesUnsafeContentAlone() {
|
||||
let word = String(repeating: "x", count: 90)
|
||||
let value = PureYAML.Model.Value.mapping(.init([.init(key: "blob", value: .string(word))]))
|
||||
let plain = PureYAML.dump(value, options: .init(scalarStyle: .plainWhenSafe, lineWidth: 40))
|
||||
#expect(plain == "blob: \(word)\n")
|
||||
let quoted = PureYAML.dump(value, options: .init(scalarStyle: .quoted, lineWidth: 40))
|
||||
#expect(quoted == "blob: \"\(word)\"\n")
|
||||
}
|
||||
|
||||
@Test("A fold that would manufacture syntax is vetoed")
|
||||
func test_unsafeFoldBoundariesVetoed() throws {
|
||||
let risky = "watch the colon: placement and the - dash and the # mark carefully now"
|
||||
let value = PureYAML.Model.Value.string(risky)
|
||||
let dumped = PureYAML.dump(value, options: .init(scalarStyle: .plainWhenSafe, lineWidth: 30))
|
||||
let reparsed = try PureYAML.parse(dumped)
|
||||
#expect(reparsed == value)
|
||||
}
|
||||
|
||||
@Test("Line width wraps flow collections one entry per line")
|
||||
func test_lineWidthWrapsFlow() throws {
|
||||
let value = try PureYAML.parse("list: [alpha, beta, gamma, delta, epsilon, zeta, eta, theta]\n")
|
||||
let options = PureYAML.Emitting.Options(
|
||||
scalarStyle: .plainWhenSafe,
|
||||
collectionStyle: .flow,
|
||||
lineWidth: 30,
|
||||
)
|
||||
let dumped = PureYAML.dump(value, options: options)
|
||||
#expect(dumped.split(separator: "\n").count > 1)
|
||||
let reparsed = try PureYAML.parse(dumped)
|
||||
#expect(reparsed == value)
|
||||
}
|
||||
|
||||
@Test("Sequence items fold with safe continuation indentation")
|
||||
func test_sequenceItemFolding() throws {
|
||||
let long = "one two three four five six seven eight nine ten eleven twelve thirteen"
|
||||
let value = PureYAML.Model.Value.sequence([.string(long), .string("short")])
|
||||
let dumped = PureYAML.dump(value, options: .init(scalarStyle: .plainWhenSafe, lineWidth: 30))
|
||||
let reparsed = try PureYAML.parse(dumped)
|
||||
#expect(reparsed == value)
|
||||
}
|
||||
|
||||
@Test("Consecutive interior spaces forbid folding at that boundary")
|
||||
func test_consecutiveSpacesSurviveFolding() throws {
|
||||
let doubled = "left middle and then a good number of extra words to overflow the width right"
|
||||
let value = PureYAML.Model.Value.string(doubled)
|
||||
let dumped = PureYAML.dump(value, options: .init(scalarStyle: .plainWhenSafe, lineWidth: 25))
|
||||
let reparsed = try PureYAML.parse(dumped)
|
||||
#expect(reparsed == value)
|
||||
}
|
||||
|
||||
@Test("Canonical wins over conflicting style options")
|
||||
func test_canonicalOverridesStyles() throws {
|
||||
let value = try PureYAML.parse("a: 1\n")
|
||||
let styled = PureYAML.dump(value, options: .init(
|
||||
scalarStyle: .plainWhenSafe,
|
||||
collectionStyle: .flow,
|
||||
canonical: true,
|
||||
lineWidth: 10,
|
||||
))
|
||||
#expect(styled == "---\n!!map {\n ? !!str \"a\"\n : !!int \"1\",\n}\n")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user