fix(parsing): reject a comment interrupting a multi-line plain scalar

A comment terminates a plain scalar, so a multi-line plain scalar cannot
resume after one. The parser accepted 'key: word1' then a '#' line then
'  word2', and 'a' then 'b # ...' then 'c', merging the trailing content
back into the value ('word1 word2', 'a b c').

The scanner folds a genuine multi-line plain scalar into a single token
and splits it into a value-line scalar, an indent, and a further scalar
only when a comment broke its continuation folding. That token shape
therefore always denotes a comment-interrupted scalar, so the parser now
rejects it rather than re-merging. The re-merge path and its helper are
removed as they only ever served this malformed shape. A real multi-line
plain scalar and a trailing comment after a value stay valid.

Measured against the official YAML test-suite, parse-level conformance
rises from 264/333 (79.3%) to 266/333 (79.9%) with no regression.
This commit is contained in:
Mihaela M 2026-06-27 19:47:28 +02:00
parent b964fea138
commit 8649f1722a
5 changed files with 60 additions and 46 deletions

View File

@ -23,6 +23,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- A comment can no longer interrupt a multi-line plain scalar. A comment terminates a plain
scalar, so content that tries to continue it on a following line (`key: word1` then a `#` line
then ` word2`, or `a` then `b # ...` then `c`) is now rejected instead of being silently merged
back into the value. A genuine multi-line plain scalar and a trailing comment after a value are
unaffected. The parser path that re-merged the scanner-split scalar is removed, since the scanner
only splits a plain scalar this way when a comment broke its folding. Lifts official test-suite
parse-level conformance from 264/333 (79.3%) to 266/333 (79.9%), with no regression.
- A quoted scalar that spans a line break can no longer be a block implicit key (`"c⏎ d": 1`).
An implicit key in block context must lie on a single line, so a quoted scalar whose open and
close quotes sit on different lines is now rejected when used as such a key. The rule is

View File

@ -26,6 +26,7 @@ public extension PureYAML.Parsing {
case contentAfterDocumentEnd(line: Int)
case blockSequenceAsInlineMappingValue(line: Int)
case multilineImplicitKey(line: Int)
case commentInMultilineScalar(line: Int)
public var description: String {
switch self {
@ -79,6 +80,8 @@ public extension PureYAML.Parsing {
"a block sequence cannot start on the same line as a mapping value at line \(line)"
case let .multilineImplicitKey(line):
"an implicit mapping key must not span multiple lines at line \(line)"
case let .commentInMultilineScalar(line):
"a comment cannot interrupt a multi-line plain scalar at line \(line)"
}
}
@ -134,6 +137,8 @@ public extension PureYAML.Parsing {
"blockSequenceAsInlineMappingValue"
case .multilineImplicitKey:
"multilineImplicitKey"
case .commentInMultilineScalar:
"commentInMultilineScalar"
}
}
@ -154,7 +159,8 @@ public extension PureYAML.Parsing {
let .directiveWithoutDocument(line),
let .contentAfterDocumentEnd(line),
let .blockSequenceAsInlineMappingValue(line),
let .multilineImplicitKey(line):
let .multilineImplicitKey(line),
let .commentInMultilineScalar(line):
line
case let .expectedNode(line, _),
let .expectedScalarKey(line, _),
@ -186,7 +192,8 @@ public extension PureYAML.Parsing {
.directiveWithoutDocument,
.contentAfterDocumentEnd,
.blockSequenceAsInlineMappingValue,
.multilineImplicitKey:
.multilineImplicitKey,
.commentInMultilineScalar:
nil
case let .expectedNode(_, column),
let .expectedScalarKey(_, column),

View File

@ -36,8 +36,8 @@ extension PureYAML.Parsing.TokenEventParser {
}
return result
}
if properties == .none, try canParsePlainScalarWithContinuation(after: valueIndicator) {
return try parsePlainScalarWithContinuation(properties: properties)
if properties == .none, try isCommentInterruptedScalarValue(after: valueIndicator) {
return try rejectCommentInterruptedScalar()
}
if cursor.current?.kind.isMappingKey == true, hasNodeOnSameLine(after: valueIndicator) {
return try parseBlockMapping(
@ -66,36 +66,18 @@ extension PureYAML.Parsing.TokenEventParser {
return PureYAML.Parsing.NodeResult(kind: .scalar, endMark: mark)
}
mutating func parsePlainScalarWithContinuation(
properties: PureYAML.Parsing.NodeProperties,
) throws -> PureYAML.Parsing.NodeResult {
/// Rejects a plain scalar that the scanner split into a value-line scalar, an indent, and a
/// further scalar. The scanner folds a genuine multi-line plain scalar into a single token, so
/// this token shape arises only when a comment broke its continuation: a comment cannot
/// interrupt a multi-line plain scalar, so the construct is malformed.
mutating func rejectCommentInterruptedScalar() throws -> PureYAML.Parsing.NodeResult {
let first = try expect("plain scalar") { kind in
if case .scalar(_, .plain) = kind {
return true
}
return false
}
var value = try decodedScalar(from: first)
var endMark = first.endMark
_ = try expect("indent") { $0.isIndent }
while isScalar(cursor.current) {
let token = try cursor.advance() ?? first
let continuation = try plainContinuationText(from: token)
value += " " + continuation
endMark = token.endMark
}
_ = try expect("dedent") { $0.isDedent }
let mark = properties.mark ?? first.mark
try emit(.scalar(
value: value,
anchor: properties.anchor,
tag: properties.tag,
style: .plain,
mark: mark,
))
return PureYAML.Parsing.NodeResult(kind: .scalar, endMark: endMark)
throw PureYAML.Parsing.ParseError.commentInMultilineScalar(line: first.mark.line)
}
mutating func consumeProperties() throws -> PureYAML.Parsing.NodeProperties {
@ -141,20 +123,6 @@ extension PureYAML.Parsing.TokenEventParser {
return try decodeScalar(value, style: style, line: token.mark.line)
}
func plainContinuationText(from token: PureYAML.Parsing.Token) throws -> String {
guard case let .scalar(value, style) = token.kind else {
throw unexpectedToken(expected: "scalar")
}
switch style {
case .doubleQuoted:
return "\"\(value)\""
case .singleQuoted:
return "'\(value)'"
case .folded, .literal, .plain:
return try decodeScalar(value, style: style, line: token.mark.line)
}
}
func hasNodeOnSameLine(after token: PureYAML.Parsing.Token) -> Bool {
guard let current = cursor.current, !current.kind.isTerminator else {
return false
@ -162,7 +130,7 @@ extension PureYAML.Parsing.TokenEventParser {
return current.mark.line == token.mark.line
}
mutating func canParsePlainScalarWithContinuation(after token: PureYAML.Parsing.Token) throws -> Bool {
mutating func isCommentInterruptedScalarValue(after token: PureYAML.Parsing.Token) throws -> Bool {
guard case .scalar(_, .plain) = cursor.current?.kind else {
return false
}
@ -178,7 +146,7 @@ extension PureYAML.Parsing.TokenEventParser {
return true
}
mutating func canParsePlainScalarContinuation() throws -> Bool {
mutating func isCommentInterruptedScalar() throws -> Bool {
guard case .scalar(_, .plain) = cursor.current?.kind else {
return false
}

View File

@ -241,8 +241,8 @@ extension PureYAML.Parsing.TokenEventParser {
includeIndentedContinuation: false,
)
case let .scalar(value, style):
if try canParsePlainScalarContinuation() {
return try parsePlainScalarWithContinuation(properties: properties)
if try isCommentInterruptedScalar() {
return try rejectCommentInterruptedScalar()
}
_ = try cursor.advance()
let decoded = try decodeScalar(value, style: style, line: token.mark.line)

View File

@ -0,0 +1,32 @@
@testable import PureYAML
import Testing
@Suite("Comments and multi-line plain scalars")
struct CommentInPlainScalarTests {
@Test("A comment interrupting a multi-line plain scalar is rejected", arguments: [
"key: word1\n# xxx\n word2\n", // a comment line between the value and its continuation
"plain: a\n b # end of scalar\n c\n", // a comment ends the scalar, then it tries to continue
])
func test_commentInterruptedScalarRejected(_ yaml: String) throws {
#expect(throws: PureYAML.Parsing.ParseError.self) {
try PureYAML.parse(yaml)
}
}
@Test("Multi-line plain scalars and trailing comments stay valid")
func test_validMultilineScalarsAndComments() throws {
#expect(try PureYAML.parse("plain: a\n b\n c\n") == .mapping(.init([
.init(key: "plain", value: .string("a b c")),
])))
#expect(try PureYAML.parse("key: a b # comment\n") == .mapping(.init([
.init(key: "key", value: .string("a b")),
])))
#expect(try PureYAML.parse("key: a\n b # comment\n") == .mapping(.init([
.init(key: "key", value: .string("a b")),
])))
#expect(try PureYAML.parse("a: 1\n# comment\nb: 2\n") == .mapping(.init([
.init(key: "a", value: .int(1)),
.init(key: "b", value: .int(2)),
])))
}
}