feat(parsing): exact scalar end positions in the located parse

The scalar event now carries the scanner token's end mark, so a located
scalar's range spans the whole token (end exclusive, one past the last
character) instead of being start-anchored. This gives consumers exact
highlight widths and is the prerequisite for surgical, position-based
text edits. Only the scalar event case gained a field; the value tree,
its equality, and the event dump (which compares value and style, not
marks) are unchanged, so the canonical-event and whole-suite gates stay
green (343 tests).
This commit is contained in:
Mihaela Mihaljevic 2026-06-26 02:25:01 +02:00
parent 3f3ce310b1
commit f7500400ee
11 changed files with 29 additions and 22 deletions

View File

@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- The located parse now reports an exact end position for scalars, not just a start: the
scalar event carries the scanner token's end mark, so a `LocatedValue.scalar`'s range covers
the whole token (end exclusive, one past the last character) instead of being start-anchored.
Collections already spanned open to close. The event dump compares value and style, not marks,
so conformance is unaffected; the whole-suite gates stay green.
- Add an opt-in located parse, `PureYAML.parseLocated(_:)` and
`PureYAML.parseStreamLocated(_:)`, returning a `Model.LocatedValue` tree parallel
in shape to `Value` where every node carries its source span (1-based line and

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)"
@ -61,8 +61,9 @@ extension PureYAML.Parsing.Event {
return .mappingStart(anchor: anchor, tag: tag, style: style, mark: bumped)
case .mappingEnd:
return .mappingEnd(mark: bumped)
case let .scalar(value, anchor, tag, style, _):
return .scalar(value: value, anchor: anchor, tag: tag, style: style, mark: bumped)
case let .scalar(value, anchor, tag, style, _, endMark):
let bumpedEnd = PureYAML.Parsing.Mark(line: endMark.line + 1, column: endMark.column + 1, index: endMark.index)
return .scalar(value: value, anchor: anchor, tag: tag, style: style, mark: bumped, endMark: bumpedEnd)
case let .alias(anchor, _):
return .alias(anchor: anchor, mark: bumped)
}
@ -94,7 +95,7 @@ extension PureYAML.Parsing.Event {
case let .alias(_, mark),
let .mappingEnd(mark),
let .mappingStart(_, _, _, mark),
let .scalar(_, _, _, _, mark),
let .scalar(_, _, _, _, mark, _),
let .sequenceEnd(mark),
let .sequenceStart(_, _, _, mark),
let .streamEnd(mark),

View File

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

@ -68,8 +68,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):
@ -157,9 +157,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 {
anchors[anchor] = model
}
@ -171,8 +171,8 @@ private extension PureYAML.Parsing.EventStreamComposer {
tag: scalarParser.normalizedTag(tag),
style: style,
),
mark: mark,
located: tracksLocations ? .scalar(model, range(at: mark)) : nil,
mark: marks.start,
located: tracksLocations ? .scalar(model, range(start: marks.start, end: marks.end)) : nil,
))
}

View File

@ -66,7 +66,7 @@ extension PureYAML.Parsing.LibYAML.Parser {
case let .scalar(value, style):
state = states.removeLast()
skipToken()
return .scalar(value: value, anchor: props.anchor, tag: tag, style: style, mark: props.startMark)
return .scalar(value: value, anchor: props.anchor, tag: tag, style: style, mark: props.startMark, endMark: token.endMark)
case .flowSequenceStart:
state = .flowSequenceFirstEntry
return .sequenceStart(anchor: props.anchor, tag: tag, style: .flow, mark: props.startMark)
@ -88,7 +88,7 @@ extension PureYAML.Parsing.LibYAML.Parser {
private mutating func emptyNodeOrError(token: Token, block: Bool, props: NodeProperties, tag: String?) throws -> Event {
if props.anchor != nil || tag != nil {
state = states.removeLast()
return .scalar(value: "", anchor: props.anchor, tag: tag, style: .plain, mark: props.startMark)
return .scalar(value: "", anchor: props.anchor, tag: tag, style: .plain, mark: props.startMark, endMark: props.startMark)
}
throw ParserError(
context: block ? "while parsing a block node" : "while parsing a flow node",

View File

@ -134,7 +134,7 @@ extension PureYAML.Parsing.LibYAML {
/// Port of `yaml_parser_process_empty_scalar`: an empty plain scalar event
/// stands in for an absent node.
func processEmptyScalar(at mark: Mark) -> Event {
.scalar(value: "", anchor: nil, tag: nil, style: .plain, mark: mark)
.scalar(value: "", anchor: nil, tag: nil, style: .plain, mark: mark, endMark: mark)
}
}
}

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

@ -120,7 +120,7 @@ enum CanonicalEventCorpus {
case .sequenceEnd: "-SEQ"
case let .mappingStart(anchor, tag, style, _): "+MAP" + (style == .flow ? " {}" : "") + props(anchor, tag)
case .mappingEnd: "-MAP"
case let .scalar(value, anchor, tag, style, _): "=VAL" + props(anchor, tag) + " " + styleChar(style) + escape(value)
case let .scalar(value, anchor, tag, style, _, _): "=VAL" + props(anchor, tag) + " " + styleChar(style) + escape(value)
case let .alias(anchor, _): "=ALI *" + anchor
}
}

View File

@ -29,7 +29,7 @@ struct LibYAMLParserTests {
"+MAP" + (style == .flow ? " {}" : "") + properties(anchor, tag)
case .mappingEnd:
"-MAP"
case let .scalar(value, anchor, tag, style, _):
case let .scalar(value, anchor, tag, style, _, _):
"=VAL" + properties(anchor, tag) + " " + styleChar(style) + escape(value)
case let .alias(anchor, _):
"=ALI *" + anchor

View File

@ -42,11 +42,12 @@ struct LocatedValueConformanceTests {
#expect(mappingRange.start.line == 1)
#expect(mappingRange.start.column == 1)
// name: web -> key at 1:1, value at 1:7
// 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]

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