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
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module PGN
2
4
  # {PGN::MoveCalculator} is responsible for computing all of the ways that a
3
5
  # specific move changes the current position. This includes which squares on
@@ -80,9 +82,12 @@ module PGN
80
82
  }
81
83
  }.freeze
82
84
 
83
- attr_accessor :board
84
- attr_accessor :move
85
- attr_accessor :origin
85
+ # Frozen rook-origin -> castling-restriction lookup, shared by both
86
+ # white ('R') and black ('r') since their rook origins (a1/h1, a8/h8)
87
+ # are distinct keys. Replaces a per-call hash literal.
88
+ ROOK_RESTRICTIONS = { 'a1' => 'Q', 'h1' => 'K', 'a8' => 'q', 'h8' => 'k' }.freeze
89
+
90
+ attr_accessor :board, :move, :origin
86
91
 
87
92
  # @param board [PGN::Board] the current board
88
93
  # @param move [PGN::Move] the current move
@@ -114,9 +119,9 @@ module PGN
114
119
  when 'k'
115
120
  restrict += %w[k q]
116
121
  when 'R'
117
- restrict << { 'a1' => 'Q', 'h1' => 'K' }[origin]
122
+ restrict << ROOK_RESTRICTIONS[origin]
118
123
  when 'r'
119
- restrict << { 'a8' => 'q', 'h8' => 'k' }[origin]
124
+ restrict << ROOK_RESTRICTIONS[origin]
120
125
  end
121
126
 
122
127
  # when castling occurs
@@ -129,7 +134,7 @@ module PGN
129
134
  restrict << 'K' if move.destination == 'h1'
130
135
  restrict << 'k' if move.destination == 'h8'
131
136
 
132
- restrict.compact.uniq
137
+ restrict.empty? ? restrict : restrict.compact.uniq
133
138
  end
134
139
 
135
140
  # @return [Boolean] whether to increment the halfmove clock
@@ -149,12 +154,12 @@ module PGN
149
154
  def en_passant_square
150
155
  return nil if move.castle
151
156
 
152
- if move.pawn? && (origin[1].to_i - move.destination[1].to_i).abs == 2
153
- if move.white?
154
- origin[0] + '3'
155
- else
156
- origin[0] + '6'
157
- end
157
+ return unless move.pawn? && (origin[1].to_i - move.destination[1].to_i).abs == 2
158
+
159
+ if move.white?
160
+ "#{origin[0]}3"
161
+ else
162
+ "#{origin[0]}6"
158
163
  end
159
164
  end
160
165
 
@@ -260,7 +265,7 @@ end
260
265
  possibilities.select { |p| board.position_for(p).match(move.disambiguation) }
261
266
  else
262
267
  possibilities
263
- end
268
+ end
264
269
  end
265
270
 
266
271
  # A pawn can't move two spaces if there is a pawn in front of it.
@@ -270,17 +275,19 @@ end
270
275
  possibilities.reject { |p| board.position_for(p).match(/2|7/) }
271
276
  else
272
277
  possibilities
273
- end
278
+ end
274
279
  end
275
280
 
276
281
  # A piece can't move if it would result in a discovered check.
277
282
  #
278
283
  def disambiguate_discovered_check(possibilities)
284
+ king_pos = king_position
285
+
279
286
  DIRECTIONS.each do |attacking_piece, directions|
280
287
  attacking_piece = attacking_piece.upcase if move.black?
281
288
 
282
289
  directions.each do |dir|
283
- piece, square = first_piece(king_position, dir)
290
+ piece, square = first_piece(king_pos, dir)
284
291
  next unless piece == move.piece && possibilities.include?(square)
285
292
 
286
293
  piece, = first_piece(square, dir)
@@ -298,7 +305,7 @@ end
298
305
  piece = nil
299
306
 
300
307
  while valid_square?(file += i, rank += j)
301
- break if piece = board.at(file, rank)
308
+ break if (piece = board.at(file, rank))
302
309
  end
303
310
 
304
311
  [piece, [file, rank]]
@@ -316,22 +323,21 @@ end
316
323
  def king_position
317
324
  king = move.white? ? 'K' : 'k'
318
325
 
319
- coords = nil
320
326
  0.upto(7) do |file|
321
327
  0.upto(7) do |rank|
322
- coords = [file, rank] if board.at(file, rank) == king
328
+ return [file, rank] if board.at(file, rank) == king
323
329
  end
324
330
  end
325
331
 
326
- coords
332
+ nil
327
333
  end
328
334
 
329
335
  def valid_square?(file, rank)
330
- (0..7) === file && (0..7) === rank
336
+ (0..7).include?(file) && (0..7).include?(rank)
331
337
  end
332
338
 
333
339
  def destination_coords
334
- board.coordinates_for(move.destination)
340
+ @dest_coords ||= board.coordinates_for(move.destination)
335
341
  end
336
342
  end
337
343
  end
data/lib/pgn/parser.rb CHANGED
@@ -1,208 +1,18 @@
1
- require 'whittle'
1
+ # frozen_string_literal: true
2
2
 
3
- module PGN
4
- # {PGN::Parser} uses the whittle gem to parse pgn files based on their
5
- # context free grammar.
6
- #
7
- class Parser < Whittle::Parser
8
- def lex(input)
9
- line = 1
10
- offset = 0
11
- ending = input.length
12
- @@pgn ||= ''
13
- @@game_comment ||= nil
14
-
15
- until offset == ending
16
- next_token(input, offset, line).tap do |token|
17
- if !token.nil?
18
- token[:offset] = offset
19
- line, token[:line] = token[:line], line
20
- yield token unless token[:discarded]
21
- @@pgn += token[:value]
22
- offset += token[:value].length
23
- else
24
- raise Whittle::UnconsumedInputError,
25
- "Unmatched input #{input[offset..-1].inspect} on line #{line}"
26
- # offset += 1
27
- end
28
- end
29
- end
30
-
31
- yield ({ name: :$end, line: line, value: nil, offset: offset })
32
- end
33
-
34
- rule(wsp: /\s+/).skip!
35
- rule(
36
- pgn_comment: /\A% .*/x
37
- ).skip!
38
-
39
- rule('[')
40
- rule(']')
41
- rule('(')
42
- rule(')')
43
-
44
- start(:pgn_database)
45
-
46
- rule(:pgn_database) do |r|
47
- r[].as { [] }
48
- r[:pgn_comment, :pgn_database].as { |_title, db| db }
49
- r[:pgn_database, :pgn_game].as { |database, game| database << game }
50
- end
51
-
52
- rule(:pgn_game) do |r|
53
- r[:tag_section, :movetext_section].as do |tags, moves|
54
- old_pgn = @@pgn
55
- @@pgn = ''
56
- comment = @@game_comment
57
- @@game_comment = nil
58
- { tags: tags, result: moves.pop, moves: moves, pgn: old_pgn, comment: comment }
59
- end
60
- end
61
-
62
- rule(:tag_section) do |r|
63
- r[:tag_pair, :tag_section].as { |pair, section| section.merge(pair) }
64
- r[:tag_pair]
65
- end
66
-
67
- rule(:tag_pair) do |r|
68
- r['[', :tag_name, :tag_value, ']'].as { |_, a, b, _| { a => b } }
69
- end
70
-
71
- rule(:tag_value) do |r|
72
- r[:string].as { |value| value[1...-1] }
73
- end
74
-
75
- rule(:movetext_section) do |r|
76
- r[:element_sequence, :game_termination].as { |a, b| a << b }
77
- end
3
+ require 'pgn/pgn_parser'
78
4
 
79
- rule(:element_sequence) do |r|
80
- r[:element_sequence, :element].as do |sequence, element|
81
- element.nil? ? sequence : sequence << element
82
- end
83
- r[].as { [] }
84
- end
85
-
86
- rule(:element) do |r|
87
- r[:move_number_indication].as { nil }
88
- r[:san_move_annotated]
89
- r[:san_move_annotated, :variation_list].as do |move, variations|
90
- move.variations = variations
91
- move
92
- end
93
- r[:comment].as { |c| @@game_comment = c; nil }
94
- end
95
-
96
- rule(:san_move_annotated) do |r|
97
- r[:san_move].as { |move| MoveText.new(move) }
98
- r[:san_move, :comment].as do |move, comment|
99
- MoveText.new(move, nil, comment)
100
- end
101
- r[:san_move, :annotation_list].as do |move, annotation|
102
- MoveText.new(move, annotation)
103
- end
104
- r[:san_move, :annotation_list, :comment].as do |move, annotation, comment|
105
- MoveText.new(move, annotation, comment)
106
- end
107
- r[:san_move, :comment, :annotation_list].as do |move, comment, annotation|
108
- MoveText.new(move, annotation, comment)
109
- end
110
- end
111
-
112
- rule(:annotation_list) do |r|
113
- r[:annotation_list, :numeric_annotation_glyph].as do |sequence, element|
114
- element.nil? ? sequence : sequence << element
115
- end
116
- r[:numeric_annotation_glyph].as { |v| [v] }
117
- end
118
-
119
- rule(:variation_list) do |r|
120
- r[:variation, :variation_list].as do |variation, sequence|
121
- sequence << variation
122
- end
123
- r[:variation].as { |v| [v] }
124
- end
125
-
126
- rule(:variation) do |r|
127
- r['(', :element_sequence, ')'].as { |_, sequence, _| sequence }
5
+ module PGN
6
+ # {PGN::Parser} is the public entry point for parsing PGN text into a list
7
+ # of game hashes. It delegates to the stdlib Racc + StringScanner parser
8
+ # {PGN::PgnParser}.
9
+ class Parser
10
+ # @param input [String] the raw PGN text (already force-encoded by the
11
+ # caller, e.g. via {PGN.parse}).
12
+ # @return [Array<Hash>] one Hash per game, with keys +:tags+, +:result+,
13
+ # +:moves+, +:pgn+, +:comment+.
14
+ def parse(input)
15
+ PGN::PgnParser.new.parse(input)
128
16
  end
129
-
130
- rule(
131
- string: /
132
- " # beginning of string
133
- (
134
- [[:print:]&&[^\\"]] | # printing characters except quote and backslash
135
- \\\\ | # escaped backslashes
136
- \\" # escaped quotation marks
137
- )* # zero or more of the above
138
- " # end of string
139
- /x
140
- )
141
-
142
- rule(
143
- comment: /
144
- (
145
- \{ # beginning of comment
146
- (
147
- [[:print:]&&[^\\\{\}]] | # printing characters except brace and backslash
148
- \n |
149
- \\\\ | # escaped backslashes
150
- \\\{|\\\} | # escaped braces
151
- \n | # newlines
152
- \g<1> # recursive
153
- )* # zero or more of the above
154
- \} # end of comment
155
- )
156
- /x
157
- )
158
-
159
- rule(
160
- game_termination: %r{
161
- 1-0 | # white wins
162
- 0-1 | # black wins
163
- 1\/2-1\/2 | # draw
164
- \* # ?
165
- }x
166
- )
167
-
168
- rule(
169
- move_number_indication: /
170
- [[:digit:]]+\.* # one or more digits followed by zero or more periods
171
- /x
172
- )
173
-
174
- rule(
175
- san_move: %r{
176
- (
177
- -- | # "don't care" move (used in variations)
178
- [O0](-[O0]){1,2} | # castling (O-O, O-O-O)
179
- [a-h][1-8] | # pawn moves (e4, d7)
180
- [BKNQR][a-h1-8]?x?[a-h][1-8] | # major piece moves w/ optional specifier
181
- # and capture
182
- # (Bd2, N4c3, Raxc1)
183
- [a-h][1-8]?x[a-h][1-8] # pawn captures
184
- )
185
- (
186
- =[BNQR] # optional promotion (d8=Q)
187
- )?
188
- (
189
- \+ | # check (g5+)
190
- \# # checkmate (Qe7#)
191
- )?
192
- }x
193
- )
194
-
195
- rule(
196
- tag_name: /
197
- [A-Za-z0-9_]+ # letters, digits and underscores only
198
- /x
199
- )
200
-
201
- rule(
202
- numeric_annotation_glyph: /
203
- \$\d+ | # dollar sign followed by an integer from 0 to 255
204
- [\?!][\?!]? # support the most used annotations directly
205
- /x
206
- )
207
17
  end
208
18
  end