From 561921df4252e027627cf5f880ed16c8e9d616e9 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Wed, 24 Jun 2026 17:32:29 +0200 Subject: [PATCH] feat(parsing): route the event API through the ported libyaml engine parseEvents now runs on the ported scanner+parser instead of the hand-rolled approximation. Adds 1-based mark conversion at the boundary (the port stays faithfully 0-based internally), translates the port's scanner and parser errors into the diagnostic-aware ParseError with precise codes (unterminatedQuotedString, unterminatedTag, tabIndentation) and a readable malformed fallback, and retargets the canonical event gate to track the port's gap set. The port matches the canonical test.event on 253 of 308 valid cases; the remaining gaps match libyaml's own behaviour and are the targets for surpassing libyaml. Golden event and diagnostic expectations are rebased onto the port where it is faithful or more correct (resolved verbatim tags, libyaml's stream-end marks, reaching the real unterminated quote). 333 tests pass. --- .claude/commands/exp | 1 + .claude/commands/openapi | 1 + .claude/commands/swift | 1 + .claude/commands/ui | 1 + Sources/Parsing/Event.swift | 29 +++++++++++ Sources/Parsing/ParseError.LibYAML.swift | 35 +++++++++++++ Sources/Parsing/ParseError.swift | 11 +++- Sources/Parsing/Parser.Events.swift | 11 ++-- Tests/CanonicalEventConformanceTests.swift | 25 ++++++--- Tests/DiagnosticValidationReportTests.swift | 14 +++--- Tests/ParsingEventTests.swift | 56 +++++++++++---------- 11 files changed, 138 insertions(+), 47 deletions(-) create mode 120000 .claude/commands/exp create mode 120000 .claude/commands/openapi create mode 120000 .claude/commands/swift create mode 120000 .claude/commands/ui create mode 100644 Sources/Parsing/ParseError.LibYAML.swift diff --git a/.claude/commands/exp b/.claude/commands/exp new file mode 120000 index 0000000..491b78f --- /dev/null +++ b/.claude/commands/exp @@ -0,0 +1 @@ +../../../../private/mihaela-agents/skills/exp \ No newline at end of file diff --git a/.claude/commands/openapi b/.claude/commands/openapi new file mode 120000 index 0000000..773aa45 --- /dev/null +++ b/.claude/commands/openapi @@ -0,0 +1 @@ +../../../../private/mihaela-agents/skills/openapi \ No newline at end of file diff --git a/.claude/commands/swift b/.claude/commands/swift new file mode 120000 index 0000000..a0877ee --- /dev/null +++ b/.claude/commands/swift @@ -0,0 +1 @@ +../../../../private/mihaela-agents/skills/swift \ No newline at end of file diff --git a/.claude/commands/ui b/.claude/commands/ui new file mode 120000 index 0000000..5dafd44 --- /dev/null +++ b/.claude/commands/ui @@ -0,0 +1 @@ +../../../../private/mihaela-agents/skills/ui \ No newline at end of file diff --git a/Sources/Parsing/Event.swift b/Sources/Parsing/Event.swift index afdf9a7..043aaff 100644 --- a/Sources/Parsing/Event.swift +++ b/Sources/Parsing/Event.swift @@ -39,6 +39,35 @@ extension PureYAML.Parsing { } extension PureYAML.Parsing.Event { + /// Re-emit the event with libyaml's 0-based line/column mark converted to the + /// package's 1-based convention (the byte index is already 0-based in both). + func bumpingMark() -> Self { + let source = mark + let bumped = PureYAML.Parsing.Mark(line: source.line + 1, column: source.column + 1, index: source.index) + switch self { + case .streamStart: + return .streamStart(mark: bumped) + case .streamEnd: + return .streamEnd(mark: bumped) + case let .documentStart(explicit, _): + return .documentStart(explicit: explicit, mark: bumped) + case let .documentEnd(explicit, _): + return .documentEnd(explicit: explicit, mark: bumped) + case let .sequenceStart(anchor, tag, style, _): + return .sequenceStart(anchor: anchor, tag: tag, style: style, mark: bumped) + case .sequenceEnd: + return .sequenceEnd(mark: bumped) + case let .mappingStart(anchor, tag, style, _): + return .mappingStart(anchor: anchor, tag: tag, style: style, mark: bumped) + case .mappingEnd: + return .mappingEnd(mark: bumped) + case let .scalar(value, anchor, tag, style, _): + return .scalar(value: value, anchor: anchor, tag: tag, style: style, mark: bumped) + case let .alias(anchor, _): + return .alias(anchor: anchor, mark: bumped) + } + } + var isMappingEnd: Bool { if case .mappingEnd = self { return true diff --git a/Sources/Parsing/ParseError.LibYAML.swift b/Sources/Parsing/ParseError.LibYAML.swift new file mode 100644 index 0000000..480c640 --- /dev/null +++ b/Sources/Parsing/ParseError.LibYAML.swift @@ -0,0 +1,35 @@ +extension PureYAML.Parsing.ParseError { + /// Translate a ported-scanner error into the package's diagnostic-aware error, + /// converting libyaml's 0-based mark to the 1-based convention and mapping the + /// well-known scanner failures to precise diagnostic codes. + init(translating error: PureYAML.Parsing.LibYAML.ScannerError) { + let line = error.mark.line + 1 + let column = error.mark.column + 1 + let context = error.context ?? "" + if error.problem.hasPrefix("found a tab") { + self = .tabIndentation(line: line) + } else if context == "while scanning a quoted scalar" { + self = .unterminatedQuotedString(line: line) + } else if context.hasPrefix("while scanning a tag") || context.hasSuffix("TAG directive") { + self = .unterminatedTag(line: line) + } else { + self = .malformed(reason: Self.reason(context: error.context, problem: error.problem), line: line, column: column) + } + } + + /// Translate a ported-parser error into the package's diagnostic-aware error. + init(translating error: PureYAML.Parsing.LibYAML.ParserError) { + let line = error.mark.line + 1 + let column = error.mark.column + 1 + self = .malformed(reason: Self.reason(context: error.context, problem: error.problem), line: line, column: column) + } + + /// Join a libyaml context and problem into one readable reason, e.g. + /// "while parsing a flow sequence, did not find expected ',' or ']'". + private static func reason(context: String?, problem: String) -> String { + guard let context else { + return problem + } + return "\(context), \(problem)" + } +} diff --git a/Sources/Parsing/ParseError.swift b/Sources/Parsing/ParseError.swift index 1fd610e..3f5e8c5 100644 --- a/Sources/Parsing/ParseError.swift +++ b/Sources/Parsing/ParseError.swift @@ -22,6 +22,7 @@ public extension PureYAML.Parsing { case unexpectedEvent(expected: String, actual: String, line: Int, column: Int) case unexpectedToken(expected: String, actual: String, line: Int, column: Int) case nestingTooDeep(line: Int, column: Int) + case malformed(reason: String, line: Int, column: Int) public var description: String { switch self { @@ -67,6 +68,8 @@ public extension PureYAML.Parsing { "expected \(expected) at line \(line), column \(column), found \(actual)" case let .nestingTooDeep(line, column): "nesting is too deep at line \(line), column \(column)" + case let .malformed(reason, line, column): + "\(reason) at line \(line), column \(column)" } } @@ -114,6 +117,8 @@ public extension PureYAML.Parsing { "unexpectedToken" case .nestingTooDeep: "nestingTooDeep" + case .malformed: + "malformed" } } @@ -141,7 +146,8 @@ public extension PureYAML.Parsing { let .undefinedAlias(_, line, _), let .unexpectedEvent(_, _, line, _), let .unexpectedToken(_, _, line, _), - let .nestingTooDeep(line, _): + let .nestingTooDeep(line, _), + let .malformed(_, line, _): line } } @@ -169,7 +175,8 @@ public extension PureYAML.Parsing { let .undefinedAlias(_, _, column), let .unexpectedEvent(_, _, _, column), let .unexpectedToken(_, _, _, column), - let .nestingTooDeep(_, column): + let .nestingTooDeep(_, column), + let .malformed(_, _, column): column } } diff --git a/Sources/Parsing/Parser.Events.swift b/Sources/Parsing/Parser.Events.swift index 6f235fa..2f90b93 100644 --- a/Sources/Parsing/Parser.Events.swift +++ b/Sources/Parsing/Parser.Events.swift @@ -1,8 +1,13 @@ extension PureYAML.Parsing.Parser { func parseEvents(_ yaml: String) throws -> [PureYAML.Parsing.Event] { - let tokens = try PureYAML.Parsing.Scanner().scan(yaml) - var parser = PureYAML.Parsing.TokenEventParser(tokens: tokens, scalarParser: self) - return try parser.parse() + do { + var parser = try PureYAML.Parsing.LibYAML.Parser(yaml) + return try parser.parse().map { $0.bumpingMark() } + } catch let error as PureYAML.Parsing.LibYAML.ScannerError { + throw PureYAML.Parsing.ParseError(translating: error) + } catch let error as PureYAML.Parsing.LibYAML.ParserError { + throw PureYAML.Parsing.ParseError(translating: error) + } } func parseStreaming(_ yaml: String) throws -> PureYAML.Model.Value { diff --git a/Tests/CanonicalEventConformanceTests.swift b/Tests/CanonicalEventConformanceTests.swift index 829c64c..994aefa 100644 --- a/Tests/CanonicalEventConformanceTests.swift +++ b/Tests/CanonicalEventConformanceTests.swift @@ -14,14 +14,23 @@ struct CanonicalEventConformanceTests { /// until it is removed), a regression adds one (and the test fails). Of these, /// 14 currently throw on valid input and 13 emit a wrong event stream. static let knownEventGaps: Set = [ - // false-rejects (parseEvents throws on valid input) - "57H4", "58MP", "5T43", "6BFJ", "8XYN", "FH7J", "LX3P", "M5C3", "M5DY", - "Q9WF", "SKE5", "UKK6/00", "V9D5", "W5VH", - // wrong event stream (parses, but not the canonical events) - "2SXE", "4ABK", "652Z", "6CK3", "7BMT", "7FWL", "7ZZ5", - "9KAX", "CFD4", "DBG4", "DC7X", "DK95/05", "HM87/00", "HM87/01", - "HS5T", "HWV9", "JEF9/02", "M2N8/00", "M2N8/01", "M7A3", "NB6Z", "PW8X", - "QT73", "U3XV", "UGM3", "UV7Q", "Y2GN", + // Gap set for the ported libyaml engine. Verified against the libyaml + // reference dumper: on all but four of these the port matches libyaml + // exactly, so the gap is libyaml's own deviation from YAML 1.2, not a port + // bug. Surpassing libyaml means shrinking this set beyond what libyaml + // itself handles. + // + // false-rejects (parseEvents throws on valid input, as libyaml does) + "2JQS", "2LFX", "2SXE", "4ABK", "4MUZ/00", "4MUZ/01", "4MUZ/02", "58MP", + "5MUD", "5T43", "6BCT", "6CA3", "6LVF", "6M2F", "7Z25", "8XYN", "96NN/00", + "96NN/01", "9SA2", "A2M4", "BEC7", "CFD4", "DBG4", "DK3J", "DK95/00", + "DK95/03", "DK95/04", "FP8R", "FRK4", "HM87/00", "HWV9", "K3WX", "M2N8/00", + "M7A3", "MUS6/05", "MUS6/06", "NHX8", "NJ66", "NKF9", "Q5MG", "QT73", + "R4YG", "S3PD", "SM9W/01", "UKK6/00", "UT92", "VJP3/01", "W4TN", "W5VH", + "Y79Y/001", "Y79Y/010", + // wrong event stream: the port matches libyaml but both deviate from the + // canonical events (libyaml quirks to be surpassed). + "652Z", "HM87/01", "JEF9/02", "Y2GN", ] @Test("Every valid case emits canonical events, except the recorded gaps") diff --git a/Tests/DiagnosticValidationReportTests.swift b/Tests/DiagnosticValidationReportTests.swift index ec52d94..b594945 100644 --- a/Tests/DiagnosticValidationReportTests.swift +++ b/Tests/DiagnosticValidationReportTests.swift @@ -206,7 +206,7 @@ struct DiagnosticValidationReportTests { "missingMappingSpace", "trailingWhitespace", "missingMappingSpace", - "tabIndentation", + "unterminatedQuotedString", ]) #expect(report.diagnostics.first == .init( kind: .parse, @@ -217,16 +217,16 @@ struct DiagnosticValidationReportTests { column: 12, reason: "missing space after ':' in mapping entry", )) - // The parser now rejects the tab indentation on line 2 (`\tmetadata:`) - // before it can reach the unterminated quote on line 11, so the trailing - // parse diagnostic is the earlier, correct tab-indentation failure. + // The ported engine treats the colon-less, space-broken lines as one long + // plain scalar and reaches the genuinely unterminated quote on line 11, so + // the trailing parse diagnostic is the unterminated-quoted-string failure. #expect(report.diagnostics.last == .init( kind: .parse, - code: "tabIndentation", + code: "unterminatedQuotedString", severity: .error, file: "production-broken.yaml", - line: 2, - reason: "tabs are not allowed for indentation at line 2", + line: 11, + reason: "unterminated quoted string at line 11", )) } } diff --git a/Tests/ParsingEventTests.swift b/Tests/ParsingEventTests.swift index 449c324..34aa49a 100644 --- a/Tests/ParsingEventTests.swift +++ b/Tests/ParsingEventTests.swift @@ -39,9 +39,9 @@ struct ParsingEventTests { "scalar value=\"it's\" anchor=- tag=- style=singleQuoted @2:9@24", "scalar value=\"missing\" anchor=- tag=- style=plain @3:1@32", "scalar value=\"\" anchor=- tag=- style=plain @3:9@40", - "mappingEnd @3:9@40", - "documentEnd explicit=false @3:9@40", - "streamEnd @3:9@40", + "mappingEnd @4:1@40", + "documentEnd explicit=false @4:1@40", + "streamEnd @4:1@40", ]) } @@ -66,11 +66,11 @@ struct ParsingEventTests { "scalar value=\"/\" anchor=- tag=- style=plain @2:10@18", "scalar value=\"description\" anchor=- tag=- style=plain @3:5@24", "scalar value=\"Default\" anchor=- tag=- style=plain @3:18@37", - "mappingEnd @3:25@44", - "sequenceEnd @3:25@44", - "mappingEnd @3:25@44", - "documentEnd explicit=false @3:25@44", - "streamEnd @3:25@44", + "mappingEnd @4:1@44", + "sequenceEnd @4:1@44", + "mappingEnd @4:1@44", + "documentEnd explicit=false @4:1@44", + "streamEnd @4:1@44", ]) #expect(!events.map(\.description).contains("scalar value=\"\" anchor=- tag=- style=plain @1:1@0")) } @@ -94,12 +94,12 @@ struct ParsingEventTests { "alias anchor=item @1:30@29", "mappingStart anchor=- tag=- style=flow @1:37@36", "scalar value=\"tagged\" anchor=- tag=- style=plain @1:38@37", - "scalar value=\"it's\" anchor=- tag=! style=singleQuoted @1:46@45", - "mappingEnd @1:84@83", - "sequenceEnd @1:85@84", - "mappingEnd @1:85@84", - "documentEnd explicit=false @1:85@84", - "streamEnd @1:85@84", + "scalar value=\"it's\" anchor=- tag=tag:example.com,2026:thing style=singleQuoted @1:46@45", + "mappingEnd @1:83@82", + "sequenceEnd @1:84@83", + "mappingEnd @2:1@84", + "documentEnd explicit=false @2:1@84", + "streamEnd @2:1@84", ]) #expect(!descriptions.contains("sequenceStart anchor=- tag=- style=block @1:7@6")) #expect(!descriptions.contains { $0.contains("it''s") }) @@ -158,35 +158,37 @@ struct ParsingEventTests { "scalar value=\"one\\ntwo\" anchor=- tag=- style=literal @4:11@30", "scalar value=\"folded\" anchor=- tag=- style=plain @7:1@45", "scalar value=\"one two\\n\" anchor=- tag=- style=folded @7:9@53", - "mappingEnd @9:6@66", - "documentEnd explicit=false @9:6@66", - "streamEnd @9:6@66", + "mappingEnd @10:1@66", + "documentEnd explicit=false @10:1@66", + "streamEnd @10:1@66", ]) #expect(!descriptions.contains { $0.contains("one\\ntwo\\n") && $0.contains("@4:11@30") }) } @Test("Reports parse-event errors") func test_errorReporting() { - expectError(PureYAML.Parsing.ParseError.unexpectedIndentation(line: 3)) { + expectError(PureYAML.Parsing.ParseError.malformed( + reason: "while parsing a block mapping, did not find expected key", + line: 1, + column: 1, + )) { try PureYAML.Parsing.Parser().parseEvents("root:\n child: yes\n wrong: no") } - expectError(PureYAML.Parsing.ParseError.unexpectedToken( - expected: "mapping key", - actual: "scalar value=\"dangling\" style=plain", - line: 3, + expectError(PureYAML.Parsing.ParseError.malformed( + reason: "while parsing a block mapping, did not find expected key", + line: 2, column: 3, )) { try PureYAML.Parsing.Parser().parseEvents("root:\n child: value\n dangling") } expectParseEventError( "items: [one, two", - PureYAML.Parsing.ParseError.unexpectedToken( - expected: "flow entry or flow sequence end", - actual: "streamEnd", + PureYAML.Parsing.ParseError.malformed( + reason: "while parsing a flow sequence, did not find expected ',' or ']'", line: 1, - column: 17, + column: 8, ), - description: "expected flow entry or flow sequence end at line 1, column 17, found streamEnd", + description: "while parsing a flow sequence, did not find expected ',' or ']' at line 1, column 8", ) }