diff --git a/CHANGELOG.md b/CHANGELOG.md index cd9d29e..297398b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Sources/Parsing/Scanner.Indentation.swift b/Sources/Parsing/Scanner.Indentation.swift index b98a08b..cbdf983 100644 --- a/Sources/Parsing/Scanner.Indentation.swift +++ b/Sources/Parsing/Scanner.Indentation.swift @@ -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) { diff --git a/Sources/Parsing/Scanner.swift b/Sources/Parsing/Scanner.swift index 43339dd..d7a582a 100644 --- a/Sources/Parsing/Scanner.swift +++ b/Sources/Parsing/Scanner.swift @@ -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 diff --git a/Tests/BlockScalarLeadingBlankTests.swift b/Tests/BlockScalarLeadingBlankTests.swift new file mode 100644 index 0000000..47cff17 --- /dev/null +++ b/Tests/BlockScalarLeadingBlankTests.swift @@ -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") + } +}