diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ca1fcb..0195f4c 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/Sources/Emitting/Dumper.Canonical.swift b/Sources/Emitting/Dumper.Canonical.swift new file mode 100644 index 0000000..8726fa3 --- /dev/null +++ b/Sources/Emitting/Dumper.Canonical.swift @@ -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: + "" + } + } +} diff --git a/Sources/Emitting/Dumper.ScalarAnalysis.swift b/Sources/Emitting/Dumper.ScalarAnalysis.swift new file mode 100644 index 0000000..8e603cc --- /dev/null +++ b/Sources/Emitting/Dumper.ScalarAnalysis.swift @@ -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 + } +} diff --git a/Sources/Emitting/Dumper.Wrap.swift b/Sources/Emitting/Dumper.Wrap.swift new file mode 100644 index 0000000..a139c4c --- /dev/null +++ b/Sources/Emitting/Dumper.Wrap.swift @@ -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 + } +} diff --git a/Sources/Emitting/Dumper.swift b/Sources/Emitting/Dumper.swift index fa7315c..0ad98ac 100644 --- a/Sources/Emitting/Dumper.swift +++ b/Sources/Emitting/Dumper.swift @@ -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 - } } diff --git a/Sources/Emitting/Options.swift b/Sources/Emitting/Options.swift index 948f8df..7bfaa35 100644 --- a/Sources/Emitting/Options.swift +++ b/Sources/Emitting/Options.swift @@ -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 } } } diff --git a/Sources/Tagged/Tagged.Dumper.Canonical.swift b/Sources/Tagged/Tagged.Dumper.Canonical.swift new file mode 100644 index 0000000..d0ec523 --- /dev/null +++ b/Sources/Tagged/Tagged.Dumper.Canonical.swift @@ -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: + "\"\"" + } + } +} diff --git a/Sources/Tagged/Tagged.Dumper.swift b/Sources/Tagged/Tagged.Dumper.swift index b8671a2..a5c58f0 100644 --- a/Sources/Tagged/Tagged.Dumper.swift +++ b/Sources/Tagged/Tagged.Dumper.swift @@ -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 } } } diff --git a/Tests/EmitterCanonicalAndWidthTests.swift b/Tests/EmitterCanonicalAndWidthTests.swift new file mode 100644 index 0000000..e53d740 --- /dev/null +++ b/Tests/EmitterCanonicalAndWidthTests.swift @@ -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 ! "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") + } +}