fix(parsing): parse a bare document after a document-end marker

Content after a '...' document-end marker begins a new bare document, but
the scanner rejected it as an unsupported multi-document stream and the
parser expected the document to end at the marker. A stream like
'--- scalar / ... / key: value' or 'a / ... / b' is two documents.

The scanner now reopens the stream when content follows '...', and
parseDocument returns at a '...' so the outer stream loop parses the next
document, bare or marked. A trailing '...' with nothing after it still
leaves a single document. The single-document composer still rejects a
multi-document stream, so 'parse' is unchanged. Two tests that asserted
content after '...' is an error are corrected, since the construct is valid
per the grammar and the official test-suite.

Measured against the official YAML test-suite, parse-level conformance
rises from 288/333 (86.5%) to 289/333 (86.8%) with no regression.
This commit is contained in:
Mihaela M 2026-06-28 04:23:06 +02:00
parent 3291a3701f
commit 2501cacd19
6 changed files with 41 additions and 9 deletions

View File

@ -23,6 +23,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Content after a document-end marker (`...`) now begins a new bare document. A stream such as
`--- scalar / ... / key: value` or `a / ... / b` is two documents, but the scanner rejected the
content and the parser expected the document to end at the marker. The scanner now reopens the
stream after `...`, and the document loop returns at a `...` so the outer loop parses the next
document. A trailing `...` with nothing after it still leaves a single document. Lifts official
test-suite parse-level conformance from 288/333 (86.5%) to 289/333 (86.8%), with no regression.
- An anchor or tag with no node after it now denotes an empty node. A property always attaches to a
node and an empty node is allowed, so `&anchor` alone, `key: &a`, or a `!!str` with nothing after
it (`x: !!str` is the empty string, `x: !!null` is null) is the property applied to a null node

View File

@ -25,8 +25,10 @@ extension PureYAML.Parsing.Scanner {
resetForDocumentEnd(&state)
return true
}
guard !state.isDocumentClosed else {
throw PureYAML.Parsing.ParseError.unsupportedMultiDocumentStream(line: state.reader.mark.line)
// Content after a document-end marker (`...`) begins a new bare document, so reopen the
// stream rather than rejecting it.
if state.isDocumentClosed {
state.isDocumentClosed = false
}
return false
}

View File

@ -149,8 +149,11 @@ extension PureYAML.Parsing.TokenEventParser {
endMark = dedent.endMark
}
if cursor.current?.kind.isDocumentEnd == true {
// A `...` ends this document. Content after it begins a new bare document, parsed by the
// outer stream loop, so emit the end and return rather than expecting a boundary here.
let end = try expect("document end") { $0.isDocumentEnd }
endMark = end.endMark
try emit(.documentEnd(mark: end.endMark))
return
}
if let current = cursor.current, !canEndDocument(at: current) {
throw PureYAML.Parsing.ParseError.unexpectedToken(

View File

@ -0,0 +1,21 @@
@testable import PureYAML
import Testing
@Suite("Bare documents after a document-end marker")
struct BareDocumentAfterEndTests {
@Test("Content after `...` is a new bare document")
func test_bareDocumentAfterEnd() throws {
// A document started with `---`, ended with `...`, then a bare document follows.
let documents = try PureYAML.parseStream("---\nscalar1\n...\nkey: value\n")
#expect(documents.count == 2)
#expect(documents[0].value == .string("scalar1"))
#expect(documents[1].value == .mapping(.init([.init(key: "key", value: .string("value"))])))
}
@Test("A `...` with nothing after it leaves a single document")
func test_trailingEndMarkerLeavesOneDocument() throws {
let documents = try PureYAML.parseStream("a\n...\n")
#expect(documents.count == 1)
#expect(documents[0].value == .string("a"))
}
}

View File

@ -269,7 +269,6 @@ struct ScannerTests {
// directive cannot appear) is still rejected.
expectScannerError("value: yes\n%FOO bar", .unsupportedDirective(name: "%FOO", line: 2))
expectScannerError("---\n%TAG !e! tag:example.com,2026:", .unsupportedDirective(name: "%TAG", line: 2))
expectScannerError("value: one\n...\nvalue: two", .unsupportedMultiDocumentStream(line: 3))
}
}

View File

@ -140,17 +140,18 @@ struct StreamParsingTests {
)
}
@Test("Stream parser rejects content after explicit document end without next start")
func test_streamParserRejectsContentAfterExplicitDocumentEndWithoutNextStart() {
expectStreamParseError(
@Test("Content after an explicit document end begins a new bare document")
func test_contentAfterExplicitDocumentEndBeginsBareDocument() throws {
let documents = try PureYAML.parseStream(
"""
name: First
...
name: Second
""",
.unsupportedMultiDocumentStream(line: 3),
description: "multi-document streams are not supported at line 3",
)
#expect(documents.count == 2)
#expect(documents[0].value == .mapping(.init([.init(key: "name", value: .string("First"))])))
#expect(documents[1].value == .mapping(.init([.init(key: "name", value: .string("Second"))])))
}
@Test("Stream parser rejects extra root content without a document start")