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
data/lib/pgn/lexer.rb ADDED
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'strscan'
4
+
5
+ module PGN
6
+ # {PGN::Lexer} is a {StringScanner}-based tokenizer for PGN. It reuses the
7
+ # same terminal patterns as the legacy whittle parser so tokenization is
8
+ # byte-compatible, but performs far fewer Ruby allocations because the
9
+ # scanning happens in the C-backed StringScanner.
10
+ #
11
+ # The lexer also records, per game, the byte offset of the game's first
12
+ # non-discarded token ({#game_starts}). The parser uses these offsets to
13
+ # slice the verbatim {PGN::Game#pgn} raw text out of the original input,
14
+ # reproducing the legacy accumulator's output exactly.
15
+ #
16
+ # All offsets are byte offsets (StringScanner works in bytes); slicing is
17
+ # done with {String#byteslice} so multibyte (UTF-8) input stays intact.
18
+ #
19
+ class Lexer
20
+ Token = Struct.new(:type, :value, :offset, :line, keyword_init: true) do
21
+ def inspect
22
+ "#<PGN::Lexer::Token #{type.inspect} value=#{value.inspect} @#{offset} L#{line}>"
23
+ end
24
+ end
25
+
26
+ # Discarded: insignificant whitespace.
27
+ WSP = /\s+/.freeze
28
+
29
+ # Discarded: a PGN "rest of line" comment beginning with `%`.
30
+ PGN_COMMENT = /% .*/.freeze
31
+
32
+ # A tag value string. Allows unescaped double-quotes inside the value
33
+ # (a form seen in real-world PGN files) — only a bare backslash starts
34
+ # an escape. Matches the legacy parser's relaxed string rule.
35
+ STRING = /
36
+ " # beginning of string
37
+ (
38
+ [[:print:]&&[^\\]] | # printing characters except backslash
39
+ \\\\ | # escaped backslashes
40
+ \\" # escaped quotation marks
41
+ )* # zero or more of the above
42
+ " # end of string
43
+ /x.freeze
44
+
45
+ # A brace-delimited comment, with recursive nesting via \g<1>.
46
+ COMMENT = /
47
+ (
48
+ \{ # beginning of comment
49
+ (
50
+ [[:print:]&&[^\\\{\}]] | # printing characters except brace and backslash
51
+ \n |
52
+ \\\\ | # escaped backslashes
53
+ \\\{|\\\} | # escaped braces
54
+ \n | # newlines
55
+ \g<1> # recursive
56
+ )* # zero or more of the above
57
+ \} # end of comment
58
+ )
59
+ /x.freeze
60
+
61
+ # Game termination marker.
62
+ GAME_TERMINATION = %r{
63
+ 1-0 | # white wins
64
+ 0-1 | # black wins
65
+ 1\/2-1\/2 | # draw
66
+ \* # ?
67
+ }x.freeze
68
+
69
+ # A move in standard algebraic notation (incl. castling, promotion,
70
+ # check/mate, the `--` "don't care" move).
71
+ SAN_MOVE = %r{
72
+ (
73
+ -- | # "don't care" move (used in variations)
74
+ [O0](-[O0]){1,2} | # castling (O-O, O-O-O)
75
+ [a-h][1-8] | # pawn moves (e4, d7)
76
+ [BKNQR][a-h1-8]?x?[a-h][1-8] | # major piece moves w/ optional specifier
77
+ [a-h][1-8]?x[a-h][1-8] # pawn captures
78
+ )
79
+ (
80
+ =[BNQR] # optional promotion (d8=Q)
81
+ )?
82
+ (
83
+ \+ | # check (g5+)
84
+ \# # checkmate (Qe7#)
85
+ )?
86
+ }x.freeze
87
+
88
+ # A move number indication, e.g. `1.`, `12.`, `1...`.
89
+ MOVE_NUMBER = /[[:digit:]]+\.*/.freeze
90
+
91
+ # A tag name (letters, digits, underscores).
92
+ TAG_NAME = /[A-Za-z0-9_]+/.freeze
93
+
94
+ # A numeric annotation glyph (`$1`) or a punctuation annotation (`?!`,
95
+ # `!?`, `??`, ...).
96
+ NAG = /
97
+ \$\d+ | # dollar sign followed by an integer
98
+ [\?!][\?!]? # support the most used annotations directly
99
+ /x.freeze
100
+
101
+ # Order matters: more specific / longer tokens are tried first so that
102
+ # e.g. `1-0` (termination) wins over `1` (move number), and `0-0`
103
+ # (castling) wins over `0` (move number). Whitespace and `%` comments
104
+ # are discarded (consumed but not emitted).
105
+ RULES = [
106
+ [:wsp, WSP, true], # discarded
107
+ [:pgn_comment, PGN_COMMENT, true], # discarded
108
+ [:comment, COMMENT, false],
109
+ [:string, STRING, false],
110
+ [:game_termination, GAME_TERMINATION, false],
111
+ [:san_move, SAN_MOVE, false],
112
+ [:nag, NAG, false],
113
+ [:move_number, MOVE_NUMBER, false],
114
+ [:tag_name, TAG_NAME, false],
115
+ ].freeze.each(&:freeze)
116
+
117
+ # Single-character literals, matched by their byte value.
118
+ LITERAL_BYTES = {
119
+ 91 => :lbracket, # [
120
+ 93 => :rbracket, # ]
121
+ 40 => :lparen, # (
122
+ 41 => :rparen, # )
123
+ }.freeze
124
+
125
+ def initialize(input)
126
+ @input = input
127
+ @ss = StringScanner.new(input)
128
+ @line = 1
129
+ @game_starts = []
130
+ @between_games = true # at start we are "between" games
131
+ end
132
+
133
+ attr_reader :game_starts
134
+
135
+ # The list of {Token}s for the whole input. Convenience for specs.
136
+ def tokens
137
+ result = []
138
+ while (t = next_token)
139
+ result << t
140
+ end
141
+ result
142
+ end
143
+
144
+ # Returns the next {Token}, or +nil+ at end of input.
145
+ def next_token
146
+ until @ss.eos?
147
+ off = @ss.pos
148
+
149
+ if (lit = LITERAL_BYTES[@input.getbyte(off)])
150
+ @ss.pos = off + 1
151
+ note_token(lit, off)
152
+ return Token.new(type: lit, value: @input.byteslice(off, 1),
153
+ offset: off, line: @line)
154
+ end
155
+
156
+ type, value = scan_one
157
+ advance_line(value)
158
+ if type == :wsp || type == :pgn_comment
159
+ # discarded: keep looping without emitting
160
+ next
161
+ end
162
+ note_token(type, off)
163
+ return Token.new(type: type, value: value, offset: off, line: @line)
164
+ end
165
+ nil
166
+ end
167
+
168
+ private
169
+
170
+ # Try each terminal rule in order; return [type, matched_string] for
171
+ # the first match, or raise if nothing matches at the current position.
172
+ def scan_one
173
+ RULES.each do |(type, re, _discarded)|
174
+ if (m = @ss.scan(re))
175
+ return [type, m]
176
+ end
177
+ end
178
+ raise UnconsumedInputError,
179
+ "Unmatched input #{@input.byteslice(@ss.pos..).inspect} on line #{@line}"
180
+ end
181
+
182
+ # Track per-game content-start offsets for verbatim pgn slicing.
183
+ # game_termination belongs to the current game and marks that the NEXT
184
+ # non-discarded token begins a new game.
185
+ def note_token(type, off)
186
+ if type == :game_termination
187
+ @between_games = true
188
+ elsif @between_games
189
+ @game_starts << off
190
+ @between_games = false
191
+ end
192
+ end
193
+
194
+ def advance_line(str)
195
+ @line += str.count("\n")
196
+ end
197
+ end
198
+
199
+ # Raised when the lexer cannot match the input at the current position.
200
+ class UnconsumedInputError < StandardError; end
201
+ end
data/lib/pgn/move.rb CHANGED
@@ -95,9 +95,13 @@ module PGN
95
95
  match = move.match(SAN_REGEX)
96
96
  return if match.nil?
97
97
 
98
- match.names.each do |name|
99
- send("#{name}=", match[name]) if respond_to?(name)
100
- end
98
+ self.piece = match[:piece]
99
+ self.destination = match[:destination]
100
+ self.promotion = match[:promotion]
101
+ self.check = match[:check]
102
+ self.capture = match[:capture]
103
+ self.disambiguation = match[:disambiguation]
104
+ self.castle = match[:castle]
101
105
  end
102
106
 
103
107
  def piece=(val)
@@ -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,7 @@ module PGN
80
82
  }
81
83
  }.freeze
82
84
 
83
- attr_accessor :board
84
- attr_accessor :move
85
- attr_accessor :origin
85
+ attr_accessor :board, :move, :origin
86
86
 
87
87
  # @param board [PGN::Board] the current board
88
88
  # @param move [PGN::Move] the current move
@@ -149,12 +149,12 @@ module PGN
149
149
  def en_passant_square
150
150
  return nil if move.castle
151
151
 
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
152
+ return unless move.pawn? && (origin[1].to_i - move.destination[1].to_i).abs == 2
153
+
154
+ if move.white?
155
+ "#{origin[0]}3"
156
+ else
157
+ "#{origin[0]}6"
158
158
  end
159
159
  end
160
160
 
@@ -260,7 +260,7 @@ end
260
260
  possibilities.select { |p| board.position_for(p).match(move.disambiguation) }
261
261
  else
262
262
  possibilities
263
- end
263
+ end
264
264
  end
265
265
 
266
266
  # A pawn can't move two spaces if there is a pawn in front of it.
@@ -270,17 +270,19 @@ end
270
270
  possibilities.reject { |p| board.position_for(p).match(/2|7/) }
271
271
  else
272
272
  possibilities
273
- end
273
+ end
274
274
  end
275
275
 
276
276
  # A piece can't move if it would result in a discovered check.
277
277
  #
278
278
  def disambiguate_discovered_check(possibilities)
279
+ king_pos = king_position
280
+
279
281
  DIRECTIONS.each do |attacking_piece, directions|
280
282
  attacking_piece = attacking_piece.upcase if move.black?
281
283
 
282
284
  directions.each do |dir|
283
- piece, square = first_piece(king_position, dir)
285
+ piece, square = first_piece(king_pos, dir)
284
286
  next unless piece == move.piece && possibilities.include?(square)
285
287
 
286
288
  piece, = first_piece(square, dir)
@@ -298,7 +300,7 @@ end
298
300
  piece = nil
299
301
 
300
302
  while valid_square?(file += i, rank += j)
301
- break if piece = board.at(file, rank)
303
+ break if (piece = board.at(file, rank))
302
304
  end
303
305
 
304
306
  [piece, [file, rank]]
@@ -316,18 +318,17 @@ end
316
318
  def king_position
317
319
  king = move.white? ? 'K' : 'k'
318
320
 
319
- coords = nil
320
321
  0.upto(7) do |file|
321
322
  0.upto(7) do |rank|
322
- coords = [file, rank] if board.at(file, rank) == king
323
+ return [file, rank] if board.at(file, rank) == king
323
324
  end
324
325
  end
325
326
 
326
- coords
327
+ nil
327
328
  end
328
329
 
329
330
  def valid_square?(file, rank)
330
- (0..7) === file && (0..7) === rank
331
+ (0..7).include?(file) && (0..7).include?(rank)
331
332
  end
332
333
 
333
334
  def destination_coords
data/lib/pgn/parser.rb CHANGED
@@ -1,208 +1,28 @@
1
- require 'whittle'
1
+ require 'pgn/pgn_parser'
2
2
 
3
3
  module PGN
4
- # {PGN::Parser} uses the whittle gem to parse pgn files based on their
5
- # context free grammar.
4
+ # {PGN::Parser} is the public entry point for parsing PGN text into a list
5
+ # of game hashes. It delegates to a concrete backend parser (currently the
6
+ # stdlib Racc + StringScanner parser {PGN::PgnParser}).
6
7
  #
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 })
8
+ # The +backend+ class attribute is retained so callers (tests, benchmarks)
9
+ # can substitute an alternative parser responding to +new.parse(pgn_string)+.
10
+ class Parser
11
+ class << self
12
+ # The backend parser class used by {.parse}.
13
+ # @return [Class]
14
+ attr_accessor :backend
32
15
  end
33
16
 
34
- rule(wsp: /\s+/).skip!
35
- rule(
36
- pgn_comment: /\A% .*/x
37
- ).skip!
38
-
39
- rule('[')
40
- rule(']')
41
- rule('(')
42
- rule(')')
17
+ # Default backend is the stdlib Racc + StringScanner parser.
18
+ self.backend = PGN::PgnParser
43
19
 
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 }
20
+ # @param input [String] the raw PGN text (already force-encoded by the
21
+ # caller, e.g. via {PGN.parse}).
22
+ # @return [Array<Hash>] one Hash per game, with keys +:tags+, +:result+,
23
+ # +:moves+, +:pgn+, +:comment+.
24
+ def parse(input)
25
+ self.class.backend.new.parse(input)
50
26
  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
78
-
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 }
128
- 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
27
  end
208
28
  end