1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
|
//
// PGN.swift
// Sage
//
// Copyright 2016-2017 Nikolai Vazquez
// Modified by SuperGeroy
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
/// Portable game notation data.
///
/// - seealso: [Portable Game Notation (Wikipedia)](https://en.wikipedia.org/wiki/Portable_Game_Notation),
/// [PGN Specification](https://www.chessclub.com/user/help/PGN-spec)
import Foundation
public struct PGN: Equatable {
/// PGN tag.
public enum Tag: String, CustomStringConvertible {
/// Event tag.
case event = "Event"
/// Site tag.
case site = "Site"
/// Date tag.
case date = "Date"
/// Round tag.
case round = "Round"
/// White tag.
case white = "White"
/// Black tag.
case black = "Black"
/// Result tag.
case result = "Result"
/// Annotator tag.
case annotator = "Annotator"
/// Ply (moves) count tag.
case plyCount = "PlyCount"
/// TimeControl tag.
case timeControl = "TimeControl"
/// Time tag.
case time = "Time"
/// Termination tag.
case termination = "Termination"
/// Playing mode tag.
case mode = "Mode"
/// FEN tag.
case fen = "FEN"
/// White player's title tag.
case whiteTitle = "WhiteTitle"
/// Black player's title tag.
case blackTitle = "BlackTitle"
/// White player's elo rating tag.
case whiteElo = "WhiteElo"
/// Black player's elo rating tag.
case blackElo = "BlackElo"
/// White player's United States Chess Federation rating tag.
case whiteUSCF = "WhiteUSCF"
/// Black player's United States Chess Federation rating tag.
case blackUSCF = "BlackUSCF"
/// White player's network or email address tag.
case whiteNA = "WhiteNA"
/// Black player's network or email address tag.
case blackNA = "BlackNA"
/// White player's type tag; either human or program.
case whiteType = "WhiteType"
/// Black player's type tag; either human or program.
case blackType = "BlackType"
/// The starting date tag of the event.
case eventDate = "EventDate"
/// Tag for the name of the sponsor of the event.
case eventSponsor = "EventSponsor"
/// The playing section tag of a tournament.
case section = "Section"
/// Tag for the stage of a multistage event.
case stage = "Stage"
/// The board number tag in a team event or in a simultaneous exhibition.
case board = "Board"
/// The traditional opening name tag.
case opening = "Opening"
/// Tag used to further refine the opening tag.
case variation = "Variation"
/// Used to further refine the variation tag.
case subVariation = "SubVariation"
/// Tag used for an opening designation from the five volume *Encyclopedia of Chess Openings*.
case eco = "ECO"
/// Tag used for an opening designation from the *New in Chess* database.
case nic = "NIC"
/// Tag similar to the Time tag but given according to the Universal Coordinated Time standard.
case utcTime = "UTCTime"
/// Tag similar to the Date tag but given according to the Universal Coordinated Time standard.
case utcDate = "UTCDate"
/// Tag for the "set-up" status of the game.
case setUp = "SetUp"
/// A textual representation of `self`.
public var description: String {
return rawValue
}
}
/// An error thrown by `PGN.init(parse:)`.
public enum ParseError: Error {
/// Unexpected quote found in move text.
case unexpectedQuote(String)
/// Unexpected closing brace found outside of comment.
case unexpectedClosingBrace(String)
/// No closing brace for comment.
case noClosingBrace(String)
/// No closing quote for tag value.
case noClosingQuote(String)
/// No closing bracket for tag pair.
case noClosingBracket(String)
/// Wrong number of tokens for tag pair.
case tagPairTokenCount([String])
/// Incorrect count of parenthesis for recursive annotation variation.
case parenthesisCountForRAV(String)
}
/// The tag pairs for `self`.
public var tagPairs: [String: String]
/// The moves in standard algebraic notation.
public var moves: [String]
/// The game outcome.
public var outcome: Game.Outcome? {
get {
let resultTag = Tag.result
return self[resultTag].flatMap(Game.Outcome.init)
}
set {
let resultTag = Tag.result
self[resultTag] = newValue?.description
}
}
/// Create PGN with `tagPairs` and `moves`.
public init(tagPairs: [String: String] = [:], moves: [String] = []) {
self.tagPairs = tagPairs
self.moves = moves
}
/// Create PGN with `tagPairs` and `moves`.
public init(tagPairs: [Tag: String], moves: [String] = []) {
self.init(moves: moves)
for (tag, value) in tagPairs {
self[tag] = value
}
}
/// Create PGN by parsing `string`.
///
/// - throws: `ParseError` if an error occured while parsing.
public init(parse string: String) throws {
self.init()
if string.isEmpty { return }
for line in string._splitByNewlines() {
if line.first == "[" {
let commentsStripped = try line._commentsStripped(strings: true)
let (tag, value) = try commentsStripped._tagPair()
tagPairs[tag] = value
} else if line.first != "%" {
let commentsStripped = try line._commentsStripped(strings: false)
// Lichess PGNs have an extra comment after a few days
if (commentsStripped.count == 0 ) {
continue
}
let (moves, outcome) = try commentsStripped._moves()
self.moves += moves
if let outcome = outcome {
self.outcome = outcome
}
}
}
}
/// Get or set the value for `tag`.
public subscript(tag: Tag) -> String? {
get {
return tagPairs[tag.rawValue]
}
set {
tagPairs[tag.rawValue] = newValue
}
}
/// Returns `self` in export string format.
/// // PGN(tagPairs: ["Black": "Spassky, Boris V.", "Event": "F/S Return Match", "Round": "29", "Result": "½-½", "Site": "Belgrade, Serbia Yugoslavia|JUG", "Date": "1992.11.04", "White": "Fischer, Robert J."
public func exported() -> String {
var result = ""
var tagPairs = self.tagPairs
let sevenTags = [("Event", "?"),
("Site", "?"),
("Date", "????.??.??"),
("Round", "?"),
("White", "?"),
("Black", "?"),
("Result", "*")]
let orderedTags = ["Event", "Site", "Date", "Round", "White", "Black", "Result"]
for tag in orderedTags {
if let value = tagPairs.removeValue(forKey: tag) {
result += "[\(tag) \"\(value)\"]\n"
} else {
if let defaultValue = sevenTags.first(where: { $0.0 == tag })?.1 {
result += "[\(tag) \"\(defaultValue)\"]\n"
} else {
// Handle cases where the tag is not in 'sevenTags'
}
}
}
for (tag, value) in tagPairs {
result += "[\(tag) \"\(value)\"]\n"
}
let strideTo = stride(from: 0, to: moves.endIndex, by: 2)
var moveLine = ""
for num in strideTo {
let moveNumber = (num + 2) / 2
var moveString = "\(moveNumber). \(moves[num])"
if num + 1 < moves.endIndex {
moveString += " \(moves[num + 1])"
}
if moveString.count + moveLine.count < 80 {
if !moveLine.isEmpty {
moveString = " \(moveString)"
}
moveLine += moveString
} else {
result += "\n\(moveLine)"
moveLine = moveString
}
}
if !moveLine.isEmpty {
result += "\n\(moveLine)"
}
if let outcomeString = outcome?.description {
if moveLine.isEmpty {
result += "\n\(outcomeString)"
} else if outcomeString.count + moveLine.count < 80 {
result += " \(outcomeString)"
} else {
result += "\n\(outcomeString)"
}
}
return result
}
}
private extension Character {
static let newlines: Set<Character> = ["\u{000A}", "\u{000B}", "\u{000C}", "\u{000D}",
"\u{0085}", "\u{2028}", "\u{2029}"]
static let whitespaces: Set<Character> = ["\u{0020}", "\u{00A0}", "\u{1680}", "\u{180E}", "\u{2000}",
"\u{2001}", "\u{2002}", "\u{2003}", "\u{2004}", "\u{2005}",
"\u{2006}", "\u{2007}", "\u{2008}", "\u{2009}", "\u{200A}",
"\u{200B}", "\u{202F}", "\u{205F}", "\u{3000}", "\u{FEFF}"]
static let digits: Set<Character> = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
var isDigit: Bool {
return Character.digits.contains(self)
}
}
private extension String {
var _lastIndex: Index {
return index(before: endIndex)
}
@inline(__always)
func _split(by set: Set<Character>) -> [String] {
return split(whereSeparator: set.contains).map(String.init)
}
@inline(__always)
func _splitByNewlines() -> [String] {
return _split(by: Character.newlines)
}
@inline(__always)
func _splitByWhitespaces() -> [String] {
return _split(by: Character.whitespaces)
}
@inline(__always)
func _tagPair() throws -> (String, String) {
guard last == "]" else {
throw PGN.ParseError.noClosingBracket(self)
}
let startIndex = index(after: self.startIndex)
let endIndex = index(before: self.endIndex)
let tokens = String(self[startIndex ..< endIndex])._split(by: ["\""])
guard tokens.count == 2 else {
throw PGN.ParseError.tagPairTokenCount(tokens)
}
let tagParts = tokens[0]._splitByWhitespaces()
guard tagParts.count == 1 else {
throw PGN.ParseError.tagPairTokenCount(tagParts)
}
return (tagParts[0], tokens[1])
}
@inline(__always)
func _moves() throws -> (moves: [String], outcome: Game.Outcome?) {
var stripped = ""
var ravDepth = 0
var startIndex = self.startIndex
let lastIndex = _lastIndex
for (index, character) in zip(indices, self) {
if character == "(" {
if ravDepth == 0 {
stripped += self[startIndex ..< index]
}
ravDepth += 1
} else if character == ")" {
ravDepth -= 1
if ravDepth == 0 {
startIndex = self.index(after: index)
}
} else if index == lastIndex && ravDepth == 0 {
stripped += self[startIndex ... index]
}
}
guard ravDepth == 0 else {
throw PGN.ParseError.parenthesisCountForRAV(self)
}
let tokens = stripped._split(by: [" ", "."])
let moves = tokens.filter({ $0.first?.isDigit == false }).map { $0.replacingOccurrences(of: "?", with: "").replacingOccurrences(of: "!", with: "")}
let outcome = tokens.last.flatMap(Game.Outcome.init)
return (moves, outcome)
}
@inline(__always)
func _commentsStripped(strings consideringStrings: Bool) throws -> String {
var stripped = ""
var startIndex = self.startIndex
let lastIndex = _lastIndex
var afterEscape = false
var inString = false
var inComment = false
for (index, character) in zip(indices, self) {
if character == "\\" {
afterEscape = true
continue
}
if character == "\"" {
if !inComment {
guard consideringStrings else {
throw PGN.ParseError.unexpectedQuote(self)
}
if !inString {
inString = true
} else if !afterEscape {
inString = false
}
}
} else if !inString {
if character == ";" && !inComment {
stripped += self[startIndex ..< index]
break
} else if character == "{" && !inComment {
inComment = true
stripped += self[startIndex ..< index]
} else if character == "}" {
guard inComment else {
throw PGN.ParseError.unexpectedClosingBrace(self)
}
inComment = false
startIndex = self.index(after: index)
}
}
if index >= startIndex && index == lastIndex && !inComment {
stripped += self[startIndex ... index]
}
afterEscape = false
}
guard !inString else {
throw PGN.ParseError.noClosingQuote(self)
}
guard !inComment else {
throw PGN.ParseError.noClosingBrace(self)
}
return stripped
}
}
/// Returns a Boolean value indicating whether two values are equal.
public func == (lhs: PGN, rhs: PGN) -> Bool {
return lhs.tagPairs == rhs.tagPairs
&& lhs.moves == rhs.moves
}
|