From 9ce57e86e5ca905940dd02ae7cf4e5c5d2e968f4 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Wed, 24 Jun 2026 02:55:07 +0200 Subject: [PATCH] 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. --- Sources/Parsing/LibYAML.Scanner.swift | 92 ++++++++++++++++++++++ Sources/Parsing/LibYAML.ScannerError.swift | 10 +++ Sources/Parsing/LibYAML.SimpleKey.swift | 20 +++++ Tests/LibYAMLSimpleKeyTests.swift | 66 ++++++++++++++++ 4 files changed, 188 insertions(+) create mode 100644 Sources/Parsing/LibYAML.Scanner.swift create mode 100644 Sources/Parsing/LibYAML.ScannerError.swift create mode 100644 Sources/Parsing/LibYAML.SimpleKey.swift create mode 100644 Tests/LibYAMLSimpleKeyTests.swift diff --git a/Sources/Parsing/LibYAML.Scanner.swift b/Sources/Parsing/LibYAML.Scanner.swift new file mode 100644 index 0000000..9a60f82 --- /dev/null +++ b/Sources/Parsing/LibYAML.Scanner.swift @@ -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() + } + } + } +} diff --git a/Sources/Parsing/LibYAML.ScannerError.swift b/Sources/Parsing/LibYAML.ScannerError.swift new file mode 100644 index 0000000..49f1887 --- /dev/null +++ b/Sources/Parsing/LibYAML.ScannerError.swift @@ -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 + } +} diff --git a/Sources/Parsing/LibYAML.SimpleKey.swift b/Sources/Parsing/LibYAML.SimpleKey.swift new file mode 100644 index 0000000..503c6e0 --- /dev/null +++ b/Sources/Parsing/LibYAML.SimpleKey.swift @@ -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) + } + } +} diff --git a/Tests/LibYAMLSimpleKeyTests.swift b/Tests/LibYAMLSimpleKeyTests.swift new file mode 100644 index 0000000..2f397d9 --- /dev/null +++ b/Tests/LibYAMLSimpleKeyTests.swift @@ -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) + } +}