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:
Mihaela Mihaljevic 2026-07-12 13:20:12 +02:00
parent ec3642bacc
commit 2a84d6616f
16 changed files with 1176 additions and 11 deletions

View File

@ -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

View File

@ -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(

View 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
}
}
}

View 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 }
}
}

View 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)
}
}

View File

@ -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))

View File

@ -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? {

View File

@ -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
}
}
}

View File

@ -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
}
}
}

View File

@ -3,6 +3,7 @@ public extension PureYAML.Tagged {
case scalar
case sequence
case mapping
case alias
public var description: String {
rawValue

View File

@ -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
}
}
}

View 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
}
}
}

View File

@ -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
}
}
}

View File

@ -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
}
}

View 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"))
}
}

View 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)
}
}