fix(parsing): reject content after a document end marker

A document-end marker takes no node on its own line: only a comment or
blank may follow. The scanner skipped the rest of the marker's line, so
content such as the 'invalid' in '... invalid' was silently dropped and
the document accepted.

scanDocumentMarker now distinguishes the two markers after skipping spaces:
a document start may carry the root node on its line and leaves it for the
main loop, while a document end rejects any real content (a comment or
blank still passes). A '...' not followed by an indicator boundary is not
a marker at all and remains an ordinary plain scalar, so '...junk' still
parses as content.

Measured against the official YAML test-suite, parse-level conformance
rises from 255/333 (76.6%) to 256/333 (76.9%) with no regression.
This commit is contained in:
Mihaela M 2026-06-27 17:56:37 +02:00
parent 27c423dbb9
commit fcba4e1f57
4 changed files with 54 additions and 14 deletions

View File

@ -23,6 +23,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- A `...` document-end marker no longer accepts content on its own line. Only a comment or blank
may follow the marker, so `... invalid` is now rejected; the scanner previously skipped the rest
of the line. A bare `...`, a trailing comment (`... # done`), and a `...` not followed by a
boundary (`...junk`, which is an ordinary plain scalar, not a marker) are unaffected. Lifts
official test-suite parse-level conformance from 255/333 (76.6%) to 256/333 (76.9%), with no
regression.
- A directive must now be followed by an explicit `---` document start. A stream that ends, or
reaches a `...` document-end marker, while a directive is still awaiting its document
(`%YAML 1.2` alone, or `%YAML 1.2` then `...`) is now rejected, as the YAML grammar requires a

View File

@ -23,6 +23,7 @@ public extension PureYAML.Parsing {
case malformedDirective(name: String, line: Int)
case duplicateDirective(name: String, line: Int)
case directiveWithoutDocument(line: Int)
case contentAfterDocumentEnd(line: Int)
public var description: String {
switch self {
@ -70,6 +71,8 @@ public extension PureYAML.Parsing {
"duplicate directive '\(name)' at line \(line)"
case let .directiveWithoutDocument(line):
"a directive must be followed by a document at line \(line)"
case let .contentAfterDocumentEnd(line):
"no content may follow a document end marker at line \(line)"
}
}
@ -119,6 +122,8 @@ public extension PureYAML.Parsing {
"duplicateDirective"
case .directiveWithoutDocument:
"directiveWithoutDocument"
case .contentAfterDocumentEnd:
"contentAfterDocumentEnd"
}
}
@ -136,7 +141,8 @@ public extension PureYAML.Parsing {
let .invalidEscape(_, line),
let .malformedDirective(_, line),
let .duplicateDirective(_, line),
let .directiveWithoutDocument(line):
let .directiveWithoutDocument(line),
let .contentAfterDocumentEnd(line):
line
case let .expectedNode(line, _),
let .expectedScalarKey(line, _),
@ -165,7 +171,8 @@ public extension PureYAML.Parsing {
.invalidEscape,
.malformedDirective,
.duplicateDirective,
.directiveWithoutDocument:
.directiveWithoutDocument,
.contentAfterDocumentEnd:
nil
case let .expectedNode(_, column),
let .expectedScalarKey(_, column),

View File

@ -11,7 +11,7 @@ extension PureYAML.Parsing.Scanner {
return false
}
if startsWith("---", state.reader), isIndicatorBoundary(state.reader.peek(offset: 3)) {
scanDocumentMarker(.documentStart, state: &state)
try scanDocumentMarker(.documentStart, state: &state)
resetForDocumentStart(&state)
return true
}
@ -21,7 +21,7 @@ extension PureYAML.Parsing.Scanner {
}
// A `...` ends a document, so a directive still awaiting its `---` never gets one.
try enforceDirectiveHasDocument(state)
scanDocumentMarker(.documentEnd, state: &state)
try scanDocumentMarker(.documentEnd, state: &state)
resetForDocumentEnd(&state)
return true
}
@ -34,24 +34,24 @@ extension PureYAML.Parsing.Scanner {
func scanDocumentMarker(
_ kind: PureYAML.Parsing.TokenKind,
state: inout State,
) {
) throws {
let start = state.reader.mark
state.reader.advance()
state.reader.advance()
state.reader.advance()
state.append(kind, mark: start, endMark: state.reader.mark)
// The document's root node may begin on the marker's own line (e.g. `--- >`, `--- foo`,
// `--- {a: 1}`, `--- !!str x`). After a document start, skip only the spaces and leave any
// real content for the main loop to scan; an empty rest-of-line or a comment is consumed.
// A document end (`...`) takes no same-line node, so its line is always skipped.
if case .documentStart = kind {
while state.reader.peek() == " " {
state.reader.advance()
}
if let next = state.reader.peek(), !isLineBreak(next), next != "#" {
while state.reader.peek() == " " {
state.reader.advance()
}
if let next = state.reader.peek(), !isLineBreak(next), next != "#" {
// The marker's own line carries real content. A document start may carry the root node
// here (e.g. `--- >`, `--- foo`, `--- {a: 1}`), so leave it for the main loop to scan.
if case .documentStart = kind {
state.isAtLineStart = false
return
}
// A document end (`...`) takes no same-line node; only a comment or blank may follow it.
throw PureYAML.Parsing.ParseError.contentAfterDocumentEnd(line: state.reader.mark.line)
}
skipLine(&state)
}

View File

@ -0,0 +1,27 @@
@testable import PureYAML
import Testing
@Suite("Content after a document end marker")
struct DocumentEndContentTests {
@Test("Real content on a `...` marker's line is rejected")
func test_contentAfterDocumentEndRejected() throws {
#expect(throws: PureYAML.Parsing.ParseError.self) {
try PureYAML.parse("---\nkey: value\n... invalid\n")
}
}
@Test("A bare `...`, or one followed only by a comment, is accepted", arguments: [
"---\nx\n...\n", // nothing after the marker
"---\nx\n... # comment\n", // only a comment follows
"---\nx\n... \n", // only trailing spaces follow
])
func test_bareDocumentEndAccepted(_ yaml: String) throws {
#expect(try PureYAML.parse(yaml) == .string("x"))
}
@Test("`...` not followed by a boundary is a plain scalar, not a marker")
func test_dotsWithoutBoundaryIsScalar() throws {
// `...junk` is not a document-end marker, so it continues the plain scalar `x`.
#expect(try PureYAML.parse("---\nx\n...junk\n") == .string("x ...junk"))
}
}