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.
249 lines
11 KiB
Swift
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]
|
|
}
|
|
}
|