diff --git a/Sources/FischerCore/PGN/Model/BasicPGNGame.swift b/Sources/FischerCore/PGN/Model/BasicPGNGame.swift index a5430e8a..683e9d83 100644 --- a/Sources/FischerCore/PGN/Model/BasicPGNGame.swift +++ b/Sources/FischerCore/PGN/Model/BasicPGNGame.swift @@ -6,7 +6,7 @@ // -public struct BasicPGNGame { +public struct BasicPGNGame: Equatable, Sendable { public var tags: [PGNTag: String] public var game: String @@ -14,4 +14,51 @@ public struct BasicPGNGame { self.tags = tags self.game = game } + + public subscript(_ tag: PGNTag) -> String? { + tags[tag] + } + + public subscript(tagName tagName: String) -> String? { + tags[PGNTag(rawValue: tagName) ?? .custom(tagName)] + } + + public var movetext: String { + game + } +} + +extension BasicPGNGame: Codable { + private enum CodingKeys: String, CodingKey { + case tags + case game + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let rawTags = try container.decode([String: String].self, forKey: .tags) + + self.init( + tags: Dictionary( + rawTags.map { key, value in + (PGNTag(rawValue: key) ?? .custom(key), value) + }, + uniquingKeysWith: { _, newValue in newValue } + ), + game: try container.decode(String.self, forKey: .game) + ) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + let rawTags = Dictionary( + tags.map { tag, value in + (tag.rawValue, value) + }, + uniquingKeysWith: { _, newValue in newValue } + ) + + try container.encode(rawTags, forKey: .tags) + try container.encode(game, forKey: .game) + } } diff --git a/Sources/FischerCore/PGN/Parsers/BasicPGNParser.swift b/Sources/FischerCore/PGN/Parsers/BasicPGNParser.swift index 0bb1b2df..3a57fa5d 100644 --- a/Sources/FischerCore/PGN/Parsers/BasicPGNParser.swift +++ b/Sources/FischerCore/PGN/Parsers/BasicPGNParser.swift @@ -7,8 +7,10 @@ import Parsing -struct BasicPGNParser: Parser { - var body: some Parser { +public struct BasicPGNParser: Parser { + public init() {} + + public var body: some Parser { Many { BasicPGNGameParser() } separator: { @@ -17,4 +19,17 @@ struct BasicPGNParser: Parser { Whitespace() } } + + public func parse(_ pgn: String) throws -> [BasicPGNGame] { + try parse(pgn[...]) + } +} + +/// Reads PGN documents into lightweight ``BasicPGNGame`` values. +public struct BasicPGNReader { + public init() {} + + public func parse(_ pgn: String) throws -> [BasicPGNGame] { + try BasicPGNParser().parse(pgn) + } } diff --git a/Sources/FischerCore/PGN/Parsers/TagParser.swift b/Sources/FischerCore/PGN/Parsers/TagParser.swift index deaa09ce..472a0acd 100644 --- a/Sources/FischerCore/PGN/Parsers/TagParser.swift +++ b/Sources/FischerCore/PGN/Parsers/TagParser.swift @@ -6,20 +6,126 @@ // import Parsing + struct TagParser: Parser { - var body: some Parser { - Many { - Parse { - "[" - PrefixUpTo(" \"").compactMap{PGNTag(rawValue: String($0)) } - " \"" - Prefix{ $0 != "\""}.map(String.init) - "\"]" + func parse(_ input: inout Substring) throws -> [PGNTag: String] { + var tags: [PGNTag: String] = [:] + + while input.first == "[" { + let (tag, value) = try parseTag(&input) + tags[tag] = value + + var nextTagInput = input + nextTagInput.consumeWhitespace() + guard nextTagInput.first == "[" else { + break } - } separator: { - Whitespace() - }.map { tags in - Dictionary(tags, uniquingKeysWith: { _, newValue in newValue }) + input = nextTagInput + } + + return tags + } + + private func parseTag(_ input: inout Substring) throws -> (PGNTag, String) { + guard input.first == "[" else { + throw TagParserError.unterminatedTag + } + input.removeFirst() + input.consumeInlineWhitespace() + + let rawName = input.consume { character in + character != "\"" && character != "]" + } + let name = rawName.trimmingInlineWhitespace() + guard !name.isEmpty else { + throw TagParserError.unterminatedTag + } + + guard input.first == "\"" else { + throw TagParserError.unterminatedTag + } + input.removeFirst() + + let value = try parseTagValue(&input) + + input.consumeInlineWhitespace() + guard input.first == "]" else { + throw TagParserError.unterminatedTag + } + input.removeFirst() + + return (PGNTag(rawValue: name) ?? .custom(name), value) + } + + private func parseTagValue(_ input: inout Substring) throws -> String { + var value = "" + var escaped = false + + while let character = input.first { + input.removeFirst() + + if escaped { + if character == "\"" || character == "\\" { + value.append(character) + } else { + value.append("\\") + value.append(character) + } + escaped = false + continue + } + + if character == "\\" { + escaped = true + continue + } + + if character == "\"" { + return value + } + + if character == "\n" { + throw TagParserError.unterminatedTag + } + + value.append(character) + } + + throw TagParserError.unterminatedTag + } +} + +private enum TagParserError: Error, Equatable { + case unterminatedTag +} + +private extension Substring { + mutating func consume(while shouldConsume: (Character) -> Bool) -> Substring { + let prefix = prefix(while: shouldConsume) + removeFirst(prefix.count) + return prefix + } + + mutating func consumeInlineWhitespace() { + _ = consume { character in + character == " " || character == "\t" + } + } + + mutating func consumeWhitespace() { + _ = consume { character in + character.isWhitespace + } + } + + func trimmingInlineWhitespace() -> String { + var substring = self + while substring.first == " " || substring.first == "\t" { + substring.removeFirst() + } + while substring.last == " " || substring.last == "\t" { + substring.removeLast() } + return String(substring) } } diff --git a/Tests/FischerCoreTests/PGN/BasicPGNPublicAPITests.swift b/Tests/FischerCoreTests/PGN/BasicPGNPublicAPITests.swift new file mode 100644 index 00000000..7ae76cde --- /dev/null +++ b/Tests/FischerCoreTests/PGN/BasicPGNPublicAPITests.swift @@ -0,0 +1,75 @@ +import Foundation +import FischerCore +import Testing + +final class BasicPGNPublicAPITests { + @Test("Basic PGN reader preserves custom tags and escaped values") + func basicReaderPreservesCustomTagsAndEscapedValues() throws { + let input = #""" + [Event "Rated Bullet game"] + [Site "https://lichess.org/QYJK65fM"] + [Rated "true"] + [Perf "bullet"] + [Speed "bullet"] + [GameUrl "https://lichess.org/QYJK65fM"] + [Link "https://www.chess.com/analysis/game/live/140609505268"] + [Escaped "escaped \"quote\" and \\ slash"] + [Result "1-0"] + + 1. e4 e5 2. Nf3 Nc6 1-0 + """# + + let games = try BasicPGNReader().parse(input) + let game = try #require(games.first) + + #expect(games.count == 1) + #expect(game[.event] == "Rated Bullet game") + #expect(game[.custom("Rated")] == "true") + #expect(game[tagName: "Perf"] == "bullet") + #expect(game[tagName: "Speed"] == "bullet") + #expect(game[tagName: "GameUrl"] == "https://lichess.org/QYJK65fM") + #expect(game[.link] == "https://www.chess.com/analysis/game/live/140609505268") + #expect(game[tagName: "Escaped"] == #"escaped "quote" and \ slash"#) + #expect(game.movetext.contains("1. e4 e5 2. Nf3 Nc6 1-0")) + } + + @Test("Basic PGN parser is public without testable import") + func basicParserIsPublicWithoutTestableImport() throws { + let input = """ + [Event "Game One"] + [Result "*"] + + 1. e4 e5 * + + [Event "Game Two"] + [Result "0-1"] + + 1. d4 d5 0-1 + """ + + let games = try BasicPGNParser().parse(input) + + #expect(games.count == 2) + #expect(games[0][.event] == "Game One") + #expect(games[0].game.contains("1. e4 e5 *")) + #expect(games[1][.event] == "Game Two") + #expect(games[1].game.contains("1. d4 d5 0-1")) + } + + @Test("Basic PGN game is equatable and codable") + func basicGameIsEquatableAndCodable() throws { + let game = BasicPGNGame( + tags: [ + .event: "Codable Game", + .custom("GameUrl"): "https://lichess.org/abcdef" + ], + game: "\n\n1. c4 e5 *" + ) + + let encoded = try JSONEncoder().encode(game) + let decoded = try JSONDecoder().decode(BasicPGNGame.self, from: encoded) + + #expect(decoded == game) + #expect(decoded[tagName: "GameUrl"] == "https://lichess.org/abcdef") + } +}