From 5c34ebd8b6944569cb8dfb39c30d375c649a5f7b Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Wed, 24 Jun 2026 22:09:14 +0200 Subject: [PATCH] fix(parsing): reject a misindented multi-line quoted scalar continuation A quoted scalar's continuation line must be indented past the parent block, so '"a' / 'b"' (continuation in column 0) and '"bar' / '\tbaz' (tab indentation) are malformed; libyaml accepts both. Record the indentation error during the continuation scan but defer raising it until the closing quote is found. An unterminated quote reaches end of input first and still reports the missing quote (the root cause) rather than the indentation symptom, preserving the multi-error diagnostic. Surpasses libyaml on QB6E and DK95/01. Whole-suite conformance is now 402/402 with zero recorded gaps; canonical events stay 308/308. --- CHANGELOG.md | 7 +++++ .../Parsing/LibYAML.Scanner.FlowScalar.swift | 26 +++++++++++++++++-- Tests/Fixtures/yaml-test-suite-manifest.yaml | 8 +++--- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8666a6..f271850 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Reject an under-indented or tab-indented continuation in a multi-line quoted + scalar. A quoted scalar's continuation must be indented past the parent block, so + `"a`\n`b"` (continuation in column 0) and `"bar`\n`\tbaz"` (tab indentation) are + malformed. The error is deferred until the closing quote is found, so an + unterminated quote still reports the missing quote (its root cause) rather than + the indentation symptom. Surpasses libyaml on QB6E and DK95/01, bringing + whole-suite conformance to 402/402. - Reject a tab used to indent flow-collection content. A tab is never indentation, so flow content reached through a tab below the continuation indent (`- [`\n `\tfoo`) is malformed, even though the tab inflates the column. A tab at or past diff --git a/Sources/Parsing/LibYAML.Scanner.FlowScalar.swift b/Sources/Parsing/LibYAML.Scanner.FlowScalar.swift index 992fb6c..d4f6f04 100644 --- a/Sources/Parsing/LibYAML.Scanner.FlowScalar.swift +++ b/Sources/Parsing/LibYAML.Scanner.FlowScalar.swift @@ -28,6 +28,11 @@ extension PureYAML.Parsing.LibYAML.Scanner { reader.skip() var state = FlowState() let quote: Unicode.Scalar = single ? "'" : "\"" + let indent = indentEngine.indent + 1 + // A continuation indented below `indent` is an error, but it is recorded + // and only raised once the quote is known to close; an unterminated quote + // reaches end of input first and reports that instead. + var indentError: PureYAML.Parsing.LibYAML.ScannerError? while true { if reader.column == 0, documentMarkerAhead() { throw flowError("found unexpected document indicator", startMark) @@ -39,10 +44,13 @@ extension PureYAML.Parsing.LibYAML.Scanner { if reader.peek() == quote { break } - consumeFlowBreaks(&state) + consumeFlowBreaks(&state, indent: indent, quote: quote, startMark: startMark, error: &indentError) foldFlow(&state) } reader.skip() + if let indentError { + throw indentError + } return PureYAML.Parsing.LibYAML.Token( type: .scalar(value: state.string, style: single ? .singleQuoted : .doubleQuoted), startMark: startMark, @@ -139,9 +147,18 @@ extension PureYAML.Parsing.LibYAML.Scanner { /// Consume blanks and line breaks between content, recording pending /// whitespace and folded breaks. - private mutating func consumeFlowBreaks(_ state: inout FlowState) { + private mutating func consumeFlowBreaks( + _ state: inout FlowState, + indent: Int, + quote: Unicode.Scalar, + startMark: PureYAML.Parsing.Mark, + error: inout PureYAML.Parsing.LibYAML.ScannerError?, + ) { while Classify.isBlank(reader.peek()) || Classify.isBreak(reader.peek()) { if Classify.isBlank(reader.peek()) { + if error == nil, state.leadingBlanks, reader.column < indent, Classify.isTab(reader.peek()), !blankLineAhead() { + error = flowError("found a tab character that violates indentation", startMark) + } if !state.leadingBlanks, let blank = reader.peek() { state.whitespaces.unicodeScalars.append(blank) } @@ -156,6 +173,11 @@ extension PureYAML.Parsing.LibYAML.Scanner { state.trailingBreaks += "\n" } } + // The continuation content must be indented at least `indent`; a less + // indented (non-closing, non-empty) line is malformed. + if error == nil, state.leadingBlanks, let next = reader.peek(), next != quote, !Classify.isBreakZ(next), reader.column < indent { + error = flowError("found wrong indentation in a multi-line quoted scalar", startMark) + } } /// Fold the pending breaks or whitespace into the value. diff --git a/Tests/Fixtures/yaml-test-suite-manifest.yaml b/Tests/Fixtures/yaml-test-suite-manifest.yaml index 4053908..aec0eda 100644 --- a/Tests/Fixtures/yaml-test-suite-manifest.yaml +++ b/Tests/Fixtures/yaml-test-suite-manifest.yaml @@ -10,8 +10,8 @@ source: # oracle: json if in.json present, else none # status: passing (PureYAML conformant today) | gap (not yet conformant) total: 402 -passing: 400 -gap: 2 +passing: 402 +gap: 0 cases: - id: "229Q" name: "Spec Example 2.4. Sequence of Mappings" @@ -1506,7 +1506,7 @@ cases: localPath: yaml-test-suite/DK95/01/in.yaml expected: reject oracle: json - status: gap + status: passing byteCount: 16 lineCount: 2 - id: "DK95/02" @@ -2506,7 +2506,7 @@ cases: localPath: yaml-test-suite/QB6E/in.yaml expected: reject oracle: none - status: gap + status: passing byteCount: 20 lineCount: 4 - id: "QF4Y"