pgn2 0.4.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +50 -0
- data/.github/workflows/publish.yml +75 -0
- data/.rubocop.yml +38 -0
- data/CHANGELOG.md +52 -0
- data/README.md +104 -4
- data/Rakefile +20 -0
- data/bench/.keep +0 -0
- data/bench/IMPROVEMENTS.md +75 -0
- data/bench/baseline_moves.pre-optimization.txt +22 -0
- data/bench/baseline_moves.txt +22 -0
- data/bench/baseline_parse.pre-optimization.txt +25 -0
- data/bench/baseline_parse.racc.txt +25 -0
- data/bench/baseline_parse.txt +25 -0
- data/bench/profile_moves.rb +53 -0
- data/bench/profile_parse.rb +44 -0
- data/docs/superpowers/plans/2026-08-12-efficiency-optimizations.md +573 -0
- data/docs/superpowers/plans/2026-08-12-efficiency-tests-and-profiling.md +1091 -0
- data/docs/superpowers/plans/2026-08-12-to-pgn-serialization.md +162 -0
- data/docs/superpowers/plans/2026-08-13-whittle-to-racc-migration.md +130 -0
- data/docs/superpowers/specs/2026-08-12-to-pgn-serialization-design.md +217 -0
- data/lib/pgn/board.rb +33 -15
- data/lib/pgn/fen.rb +16 -8
- data/lib/pgn/game.rb +11 -2
- data/lib/pgn/lexer.rb +201 -0
- data/lib/pgn/move.rb +7 -3
- data/lib/pgn/move_calculator.rb +18 -17
- data/lib/pgn/parser.rb +19 -199
- data/lib/pgn/pgn_parser.rb +392 -0
- data/lib/pgn/pgn_parser.y +142 -0
- data/lib/pgn/serializer.rb +141 -0
- data/lib/pgn/version.rb +1 -1
- data/lib/pgn.rb +3 -0
- data/pgn2.gemspec +12 -2
- data/spec/board_spec.rb +111 -0
- data/spec/fen_spec.rb +25 -0
- data/spec/game_spec.rb +74 -0
- data/spec/lexer_spec.rb +153 -0
- data/spec/move_calculator_spec.rb +226 -0
- data/spec/move_spec.rb +136 -0
- data/spec/parser_explicit_spec.rb +210 -0
- data/spec/parser_spec.rb +15 -0
- data/spec/pgn_files/doublequotes.pgn +21 -0
- data/spec/pgn_files/specialcharacters.pgn +79 -0
- data/spec/position_spec.rb +73 -0
- data/spec/serializer_spec.rb +89 -0
- data/spec/spec_helper.rb +0 -1
- metadata +99 -15
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
#
|
|
2
|
+
# DO NOT MODIFY!!!!
|
|
3
|
+
# This file is automatically generated by Racc 1.8.1
|
|
4
|
+
# from Racc grammar file "pgn_parser.y".
|
|
5
|
+
#
|
|
6
|
+
|
|
7
|
+
require 'racc/parser.rb'
|
|
8
|
+
module PGN
|
|
9
|
+
class PgnParser < Racc::Parser
|
|
10
|
+
|
|
11
|
+
module_eval(<<'...end pgn_parser.y/module_eval...', 'pgn_parser.y', 91)
|
|
12
|
+
|
|
13
|
+
def parse(input)
|
|
14
|
+
@lexer = PGN::Lexer.new(input)
|
|
15
|
+
@input = input
|
|
16
|
+
games = do_parse
|
|
17
|
+
assign_pgn!(games)
|
|
18
|
+
games
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def next_token
|
|
22
|
+
t = @lexer.next_token
|
|
23
|
+
return [false, false] unless t
|
|
24
|
+
[translate_type(t.type), t.value]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def translate_type(sym)
|
|
30
|
+
case sym
|
|
31
|
+
when :string then :STRING
|
|
32
|
+
when :comment then :COMMENT
|
|
33
|
+
when :game_termination then :GAME_TERMINATION
|
|
34
|
+
when :san_move then :SAN_MOVE
|
|
35
|
+
when :nag then :NAG
|
|
36
|
+
when :move_number then :MOVE_NUMBER
|
|
37
|
+
when :tag_name then :TAG_NAME
|
|
38
|
+
when :lbracket then '['
|
|
39
|
+
when :rbracket then ']'
|
|
40
|
+
when :lparen then '('
|
|
41
|
+
when :rparen then ')'
|
|
42
|
+
else
|
|
43
|
+
raise "PGN::Lexer produced an unknown token type: #{sym.inspect}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Slice the verbatim raw PGN text per game out of the original input using
|
|
48
|
+
# the per-game content-start byte offsets recorded by the lexer.
|
|
49
|
+
#
|
|
50
|
+
# Game 0 spans from byte 0; game k (k >= 1) spans from its first
|
|
51
|
+
# non-discarded token's offset; each game ends where the next game begins
|
|
52
|
+
# (or at EOF for the last game). Leading/trailing discarded tokens (a
|
|
53
|
+
# game's leading `%` comment, or the whitespace between games) fold into
|
|
54
|
+
# the adjacent game's span, reproducing the legacy accumulator output.
|
|
55
|
+
def assign_pgn!(games)
|
|
56
|
+
starts = @lexer.game_starts
|
|
57
|
+
total = @input.bytesize
|
|
58
|
+
games.each_with_index do |game, k|
|
|
59
|
+
start = (k == 0) ? 0 : starts[k]
|
|
60
|
+
fin = (k + 1 < games.length) ? starts[k + 1] : total
|
|
61
|
+
game[:pgn] = @input.byteslice(start...fin)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
...end pgn_parser.y/module_eval...
|
|
65
|
+
##### State transition tables begin ###
|
|
66
|
+
|
|
67
|
+
racc_action_table = [
|
|
68
|
+
2, 16, 7, 17, 22, 14, 6, 24, 11, 6,
|
|
69
|
+
31, 16, 12, 17, 29, 14, 18, 30, 21, 25,
|
|
70
|
+
21, 24, 30 ]
|
|
71
|
+
|
|
72
|
+
racc_action_check = [
|
|
73
|
+
1, 27, 2, 27, 17, 27, 5, 17, 6, 1,
|
|
74
|
+
27, 9, 9, 9, 23, 9, 11, 23, 15, 18,
|
|
75
|
+
20, 22, 28 ]
|
|
76
|
+
|
|
77
|
+
racc_action_pointer = [
|
|
78
|
+
nil, 0, 2, nil, nil, -3, 0, nil, nil, 8,
|
|
79
|
+
nil, 14, nil, nil, nil, 7, nil, 1, 9, nil,
|
|
80
|
+
9, nil, 15, 11, nil, nil, nil, -2, 16, nil,
|
|
81
|
+
nil, nil ]
|
|
82
|
+
|
|
83
|
+
racc_action_default = [
|
|
84
|
+
-1, -24, -24, -2, -8, -4, -24, 32, -3, -24,
|
|
85
|
+
-5, -24, -7, -9, -10, -11, -13, -14, -24, -12,
|
|
86
|
+
-21, -8, -15, -16, -19, -6, -22, -24, -18, -17,
|
|
87
|
+
-20, -23 ]
|
|
88
|
+
|
|
89
|
+
racc_goto_table = [
|
|
90
|
+
9, 19, 23, 1, 4, 3, 26, 28, 10, 8,
|
|
91
|
+
nil, nil, nil, nil, nil, nil, nil, 27 ]
|
|
92
|
+
|
|
93
|
+
racc_goto_check = [
|
|
94
|
+
6, 9, 10, 1, 3, 2, 9, 10, 3, 4,
|
|
95
|
+
nil, nil, nil, nil, nil, nil, nil, 6 ]
|
|
96
|
+
|
|
97
|
+
racc_goto_pointer = [
|
|
98
|
+
nil, 3, 4, 3, 5, nil, -4, nil, nil, -14,
|
|
99
|
+
-15, nil ]
|
|
100
|
+
|
|
101
|
+
racc_goto_default = [
|
|
102
|
+
nil, nil, nil, nil, nil, 5, nil, 13, 15, nil,
|
|
103
|
+
nil, 20 ]
|
|
104
|
+
|
|
105
|
+
racc_reduce_table = [
|
|
106
|
+
0, 0, :racc_error,
|
|
107
|
+
0, 14, :_reduce_1,
|
|
108
|
+
2, 14, :_reduce_2,
|
|
109
|
+
2, 15, :_reduce_3,
|
|
110
|
+
1, 16, :_reduce_4,
|
|
111
|
+
2, 16, :_reduce_5,
|
|
112
|
+
4, 18, :_reduce_6,
|
|
113
|
+
2, 17, :_reduce_7,
|
|
114
|
+
0, 19, :_reduce_8,
|
|
115
|
+
2, 19, :_reduce_9,
|
|
116
|
+
1, 20, :_reduce_10,
|
|
117
|
+
1, 20, :_reduce_none,
|
|
118
|
+
2, 20, :_reduce_12,
|
|
119
|
+
1, 20, :_reduce_13,
|
|
120
|
+
1, 21, :_reduce_14,
|
|
121
|
+
2, 21, :_reduce_15,
|
|
122
|
+
2, 21, :_reduce_16,
|
|
123
|
+
3, 21, :_reduce_17,
|
|
124
|
+
3, 21, :_reduce_18,
|
|
125
|
+
1, 23, :_reduce_19,
|
|
126
|
+
2, 23, :_reduce_20,
|
|
127
|
+
1, 22, :_reduce_21,
|
|
128
|
+
2, 22, :_reduce_22,
|
|
129
|
+
3, 24, :_reduce_23 ]
|
|
130
|
+
|
|
131
|
+
racc_reduce_n = 24
|
|
132
|
+
|
|
133
|
+
racc_shift_n = 32
|
|
134
|
+
|
|
135
|
+
racc_token_table = {
|
|
136
|
+
false => 0,
|
|
137
|
+
:error => 1,
|
|
138
|
+
:STRING => 2,
|
|
139
|
+
:COMMENT => 3,
|
|
140
|
+
:GAME_TERMINATION => 4,
|
|
141
|
+
:SAN_MOVE => 5,
|
|
142
|
+
:NAG => 6,
|
|
143
|
+
:MOVE_NUMBER => 7,
|
|
144
|
+
:TAG_NAME => 8,
|
|
145
|
+
"[" => 9,
|
|
146
|
+
"]" => 10,
|
|
147
|
+
"(" => 11,
|
|
148
|
+
")" => 12 }
|
|
149
|
+
|
|
150
|
+
racc_nt_base = 13
|
|
151
|
+
|
|
152
|
+
racc_use_result_var = true
|
|
153
|
+
|
|
154
|
+
Racc_arg = [
|
|
155
|
+
racc_action_table,
|
|
156
|
+
racc_action_check,
|
|
157
|
+
racc_action_default,
|
|
158
|
+
racc_action_pointer,
|
|
159
|
+
racc_goto_table,
|
|
160
|
+
racc_goto_check,
|
|
161
|
+
racc_goto_default,
|
|
162
|
+
racc_goto_pointer,
|
|
163
|
+
racc_nt_base,
|
|
164
|
+
racc_reduce_table,
|
|
165
|
+
racc_token_table,
|
|
166
|
+
racc_shift_n,
|
|
167
|
+
racc_reduce_n,
|
|
168
|
+
racc_use_result_var ]
|
|
169
|
+
Ractor.make_shareable(Racc_arg) if defined?(Ractor)
|
|
170
|
+
|
|
171
|
+
Racc_token_to_s_table = [
|
|
172
|
+
"$end",
|
|
173
|
+
"error",
|
|
174
|
+
"STRING",
|
|
175
|
+
"COMMENT",
|
|
176
|
+
"GAME_TERMINATION",
|
|
177
|
+
"SAN_MOVE",
|
|
178
|
+
"NAG",
|
|
179
|
+
"MOVE_NUMBER",
|
|
180
|
+
"TAG_NAME",
|
|
181
|
+
"\"[\"",
|
|
182
|
+
"\"]\"",
|
|
183
|
+
"\"(\"",
|
|
184
|
+
"\")\"",
|
|
185
|
+
"$start",
|
|
186
|
+
"pgn_database",
|
|
187
|
+
"pgn_game",
|
|
188
|
+
"tag_section",
|
|
189
|
+
"movetext_section",
|
|
190
|
+
"tag_pair",
|
|
191
|
+
"element_sequence",
|
|
192
|
+
"element",
|
|
193
|
+
"san_move_annotated",
|
|
194
|
+
"variation_list",
|
|
195
|
+
"annotation_list",
|
|
196
|
+
"variation" ]
|
|
197
|
+
Ractor.make_shareable(Racc_token_to_s_table) if defined?(Ractor)
|
|
198
|
+
|
|
199
|
+
Racc_debug_parser = false
|
|
200
|
+
|
|
201
|
+
##### State transition tables end #####
|
|
202
|
+
|
|
203
|
+
# reduce 0 omitted
|
|
204
|
+
|
|
205
|
+
module_eval(<<'.,.,', 'pgn_parser.y', 7)
|
|
206
|
+
def _reduce_1(val, _values, result)
|
|
207
|
+
result = []
|
|
208
|
+
result
|
|
209
|
+
end
|
|
210
|
+
.,.,
|
|
211
|
+
|
|
212
|
+
module_eval(<<'.,.,', 'pgn_parser.y', 8)
|
|
213
|
+
def _reduce_2(val, _values, result)
|
|
214
|
+
result = val[0] << val[1]
|
|
215
|
+
result
|
|
216
|
+
end
|
|
217
|
+
.,.,
|
|
218
|
+
|
|
219
|
+
module_eval(<<'.,.,', 'pgn_parser.y', 13)
|
|
220
|
+
def _reduce_3(val, _values, result)
|
|
221
|
+
result = val[1].pop
|
|
222
|
+
result = {
|
|
223
|
+
tags: val[0],
|
|
224
|
+
result: result,
|
|
225
|
+
moves: val[1],
|
|
226
|
+
pgn: nil,
|
|
227
|
+
comment: (@game_comment.tap { @game_comment = nil }),
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
result
|
|
231
|
+
end
|
|
232
|
+
.,.,
|
|
233
|
+
|
|
234
|
+
module_eval(<<'.,.,', 'pgn_parser.y', 24)
|
|
235
|
+
def _reduce_4(val, _values, result)
|
|
236
|
+
result = val[0]
|
|
237
|
+
result
|
|
238
|
+
end
|
|
239
|
+
.,.,
|
|
240
|
+
|
|
241
|
+
module_eval(<<'.,.,', 'pgn_parser.y', 27)
|
|
242
|
+
def _reduce_5(val, _values, result)
|
|
243
|
+
# Right-recursive with section.merge(pair) reproduces the legacy
|
|
244
|
+
# whittle parser's tag semantics exactly: reverse source insertion
|
|
245
|
+
# order, and first-occurrence-wins on duplicate keys. This keeps
|
|
246
|
+
# serialized tag order byte-compatible with the legacy behavior.
|
|
247
|
+
result = val[1].merge(val[0])
|
|
248
|
+
|
|
249
|
+
result
|
|
250
|
+
end
|
|
251
|
+
.,.,
|
|
252
|
+
|
|
253
|
+
module_eval(<<'.,.,', 'pgn_parser.y', 35)
|
|
254
|
+
def _reduce_6(val, _values, result)
|
|
255
|
+
result = { val[1] => val[2][1...-1] }
|
|
256
|
+
result
|
|
257
|
+
end
|
|
258
|
+
.,.,
|
|
259
|
+
|
|
260
|
+
module_eval(<<'.,.,', 'pgn_parser.y', 38)
|
|
261
|
+
def _reduce_7(val, _values, result)
|
|
262
|
+
result = val[0] << val[1]
|
|
263
|
+
result
|
|
264
|
+
end
|
|
265
|
+
.,.,
|
|
266
|
+
|
|
267
|
+
module_eval(<<'.,.,', 'pgn_parser.y', 41)
|
|
268
|
+
def _reduce_8(val, _values, result)
|
|
269
|
+
result = []
|
|
270
|
+
result
|
|
271
|
+
end
|
|
272
|
+
.,.,
|
|
273
|
+
|
|
274
|
+
module_eval(<<'.,.,', 'pgn_parser.y', 44)
|
|
275
|
+
def _reduce_9(val, _values, result)
|
|
276
|
+
result = val[1].nil? ? val[0] : val[0] << val[1]
|
|
277
|
+
|
|
278
|
+
result
|
|
279
|
+
end
|
|
280
|
+
.,.,
|
|
281
|
+
|
|
282
|
+
module_eval(<<'.,.,', 'pgn_parser.y', 48)
|
|
283
|
+
def _reduce_10(val, _values, result)
|
|
284
|
+
result = nil
|
|
285
|
+
result
|
|
286
|
+
end
|
|
287
|
+
.,.,
|
|
288
|
+
|
|
289
|
+
# reduce 11 omitted
|
|
290
|
+
|
|
291
|
+
module_eval(<<'.,.,', 'pgn_parser.y', 52)
|
|
292
|
+
def _reduce_12(val, _values, result)
|
|
293
|
+
result = val[0]
|
|
294
|
+
result.variations = val[1]
|
|
295
|
+
result
|
|
296
|
+
|
|
297
|
+
result
|
|
298
|
+
end
|
|
299
|
+
.,.,
|
|
300
|
+
|
|
301
|
+
module_eval(<<'.,.,', 'pgn_parser.y', 58)
|
|
302
|
+
def _reduce_13(val, _values, result)
|
|
303
|
+
# A standalone comment (not attached to a move) becomes the game
|
|
304
|
+
# comment; the last such comment in the game wins.
|
|
305
|
+
@game_comment = val[0]
|
|
306
|
+
result = nil
|
|
307
|
+
|
|
308
|
+
result
|
|
309
|
+
end
|
|
310
|
+
.,.,
|
|
311
|
+
|
|
312
|
+
module_eval(<<'.,.,', 'pgn_parser.y', 65)
|
|
313
|
+
def _reduce_14(val, _values, result)
|
|
314
|
+
result = MoveText.new(val[0])
|
|
315
|
+
result
|
|
316
|
+
end
|
|
317
|
+
.,.,
|
|
318
|
+
|
|
319
|
+
module_eval(<<'.,.,', 'pgn_parser.y', 66)
|
|
320
|
+
def _reduce_15(val, _values, result)
|
|
321
|
+
result = MoveText.new(val[0], nil, val[1])
|
|
322
|
+
result
|
|
323
|
+
end
|
|
324
|
+
.,.,
|
|
325
|
+
|
|
326
|
+
module_eval(<<'.,.,', 'pgn_parser.y', 67)
|
|
327
|
+
def _reduce_16(val, _values, result)
|
|
328
|
+
result = MoveText.new(val[0], val[1])
|
|
329
|
+
result
|
|
330
|
+
end
|
|
331
|
+
.,.,
|
|
332
|
+
|
|
333
|
+
module_eval(<<'.,.,', 'pgn_parser.y', 68)
|
|
334
|
+
def _reduce_17(val, _values, result)
|
|
335
|
+
result = MoveText.new(val[0], val[1], val[2])
|
|
336
|
+
result
|
|
337
|
+
end
|
|
338
|
+
.,.,
|
|
339
|
+
|
|
340
|
+
module_eval(<<'.,.,', 'pgn_parser.y', 69)
|
|
341
|
+
def _reduce_18(val, _values, result)
|
|
342
|
+
result = MoveText.new(val[0], val[2], val[1])
|
|
343
|
+
result
|
|
344
|
+
end
|
|
345
|
+
.,.,
|
|
346
|
+
|
|
347
|
+
module_eval(<<'.,.,', 'pgn_parser.y', 72)
|
|
348
|
+
def _reduce_19(val, _values, result)
|
|
349
|
+
result = [val[0]]
|
|
350
|
+
result
|
|
351
|
+
end
|
|
352
|
+
.,.,
|
|
353
|
+
|
|
354
|
+
module_eval(<<'.,.,', 'pgn_parser.y', 73)
|
|
355
|
+
def _reduce_20(val, _values, result)
|
|
356
|
+
result = val[0] << val[1]
|
|
357
|
+
result
|
|
358
|
+
end
|
|
359
|
+
.,.,
|
|
360
|
+
|
|
361
|
+
module_eval(<<'.,.,', 'pgn_parser.y', 76)
|
|
362
|
+
def _reduce_21(val, _values, result)
|
|
363
|
+
result = [val[0]]
|
|
364
|
+
result
|
|
365
|
+
end
|
|
366
|
+
.,.,
|
|
367
|
+
|
|
368
|
+
module_eval(<<'.,.,', 'pgn_parser.y', 79)
|
|
369
|
+
def _reduce_22(val, _values, result)
|
|
370
|
+
# Right-recursive prepend reproduces the legacy whittle parser's
|
|
371
|
+
# variation-order reversal on every parse. This keeps parsed-game
|
|
372
|
+
# serialization byte-compatible with the legacy behavior; the quirk
|
|
373
|
+
# can be fixed in a separate change.
|
|
374
|
+
result = val[1] << val[0]
|
|
375
|
+
|
|
376
|
+
result
|
|
377
|
+
end
|
|
378
|
+
.,.,
|
|
379
|
+
|
|
380
|
+
module_eval(<<'.,.,', 'pgn_parser.y', 87)
|
|
381
|
+
def _reduce_23(val, _values, result)
|
|
382
|
+
result = val[1]
|
|
383
|
+
result
|
|
384
|
+
end
|
|
385
|
+
.,.,
|
|
386
|
+
|
|
387
|
+
def _reduce_none(val, _values, result)
|
|
388
|
+
val[0]
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
end # class PgnParser
|
|
392
|
+
end # module PGN
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
class PGN::PgnParser
|
|
2
|
+
|
|
3
|
+
token STRING COMMENT GAME_TERMINATION SAN_MOVE NAG MOVE_NUMBER TAG_NAME
|
|
4
|
+
|
|
5
|
+
rule
|
|
6
|
+
|
|
7
|
+
pgn_database:
|
|
8
|
+
/* empty */ { result = [] }
|
|
9
|
+
| pgn_database pgn_game { result = val[0] << val[1] }
|
|
10
|
+
|
|
11
|
+
pgn_game:
|
|
12
|
+
tag_section movetext_section
|
|
13
|
+
{
|
|
14
|
+
result = val[1].pop
|
|
15
|
+
result = {
|
|
16
|
+
tags: val[0],
|
|
17
|
+
result: result,
|
|
18
|
+
moves: val[1],
|
|
19
|
+
pgn: nil,
|
|
20
|
+
comment: (@game_comment.tap { @game_comment = nil }),
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
tag_section:
|
|
25
|
+
tag_pair { result = val[0] }
|
|
26
|
+
| tag_pair tag_section
|
|
27
|
+
{
|
|
28
|
+
# Right-recursive with section.merge(pair) reproduces the legacy
|
|
29
|
+
# whittle parser's tag semantics exactly: reverse source insertion
|
|
30
|
+
# order, and first-occurrence-wins on duplicate keys. This keeps
|
|
31
|
+
# serialized tag order byte-compatible with the legacy behavior.
|
|
32
|
+
result = val[1].merge(val[0])
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
tag_pair:
|
|
36
|
+
'[' TAG_NAME STRING ']' { result = { val[1] => val[2][1...-1] } }
|
|
37
|
+
|
|
38
|
+
movetext_section:
|
|
39
|
+
element_sequence GAME_TERMINATION { result = val[0] << val[1] }
|
|
40
|
+
|
|
41
|
+
element_sequence:
|
|
42
|
+
/* empty */ { result = [] }
|
|
43
|
+
| element_sequence element
|
|
44
|
+
{
|
|
45
|
+
result = val[1].nil? ? val[0] : val[0] << val[1]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
element:
|
|
49
|
+
MOVE_NUMBER { result = nil }
|
|
50
|
+
| san_move_annotated
|
|
51
|
+
| san_move_annotated variation_list
|
|
52
|
+
{
|
|
53
|
+
result = val[0]
|
|
54
|
+
result.variations = val[1]
|
|
55
|
+
result
|
|
56
|
+
}
|
|
57
|
+
| COMMENT
|
|
58
|
+
{
|
|
59
|
+
# A standalone comment (not attached to a move) becomes the game
|
|
60
|
+
# comment; the last such comment in the game wins.
|
|
61
|
+
@game_comment = val[0]
|
|
62
|
+
result = nil
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
san_move_annotated:
|
|
66
|
+
SAN_MOVE { result = MoveText.new(val[0]) }
|
|
67
|
+
| SAN_MOVE COMMENT { result = MoveText.new(val[0], nil, val[1]) }
|
|
68
|
+
| SAN_MOVE annotation_list { result = MoveText.new(val[0], val[1]) }
|
|
69
|
+
| SAN_MOVE annotation_list COMMENT { result = MoveText.new(val[0], val[1], val[2]) }
|
|
70
|
+
| SAN_MOVE COMMENT annotation_list { result = MoveText.new(val[0], val[2], val[1]) }
|
|
71
|
+
|
|
72
|
+
annotation_list:
|
|
73
|
+
NAG { result = [val[0]] }
|
|
74
|
+
| annotation_list NAG { result = val[0] << val[1] }
|
|
75
|
+
|
|
76
|
+
variation_list:
|
|
77
|
+
variation { result = [val[0]] }
|
|
78
|
+
| variation variation_list
|
|
79
|
+
{
|
|
80
|
+
# Right-recursive prepend reproduces the legacy whittle parser's
|
|
81
|
+
# variation-order reversal on every parse. This keeps parsed-game
|
|
82
|
+
# serialization byte-compatible with the legacy behavior; the quirk
|
|
83
|
+
# can be fixed in a separate change.
|
|
84
|
+
result = val[1] << val[0]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
variation:
|
|
88
|
+
'(' element_sequence ')' { result = val[1] }
|
|
89
|
+
|
|
90
|
+
---- inner
|
|
91
|
+
|
|
92
|
+
def parse(input)
|
|
93
|
+
@lexer = PGN::Lexer.new(input)
|
|
94
|
+
@input = input
|
|
95
|
+
games = do_parse
|
|
96
|
+
assign_pgn!(games)
|
|
97
|
+
games
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def next_token
|
|
101
|
+
t = @lexer.next_token
|
|
102
|
+
return [false, false] unless t
|
|
103
|
+
[translate_type(t.type), t.value]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def translate_type(sym)
|
|
109
|
+
case sym
|
|
110
|
+
when :string then :STRING
|
|
111
|
+
when :comment then :COMMENT
|
|
112
|
+
when :game_termination then :GAME_TERMINATION
|
|
113
|
+
when :san_move then :SAN_MOVE
|
|
114
|
+
when :nag then :NAG
|
|
115
|
+
when :move_number then :MOVE_NUMBER
|
|
116
|
+
when :tag_name then :TAG_NAME
|
|
117
|
+
when :lbracket then '['
|
|
118
|
+
when :rbracket then ']'
|
|
119
|
+
when :lparen then '('
|
|
120
|
+
when :rparen then ')'
|
|
121
|
+
else
|
|
122
|
+
raise "PGN::Lexer produced an unknown token type: #{sym.inspect}"
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Slice the verbatim raw PGN text per game out of the original input using
|
|
127
|
+
# the per-game content-start byte offsets recorded by the lexer.
|
|
128
|
+
#
|
|
129
|
+
# Game 0 spans from byte 0; game k (k >= 1) spans from its first
|
|
130
|
+
# non-discarded token's offset; each game ends where the next game begins
|
|
131
|
+
# (or at EOF for the last game). Leading/trailing discarded tokens (a
|
|
132
|
+
# game's leading `%` comment, or the whitespace between games) fold into
|
|
133
|
+
# the adjacent game's span, reproducing the legacy accumulator output.
|
|
134
|
+
def assign_pgn!(games)
|
|
135
|
+
starts = @lexer.game_starts
|
|
136
|
+
total = @input.bytesize
|
|
137
|
+
games.each_with_index do |game, k|
|
|
138
|
+
start = (k == 0) ? 0 : starts[k]
|
|
139
|
+
fin = (k + 1 < games.length) ? starts[k + 1] : total
|
|
140
|
+
game[:pgn] = @input.byteslice(start...fin)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
module PGN
|
|
2
|
+
# {PGN::Serializer} converts a {PGN::Game} into a canonical PGN string.
|
|
3
|
+
#
|
|
4
|
+
# Serialization is purely structural: it reads tags, moves, comments,
|
|
5
|
+
# annotations, variations, and result from the game and emits valid PGN
|
|
6
|
+
# without replaying any moves on a board. Move numbering state is seeded
|
|
7
|
+
# from `game.starting_position` so games starting from a FEN tag (e.g.
|
|
8
|
+
# with black to move) are numbered correctly.
|
|
9
|
+
#
|
|
10
|
+
# @see PGN::Game#to_pgn
|
|
11
|
+
#
|
|
12
|
+
class Serializer
|
|
13
|
+
# @param game [PGN::Game] the game to serialize
|
|
14
|
+
def initialize(game)
|
|
15
|
+
@game = game
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @return [String] a canonical PGN string ending with a trailing newline
|
|
19
|
+
def to_s
|
|
20
|
+
tag_section + "\n\n" + movetext_section + "\n"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
# Tag section: one tag pair per line in the order of `game.tags`. If
|
|
26
|
+
# `tags` is nil or empty, synthesize a `[Result "..."]` tag so the output
|
|
27
|
+
# stays parseable by the current grammar (which requires at least one
|
|
28
|
+
# tag pair).
|
|
29
|
+
def tag_section
|
|
30
|
+
tags = @game.tags
|
|
31
|
+
if tags.nil? || tags.empty?
|
|
32
|
+
%([Result "#{result_token}"])
|
|
33
|
+
else
|
|
34
|
+
tags.map { |key, value| %([#{key} "#{escape_tag(value)}"]) }.join("\n")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Movetext section: optional game comment, then the move line, then the
|
|
39
|
+
# result, all joined by spaces.
|
|
40
|
+
def movetext_section
|
|
41
|
+
tokens = []
|
|
42
|
+
if @game.comment && !@game.comment.empty?
|
|
43
|
+
tokens << "{ #{escape_comment(@game.comment)} }"
|
|
44
|
+
end
|
|
45
|
+
line = emit_line(@game.moves, starting_fullmove, starting_player)
|
|
46
|
+
tokens << line unless line.empty?
|
|
47
|
+
tokens << result_token
|
|
48
|
+
tokens.join(" ")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The result token: the game's result if present and non-empty, else "*".
|
|
52
|
+
def result_token
|
|
53
|
+
(@game.result.nil? || @game.result.empty?) ? "*" : @game.result
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Emit a single line (mainline or variation) of movetext, tracking the
|
|
57
|
+
# numbering state described in the design spec.
|
|
58
|
+
def emit_line(moves, fullmove, player)
|
|
59
|
+
tokens = []
|
|
60
|
+
prev_player = nil
|
|
61
|
+
prev_had_extras = false
|
|
62
|
+
|
|
63
|
+
moves.each do |move|
|
|
64
|
+
if player == :white
|
|
65
|
+
tokens << "#{fullmove}."
|
|
66
|
+
tokens << move_token(move, fullmove, player)
|
|
67
|
+
else # black
|
|
68
|
+
need_number = prev_player.nil? || prev_had_extras || prev_player != :white
|
|
69
|
+
tokens << "#{fullmove}..." if need_number
|
|
70
|
+
tokens << move_token(move, fullmove, player)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
prev_player = player
|
|
74
|
+
prev_had_extras = has_extras?(move)
|
|
75
|
+
fullmove += 1 if player == :black
|
|
76
|
+
player = opposite(player)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
tokens.join(" ")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# A move token: notation plus trailing extras (annotation, comment,
|
|
83
|
+
# variations), joined by spaces. Variations are serialized recursively
|
|
84
|
+
# from the position *before* the move (the same fullmove/player).
|
|
85
|
+
def move_token(move, fullmove, player)
|
|
86
|
+
parts = [move.notation]
|
|
87
|
+
(move.annotation || []).each { |a| parts << a }
|
|
88
|
+
if move.comment && !move.comment.empty?
|
|
89
|
+
parts << "{ #{escape_comment(move.comment)} }"
|
|
90
|
+
end
|
|
91
|
+
(move.variations || []).each do |variation|
|
|
92
|
+
parts << "(#{emit_line(variation, fullmove, player)})"
|
|
93
|
+
end
|
|
94
|
+
parts.join(" ")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Whether a move carries any annotation, comment, or variation.
|
|
98
|
+
def has_extras?(move)
|
|
99
|
+
(!move.annotation.nil? && !move.annotation.empty?) ||
|
|
100
|
+
(!move.comment.nil? && !move.comment.empty?) ||
|
|
101
|
+
(!move.variations.nil? && !move.variations.empty?)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def starting_fullmove
|
|
105
|
+
@game.starting_position.fullmove
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def starting_player
|
|
109
|
+
@game.starting_position.player
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def opposite(player)
|
|
113
|
+
player == :white ? :black : :white
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Escape backslashes and double quotes for a tag value. The block form of
|
|
117
|
+
# gsub is used so the replacement string is taken literally (gsub's
|
|
118
|
+
# string replacement would otherwise re-interpret backslashes).
|
|
119
|
+
def escape_tag(value)
|
|
120
|
+
value.to_s
|
|
121
|
+
.gsub("\\") { "\\\\" }
|
|
122
|
+
.gsub('"') { "\\\"" }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Escape backslashes and braces for a comment body (block form, so the
|
|
126
|
+
# replacement string is taken literally — gsub's string replacement
|
|
127
|
+
# would otherwise re-interpret backslashes).
|
|
128
|
+
#
|
|
129
|
+
# Note: the current parser's `MoveText#clean_text` does not unescape,
|
|
130
|
+
# so comments containing literal braces (e.g. the `nested_comments.pgn`
|
|
131
|
+
# fixture) will not round-trip byte-for-byte until parser improvements
|
|
132
|
+
# (sub-project 3) add unescaping. This matches the design spec's
|
|
133
|
+
# acknowledged v1 limitation.
|
|
134
|
+
def escape_comment(text)
|
|
135
|
+
text.to_s
|
|
136
|
+
.gsub("\\") { "\\\\" }
|
|
137
|
+
.gsub("{") { "\\{" }
|
|
138
|
+
.gsub("}") { "\\}" }
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
data/lib/pgn/version.rb
CHANGED
data/lib/pgn.rb
CHANGED