diff --git a/Sources/Parsing/Scanner.Directives.swift b/Sources/Parsing/Scanner.Directives.swift index 5119ea7..c243f87 100644 --- a/Sources/Parsing/Scanner.Directives.swift +++ b/Sources/Parsing/Scanner.Directives.swift @@ -20,8 +20,22 @@ extension PureYAML.Parsing.Scanner { return true } if startsWith("...", state.reader), isIndicatorBoundary(state.reader.peek(offset: 3)) { - guard !state.isDocumentClosed else { - throw PureYAML.Parsing.ParseError.unsupportedMultiDocumentStream(line: state.reader.mark.line) + // A `...` after a document is already closed (only comments or blank lines since the + // last `...`) ends nothing: it is a repeated document-end marker, which YAML allows. Skip + // it as a no-op rather than emitting a second documentEnd (which would compose a phantom + // null document) or rejecting a valid stream. Content after it is still an error. + if state.isDocumentClosed { + state.reader.advance() + state.reader.advance() + state.reader.advance() + while state.reader.peek() == " " { + state.reader.advance() + } + if let next = state.reader.peek(), !isLineBreak(next), next != "#" { + throw PureYAML.Parsing.ParseError.contentAfterDocumentEnd(line: state.reader.mark.line) + } + skipLine(&state) + return true } // A `...` ends a document, so a directive still awaiting its `---` never gets one. try enforceDirectiveHasDocument(state) diff --git a/Tests/RepeatedDocumentEndTests.swift b/Tests/RepeatedDocumentEndTests.swift new file mode 100644 index 0000000..a936f91 --- /dev/null +++ b/Tests/RepeatedDocumentEndTests.swift @@ -0,0 +1,30 @@ +@testable import PureYAML +import Testing + +/// A document-end marker (`...`) may appear repeatedly: once a document is closed, a further `...` +/// (with only comments or blank lines since the last one) ends nothing and is a no-op, rather than +/// being rejected. Content other than a comment after such a marker is still an error. +@Suite("Repeated document end") +struct RepeatedDocumentEndTests { + @Test("A second `...` after a closed document, separated by a comment, is a no-op") + func test_repeatedMarkerIsNoOp() throws { + let documents = try PureYAML.parseStream("a\n...\n# comment\n...\nb\n").map(\.value) + #expect(documents == [.string("a"), .string("b")]) + } + + @Test("A comment-only region between two `...` markers contributes no document") + func test_commentOnlyBetweenMarkers() throws { + // Mirrors suite case M7A3: a bare document, then `...`, a comment, a repeated `...`, and a + // root block scalar as the next document. + let yaml = "Bare\ndocument\n...\n# No document\n...\n|\n%!PS-Adobe-2.0 # Not the first line\n" + let documents = try PureYAML.parseStream(yaml).map(\.value) + #expect(documents == [.string("Bare document"), .string("%!PS-Adobe-2.0 # Not the first line\n")]) + } + + @Test("Real content after a repeated `...` marker is still rejected") + func test_contentAfterRepeatedMarkerRejected() { + #expect(throws: PureYAML.Parsing.ParseError.self) { + _ = try PureYAML.parseStream("a\n...\n... x\n") + } + } +}