fix(parsing): fold root block scalars with column-0 content

A block scalar that is the document's own root node (`>`, `|`, or after
`---` on the same line) may begin its content at column 0: the document node's
indentation is -1, so column-0 content is still more indented than its parent.
The scanner read the base level as 0 and required content strictly deeper than
it, so the lines fell through indentation handling and were mis-scanned as a
stray plain scalar after the document, raising "expected document boundary".

A root block scalar and an under-indented nested one (`- |` then unindented
content) present identical indentation state at the first content line, so the
two cannot be told apart there. Capture the distinction at header-scan time
instead: a root block scalar has the base indentation and no mapping value,
explicit key, block-entry implicit indent, or flow context introducing it on
its line. Only then is its parent the document; a sequence item or mapping
value keeps its parent's indent as the floor, so its under-indented content is
still rejected, unchanged.

Raises yaml-test-suite parse-level to 291/333 (87.4%) and value-level to
173/191 (90.6%). A regression suite covers the root cases (including a leading
'#' as literal content) and the nested under-indented cases that must error.
410 tests pass.
This commit is contained in:
Mihaela M 2026-06-28 17:42:38 +02:00
parent 8f87a1ecd0
commit a5b8986d5e
5 changed files with 78 additions and 0 deletions

View File

@ -84,6 +84,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- A block scalar that is the document's own root node now folds or keeps content that begins at
column 0, e.g. `>` or `--- |` followed by unindented lines. The document node's indentation is -1,
so its content at column 0 is still more indented than its parent, but the scanner read the base
level as 0 and required content to be strictly deeper, leaving the lines to be mis-scanned as a
stray scalar after the document. The scanner now recognizes a root block scalar (one with no
enclosing mapping value, explicit key, block-entry indent, or flow context on its header line) and
establishes its content indentation at column 0. A non-root block scalar keeps its parent's indent
as the floor, so an under-indented sequence item or mapping value is still rejected. Raises
yaml-test-suite parse-level conformance to 291/333 (87.4%) and value-level to 173/191 (90.6%).
- 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

View File

@ -35,6 +35,14 @@ extension PureYAML.Parsing.Scanner {
)
} else if width > current {
try applyIncreasedIndentation(width: width, start: start, end: end, state: &state)
} else if state.pendingBlockScalarIndentation, state.pendingBlockScalarIsRootNode, width == current {
// A root block scalar (`>`/`|` as the whole document) has the document node as its
// parent, whose indentation is -1, so content at column 0 (width == current == 0) is
// still more indented than its parent and must establish the block scalar's content
// indentation here, where `width > current` (with the base level read as 0) cannot fire.
// A non-root block scalar (a sequence item or mapping value) keeps its parent's indent as
// the floor, so its under-indented content still falls through to an error, unchanged.
try applyIncreasedIndentation(width: width, start: start, end: end, state: &state)
} else if width < current {
try applyDedentation(width: width, start: start, end: end, state: &state)
}
@ -224,6 +232,7 @@ extension PureYAML.Parsing.Scanner {
state.pendingBlockScalarIndentationIndicator = nil
state.pendingBlockScalarLeadingBlankLines = 0
state.pendingBlockScalarMaxBlankIndent = 0
state.pendingBlockScalarIsRootNode = false
}
func closeIndentation(_ state: inout State) {

View File

@ -88,6 +88,16 @@ extension PureYAML.Parsing.Scanner {
)
state.pendingBlockScalarIndentation = true
state.pendingBlockScalarIndentationIndicator = indentationIndicator
// A root block scalar (the document's own node) has no enclosing collection: it sits at the
// base indentation with no mapping value, explicit key, block-entry implicit indent, or flow
// context introducing it on this line. Only then is its parent the document (indentation -1),
// letting its content begin at column 0. A sequence item or mapping value keeps its parent's
// indent as the floor instead.
state.pendingBlockScalarIsRootNode = state.indentation == [0]
&& !state.sawMappingValueOnLine
&& !state.pendingExplicitKey
&& state.validImplicitIndentation.isEmpty
&& state.flowDepth == 0
}
func scanBlockScalarContent(_ state: inout State) {

View File

@ -61,6 +61,9 @@ extension PureYAML.Parsing.Scanner {
var validImplicitIndentation: Set<Int> = []
var pendingBlockScalarIndentation = false
var pendingBlockScalarIndentationIndicator: Int?
/// Whether the pending block scalar is the document's own root node (no enclosing collection),
/// so its content may begin at column 0 (the document parent's indentation being -1).
var pendingBlockScalarIsRootNode = false
var pendingBlockScalarLeadingBlankLines = 0
/// The widest indentation seen on a block scalar's leading blank lines (before its first
/// content line). The spec forbids a leading empty line from being more indented than the

View File

@ -0,0 +1,46 @@
@testable import PureYAML
import Testing
/// A block scalar that is the document's own root node has the document as its parent, whose
/// indentation is -1, so its content may begin at column 0. A block scalar nested in a collection
/// keeps its parent's indentation as the floor, so column-0 content there is under-indented and stays
/// an error. These two sit on the same scanner state at the first content line, distinguished only by
/// whether a mapping value, explicit key, block entry, or flow context introduced the scalar.
@Suite("Root block scalar indentation")
struct RootBlockScalarIndentationTests {
@Test("A root block scalar folds content that begins at column 0")
func test_rootFoldedAtColumnZero() throws {
#expect(try PureYAML.parse(">\nline1\nline2\n") == .string("line1 line2\n"))
#expect(try PureYAML.parse("--- >\nline1\nline2\n") == .string("line1 line2\n"))
}
@Test("A root literal block keeps line breaks for content at column 0")
func test_rootLiteralAtColumnZero() throws {
#expect(try PureYAML.parse("|\nline1\nline2\n") == .string("line1\nline2\n"))
#expect(try PureYAML.parse("--- |\nline1\nline2\n") == .string("line1\nline2\n"))
}
@Test("In a block scalar at column 0, a leading '#' is literal content, not a comment")
func test_rootBlockScalarHashIsLiteral() throws {
#expect(try PureYAML.parse(">\nline1\n# no comment\nline3\n") == .string("line1 # no comment line3\n"))
}
@Test("A non-root block scalar with content at column 0 is still under-indented and rejected")
func test_nestedUnderIndentedStillRejected() {
// A sequence item value: its parent is the sequence at indent 0, so content must be indented.
#expect(throws: PureYAML.Parsing.ParseError.self) {
try PureYAML.parse("- |\ncontent\n")
}
// A mapping value: same floor, so unindented content is not the block scalar's body.
#expect(throws: PureYAML.Parsing.ParseError.self) {
try PureYAML.parse("key: |\ncontent\n")
}
}
@Test("A nested block scalar with properly indented content is unaffected")
func test_nestedIndentedStillWorks() throws {
#expect(try PureYAML.parse("key: |\n content\n") == .mapping(.init([
.init(key: "key", value: .string("content\n")),
])))
}
}