fix(parsing): reject an under-indented property on a mapping value

A mapping value's anchor or tag on the line below the key must be indented
past the key. The parser consumed such a property regardless of its column,
so 'seq:' then '&anchor' in column 1, anchoring a sequence at the document
level, parsed instead of failing.

When the value's properties are consumed before the indent check, a
properly indented one would have been preceded by an indent token and
consumed inside the indented value; reaching that point with a property on
a later line therefore means it sits at the key's indentation, which is
rejected. The indented-value branch and the new guard move to their own
helpers so parseMappingPair stays within the complexity and length limits.
An inline property, an indented property, and a block sequence at the key's
indent are unaffected.

Measured against the official YAML test-suite, parse-level conformance
rises from 275/333 (82.6%) to 276/333 (82.9%) with no regression.
This commit is contained in:
Mihaela M 2026-06-28 01:28:00 +02:00
parent 30dbd9316b
commit ecf634fdf7
3 changed files with 65 additions and 15 deletions

View File

@ -23,6 +23,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- A mapping value's anchor or tag on the line below the key must now be indented past the key. A
property at the key's own indentation (`seq:` then `&anchor` in column 1) is ambiguous with a
sibling and is rejected, while an inline property, a property indented under the key, and a block
sequence at the key's indent (which carries no property) stay valid. Lifts official test-suite
parse-level conformance from 275/333 (82.6%) to 276/333 (82.9%), with no regression.
- A `-` immediately before a flow indicator is no longer treated as a scalar. In a flow collection
a `-` that begins a plain scalar must be followed by a non-indicator character, so `[-]`,
`[-, -]`, and `{a: -}` are now rejected. A dash followed by ordinary content is unaffected:

View File

@ -19,22 +19,9 @@ extension PureYAML.Parsing.TokenEventParser {
}
let properties = try consumeProperties()
try enforceValuePropertyIndented(properties, valueIndicator: valueIndicator)
if cursor.current?.kind.isIndent == true {
_ = try expect("indent") { $0.isIndent }
let result: PureYAML.Parsing.NodeResult = if cursor.current?.kind.isBlockEntry == true {
try parseBlockSequence(
properties: properties,
allowMappingKeyTerminator: true,
)
} else {
try parseNode(properties: properties)
}
if cursor.current?.kind.isDedent == true {
_ = try expect("dedent") { $0.isDedent }
} else if cursor.current?.kind.isMappingKey != true {
_ = try expect("dedent") { $0.isDedent }
}
return result
return try parseIndentedMappingValue(properties: properties)
}
if properties == .none, try isCommentInterruptedScalarValue(after: valueIndicator) {
return try rejectCommentInterruptedScalar()
@ -59,6 +46,42 @@ extension PureYAML.Parsing.TokenEventParser {
return try emitNullMappingValue(at: valueIndicator.endMark)
}
/// Parses a mapping value that begins on an indented line below the `:`. A leading block entry
/// is a block sequence; anything else is a node. The closing dedent is consumed unless the value
/// ended on a sibling mapping key, which the surrounding mapping continues from.
mutating func parseIndentedMappingValue(
properties: PureYAML.Parsing.NodeProperties,
) throws -> PureYAML.Parsing.NodeResult {
_ = try expect("indent") { $0.isIndent }
let result: PureYAML.Parsing.NodeResult = if cursor.current?.kind.isBlockEntry == true {
try parseBlockSequence(properties: properties, allowMappingKeyTerminator: true)
} else {
try parseNode(properties: properties)
}
if cursor.current?.kind.isDedent == true {
_ = try expect("dedent") { $0.isDedent }
} else if cursor.current?.kind.isMappingKey != true {
_ = try expect("dedent") { $0.isDedent }
}
return result
}
/// A value's anchor or tag on a line below the `:` must be indented past the key. Reaching the
/// value-property consumption with a property on a later line means no indent token preceded it
/// (a properly indented one is consumed inside the indented value), so it sits at the key's
/// level and is malformed (`seq:` then `&anchor` in column 1).
func enforceValuePropertyIndented(
_ properties: PureYAML.Parsing.NodeProperties,
valueIndicator: PureYAML.Parsing.Token,
) throws {
guard properties != .none, let propertyMark = properties.mark,
propertyMark.line != valueIndicator.mark.line
else {
return
}
throw PureYAML.Parsing.ParseError.unexpectedIndentation(line: propertyMark.line)
}
/// 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 {

View File

@ -0,0 +1,22 @@
@testable import PureYAML
import Testing
@Suite("A mapping value's property indentation")
struct ValuePropertyIndentationTests {
@Test("A value's anchor on a line below the key, at the key's indent, is rejected")
func test_underIndentedValuePropertyRejected() throws {
#expect(throws: PureYAML.Parsing.ParseError.self) {
try PureYAML.parse("---\nseq:\n&anchor\n- a\n- b\n")
}
}
@Test("Properties indented past the key, or inline, stay valid", arguments: [
"seq:\n &a\n - x\n", // an anchor indented under the key
"key: &a value\n", // an inline anchor on the value
"seq:\n- a\n- b\n", // a block sequence at the key's indent (no property)
"key:\n &a sub: v\n", // an indented anchored key
])
func test_validValuePropertyPlacement(_ yaml: String) throws {
_ = try PureYAML.parse(yaml)
}
}