From d4ba594012004e1ab78260506e61c21d31bd8e8d Mon Sep 17 00:00:00 2001 From: Mihaela M Date: Mon, 29 Jun 2026 07:14:42 +0200 Subject: [PATCH] feat(parsing): opt-in located parse on the hand-rolled engine Port the located value tree onto the hand-rolled engine, reusing the engine-agnostic located layer (Model.LocatedValue, LocatedPair, SourcePosition, SourceRange, and the located composer helper) authored on the conformance branch. The streaming composer now builds a parallel located tree behind an opt-in tracksLocations flag, exposed as parseLocated / parseStreamLocated; the plain Value path stays byte-identical, gated behind the flag. The scalar event carries the scanner token's end mark, so a located scalar spans the whole token (end exclusive, one past the last character) rather than being start-anchored. The hand-rolled scanner already tracked token end marks, so this surfaces information the engine was already computing. Also stop emitting a spurious null document for a comment-only or empty stream: YAML 1.2 gives such a stream zero documents, while the single-document value API still maps it to null, so parse("") is unchanged. A whole-suite test proves the located projection equals a plain parse for every passing case; the value, event-dump, and conformance suites stay green (507 tests). --- Sources/API.swift | 11 ++ Sources/Model/LocatedPair.swift | 20 ++++ Sources/Model/LocatedValue.swift | 58 ++++++++++ Sources/Model/SourcePosition.swift | 19 ++++ Sources/Model/SourceRange.swift | 20 ++++ Sources/Parsing/Event.swift | 6 +- Sources/Parsing/EventComposer.swift | 10 +- Sources/Parsing/EventStreamComposedNode.swift | 2 + .../Parsing/EventStreamComposer.Located.swift | 63 +++++++++++ Sources/Parsing/EventStreamComposer.swift | 85 +++++++++++++-- Sources/Parsing/EventStreamContainer.swift | 5 + Sources/Parsing/Parser.Events.swift | 34 ++++++ Sources/Parsing/Parser.swift | 8 ++ Sources/Parsing/TokenEventParser.Block.swift | 3 +- Sources/Parsing/TokenEventParser.Flow.swift | 10 +- .../Parsing/TokenEventParser.Helpers.swift | 2 +- Sources/Parsing/TokenEventParser.swift | 17 ++- Sources/Tagged/Tagged.EventComposer.swift | 4 +- Tests/LocatedValueConformanceTests.swift | 100 ++++++++++++++++++ Tests/ParsingEventTests.swift | 12 +-- 20 files changed, 452 insertions(+), 37 deletions(-) create mode 100644 Sources/Model/LocatedPair.swift create mode 100644 Sources/Model/LocatedValue.swift create mode 100644 Sources/Model/SourcePosition.swift create mode 100644 Sources/Model/SourceRange.swift create mode 100644 Sources/Parsing/EventStreamComposer.Located.swift create mode 100644 Tests/LocatedValueConformanceTests.swift diff --git a/Sources/API.swift b/Sources/API.swift index af50fae..a463432 100644 --- a/Sources/API.swift +++ b/Sources/API.swift @@ -9,6 +9,17 @@ public extension PureYAML { try Parsing.Parser().parseStream(yaml) } + /// Parses a YAML document into a located value tree, annotating every node with its source + /// span. The plain ``parse(_:)`` is unaffected; positions are strictly opt-in. + static func parseLocated(_ yaml: String) throws -> Model.LocatedValue { + try Parsing.Parser().parseLocated(yaml) + } + + /// Parses a YAML stream into one located value tree per document, in document order. + static func parseStreamLocated(_ yaml: String) throws -> [Model.LocatedValue] { + try Parsing.Parser().parseStreamLocated(yaml) + } + /// Serializes a ``Model/Value`` tree into YAML with the selected options. static func dump( _ value: Model.Value, diff --git a/Sources/Model/LocatedPair.swift b/Sources/Model/LocatedPair.swift new file mode 100644 index 0000000..621638c --- /dev/null +++ b/Sources/Model/LocatedPair.swift @@ -0,0 +1,20 @@ +public extension PureYAML.Model { + /// A key-value pair in a located mapping: the key (as a ``Key``), the source span of the + /// key node, and the located value. Parallel to ``Pair`` but carrying source coordinates. + struct LocatedPair: Equatable, Sendable { + public var keyNode: Key + public var keyRange: SourceRange + public var value: LocatedValue + + public init(keyNode: Key, keyRange: SourceRange, value: LocatedValue) { + self.keyNode = keyNode + self.keyRange = keyRange + self.value = value + } + + /// The position-free pair, projecting the located value back to a plain ``Value``. + public var pair: Pair { + Pair(keyNode: keyNode, value: value.value) + } + } +} diff --git a/Sources/Model/LocatedValue.swift b/Sources/Model/LocatedValue.swift new file mode 100644 index 0000000..8276dbd --- /dev/null +++ b/Sources/Model/LocatedValue.swift @@ -0,0 +1,58 @@ +public extension PureYAML.Model { + /// A value tree annotated with source spans, parallel in shape to ``Value``: every node + /// carries its own ``SourceRange`` and its located children. It is produced by the opt-in + /// ``PureYAML/parseLocated(_:)`` and ``PureYAML/parseStreamLocated(_:)`` entry points; the + /// plain ``Value`` API is unchanged, so source positions never affect value equality, the + /// conformance oracle, or any existing consumer. + /// + /// `value` projects the position-free ``Value`` (identical to what a plain parse returns), + /// and `range` is the node's own span, so a consumer can keep operating on ``Value`` and + /// consult the located tree only when it needs a location. + indirect enum LocatedValue: Equatable, Sendable { + /// A leaf scalar (`null`, `bool`, `int`, `double`, or `string`) with its start-anchored span. + case scalar(Value, SourceRange) + /// A sequence: its located items and the span from its opening to its closing. + case sequence([LocatedValue], SourceRange) + /// A mapping: its located pairs and the span from its opening to its closing. + case mapping([LocatedPair], SourceRange) + } +} + +public extension PureYAML.Model.LocatedValue { + /// The span of this node. + var range: PureYAML.Model.SourceRange { + switch self { + case let .scalar(_, range), let .sequence(_, range), let .mapping(_, range): + range + } + } + + /// The position-free value this located node projects to, identical to a plain parse. + var value: PureYAML.Model.Value { + switch self { + case let .scalar(value, _): + value + case let .sequence(items, _): + .sequence(items.map(\.value)) + case let .mapping(pairs, _): + .mapping(PureYAML.Model.Mapping(pairs.map(\.pair))) + } + } + + /// Wrap a plain ``Value`` so every node shares one span. Used where a real per-node span is + /// not available (an alias expansion or a merge-inherited pair, whose content is defined + /// elsewhere): the whole subtree honestly points at its single reference site. + static func synthesized(from value: PureYAML.Model.Value, at range: PureYAML.Model.SourceRange) -> Self { + switch value { + case .null, .bool, .int, .double, .string: + return .scalar(value, range) + case let .sequence(items): + return .sequence(items.map { synthesized(from: $0, at: range) }, range) + case let .mapping(mapping): + let pairs = mapping.pairs.map { + PureYAML.Model.LocatedPair(keyNode: $0.keyNode, keyRange: range, value: synthesized(from: $0.value, at: range)) + } + return .mapping(pairs, range) + } + } +} diff --git a/Sources/Model/SourcePosition.swift b/Sources/Model/SourcePosition.swift new file mode 100644 index 0000000..be86629 --- /dev/null +++ b/Sources/Model/SourcePosition.swift @@ -0,0 +1,19 @@ +public extension PureYAML.Model { + /// A 1-based line/column position in the source text. Carried by ``LocatedValue`` so a + /// consumer (an editor, a linter, a CI report) can point at exactly where a value-tree + /// node was written. Positions live on the parallel located tree, never on ``Value``, + /// so the plain value tree and its equality stay free of source coordinates. + struct SourcePosition: Equatable, Hashable, Sendable, Comparable { + public var line: Int + public var column: Int + + public init(line: Int, column: Int) { + self.line = line + self.column = column + } + + public static func < (lhs: Self, rhs: Self) -> Bool { + (lhs.line, lhs.column) < (rhs.line, rhs.column) + } + } +} diff --git a/Sources/Model/SourceRange.swift b/Sources/Model/SourceRange.swift new file mode 100644 index 0000000..d439070 --- /dev/null +++ b/Sources/Model/SourceRange.swift @@ -0,0 +1,20 @@ +public extension PureYAML.Model { + /// A source span for a value-tree node: a start and end ``SourcePosition``. A collection + /// spans from its opening to its closing event; a scalar carries a start-anchored span + /// (start equal to end), because the event stream records a scalar's start mark only. + struct SourceRange: Equatable, Hashable, Sendable { + public var start: SourcePosition + public var end: SourcePosition + + public init(start: SourcePosition, end: SourcePosition) { + self.start = start + self.end = end + } + + /// A zero-width span at a single position, used when only a point is known. + public init(at position: SourcePosition) { + start = position + end = position + } + } +} diff --git a/Sources/Parsing/Event.swift b/Sources/Parsing/Event.swift index 31d2e55..891a27c 100644 --- a/Sources/Parsing/Event.swift +++ b/Sources/Parsing/Event.swift @@ -8,7 +8,7 @@ extension PureYAML.Parsing { case sequenceEnd(mark: Mark) case mappingStart(anchor: String?, tag: String?, style: CollectionStyle, mark: Mark) case mappingEnd(mark: Mark) - case scalar(value: String, anchor: String?, tag: String?, style: ScalarStyle, mark: Mark) + case scalar(value: String, anchor: String?, tag: String?, style: ScalarStyle, mark: Mark, endMark: Mark) case alias(anchor: String, mark: Mark) var description: String { @@ -29,7 +29,7 @@ extension PureYAML.Parsing { "mappingStart anchor=\(describe(anchor)) tag=\(describe(tag)) style=\(style) @\(mark)" case let .mappingEnd(mark): "mappingEnd @\(mark)" - case let .scalar(value, anchor, tag, style, mark): + case let .scalar(value, anchor, tag, style, mark, _): "scalar value=\"\(escape(value))\" anchor=\(describe(anchor)) tag=\(describe(tag)) style=\(style) @\(mark)" case let .alias(anchor, mark): "alias anchor=\(anchor) @\(mark)" @@ -67,7 +67,7 @@ extension PureYAML.Parsing.Event { let .documentStart(mark), let .mappingEnd(mark), let .mappingStart(_, _, _, mark), - let .scalar(_, _, _, _, mark), + let .scalar(_, _, _, _, mark, _), let .sequenceEnd(mark), let .sequenceStart(_, _, _, mark), let .streamEnd(mark), diff --git a/Sources/Parsing/EventComposer.swift b/Sources/Parsing/EventComposer.swift index bee2666..1a4aac3 100644 --- a/Sources/Parsing/EventComposer.swift +++ b/Sources/Parsing/EventComposer.swift @@ -22,6 +22,12 @@ extension PureYAML.Parsing { } return false } + // An empty or comment-only stream has no document events; the + // single-document value API maps that to null. + if current?.isStreamEnd == true { + _ = try expectStreamEnd() + return .null + } _ = try expect("document start") { event in if case .documentStart = event { return true @@ -96,7 +102,7 @@ extension PureYAML.Parsing.EventComposer { let value = try composeMapping() store(value, anchor: anchor) return value - case let .scalar(value, anchor, tag, style, mark): + case let .scalar(value, anchor, tag, style, mark, _): index += 1 let model = try composeScalar( value, @@ -157,7 +163,7 @@ extension PureYAML.Parsing.EventComposer { guard let event = current else { throw unexpectedEvent(expected: "mapping key") } - guard case let .scalar(value, anchor, tag, style, _) = event else { + guard case let .scalar(value, anchor, tag, style, _, _) = event else { let value = try composeNode() return ComposedMappingKey( keyNode: PureYAML.Model.Key(value: value), diff --git a/Sources/Parsing/EventStreamComposedNode.swift b/Sources/Parsing/EventStreamComposedNode.swift index 4e0bf83..cde9ba3 100644 --- a/Sources/Parsing/EventStreamComposedNode.swift +++ b/Sources/Parsing/EventStreamComposedNode.swift @@ -4,5 +4,7 @@ extension PureYAML.Parsing { var mappingKey: PureYAML.Parsing.EventComposer.ComposedMappingKey var mark: PureYAML.Parsing.Mark var sequenceItems: [EventStreamComposedNode] = [] + /// The located projection, populated only when the composer tracks locations. + var located: PureYAML.Model.LocatedValue? } } diff --git a/Sources/Parsing/EventStreamComposer.Located.swift b/Sources/Parsing/EventStreamComposer.Located.swift new file mode 100644 index 0000000..084ff1a --- /dev/null +++ b/Sources/Parsing/EventStreamComposer.Located.swift @@ -0,0 +1,63 @@ +extension PureYAML.Parsing.EventStreamComposer { + func range(at mark: PureYAML.Parsing.Mark) -> PureYAML.Model.SourceRange { + .init(at: .init(line: mark.line, column: mark.column)) + } + + func range(start: PureYAML.Parsing.Mark, end: PureYAML.Parsing.Mark) -> PureYAML.Model.SourceRange { + .init(start: .init(line: start.line, column: start.column), end: .init(line: end.line, column: end.column)) + } + + /// The node's located projection, falling back to a synthesized one at the node's mark if + /// the located field was somehow not populated (never expected while tracking locations). + func locatedNode(_ node: PureYAML.Parsing.EventStreamComposedNode) -> PureYAML.Model.LocatedValue { + node.located ?? .synthesized(from: node.value, at: range(at: node.mark)) + } + + /// The located pairs a merge value (`<<`) contributes, mirroring ``mergeSources(from:)``. + /// The value-side merge validation has already run, so the located shape is a mapping or a + /// sequence of mappings; anything else contributes nothing. + func locatedMergeSources(from node: PureYAML.Parsing.EventStreamComposedNode) -> [[PureYAML.Model.LocatedPair]] { + switch locatedNode(node) { + case let .mapping(pairs, _): + [pairs] + case let .sequence(items, _): + items.compactMap { item in + guard case let .mapping(pairs, _) = item else { + return nil + } + return pairs + } + case .scalar: + [] + } + } + + /// The final located pairs, mirroring ``mergedPairs(from:localPairs:)`` exactly (same dedup, + /// same order) so the located mapping has the identical shape to the value mapping. + func mergedLocatedPairs( + from mergeSources: [[PureYAML.Model.LocatedPair]], + localPairs: [PureYAML.Model.LocatedPair], + ) -> [PureYAML.Model.LocatedPair] { + guard !mergeSources.isEmpty else { + return localPairs + } + + let localKeys = Set(localPairs.map(\.keyNode)) + var inheritedKeys = Set() + var inheritedPairs: [PureYAML.Model.LocatedPair] = [] + for source in mergeSources { + var sourceKeys = Set() + for pair in source { + guard !localKeys.contains(pair.keyNode), + !inheritedKeys.contains(pair.keyNode) + else { + continue + } + inheritedPairs.append(pair) + sourceKeys.insert(pair.keyNode) + } + inheritedKeys.formUnion(sourceKeys) + } + return inheritedPairs + localPairs + } +} diff --git a/Sources/Parsing/EventStreamComposer.swift b/Sources/Parsing/EventStreamComposer.swift index 314cdf5..597a2dd 100644 --- a/Sources/Parsing/EventStreamComposer.swift +++ b/Sources/Parsing/EventStreamComposer.swift @@ -2,19 +2,26 @@ extension PureYAML.Parsing { struct EventStreamComposer { let scalarParser: Parser let mode: EventStreamComposerMode + // When true, the composer builds a parallel located tree alongside each value. The + // default value path never sets this, so it stays byte-identical to before. + var tracksLocations = false var anchors: [String: PureYAML.Model.Value] = [:] var containers: [EventStreamContainer] = [] var documentValues: [PureYAML.Model.Value] = [] + var documentLocated: [PureYAML.Model.LocatedValue] = [] var currentDocumentValue: PureYAML.Model.Value? + var currentDocumentLocated: PureYAML.Model.LocatedValue? var didSeeStreamStart = false var didSeeStreamEnd = false init( scalarParser: Parser, mode: EventStreamComposerMode, + tracksLocations: Bool = false, ) { self.scalarParser = scalarParser self.mode = mode + self.tracksLocations = tracksLocations } mutating func consume(_ event: PureYAML.Parsing.Event) throws { @@ -27,8 +34,8 @@ extension PureYAML.Parsing { try startDocument(mark: mark) case let .documentEnd(mark): try endDocument(mark: mark) - case let .scalar(value, anchor, tag, style, mark): - try consumeScalar(value, anchor: anchor, tag: tag, style: style, mark: mark) + case let .scalar(value, anchor, tag, style, mark, endMark): + try consumeScalar(value, anchor: anchor, tag: tag, style: style, marks: (mark, endMark)) case let .alias(anchor, mark): try consumeAlias(anchor, mark: mark) case let .sequenceStart(anchor, _, _, mark): @@ -46,10 +53,13 @@ extension PureYAML.Parsing { guard didSeeStreamStart, didSeeStreamEnd else { throw unexpectedEvent(expected: "complete YAML stream", mark: .start) } - guard documentValues.count == 1, let value = documentValues.first else { + // An empty or comment-only stream has zero documents and maps to null; + // a single document yields its value; more than one is not a single + // document and is rejected. + guard documentValues.count <= 1 else { throw unexpectedEvent(expected: "single YAML document", mark: .start) } - return value + return documentValues.first ?? .null } mutating func composedStream() throws -> [PureYAML.Stream.Document] { @@ -60,6 +70,27 @@ extension PureYAML.Parsing { PureYAML.Stream.Document(index: index, value: value) } } + + /// The single document's located tree (mirrors ``composedValue()``). Only meaningful + /// when the composer was created with `tracksLocations: true`. + mutating func composedLocated() throws -> PureYAML.Model.LocatedValue { + guard didSeeStreamStart, didSeeStreamEnd else { + throw unexpectedEvent(expected: "complete YAML stream", mark: .start) + } + guard documentLocated.count <= 1 else { + throw unexpectedEvent(expected: "single YAML document", mark: .start) + } + return documentLocated.first ?? .scalar(.null, .init(at: .init(line: 1, column: 1))) + } + + /// The stream's located trees, one per document (mirrors ``composedStream()``). Only + /// meaningful when the composer was created with `tracksLocations: true`. + mutating func composedLocatedStream() throws -> [PureYAML.Model.LocatedValue] { + guard didSeeStreamStart, didSeeStreamEnd else { + throw unexpectedEvent(expected: "complete YAML stream", mark: .start) + } + return documentLocated + } } } @@ -73,6 +104,7 @@ private extension PureYAML.Parsing.EventStreamComposer { } anchors = [:] currentDocumentValue = nil + currentDocumentLocated = nil } mutating func endDocument(mark: PureYAML.Parsing.Mark) throws { @@ -81,6 +113,10 @@ private extension PureYAML.Parsing.EventStreamComposer { } documentValues.append(currentDocumentValue ?? .null) currentDocumentValue = nil + if tracksLocations { + documentLocated.append(currentDocumentLocated ?? .scalar(.null, range(at: mark))) + currentDocumentLocated = nil + } } mutating func consumeScalar( @@ -88,9 +124,9 @@ private extension PureYAML.Parsing.EventStreamComposer { anchor: String?, tag: String?, style: PureYAML.Parsing.ScalarStyle, - mark: PureYAML.Parsing.Mark, + marks: (start: PureYAML.Parsing.Mark, end: PureYAML.Parsing.Mark), ) throws { - let model = try scalarParser.composeScalarValue(value, tag: tag, style: style, mark: mark) + let model = try scalarParser.composeScalarValue(value, tag: tag, style: style, mark: marks.start) if let anchor { // Register the resolved node, not the raw text: an alias resolves to the anchored node's // value, so an empty scalar anchors null and a typed scalar (`&a 42`) anchors that value. @@ -104,7 +140,8 @@ private extension PureYAML.Parsing.EventStreamComposer { tag: scalarParser.normalizedTag(tag), style: style, ), - mark: mark, + mark: marks.start, + located: tracksLocations ? .scalar(model, range(start: marks.start, end: marks.end)) : nil, )) } @@ -128,6 +165,7 @@ private extension PureYAML.Parsing.EventStreamComposer { style: nil, ), mark: mark, + located: tracksLocations ? .synthesized(from: value, at: range(at: mark)) : nil, )) } @@ -147,6 +185,9 @@ private extension PureYAML.Parsing.EventStreamComposer { ), mark: container.mark, sequenceItems: container.values, + located: tracksLocations + ? .sequence(container.locatedValues, range(start: container.mark, end: mark)) + : nil, )) } @@ -163,6 +204,12 @@ private extension PureYAML.Parsing.EventStreamComposer { ) let value = PureYAML.Model.Value.mapping(.init(pairs)) store(value, anchor: container.anchor) + let located: PureYAML.Model.LocatedValue? = tracksLocations + ? .mapping( + mergedLocatedPairs(from: container.mergeLocatedSources, localPairs: container.localLocatedPairs), + range(start: container.mark, end: mark), + ) + : nil try complete(.init( value: value, mappingKey: .init( @@ -172,18 +219,25 @@ private extension PureYAML.Parsing.EventStreamComposer { style: nil, ), mark: container.mark, + located: located, )) } mutating func complete(_ node: PureYAML.Parsing.EventStreamComposedNode) throws { guard !containers.isEmpty else { currentDocumentValue = node.value + if tracksLocations { + currentDocumentLocated = node.located + } return } switch containers[containers.count - 1].kind { case .sequence: containers[containers.count - 1].values.append(node) + if tracksLocations { + containers[containers.count - 1].locatedValues.append(locatedNode(node)) + } case .mapping: try completeMappingNode(node) } @@ -193,6 +247,9 @@ private extension PureYAML.Parsing.EventStreamComposer { let index = containers.count - 1 guard let key = containers[index].pendingKey else { containers[index].pendingKey = node.mappingKey + if tracksLocations { + containers[index].pendingKeyNode = node + } return } @@ -200,10 +257,24 @@ private extension PureYAML.Parsing.EventStreamComposer { try containers[index].mergeSources.append(contentsOf: mergeSources( from: node, )) + if tracksLocations { + containers[index].mergeLocatedSources.append(contentsOf: locatedMergeSources(from: node)) + } } else { containers[index].localPairs.append(.init(keyNode: key.keyNode, value: node.value)) + if tracksLocations { + let keyNode = containers[index].pendingKeyNode + containers[index].localLocatedPairs.append(.init( + keyNode: key.keyNode, + keyRange: keyNode.flatMap(\.located)?.range ?? locatedNode(node).range, + value: locatedNode(node), + )) + } } containers[index].pendingKey = nil + if tracksLocations { + containers[index].pendingKeyNode = nil + } } mutating func store( diff --git a/Sources/Parsing/EventStreamContainer.swift b/Sources/Parsing/EventStreamContainer.swift index ccb391c..74bc644 100644 --- a/Sources/Parsing/EventStreamContainer.swift +++ b/Sources/Parsing/EventStreamContainer.swift @@ -7,5 +7,10 @@ extension PureYAML.Parsing { var localPairs: [PureYAML.Model.Pair] = [] var pendingKey: PureYAML.Parsing.EventComposer.ComposedMappingKey? var mark: PureYAML.Parsing.Mark + // Location tracking (parallel to the above, only when the composer tracks locations). + var locatedValues: [PureYAML.Model.LocatedValue] = [] + var mergeLocatedSources: [[PureYAML.Model.LocatedPair]] = [] + var localLocatedPairs: [PureYAML.Model.LocatedPair] = [] + var pendingKeyNode: EventStreamComposedNode? } } diff --git a/Sources/Parsing/Parser.Events.swift b/Sources/Parsing/Parser.Events.swift index 6f235fa..0e8e125 100644 --- a/Sources/Parsing/Parser.Events.swift +++ b/Sources/Parsing/Parser.Events.swift @@ -36,4 +36,38 @@ extension PureYAML.Parsing.Parser { try parser.parseStreaming() return try composer.composedStream() } + + func parseLocatedStreaming(_ yaml: String) throws -> PureYAML.Model.LocatedValue { + var composer = PureYAML.Parsing.EventStreamComposer( + scalarParser: self, + mode: .singleDocument, + tracksLocations: true, + ) + var parser = PureYAML.Parsing.TokenEventParser( + yaml: yaml, + scalarParser: self, + eventSink: { event in + try composer.consume(event) + }, + ) + try parser.parseStreaming() + return try composer.composedLocated() + } + + func parseStreamLocatedStreaming(_ yaml: String) throws -> [PureYAML.Model.LocatedValue] { + var composer = PureYAML.Parsing.EventStreamComposer( + scalarParser: self, + mode: .stream, + tracksLocations: true, + ) + var parser = PureYAML.Parsing.TokenEventParser( + yaml: yaml, + scalarParser: self, + eventSink: { event in + try composer.consume(event) + }, + ) + try parser.parseStreaming() + return try composer.composedLocatedStream() + } } diff --git a/Sources/Parsing/Parser.swift b/Sources/Parsing/Parser.swift index 2c0d85f..fa65425 100644 --- a/Sources/Parsing/Parser.swift +++ b/Sources/Parsing/Parser.swift @@ -10,5 +10,13 @@ public extension PureYAML.Parsing { public func parseStream(_ yaml: String) throws -> [PureYAML.Stream.Document] { try parseStreamStreaming(yaml) } + + public func parseLocated(_ yaml: String) throws -> PureYAML.Model.LocatedValue { + try parseLocatedStreaming(yaml) + } + + public func parseStreamLocated(_ yaml: String) throws -> [PureYAML.Model.LocatedValue] { + try parseStreamLocatedStreaming(yaml) + } } } diff --git a/Sources/Parsing/TokenEventParser.Block.swift b/Sources/Parsing/TokenEventParser.Block.swift index f1b97dd..7876b2a 100644 --- a/Sources/Parsing/TokenEventParser.Block.swift +++ b/Sources/Parsing/TokenEventParser.Block.swift @@ -110,7 +110,7 @@ extension PureYAML.Parsing.TokenEventParser { _ = try expect("dedent") { $0.isDedent } } else { let mark = entry.endMark - try emit(.scalar(value: "", anchor: nil, tag: nil, style: .plain, mark: mark)) + try emit(.scalar(value: "", anchor: nil, tag: nil, style: .plain, mark: mark, endMark: mark)) endMark = mark } } @@ -253,6 +253,7 @@ extension PureYAML.Parsing.TokenEventParser { tag: properties.tag, style: scalarStyle(for: header.kind), mark: mark, + endMark: endMark, )) return PureYAML.Parsing.NodeResult(kind: .scalar, endMark: endMark) } diff --git a/Sources/Parsing/TokenEventParser.Flow.swift b/Sources/Parsing/TokenEventParser.Flow.swift index c1da0da..3d0ce49 100644 --- a/Sources/Parsing/TokenEventParser.Flow.swift +++ b/Sources/Parsing/TokenEventParser.Flow.swift @@ -61,7 +61,7 @@ extension PureYAML.Parsing.TokenEventParser { mutating func parseEmptyKeyFlowMappingElement() throws -> PureYAML.Parsing.NodeResult { let mark = cursor.current?.mark ?? .start try emit(.mappingStart(anchor: nil, tag: nil, style: .flow, mark: mark)) - try emit(.scalar(value: "", anchor: nil, tag: nil, style: .plain, mark: mark)) + try emit(.scalar(value: "", anchor: nil, tag: nil, style: .plain, mark: mark, endMark: mark)) let valueIndicator = try expect("mapping value") { kind in if case .mappingValue = kind { return true @@ -72,7 +72,7 @@ extension PureYAML.Parsing.TokenEventParser { let endMark: PureYAML.Parsing.Mark if properties == .none, cursor.current?.kind.isFlowTerminator != false { endMark = valueIndicator.endMark - try emit(.scalar(value: "", anchor: nil, tag: nil, style: .plain, mark: endMark)) + try emit(.scalar(value: "", anchor: nil, tag: nil, style: .plain, mark: endMark, endMark: endMark)) } else { endMark = try parseNode(properties: properties).endMark } @@ -141,7 +141,7 @@ extension PureYAML.Parsing.TokenEventParser { let keyEndMark: PureYAML.Parsing.Mark if case .mappingValue = cursor.current?.kind { let mark = keyProperties.mark ?? cursor.current?.mark ?? .start - try emit(.scalar(value: "", anchor: keyProperties.anchor, tag: keyProperties.tag, style: .plain, mark: mark)) + try emit(.scalar(value: "", anchor: keyProperties.anchor, tag: keyProperties.tag, style: .plain, mark: mark, endMark: mark)) keyEndMark = mark } else { keyEndMark = try parseNode(properties: keyProperties).endMark @@ -149,7 +149,7 @@ extension PureYAML.Parsing.TokenEventParser { // A flow mapping entry may omit its `:` value indicator: `{ a }` and `{ a, b }` are sets whose // keys map to null. A value parses only when a `:` follows the key. guard case .mappingValue = cursor.current?.kind else { - try emit(.scalar(value: "", anchor: nil, tag: nil, style: .plain, mark: keyEndMark)) + try emit(.scalar(value: "", anchor: nil, tag: nil, style: .plain, mark: keyEndMark, endMark: keyEndMark)) return PureYAML.Parsing.NodeResult(kind: .scalar, endMark: keyEndMark) } let valueIndicator = try expect("mapping value") { kind in @@ -162,7 +162,7 @@ extension PureYAML.Parsing.TokenEventParser { let properties = try consumeProperties() guard properties != .none || cursor.current?.kind.isFlowTerminator == false else { let mark = valueIndicator.endMark - try emit(.scalar(value: "", anchor: nil, tag: nil, style: .plain, mark: mark)) + try emit(.scalar(value: "", anchor: nil, tag: nil, style: .plain, mark: mark, endMark: mark)) return PureYAML.Parsing.NodeResult(kind: .scalar, endMark: mark) } return try parseNode(properties: properties) diff --git a/Sources/Parsing/TokenEventParser.Helpers.swift b/Sources/Parsing/TokenEventParser.Helpers.swift index f92ab70..2ce99d0 100644 --- a/Sources/Parsing/TokenEventParser.Helpers.swift +++ b/Sources/Parsing/TokenEventParser.Helpers.swift @@ -132,7 +132,7 @@ extension PureYAML.Parsing.TokenEventParser { /// Emit an empty (null) scalar as a mapping value and return its node result, used where a /// mapping entry has no value: an explicit key with no `:`, or a `key:` with nothing after it. mutating func emitNullMappingValue(at mark: PureYAML.Parsing.Mark) throws -> PureYAML.Parsing.NodeResult { - try emit(.scalar(value: "", anchor: nil, tag: nil, style: .plain, mark: mark)) + try emit(.scalar(value: "", anchor: nil, tag: nil, style: .plain, mark: mark, endMark: mark)) return PureYAML.Parsing.NodeResult(kind: .scalar, endMark: mark) } diff --git a/Sources/Parsing/TokenEventParser.swift b/Sources/Parsing/TokenEventParser.swift index 68965dd..cc1f314 100644 --- a/Sources/Parsing/TokenEventParser.swift +++ b/Sources/Parsing/TokenEventParser.swift @@ -95,16 +95,12 @@ extension PureYAML.Parsing { } try emit(.streamStart(mark: streamStart.mark)) - var didParseDocument = false + // A stream whose only content is comments or whitespace has zero documents + // (YAML 1.2, matching libyaml and the test suite), so no document events are + // emitted. The value-level API maps an empty document stream to a single null + // value separately. while cursor.current?.kind.isStreamEnd != true { try parseDocument() - didParseDocument = true - } - if !didParseDocument { - let mark = cursor.current?.mark ?? streamStart.endMark - try emit(.documentStart(mark: mark)) - try appendEmptyDocument(at: mark) - try emit(.documentEnd(mark: mark)) } let streamEnd = try expect("stream end") { kind in if case .streamEnd = kind { @@ -187,7 +183,7 @@ extension PureYAML.Parsing.TokenEventParser { } mutating func appendEmptyDocument(at mark: PureYAML.Parsing.Mark) throws { - try emit(.scalar(value: "", anchor: nil, tag: nil, style: .plain, mark: mark)) + try emit(.scalar(value: "", anchor: nil, tag: nil, style: .plain, mark: mark, endMark: mark)) } func canEndDocument(at token: PureYAML.Parsing.Token) -> Bool { @@ -220,7 +216,7 @@ extension PureYAML.Parsing.TokenEventParser { guard properties.anchor != nil || properties.tag != nil else { throw PureYAML.Parsing.ParseError.expectedNode(line: mark.line, column: mark.column) } - try emit(.scalar(value: "", anchor: properties.anchor, tag: properties.tag, style: .plain, mark: mark)) + try emit(.scalar(value: "", anchor: properties.anchor, tag: properties.tag, style: .plain, mark: mark, endMark: mark)) return PureYAML.Parsing.NodeResult(kind: .scalar, endMark: mark) } @@ -281,6 +277,7 @@ extension PureYAML.Parsing.TokenEventParser { tag: properties.tag, style: style, mark: mark, + endMark: token.endMark, )) return PureYAML.Parsing.NodeResult(kind: .scalar, endMark: token.endMark) default: diff --git a/Sources/Tagged/Tagged.EventComposer.swift b/Sources/Tagged/Tagged.EventComposer.swift index a234743..8ba3573 100644 --- a/Sources/Tagged/Tagged.EventComposer.swift +++ b/Sources/Tagged/Tagged.EventComposer.swift @@ -106,7 +106,7 @@ extension PureYAML.Tagged.EventComposer { let value = try composeMapping(tag: tag) store(value, anchor: anchor) return value - case let .scalar(value, anchor, tag, style, mark): + case let .scalar(value, anchor, tag, style, mark, _): index += 1 let scalar = try PureYAML.Tagged.Scalar( rawValue: value, @@ -166,7 +166,7 @@ extension PureYAML.Tagged.EventComposer { guard let event = current else { throw unexpectedEvent(expected: "mapping key") } - guard case let .scalar(value, anchor, tag, style, mark) = event else { + guard case let .scalar(value, anchor, tag, style, mark, _) = event else { let mark = event.mark throw PureYAML.Parsing.ParseError.expectedScalarKey(line: mark.line, column: mark.column) } diff --git a/Tests/LocatedValueConformanceTests.swift b/Tests/LocatedValueConformanceTests.swift new file mode 100644 index 0000000..f0e45ef --- /dev/null +++ b/Tests/LocatedValueConformanceTests.swift @@ -0,0 +1,100 @@ +import Foundation +@testable import PureYAML +import Testing + +/// Proves the opt-in located parse (``PureYAML/parseLocated(_:)`` and +/// ``PureYAML/parseStreamLocated(_:)``) is correct in two ways: its projected value is identical +/// to a plain parse for every case in the official suite (so its tree shape never drifts from the +/// value tree, even through merge keys and aliases), and it reports exact source coordinates on a +/// hand-checked document. The plain value path is untouched, so the conformance gates are unaffected. +@Suite("Located Value") +struct LocatedValueConformanceTests { + @Test("A located parse projects back to exactly the plain value") + func test_locatedProjectsToPlainValue() throws { + let yaml = """ + name: web + port: 8080 + items: + - first + - second + nested: + key: value + """ + let located = try PureYAML.parseLocated(yaml) + let plain = try PureYAML.parse(yaml) + #expect(located.value == plain) + } + + @Test("A located parse reports exact line and column for each node") + func test_locatedReportsExactCoordinates() throws { + let yaml = """ + name: web + port: 8080 + items: + - first + - second + """ + guard case let .mapping(pairs, mappingRange) = try PureYAML.parseLocated(yaml) else { + Issue.record("expected a mapping at the document root") + return + } + // The mapping spans from its first key. + #expect(mappingRange.start.line == 1) + #expect(mappingRange.start.column == 1) + + // name: web -> key at 1:1, value spanning columns 7..10 (end exclusive, one past 'web') + let name = pairs[0] + #expect(name.keyNode.stringValue == "name") + #expect(name.keyRange.start == .init(line: 1, column: 1)) + #expect(name.value.range.start == .init(line: 1, column: 7)) + #expect(name.value.range.end == .init(line: 1, column: 10)) + + // port: 8080 -> key at 2:1, value at 2:7 + let port = pairs[1] + #expect(port.keyRange.start == .init(line: 2, column: 1)) + #expect(port.value.range.start == .init(line: 2, column: 7)) + + // items: a sequence whose items sit at 4:5 and 5:5 + guard case let .sequence(items, _) = pairs[2].value else { + Issue.record("expected a sequence for 'items'") + return + } + #expect(items.count == 2) + #expect(items[0].range.start == .init(line: 4, column: 5)) + #expect(items[1].range.start == .init(line: 5, column: 5)) + #expect(items[0].value == .string("first")) + } + + /// Every case in the official suite that parses cleanly must have a located projection + /// identical to its plain parse, so the located tree shape never drifts from the value tree. + /// Opt-in like the plain conformance harness: set `PUREYAML_RUN_YAML_TEST_SUITE=1` and point + /// `PUREYAML_YAML_TEST_SUITE_DIR` at a checkout of the suite's case directories. + @Test("Every passing suite case projects to the same value as a plain parse") + func test_locatedMatchesPlainParseAcrossSuite() throws { + guard ProcessInfo.processInfo.environment["PUREYAML_RUN_YAML_TEST_SUITE"] == "1" else { + return + } + let manager = FileManager.default + let root = ProcessInfo.processInfo.environment["PUREYAML_YAML_TEST_SUITE_DIR"] + ?? manager.currentDirectoryPath + "/.build/yaml-test-suite" + let names = try #require(try? manager.contentsOfDirectory(atPath: root), "suite corpus not found at \(root)") + var compared = 0 + for name in names.sorted() { + let dir = root + "/" + name + guard manager.fileExists(atPath: dir + "/in.yaml"), + !manager.fileExists(atPath: dir + "/error"), + let yaml = try? String(contentsOfFile: dir + "/in.yaml", encoding: .utf8) + else { + continue + } + // Only compare cases that parse cleanly; the located projection must match exactly. + guard let plain = try? PureYAML.parseStream(yaml).map(\.value) else { + continue + } + let located = try PureYAML.parseStreamLocated(yaml).map(\.value) + #expect(located == plain, "\(name): located projection diverged from plain parse") + compared += 1 + } + #expect(compared > 0) + } +} diff --git a/Tests/ParsingEventTests.swift b/Tests/ParsingEventTests.swift index d0fc499..8de7ce9 100644 --- a/Tests/ParsingEventTests.swift +++ b/Tests/ParsingEventTests.swift @@ -7,7 +7,7 @@ struct ParsingEventTests { func test_eventModelRepresentsAliasesAnchorsTagsAndFlowStyle() { let mark = PureYAML.Parsing.Mark(line: 2, column: 3, index: 12) let events: [PureYAML.Parsing.Event] = [ - .scalar(value: "value", anchor: "root", tag: "!Thing", style: .singleQuoted, mark: mark), + .scalar(value: "value", anchor: "root", tag: "!Thing", style: .singleQuoted, mark: mark, endMark: mark), .sequenceStart(anchor: "items", tag: "!seq", style: .flow, mark: mark), .alias(anchor: "root", mark: mark), ] @@ -190,15 +190,15 @@ struct ParsingEventTests { ) } - @Test("Emits null-document events for empty input") - func test_emptyInputEmitsNullDocumentEvents() throws { + @Test("Emits no document for empty input") + func test_emptyInputEmitsNoDocument() throws { + // YAML 1.2: a stream with no content (empty or comment-only) has zero + // documents, matching libyaml and the test suite. The value API maps this + // empty stream to a single null value separately. let events = try PureYAML.Parsing.Parser().parseEvents("") #expect(events.map(\.description) == [ "streamStart @1:1@0", - "documentStart @1:1@0", - "scalar value=\"\" anchor=- tag=- style=plain @1:1@0", - "documentEnd @1:1@0", "streamEnd @1:1@0", ]) }