feat(tagged): preserve anchors, aliases, and per-node styles end to end
Tagged.Scalar/Sequence/Mapping gain anchor and style fields (Tagged.ScalarStyle, Tagged.CollectionStyle), Tagged.Node gains an alias(anchor:node:) case carrying both the reference and its resolution, and parseTagged populates all of it from the parse events instead of discarding anchors and expanding aliases into copies. PureYAML.dump(_ node: Tagged.Node, options:) re-emits anchors, alias references, tags, and per-node styles over the document-wide policy. Plain scalars re-emit their source spelling (NO stays NO, an empty null stays empty); literal blocks cover keep chomping, single-line strip chomping, leading blank lines, and whitespace-led first lines via an explicit indentation indicator; folded scalars re-emit as > when the folding rules invert exactly and degrade to a literal block otherwise. The round-trip law parseTagged(dump(parseTagged(d))) == parseTagged(d) is corpus-tested over every real-world fixture, with one documented degradation: single-quoted content containing line breaks re-emits double-quoted, value unchanged. Closes #9
This commit is contained in:
parent
ec3642bacc
commit
2a84d6616f
38
CHANGELOG.md
38
CHANGELOG.md
@ -9,6 +9,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- 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
|
||||
`anchor` and `style` fields (`Tagged.ScalarStyle`: plain, single/double
|
||||
quoted, literal, folded; `Tagged.CollectionStyle`: block, flow), and
|
||||
`Tagged.Node` gains an `alias(anchor:node:)` case carrying both the
|
||||
reference and its resolution, so value-oriented consumers read through it
|
||||
without a resolution step (`modelValueErasingTags` matches `parse(_:)`'s
|
||||
expansion) while emitters re-emit the reference. Cycles are impossible by
|
||||
construction: an anchor registers only after its node fully composes, so
|
||||
a self-referential alias stays an `undefinedAlias` parse error.
|
||||
- `PureYAML.dump(_ node: Tagged.Node, options:)` and `Tagged.Dumper`: emit
|
||||
a tagged tree back to YAML with anchors (`&name`), alias references
|
||||
(`*name`, never expanded copies), tags (`!!short` for built-ins,
|
||||
`!<verbatim>` otherwise), and per-node styles that win over the
|
||||
document-wide policy. Literal blocks handle keep-chomping (`|+`),
|
||||
strip-chomped single lines (`|-`), leading blank lines, tab-led and
|
||||
whitespace-only interior lines, and whitespace-led first lines via an
|
||||
explicit indentation indicator (`|2`); folded scalars re-emit as `>` when
|
||||
the folding rules invert exactly, degrading to a literal block (value
|
||||
still exact) otherwise. Plain scalars re-emit their source spelling, so
|
||||
`NO` resolved as a bool does not normalize to `false` and an empty null
|
||||
stays empty. The round-trip law is corpus-tested over every real-world
|
||||
fixture: `parseTagged(dump(parseTagged(d)))` equals `parseTagged(d)`
|
||||
with exactly one documented style degradation (single-quoted content
|
||||
containing line breaks re-emits double-quoted; the value is unchanged).
|
||||
|
||||
- Add `Emitting.Options.indentWidth` and `.sortKeys` (issue #10's first
|
||||
slice), closing two of the four gaps a feature-parity audit against Yams
|
||||
found in the emitter (`PureYAMLYamsComparison`'s `FEATURE-PARITY.md`).
|
||||
@ -304,6 +331,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
resuming mid-token) is deferred, tracked at
|
||||
<https://codeberg.org/NodetteHQ/PureYAML/issues/4>.
|
||||
|
||||
### Changed
|
||||
|
||||
- `parseTagged(_:)`/`parseTaggedStream(_:)` now preserve alias references
|
||||
as `alias` nodes instead of expanding them into copies of the anchored
|
||||
node, and populate the new `anchor`/`style` fields from the source.
|
||||
Consumers switching exhaustively over `Tagged.Node` gain a case;
|
||||
`Tagged.NodeKind` gains `.alias`. Resolved values are unchanged:
|
||||
`modelValueErasingTags` and `Tagged.Constructor` read through aliases.
|
||||
`Tagged.Validator` walks each anchored subtree once at its definition
|
||||
site and does not re-walk it per alias reference.
|
||||
|
||||
## [0.2.0] - 2026-07-05
|
||||
|
||||
### Changed
|
||||
|
||||
@ -36,6 +36,16 @@ public extension PureYAML {
|
||||
Emitting.Dumper(options: options).dump(documents)
|
||||
}
|
||||
|
||||
/// Serializes a tag-preserving node tree into YAML, re-emitting anchors,
|
||||
/// aliases (as `*name` references, not expanded copies), explicit tags,
|
||||
/// and per-node presentation styles.
|
||||
static func dump(
|
||||
_ node: Tagged.Node,
|
||||
options: Emitting.Options = .default,
|
||||
) -> String {
|
||||
Tagged.Dumper(options: options).dump(node)
|
||||
}
|
||||
|
||||
/// Validates a parsed YAML value with the default validation rules.
|
||||
@discardableResult
|
||||
static func validate(
|
||||
|
||||
23
Sources/Tagged/Tagged.CollectionStyle.swift
Normal file
23
Sources/Tagged/Tagged.CollectionStyle.swift
Normal file
@ -0,0 +1,23 @@
|
||||
public extension PureYAML.Tagged {
|
||||
/// The presentation style a collection had in the source document, or the
|
||||
/// style a caller wants a collection to take on output. `nil` on a node
|
||||
/// means "no per-node preference": the emitter's document-wide policy
|
||||
/// decides.
|
||||
enum CollectionStyle: String, Equatable, Sendable, CustomStringConvertible {
|
||||
case block
|
||||
case flow
|
||||
|
||||
public var description: String {
|
||||
rawValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PureYAML.Tagged.CollectionStyle {
|
||||
init(_ style: PureYAML.Parsing.CollectionStyle) {
|
||||
self = switch style {
|
||||
case .block: .block
|
||||
case .flow: .flow
|
||||
}
|
||||
}
|
||||
}
|
||||
288
Sources/Tagged/Tagged.Dumper.Scalars.swift
Normal file
288
Sources/Tagged/Tagged.Dumper.Scalars.swift
Normal file
@ -0,0 +1,288 @@
|
||||
// MARK: - Scalar styles
|
||||
|
||||
extension PureYAML.Tagged.Dumper {
|
||||
/// Renders a scalar honoring its per-node style where the value can
|
||||
/// legally take it, falling back to double quoting otherwise. Values
|
||||
/// with a plain source style re-emit their source spelling when that
|
||||
/// spelling is syntactically safe.
|
||||
func renderScalarValue(
|
||||
_ scalar: PureYAML.Tagged.Scalar,
|
||||
flow: Bool,
|
||||
renderer: PureYAML.Emitting.Dumper,
|
||||
) -> String {
|
||||
if scalar.value == .null, scalar.style == .plain, isPlainNullForm(scalar.rawValue) {
|
||||
// A plain null re-emits its source spelling: `key:` with an empty
|
||||
// value, `~`, and the null word forms all reparse to null, so the
|
||||
// source form is preserved instead of normalizing to "null".
|
||||
return scalar.rawValue
|
||||
}
|
||||
guard case let .string(string) = scalar.value else {
|
||||
return renderNonString(scalar, flow: flow, renderer: renderer)
|
||||
}
|
||||
switch scalar.style {
|
||||
case .plain:
|
||||
return renderPlainString(string, tag: scalar.tag, flow: flow, renderer: renderer)
|
||||
case .singleQuoted:
|
||||
return renderSingleQuoted(string, renderer: renderer)
|
||||
case .doubleQuoted:
|
||||
return renderer.quote(string)
|
||||
case .literal, .folded:
|
||||
// Multi-line handling happens in renderLiteralBlock; reaching
|
||||
// here means the value cannot take a block form (single line, or
|
||||
// flow context, or block-unsafe content), so quote it.
|
||||
return renderer.quote(string)
|
||||
case nil:
|
||||
return flow ? renderer.renderFlowScalar(scalar.value) : renderer.renderScalar(scalar.value)
|
||||
}
|
||||
}
|
||||
|
||||
/// A plain non-string re-emits its source spelling when that spelling
|
||||
/// is syntactically safe: `NO` resolved to a bool and `1_000` to an int
|
||||
/// on parse, so the same resolver maps the same text back to the same
|
||||
/// value, and normalizing the spelling (`NO` to `false`) would lose the
|
||||
/// source form for nothing.
|
||||
func renderNonString(
|
||||
_ scalar: PureYAML.Tagged.Scalar,
|
||||
flow: Bool,
|
||||
renderer: PureYAML.Emitting.Dumper,
|
||||
) -> String {
|
||||
if scalar.style == .plain, isSyntacticallyPlainSafe(scalar.rawValue, flow: flow, renderer: renderer) {
|
||||
return scalar.rawValue
|
||||
}
|
||||
return renderer.renderScalar(scalar.value)
|
||||
}
|
||||
|
||||
/// A plain string must stay a string on reparse: without an explicit
|
||||
/// tag, content that resolves as a number, bool, or null must be
|
||||
/// quoted; with one, the tag governs resolution (`!!str 42` reparses as
|
||||
/// the string "42") and only syntax matters.
|
||||
func renderPlainString(
|
||||
_ string: String,
|
||||
tag: PureYAML.Tagged.Tag?,
|
||||
flow: Bool,
|
||||
renderer: PureYAML.Emitting.Dumper,
|
||||
) -> String {
|
||||
let stringSafe = tag != nil
|
||||
|| (!renderer.isReservedPlainScalar(string) && !renderer.isNumberLikePlainScalar(string))
|
||||
return stringSafe && isSyntacticallyPlainSafe(string, flow: flow, renderer: renderer)
|
||||
? string
|
||||
: renderer.quote(string)
|
||||
}
|
||||
|
||||
/// Single quoting escapes only the quote itself (by doubling); content
|
||||
/// with line breaks has no exact single-quoted form (quoted folding
|
||||
/// would rewrite the breaks), so it degrades to double quoting.
|
||||
func renderSingleQuoted(
|
||||
_ string: String,
|
||||
renderer: PureYAML.Emitting.Dumper,
|
||||
) -> String {
|
||||
guard !string.contains("\n"), !string.contains("\r") else {
|
||||
return renderer.quote(string)
|
||||
}
|
||||
var escaped = ""
|
||||
for character in string {
|
||||
escaped.append(character)
|
||||
if character == "'" {
|
||||
escaped.append("'")
|
||||
}
|
||||
}
|
||||
return "'" + escaped + "'"
|
||||
}
|
||||
|
||||
/// The plain source spellings that reparse to null: an empty value, `~`,
|
||||
/// and the three null word forms.
|
||||
func isPlainNullForm(_ rawValue: String) -> Bool {
|
||||
switch rawValue {
|
||||
case "", "~", "null", "Null", "NULL":
|
||||
true
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Syntactic plain safety, following the spec's actual rules rather than
|
||||
/// the emitter policy path's coarser ones: a leading `-`, `?`, or `:` is
|
||||
/// safe unless followed by a space (or alone), and `#` only opens a
|
||||
/// comment after whitespace, so a URL fragment stays plain. Resolution
|
||||
/// safety (a string that would re-resolve as a number or bool) is the
|
||||
/// caller's concern: it applies to untagged strings but is exactly wrong
|
||||
/// for non-strings re-emitting their source spelling.
|
||||
func isSyntacticallyPlainSafe(
|
||||
_ string: String,
|
||||
flow: Bool,
|
||||
renderer: PureYAML.Emitting.Dumper,
|
||||
) -> Bool {
|
||||
guard !string.isEmpty else {
|
||||
return false
|
||||
}
|
||||
guard string.first?.isWhitespace == false, string.last?.isWhitespace == false else {
|
||||
return false
|
||||
}
|
||||
guard !string.contains("\n"), !string.contains("\r"), !string.contains("\t") else {
|
||||
return false
|
||||
}
|
||||
guard let first = string.first else {
|
||||
return false
|
||||
}
|
||||
let second = string.dropFirst().first
|
||||
switch first {
|
||||
case "-", "?", ":":
|
||||
if second == nil || second == " " {
|
||||
return false
|
||||
}
|
||||
case ",", "[", "]", "{", "}", "#", "&", "*", "!", "|", ">", "'", "\"", "%", "@", "`":
|
||||
return false
|
||||
default:
|
||||
break
|
||||
}
|
||||
guard !renderer.containsColonSpace(string), !containsCommentAfterSpace(string) else {
|
||||
return false
|
||||
}
|
||||
return !flow || !renderer.containsFlowDelimiter(string)
|
||||
}
|
||||
|
||||
/// `#` starts a comment only when preceded by whitespace; a `#` embedded
|
||||
/// in a word (a URL fragment, an IP:port#tag form) is plain content.
|
||||
func containsCommentAfterSpace(_ string: String) -> Bool {
|
||||
var previousWasWhitespace = false
|
||||
for character in string {
|
||||
if character == "#", previousWasWhitespace {
|
||||
return true
|
||||
}
|
||||
previousWasWhitespace = character.isWhitespace
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Block scalars
|
||||
|
||||
extension PureYAML.Tagged.Dumper {
|
||||
/// Block-scalar rendering for `literal` and `folded` styles. A folded
|
||||
/// scalar re-emits as `>` when the value's shape allows an exact
|
||||
/// inversion of the folding rules, otherwise it degrades to a literal
|
||||
/// block (`|`), which still reproduces the value exactly; only the fold
|
||||
/// marks differ. Returns `nil` when the value cannot take any block
|
||||
/// form, in which case the scalar falls back to double quoting.
|
||||
func renderLiteralBlock(
|
||||
_ scalar: PureYAML.Tagged.Scalar,
|
||||
props: String,
|
||||
contentIndent: Int,
|
||||
) -> (header: String, content: [String])? {
|
||||
guard
|
||||
case let .string(string) = scalar.value,
|
||||
scalar.style == .literal || scalar.style == .folded
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
if scalar.style == .folded, let folded = renderFoldedBlock(string, props: props, contentIndent: contentIndent) {
|
||||
return folded
|
||||
}
|
||||
guard canRenderLiteralBlockContent(string) else {
|
||||
return nil
|
||||
}
|
||||
let trailingNewlines = countTrailingNewlines(string)
|
||||
let chomping: String
|
||||
let contentSource: String
|
||||
switch trailingNewlines {
|
||||
case 0:
|
||||
chomping = "-"
|
||||
contentSource = string
|
||||
case 1:
|
||||
chomping = ""
|
||||
contentSource = String(string.dropLast())
|
||||
default:
|
||||
// Two or more trailing newlines need keep chomping: clip (`|`)
|
||||
// would collapse them to one.
|
||||
chomping = "+"
|
||||
contentSource = String(string.dropLast())
|
||||
}
|
||||
// A whitespace-led first content line defeats indentation
|
||||
// auto-detection, so the header carries an explicit indentation
|
||||
// indicator and the leading whitespace becomes content.
|
||||
let needsIndicator = firstContentLine(of: contentSource)?.first?.isWhitespace == true
|
||||
if needsIndicator, !(1 ... 9).contains(options.indentWidth) {
|
||||
return nil
|
||||
}
|
||||
let indicator = "|" + (needsIndicator ? String(options.indentWidth) : "") + chomping
|
||||
let header = props.isEmpty ? indicator : props + " " + indicator
|
||||
let prefix = String(repeating: " ", count: contentIndent)
|
||||
let content = contentSource
|
||||
.split(separator: "\n", omittingEmptySubsequences: false)
|
||||
.map { $0.isEmpty ? "" : prefix + String($0) }
|
||||
return (header, content)
|
||||
}
|
||||
|
||||
/// Exact inversion of folding for the shapes that allow one: every
|
||||
/// logical line separated by single newlines, no empty logical lines, no
|
||||
/// leading or trailing whitespace per line. Emitting each logical line
|
||||
/// as its own paragraph (blank line between) folds back to exactly one
|
||||
/// newline per boundary.
|
||||
func renderFoldedBlock(
|
||||
_ string: String,
|
||||
props: String,
|
||||
contentIndent: Int,
|
||||
) -> (header: String, content: [String])? {
|
||||
let trailingNewlines = countTrailingNewlines(string)
|
||||
guard trailingNewlines <= 1 else {
|
||||
return nil
|
||||
}
|
||||
let contentSource = trailingNewlines == 1 ? String(string.dropLast()) : string
|
||||
guard !contentSource.isEmpty, !contentSource.contains("\r"), !contentSource.contains("\t") else {
|
||||
return nil
|
||||
}
|
||||
let lines = contentSource.split(separator: "\n", omittingEmptySubsequences: false)
|
||||
let safe = lines.allSatisfy { line in
|
||||
!line.isEmpty && line.first?.isWhitespace == false && line.last?.isWhitespace == false
|
||||
}
|
||||
guard safe else {
|
||||
return nil
|
||||
}
|
||||
let indicator = trailingNewlines == 1 ? ">" : ">-"
|
||||
let header = props.isEmpty ? indicator : props + " " + indicator
|
||||
let prefix = String(repeating: " ", count: contentIndent)
|
||||
var content: [String] = []
|
||||
for (index, line) in lines.enumerated() {
|
||||
if index > 0 {
|
||||
content.append("")
|
||||
}
|
||||
content.append(prefix + String(line))
|
||||
}
|
||||
return (header, content)
|
||||
}
|
||||
|
||||
func countTrailingNewlines(_ string: String) -> Int {
|
||||
var count = 0
|
||||
for character in string.reversed() {
|
||||
guard character == "\n" else {
|
||||
break
|
||||
}
|
||||
count += 1
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
/// A literal block can hold any string with at least one non-empty line
|
||||
/// and no carriage returns (single-line content emits as `|-`, a
|
||||
/// whitespace-led first line gets an explicit indentation indicator).
|
||||
/// Trailing spaces, tab-led or whitespace-only interior lines, leading
|
||||
/// blank lines, and more deeply indented interior lines are all
|
||||
/// preserved content: the block's own indent is stripped and the rest
|
||||
/// survives verbatim.
|
||||
func canRenderLiteralBlockContent(_ value: String) -> Bool {
|
||||
guard !value.contains("\r") else {
|
||||
return false
|
||||
}
|
||||
let contentSource = value.hasSuffix("\n") ? String(value.dropLast()) : value
|
||||
guard !contentSource.isEmpty else {
|
||||
return false
|
||||
}
|
||||
return firstContentLine(of: contentSource) != nil
|
||||
}
|
||||
|
||||
func firstContentLine(of contentSource: String) -> Substring? {
|
||||
contentSource
|
||||
.split(separator: "\n", omittingEmptySubsequences: false)
|
||||
.first { !$0.isEmpty }
|
||||
}
|
||||
}
|
||||
312
Sources/Tagged/Tagged.Dumper.swift
Normal file
312
Sources/Tagged/Tagged.Dumper.swift
Normal file
@ -0,0 +1,312 @@
|
||||
public extension PureYAML.Tagged {
|
||||
/// Serializes a tag-preserving node tree back to YAML, re-emitting
|
||||
/// anchors as `&name`, aliases as `*name` references (not expanded
|
||||
/// copies), explicit tags, and per-node presentation styles.
|
||||
///
|
||||
/// Per-node styles win over the document-wide policy in `options`; a
|
||||
/// node with a `nil` style falls back to that policy. A requested style
|
||||
/// the value cannot legally take (a plain-unsafe string asked to be
|
||||
/// plain, a single-line string asked to be literal) falls back to double
|
||||
/// quoting, which can represent every string.
|
||||
struct Dumper: Sendable {
|
||||
public var options: PureYAML.Emitting.Options
|
||||
|
||||
public init(options: PureYAML.Emitting.Options = .default) {
|
||||
self.options = options
|
||||
}
|
||||
|
||||
public func dump(_ node: PureYAML.Tagged.Node) -> String {
|
||||
let scalarRenderer = PureYAML.Emitting.Dumper(options: options)
|
||||
if isFlowRendered(node) {
|
||||
return renderFlow(node, renderer: scalarRenderer) + "\n"
|
||||
}
|
||||
return renderBlock(node, indent: 0, renderer: scalarRenderer)
|
||||
.joined(separator: "\n") + "\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PureYAML.Tagged.Dumper {
|
||||
typealias Node = PureYAML.Tagged.Node
|
||||
|
||||
/// The `&anchor !tag` prefix for a node, empty when the node has neither.
|
||||
/// Anchor precedes tag, matching libyaml's canonical property order.
|
||||
func properties(_ node: Node) -> String {
|
||||
var parts: [String] = []
|
||||
if let anchor = node.anchor, node.kind != .alias {
|
||||
parts.append("&" + anchor)
|
||||
}
|
||||
if let tag = node.tag {
|
||||
parts.append(renderTag(tag))
|
||||
}
|
||||
return parts.joined(separator: " ")
|
||||
}
|
||||
|
||||
/// Built-in tags render in `!!suffix` shorthand; everything else renders
|
||||
/// verbatim so unknown tag URIs and local tags survive unchanged.
|
||||
func renderTag(_ tag: PureYAML.Tagged.Tag) -> String {
|
||||
if tag.isBuiltIn {
|
||||
return "!!" + tag.rawValue.dropFirst("tag:yaml.org,2002:".count)
|
||||
}
|
||||
return "!<" + tag.rawValue + ">"
|
||||
}
|
||||
|
||||
func isFlowRendered(_ node: Node) -> Bool {
|
||||
switch node {
|
||||
case let .mapping(mapping):
|
||||
(mapping.style ?? collectionPolicy) == .flow
|
||||
case let .sequence(sequence):
|
||||
(sequence.style ?? collectionPolicy) == .flow
|
||||
case .scalar, .alias:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
var collectionPolicy: PureYAML.Tagged.CollectionStyle {
|
||||
switch options.collectionStyle {
|
||||
case .block: .block
|
||||
case .flow: .flow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Block rendering
|
||||
|
||||
extension PureYAML.Tagged.Dumper {
|
||||
func renderBlock(
|
||||
_ node: Node,
|
||||
indent: Int,
|
||||
renderer: PureYAML.Emitting.Dumper,
|
||||
) -> [String] {
|
||||
let prefix = String(repeating: " ", count: indent)
|
||||
switch node {
|
||||
case .alias:
|
||||
return [prefix + renderInline(node, renderer: renderer)]
|
||||
case .scalar:
|
||||
return renderBlockScalar(node, indent: indent, renderer: renderer)
|
||||
case let .sequence(sequence):
|
||||
return renderBlockSequence(sequence, node: node, indent: indent, renderer: renderer)
|
||||
case let .mapping(mapping):
|
||||
return renderBlockMapping(mapping, node: node, indent: indent, renderer: renderer)
|
||||
}
|
||||
}
|
||||
|
||||
func renderBlockScalar(
|
||||
_ node: Node,
|
||||
indent: Int,
|
||||
renderer: PureYAML.Emitting.Dumper,
|
||||
) -> [String] {
|
||||
let prefix = String(repeating: " ", count: indent)
|
||||
let props = properties(node)
|
||||
guard case let .scalar(scalar) = node else {
|
||||
return [prefix + renderInline(node, renderer: renderer)]
|
||||
}
|
||||
if let block = renderLiteralBlock(
|
||||
scalar,
|
||||
props: props,
|
||||
contentIndent: indent + options.indentWidth,
|
||||
) {
|
||||
return [prefix + block.header] + block.content
|
||||
}
|
||||
let rendered = renderScalarValue(scalar, flow: false, renderer: renderer)
|
||||
return [prefix + (props.isEmpty ? rendered : props + " " + rendered)]
|
||||
}
|
||||
|
||||
func renderBlockSequence(
|
||||
_ sequence: PureYAML.Tagged.Sequence,
|
||||
node: Node,
|
||||
indent: Int,
|
||||
renderer: PureYAML.Emitting.Dumper,
|
||||
) -> [String] {
|
||||
let prefix = String(repeating: " ", count: indent)
|
||||
let props = properties(node)
|
||||
var lines: [String] = []
|
||||
if !props.isEmpty {
|
||||
lines.append(prefix + props)
|
||||
}
|
||||
guard !sequence.values.isEmpty else {
|
||||
return props.isEmpty ? [prefix + "[]"] : [prefix + props + " []"]
|
||||
}
|
||||
for value in sequence.values {
|
||||
lines.append(contentsOf: renderBlockSequenceItem(value, indent: indent, renderer: renderer))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func renderBlockSequenceItem(
|
||||
_ value: Node,
|
||||
indent: Int,
|
||||
renderer: PureYAML.Emitting.Dumper,
|
||||
) -> [String] {
|
||||
let prefix = String(repeating: " ", count: indent)
|
||||
if let block = literalBlock(for: value, contentIndent: indent + options.indentWidth) {
|
||||
return [prefix + "- " + block.header] + block.content
|
||||
}
|
||||
if let inline = renderInlineIfSimple(value, renderer: renderer) {
|
||||
return [inline.isEmpty ? prefix + "-" : prefix + "- " + inline]
|
||||
}
|
||||
let childProps = properties(value)
|
||||
var lines: [String] = []
|
||||
if childProps.isEmpty {
|
||||
lines.append(prefix + "-")
|
||||
} else {
|
||||
lines.append(prefix + "- " + childProps)
|
||||
}
|
||||
lines.append(contentsOf: renderBlockBody(value, indent: indent + options.indentWidth, renderer: renderer))
|
||||
return lines
|
||||
}
|
||||
|
||||
func renderBlockMapping(
|
||||
_ mapping: PureYAML.Tagged.Mapping,
|
||||
node: Node,
|
||||
indent: Int,
|
||||
renderer: PureYAML.Emitting.Dumper,
|
||||
) -> [String] {
|
||||
let prefix = String(repeating: " ", count: indent)
|
||||
let props = properties(node)
|
||||
var lines: [String] = []
|
||||
if !props.isEmpty {
|
||||
lines.append(prefix + props)
|
||||
}
|
||||
guard !mapping.pairs.isEmpty else {
|
||||
return props.isEmpty ? [prefix + "{}"] : [prefix + props + " {}"]
|
||||
}
|
||||
for pair in mapping.pairs {
|
||||
lines.append(contentsOf: renderBlockPair(pair, indent: indent, renderer: renderer))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func renderBlockPair(
|
||||
_ pair: PureYAML.Tagged.Pair,
|
||||
indent: Int,
|
||||
renderer: PureYAML.Emitting.Dumper,
|
||||
) -> [String] {
|
||||
let prefix = String(repeating: " ", count: indent)
|
||||
var key = renderer.escapeKey(pair.key)
|
||||
if let keyTag = pair.keyTag {
|
||||
key = renderTag(keyTag) + " " + key
|
||||
}
|
||||
if let block = literalBlock(for: pair.value, contentIndent: indent + options.indentWidth) {
|
||||
return [prefix + key + ": " + block.header] + block.content
|
||||
}
|
||||
if let inline = renderInlineIfSimple(pair.value, renderer: renderer) {
|
||||
return [inline.isEmpty ? prefix + key + ":" : prefix + key + ": " + inline]
|
||||
}
|
||||
let childProps = properties(pair.value)
|
||||
var lines: [String] = []
|
||||
if childProps.isEmpty {
|
||||
lines.append(prefix + key + ":")
|
||||
} else {
|
||||
lines.append(prefix + key + ": " + childProps)
|
||||
}
|
||||
lines.append(contentsOf: renderBlockBody(pair.value, indent: indent + options.indentWidth, renderer: renderer))
|
||||
return lines
|
||||
}
|
||||
|
||||
/// The node's content lines with its properties already emitted by the
|
||||
/// caller (on the `-` or `key:` line), so collections render bare here.
|
||||
func renderBlockBody(
|
||||
_ node: Node,
|
||||
indent: Int,
|
||||
renderer: PureYAML.Emitting.Dumper,
|
||||
) -> [String] {
|
||||
switch node {
|
||||
case let .sequence(sequence):
|
||||
renderBlockSequence(
|
||||
sequence,
|
||||
node: .sequence(.init(values: sequence.values)),
|
||||
indent: indent,
|
||||
renderer: renderer,
|
||||
)
|
||||
case let .mapping(mapping):
|
||||
renderBlockMapping(
|
||||
mapping,
|
||||
node: .mapping(.init(pairs: mapping.pairs)),
|
||||
indent: indent,
|
||||
renderer: renderer,
|
||||
)
|
||||
case .scalar, .alias:
|
||||
renderBlock(node, indent: indent, renderer: renderer)
|
||||
}
|
||||
}
|
||||
|
||||
/// The block-scalar form for a scalar node whose style asks for one,
|
||||
/// with the node's properties folded into the header; `nil` for
|
||||
/// non-scalars and for scalars that render inline.
|
||||
func literalBlock(
|
||||
for node: Node,
|
||||
contentIndent: Int,
|
||||
) -> (header: String, content: [String])? {
|
||||
guard case let .scalar(scalar) = node else {
|
||||
return nil
|
||||
}
|
||||
return renderLiteralBlock(scalar, props: properties(node), contentIndent: contentIndent)
|
||||
}
|
||||
|
||||
/// One-line rendering when the node fits on the parent's line: aliases,
|
||||
/// scalars without a literal block, flow-styled collections, and empty
|
||||
/// collections. Returns `nil` when the node needs its own block lines.
|
||||
func renderInlineIfSimple(
|
||||
_ node: Node,
|
||||
renderer: PureYAML.Emitting.Dumper,
|
||||
) -> String? {
|
||||
let props = properties(node)
|
||||
switch node {
|
||||
case .alias:
|
||||
return renderInline(node, renderer: renderer)
|
||||
case let .scalar(scalar):
|
||||
if renderLiteralBlock(scalar, props: props, contentIndent: 0) != nil {
|
||||
return nil
|
||||
}
|
||||
let rendered = renderScalarValue(scalar, flow: false, renderer: renderer)
|
||||
return props.isEmpty ? rendered : props + " " + rendered
|
||||
case let .sequence(sequence):
|
||||
if isFlowRendered(node) || sequence.values.isEmpty {
|
||||
return renderInline(node, renderer: renderer)
|
||||
}
|
||||
return nil
|
||||
case let .mapping(mapping):
|
||||
if isFlowRendered(node) || mapping.pairs.isEmpty {
|
||||
return renderInline(node, renderer: renderer)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Flow and inline rendering
|
||||
|
||||
extension PureYAML.Tagged.Dumper {
|
||||
func renderInline(
|
||||
_ node: Node,
|
||||
renderer: PureYAML.Emitting.Dumper,
|
||||
) -> String {
|
||||
let props = properties(node)
|
||||
let body: String
|
||||
switch node {
|
||||
case let .alias(anchor, _):
|
||||
return "*" + anchor
|
||||
case let .scalar(scalar):
|
||||
body = renderScalarValue(scalar, flow: true, renderer: renderer)
|
||||
case let .sequence(sequence):
|
||||
body = "[" + sequence.values
|
||||
.map { renderFlow($0, renderer: renderer) }
|
||||
.joined(separator: ", ") + "]"
|
||||
case let .mapping(mapping):
|
||||
body = "{" + mapping.pairs
|
||||
.map { pair in
|
||||
renderer.escapeKey(pair.key) + ": " + renderFlow(pair.value, renderer: renderer)
|
||||
}
|
||||
.joined(separator: ", ") + "}"
|
||||
}
|
||||
return props.isEmpty ? body : props + " " + body
|
||||
}
|
||||
|
||||
func renderFlow(
|
||||
_ node: Node,
|
||||
renderer: PureYAML.Emitting.Dumper,
|
||||
) -> String {
|
||||
renderInline(node, renderer: renderer)
|
||||
}
|
||||
}
|
||||
@ -101,9 +101,9 @@ extension PureYAML.Tagged.EventComposer {
|
||||
column: mark.column,
|
||||
)
|
||||
}
|
||||
return value
|
||||
case let .mappingStart(anchor, tag, _, _):
|
||||
let value = try composeMapping(tag: tag)
|
||||
return .alias(anchor: anchor, node: value)
|
||||
case let .mappingStart(anchor, tag, style, _):
|
||||
let value = try composeMapping(tag: tag, anchor: anchor, style: style)
|
||||
store(value, anchor: anchor)
|
||||
return value
|
||||
case let .scalar(value, anchor, tag, style, mark, _):
|
||||
@ -117,12 +117,14 @@ extension PureYAML.Tagged.EventComposer {
|
||||
mark: mark,
|
||||
),
|
||||
tag: .normalized(tag),
|
||||
anchor: anchor,
|
||||
style: .init(style),
|
||||
)
|
||||
let node = PureYAML.Tagged.Node.scalar(scalar)
|
||||
store(node, anchor: anchor)
|
||||
return node
|
||||
case let .sequenceStart(anchor, tag, _, _):
|
||||
let value = try composeSequence(tag: tag)
|
||||
case let .sequenceStart(anchor, tag, style, _):
|
||||
let value = try composeSequence(tag: tag, anchor: anchor, style: style)
|
||||
store(value, anchor: anchor)
|
||||
return value
|
||||
default:
|
||||
@ -130,7 +132,11 @@ extension PureYAML.Tagged.EventComposer {
|
||||
}
|
||||
}
|
||||
|
||||
mutating func composeSequence(tag: String?) throws -> PureYAML.Tagged.Node {
|
||||
mutating func composeSequence(
|
||||
tag: String?,
|
||||
anchor: String?,
|
||||
style: PureYAML.Parsing.CollectionStyle,
|
||||
) throws -> PureYAML.Tagged.Node {
|
||||
_ = try expect("sequence start") { event in
|
||||
if case .sequenceStart = event {
|
||||
return true
|
||||
@ -142,10 +148,19 @@ extension PureYAML.Tagged.EventComposer {
|
||||
try values.append(composeNode())
|
||||
}
|
||||
_ = try expect("sequence end") { $0.isSequenceEnd }
|
||||
return .sequence(.init(values: values, tag: .normalized(tag)))
|
||||
return .sequence(.init(
|
||||
values: values,
|
||||
tag: .normalized(tag),
|
||||
anchor: anchor,
|
||||
style: .init(style),
|
||||
))
|
||||
}
|
||||
|
||||
mutating func composeMapping(tag: String?) throws -> PureYAML.Tagged.Node {
|
||||
mutating func composeMapping(
|
||||
tag: String?,
|
||||
anchor: String?,
|
||||
style: PureYAML.Parsing.CollectionStyle,
|
||||
) throws -> PureYAML.Tagged.Node {
|
||||
_ = try expect("mapping start") { event in
|
||||
if case .mappingStart = event {
|
||||
return true
|
||||
@ -159,7 +174,12 @@ extension PureYAML.Tagged.EventComposer {
|
||||
pairs.append(.init(key: key.value, keyTag: key.tag, value: value))
|
||||
}
|
||||
_ = try expect("mapping end") { $0.isMappingEnd }
|
||||
return .mapping(.init(pairs: pairs, tag: .normalized(tag)))
|
||||
return .mapping(.init(
|
||||
pairs: pairs,
|
||||
tag: .normalized(tag),
|
||||
anchor: anchor,
|
||||
style: .init(style),
|
||||
))
|
||||
}
|
||||
|
||||
mutating func composeMappingKey() throws -> ComposedMappingKey {
|
||||
@ -175,6 +195,8 @@ extension PureYAML.Tagged.EventComposer {
|
||||
rawValue: value,
|
||||
value: scalarParser.composeScalarValue(value, tag: tag, style: style, mark: mark),
|
||||
tag: .normalized(tag),
|
||||
anchor: anchor,
|
||||
style: .init(style),
|
||||
)
|
||||
store(.scalar(scalar), anchor: anchor)
|
||||
return ComposedMappingKey(value: value, tag: .normalized(tag))
|
||||
|
||||
@ -2,13 +2,23 @@ public extension PureYAML.Tagged {
|
||||
struct Mapping: Equatable, Sendable {
|
||||
public var pairs: [Pair]
|
||||
public var tag: Tag?
|
||||
/// The `&name` anchor on this mapping in the source document, or the
|
||||
/// anchor to attach on output. `nil` when the node is unanchored.
|
||||
public var anchor: String?
|
||||
/// The mapping's presentation style. Populated from the source on
|
||||
/// parse; `nil` on hand-built nodes that have no style preference.
|
||||
public var style: CollectionStyle?
|
||||
|
||||
public init(
|
||||
pairs: [Pair],
|
||||
tag: Tag? = nil,
|
||||
anchor: String? = nil,
|
||||
style: CollectionStyle? = nil,
|
||||
) {
|
||||
self.pairs = pairs
|
||||
self.tag = tag
|
||||
self.anchor = anchor
|
||||
self.style = style
|
||||
}
|
||||
|
||||
public subscript(_ key: String) -> Node? {
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
public extension PureYAML.Tagged.Node {
|
||||
/// Converts a tagged node into a model value while explicitly erasing tags.
|
||||
/// An alias converts to its resolved node's value, matching `parse(_:)`'s
|
||||
/// alias-expanding semantics.
|
||||
var modelValueErasingTags: PureYAML.Model.Value {
|
||||
switch self {
|
||||
case let .mapping(mapping):
|
||||
@ -10,6 +12,8 @@ public extension PureYAML.Tagged.Node {
|
||||
scalar.value
|
||||
case let .sequence(sequence):
|
||||
.sequence(sequence.values.map(\.modelValueErasingTags))
|
||||
case let .alias(_, node):
|
||||
node.modelValueErasingTags
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,18 @@
|
||||
public extension PureYAML.Tagged {
|
||||
/// YAML node tree that preserves explicit source tags.
|
||||
enum Node: Equatable, Sendable {
|
||||
/// YAML node tree that preserves explicit source tags, anchors, aliases,
|
||||
/// and per-node presentation styles.
|
||||
///
|
||||
/// An `alias` case carries both the `*name` anchor it referenced and the
|
||||
/// node it resolved to, so value-oriented consumers can read through the
|
||||
/// alias without a separate resolution step while emitters can re-emit
|
||||
/// the reference instead of expanding it. Resolution cannot cycle: the
|
||||
/// composer registers an anchor only after its node is fully composed, so
|
||||
/// a self-referential alias is an `undefinedAlias` parse error.
|
||||
indirect enum Node: Equatable, Sendable {
|
||||
case scalar(Scalar)
|
||||
case sequence(Sequence)
|
||||
case mapping(Mapping)
|
||||
case alias(anchor: String, node: Node)
|
||||
|
||||
public var tag: Tag? {
|
||||
switch self {
|
||||
@ -13,6 +22,8 @@ public extension PureYAML.Tagged {
|
||||
scalar.tag
|
||||
case let .sequence(sequence):
|
||||
sequence.tag
|
||||
case .alias:
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,6 +35,33 @@ public extension PureYAML.Tagged {
|
||||
.scalar
|
||||
case .sequence:
|
||||
.sequence
|
||||
case .alias:
|
||||
.alias
|
||||
}
|
||||
}
|
||||
|
||||
/// The `&name` anchor attached to this node, `nil` when unanchored.
|
||||
/// An alias reports the anchor it references.
|
||||
public var anchor: String? {
|
||||
switch self {
|
||||
case let .mapping(mapping):
|
||||
mapping.anchor
|
||||
case let .scalar(scalar):
|
||||
scalar.anchor
|
||||
case let .sequence(sequence):
|
||||
sequence.anchor
|
||||
case let .alias(anchor, _):
|
||||
anchor
|
||||
}
|
||||
}
|
||||
|
||||
/// The node an alias resolved to; any other node returns itself.
|
||||
public var resolved: Node {
|
||||
switch self {
|
||||
case .mapping, .scalar, .sequence:
|
||||
self
|
||||
case let .alias(_, node):
|
||||
node
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ public extension PureYAML.Tagged {
|
||||
case scalar
|
||||
case sequence
|
||||
case mapping
|
||||
case alias
|
||||
|
||||
public var description: String {
|
||||
rawValue
|
||||
|
||||
@ -3,15 +3,25 @@ public extension PureYAML.Tagged {
|
||||
public var rawValue: String
|
||||
public var value: PureYAML.Model.Value
|
||||
public var tag: Tag?
|
||||
/// The `&name` anchor on this scalar in the source document, or the
|
||||
/// anchor to attach on output. `nil` when the node is unanchored.
|
||||
public var anchor: String?
|
||||
/// The scalar's presentation style. Populated from the source on
|
||||
/// parse; `nil` on hand-built nodes that have no style preference.
|
||||
public var style: ScalarStyle?
|
||||
|
||||
public init(
|
||||
rawValue: String,
|
||||
value: PureYAML.Model.Value,
|
||||
tag: Tag? = nil,
|
||||
anchor: String? = nil,
|
||||
style: ScalarStyle? = nil,
|
||||
) {
|
||||
self.rawValue = rawValue
|
||||
self.value = value
|
||||
self.tag = tag
|
||||
self.anchor = anchor
|
||||
self.style = style
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
28
Sources/Tagged/Tagged.ScalarStyle.swift
Normal file
28
Sources/Tagged/Tagged.ScalarStyle.swift
Normal file
@ -0,0 +1,28 @@
|
||||
public extension PureYAML.Tagged {
|
||||
/// The presentation style a scalar had in the source document, or the
|
||||
/// style a caller wants a scalar to take on output. `nil` on a node means
|
||||
/// "no per-node preference": the emitter's document-wide policy decides.
|
||||
enum ScalarStyle: String, Equatable, Sendable, CustomStringConvertible {
|
||||
case plain
|
||||
case singleQuoted
|
||||
case doubleQuoted
|
||||
case literal
|
||||
case folded
|
||||
|
||||
public var description: String {
|
||||
rawValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PureYAML.Tagged.ScalarStyle {
|
||||
init(_ style: PureYAML.Parsing.ScalarStyle) {
|
||||
self = switch style {
|
||||
case .plain: .plain
|
||||
case .singleQuoted: .singleQuoted
|
||||
case .doubleQuoted: .doubleQuoted
|
||||
case .literal: .literal
|
||||
case .folded: .folded
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,13 +2,23 @@ public extension PureYAML.Tagged {
|
||||
struct Sequence: Equatable, Sendable {
|
||||
public var values: [Node]
|
||||
public var tag: Tag?
|
||||
/// The `&name` anchor on this sequence in the source document, or the
|
||||
/// anchor to attach on output. `nil` when the node is unanchored.
|
||||
public var anchor: String?
|
||||
/// The sequence's presentation style. Populated from the source on
|
||||
/// parse; `nil` on hand-built nodes that have no style preference.
|
||||
public var style: CollectionStyle?
|
||||
|
||||
public init(
|
||||
values: [Node],
|
||||
tag: Tag? = nil,
|
||||
anchor: String? = nil,
|
||||
style: CollectionStyle? = nil,
|
||||
) {
|
||||
self.values = values
|
||||
self.tag = tag
|
||||
self.anchor = anchor
|
||||
self.style = style
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -115,6 +115,11 @@ extension PureYAML.Tagged.Validator {
|
||||
}
|
||||
case .scalar:
|
||||
break
|
||||
case .alias:
|
||||
// The aliased subtree was already walked at its anchor's
|
||||
// definition site; recursing here would report every issue in
|
||||
// that subtree once more per reference.
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
159
Tests/TaggedNodeCorpusRoundTripTests.swift
Normal file
159
Tests/TaggedNodeCorpusRoundTripTests.swift
Normal file
@ -0,0 +1,159 @@
|
||||
import Foundation
|
||||
@testable import PureYAML
|
||||
import Testing
|
||||
|
||||
/// The tagged round-trip law applied to the whole real-world corpus, not just
|
||||
/// handwritten cases: for every fixture the tagged parser accepts,
|
||||
/// `parseTagged(dump(parseTagged(d)))` must equal `parseTagged(d)`: anchors,
|
||||
/// aliases, tags, styles, values, and key order all survive. Fixtures the
|
||||
/// tagged parser rejects (multi-document streams, alias mapping keys) are
|
||||
/// counted as rejected, never silently skipped: the test asserts the corpus
|
||||
/// split so a parser regression that starts rejecting more files fails here.
|
||||
@Suite("Tagged corpus round-trip")
|
||||
struct TaggedNodeCorpusRoundTripTests {
|
||||
@Test("Every tagged-parseable real-world fixture round-trips exactly")
|
||||
func test_realWorldCorpusRoundTrips() throws {
|
||||
let urls = try #require(Bundle.module.urls(
|
||||
forResourcesWithExtension: "yaml",
|
||||
subdirectory: "Fixtures/real-yaml",
|
||||
))
|
||||
var accepted = 0
|
||||
var rejected = 0
|
||||
for url in urls.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) {
|
||||
let source = try String(contentsOf: url, encoding: .utf8)
|
||||
let parsed: PureYAML.Tagged.Node
|
||||
do {
|
||||
parsed = try PureYAML.parseTagged(source)
|
||||
} catch {
|
||||
rejected += 1
|
||||
continue
|
||||
}
|
||||
accepted += 1
|
||||
let dumped = PureYAML.dump(parsed)
|
||||
let reparsed: PureYAML.Tagged.Node
|
||||
do {
|
||||
reparsed = try PureYAML.parseTagged(dumped)
|
||||
} catch {
|
||||
Issue.record("\(url.lastPathComponent): dump produced unparseable YAML: \(error)")
|
||||
continue
|
||||
}
|
||||
if let detail = firstDivergence(parsed, reparsed, path: "$") {
|
||||
Issue.record("\(url.lastPathComponent): \(detail)")
|
||||
}
|
||||
}
|
||||
#expect(accepted > 0)
|
||||
#expect(accepted + rejected == urls.count)
|
||||
}
|
||||
|
||||
/// Locates the first difference between two trees under the round-trip
|
||||
/// law (exact equality, except the dumper's documented style
|
||||
/// degradations), naming the diverging node. Returns `nil` when the
|
||||
/// trees are equivalent under the law.
|
||||
func firstDivergence(
|
||||
_ parsed: PureYAML.Tagged.Node,
|
||||
_ reparsed: PureYAML.Tagged.Node,
|
||||
path: String,
|
||||
) -> String? {
|
||||
if parsed == reparsed {
|
||||
return nil
|
||||
}
|
||||
switch (parsed, reparsed) {
|
||||
case let (.scalar(lhs), .scalar(rhs)):
|
||||
return scalarDivergence(lhs, rhs, path: path)
|
||||
case let (.sequence(lhs), .sequence(rhs)):
|
||||
return sequenceDivergence(lhs, rhs, path: path)
|
||||
case let (.mapping(lhs), .mapping(rhs)):
|
||||
return mappingDivergence(lhs, rhs, path: path)
|
||||
case let (.alias(lhs, lhsNode), .alias(rhs, rhsNode)):
|
||||
if lhs != rhs {
|
||||
return "\(path): alias anchor \(lhs) != \(rhs)"
|
||||
}
|
||||
return firstDivergence(lhsNode, rhsNode, path: "\(path)*")
|
||||
default:
|
||||
return "\(path): kind \(parsed.kind) != \(reparsed.kind)"
|
||||
}
|
||||
}
|
||||
|
||||
func scalarDivergence(
|
||||
_ lhs: PureYAML.Tagged.Scalar,
|
||||
_ rhs: PureYAML.Tagged.Scalar,
|
||||
path: String,
|
||||
) -> String? {
|
||||
if lhs.value != rhs.value {
|
||||
return "\(path): value \(lhs.value) != \(rhs.value)"
|
||||
}
|
||||
if lhs.tag != rhs.tag {
|
||||
return "\(path): tag \(String(describing: lhs.tag)) != \(String(describing: rhs.tag))"
|
||||
}
|
||||
if lhs.anchor != rhs.anchor {
|
||||
return "\(path): anchor \(String(describing: lhs.anchor)) != \(String(describing: rhs.anchor))"
|
||||
}
|
||||
if lhs.style != rhs.style, !isAllowedStyleDegradation(from: lhs, to: rhs) {
|
||||
return "\(path): style \(String(describing: lhs.style)) != \(String(describing: rhs.style)) raw=\(lhs.rawValue.debugDescription)"
|
||||
}
|
||||
if lhs.rawValue != rhs.rawValue {
|
||||
return "\(path): rawValue \(lhs.rawValue.debugDescription) != \(rhs.rawValue.debugDescription)"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sequenceDivergence(
|
||||
_ lhs: PureYAML.Tagged.Sequence,
|
||||
_ rhs: PureYAML.Tagged.Sequence,
|
||||
path: String,
|
||||
) -> String? {
|
||||
if lhs.values.count != rhs.values.count {
|
||||
return "\(path): sequence count \(lhs.values.count) != \(rhs.values.count)"
|
||||
}
|
||||
if lhs.tag != rhs.tag || lhs.anchor != rhs.anchor || lhs.style != rhs.style {
|
||||
return "\(path): sequence properties differ (style \(String(describing: lhs.style)) != \(String(describing: rhs.style)))"
|
||||
}
|
||||
for (index, pair) in zip(lhs.values, rhs.values).enumerated() {
|
||||
if let diff = firstDivergence(pair.0, pair.1, path: "\(path)[\(index)]") {
|
||||
return diff
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mappingDivergence(
|
||||
_ lhs: PureYAML.Tagged.Mapping,
|
||||
_ rhs: PureYAML.Tagged.Mapping,
|
||||
path: String,
|
||||
) -> String? {
|
||||
if lhs.pairs.count != rhs.pairs.count {
|
||||
return "\(path): mapping count \(lhs.pairs.count) != \(rhs.pairs.count)"
|
||||
}
|
||||
if lhs.tag != rhs.tag || lhs.anchor != rhs.anchor || lhs.style != rhs.style {
|
||||
return "\(path): mapping properties differ (style \(String(describing: lhs.style)) != \(String(describing: rhs.style)))"
|
||||
}
|
||||
for (left, right) in zip(lhs.pairs, rhs.pairs) {
|
||||
if left.key != right.key {
|
||||
return "\(path): key \(left.key.debugDescription) != \(right.key.debugDescription)"
|
||||
}
|
||||
if left.keyTag != right.keyTag {
|
||||
return "\(path).\(left.key): keyTag differs"
|
||||
}
|
||||
if let diff = firstDivergence(left.value, right.value, path: "\(path).\(left.key)") {
|
||||
return diff
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// The dumper's one documented style degradation: single-quoted content
|
||||
/// containing line breaks has no exact single-quoted re-emission (quoted
|
||||
/// folding would rewrite the breaks), so it re-emits double-quoted. The
|
||||
/// value still round-trips exactly. Every other style must survive.
|
||||
func isAllowedStyleDegradation(
|
||||
from parsed: PureYAML.Tagged.Scalar,
|
||||
to reparsed: PureYAML.Tagged.Scalar,
|
||||
) -> Bool {
|
||||
guard case let .string(string) = parsed.value else {
|
||||
return false
|
||||
}
|
||||
return parsed.style == .singleQuoted
|
||||
&& reparsed.style == .doubleQuoted
|
||||
&& (string.contains("\n") || string.contains("\r"))
|
||||
}
|
||||
}
|
||||
207
Tests/TaggedNodeRoundTripTests.swift
Normal file
207
Tests/TaggedNodeRoundTripTests.swift
Normal file
@ -0,0 +1,207 @@
|
||||
@testable import PureYAML
|
||||
import Testing
|
||||
|
||||
/// The round-trip law for the tag-preserving tree: for a document with
|
||||
/// anchors, aliases, tags, and styles, `parseTagged(dump(parseTagged(d)))`
|
||||
/// equals `parseTagged(d)`: the alias topology survives as references, not
|
||||
/// expanded copies.
|
||||
@Suite("Tagged node anchors, aliases, and styles round-trip")
|
||||
struct TaggedNodeRoundTripTests {
|
||||
@Test("An anchored scalar and its alias parse into anchor and alias nodes")
|
||||
func test_aliasIsPreservedNotExpanded() throws {
|
||||
let node = try PureYAML.parseTagged("first: &shared value\nsecond: *shared\n")
|
||||
guard case let .mapping(mapping) = node else {
|
||||
Issue.record("expected mapping, got \(node.kind)")
|
||||
return
|
||||
}
|
||||
guard case let .scalar(first)? = mapping["first"] else {
|
||||
Issue.record("expected scalar for first")
|
||||
return
|
||||
}
|
||||
#expect(first.anchor == "shared")
|
||||
#expect(first.style == .plain)
|
||||
guard case let .alias(anchor, resolved)? = mapping["second"] else {
|
||||
Issue.record("expected alias for second, got \(String(describing: mapping["second"]?.kind))")
|
||||
return
|
||||
}
|
||||
#expect(anchor == "shared")
|
||||
#expect(resolved == .scalar(first))
|
||||
}
|
||||
|
||||
@Test("An anchored scalar document round-trips through dump and reparse")
|
||||
func test_scalarAliasRoundTrip() throws {
|
||||
let source = "first: &shared value\nsecond: *shared\n"
|
||||
let parsed = try PureYAML.parseTagged(source)
|
||||
let dumped = PureYAML.dump(parsed)
|
||||
#expect(dumped.contains("&shared"))
|
||||
#expect(dumped.contains("*shared"))
|
||||
let reparsed = try PureYAML.parseTagged(dumped)
|
||||
#expect(reparsed == parsed)
|
||||
}
|
||||
|
||||
@Test("An anchored mapping referenced by alias round-trips as a reference")
|
||||
func test_mappingAliasRoundTrip() throws {
|
||||
let source = """
|
||||
defaults: &base
|
||||
retries: 3
|
||||
timeout: 30
|
||||
service: *base
|
||||
"""
|
||||
let parsed = try PureYAML.parseTagged(source)
|
||||
let dumped = PureYAML.dump(parsed)
|
||||
#expect(dumped.contains("&base"))
|
||||
#expect(dumped.contains("*base"))
|
||||
let reparsed = try PureYAML.parseTagged(dumped)
|
||||
#expect(reparsed == parsed)
|
||||
}
|
||||
|
||||
@Test("An anchored sequence referenced twice keeps both alias sites")
|
||||
func test_sequenceAliasTwoReferences() throws {
|
||||
let source = "list: &items [1, 2]\na: *items\nb: *items\n"
|
||||
let parsed = try PureYAML.parseTagged(source)
|
||||
let dumped = PureYAML.dump(parsed)
|
||||
let reparsed = try PureYAML.parseTagged(dumped)
|
||||
#expect(reparsed == parsed)
|
||||
guard case let .mapping(mapping) = reparsed else {
|
||||
Issue.record("expected mapping")
|
||||
return
|
||||
}
|
||||
#expect(mapping["a"]?.kind == .alias)
|
||||
#expect(mapping["b"]?.kind == .alias)
|
||||
}
|
||||
|
||||
@Test("Explicit tags on scalars and collections round-trip")
|
||||
func test_explicitTagsRoundTrip() throws {
|
||||
let source = "count: !!str 42\nthing: !Custom widget\n"
|
||||
let parsed = try PureYAML.parseTagged(source)
|
||||
let dumped = PureYAML.dump(parsed)
|
||||
let reparsed = try PureYAML.parseTagged(dumped)
|
||||
#expect(reparsed == parsed)
|
||||
guard case let .mapping(mapping) = reparsed else {
|
||||
Issue.record("expected mapping")
|
||||
return
|
||||
}
|
||||
#expect(mapping["count"]?.tag == .string)
|
||||
#expect(mapping["thing"]?.tag == .init("!Custom"))
|
||||
}
|
||||
|
||||
@Test("Flow style on a nested collection survives a block document")
|
||||
func test_perNodeFlowStyleSurvives() throws {
|
||||
let source = "outer:\n inner: [1, 2, 3]\n"
|
||||
let parsed = try PureYAML.parseTagged(source)
|
||||
let dumped = PureYAML.dump(parsed)
|
||||
#expect(dumped.contains("[1, 2, 3]"))
|
||||
let reparsed = try PureYAML.parseTagged(dumped)
|
||||
#expect(reparsed == parsed)
|
||||
}
|
||||
|
||||
@Test("Literal block scalar style survives dump and reparse")
|
||||
func test_literalBlockStyleSurvives() throws {
|
||||
let source = "log: |\n line one\n line two\n"
|
||||
let parsed = try PureYAML.parseTagged(source)
|
||||
let dumped = PureYAML.dump(parsed)
|
||||
#expect(dumped.contains("|"))
|
||||
let reparsed = try PureYAML.parseTagged(dumped)
|
||||
guard
|
||||
case let .mapping(expected) = parsed,
|
||||
case let .scalar(expectedScalar)? = expected["log"],
|
||||
case let .mapping(actual) = reparsed,
|
||||
case let .scalar(actualScalar)? = actual["log"]
|
||||
else {
|
||||
Issue.record("expected scalar log values")
|
||||
return
|
||||
}
|
||||
#expect(actualScalar.value == expectedScalar.value)
|
||||
#expect(actualScalar.style == .literal)
|
||||
}
|
||||
|
||||
@Test("Single and double quoted scalar styles are honored on dump")
|
||||
func test_quotedStylesHonored() {
|
||||
let single = PureYAML.Tagged.Node.scalar(.init(
|
||||
rawValue: "it's",
|
||||
value: .string("it's"),
|
||||
style: .singleQuoted,
|
||||
))
|
||||
#expect(PureYAML.dump(single) == "'it''s'\n")
|
||||
let double = PureYAML.Tagged.Node.scalar(.init(
|
||||
rawValue: "plain",
|
||||
value: .string("plain"),
|
||||
style: .doubleQuoted,
|
||||
))
|
||||
#expect(PureYAML.dump(double) == "\"plain\"\n")
|
||||
}
|
||||
|
||||
@Test("A hand-built anchored tree dumps anchors and aliases")
|
||||
func test_handBuiltTreeDumps() throws {
|
||||
let shared = PureYAML.Tagged.Node.scalar(.init(
|
||||
rawValue: "reused",
|
||||
value: .string("reused"),
|
||||
anchor: "common",
|
||||
style: .plain,
|
||||
))
|
||||
let tree = PureYAML.Tagged.Node.mapping(.init(
|
||||
pairs: [
|
||||
.init(key: "original", value: shared),
|
||||
.init(key: "reference", value: .alias(anchor: "common", node: shared)),
|
||||
],
|
||||
style: .block,
|
||||
))
|
||||
let dumped = PureYAML.dump(tree)
|
||||
#expect(dumped == "original: &common reused\nreference: *common\n")
|
||||
let reparsed = try PureYAML.parseTagged(dumped)
|
||||
#expect(reparsed == tree)
|
||||
}
|
||||
|
||||
@Test("An anchored empty collection round-trips")
|
||||
func test_anchoredEmptyCollections() throws {
|
||||
let source = "empty_map: &em {}\nempty_seq: &es []\nref: *em\n"
|
||||
let parsed = try PureYAML.parseTagged(source)
|
||||
let dumped = PureYAML.dump(parsed)
|
||||
let reparsed = try PureYAML.parseTagged(dumped)
|
||||
#expect(reparsed == parsed)
|
||||
}
|
||||
|
||||
@Test("modelValueErasingTags reads through aliases to expanded values")
|
||||
func test_modelValueReadsThroughAliases() throws {
|
||||
let source = "first: &shared value\nsecond: *shared\n"
|
||||
let tagged = try PureYAML.parseTagged(source)
|
||||
let expanded = try PureYAML.parse(source)
|
||||
#expect(tagged.modelValueErasingTags == expanded)
|
||||
}
|
||||
|
||||
@Test("Alias topology survives, not just values: reference stays a reference")
|
||||
func test_aliasTopologyNotJustValues() throws {
|
||||
let source = "a: &x deep\nb: *x\nc: deep\n"
|
||||
let parsed = try PureYAML.parseTagged(source)
|
||||
let dumped = PureYAML.dump(parsed)
|
||||
let reparsed = try PureYAML.parseTagged(dumped)
|
||||
guard case let .mapping(mapping) = reparsed else {
|
||||
Issue.record("expected mapping")
|
||||
return
|
||||
}
|
||||
#expect(mapping["b"]?.kind == .alias)
|
||||
#expect(mapping["c"]?.kind == .scalar)
|
||||
}
|
||||
|
||||
@Test("Aliases nested inside anchored collections round-trip")
|
||||
func test_nestedAliasInsideAnchoredCollection() throws {
|
||||
let source = """
|
||||
base: &b
|
||||
name: &n shared-name
|
||||
alias_inside: *n
|
||||
copy: *b
|
||||
"""
|
||||
let parsed = try PureYAML.parseTagged(source)
|
||||
let dumped = PureYAML.dump(parsed)
|
||||
let reparsed = try PureYAML.parseTagged(dumped)
|
||||
#expect(reparsed == parsed)
|
||||
}
|
||||
|
||||
@Test("The validator does not duplicate issues through alias references")
|
||||
func test_validatorSkipsAliasSubtrees() throws {
|
||||
let source = "a: &dup\n k: 1\n k: 2\nb: *dup\nc: *dup\n"
|
||||
let node = try PureYAML.parseTagged(source)
|
||||
let result = PureYAML.Tagged.Validator().collect(node)
|
||||
#expect(result.issues.count == 1)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user