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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +50 -0
  3. data/.github/workflows/publish.yml +75 -0
  4. data/.rubocop.yml +38 -0
  5. data/CHANGELOG.md +52 -0
  6. data/README.md +104 -4
  7. data/Rakefile +20 -0
  8. data/bench/.keep +0 -0
  9. data/bench/IMPROVEMENTS.md +75 -0
  10. data/bench/baseline_moves.pre-optimization.txt +22 -0
  11. data/bench/baseline_moves.txt +22 -0
  12. data/bench/baseline_parse.pre-optimization.txt +25 -0
  13. data/bench/baseline_parse.racc.txt +25 -0
  14. data/bench/baseline_parse.txt +25 -0
  15. data/bench/profile_moves.rb +53 -0
  16. data/bench/profile_parse.rb +44 -0
  17. data/docs/superpowers/plans/2026-08-12-efficiency-optimizations.md +573 -0
  18. data/docs/superpowers/plans/2026-08-12-efficiency-tests-and-profiling.md +1091 -0
  19. data/docs/superpowers/plans/2026-08-12-to-pgn-serialization.md +162 -0
  20. data/docs/superpowers/plans/2026-08-13-whittle-to-racc-migration.md +130 -0
  21. data/docs/superpowers/specs/2026-08-12-to-pgn-serialization-design.md +217 -0
  22. data/lib/pgn/board.rb +33 -15
  23. data/lib/pgn/fen.rb +16 -8
  24. data/lib/pgn/game.rb +11 -2
  25. data/lib/pgn/lexer.rb +201 -0
  26. data/lib/pgn/move.rb +7 -3
  27. data/lib/pgn/move_calculator.rb +18 -17
  28. data/lib/pgn/parser.rb +19 -199
  29. data/lib/pgn/pgn_parser.rb +392 -0
  30. data/lib/pgn/pgn_parser.y +142 -0
  31. data/lib/pgn/serializer.rb +141 -0
  32. data/lib/pgn/version.rb +1 -1
  33. data/lib/pgn.rb +3 -0
  34. data/pgn2.gemspec +12 -2
  35. data/spec/board_spec.rb +111 -0
  36. data/spec/fen_spec.rb +25 -0
  37. data/spec/game_spec.rb +74 -0
  38. data/spec/lexer_spec.rb +153 -0
  39. data/spec/move_calculator_spec.rb +226 -0
  40. data/spec/move_spec.rb +136 -0
  41. data/spec/parser_explicit_spec.rb +210 -0
  42. data/spec/parser_spec.rb +15 -0
  43. data/spec/pgn_files/doublequotes.pgn +21 -0
  44. data/spec/pgn_files/specialcharacters.pgn +79 -0
  45. data/spec/position_spec.rb +73 -0
  46. data/spec/serializer_spec.rb +89 -0
  47. data/spec/spec_helper.rb +0 -1
  48. 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
@@ -1,3 +1,3 @@
1
1
  module PGN
2
- VERSION = '0.4.0'.freeze
2
+ VERSION = '1.0.0'.freeze
3
3
  end
data/lib/pgn.rb CHANGED
@@ -3,8 +3,11 @@ require 'pgn/fen'
3
3
  require 'pgn/game'
4
4
  require 'pgn/move'
5
5
  require 'pgn/move_calculator'
6
+ require 'pgn/lexer'
7
+ require 'pgn/pgn_parser'
6
8
  require 'pgn/parser'
7
9
  require 'pgn/position'
10
+ require 'pgn/serializer'
8
11
  require 'pgn/version'
9
12
 
10
13
  module PGN