PureYAML/Sources/Parsing/TokenEventParser.swift
Mihaela Mihaljevic cd05976fac feat(parsing): handle directives and document-end boundaries
Three root causes: (1) a '%' begins a directive only in directive position (before document content, outside flow); everywhere else it is plain content, fixing misfires inside plain scalars, flow mappings, and block scalars. (2) %YAML accepts any 1.x (a higher minor is treated as 1.2 without error), rejects major != 1 and duplicate %YAML, and requires a following '---'; reserved and %TAG directives are accepted and ignored. A directive not followed by a document start is rejected with the new directiveWithoutDocument error. (3) An explicit '...' end marker closes the current document so following content begins a fresh document in parseStream; single-document parse still rejects multi-document input at compose time.

Tests/UnsupportedYAMLGapsFixtures.swift, StreamParsingTests, ScannerTests, and ParsingErrorTests were updated in lockstep to assert the now-correct behavior (percent-after-content is plain text; '...' then content is two documents; %YAML 1.3 is valid while 2.0 and duplicate %YAML are rejected); no test was weakened and multi-document rejection for single-document parse is still covered.

Flips 15 official YAML test suite cases (295/402); SF5V, 9MMA, B63P, H7TQ now correctly rejected. 6LVF and W4TN are reclassified to json-mismatch (blocked by other scalar gaps). Updates the manifest, fixtures, counts, and roadmap.
2026-06-23 21:31:58 +02:00

314 lines
13 KiB
Swift

extension PureYAML.Parsing {
struct TokenEventParser {
var cursor: TokenCursor
let scalarParser: Parser
var events: [Event]?
var eventSink: ((Event) throws -> Void)?
/// Current node recursion depth, tracked so deeply nested input fails
/// with a diagnostic instead of overflowing the stack.
var depth = 0
/// Maximum node nesting the parser will follow before failing with
/// ``ParseError/nestingTooDeep(line:column:)``.
///
/// This bound is the single safety invariant for deeply nested input,
/// and it is *necessary*, not merely convenient. A ``Model/Value`` is a
/// recursive tree, so composing it, emitting it, comparing it, hashing
/// it, and -- crucially -- deallocating it all recurse with the nesting
/// depth. Swift tears down a recursive value type recursively, and that
/// teardown cannot be made iterative cheaply, so the only way to keep
/// every operation within the call stack is to bound the depth of the
/// tree where it is built: here, while parsing.
///
/// Because every parsing entry point routes through ``parseNode``, this
/// one bound caps the depth of any parsed tree, which transitively keeps
/// the parser, the event composers, the emitter, equality, hashing, and
/// deallocation all well inside their limits. Measured on the main
/// thread's 8 MB stack: the parser overflows near depth 225 and the
/// tree operations near depth 50,000, while real-world specs nest below
/// ~40. So 64 accepts every realistic document yet rejects pathological
/// input -- for example a 100 KB `[[[...]]]` -- cleanly and early.
///
/// The bound cannot simply be raised: above ~225 the recursive parser
/// itself overflows before the guard fires (lifting it would require an
/// iterative parser), and a tree past ~5,000 deep crashes on `deinit`
/// regardless, so a higher limit would trade a parse-time failure for an
/// un-fixable teardown crash. 64 is the largest value that is both safe
/// here and sufficient for real input.
///
/// Caveat: a tree built *without* the parser -- for example by encoding
/// a pathologically deep `Codable` value -- bypasses this bound, and
/// operations on such a tree are O(depth) in stack like any recursive
/// value type. Keeping those shallow is the caller's responsibility, the
/// same way the standard library cannot prevent unbounded recursion in
/// user code.
static let maxNestingDepth = 64
init(
tokens: [Token],
scalarParser: Parser,
) {
cursor = TokenCursor(tokens: tokens)
self.scalarParser = scalarParser
events = []
eventSink = nil
}
mutating func parse() throws -> [Event] {
try parseEvents()
return events ?? []
}
init(
tokens: [Token],
scalarParser: Parser,
eventSink: @escaping (Event) throws -> Void,
) {
cursor = TokenCursor(tokens: tokens)
self.scalarParser = scalarParser
events = nil
self.eventSink = eventSink
}
init(
yaml: String,
scalarParser: Parser,
eventSink: @escaping (Event) throws -> Void,
) {
cursor = TokenCursor(yaml: yaml)
self.scalarParser = scalarParser
events = nil
self.eventSink = eventSink
}
mutating func parseStreaming() throws {
try parseEvents()
}
mutating func parseEvents() throws {
let streamStart = try expect("stream start") { kind in
if case .streamStart = kind {
return true
}
return false
}
try emit(.streamStart(mark: streamStart.mark))
var didParseDocument = false
while cursor.current?.kind.isStreamEnd != true {
try parseDocument()
didParseDocument = true
}
if !didParseDocument {
let mark = cursor.current?.mark ?? streamStart.endMark
try emit(.documentStart(mark: mark))
try appendEmptyDocument(at: mark)
try emit(.documentEnd(mark: mark))
}
let streamEnd = try expect("stream end") { kind in
if case .streamEnd = kind {
return true
}
return false
}
try emit(.streamEnd(mark: streamEnd.mark))
}
mutating func emit(_ event: Event) throws {
if let eventSink {
try eventSink(event)
} else {
events?.append(event)
}
}
}
}
extension PureYAML.Parsing.TokenEventParser {
mutating func parseDocument() throws {
let startMark = try parseDocumentStartMark()
try emit(.documentStart(mark: startMark))
if cursor.current?.kind.isDocumentEnd == true {
let end = try expect("document end") { $0.isDocumentEnd }
try appendEmptyDocument(at: end.mark)
try emit(.documentEnd(mark: end.endMark))
return
}
if cursor.current?.kind.isDocumentStart == true || cursor.current?.kind.isStreamEnd == true {
try appendEmptyDocument(at: cursor.current?.mark ?? startMark)
try emit(.documentEnd(mark: cursor.current?.mark ?? startMark))
return
}
var endMark = try parseDocumentRootNode().endMark
while cursor.current?.kind.isDedent == true {
let dedent = try expect("dedent") { $0.isDedent }
endMark = dedent.endMark
}
if cursor.current?.kind.isDocumentEnd == true {
// An explicit `...` end marker closes the document outright. Whatever
// follows -- a bare node, an explicit `---`, or the end of the stream
// -- starts a fresh document on the next loop iteration, so emit the
// end here and return rather than demanding a document boundary token.
let end = try expect("document end") { $0.isDocumentEnd }
try emit(.documentEnd(mark: end.endMark))
return
}
if let current = cursor.current, !canEndDocument(at: current) {
throw PureYAML.Parsing.ParseError.unexpectedToken(
expected: "document boundary or stream end",
actual: current.kind.description,
line: current.mark.line,
column: current.mark.column,
)
}
try emit(.documentEnd(mark: endMark))
}
mutating func parseDocumentRootNode() throws -> PureYAML.Parsing.NodeResult {
guard cursor.current?.kind.isIndent == true else {
return try parseNode()
}
_ = try expect("indent") { $0.isIndent }
let result = try parseNode()
if cursor.current?.kind.isDedent == true {
_ = try expect("dedent") { $0.isDedent }
}
return result
}
mutating func parseDocumentStartMark() throws -> PureYAML.Parsing.Mark {
guard cursor.current?.kind.isDocumentStart == true else {
return cursor.current?.mark ?? cursor.previous?.endMark ?? .start
}
let start = try cursor.advance()
return start?.mark ?? .start
}
mutating func appendEmptyDocument(at mark: PureYAML.Parsing.Mark) throws {
try emit(.scalar(value: "", anchor: nil, tag: nil, style: .plain, mark: mark))
}
func canEndDocument(at token: PureYAML.Parsing.Token) -> Bool {
token.kind.isDocumentStart || token.kind.isStreamEnd
}
mutating func parseNode() throws -> PureYAML.Parsing.NodeResult {
let properties = try consumeProperties()
return try parseNode(properties: properties)
}
/// Reject documents nested past ``maxNestingDepth`` with a diagnostic so a
/// pathologically nested input fails cleanly instead of overflowing the
/// recursive-descent stack.
func enforceNestingLimit(properties: PureYAML.Parsing.NodeProperties) throws {
guard depth > Self.maxNestingDepth else {
return
}
let mark = properties.mark ?? cursor.current?.mark ?? cursor.previous?.endMark ?? .start
throw PureYAML.Parsing.ParseError.nestingTooDeep(line: mark.line, column: mark.column)
}
mutating func parseNode(
properties initialProperties: PureYAML.Parsing.NodeProperties,
) throws -> PureYAML.Parsing.NodeResult {
depth += 1
defer { depth -= 1 }
try enforceNestingLimit(properties: initialProperties)
var properties = initialProperties
try properties.mergeUnset(from: consumeProperties())
guard let token = cursor.current, !token.kind.isTerminator else {
return try emptyScalarOrThrow(properties: properties)
}
switch token.kind {
case let .alias(anchor):
return try parseAliasNode(anchor: anchor, token: token, properties: properties)
case .blockEntry:
return try parseBlockSequence(properties: properties)
case .blockScalarHeader:
return try parseBlockScalar(properties: properties)
case .flowMappingStart:
return try parseFlowMapping(properties: properties)
case .flowSequenceStart:
return try parseFlowSequence(properties: properties)
case .mappingKey, .mappingValue:
return try parseBlockMappingNode(token: token, properties: properties)
case .indent:
return try parseIndentedNode(properties: properties)
case let .scalar(value, style):
return try parseScalarNode(token: token, value: value, style: style, properties: properties)
default:
throw PureYAML.Parsing.ParseError.expectedNode(line: token.mark.line, column: token.mark.column)
}
}
/// Parses an alias node. An alias immediately followed by a `:` value
/// indicator is the key of a block mapping (`*alias : value`), not a
/// standalone alias, so route into the mapping and let the key parser
/// consume it; otherwise emit the alias event directly.
mutating func parseAliasNode(
anchor: String,
token: PureYAML.Parsing.Token,
properties: PureYAML.Parsing.NodeProperties,
) throws -> PureYAML.Parsing.NodeResult {
if try cursor.peek()?.kind.isMappingValue == true {
return try parseBlockMapping(
properties: properties,
includeIndentedContinuation: false,
)
}
_ = try cursor.advance()
let mark = properties.mark ?? token.mark
try emit(.alias(anchor: anchor, mark: mark))
return PureYAML.Parsing.NodeResult(kind: .alias, endMark: token.endMark)
}
/// Parses a block mapping introduced by a `mappingKey`/`mappingValue`
/// indicator. Properties on the same line as the first key indicator belong
/// to that key node, not to the mapping: `&a a: b` anchors the key `a`,
/// whereas `&a` alone on a line before the mapping anchors the map.
mutating func parseBlockMappingNode(
token: PureYAML.Parsing.Token,
properties: PureYAML.Parsing.NodeProperties,
) throws -> PureYAML.Parsing.NodeResult {
if properties != .none, let mark = properties.mark, mark.line == token.mark.line, token.kind.isMappingKey {
return try parseBlockMapping(
properties: .none,
includeIndentedContinuation: false,
firstKeyProperties: properties,
)
}
return try parseBlockMapping(
properties: properties,
includeIndentedContinuation: false,
)
}
mutating func parseScalarNode(
token: PureYAML.Parsing.Token,
value: String,
style: PureYAML.Parsing.ScalarStyle,
properties: PureYAML.Parsing.NodeProperties,
) throws -> PureYAML.Parsing.NodeResult {
if try canParsePlainScalarContinuation() {
return try parsePlainScalarWithContinuation(properties: properties)
}
_ = try cursor.advance()
let decoded = try decodeScalar(value, style: style, line: token.mark.line)
let mark = properties.mark ?? token.mark
try emit(.scalar(
value: decoded,
anchor: properties.anchor,
tag: properties.tag,
style: style,
mark: mark,
))
return PureYAML.Parsing.NodeResult(kind: .scalar, endMark: token.endMark)
}
}