feat(parsing): port libyaml simple-key mechanism to Swift

Stage 2 of the faithful libyaml scanner port. Translates yaml_parser_save_simple_key, yaml_parser_remove_simple_key, yaml_parser_stale_simple_keys, and the flow-level push/pop (yaml_parser_increase_flow_level / decrease) onto a new PureYAML.Parsing.LibYAML.Scanner aggregate that embeds the indent engine. The simple-key mechanism is what lets a quoted scalar, a nested collection, or a property-carrying node become a mapping key when a ':' follows, and is required exactly at the block indent. Modelled faithfully: a potential key goes stale once it spans a line break or exceeds 1024 characters, and a required key that is removed or goes stale is the 'could not find expected colon' error. Unit-tested for the required/not-required, removal-error, staleness, and flow-level cases. No public behavior changed yet.
This commit is contained in:
Mihaela Mihaljevic 2026-06-24 02:55:07 +02:00
parent c3033c356d
commit 9ce57e86e5
4 changed files with 188 additions and 0 deletions

View File

@ -0,0 +1,92 @@
extension PureYAML.Parsing.LibYAML {
/// Aggregate scanner state, mirroring the fields of `yaml_parser_t` that the
/// ported functions read and write. It is built up as the port proceeds;
/// currently it holds the indentation engine and the simple-key mechanism.
/// Column values follow libyaml's 0-based convention internally; conversion to
/// the package's 1-based `Mark` happens at token emission during integration.
struct Scanner {
/// The indentation stack and `roll_indent` / `unroll_indent` (also the
/// owner of `flow_level` and the token queue).
var indentEngine = IndentEngine()
/// `parser->simple_keys`. Always holds at least the block-context level.
var simpleKeys: [SimpleKey] = [.empty]
/// `parser->simple_key_allowed`. True at stream start.
var simpleKeyAllowed = true
/// `parser->mark`, the current position (column is 0-based here).
var mark: PureYAML.Parsing.Mark = .start
/// `parser->flow_level`, kept on the indent engine.
var flowLevel: Int {
indentEngine.flowLevel
}
/// Port of `yaml_parser_stale_simple_keys`. A potential simple key is
/// invalid once it is on an earlier line or more than 1024 characters back
/// (a simple key is single-line and bounded); a required one going stale is
/// an error.
mutating func staleSimpleKeys() throws {
for index in simpleKeys.indices {
let key = simpleKeys[index]
guard key.possible, key.mark.line < mark.line || key.mark.index + 1024 < mark.index else {
continue
}
if key.required {
throw ScannerError(
context: "while scanning a simple key",
problem: "could not find expected ':'",
mark: key.mark,
)
}
simpleKeys[index].possible = false
}
}
/// Port of `yaml_parser_save_simple_key`. Record the current position as a
/// potential simple key for this flow level when one is allowed. A key is
/// required (a `:` must follow) when it sits exactly at the block indent.
mutating func saveSimpleKey() throws {
let required = flowLevel == 0 && indentEngine.indent == mark.column
guard simpleKeyAllowed else {
return
}
let key = SimpleKey(
possible: true,
required: required,
tokenNumber: indentEngine.tokensParsed + indentEngine.tokens.count,
mark: mark,
)
try removeSimpleKey()
simpleKeys[simpleKeys.count - 1] = key
}
/// Port of `yaml_parser_remove_simple_key`. Drop the potential key at the
/// current flow level; removing a required key is an error.
mutating func removeSimpleKey() throws {
let top = simpleKeys.count - 1
if simpleKeys[top].possible, simpleKeys[top].required {
throw ScannerError(
context: "while scanning a simple key",
problem: "could not find expected ':'",
mark: simpleKeys[top].mark,
)
}
simpleKeys[top].possible = false
}
/// Port of `yaml_parser_increase_flow_level`. Push an empty simple key for
/// the new flow level.
mutating func increaseFlowLevel() {
simpleKeys.append(.empty)
indentEngine.flowLevel += 1
}
/// Port of `yaml_parser_decrease_flow_level`. Pop the flow level's simple
/// key.
mutating func decreaseFlowLevel() {
if indentEngine.flowLevel > 0 {
indentEngine.flowLevel -= 1
simpleKeys.removeLast()
}
}
}
}

View File

@ -0,0 +1,10 @@
extension PureYAML.Parsing.LibYAML {
/// A scanning error, mirroring libyaml's `yaml_parser_set_scanner_error`
/// (`context` plus `problem` at a mark). It is mapped to the package's public
/// `ParseError` when the port is integrated.
struct ScannerError: Swift.Error, Equatable {
var context: String
var problem: String
var mark: PureYAML.Parsing.Mark
}
}

View File

@ -0,0 +1,20 @@
extension PureYAML.Parsing.LibYAML {
/// Port of `yaml_simple_key_t`. A potential implicit ("simple") mapping key:
/// a node that may turn out to be a key once a `:` is seen on the same line.
/// `tokenNumber` records where its `KEY` token must be spliced into the queue.
struct SimpleKey: Equatable {
/// Whether a simple key is still possible at this flow level.
var possible: Bool
/// Whether a `:` is required (a key sitting exactly at the block indent).
var required: Bool
/// The absolute token number where the `KEY` token is inserted.
var tokenNumber: Int
/// The position the potential key started at.
var mark: PureYAML.Parsing.Mark
/// The empty entry pushed for a new flow level (`{ 0, 0, 0, {0,0,0} }`).
static var empty: Self {
Self(possible: false, required: false, tokenNumber: 0, mark: .start)
}
}
}

View File

@ -0,0 +1,66 @@
@testable import PureYAML
import Testing
/// Verifies the ported simple-key mechanism (`save_simple_key`,
/// `remove_simple_key`, `stale_simple_keys`, flow-level push/pop) reproduces
/// libyaml's behavior.
@Suite("LibYAML Simple Key")
struct LibYAMLSimpleKeyTests {
private func mark(line: Int, column: Int, index: Int) -> PureYAML.Parsing.Mark {
PureYAML.Parsing.Mark(line: line, column: column, index: index)
}
@Test("saves a possible key, required when it sits at the block indent")
func test_saveSimpleKeyRequiredAtIndent() throws {
var scanner = PureYAML.Parsing.LibYAML.Scanner()
scanner.indentEngine.rollIndent(column: 0, number: -1, type: .blockMappingStart, mark: .start)
scanner.mark = mark(line: 1, column: 0, index: 0)
try scanner.saveSimpleKey()
#expect(scanner.simpleKeys.last?.possible == true)
#expect(scanner.simpleKeys.last?.required == true)
}
@Test("a key not at the block indent is possible but not required")
func test_saveSimpleKeyNotRequired() throws {
var scanner = PureYAML.Parsing.LibYAML.Scanner()
scanner.indentEngine.rollIndent(column: 0, number: -1, type: .blockMappingStart, mark: .start)
scanner.mark = mark(line: 1, column: 4, index: 4)
try scanner.saveSimpleKey()
#expect(scanner.simpleKeys.last?.possible == true)
#expect(scanner.simpleKeys.last?.required == false)
}
@Test("removing a required possible key is an error")
func test_removeRequiredKeyThrows() throws {
var scanner = PureYAML.Parsing.LibYAML.Scanner()
scanner.indentEngine.rollIndent(column: 0, number: -1, type: .blockMappingStart, mark: .start)
scanner.mark = mark(line: 1, column: 0, index: 0)
try scanner.saveSimpleKey()
#expect(throws: PureYAML.Parsing.LibYAML.ScannerError.self) {
try scanner.removeSimpleKey()
}
}
@Test("a non-required key on an earlier line goes stale without error")
func test_staleNonRequiredKey() throws {
var scanner = PureYAML.Parsing.LibYAML.Scanner()
scanner.indentEngine.rollIndent(column: 0, number: -1, type: .blockMappingStart, mark: .start)
scanner.mark = mark(line: 1, column: 4, index: 4)
try scanner.saveSimpleKey()
scanner.mark = mark(line: 2, column: 0, index: 10)
try scanner.staleSimpleKeys()
#expect(scanner.simpleKeys.last?.possible == false)
}
@Test("flow level pushes and pops a simple-key slot")
func test_flowLevelStack() {
var scanner = PureYAML.Parsing.LibYAML.Scanner()
#expect(scanner.simpleKeys.count == 1)
scanner.increaseFlowLevel()
#expect(scanner.flowLevel == 1)
#expect(scanner.simpleKeys.count == 2)
scanner.decreaseFlowLevel()
#expect(scanner.flowLevel == 0)
#expect(scanner.simpleKeys.count == 1)
}
}