Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion Sources/FischerCore/PGN/Model/BasicPGNGame.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,59 @@
//


public struct BasicPGNGame {
public struct BasicPGNGame: Equatable, Sendable {
public var tags: [PGNTag: String]
public var game: String

public init(tags: [PGNTag : String], game: String) {
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)
}
}
19 changes: 17 additions & 2 deletions Sources/FischerCore/PGN/Parsers/BasicPGNParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@

import Parsing

struct BasicPGNParser: Parser {
var body: some Parser<Substring, [BasicPGNGame]> {
public struct BasicPGNParser: Parser {
public init() {}

public var body: some Parser<Substring, [BasicPGNGame]> {
Many {
BasicPGNGameParser()
} separator: {
Expand All @@ -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)
}
}
130 changes: 118 additions & 12 deletions Sources/FischerCore/PGN/Parsers/TagParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,126 @@
//

import Parsing

struct TagParser: Parser {
var body: some Parser<Substring, [PGNTag: String]> {
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)
}
}
75 changes: 75 additions & 0 deletions Tests/FischerCoreTests/PGN/BasicPGNPublicAPITests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading