From 2501cacd195e31140dea908b427510cd588c070a Mon Sep 17 00:00:00 2001 From: Mihaela M Date: Sun, 28 Jun 2026 04:23:06 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 6 ++++++ Sources/Parsing/Scanner.Directives.swift | 6 ++++-- Sources/Parsing/TokenEventParser.swift | 5 ++++- Tests/BareDocumentAfterEndTests.swift | 21 +++++++++++++++++++++ Tests/ScannerTests.swift | 1 - Tests/StreamParsingTests.swift | 11 ++++++----- 6 files changed, 41 insertions(+), 9 deletions(-) create mode 100644 Tests/BareDocumentAfterEndTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 6964457..793ce0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Sources/Parsing/Scanner.Directives.swift b/Sources/Parsing/Scanner.Directives.swift index 14db655..187d23c 100644 --- a/Sources/Parsing/Scanner.Directives.swift +++ b/Sources/Parsing/Scanner.Directives.swift @@ -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 } diff --git a/Sources/Parsing/TokenEventParser.swift b/Sources/Parsing/TokenEventParser.swift index 833bb5f..68965dd 100644 --- a/Sources/Parsing/TokenEventParser.swift +++ b/Sources/Parsing/TokenEventParser.swift @@ -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( diff --git a/Tests/BareDocumentAfterEndTests.swift b/Tests/BareDocumentAfterEndTests.swift new file mode 100644 index 0000000..b22d14e --- /dev/null +++ b/Tests/BareDocumentAfterEndTests.swift @@ -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")) + } +} diff --git a/Tests/ScannerTests.swift b/Tests/ScannerTests.swift index 70b1443..1033baf 100644 --- a/Tests/ScannerTests.swift +++ b/Tests/ScannerTests.swift @@ -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)) } } diff --git a/Tests/StreamParsingTests.swift b/Tests/StreamParsingTests.swift index 07c1851..0b2c0d0 100644 --- a/Tests/StreamParsingTests.swift +++ b/Tests/StreamParsingTests.swift @@ -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")