Tighten error detection per the YAML 1.2 spec across four rules: (1) a tab may never supply block-structure indentation (line-start indentation, block-scalar indentation, and separation before a nested block node are rejected; a tab separating an indicator from a scalar value stays valid); (2) a ---/... document marker inside an open quoted scalar or flow collection is rejected instead of silently ending the document; (3) a %YAML/%TAG line that opens a second explicit document with no intervening ... is rejected; (4) an anchor cannot be attached to an alias node. This repairs 4 of the 5 error-detection regressions introduced by buckets 3-5 (MUS6/01, N782, SU74, Y79Y/009); 9MQT/01 is deferred (its invalidity is in same-line content after the --- marker, which needs document-content scanning that risks the baseline). Rejects 13 accepted-invalid suite cases and fixes 2 more by value (6BCT, Y79Y/010); 305/402 passing. BEHAVIOR CHANGE: tab-indented block structure (previously parsed permissively) is now rejected per spec; no real-world corpus seed relied on this. Updates the manifest, fixtures, counts, two affected tests (with spec justification), and the roadmap (bucket 6 marked PARTIAL).
225 lines
9.4 KiB
Swift
225 lines
9.4 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)
|
|
skipLine(&state)
|
|
}
|
|
|
|
func resetForDocumentStart(_ state: inout State) {
|
|
state.hasDocumentContent = false
|
|
state.hasDocumentStartMarker = true
|
|
state.isDocumentClosed = false
|
|
state.sawYAMLDirective = false
|
|
state.hasPendingDirective = false
|
|
}
|
|
|
|
func resetForDocumentEnd(_ state: inout State) {
|
|
state.hasDocumentContent = false
|
|
state.hasDocumentStartMarker = false
|
|
state.isDocumentClosed = true
|
|
state.sawYAMLDirective = false
|
|
state.hasPendingDirective = false
|
|
}
|
|
|
|
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]
|
|
}
|
|
}
|