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).
This commit is contained in:
Mihaela M 2026-06-29 07:14:42 +02:00
parent 942a7ff2bc
commit d4ba594012
20 changed files with 452 additions and 37 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<PureYAML.Model.Key>()
var inheritedPairs: [PureYAML.Model.LocatedPair] = []
for source in mergeSources {
var sourceKeys = Set<PureYAML.Model.Key>()
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
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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",
])
}