diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b4ef04..237ae80 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 +- 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 diff --git a/Sources/Parsing/ParseError.swift b/Sources/Parsing/ParseError.swift index 01d8783..3d3bae1 100644 --- a/Sources/Parsing/ParseError.swift +++ b/Sources/Parsing/ParseError.swift @@ -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), diff --git a/Sources/Parsing/Scanner.Directives.swift b/Sources/Parsing/Scanner.Directives.swift index 7c5f19f..a25c684 100644 --- a/Sources/Parsing/Scanner.Directives.swift +++ b/Sources/Parsing/Scanner.Directives.swift @@ -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) } diff --git a/Tests/DocumentEndContentTests.swift b/Tests/DocumentEndContentTests.swift new file mode 100644 index 0000000..954cd23 --- /dev/null +++ b/Tests/DocumentEndContentTests.swift @@ -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")) + } +}