fix(parsing): reject an over-indented leading line in a block scalar
A block scalar's leading empty lines must not be more indented than its
first non-empty line, so a blank line wider than the content (a 5-space
blank above 2-space content, or 2,3-space blanks above 1-space content) is
malformed. The scanner buffered the leading blanks without comparing their
indentation to the content, folding such input into the value.
The scanner now records the widest leading blank and rejects the scalar
when it exceeds the content indentation, established for both auto-detected
and explicit indicators. Indentation is counted in leading spaces, not
tab-expanded width, so a tab on a blank line ('␣\t') is one space rather
than a jump to the next tab stop, keeping such valid block scalars intact.
Measured against the official YAML test-suite, parse-level conformance
rises from 270/333 (81.1%) to 273/333 (82.0%) with no regression.
This commit is contained in:
parent
c1dda8bbab
commit
f1db1344da
@ -23,6 +23,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- A block scalar leading blank line that is more indented than the content is now rejected. The
|
||||
spec forbids a leading empty line from containing more spaces than the first non-empty line, so a
|
||||
block scalar whose blank lines are wider than its content (`|` then a 5-space blank then 2-space
|
||||
content) no longer parses. Indentation is counted in spaces, not tab-expanded width, so a tab on
|
||||
a blank line (`␣\t`) is not treated as a deeper indent. Leading blanks within the content indent
|
||||
are unaffected. Lifts official test-suite parse-level conformance from 270/333 (81.1%) to
|
||||
273/333 (82.0%), with no regression.
|
||||
- A document marker line (`---` or `...`) inside a multi-line quoted scalar is now rejected. Such
|
||||
a line is ambiguous with a document boundary, so the spec forbids it within a scalar; the scanner
|
||||
previously folded it into the value. The marker must begin a line and be followed by a boundary
|
||||
|
||||
@ -4,6 +4,7 @@ extension PureYAML.Parsing.Scanner {
|
||||
let width = try scanIndentationWidth(&state)
|
||||
guard shouldProcessIndentation(state) else {
|
||||
preserveBlockScalarSurplusOnBlankLine(width: width, state: &state)
|
||||
recordPendingBlockScalarBlankIndent(width: width, state: &state)
|
||||
return
|
||||
}
|
||||
|
||||
@ -33,7 +34,7 @@ extension PureYAML.Parsing.Scanner {
|
||||
state: &state,
|
||||
)
|
||||
} else if width > current {
|
||||
applyIncreasedIndentation(width: width, start: start, end: end, state: &state)
|
||||
try applyIncreasedIndentation(width: width, start: start, end: end, state: &state)
|
||||
} else if width < current {
|
||||
try applyDedentation(width: width, start: start, end: end, state: &state)
|
||||
}
|
||||
@ -42,6 +43,27 @@ extension PureYAML.Parsing.Scanner {
|
||||
clearPendingBlockScalarIndentation(&state)
|
||||
}
|
||||
|
||||
/// Records a leading blank line's indentation while a block scalar's content indentation is
|
||||
/// still pending (before its first content line), so an over-indented leading line can be
|
||||
/// caught once the content indentation is established.
|
||||
func recordPendingBlockScalarBlankIndent(width _: Int, state: inout State) {
|
||||
guard state.pendingBlockScalarIndentation, state.blockScalarIndentation == nil else {
|
||||
return
|
||||
}
|
||||
// Compare leading spaces, not the tab-expanded width: a tab on a blank line is not
|
||||
// indentation, so `␣\t` counts as one space, not a jump to the next tab stop.
|
||||
state.pendingBlockScalarMaxBlankIndent = max(state.pendingBlockScalarMaxBlankIndent, state.lastLeadingSpaceCount)
|
||||
}
|
||||
|
||||
/// A block scalar's leading empty lines must not be more indented than its content, so reject
|
||||
/// the scalar if the widest leading blank exceeds the content indentation.
|
||||
func enforceLeadingBlankIndentation(contentIndent: Int, line: Int, state: State) throws {
|
||||
guard state.pendingBlockScalarMaxBlankIndent > contentIndent else {
|
||||
return
|
||||
}
|
||||
throw PureYAML.Parsing.ParseError.unexpectedIndentation(line: line)
|
||||
}
|
||||
|
||||
/// A whitespace-only line inside an active block scalar that is indented past the block's
|
||||
/// content indentation carries the surplus spaces as content (e.g. a 3-space line in a 2-space
|
||||
/// block contributes `" "`). The width is consumed before the line is recognized as blank, so
|
||||
@ -56,17 +78,24 @@ extension PureYAML.Parsing.Scanner {
|
||||
|
||||
func scanIndentationWidth(_ state: inout State) throws -> Int {
|
||||
var width = 0
|
||||
var leadingSpaces = 0
|
||||
var sawTab = false
|
||||
while let character = state.reader.peek() {
|
||||
if character == " " {
|
||||
width += 1
|
||||
if !sawTab {
|
||||
leadingSpaces += 1
|
||||
}
|
||||
state.reader.advance()
|
||||
} else if character == "\t" {
|
||||
sawTab = true
|
||||
width = nextTabStop(after: width)
|
||||
state.reader.advance()
|
||||
} else {
|
||||
return width
|
||||
break
|
||||
}
|
||||
}
|
||||
state.lastLeadingSpaceCount = leadingSpaces
|
||||
return width
|
||||
}
|
||||
|
||||
@ -118,6 +147,7 @@ extension PureYAML.Parsing.Scanner {
|
||||
guard width >= blockScalarIndentation else {
|
||||
throw PureYAML.Parsing.ParseError.unexpectedIndentation(line: marks.end.line)
|
||||
}
|
||||
try enforceLeadingBlankIndentation(contentIndent: blockScalarIndentation, line: marks.end.line, state: state)
|
||||
state.indentation.append(blockScalarIndentation)
|
||||
state.append(.indent(width: blockScalarIndentation), mark: marks.start, endMark: marks.end)
|
||||
state.blockScalarIndentation = blockScalarIndentation
|
||||
@ -141,12 +171,15 @@ extension PureYAML.Parsing.Scanner {
|
||||
start: PureYAML.Parsing.Mark,
|
||||
end: PureYAML.Parsing.Mark,
|
||||
state: inout State,
|
||||
) {
|
||||
state.indentation.append(width)
|
||||
state.append(.indent(width: width), mark: start, endMark: end)
|
||||
) throws {
|
||||
guard state.pendingBlockScalarIndentation else {
|
||||
state.indentation.append(width)
|
||||
state.append(.indent(width: width), mark: start, endMark: end)
|
||||
return
|
||||
}
|
||||
try enforceLeadingBlankIndentation(contentIndent: width, line: end.line, state: state)
|
||||
state.indentation.append(width)
|
||||
state.append(.indent(width: width), mark: start, endMark: end)
|
||||
state.blockScalarIndentation = width
|
||||
state.blockScalarContentPrefix = ""
|
||||
emitBufferedBlockScalarLeadingBlanks(&state)
|
||||
@ -190,6 +223,7 @@ extension PureYAML.Parsing.Scanner {
|
||||
state.pendingBlockScalarIndentation = false
|
||||
state.pendingBlockScalarIndentationIndicator = nil
|
||||
state.pendingBlockScalarLeadingBlankLines = 0
|
||||
state.pendingBlockScalarMaxBlankIndent = 0
|
||||
}
|
||||
|
||||
func closeIndentation(_ state: inout State) {
|
||||
|
||||
@ -61,6 +61,14 @@ extension PureYAML.Parsing.Scanner {
|
||||
var pendingBlockScalarIndentation = false
|
||||
var pendingBlockScalarIndentationIndicator: Int?
|
||||
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
|
||||
/// content, so this is compared to the content indentation once that is known.
|
||||
var pendingBlockScalarMaxBlankIndent = 0
|
||||
// The count of leading space characters (before any tab) from the most recent indentation
|
||||
// scan. Indentation is measured in spaces, so this, not the tab-expanded width, is what the
|
||||
// leading-blank comparison uses: a tab is not indentation.
|
||||
var lastLeadingSpaceCount = 0
|
||||
var blockScalarIndentation: Int?
|
||||
var blockScalarContentPrefix = ""
|
||||
/// Whether a real token has been scanned on the current physical line. Reset when the scan
|
||||
|
||||
27
Tests/BlockScalarLeadingBlankTests.swift
Normal file
27
Tests/BlockScalarLeadingBlankTests.swift
Normal file
@ -0,0 +1,27 @@
|
||||
@testable import PureYAML
|
||||
import Testing
|
||||
|
||||
@Suite("Block scalar leading blank indentation")
|
||||
struct BlockScalarLeadingBlankTests {
|
||||
@Test("A leading blank line more indented than the content is rejected", arguments: [
|
||||
"block scalar: >\n \n \n \n invalid\n", // blanks at 2,3 spaces over content at 1
|
||||
"---\nblock scalar: |\n \n more spaces\n are invalid\n", // a 5-space blank over content at 2
|
||||
])
|
||||
func test_overIndentedLeadingBlankRejected(_ yaml: String) throws {
|
||||
#expect(throws: PureYAML.Parsing.ParseError.self) {
|
||||
try PureYAML.parse(yaml)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Leading blanks within the content indent, including a tab, stay valid")
|
||||
func test_validLeadingBlanks() throws {
|
||||
#expect(try PureYAML.parse("x: |\n\n content\n") == .mapping(.init([
|
||||
.init(key: "x", value: .string("\ncontent\n")),
|
||||
])))
|
||||
#expect(try PureYAML.parse("x: |\n \n content\n") == .mapping(.init([
|
||||
.init(key: "x", value: .string("\ncontent\n")),
|
||||
])))
|
||||
// A tab on a blank line is not indentation, so `␣\t` is one space, not a tab-stop jump.
|
||||
_ = try PureYAML.parse("- >\n \t\n detected\n")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user