diff --git a/CHANGELOG.md b/CHANGELOG.md index 8377cae..7ca1fcb 100644 --- a/CHANGELOG.md +++ b/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, + `!` 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 . +### 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 diff --git a/Sources/API.swift b/Sources/API.swift index a463432..55a8cf2 100644 --- a/Sources/API.swift +++ b/Sources/API.swift @@ -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( diff --git a/Sources/Tagged/Tagged.CollectionStyle.swift b/Sources/Tagged/Tagged.CollectionStyle.swift new file mode 100644 index 0000000..5203427 --- /dev/null +++ b/Sources/Tagged/Tagged.CollectionStyle.swift @@ -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 + } + } +} diff --git a/Sources/Tagged/Tagged.Dumper.Scalars.swift b/Sources/Tagged/Tagged.Dumper.Scalars.swift new file mode 100644 index 0000000..d045a16 --- /dev/null +++ b/Sources/Tagged/Tagged.Dumper.Scalars.swift @@ -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 } + } +} diff --git a/Sources/Tagged/Tagged.Dumper.swift b/Sources/Tagged/Tagged.Dumper.swift new file mode 100644 index 0000000..b8671a2 --- /dev/null +++ b/Sources/Tagged/Tagged.Dumper.swift @@ -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) + } +} diff --git a/Sources/Tagged/Tagged.EventComposer.swift b/Sources/Tagged/Tagged.EventComposer.swift index e012886..b90c8f1 100644 --- a/Sources/Tagged/Tagged.EventComposer.swift +++ b/Sources/Tagged/Tagged.EventComposer.swift @@ -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)) diff --git a/Sources/Tagged/Tagged.Mapping.swift b/Sources/Tagged/Tagged.Mapping.swift index 733e47f..c3a3ad0 100644 --- a/Sources/Tagged/Tagged.Mapping.swift +++ b/Sources/Tagged/Tagged.Mapping.swift @@ -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? { diff --git a/Sources/Tagged/Tagged.Node.ModelValue.swift b/Sources/Tagged/Tagged.Node.ModelValue.swift index a03747b..17982f5 100644 --- a/Sources/Tagged/Tagged.Node.ModelValue.swift +++ b/Sources/Tagged/Tagged.Node.ModelValue.swift @@ -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 } } } diff --git a/Sources/Tagged/Tagged.Node.swift b/Sources/Tagged/Tagged.Node.swift index 518b69e..eeab8bc 100644 --- a/Sources/Tagged/Tagged.Node.swift +++ b/Sources/Tagged/Tagged.Node.swift @@ -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 } } } diff --git a/Sources/Tagged/Tagged.NodeKind.swift b/Sources/Tagged/Tagged.NodeKind.swift index 65951a2..4e03d5e 100644 --- a/Sources/Tagged/Tagged.NodeKind.swift +++ b/Sources/Tagged/Tagged.NodeKind.swift @@ -3,6 +3,7 @@ public extension PureYAML.Tagged { case scalar case sequence case mapping + case alias public var description: String { rawValue diff --git a/Sources/Tagged/Tagged.Scalar.swift b/Sources/Tagged/Tagged.Scalar.swift index 527b1bb..32a765e 100644 --- a/Sources/Tagged/Tagged.Scalar.swift +++ b/Sources/Tagged/Tagged.Scalar.swift @@ -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 } } } diff --git a/Sources/Tagged/Tagged.ScalarStyle.swift b/Sources/Tagged/Tagged.ScalarStyle.swift new file mode 100644 index 0000000..49e0972 --- /dev/null +++ b/Sources/Tagged/Tagged.ScalarStyle.swift @@ -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 + } + } +} diff --git a/Sources/Tagged/Tagged.Sequence.swift b/Sources/Tagged/Tagged.Sequence.swift index 01cbbb4..e9b104f 100644 --- a/Sources/Tagged/Tagged.Sequence.swift +++ b/Sources/Tagged/Tagged.Sequence.swift @@ -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 } } } diff --git a/Sources/Tagged/Tagged.Validator.swift b/Sources/Tagged/Tagged.Validator.swift index 49e27d5..3e72425 100644 --- a/Sources/Tagged/Tagged.Validator.swift +++ b/Sources/Tagged/Tagged.Validator.swift @@ -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 } } diff --git a/Tests/TaggedNodeCorpusRoundTripTests.swift b/Tests/TaggedNodeCorpusRoundTripTests.swift new file mode 100644 index 0000000..ed1e190 --- /dev/null +++ b/Tests/TaggedNodeCorpusRoundTripTests.swift @@ -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")) + } +} diff --git a/Tests/TaggedNodeRoundTripTests.swift b/Tests/TaggedNodeRoundTripTests.swift new file mode 100644 index 0000000..93f3c23 --- /dev/null +++ b/Tests/TaggedNodeRoundTripTests.swift @@ -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) + } +}