From a5b8986d5e4921cf11345872bfade8dd987e0ed5 Mon Sep 17 00:00:00 2001 From: Mihaela M Date: Sun, 28 Jun 2026 17:42:38 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 10 +++++ Sources/Parsing/Scanner.Indentation.swift | 9 ++++ Sources/Parsing/Scanner.Scalars.swift | 10 +++++ Sources/Parsing/Scanner.swift | 3 ++ Tests/RootBlockScalarIndentationTests.swift | 46 +++++++++++++++++++++ 5 files changed, 78 insertions(+) create mode 100644 Tests/RootBlockScalarIndentationTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index d0378cb..47c3100 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Sources/Parsing/Scanner.Indentation.swift b/Sources/Parsing/Scanner.Indentation.swift index cbdf983..d92944d 100644 --- a/Sources/Parsing/Scanner.Indentation.swift +++ b/Sources/Parsing/Scanner.Indentation.swift @@ -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) { diff --git a/Sources/Parsing/Scanner.Scalars.swift b/Sources/Parsing/Scanner.Scalars.swift index b9dabfb..8520de7 100644 --- a/Sources/Parsing/Scanner.Scalars.swift +++ b/Sources/Parsing/Scanner.Scalars.swift @@ -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) { diff --git a/Sources/Parsing/Scanner.swift b/Sources/Parsing/Scanner.swift index 27d1922..5998d55 100644 --- a/Sources/Parsing/Scanner.swift +++ b/Sources/Parsing/Scanner.swift @@ -61,6 +61,9 @@ extension PureYAML.Parsing.Scanner { var validImplicitIndentation: Set = [] 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 diff --git a/Tests/RootBlockScalarIndentationTests.swift b/Tests/RootBlockScalarIndentationTests.swift new file mode 100644 index 0000000..724dcd5 --- /dev/null +++ b/Tests/RootBlockScalarIndentationTests.swift @@ -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")), + ]))) + } +}