PureYAMLStreaming/Tests/PureYAMLStreamingTests/DocumentScannerTests.swift
Mihaela Mihaljevic b4253fd289 fix: drive document boundary scanning off PureYAML's token scanner
Replace the line-based --- heuristic with a scan over PureYAML's
resumable token scanner, so a --- inside a quoted scalar can no longer
misfire as a document boundary and directives stay attached to the
document they introduce. Add a differential suite that checks the new
scanner against the old line-based reference across generated streams
and the full Geekbench corpus.
2026-07-26 20:59:39 +02:00

163 lines
7.1 KiB
Swift

import Foundation
import PureYAML
import Testing
@testable import PureYAMLStreaming
@Suite("Resumable document scanner")
struct DocumentScannerTests {
/// Grammar-valid streams the scanner must split without loss for any
/// chunking. Each exercises a distinct boundary shape: explicit markers,
/// no markers, comments around markers, marker with trailing comment,
/// block scalar bodies, explicit document ends, empty documents, CRLF,
/// a non-newline break character, comment-only input, and empty input.
static let chunkingInputs: [String] = [
"---\na: 1\n---\nb: 2\n",
"name: only\n",
"# note\n---\na: 1\n",
"---\na: 1\n# trailing\n---\nb: 2\n",
"a: 1\n--- # comment\nb: 2\n",
"a: |\n ---\n not a boundary\n---\nb: 2\n",
"a: 1\n...\nb: 2\n",
"---\na: 1\n...\n---\nb: 2\n",
"---\n---\n",
"a: 1\r\n---\r\nb: 2\r\n",
"a: \"x\u{2028} y\"\n---\nb: 2\n",
"# only comments\n\n",
"",
]
@Test("splits explicit documents at --- markers")
func splitsExplicitDocuments() throws {
let segments = try rewiredSegments("---\na: 1\n---\nb: 2\n", chunkSize: 1024)
#expect(segments == ["---\na: 1\n", "---\nb: 2\n"])
}
@Test("emits identical lossless segments for any chunking", arguments: chunkingInputs)
func chunkingDoesNotChangeSegments(input: String) throws {
let wholeFeed = try rewiredSegments(input, chunkSize: max(input.unicodeScalars.count, 1))
#expect(wholeFeed.joined() == input)
for chunkSize in [1, 2, 3, 7, 64] {
let segments = try rewiredSegments(input, chunkSize: chunkSize)
#expect(segments == wholeFeed, "chunk size \(chunkSize) changed the segments for: \(input.debugDescription)")
}
}
@Test("attaches leading directives to their document")
func attachesLeadingDirectives() throws {
let input = "%YAML 1.2\n---\na: 1\n"
#expect(try rewiredSegments(input, chunkSize: 3) == [input])
let documents = try PureYAMLStreaming.Parser(chunkSize: 3).collectDocuments(data: Data(input.utf8))
#expect(documents.count == 1)
#expect(intValue("a", in: documents[0].value) == 1)
}
@Test("splits before directives that follow an explicit document end")
func splitsBeforeDirectivesAfterDocumentEnd() throws {
let input = "---\na: 1\n...\n%YAML 1.2\n---\nb: 2\n"
let segments = try rewiredSegments(input, chunkSize: 5)
#expect(segments == ["---\na: 1\n...\n", "%YAML 1.2\n---\nb: 2\n"])
let documents = try PureYAMLStreaming.Parser(chunkSize: 5).collectDocuments(data: Data(input.utf8))
#expect(documents.count == 2)
}
@Test("keeps a directive without a preceding document end in the erroneous segment")
func keepsInvalidDirectiveInSegment() throws {
// Batch parsing rejects a directive that follows content without an
// explicit `...`, so the splitter must not carve out a boundary that
// would make both halves individually valid.
let input = "a: 1\n%YAML 1.2\n---\nb: 2\n"
#expect(throws: (any Error).self) {
try PureYAML.Parsing.Parser().parseStream(input)
}
let segments = try rewiredSegments(input, chunkSize: 4)
#expect(segments == [input])
#expect(throws: (any Error).self) {
try PureYAML.Parsing.Parser().parseStream(segments[0])
}
}
@Test("rejects a grammar-internal --- like batch parsing instead of splitting")
func rejectsDocumentMarkerInsideQuotedScalar() throws {
// The old line-based heuristic cut this quoted scalar in half. The
// grammar rejects a document indicator inside a quoted scalar, so the
// token-driven scanner must surface the same rejection batch parsing
// does, not fabricate a boundary.
let input = "a: \"x\n--- y\"\nb: 2\n"
#expect(throws: (any Error).self) {
try PureYAML.Parsing.Parser().parseStream(input)
}
#expect(throws: (any Error).self) {
_ = try rewiredSegments(input, chunkSize: 4)
}
}
@Test("emits a boundary confirmable only at end of input")
func emitsBoundaryAtEndOfInput() throws {
// A trailing `---` line without a newline is a document start only if
// no fourth `-` follows, which end of input alone can confirm. The old
// scanner dropped the first document here.
var scanner = PureYAMLStreaming.ResumableDocumentScanner()
let consumed = try scanner.consume("a: 1\n---")
let finished = try scanner.finish()
#expect(consumed + finished == ["a: 1\n", "---"])
let documents = try PureYAMLStreaming.Parser(chunkSize: 3).collectDocuments(data: Data("a: 1\n---".utf8))
#expect(documents.count == 2)
#expect(intValue("a", in: documents[0].value) == 1)
#expect(documents[1].value == .null)
}
@Test("preserves CRLF line endings across boundaries")
func preservesCRLF() throws {
let segments = try rewiredSegments("a: 1\r\n---\r\nb: 2\r\n", chunkSize: 2)
#expect(segments == ["a: 1\r\n", "---\r\nb: 2\r\n"])
}
@Test("counts non-newline break characters like the token scanner")
func countsUnicodeBreaks() throws {
// U+2028 is a line break to the scanner's reader, so the boundary
// line's index must account for it when mapping back to text. The
// continuation after the break is indented, as the grammar requires
// for a multi-line quoted scalar.
let segments = try rewiredSegments("a: \"x\u{2028} y\"\n---\nb: 2\n", chunkSize: 3)
#expect(segments == ["a: \"x\u{2028} y\"\n", "---\nb: 2\n"])
}
@Test("lumps a bare document after an explicit end into one segment")
func lumpsBareDocumentAfterEnd() throws {
let input = "a: 1\n...\nb: 2\n"
#expect(try rewiredSegments(input, chunkSize: 4) == [input])
let documents = try PureYAMLStreaming.Parser(chunkSize: 4).collectDocuments(data: Data(input.utf8))
#expect(documents.count == 2)
}
@Test("returns nothing for empty input")
func returnsNothingForEmptyInput() throws {
var scanner = PureYAMLStreaming.ResumableDocumentScanner()
#expect(try scanner.consume("") == [])
#expect(try scanner.finish() == [])
}
@Test("returns the raw remainder for comment-only input")
func returnsRemainderForCommentOnlyInput() throws {
let input = "# only\n\n"
#expect(try rewiredSegments(input, chunkSize: 2) == [input])
let documents = try PureYAMLStreaming.Parser(chunkSize: 2).collectDocuments(data: Data(input.utf8))
#expect(documents.isEmpty)
}
@Test("wraps document sources in streaming tokens")
func wrapsDocumentSourcesInStreamingTokens() throws {
var scanner = PureYAMLStreaming.ResumableDocumentScanner()
var tokens: [PureYAMLStreaming.StreamingToken] = []
tokens += try scanner.consumeToken("---\na: 1\n---\n")
tokens += try scanner.finish().map(PureYAMLStreaming.StreamingToken.documentSource)
#expect(tokens == [.documentSource("---\na: 1\n"), .documentSource("---\n")])
}
}