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:
parent
942a7ff2bc
commit
d4ba594012
@ -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,
|
||||
|
||||
20
Sources/Model/LocatedPair.swift
Normal file
20
Sources/Model/LocatedPair.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
58
Sources/Model/LocatedValue.swift
Normal file
58
Sources/Model/LocatedValue.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
19
Sources/Model/SourcePosition.swift
Normal file
19
Sources/Model/SourcePosition.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
20
Sources/Model/SourceRange.swift
Normal file
20
Sources/Model/SourceRange.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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?
|
||||
}
|
||||
}
|
||||
|
||||
63
Sources/Parsing/EventStreamComposer.Located.swift
Normal file
63
Sources/Parsing/EventStreamComposer.Located.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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?
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
100
Tests/LocatedValueConformanceTests.swift
Normal file
100
Tests/LocatedValueConformanceTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
])
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user