PureYAML/Sources/Parsing/Scanner.Directives.swift
Mihaela Mihaljevic 1875d66d46 fix(parsing): parse multi-line and block scalars at the document root
Model libyaml's parser->indent (the enclosing block-collection column, -1 at the bare root) separately from per-line indentation, via a blockIndent stack that rolls on block-sequence entries and mapping keys, unrolls at each block-line start, and resets at document boundaries. Plain-scalar continuation folding now compares against blockIndent, so a root scalar folds its column-0 continuations (single break to space, blank line to newline, per-line whitespace stripped) while a scalar inside a collection still ends at a sibling line. The ':' value indicator deliberately does not roll the block indent.

A '---' document-start marker may now be followed by the document's root node on the same line ('--- foo', '--- [1, 2]', '--- |', '--- >'), and a block scalar whose body sits at the document root's column 0 is captured and ends at the next '---'/'...' marker.

Flips 33 official YAML test suite cases (338/402): 18 false-rejects and 12 value mismatches now match, and 3 invalid inputs (9KBC, ZVH3, 9MQT/01) are now correctly rejected. M7A3 and NB6Z now parse but with mismatched values (separate multi-doc and tab-folding gaps) and stay tracked. Adds root multi-line plain scalar regression tests; updates manifest, fixtures, counts, and roadmap.
2026-06-23 22:56:33 +02:00

249 lines
11 KiB
Swift

extension PureYAML.Parsing.Scanner {
func scanDirectiveOrDocumentMarker(_ state: inout State) throws -> Bool {
if state.flowDepth > 0, state.reader.mark.column == 1, isDocumentMarkerHere(state) {
// A `---` or `...` document marker at column 0 is recognized regardless
// of flow nesting (scanner.c:908-924 keys only on `mark.column == 0`),
// but a flow collection may not span a document boundary. libyaml reports
// the unterminated flow at the parser layer; reject it here.
throw PureYAML.Parsing.ParseError.unexpectedToken(
expected: "flow collection close before document marker",
actual: "document marker",
line: state.reader.mark.line,
column: state.reader.mark.column,
)
}
guard state.indentation.last == 0, state.flowDepth == 0 else {
return false
}
if state.reader.peek() == "%" {
// A directive section is only valid at the start of the stream or right
// after a `...` document-end suffix. Once a document has started (via
// `---`), a `%YAML`/`%TAG` line that itself introduces a new explicit
// document (its next line is a `---`/`...` marker) begins a second
// directive section that the YAML grammar's l-document-suffix rule
// forbids without an intervening `...`. This is the MUS6/01 case
// (`%YAML`/`---`/`%YAML`/`---`), distinct from a `%`-prefixed line that
// is part of the current document body.
if state.hasDocumentStartMarker, isDirectiveLineHere(state), directiveLineIntroducesDocument(state) {
throw PureYAML.Parsing.ParseError.directiveWithoutDocument(line: state.reader.mark.line)
}
// `%` introduces a directive only in directive position: before any
// document content and before the `---` that starts a document. After
// content, after `---`, or anywhere inside a node, `%` is plain text,
// so defer to ordinary token scanning by returning false. This mirrors
// the C scanner, which only enters `yaml_parser_scan_directive` from
// the start-of-line directive context, never inside a node or scalar.
guard !state.hasDocumentContent, !state.hasDocumentStartMarker else {
return false
}
try scanDirective(&state)
return true
}
if state.reader.peek() == "#" {
return false
}
if startsWith("---", state.reader), isIndicatorBoundary(state.reader.peek(offset: 3)) {
scanDocumentMarker(.documentStart, state: &state)
resetForDocumentStart(&state)
return true
}
if startsWith("...", state.reader), isIndicatorBoundary(state.reader.peek(offset: 3)) {
// A `...` end marker terminates the directive section it follows just
// as a `---` would, except that no document was started, so a pending
// directive here was never attached to a document and is rejected.
if state.hasPendingDirective {
throw PureYAML.Parsing.ParseError.directiveWithoutDocument(line: state.reader.mark.line)
}
scanDocumentMarker(.documentEnd, state: &state)
resetForDocumentEnd(&state)
return true
}
return false
}
/// True when, after the directive line under the reader, the following line at
/// column 0 is a `---`/`...` document marker, i.e. the directive line opens a new
/// explicit document. Used to tell a genuine second directive section (MUS6/01)
/// apart from a `%`-prefixed line that belongs to the current document body.
func directiveLineIntroducesDocument(_ state: State) -> Bool {
var probe = state.reader
while let character = probe.peek(), !isLineBreak(character) {
probe.advance()
}
guard isLineBreak(probe.peek()) else {
return false
}
probe.advance()
guard probe.mark.column == 1 else {
return false
}
return (startsWith3(probe, "-") || startsWith3(probe, "."))
&& isIndicatorBoundary(probe.peek(offset: 3))
}
private func startsWith3(_ reader: PureYAML.Parsing.Reader, _ character: Character) -> Bool {
reader.peek(offset: 0) == character
&& reader.peek(offset: 1) == character
&& reader.peek(offset: 2) == character
}
/// True when the reader sits on a recognized directive line (`%YAML` or `%TAG`).
/// Reserved/unknown `%...` directives are not treated as a hard error here; only
/// the two grammar-significant directives gate the document-suffix rule.
func isDirectiveLineHere(_ state: State) -> Bool {
startsWith("%YAML", state.reader) || startsWith("%TAG", state.reader)
}
/// True when the reader sits on a `---` or `...` document marker, i.e. three
/// identical marker characters followed by a blank, line break, or end of input.
func isDocumentMarkerHere(_ state: State) -> Bool {
(startsWith("---", state.reader) || startsWith("...", state.reader))
&& isIndicatorBoundary(state.reader.peek(offset: 3))
}
func scanDocumentMarker(
_ kind: PureYAML.Parsing.TokenKind,
state: inout State,
) {
let start = state.reader.mark
state.reader.advance()
state.reader.advance()
state.reader.advance()
state.append(kind, mark: start, endMark: state.reader.mark)
// A `---` start marker may be followed by the document's root node on the
// same line (`--- foo`, `--- |`, `--- [1, 2]`), matching libyaml, which scans
// the marker as a token and keeps scanning the line. Skip the inline spaces
// and, when real content follows, hand it to the ordinary token scanner; a
// trailing comment or empty remainder is skipped as before. A `...` end
// marker carries no node, so its line (only an optional comment) is skipped.
guard case .documentStart = kind else {
skipLine(&state)
return
}
skipSeparation(&state)
guard let character = state.reader.peek(), !isLineBreak(character), character != "#" else {
skipLine(&state)
return
}
state.isAtLineStart = false
}
func resetForDocumentStart(_ state: inout State) {
state.hasDocumentContent = false
state.hasDocumentStartMarker = true
state.isDocumentClosed = false
state.sawYAMLDirective = false
state.hasPendingDirective = false
resetBlockIndent(&state)
}
func resetForDocumentEnd(_ state: inout State) {
state.hasDocumentContent = false
state.hasDocumentStartMarker = false
state.isDocumentClosed = true
state.sawYAMLDirective = false
state.hasPendingDirective = false
resetBlockIndent(&state)
}
/// Mirror libyaml unrolling the block indent to -1 at a document boundary so a
/// new document's root scalar starts with no enclosing block collection.
func resetBlockIndent(_ state: inout State) {
state.blockIndent = -1
state.blockIndents.removeAll(keepingCapacity: true)
}
func scanDirective(_ state: inout State) throws {
let start = state.reader.mark
let line = state.reader.consume { character in
!isLineBreak(character)
}
let fields = directiveFields(line)
guard let name = fields.first else {
return
}
switch name {
case "%YAML":
try scanYAMLDirective(fields: fields, start: start, state: &state)
case "%TAG":
scanTagDirective(fields: fields, state: &state)
default:
// Reserved or unknown directives are ignored with a warning per the
// spec; PureYAML accepts and discards them rather than erroring.
break
}
state.hasPendingDirective = true
}
/// Splits a directive line into its `%name` and parameter fields, dropping
/// any trailing comment. Fields are separated by spaces or tabs, matching the
/// C scanner's blank handling around directive values.
private func directiveFields(_ line: String) -> [String] {
var fields: [String] = []
var current = ""
for character in line {
// A `#` at a field boundary (i.e. preceded by whitespace or the line
// start) begins a trailing comment that ends the directive's fields.
if character == "#", current.isEmpty {
break
}
if character == " " || character == "\t" {
if !current.isEmpty {
fields.append(current)
current = ""
}
continue
}
current.append(character)
}
if !current.isEmpty {
fields.append(current)
}
return fields
}
private func scanYAMLDirective(
fields: [String],
start: PureYAML.Parsing.Mark,
state: inout State,
) throws {
// A `%YAML` directive carries exactly one version field of the form
// `major.minor`. A duplicate within one document, extra trailing fields,
// or an unsupported major version is an error; a higher 1.x minor is
// accepted (treated as 1.2 with a warning) so future minors keep parsing.
guard !state.sawYAMLDirective else {
throw PureYAML.Parsing.ParseError.incompatibleYAMLDirective(line: start.line)
}
guard fields.count == 2, isSupportedYAMLVersion(fields[1]) else {
throw PureYAML.Parsing.ParseError.incompatibleYAMLDirective(line: start.line)
}
state.sawYAMLDirective = true
}
private func isSupportedYAMLVersion(_ version: String) -> Bool {
let components = version.split(separator: ".", omittingEmptySubsequences: false)
guard components.count == 2 else {
return false
}
let major = components[0]
let minor = components[1]
guard !major.isEmpty, !minor.isEmpty else {
return false
}
guard major.allSatisfy(\.isNumber), minor.allSatisfy(\.isNumber) else {
return false
}
return major == "1"
}
private func scanTagDirective(
fields: [String],
state: inout State,
) {
guard fields.count >= 3 else {
return
}
state.tagHandles[fields[1]] = fields[2]
}
}