pgn2 1.0.0 → 1.2.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.
@@ -0,0 +1,227 @@
1
+ # PGN2 performance quick wins — Approach A (quick, behavior-compatible micro-opts)
2
+
3
+ > "Approach A" denotes the safe, allocation-focused track. A later, larger
4
+ > design (Approach B) will cover architectural changes: piece-location indexes,
5
+ > lazy position computation, and a coordinate-only internal board
6
+ > representation.
7
+
8
+ ## Goal
9
+
10
+ Make the gem faster on the combined **parse + replay** workload after the
11
+ whittle → Racc migration, with only safe, behavior-compatible micro-optimizations
12
+ (no public API changes, no serialized-output changes).
13
+
14
+ ## Scope
15
+
16
+ This design covers the five highest-ROI quick wins identified in the profiles:
17
+
18
+ 1. Remove per-token `PGN::Lexer::Token` allocation on the parser hot path.
19
+ 2. Avoid double `MoveText` wrapping in `PGN::Game#moves=`.
20
+ 3. Replace `PGN::Move` SAN regex with hand-rolled parsing.
21
+ 4. Reduce small temporary allocations in `PGN::Position#move` and
22
+ `PGN::MoveCalculator`.
23
+ 5. Promote the Racc parse baseline to canonical and record new baselines in
24
+ `bench/baseline_*.txt` after verification.
25
+
26
+ Out of scope: larger architecture changes such as piece-location indexes,
27
+ lazy position computation, or coordinate-only internal board representation.
28
+
29
+ ## Context / baseline
30
+
31
+ Measured on the current checkout with `bundle exec rake bench` (Racc parser);
32
+ these are the canonical `bench/baseline_*.txt` values refreshed as the
33
+ prerequisite in item 5:
34
+
35
+ - **Parse-only:** ~125 k objects / 7.88 MB for 100 copies of the immortal game
36
+ (= 626,037 objects / 39.4 MB for the 500-game corpus in `bench/baseline_parse.txt`).
37
+ - **Parse + replay:** ~337 k objects / 21.6 MB for the same per-100 ratio
38
+ (= 1,683,087 objects / 108 MB for 500 games).
39
+ - **Replay only:** 2,177 objects / 142 kB for the 45-ply immortal game
40
+ (`bench/baseline_moves.txt`).
41
+
42
+ Historical snapshots are kept in `bench/baseline_*.pre-quickwins.txt` and
43
+ `bench/baseline_parse.racc.txt` for reference. The previously committed
44
+ `baseline_parse.txt` held stale pre-migration whittle numbers and has been
45
+ overwritten by the current Racc output.
46
+
47
+ Top allocation hot spots:
48
+
49
+ - `PGN::Lexer::Token` creation (parse-only).
50
+ - `PGN::MoveText` created twice per parsed move (parser + `Game#moves=`).
51
+ - `Move#initialize` SAN regex (`Regexp` + `MatchData` objects, replay).
52
+ - Small throw-away arrays and string conversions in `MoveCalculator` and
53
+ `Position#move`.
54
+
55
+ ## Detailed changes
56
+
57
+ ### 1. Lexer: parser hot path avoids `Token` objects
58
+
59
+ `lib/pgn/lexer.rb`
60
+
61
+ - Add a fast public method `next_token_pair` that returns `[type, value]`
62
+ directly, without allocating a `PGN::Lexer::Token` Struct.
63
+ The byte offset is **not** expensive (`@ss.pos`) and is still required, so
64
+ the saving is the Struct allocation, not offset computation.
65
+ - **Correctness requirement:** `next_token_pair` must invoke the same
66
+ `note_token(type, off)` and `advance_line(value)` side effects as
67
+ `next_token`. In particular `note_token` maintains `game_starts`, which
68
+ `PgnParser#assign_pgn!` relies on to slice each game's verbatim raw `pgn`
69
+ text — skipping it would corrupt `Game#pgn`.
70
+ - Keep `next_token` and `tokens` unchanged (they continue to return `Token`
71
+ objects and are used by specs/other callers).
72
+ - Implement both methods on top of a shared private scanning routine so the
73
+ scanning logic is not duplicated.
74
+ - The single-byte literal path already returns frozen strings via
75
+ `LITERAL_BYTES` under `# frozen_string_literal: true`; preserve that in
76
+ `next_token_pair` (no change needed beyond routing through the shared
77
+ routine).
78
+
79
+ `lib/pgn/pgn_parser.y`
80
+
81
+ - Change `PgnParser#next_token` to call `@lexer.next_token_pair` and return the
82
+ resulting array directly.
83
+ - Regenerate `lib/pgn/pgn_parser.rb` from the `.y` file with Racc.
84
+ - No changes to grammar or semantics.
85
+
86
+ ### 2. Game#moves=: stop re-wrapping existing MoveText objects
87
+
88
+ `lib/pgn/game.rb`
89
+
90
+ - Detect when an element is already a `PGN::MoveText` instance and reuse it
91
+ directly instead of creating a new one.
92
+ - Still perform castling normalization (`0` → `O`) for raw strings.
93
+ - Preserve `clean_text` behavior for fresh comments only.
94
+ - **Reuse-safety invariant:** reusing the parser's `MoveText` shares one object
95
+ between the parser's move tree and `Game#moves`. This is safe only because
96
+ nothing mutates a `MoveText` after construction; record that invariant in a
97
+ comment so a future mutation doesn't silently corrupt both holders.
98
+
99
+ Expected effect: roughly halves `MoveText` allocations on the parse path while
100
+ keeping serialized output identical.
101
+
102
+ ### 3. Move#initialize: hand-rolled SAN parser (highest-risk item — quantify first)
103
+
104
+ `lib/pgn/move.rb`
105
+
106
+ This is the highest-risk, and likely lowest-allocation-payoff, item: the SAN
107
+ regex produces ~1 `MatchData` per ply (~2.1% of the 2,177-object replay
108
+ baseline). Before committing to a full hand-roll, **measure #3 in isolation**
109
+ and confirm the payoff; if marginal, ship #1, #2, #4 alone and defer #3.
110
+
111
+ Trivial independent sub-win (do regardless of the full hand-roll):
112
+
113
+ - In `Move#piece=`, replace `return if san.match('O-O')` (which allocates a
114
+ `MatchData` on every `Move.new`, castling or not) with a non-allocating
115
+ check such as `san.include?('O-O')` or `san.start_with?('O')`.
116
+
117
+ Full hand-roll (if pursued):
118
+
119
+ - Replace `move.match(SAN_REGEX)` with a manual parse over the byte/string
120
+ representation of the SAN notation.
121
+ - Populate all existing attributes (`piece`, `destination`, `promotion`,
122
+ `check`, `capture`, `disambiguation`, `castle`) with the same values as
123
+ today, including the `piece=` early-return for castling.
124
+ - Keep setter methods (`piece=`, `promotion=`, `capture=`, `disambiguation=`,
125
+ `castle=`) so external callers that assign attributes manually are unaffected.
126
+ - Use frozen constants for frequently-checked piece sets (`pawn?` lives here,
127
+ in `move.rb` — see item 4).
128
+ - **Upfront fixtures:** add a dedicated spec pinning every attribute for a
129
+ comprehensive set of SAN strings: `O-O`, `O-O-O`, `O-O+`, `O-O-O#`;
130
+ pawn moves and captures incl. promotion (`e4`, `exd5`, `e8=Q`, `exd8=Q+`,
131
+ `b1=N#`); piece moves with file/rank/full disambiguation (`Nbd2`, `R1e2`,
132
+ `Qh4e1`); captures with check/mate; and the `--` "don't care" move. Do this
133
+ before implementation, not only if ambiguity surfaces.
134
+
135
+ ### 4. Position / MoveCalculator: fewer throw-away allocations
136
+
137
+ `lib/pgn/position.rb`
138
+
139
+ - Inline `next_player` logic in `Position#move` (or simply rewrite its body to
140
+ `player == :white ? :black : :white`) to avoid the `(PLAYERS - [player])`
141
+ array allocation per ply.
142
+ - Apply `castling_restrictions` only when non-empty; avoid array subtraction
143
+ when there is nothing to remove. (Safe to return the existing `castling`
144
+ array directly because castling arrays are replaced, never mutated.)
145
+
146
+ `lib/pgn/move_calculator.rb`
147
+
148
+ - Compute and cache destination coordinates in `initialize` instead of calling
149
+ `board.coordinates_for(move.destination)` repeatedly (it can be invoked 2–3
150
+ times per move across `direction_origins` / `move_origins` / `pawn_origins`).
151
+ - Inline `valid_square?` boundary checks to remove the per-call method dispatch
152
+ (note: `(0..7)` are frozen range literals already cached by the VM, so the
153
+ win is call overhead, not Range allocation — don't claim the latter).
154
+ - Replace run-time array/hash literals with frozen constants or `case`/`when`
155
+ where they appear on hot paths (e.g. rook-origin lookup in
156
+ `castling_restrictions`, the per-call hash literals `{ 'a1' => 'Q', ... }`).
157
+ - Keep semantic behavior identical (same board updates, same disambiguation,
158
+ same `king_position` scan — Approach A deliberately defers indexing/caching
159
+ to a later design).
160
+
161
+ `lib/pgn/move.rb` (relocated from move_calculator)
162
+
163
+ - `Move#pawn?` uses `%w[P p].include?(piece)`, allocating an array each call;
164
+ it's invoked from `MoveCalculator#increment_halfmove?` and `pawn_origins`.
165
+ Replace with a frozen constant or `piece == 'P' || piece == 'p'`.
166
+
167
+ ## Testing & behavior preservation
168
+
169
+ - All existing specs must pass without modification (`bundle exec rspec`).
170
+ - The `bench/baseline_moves.txt` and `bench/baseline_parse.txt` files will be
171
+ regenerated with `bundle exec rake bench` and committed if they show lower
172
+ object/byte counts and equal or better throughput.
173
+ - Public API remains unchanged: `PGN.parse`, `Game#moves=`, `Move.new`,
174
+ `Position#move`, etc. accept the same inputs and produce the same outputs and
175
+ side effects.
176
+ - No changes to PGN/FEN serialization format.
177
+ - **Lexer non-regression:** because `next_token_pair` shares the scanning
178
+ routine with `next_token`/`tokens`, the existing `tokens`/`next_token` specs
179
+ and the `game_starts`-driven `Game#pgn` raw-text slicing output must stay
180
+ byte-identical across the fixtures and inline inputs in
181
+ `spec/parser_explicit_spec.rb`.
182
+
183
+ ## Risks & mitigations
184
+
185
+ | Risk | Mitigation |
186
+ |---|---|
187
+ | Hand-rolled SAN parser mishandles an edge case | Upfront comprehensive SAN fixture spec (castling both sides, promotion with/without check, pawn & piece captures, file/rank/full disambiguation, `--`) added before implementation; full suite must stay green. |
188
+ | `next_token_pair` drifts from `next_token` behavior | Both methods share the private scanning routine, the same `RULES` ordering, and the same `note_token`/`advance_line` side effects (incl. `game_starts`). |
189
+ | `Game#moves=` no longer applies castling normalization | Continue normalizing raw strings; only skip work when input is already `MoveText`. |
190
+ | Performance wins smaller than expected | Measure before/after with the committed baseline harness. |
191
+
192
+ ## Acceptance criteria
193
+
194
+ 1. `bundle exec rspec` passes (0 failures).
195
+ 2. `lib/pgn/pgn_parser.rb` is regenerated and in sync with `.y`
196
+ (`git diff --stat` should reflect the generation, and CI racc-sync check
197
+ passes).
198
+ 3. `bundle exec rake bench` shows lower allocated objects/bytes than the
199
+ **post-Racc** committed baselines (`baseline_moves.txt` and
200
+ `baseline_parse.txt`, the latter promoted from `baseline_parse.racc.txt`
201
+ per item 5). Comparing against the old whittle `baseline_parse.txt` would
202
+ not be a meaningful gate.
203
+ 4. IPS numbers are equal or higher than current baselines.
204
+ 5. Baseline files are updated and committed as part of the change set.
205
+ 6. Lexer non-regression: `tokens`/`next_token` spec output and `Game#pgn`
206
+ raw-text slicing are byte-identical to the pre-change checkout.
207
+
208
+ ## Estimated impact
209
+
210
+ Per-item expectations (to be confirmed by measurement, not assumed in
211
+ aggregate):
212
+
213
+ - **Items 1, 2, 4 (low-risk):** the largest reliable wins. Parse-side `Token`
214
+ elimination (#1) and halved `MoveText` allocation (#2) should show up directly
215
+ in parse-only object counts; `next_player`/`pawn?`/`castling_restrictions`
216
+ (#4) trim a handful of objects per ply.
217
+ - **Item 3 (SAN hand-roll):** ~1 `MatchData` per ply (~2.1% of the 2,177-object
218
+ replay baseline) plus the `piece=` guard fix — modest, and the bulk of the
219
+ estimate does **not** come from this item.
220
+ - **Replay allocations are dominated by `Position.new` + `Board#dup` +
221
+ `MoveCalculator` arrays**, which Approach A only trims at the edges; the big
222
+ replay win is deferred to Approach B.
223
+
224
+ Conservative aggregate guess once #1/#2/#4 land: parse-only allocations down
225
+ ~10–15%, replay allocations down in the single-digit-percent range (lower than
226
+ a naive reading of these micro-opts might suggest). Actual numbers will come
227
+ from the baseline run; revise this section with measured per-item deltas.
data/lib/pgn/game.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'io/console'
2
4
 
3
5
  module PGN
@@ -11,12 +13,12 @@ module PGN
11
13
  @variations = variations
12
14
  end
13
15
 
14
- def ==(m)
15
- to_s == m.to_s
16
+ def ==(other)
17
+ to_s == other.to_s
16
18
  end
17
19
 
18
- def eql?(m)
19
- self == m
20
+ def eql?(other)
21
+ self == other
20
22
  end
21
23
 
22
24
  def hash
@@ -31,6 +33,7 @@ module PGN
31
33
  text&.gsub(/{(.*)}/, '\1')&.gsub(/\s+/, ' ')&.strip
32
34
  end
33
35
  end
36
+
34
37
  # {PGN::Game} holds all of the information about a game. It is either
35
38
  # the result of parsing a PGN file, or created by hand.
36
39
  #
@@ -57,9 +60,9 @@ module PGN
57
60
  attr_accessor :tags, :result, :pgn, :comment
58
61
  attr_reader :moves
59
62
 
60
- LEFT = /(a|\x1B\[D)\z/.freeze
61
- RIGHT = /(d|\x1B\[C)\z/.freeze
62
- EXIT = /(q|\x03)\z/.freeze
63
+ LEFT = /(a|\x1B\[D)\z/
64
+ RIGHT = /(d|\x1B\[C)\z/
65
+ EXIT = /(q|\x03)\z/
63
66
 
64
67
  # @param moves [Array<String>] a list of moves in SAN
65
68
  # @param tags [Hash<String, String>] metadata about the game
@@ -78,16 +81,7 @@ module PGN
78
81
  # Standardize castling moves to use O's instead of 0's
79
82
  #
80
83
  def moves=(moves)
81
- @moves =
82
- moves.map do |m|
83
- if m.is_a? String
84
- MoveText.new(m.include?('0') ? m.gsub('0', 'O') : m)
85
- else
86
- notation = m.notation
87
- notation = notation.gsub('0', 'O') if notation.include?('0')
88
- MoveText.new(notation, m.annotation, m.comment, m.variations)
89
- end
90
- end
84
+ @moves = moves.map { |m| standardize_castling(m) }
91
85
  end
92
86
 
93
87
  # @return [String] a canonical PGN string for this game, ending with a
@@ -141,11 +135,11 @@ module PGN
141
135
  loop do
142
136
  puts "\e[H\e[2J"
143
137
  puts positions[index].inspect
144
- hist[0..2] = (hist[1..2] << STDIN.getch)
138
+ hist[0..2] = (hist[1..2] << $stdin.getch)
145
139
 
146
140
  case hist.join
147
141
  when LEFT
148
- index -= 1 if index > 0
142
+ index -= 1 if index.positive?
149
143
  when RIGHT
150
144
  index += 1 if index < moves.length
151
145
  when EXIT
@@ -153,5 +147,22 @@ module PGN
153
147
  end
154
148
  end
155
149
  end
150
+
151
+ private
152
+
153
+ # A MoveText is reused as-is (no new object) when its notation needs no
154
+ # '0'->'O' fix and its comment has no braces left to clean; a braced
155
+ # comment still needs MoveText.new's second clean_text pass to match the
156
+ # legacy whittle byte-output.
157
+ def standardize_castling(entry)
158
+ return MoveText.new(entry.include?('0') ? entry.gsub('0', 'O') : entry) if entry.is_a?(String)
159
+
160
+ notation = entry.notation.include?('0') ? entry.notation.gsub('0', 'O') : entry.notation
161
+ if notation.equal?(entry.notation) && (entry.comment.nil? || !entry.comment.include?('{'))
162
+ return entry
163
+ end
164
+
165
+ MoveText.new(notation, entry.annotation, entry.comment, entry.variations)
166
+ end
156
167
  end
157
168
  end
data/lib/pgn/lexer.rb CHANGED
@@ -24,10 +24,10 @@ module PGN
24
24
  end
25
25
 
26
26
  # Discarded: insignificant whitespace.
27
- WSP = /\s+/.freeze
27
+ WSP = /\s+/
28
28
 
29
29
  # Discarded: a PGN "rest of line" comment beginning with `%`.
30
- PGN_COMMENT = /% .*/.freeze
30
+ PGN_COMMENT = /% .*/
31
31
 
32
32
  # A tag value string. Allows unescaped double-quotes inside the value
33
33
  # (a form seen in real-world PGN files) — only a bare backslash starts
@@ -40,14 +40,14 @@ module PGN
40
40
  \\" # escaped quotation marks
41
41
  )* # zero or more of the above
42
42
  " # end of string
43
- /x.freeze
43
+ /x
44
44
 
45
45
  # A brace-delimited comment, with recursive nesting via \g<1>.
46
46
  COMMENT = /
47
47
  (
48
48
  \{ # beginning of comment
49
49
  (
50
- [[:print:]&&[^\\\{\}]] | # printing characters except brace and backslash
50
+ [[:print:]&&[^\\{}]] | # printing characters except brace and backslash
51
51
  \n |
52
52
  \\\\ | # escaped backslashes
53
53
  \\\{|\\\} | # escaped braces
@@ -56,15 +56,15 @@ module PGN
56
56
  )* # zero or more of the above
57
57
  \} # end of comment
58
58
  )
59
- /x.freeze
59
+ /x
60
60
 
61
61
  # Game termination marker.
62
62
  GAME_TERMINATION = %r{
63
63
  1-0 | # white wins
64
64
  0-1 | # black wins
65
- 1\/2-1\/2 | # draw
65
+ 1/2-1/2 | # draw
66
66
  \* # ?
67
- }x.freeze
67
+ }x
68
68
 
69
69
  # A move in standard algebraic notation (incl. castling, promotion,
70
70
  # check/mate, the `--` "don't care" move).
@@ -83,43 +83,46 @@ module PGN
83
83
  \+ | # check (g5+)
84
84
  \# # checkmate (Qe7#)
85
85
  )?
86
- }x.freeze
86
+ }x
87
87
 
88
88
  # A move number indication, e.g. `1.`, `12.`, `1...`.
89
- MOVE_NUMBER = /[[:digit:]]+\.*/.freeze
89
+ MOVE_NUMBER = /[[:digit:]]+\.*/
90
90
 
91
91
  # A tag name (letters, digits, underscores).
92
- TAG_NAME = /[A-Za-z0-9_]+/.freeze
92
+ TAG_NAME = /[A-Za-z0-9_]+/
93
93
 
94
94
  # A numeric annotation glyph (`$1`) or a punctuation annotation (`?!`,
95
95
  # `!?`, `??`, ...).
96
96
  NAG = /
97
97
  \$\d+ | # dollar sign followed by an integer
98
- [\?!][\?!]? # support the most used annotations directly
99
- /x.freeze
98
+ [?!][?!]? # support the most used annotations directly
99
+ /x
100
100
 
101
101
  # Order matters: more specific / longer tokens are tried first so that
102
102
  # e.g. `1-0` (termination) wins over `1` (move number), and `0-0`
103
103
  # (castling) wins over `0` (move number). Whitespace and `%` comments
104
- # are discarded (consumed but not emitted).
104
+ # are discarded (consumed but not emitted). Beyond that constraint,
105
+ # rules are ordered most- to least-frequent (one san_move/move_number
106
+ # per ply/full-move vs. a handful of comments/strings per game) so the
107
+ # common case fails the fewest regexes before matching.
105
108
  RULES = [
106
109
  [:wsp, WSP, true], # discarded
107
110
  [:pgn_comment, PGN_COMMENT, true], # discarded
108
- [:comment, COMMENT, false],
109
- [:string, STRING, false],
110
111
  [:game_termination, GAME_TERMINATION, false],
111
112
  [:san_move, SAN_MOVE, false],
112
- [:nag, NAG, false],
113
113
  [:move_number, MOVE_NUMBER, false],
114
- [:tag_name, TAG_NAME, false],
114
+ [:nag, NAG, false],
115
+ [:comment, COMMENT, false],
116
+ [:string, STRING, false],
117
+ [:tag_name, TAG_NAME, false]
115
118
  ].freeze.each(&:freeze)
116
119
 
117
- # Single-character literals, matched by their byte value.
120
+ # Single-character literals, matched by their byte value: [type, frozen value].
118
121
  LITERAL_BYTES = {
119
- 91 => :lbracket, # [
120
- 93 => :rbracket, # ]
121
- 40 => :lparen, # (
122
- 41 => :rparen, # )
122
+ 91 => [:lbracket, '['], # [
123
+ 93 => [:rbracket, ']'], # ]
124
+ 40 => [:lparen, '('], # (
125
+ 41 => [:rparen, ')'] # )
123
126
  }.freeze
124
127
 
125
128
  def initialize(input)
@@ -127,7 +130,7 @@ module PGN
127
130
  @ss = StringScanner.new(input)
128
131
  @line = 1
129
132
  @game_starts = []
130
- @between_games = true # at start we are "between" games
133
+ @between_games = true # at start we are "between" games
131
134
  end
132
135
 
133
136
  attr_reader :game_starts
@@ -143,36 +146,52 @@ module PGN
143
146
 
144
147
  # Returns the next {Token}, or +nil+ at end of input.
145
148
  def next_token
149
+ type, value = next_token_pair
150
+ return nil unless type
151
+
152
+ Token.new(type: type, value: value, offset: @last_offset, line: @line)
153
+ end
154
+
155
+ # Fast path for the parser: returns [type, value] for the next
156
+ # non-discarded token, or +nil+ at end of input. Does not allocate a
157
+ # {Token} Struct, and +scan_one+ returns the matched string directly
158
+ # (stashing its type/discarded flag in ivars) so the only array
159
+ # allocated per token is the [type, value] pair Racc requires.
160
+ def next_token_pair
146
161
  until @ss.eos?
147
162
  off = @ss.pos
148
163
 
149
164
  if (lit = LITERAL_BYTES[@input.getbyte(off)])
165
+ type, value = lit
150
166
  @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)
167
+ note_token(type, off)
168
+ @last_offset = off
169
+ return [type, value]
154
170
  end
155
171
 
156
- type, value = scan_one
172
+ value = scan_one
157
173
  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)
174
+ next if @scan_discarded
175
+
176
+ note_token(@scan_type, off)
177
+ @last_offset = off
178
+ return [@scan_type, value]
164
179
  end
165
180
  nil
166
181
  end
167
182
 
168
183
  private
169
184
 
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.
185
+ # Try each terminal rule in order; return the matched string for the
186
+ # first match (stashing its type and discarded flag in +@scan_type+ /
187
+ # +@scan_discarded+ so the caller avoids allocating a 3-element tuple),
188
+ # or raise if nothing matches at the current position.
172
189
  def scan_one
173
- RULES.each do |(type, re, _discarded)|
190
+ RULES.each do |(type, re, discarded)|
174
191
  if (m = @ss.scan(re))
175
- return [type, m]
192
+ @scan_type = type
193
+ @scan_discarded = discarded
194
+ return m
176
195
  end
177
196
  end
178
197
  raise UnconsumedInputError,
data/lib/pgn/move.rb CHANGED
@@ -105,7 +105,10 @@ module PGN
105
105
  end
106
106
 
107
107
  def piece=(val)
108
- return if san.match('O-O')
108
+ # Castling SAN (O-O / O-O-O) has no piece attribute; the castle attribute
109
+ # is set later in #initialize. Use a non-allocating prefix check instead
110
+ # of `san.match('O-O')`, which allocated a MatchData on every Move.new.
111
+ return if san.start_with?('O')
109
112
 
110
113
  val ||= 'P'
111
114
  @piece = black? ? val.downcase : val
@@ -161,7 +164,7 @@ module PGN
161
164
  # @return [Boolean] whether the piece being moved is a pawn
162
165
  #
163
166
  def pawn?
164
- %w[P p].include?(piece)
167
+ piece == 'P' || piece == 'p'
165
168
  end
166
169
  end
167
170
  end
@@ -82,6 +82,11 @@ module PGN
82
82
  }
83
83
  }.freeze
84
84
 
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
+
85
90
  attr_accessor :board, :move, :origin
86
91
 
87
92
  # @param board [PGN::Board] the current board
@@ -113,15 +118,13 @@ module PGN
113
118
  restrict += %w[K Q]
114
119
  when 'k'
115
120
  restrict += %w[k q]
116
- when 'R'
117
- restrict << { 'a1' => 'Q', 'h1' => 'K' }[origin]
118
- when 'r'
119
- restrict << { 'a8' => 'q', 'h8' => 'k' }[origin]
121
+ when 'R', 'r'
122
+ restrict << ROOK_RESTRICTIONS[origin]
120
123
  end
121
124
 
122
125
  # when castling occurs
123
- restrict += %w[K Q] if %w[K Q].include? move.castle
124
- restrict += %w[k q] if %w[k q].include? move.castle
126
+ restrict += %w[K Q] if %w[K Q].include?(move.castle)
127
+ restrict += %w[k q] if %w[k q].include?(move.castle)
125
128
 
126
129
  # when a rook is taken
127
130
  restrict << 'Q' if move.destination == 'a1'
@@ -129,7 +132,7 @@ module PGN
129
132
  restrict << 'K' if move.destination == 'h1'
130
133
  restrict << 'k' if move.destination == 'h8'
131
134
 
132
- restrict.compact.uniq
135
+ restrict.empty? ? restrict : restrict.compact.uniq
133
136
  end
134
137
 
135
138
  # @return [Boolean] whether to increment the halfmove clock
@@ -332,7 +335,7 @@ module PGN
332
335
  end
333
336
 
334
337
  def destination_coords
335
- board.coordinates_for(move.destination)
338
+ @destination_coords ||= board.coordinates_for(move.destination)
336
339
  end
337
340
  end
338
341
  end
data/lib/pgn/parser.rb CHANGED
@@ -1,28 +1,18 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'pgn/pgn_parser'
2
4
 
3
5
  module PGN
4
6
  # {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}).
7
- #
8
- # The +backend+ class attribute is retained so callers (tests, benchmarks)
9
- # can substitute an alternative parser responding to +new.parse(pgn_string)+.
7
+ # of game hashes. It delegates to the stdlib Racc + StringScanner parser
8
+ # {PGN::PgnParser}.
10
9
  class Parser
11
- class << self
12
- # The backend parser class used by {.parse}.
13
- # @return [Class]
14
- attr_accessor :backend
15
- end
16
-
17
- # Default backend is the stdlib Racc + StringScanner parser.
18
- self.backend = PGN::PgnParser
19
-
20
10
  # @param input [String] the raw PGN text (already force-encoded by the
21
11
  # caller, e.g. via {PGN.parse}).
22
12
  # @return [Array<Hash>] one Hash per game, with keys +:tags+, +:result+,
23
13
  # +:moves+, +:pgn+, +:comment+.
24
14
  def parse(input)
25
- self.class.backend.new.parse(input)
15
+ PGN::PgnParser.new.parse(input)
26
16
  end
27
17
  end
28
18
  end