Seven correctness fixes to the recently landed anchors/styles, canonical, timestamp/binary, and full-node-key work, each reproduced and covered by a regression test. - Stream dump doubled the `---` marker under explicitDocumentStart or canonical: the single-document body now renders marker-free and the stream path owns marker emission, so a two-document stream reparses as two documents, not four. - Tagged flow-mapping keys used block-context safety, so a key like `a,b` emitted bare and reparsed as several entries; flow keys now use the flow-aware plain-scalar check. - Plain scalars ending in `:` reparse as an empty-valued mapping key, so both the model and tagged emit paths now fall back to quoting them. - An alias mapping key pointing at a defined non-scalar anchor raised the misleading undefinedAlias; it now raises expectedScalarKey, and a truly undefined alias key still raises undefinedAlias. - Tagged duplicate-key detection keyed on source spelling: it now keys on the resolved scalar value plus effective tag, so 0x1 and 1 collide while 1 and "1" do not and custom-tagged keys stay distinct. - Dumping a tree nested past the emitter limit overflowed the stack. - Timestamp now accepts a leap-second value of 60, counted as 60 elapsed seconds in the epoch math. - Tagged.Dumper honors sortKeys in its block and canonical paths for all-plain-string-key mappings, matching the model dumper. Nesting-guard design: dump is public and non-throwing across 90+ call sites, so making it throwing would be a breaking change. A tree nested deeper than the parser's own maxNestingDepth cannot round-trip through PureYAML (the parser rejects it), so no faithful emission exists. Each dump entry point now measures depth iteratively (no recursion, so the guard itself cannot overflow) and, past the limit, returns a documented, reparseable marker document that resolves to null. Under the limit the existing recursive renderers run byte-identically, so parsed trees and every realistic hand-built tree are unaffected. This mirrors the parser's enforceNestingLimit, which bounds depth before any recursive consumer.
245 lines
8.0 KiB
Swift
245 lines
8.0 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
|
|
}
|
|
|
|
/// Serializes one document. A value nested deeper than the parser's own
|
|
/// ``PureYAML/Parsing/Parser/maxNestingDepth`` cannot round-trip through
|
|
/// PureYAML (the parser rejects it), and the recursive renderers would
|
|
/// overflow the stack on it, so such a value is refused with a
|
|
/// documented, reparseable marker document instead of crashing.
|
|
public func dump(_ value: PureYAML.Model.Value) -> String {
|
|
guard !PureYAML.Emitting.exceedsNestingLimit(value) else {
|
|
return PureYAML.Emitting.nestingLimitDocument
|
|
}
|
|
var body = documentBody(value)
|
|
if options.canonical || options.explicitDocumentStart {
|
|
body = "---\n" + body
|
|
}
|
|
if options.explicitDocumentEnd {
|
|
body += "...\n"
|
|
}
|
|
return body
|
|
}
|
|
|
|
/// The document body with no leading `---` or trailing `...` markers, so
|
|
/// the single-document and stream paths can each own marker emission.
|
|
func documentBody(_ value: PureYAML.Model.Value) -> String {
|
|
if options.canonical {
|
|
return canonicalBody(value)
|
|
}
|
|
return 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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|