pgn2 0.4.0 → 1.1.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 (55) 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/.github/workflows/release.yml +103 -0
  5. data/.gitignore +2 -1
  6. data/.rubocop.yml +38 -0
  7. data/CHANGELOG.md +84 -0
  8. data/README.md +122 -4
  9. data/Rakefile +20 -0
  10. data/TODO.md +9 -0
  11. data/bench/.keep +0 -0
  12. data/bench/IMPROVEMENTS.md +143 -0
  13. data/bench/baseline_moves.pre-optimization.txt +22 -0
  14. data/bench/baseline_moves.pre-quickwins.txt +23 -0
  15. data/bench/baseline_moves.txt +22 -0
  16. data/bench/baseline_parse.pre-optimization.txt +25 -0
  17. data/bench/baseline_parse.pre-quickwins.txt +26 -0
  18. data/bench/baseline_parse.racc.txt +25 -0
  19. data/bench/baseline_parse.txt +25 -0
  20. data/bench/profile_moves.rb +53 -0
  21. data/bench/profile_parse.rb +44 -0
  22. data/docs/superpowers/plans/2026-08-12-efficiency-optimizations.md +573 -0
  23. data/docs/superpowers/plans/2026-08-12-efficiency-tests-and-profiling.md +1091 -0
  24. data/docs/superpowers/plans/2026-08-12-to-pgn-serialization.md +162 -0
  25. data/docs/superpowers/plans/2026-08-13-whittle-to-racc-migration.md +130 -0
  26. data/docs/superpowers/specs/2026-08-12-to-pgn-serialization-design.md +217 -0
  27. data/docs/superpowers/specs/2026-08-13-pgn-performance-quick-wins-design.md +227 -0
  28. data/lib/pgn/board.rb +33 -15
  29. data/lib/pgn/fen.rb +16 -8
  30. data/lib/pgn/game.rb +23 -3
  31. data/lib/pgn/lexer.rb +223 -0
  32. data/lib/pgn/move.rb +12 -5
  33. data/lib/pgn/move_calculator.rb +27 -21
  34. data/lib/pgn/parser.rb +13 -203
  35. data/lib/pgn/pgn_parser.rb +393 -0
  36. data/lib/pgn/pgn_parser.y +140 -0
  37. data/lib/pgn/position.rb +3 -2
  38. data/lib/pgn/serializer.rb +141 -0
  39. data/lib/pgn/version.rb +1 -1
  40. data/lib/pgn.rb +3 -0
  41. data/pgn2.gemspec +12 -2
  42. data/spec/board_spec.rb +111 -0
  43. data/spec/fen_spec.rb +25 -0
  44. data/spec/game_spec.rb +74 -0
  45. data/spec/lexer_spec.rb +153 -0
  46. data/spec/move_calculator_spec.rb +226 -0
  47. data/spec/move_spec.rb +136 -0
  48. data/spec/parser_explicit_spec.rb +210 -0
  49. data/spec/parser_spec.rb +6 -23
  50. data/spec/pgn_files/doublequotes.pgn +21 -0
  51. data/spec/pgn_files/specialcharacters.pgn +79 -0
  52. data/spec/position_spec.rb +73 -0
  53. data/spec/serializer_spec.rb +89 -0
  54. data/spec/spec_helper.rb +0 -1
  55. metadata +103 -15
@@ -0,0 +1,393 @@
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', 97)
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
+ pair = @lexer.next_token_pair
23
+ return [false, false] unless pair
24
+ type, value = pair
25
+ [translate_type(type), value]
26
+ end
27
+
28
+ private
29
+
30
+ # Punctuation tokens are racc'd by literal; everything else is racc'd by
31
+ # the upcased lexer symbol (e.g. :san_move -> :SAN_MOVE), so there's a
32
+ # single source of truth for the token vocabulary: PGN::Lexer's rules.
33
+ LITERAL_TOKENS = { lbracket: '[', rbracket: ']', lparen: '(', rparen: ')' }.freeze
34
+
35
+ def translate_type(sym)
36
+ LITERAL_TOKENS[sym] || sym.to_s.upcase.to_sym
37
+ end
38
+
39
+ # Slice the verbatim raw PGN text per game out of the original input using
40
+ # the per-game content-start byte offsets recorded by the lexer.
41
+ #
42
+ # Game 0 spans from byte 0; game k (k >= 1) spans from its first
43
+ # non-discarded token's offset; each game ends where the next game begins
44
+ # (or at EOF for the last game). Leading/trailing discarded tokens (a
45
+ # game's leading `%` comment, or the whitespace between games) fold into
46
+ # the adjacent game's span, reproducing the legacy accumulator output.
47
+ def assign_pgn!(games)
48
+ starts = @lexer.game_starts
49
+ total = @input.bytesize
50
+ games.each_with_index do |game, k|
51
+ start = (k == 0) ? 0 : starts[k]
52
+ fin = (k + 1 < games.length) ? starts[k + 1] : total
53
+ game[:pgn] = @input.byteslice(start...fin)
54
+ end
55
+ end
56
+ ...end pgn_parser.y/module_eval...
57
+ ##### State transition tables begin ###
58
+
59
+ racc_action_table = [
60
+ 2, 16, 7, 17, 23, 14, 6, 25, 11, 6,
61
+ 32, 16, 12, 17, 30, 14, 18, 31, 21, 26,
62
+ 21, 25, 31 ]
63
+
64
+ racc_action_check = [
65
+ 1, 28, 2, 28, 17, 28, 5, 17, 6, 1,
66
+ 28, 9, 9, 9, 24, 9, 11, 24, 15, 18,
67
+ 20, 23, 29 ]
68
+
69
+ racc_action_pointer = [
70
+ nil, 0, 2, nil, nil, -3, 0, nil, nil, 8,
71
+ nil, 14, nil, nil, nil, 7, nil, 1, 9, nil,
72
+ 9, nil, nil, 15, 11, nil, nil, nil, -2, 16,
73
+ nil, nil, nil ]
74
+
75
+ racc_action_default = [
76
+ -1, -25, -25, -2, -8, -4, -25, 33, -3, -25,
77
+ -5, -25, -7, -9, -10, -11, -13, -14, -25, -12,
78
+ -22, -8, -15, -16, -17, -20, -6, -23, -25, -19,
79
+ -18, -21, -24 ]
80
+
81
+ racc_goto_table = [
82
+ 9, 24, 4, 19, 1, 3, 10, 29, 27, 8,
83
+ 22, nil, nil, nil, nil, nil, nil, 28 ]
84
+
85
+ racc_goto_check = [
86
+ 6, 11, 3, 9, 1, 2, 3, 11, 9, 4,
87
+ 10, nil, nil, nil, nil, nil, nil, 6 ]
88
+
89
+ racc_goto_pointer = [
90
+ nil, 4, 4, 1, 5, nil, -4, nil, nil, -12,
91
+ -7, -16, nil ]
92
+
93
+ racc_goto_default = [
94
+ nil, nil, nil, nil, nil, 5, nil, 13, 15, nil,
95
+ nil, nil, 20 ]
96
+
97
+ racc_reduce_table = [
98
+ 0, 0, :racc_error,
99
+ 0, 14, :_reduce_1,
100
+ 2, 14, :_reduce_2,
101
+ 2, 15, :_reduce_3,
102
+ 1, 16, :_reduce_4,
103
+ 2, 16, :_reduce_5,
104
+ 4, 18, :_reduce_6,
105
+ 2, 17, :_reduce_7,
106
+ 0, 19, :_reduce_8,
107
+ 2, 19, :_reduce_9,
108
+ 1, 20, :_reduce_10,
109
+ 1, 20, :_reduce_none,
110
+ 2, 20, :_reduce_12,
111
+ 1, 20, :_reduce_13,
112
+ 1, 21, :_reduce_14,
113
+ 2, 21, :_reduce_15,
114
+ 1, 23, :_reduce_16,
115
+ 1, 23, :_reduce_17,
116
+ 2, 23, :_reduce_18,
117
+ 2, 23, :_reduce_19,
118
+ 1, 24, :_reduce_20,
119
+ 2, 24, :_reduce_21,
120
+ 1, 22, :_reduce_22,
121
+ 2, 22, :_reduce_23,
122
+ 3, 25, :_reduce_24 ]
123
+
124
+ racc_reduce_n = 25
125
+
126
+ racc_shift_n = 33
127
+
128
+ racc_token_table = {
129
+ false => 0,
130
+ :error => 1,
131
+ :STRING => 2,
132
+ :COMMENT => 3,
133
+ :GAME_TERMINATION => 4,
134
+ :SAN_MOVE => 5,
135
+ :NAG => 6,
136
+ :MOVE_NUMBER => 7,
137
+ :TAG_NAME => 8,
138
+ "[" => 9,
139
+ "]" => 10,
140
+ "(" => 11,
141
+ ")" => 12 }
142
+
143
+ racc_nt_base = 13
144
+
145
+ racc_use_result_var = true
146
+
147
+ Racc_arg = [
148
+ racc_action_table,
149
+ racc_action_check,
150
+ racc_action_default,
151
+ racc_action_pointer,
152
+ racc_goto_table,
153
+ racc_goto_check,
154
+ racc_goto_default,
155
+ racc_goto_pointer,
156
+ racc_nt_base,
157
+ racc_reduce_table,
158
+ racc_token_table,
159
+ racc_shift_n,
160
+ racc_reduce_n,
161
+ racc_use_result_var ]
162
+ Ractor.make_shareable(Racc_arg) if defined?(Ractor)
163
+
164
+ Racc_token_to_s_table = [
165
+ "$end",
166
+ "error",
167
+ "STRING",
168
+ "COMMENT",
169
+ "GAME_TERMINATION",
170
+ "SAN_MOVE",
171
+ "NAG",
172
+ "MOVE_NUMBER",
173
+ "TAG_NAME",
174
+ "\"[\"",
175
+ "\"]\"",
176
+ "\"(\"",
177
+ "\")\"",
178
+ "$start",
179
+ "pgn_database",
180
+ "pgn_game",
181
+ "tag_section",
182
+ "movetext_section",
183
+ "tag_pair",
184
+ "element_sequence",
185
+ "element",
186
+ "san_move_annotated",
187
+ "variation_list",
188
+ "move_trailer",
189
+ "annotation_list",
190
+ "variation" ]
191
+ Ractor.make_shareable(Racc_token_to_s_table) if defined?(Ractor)
192
+
193
+ Racc_debug_parser = false
194
+
195
+ ##### State transition tables end #####
196
+
197
+ # reduce 0 omitted
198
+
199
+ module_eval(<<'.,.,', 'pgn_parser.y', 9)
200
+ def _reduce_1(val, _values, result)
201
+ result = []
202
+ result
203
+ end
204
+ .,.,
205
+
206
+ module_eval(<<'.,.,', 'pgn_parser.y', 10)
207
+ def _reduce_2(val, _values, result)
208
+ result = val[0] << val[1]
209
+ result
210
+ end
211
+ .,.,
212
+
213
+ module_eval(<<'.,.,', 'pgn_parser.y', 15)
214
+ def _reduce_3(val, _values, result)
215
+ result = val[1].pop
216
+ result = {
217
+ tags: val[0],
218
+ result: result,
219
+ moves: val[1],
220
+ pgn: nil,
221
+ comment: (@game_comment.tap { @game_comment = nil }),
222
+ }
223
+
224
+ result
225
+ end
226
+ .,.,
227
+
228
+ module_eval(<<'.,.,', 'pgn_parser.y', 26)
229
+ def _reduce_4(val, _values, result)
230
+ result = val[0]
231
+ result
232
+ end
233
+ .,.,
234
+
235
+ module_eval(<<'.,.,', 'pgn_parser.y', 29)
236
+ def _reduce_5(val, _values, result)
237
+ # Right-recursive with section.merge(pair) reproduces the legacy
238
+ # whittle parser's tag semantics exactly: reverse source insertion
239
+ # order, and first-occurrence-wins on duplicate keys. This keeps
240
+ # serialized tag order byte-compatible with the legacy behavior.
241
+ result = val[1].merge(val[0])
242
+
243
+ result
244
+ end
245
+ .,.,
246
+
247
+ module_eval(<<'.,.,', 'pgn_parser.y', 37)
248
+ def _reduce_6(val, _values, result)
249
+ result = { val[1] => val[2][1...-1] }
250
+ result
251
+ end
252
+ .,.,
253
+
254
+ module_eval(<<'.,.,', 'pgn_parser.y', 40)
255
+ def _reduce_7(val, _values, result)
256
+ result = val[0] << val[1]
257
+ result
258
+ end
259
+ .,.,
260
+
261
+ module_eval(<<'.,.,', 'pgn_parser.y', 43)
262
+ def _reduce_8(val, _values, result)
263
+ result = []
264
+ result
265
+ end
266
+ .,.,
267
+
268
+ module_eval(<<'.,.,', 'pgn_parser.y', 46)
269
+ def _reduce_9(val, _values, result)
270
+ result = val[1].nil? ? val[0] : val[0] << val[1]
271
+
272
+ result
273
+ end
274
+ .,.,
275
+
276
+ module_eval(<<'.,.,', 'pgn_parser.y', 50)
277
+ def _reduce_10(val, _values, result)
278
+ result = nil
279
+ result
280
+ end
281
+ .,.,
282
+
283
+ # reduce 11 omitted
284
+
285
+ module_eval(<<'.,.,', 'pgn_parser.y', 54)
286
+ def _reduce_12(val, _values, result)
287
+ result = val[0]
288
+ result.variations = val[1]
289
+ result
290
+
291
+ result
292
+ end
293
+ .,.,
294
+
295
+ module_eval(<<'.,.,', 'pgn_parser.y', 60)
296
+ def _reduce_13(val, _values, result)
297
+ # A standalone comment (not attached to a move) becomes the game
298
+ # comment; the last such comment in the game wins.
299
+ @game_comment = val[0]
300
+ result = nil
301
+
302
+ result
303
+ end
304
+ .,.,
305
+
306
+ module_eval(<<'.,.,', 'pgn_parser.y', 67)
307
+ def _reduce_14(val, _values, result)
308
+ result = MoveText.new(val[0])
309
+ result
310
+ end
311
+ .,.,
312
+
313
+ module_eval(<<'.,.,', 'pgn_parser.y', 68)
314
+ def _reduce_15(val, _values, result)
315
+ result = MoveText.new(val[0], *val[1])
316
+ result
317
+ end
318
+ .,.,
319
+
320
+ module_eval(<<'.,.,', 'pgn_parser.y', 72)
321
+ def _reduce_16(val, _values, result)
322
+ result = [nil, val[0]]
323
+ result
324
+ end
325
+ .,.,
326
+
327
+ module_eval(<<'.,.,', 'pgn_parser.y', 73)
328
+ def _reduce_17(val, _values, result)
329
+ result = [val[0], nil]
330
+ result
331
+ end
332
+ .,.,
333
+
334
+ module_eval(<<'.,.,', 'pgn_parser.y', 74)
335
+ def _reduce_18(val, _values, result)
336
+ result = [val[0], val[1]]
337
+ result
338
+ end
339
+ .,.,
340
+
341
+ module_eval(<<'.,.,', 'pgn_parser.y', 75)
342
+ def _reduce_19(val, _values, result)
343
+ result = [val[1], val[0]]
344
+ result
345
+ end
346
+ .,.,
347
+
348
+ module_eval(<<'.,.,', 'pgn_parser.y', 78)
349
+ def _reduce_20(val, _values, result)
350
+ result = [val[0]]
351
+ result
352
+ end
353
+ .,.,
354
+
355
+ module_eval(<<'.,.,', 'pgn_parser.y', 79)
356
+ def _reduce_21(val, _values, result)
357
+ result = val[0] << val[1]
358
+ result
359
+ end
360
+ .,.,
361
+
362
+ module_eval(<<'.,.,', 'pgn_parser.y', 82)
363
+ def _reduce_22(val, _values, result)
364
+ result = [val[0]]
365
+ result
366
+ end
367
+ .,.,
368
+
369
+ module_eval(<<'.,.,', 'pgn_parser.y', 85)
370
+ def _reduce_23(val, _values, result)
371
+ # Right-recursive prepend reproduces the legacy whittle parser's
372
+ # variation-order reversal on every parse. This keeps parsed-game
373
+ # serialization byte-compatible with the legacy behavior; the quirk
374
+ # can be fixed in a separate change.
375
+ result = val[1] << val[0]
376
+
377
+ result
378
+ end
379
+ .,.,
380
+
381
+ module_eval(<<'.,.,', 'pgn_parser.y', 93)
382
+ def _reduce_24(val, _values, result)
383
+ result = val[1]
384
+ result
385
+ end
386
+ .,.,
387
+
388
+ def _reduce_none(val, _values, result)
389
+ val[0]
390
+ end
391
+
392
+ end # class PgnParser
393
+ end # module PGN
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ class PGN::PgnParser
4
+
5
+ token STRING COMMENT GAME_TERMINATION SAN_MOVE NAG MOVE_NUMBER TAG_NAME
6
+
7
+ rule
8
+
9
+ pgn_database:
10
+ /* empty */ { result = [] }
11
+ | pgn_database pgn_game { result = val[0] << val[1] }
12
+
13
+ pgn_game:
14
+ tag_section movetext_section
15
+ {
16
+ result = val[1].pop
17
+ result = {
18
+ tags: val[0],
19
+ result: result,
20
+ moves: val[1],
21
+ pgn: nil,
22
+ comment: (@game_comment.tap { @game_comment = nil }),
23
+ }
24
+ }
25
+
26
+ tag_section:
27
+ tag_pair { result = val[0] }
28
+ | tag_pair tag_section
29
+ {
30
+ # Right-recursive with section.merge(pair) reproduces the legacy
31
+ # whittle parser's tag semantics exactly: reverse source insertion
32
+ # order, and first-occurrence-wins on duplicate keys. This keeps
33
+ # serialized tag order byte-compatible with the legacy behavior.
34
+ result = val[1].merge(val[0])
35
+ }
36
+
37
+ tag_pair:
38
+ '[' TAG_NAME STRING ']' { result = { val[1] => val[2][1...-1] } }
39
+
40
+ movetext_section:
41
+ element_sequence GAME_TERMINATION { result = val[0] << val[1] }
42
+
43
+ element_sequence:
44
+ /* empty */ { result = [] }
45
+ | element_sequence element
46
+ {
47
+ result = val[1].nil? ? val[0] : val[0] << val[1]
48
+ }
49
+
50
+ element:
51
+ MOVE_NUMBER { result = nil }
52
+ | san_move_annotated
53
+ | san_move_annotated variation_list
54
+ {
55
+ result = val[0]
56
+ result.variations = val[1]
57
+ result
58
+ }
59
+ | COMMENT
60
+ {
61
+ # A standalone comment (not attached to a move) becomes the game
62
+ # comment; the last such comment in the game wins.
63
+ @game_comment = val[0]
64
+ result = nil
65
+ }
66
+
67
+ san_move_annotated:
68
+ SAN_MOVE { result = MoveText.new(val[0]) }
69
+ | SAN_MOVE move_trailer { result = MoveText.new(val[0], *val[1]) }
70
+
71
+ # [annotation_list, comment], in whichever order they followed the move.
72
+ move_trailer:
73
+ COMMENT { result = [nil, val[0]] }
74
+ | annotation_list { result = [val[0], nil] }
75
+ | annotation_list COMMENT { result = [val[0], val[1]] }
76
+ | COMMENT annotation_list { result = [val[1], val[0]] }
77
+
78
+ annotation_list:
79
+ NAG { result = [val[0]] }
80
+ | annotation_list NAG { result = val[0] << val[1] }
81
+
82
+ variation_list:
83
+ variation { result = [val[0]] }
84
+ | variation variation_list
85
+ {
86
+ # Right-recursive prepend reproduces the legacy whittle parser's
87
+ # variation-order reversal on every parse. This keeps parsed-game
88
+ # serialization byte-compatible with the legacy behavior; the quirk
89
+ # can be fixed in a separate change.
90
+ result = val[1] << val[0]
91
+ }
92
+
93
+ variation:
94
+ '(' element_sequence ')' { result = val[1] }
95
+
96
+ ---- inner
97
+
98
+ def parse(input)
99
+ @lexer = PGN::Lexer.new(input)
100
+ @input = input
101
+ games = do_parse
102
+ assign_pgn!(games)
103
+ games
104
+ end
105
+
106
+ def next_token
107
+ pair = @lexer.next_token_pair
108
+ return [false, false] unless pair
109
+ type, value = pair
110
+ [translate_type(type), value]
111
+ end
112
+
113
+ private
114
+
115
+ # Punctuation tokens are racc'd by literal; everything else is racc'd by
116
+ # the upcased lexer symbol (e.g. :san_move -> :SAN_MOVE), so there's a
117
+ # single source of truth for the token vocabulary: PGN::Lexer's rules.
118
+ LITERAL_TOKENS = { lbracket: '[', rbracket: ']', lparen: '(', rparen: ')' }.freeze
119
+
120
+ def translate_type(sym)
121
+ LITERAL_TOKENS[sym] || sym.to_s.upcase.to_sym
122
+ end
123
+
124
+ # Slice the verbatim raw PGN text per game out of the original input using
125
+ # the per-game content-start byte offsets recorded by the lexer.
126
+ #
127
+ # Game 0 spans from byte 0; game k (k >= 1) spans from its first
128
+ # non-discarded token's offset; each game ends where the next game begins
129
+ # (or at EOF for the last game). Leading/trailing discarded tokens (a
130
+ # game's leading `%` comment, or the whitespace between games) fold into
131
+ # the adjacent game's span, reproducing the legacy accumulator output.
132
+ def assign_pgn!(games)
133
+ starts = @lexer.game_starts
134
+ total = @input.bytesize
135
+ games.each_with_index do |game, k|
136
+ start = (k == 0) ? 0 : starts[k]
137
+ fin = (k + 1 < games.length) ? starts[k + 1] : total
138
+ game[:pgn] = @input.byteslice(start...fin)
139
+ end
140
+ end
data/lib/pgn/position.rb CHANGED
@@ -81,7 +81,8 @@ module PGN
81
81
  move = PGN::Move.new(str, player)
82
82
  calculator = PGN::MoveCalculator.new(board, move)
83
83
 
84
- new_castling = castling - calculator.castling_restrictions
84
+ restrictions = calculator.castling_restrictions
85
+ new_castling = restrictions.empty? ? castling : castling - restrictions
85
86
  new_halfmove = if calculator.increment_halfmove?
86
87
  halfmove + 1
87
88
  else
@@ -106,7 +107,7 @@ module PGN
106
107
  # @return [Symbol] the next player to move
107
108
  #
108
109
  def next_player
109
- (PLAYERS - [player]).first
110
+ player == :white ? :black : :white
110
111
  end
111
112
 
112
113
  def inspect
@@ -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.1.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