From 8649f1722aac219059b9002ea35b0f865d5705f7 Mon Sep 17 00:00:00 2001 From: Mihaela M Date: Sat, 27 Jun 2026 19:47:28 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 7 +++ Sources/Parsing/ParseError.swift | 11 +++- .../Parsing/TokenEventParser.Helpers.swift | 52 ++++--------------- Sources/Parsing/TokenEventParser.swift | 4 +- Tests/CommentInPlainScalarTests.swift | 32 ++++++++++++ 5 files changed, 60 insertions(+), 46 deletions(-) create mode 100644 Tests/CommentInPlainScalarTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 656b0c7..a633191 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Sources/Parsing/ParseError.swift b/Sources/Parsing/ParseError.swift index d2714c4..7c90d91 100644 --- a/Sources/Parsing/ParseError.swift +++ b/Sources/Parsing/ParseError.swift @@ -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), diff --git a/Sources/Parsing/TokenEventParser.Helpers.swift b/Sources/Parsing/TokenEventParser.Helpers.swift index 60abae4..6063af5 100644 --- a/Sources/Parsing/TokenEventParser.Helpers.swift +++ b/Sources/Parsing/TokenEventParser.Helpers.swift @@ -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 } diff --git a/Sources/Parsing/TokenEventParser.swift b/Sources/Parsing/TokenEventParser.swift index 60a8c33..98be6c1 100644 --- a/Sources/Parsing/TokenEventParser.swift +++ b/Sources/Parsing/TokenEventParser.swift @@ -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) diff --git a/Tests/CommentInPlainScalarTests.swift b/Tests/CommentInPlainScalarTests.swift new file mode 100644 index 0000000..937a1ff --- /dev/null +++ b/Tests/CommentInPlainScalarTests.swift @@ -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)), + ]))) + } +}