pgn2 1.3.0 → 1.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 33036a2920baf0e170efc5e1c387c2a53ae91fe19ad19cf745d4d6f049c9fe20
4
- data.tar.gz: 93ccbe0289a72f4af5fb8367a4d1f5bdc0c1d5eb77f1a65e3381b7d17f5d3822
3
+ metadata.gz: 9d90a73cdcc2fc1b2765b35315935c6f20272a0e2374863d0d7c491a21f815ff
4
+ data.tar.gz: e4b34dfcb1e2d0c63ed479d338a0d58f3808f965db99000844cf7dcfa3af8fc9
5
5
  SHA512:
6
- metadata.gz: 25ed517772982909d896b4b372308d235574db54aa44f3ff98f5201f9055120fc64d19fcd634c6676f7bfb66cf5e4de483a2a9ae13293b3162781339434bfac1
7
- data.tar.gz: bf2a935f6aa4fedffb5976b0febc5eb351354165275d8b248eac99f7a713d74cf6acb3cb9e0be51c6030a55481663173ab5c9008cf5dfa67904bd09ac4e271f5
6
+ metadata.gz: b222778b4e25b13fbe5e62fe694d1e5189099d8cafd7b5a15f579e3df7413aa60f43a69ee299d3a37fa312c3baf25e798a54e432ad199ec118ebd56e35b9949b
7
+ data.tar.gz: eacb82757bb6876272f320b6b5402a449d3954d0c39052ef4304682c00435b559b49a29379f77a9c7f34dbbf210c4b1865b59df66f19ae1a72bfb5c0050e1df7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.4.0 (unreleased)
4
+
5
+ ### Summary
6
+
7
+ New `PGN::Notation` module that *generates* Standard Algebraic Notation
8
+ (SAN) for a single coordinate move (origin square, destination square,
9
+ optional promotion) given the position before the move. The rest of the
10
+ gem only *parses* SAN; this is the reverse direction, needed to render
11
+ moves stored as coordinates (e.g. `e2`-`e4`) in standard chess notation.
12
+
13
+ ### Added
14
+ - `PGN::Notation.san(position, from, to, promotion = nil)` and the
15
+ convenience `PGN::Notation.san_from_fen(fen, from, to, promotion = nil)`.
16
+ Builds full SAN: piece letter, capture (`x`), castling (`O-O`/`O-O-O`),
17
+ pawn capture file (`exd5`), promotion (`=Q`), legal-move disambiguation
18
+ (file / rank / full square, respecting pins), and check (`+`) / checkmate
19
+ (`#`) suffixes. Checkmate detection drives full legal-move generation for
20
+ the side to move (the gem's first move generator). Raise `ArgumentError`
21
+ if the origin square is empty.
22
+ - New `spec/notation_spec.rb`; all 201 specs green. A round-trip harness over
23
+ every parseable fixture reproduces the original SAN of all 277 moves
24
+ exactly (including disambiguation and `+`/`#` suffixes).
25
+
26
+ ### Changed (parser performance)
27
+ - `PGN::Lexer#scan_one` now dispatches on the leading byte of the next
28
+ token via a frozen `BYTE_DISPATCH` table (with an `ALL_RULES` fallback),
29
+ trying only the 1-2 rules that can match that byte instead of walking all
30
+ nine rules in order. Profiling had `StringScanner#scan` at ~23% of parse
31
+ CPU and the rule loop ~38% inclusive; the dispatch nearly halves scan time.
32
+ - `PGN::PgnParser#next_token` mutates the lexer's `[type, value]` pair in
33
+ place rather than allocating a second translated pair — one array per token
34
+ is the Racc floor.
35
+ - Net (500 immortal games): parse-only throughput +25% (305 → 203 ms/i),
36
+ parse allocations −17% (347037 → 288537 objects / 17977414 → 15640374
37
+ bytes), parse+replay allocations −7% (831530 → 773030 objects). Serialized
38
+ PGN/FEN output stays byte-identical; all 201 specs green.
39
+
3
40
  ## 1.3.0 (2026-08-13)
4
41
 
5
42
  ### Summary
data/README.md CHANGED
@@ -122,6 +122,29 @@ _ _ _ ♙ _ _ _ _
122
122
  => r1bk3r/p2pBpNp/n4n2/1p1NP2P/6P1/3P4/P1P1K3/q5b1 b - - 1 22
123
123
  ```
124
124
 
125
+ ### Generating SAN from coordinates
126
+
127
+ {PGN::Notation} is the reverse of {PGN::Move}: it *builds* Standard
128
+ Algebraic Notation for a coordinate move (origin square, destination square,
129
+ optional promotion) given the position before the move. Use it to render
130
+ moves stored as coordinates in standard chess notation.
131
+
132
+ ```
133
+ > PGN::Notation.san(PGN::Position.start, "g1", "f3")
134
+ => "Nf3"
135
+
136
+ > PGN::Notation.san_from_fen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", "e2", "e4")
137
+ => "e4"
138
+
139
+ > fen = "r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1"
140
+ > PGN::Notation.san_from_fen(fen, "e1", "g1")
141
+ => "O-O"
142
+ ```
143
+
144
+ It handles captures, en passant, promotions, legal-move disambiguation
145
+ (file / rank / full square, respecting pins), and check (`+`) / checkmate
146
+ (`#`) suffixes.
147
+
125
148
  ## Benchmarks
126
149
 
127
150
  A reproducible profiling harness lives in `bench/`. It measures the
@@ -181,12 +204,12 @@ Parser — 500 immortal games (`bench/profile_parse.rb`):
181
204
 
182
205
  | Metric | original `pgn` | pgn2 | Δ |
183
206
  |---|---:|---:|---:|
184
- | Parse-only allocations (objects) | 1248065 | 347037 | -901028 (-72.2%) |
185
- | Parse-only allocations (bytes) | 120370470 | 17977414 | -102393056 (-85.1%) |
186
- | Parse + replay allocations (objects) | 3778073 | 831530 | -2946543 (-78.0%) |
187
- | Parse + replay allocations (bytes) | 249570048 | 47966112 | -201603936 (-80.8%) |
188
- | Parse-only throughput | 1461 ms/i | 305 ms/i | ~4.8x faster |
189
- | Parse + replay throughput | 1938 ms/i | 534 ms/i | ~3.6x faster |
207
+ | Parse-only allocations (objects) | 1248065 | 288537 | -959528 (-76.9%) |
208
+ | Parse-only allocations (bytes) | 120370470 | 15640374 | -104730096 (-87.0%) |
209
+ | Parse + replay allocations (objects) | 3778073 | 773030 | -3005043 (-79.5%) |
210
+ | Parse + replay allocations (bytes) | 249570048 | 45626128 | -203943920 (-81.7%) |
211
+ | Parse-only throughput | 1461 ms/i | 203 ms/i | ~7.2x faster |
212
+ | Parse + replay throughput | 1938 ms/i | 484 ms/i | ~4.0x faster |
190
213
 
191
214
  What changed to get there:
192
215
 
@@ -239,9 +262,19 @@ What changed to get there:
239
262
  must clone the index every move and every move pays maintenance that pawns
240
263
  (the common case, geometry-fixed origins) can't use — so it was rejected
241
264
  and reverted. The 0x88 board alone is the winner.
265
+ 15. `PGN::Lexer#scan_one` — byte-dispatch: the leading byte of the next
266
+ token selects the 1-2 `RULES` that can possibly match it (a frozen
267
+ `BYTE_DISPATCH` table; `ALL_RULES` fallback) instead of walking all nine
268
+ rules in order. `StringScanner#scan` was ~23% of parse CPU and the rule
269
+ loop ~38% inclusive; the dispatch nearly halves scan time. `PgnParser#
270
+ next_token` now mutates the lexer's `[type, value]` pair in place instead
271
+ of allocating a second translated pair (one array per token is the Racc
272
+ floor). Parse-only throughput +25% (305 → 203 ms/i), parse allocations
273
+ −17% (347037 → 288537 objects / 17977414 → 15640374 bytes for 500 games).
274
+ Output byte-identical.
242
275
 
243
276
  Public output (FEN, PGN) is byte-identical to the original gem; the full
244
- suite (182 examples) stays green. See `bench/IMPROVEMENTS.md` for the per-step
277
+ suite (201 examples) stays green. See `bench/IMPROVEMENTS.md` for the per-step
245
278
  before/after deltas that produced these tables.
246
279
 
247
280
  ## Installation
@@ -1,25 +1,25 @@
1
1
  Corpus: 500 copies of the immortal game
2
2
 
3
3
  === 1. Parse-only allocations (500 games) ===
4
- total_allocated objects: 347037
5
- total_allocated bytes: 17977414
4
+ total_allocated objects: 288537
5
+ total_allocated bytes: 15640374
6
6
 
7
7
  === 2. Parse + replay allocations (500 games) ===
8
- total_allocated objects: 831530
9
- total_allocated bytes: 47966112
8
+ total_allocated objects: 773030
9
+ total_allocated bytes: 45626128
10
10
 
11
11
  === 3. Parse-only throughput (ips) ===
12
12
  ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [x86_64-linux]
13
13
  Warming up --------------------------------------
14
14
  parse 500 games 1.000 i/100ms
15
15
  Calculating -------------------------------------
16
- parse 500 games 4.053 0.0%) i/s (246.70 ms/i) - 21.000 in 5.180770s
16
+ parse 500 games 4.92620.3%) i/s (203.01 ms/i) - 25.000 in 5.075163s
17
17
 
18
18
  === 4. Parse + replay throughput (ips) ===
19
19
  ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [x86_64-linux]
20
20
  Warming up --------------------------------------
21
21
  parse+replay 500 games 1.000 i/100ms
22
22
  Calculating -------------------------------------
23
- parse+replay 500 games 1.872 (± 0.0%) i/s (534.26 ms/i) - 10.000 in 5.342615s
23
+ parse+replay 500 games 2.065 (± 0.0%) i/s (484.34 ms/i) - 11.000 in 5.327699s
24
24
 
25
25
  Done. Compare this file against bench/baseline_parse.txt after optimizations.
data/lib/pgn/lexer.rb CHANGED
@@ -117,6 +117,39 @@ module PGN
117
117
  [:tag_name, TAG_NAME, false]
118
118
  ].freeze.each(&:freeze)
119
119
 
120
+ # Byte-dispatch table: maps the leading byte of the next token to the
121
+ # ordered list of RULES indices that could possibly match it. This lets
122
+ # {#scan_one} try one or two regexes for the common tokens instead of
123
+ # walking all nine rules in order (StringScanner#scan was ~23% of parse
124
+ # CPU and the rule loop ~38% inclusive per profiling). The order within
125
+ # each list mirrors RULES, so tokenization is byte-compatible with the
126
+ # linear scan. Indices: 0 wsp, 1 pgn_comment, 2 game_termination,
127
+ # 3 san_move, 4 move_number, 5 nag, 6 comment, 7 string, 8 tag_name.
128
+ BYTE_DISPATCH = begin
129
+ h = {
130
+ 9 => [0], 10 => [0], 11 => [0], 12 => [0], 13 => [0], 32 => [0], # whitespace
131
+ 37 => [1], # % pgn_comment
132
+ 42 => [2], # * game_termination
133
+ 34 => [7], # " string
134
+ 123 => [6], # { comment
135
+ 36 => [5], 63 => [5], 33 => [5], # $ ? ! nag
136
+ 48 => [2, 3, 4, 8], 49 => [2, 4, 8], # 0, 1 (term/castle/num/tag)
137
+ 95 => [8] # _ tag_name
138
+ }
139
+ (50..57).each { |b| h[b] = [4, 8] } # 2..9 move_number/tag_name
140
+ [66, 75, 78, 79, 81, 82].each { |b| h[b] = [3, 8] } # B K N O Q R san_move/tag
141
+ (97..104).each { |b| h[b] = [3, 8] } # a-h pawn san_move/tag
142
+ # other tag_name letters
143
+ ((65..90).to_a + (105..122).to_a - [66, 75, 78, 79, 81, 82]).each do |b|
144
+ h[b] = [8]
145
+ end
146
+ h.each_value(&:freeze)
147
+ h.freeze
148
+ end
149
+
150
+ # Fallback for bytes not in BYTE_DISPATCH: try every rule in order.
151
+ ALL_RULES = (0...RULES.length).to_a.freeze
152
+
120
153
  # Single-character literals, matched by their byte value: [type, frozen value].
121
154
  LITERAL_BYTES = {
122
155
  91 => [:lbracket, '['], # [
@@ -185,14 +218,17 @@ module PGN
185
218
  # Try each terminal rule in order; return the matched string for the
186
219
  # first match (stashing its type and discarded flag in +@scan_type+ /
187
220
  # +@scan_discarded+ so the caller avoids allocating a 3-element tuple),
188
- # or raise if nothing matches at the current position.
221
+ # or raise if nothing matches at the current position. Uses
222
+ # {BYTE_DISPATCH} to try only the rules that can match the leading byte.
189
223
  def scan_one
190
- RULES.each do |(type, re, discarded)|
191
- if (m = @ss.scan(re))
192
- @scan_type = type
193
- @scan_discarded = discarded
194
- return m
195
- end
224
+ indices = BYTE_DISPATCH[@input.getbyte(@ss.pos)] || ALL_RULES
225
+ indices.each do |i|
226
+ type, re, discarded = RULES[i]
227
+ next unless (m = @ss.scan(re))
228
+
229
+ @scan_type = type
230
+ @scan_discarded = discarded
231
+ return m
196
232
  end
197
233
  raise UnconsumedInputError,
198
234
  "Unmatched input #{@input.byteslice(@ss.pos..).inspect} on line #{@line}"
@@ -0,0 +1,474 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PGN
4
+ # {PGN::Notation} generates Standard Algebraic Notation (SAN) for a single
5
+ # move described in coordinate form -- an origin square, a destination
6
+ # square, and an optional promotion piece -- given the {PGN::Position}
7
+ # *before* the move is played.
8
+ #
9
+ # Unlike {PGN::Move}, which *parses* an existing SAN string, {PGN::Notation}
10
+ # *builds* SAN from coordinates. This requires the kind of legality analysis
11
+ # the rest of the gem does not perform: attack detection, "does this move
12
+ # leave the mover's king in check", disambiguation among same-type pieces
13
+ # that can legally reach the destination, and check (+) / checkmate (#)
14
+ # suffix detection (the latter needing full legal-move generation for the
15
+ # side to move).
16
+ #
17
+ # The board is addressed with the same 0x88 integer indices the rest of the
18
+ # gem uses (see {PGN::Board}); an index is on-board when
19
+ # `(idx & 0x88).zero?`.
20
+ #
21
+ # @example
22
+ # PGN::Notation.san(PGN::Position.start, 'g1', 'f3') #=> "Nf3"
23
+ # PGN::Notation.san_from_fen(PGN::FEN::INITIAL, 'e2', 'e4') #=> "e4"
24
+ #
25
+ class Notation
26
+ # 0x88 single-step offsets for a knight (symmetric, so they double as
27
+ # attack deltas).
28
+ KNIGHT_OFFS = [33, 31, -31, -33, 18, 14, -14, -18].freeze
29
+ # 0x88 single-step offsets for a king (symmetric).
30
+ KING_OFFS = [-1, 1, -16, 16, -15, 15, -17, 17].freeze
31
+ BISHOP_DIRS = [-15, 15, -17, 17].freeze
32
+ ROOK_DIRS = [-1, 1, -16, 16].freeze
33
+
34
+ # Build SAN for a coordinate move from a {PGN::Position}.
35
+ #
36
+ # @param position [PGN::Position] the position *before* the move
37
+ # @param from [String] origin square in algebraic notation ("e2")
38
+ # @param to [String] destination square in algebraic notation ("e4")
39
+ # @param promotion [String, nil] promotion piece letter ("q"/"Q"/"n"...);
40
+ # case-insensitive; SAN always emits an uppercase letter
41
+ # @return [String] the move in SAN, e.g. "Nf3", "exd5", "O-O", "Ra8#"
42
+ #
43
+ def self.san(position, from, to, promotion = nil)
44
+ new(position).san(from, to, promotion)
45
+ end
46
+
47
+ # Convenience: build SAN directly from a FEN string.
48
+ #
49
+ # @param fen [String] the FEN of the position *before* the move
50
+ # @param (see .san)
51
+ # @return [String] the move in SAN
52
+ #
53
+ def self.san_from_fen(fen, from, to, promotion = nil)
54
+ san(PGN::FEN.new(fen).to_position, from, to, promotion)
55
+ end
56
+
57
+ def initialize(position)
58
+ @position = position
59
+ @board = position.board
60
+ @player = position.player
61
+ @mover = (@player == :white ? 'w' : 'b')
62
+ @enemy = (@player == :white ? 'b' : 'w')
63
+ end
64
+
65
+ # @param (see .san)
66
+ # @return [String]
67
+ def san(from, to, promotion = nil)
68
+ from_idx = @board.index_of(from)
69
+ to_idx = @board.index_of(to)
70
+ piece = @board.at_index(from_idx)
71
+ raise ArgumentError, "no piece on #{from}" if piece.nil?
72
+
73
+ promotion = promotion.upcase if promotion
74
+
75
+ castling = piece.upcase == 'K' && ((from_idx & 0x0F) - (to_idx & 0x0F)).abs == 2
76
+ capture = castling ? false : capture?(piece, from_idx, to_idx)
77
+
78
+ body =
79
+ if castling
80
+ to_idx < from_idx ? 'O-O-O' : 'O-O'
81
+ elsif piece.upcase == 'P'
82
+ pawn_body(from_idx, to_idx, capture, promotion)
83
+ else
84
+ piece_body(piece, from_idx, to_idx, capture)
85
+ end
86
+
87
+ body + suffix(from_idx, to_idx, piece, promotion, castling)
88
+ end
89
+
90
+ private
91
+
92
+ # -- body construction -------------------------------------------------
93
+
94
+ def pawn_body(from_idx, to_idx, capture, promotion)
95
+ dest = @board.position_for([to_idx & 0x0F, to_idx >> 4])
96
+ promo = promotion ? "=#{promotion}" : ''
97
+ if capture
98
+ "#{Board::INDEX_TO_FILE[from_idx & 0x0F]}x#{dest}#{promo}"
99
+ else
100
+ "#{dest}#{promo}"
101
+ end
102
+ end
103
+
104
+ def piece_body(piece, from_idx, to_idx, capture)
105
+ disamb = disambiguation(piece, from_idx, to_idx)
106
+ dest = @board.position_for([to_idx & 0x0F, to_idx >> 4])
107
+ parts = [piece.upcase, disamb]
108
+ parts << 'x' if capture
109
+ parts << dest
110
+ parts.join
111
+ end
112
+
113
+ # Disambiguate a non-pawn, non-castling move among same-type pieces that
114
+ # can *legally* reach the destination. Returns '' when none can.
115
+ def disambiguation(piece, from_idx, to_idx)
116
+ others = same_type_legals(piece, from_idx, to_idx)
117
+ return '' if others.empty?
118
+
119
+ from_file = from_idx & 0x0F
120
+ from_rank = from_idx >> 4
121
+ file_clash = others.any? { |o| (o & 0x0F) == from_file }
122
+ rank_clash = others.any? { |o| (o >> 4) == from_rank }
123
+
124
+ if !file_clash
125
+ Board::INDEX_TO_FILE[from_file]
126
+ elsif !rank_clash
127
+ Board::INDEX_TO_RANK[from_rank]
128
+ else
129
+ Board::INDEX_TO_FILE[from_file] + Board::INDEX_TO_RANK[from_rank]
130
+ end
131
+ end
132
+
133
+ # Other squares holding `piece` whose move to `to_idx` is legal.
134
+ def same_type_legals(piece, from_idx, to_idx)
135
+ (0...128).each_with_object([]) do |idx, acc|
136
+ next if (idx & 0x88) != 0
137
+ next if idx == from_idx
138
+ next unless @board.at_index(idx) == piece
139
+
140
+ acc << idx if reaches?(piece, idx, to_idx) && legal_after?(idx, to_idx, piece)
141
+ end
142
+ end
143
+
144
+ # -- check / checkmate suffix -----------------------------------------
145
+
146
+ def suffix(from_idx, to_idx, piece, promotion, castling)
147
+ nb = apply_move(from_idx, to_idx, piece, promotion, castling)
148
+ opp_king = king_idx(nb, @enemy)
149
+ return '' unless opp_king && attacked?(nb, opp_king, @mover)
150
+
151
+ result_pos = PGN::Position.new(
152
+ nb,
153
+ @player == :white ? :black : :white,
154
+ [],
155
+ new_ep_square(piece, from_idx, to_idx),
156
+ 0,
157
+ @position.fullmove
158
+ )
159
+ any_legal_move?(result_pos) ? '+' : '#'
160
+ end
161
+
162
+ # -- move application -------------------------------------------------
163
+
164
+ def apply_move(from_idx, to_idx, piece, promotion, castling)
165
+ nb = @board.dup
166
+ return castle!(nb, from_idx, to_idx, piece) if castling
167
+
168
+ ep = ep_capture_square(piece, from_idx, to_idx)
169
+ nb.update_index(ep, nil) if ep
170
+ nb.update_index(from_idx, nil)
171
+ nb.update_index(to_idx, promotion ? promoted(promotion) : piece)
172
+ nb
173
+ end
174
+
175
+ def castle!(board, from_idx, to_idx, piece)
176
+ rank = from_idx >> 4
177
+ rook = piece.upcase == 'K' ? 'R' : 'r'
178
+ board.update_index(from_idx, nil)
179
+ board.update_index(to_idx, piece)
180
+ if to_idx < from_idx # queenside
181
+ board.update_index(rank * 16, nil)
182
+ board.update_index((rank * 16) + 3, rook)
183
+ else # kingside
184
+ board.update_index((rank * 16) + 7, nil)
185
+ board.update_index((rank * 16) + 5, rook)
186
+ end
187
+ board
188
+ end
189
+
190
+ def promoted(promotion)
191
+ @player == :white ? promotion.upcase : promotion.downcase
192
+ end
193
+
194
+ # En-passant captured-pawn square (or nil): a pawn moving diagonally to
195
+ # an empty destination must be capturing en passant.
196
+ def ep_capture_square(piece, from_idx, to_idx)
197
+ return nil unless piece.upcase == 'P'
198
+ return nil if (from_idx & 0x0F) == (to_idx & 0x0F)
199
+
200
+ @board.at_index(to_idx).nil? ? ((from_idx >> 4) * 16) + (to_idx & 0x0F) : nil
201
+ end
202
+
203
+ # The en-passant target square left behind by a double pawn push, for the
204
+ # resulting position (so an escaping ep capture is considered for mate).
205
+ def new_ep_square(piece, from_idx, to_idx)
206
+ return nil unless piece.upcase == 'P'
207
+ return nil if ((from_idx >> 4) - (to_idx >> 4)).abs != 2
208
+
209
+ mid = ((from_idx >> 4) + (to_idx >> 4)) / 2
210
+ Board::INDEX_TO_FILE[from_idx & 0x0F] + Board::INDEX_TO_RANK[mid]
211
+ end
212
+
213
+ # -- legality ---------------------------------------------------------
214
+
215
+ # Is `piece` from `from_idx` to `to_idx` legal (own king safe after)?
216
+ def legal_after?(from_idx, to_idx, piece)
217
+ nb = @board.dup
218
+ ep = ep_capture_square(piece, from_idx, to_idx)
219
+ nb.update_index(ep, nil) if ep
220
+ nb.update_index(from_idx, nil)
221
+ nb.update_index(to_idx, piece)
222
+ king = king_idx(nb, @mover)
223
+ king && !attacked?(nb, king, @enemy)
224
+ end
225
+
226
+ # Does `piece` at `from_idx` pseudo-legally reach `to_idx` on the board?
227
+ # (Sliders honor blockers; the destination may be empty or enemy-occupied.)
228
+ def reaches?(piece, from_idx, to_idx)
229
+ return false if from_idx == to_idx
230
+
231
+ case piece.upcase
232
+ when 'N'
233
+ KNIGHT_OFFS.any? { |o| (from_idx + o) == to_idx }
234
+ when 'K'
235
+ KING_OFFS.any? { |o| (from_idx + o) == to_idx }
236
+ when 'B', 'R', 'Q'
237
+ slider_reaches?(piece.upcase, from_idx, to_idx)
238
+ else
239
+ false
240
+ end
241
+ end
242
+
243
+ def slider_reaches?(piece_up, from_idx, to_idx)
244
+ slider_dirs(piece_up).any? do |off|
245
+ i = from_idx + off
246
+ while (i & 0x88).zero?
247
+ return true if i == to_idx
248
+ break if @board.at_index(i)
249
+
250
+ i += off
251
+ end
252
+ false
253
+ end
254
+ end
255
+
256
+ # Capture detection for the *played* move (used in the SAN body).
257
+ def capture?(piece, from_idx, to_idx)
258
+ if piece.upcase == 'P'
259
+ (from_idx & 0x0F) != (to_idx & 0x0F) # any diagonal pawn move captures
260
+ else
261
+ target = @board.at_index(to_idx)
262
+ target && !own?(target, @mover)
263
+ end
264
+ end
265
+
266
+ def own?(piece, color)
267
+ color == 'w' ? piece == piece.upcase : piece == piece.downcase
268
+ end
269
+
270
+ # Slider ray directions for a piece letter ('B'/'R'/'Q').
271
+ def slider_dirs(piece_up)
272
+ case piece_up
273
+ when 'B' then BISHOP_DIRS
274
+ when 'R' then ROOK_DIRS
275
+ else BISHOP_DIRS + ROOK_DIRS
276
+ end
277
+ end
278
+
279
+ # -- attack / legality helpers ----------------------------------------
280
+
281
+ def king_idx(board, color)
282
+ king = color == 'w' ? 'K' : 'k'
283
+ (0...128).each do |idx|
284
+ next if (idx & 0x88) != 0
285
+
286
+ return idx if board.at_index(idx) == king
287
+ end
288
+ nil
289
+ end
290
+
291
+ # Is `target` attacked by `color`-colored pieces on `board`?
292
+ def attacked?(board, target, color)
293
+ pawn_attacked?(board, target, color) ||
294
+ knight_attacked?(board, target, color) ||
295
+ king_attacked?(board, target, color) ||
296
+ slider_attacked?(board, target, color)
297
+ end
298
+
299
+ def pawn_attacked?(board, target, color)
300
+ offs = color == 'w' ? [-15, -17] : [15, 17]
301
+ pawn = color == 'w' ? 'P' : 'p'
302
+ offs.any? do |off|
303
+ i = target + off
304
+ (i & 0x88).zero? && board.at_index(i) == pawn
305
+ end
306
+ end
307
+
308
+ def knight_attacked?(board, target, color)
309
+ knight = color == 'w' ? 'N' : 'n'
310
+ KNIGHT_OFFS.any? do |off|
311
+ i = target + off
312
+ (i & 0x88).zero? && board.at_index(i) == knight
313
+ end
314
+ end
315
+
316
+ def king_attacked?(board, target, color)
317
+ king = color == 'w' ? 'K' : 'k'
318
+ KING_OFFS.any? do |off|
319
+ i = target + off
320
+ (i & 0x88).zero? && board.at_index(i) == king
321
+ end
322
+ end
323
+
324
+ def slider_attacked?(board, target, color)
325
+ bishop = color == 'w' ? 'B' : 'b'
326
+ rook = color == 'w' ? 'R' : 'r'
327
+ queen = color == 'w' ? 'Q' : 'q'
328
+ ray_hit?(board, target, BISHOP_DIRS) { |p| p == bishop || p == queen } ||
329
+ ray_hit?(board, target, ROOK_DIRS) { |p| p == rook || p == queen }
330
+ end
331
+
332
+ def ray_hit?(board, target, dirs)
333
+ dirs.each do |off|
334
+ i = target + off
335
+ while (i & 0x88).zero?
336
+ piece = board.at_index(i)
337
+ if piece
338
+ return true if yield(piece)
339
+ break
340
+ end
341
+ i += off
342
+ end
343
+ end
344
+ false
345
+ end
346
+
347
+ # -- legal-move generation (for checkmate detection) ------------------
348
+
349
+ def any_legal_move?(position)
350
+ board = position.board
351
+ color = position.player == :white ? 'w' : 'b'
352
+ ep_idx = ep_target_idx(position, board)
353
+
354
+ (0...128).any? do |idx|
355
+ next if (idx & 0x88) != 0
356
+
357
+ piece = board.at_index(idx)
358
+ piece && own?(piece, color) && move_from?(board, idx, piece, color, ep_idx)
359
+ end
360
+ end
361
+
362
+ def ep_target_idx(position, board)
363
+ ep = position.en_passant
364
+ return nil if ep.nil? || ep == '-' || ep.empty?
365
+
366
+ board.index_of(ep)
367
+ end
368
+
369
+ # Yields true if `piece` at `idx` has any legal move (escapes check).
370
+ def move_from?(board, idx, piece, color, ep_idx)
371
+ up = piece.upcase
372
+ case up
373
+ when 'N' then leaper_moves?(board, idx, piece, color, KNIGHT_OFFS)
374
+ when 'K' then leaper_moves?(board, idx, piece, color, KING_OFFS)
375
+ when 'B', 'R', 'Q'
376
+ slider_moves?(board, idx, piece, color, slider_dirs(up))
377
+ when 'P'
378
+ pawn_moves?(board, idx, piece, color, ep_idx)
379
+ else
380
+ false
381
+ end
382
+ end
383
+
384
+ def leaper_moves?(board, idx, piece, color, offs)
385
+ offs.any? do |off|
386
+ t = idx + off
387
+ (t & 0x88).zero? && landable?(board, t, color) && safe?(board, idx, t, piece, nil)
388
+ end
389
+ end
390
+
391
+ def slider_moves?(board, idx, piece, color, dirs)
392
+ dirs.any? do |off|
393
+ t = idx + off
394
+ while (t & 0x88).zero?
395
+ tp = board.at_index(t)
396
+ if tp.nil?
397
+ return true if safe?(board, idx, t, piece, nil)
398
+ else
399
+ return true if !own?(tp, color) && safe?(board, idx, t, piece, nil)
400
+ break
401
+ end
402
+ t += off
403
+ end
404
+ false
405
+ end
406
+ end
407
+
408
+ def pawn_moves?(board, idx, piece, color, ep_idx)
409
+ pawn_pushes?(board, idx, piece, color) ||
410
+ pawn_captures?(board, idx, piece, color, ep_idx)
411
+ end
412
+
413
+ def pawn_pushes?(board, idx, piece, color)
414
+ dir = color == 'w' ? 16 : -16
415
+ start_rank = color == 'w' ? 1 : 6
416
+ promo_rank = color == 'w' ? 7 : 0
417
+
418
+ t = idx + dir
419
+ return false unless (t & 0x88).zero? && board.at_index(t).nil?
420
+
421
+ promo = (t >> 4 == promo_rank) ? 'Q' : nil
422
+ return true if safe?(board, idx, t, piece, promo)
423
+
424
+ t2 = idx + (dir * 2)
425
+ (idx >> 4) == start_rank && board.at_index(t2).nil? && safe?(board, idx, t2, piece, nil)
426
+ end
427
+
428
+ def pawn_captures?(board, idx, piece, color, ep_idx) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
429
+ promo_rank = color == 'w' ? 7 : 0
430
+ caps = color == 'w' ? [15, 17] : [-15, -17]
431
+ caps.any? do |off|
432
+ t = idx + off
433
+ next false unless (t & 0x88).zero?
434
+
435
+ tp = board.at_index(t)
436
+ promo = (t >> 4 == promo_rank) ? 'Q' : nil
437
+ if tp && !own?(tp, color)
438
+ safe?(board, idx, t, piece, promo)
439
+ elsif tp.nil? && t == ep_idx
440
+ safe?(board, idx, t, piece, nil)
441
+ else
442
+ false
443
+ end
444
+ end
445
+ end
446
+
447
+ def landable?(board, target, color)
448
+ tp = board.at_index(target)
449
+ tp.nil? || !own?(tp, color)
450
+ end
451
+
452
+ # Apply a pseudo-move (with ep + promotion) on a copy and test own-king
453
+ # safety. The moving side is derived from `piece`'s case.
454
+ def safe?(board, from_idx, to_idx, piece, promotion)
455
+ nb = board.dup
456
+ if piece.upcase == 'P' && (from_idx & 0x0F) != (to_idx & 0x0F) && board.at_index(to_idx).nil?
457
+ nb.update_index(((from_idx >> 4) * 16) + (to_idx & 0x0F), nil) # ep capture
458
+ end
459
+ nb.update_index(from_idx, nil)
460
+ nb.update_index(to_idx, place_piece(piece, promotion))
461
+ color = own?(piece, 'w') ? 'w' : 'b'
462
+ king = king_idx(nb, color)
463
+ king && !attacked?(nb, king, color == 'w' ? 'b' : 'w')
464
+ end
465
+
466
+ # The piece letter to place on the destination after a pseudo-move.
467
+ def place_piece(piece, promotion)
468
+ return piece unless promotion
469
+
470
+ white = piece == piece.upcase
471
+ white ? promotion.upcase : promotion.downcase
472
+ end
473
+ end
474
+ end
@@ -21,8 +21,10 @@ module_eval(<<'...end pgn_parser.y/module_eval...', 'pgn_parser.y', 97)
21
21
  def next_token
22
22
  pair = @lexer.next_token_pair
23
23
  return [false, false] unless pair
24
- type, value = pair
25
- [translate_type(type), value]
24
+ # Mutate the lexer's [type, value] pair in place rather than allocating
25
+ # a second translated pair -- one array per token is the Racc floor.
26
+ pair[0] = translate_type(pair[0])
27
+ pair
26
28
  end
27
29
 
28
30
  private
data/lib/pgn/pgn_parser.y CHANGED
@@ -106,8 +106,10 @@ rule
106
106
  def next_token
107
107
  pair = @lexer.next_token_pair
108
108
  return [false, false] unless pair
109
- type, value = pair
110
- [translate_type(type), value]
109
+ # Mutate the lexer's [type, value] pair in place rather than allocating
110
+ # a second translated pair -- one array per token is the Racc floor.
111
+ pair[0] = translate_type(pair[0])
112
+ pair
111
113
  end
112
114
 
113
115
  private
data/lib/pgn/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module PGN
2
- VERSION = '1.3.0'.freeze
2
+ VERSION = '1.4.0'.freeze
3
3
  end
data/lib/pgn.rb CHANGED
@@ -4,6 +4,7 @@ require 'pgn/game'
4
4
  require 'pgn/move'
5
5
  require 'pgn/move_calculator'
6
6
  require 'pgn/lexer'
7
+ require 'pgn/notation'
7
8
  require 'pgn/pgn_parser'
8
9
  require 'pgn/parser'
9
10
  require 'pgn/position'
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ # Helper: build a Position from a FEN string.
6
+ def pos(fen)
7
+ PGN::FEN.new(fen).to_position
8
+ end
9
+
10
+ describe PGN::Notation do
11
+ # ------------------------------------------------------------------
12
+ # Public API
13
+ # ------------------------------------------------------------------
14
+ describe '.san' do
15
+ it 'generates a plain pawn push from coordinates' do
16
+ expect(PGN::Notation.san(pos('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'),
17
+ 'e2', 'e4')).to eq('e4')
18
+ end
19
+
20
+ it 'generates a knight move with the piece letter' do
21
+ expect(PGN::Notation.san(pos('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'),
22
+ 'g1', 'f3')).to eq('Nf3')
23
+ end
24
+
25
+ it 'generates a pawn capture with the origin file' do
26
+ fen = 'rnbqkbnr/ppp1pppp/8/3p4/4P3/8/PPPP1PPP/RNBQKBNR w KQkq d6 0 2'
27
+ expect(PGN::Notation.san(pos(fen), 'e4', 'd5')).to eq('exd5')
28
+ end
29
+
30
+ it 'generates an en passant capture' do
31
+ fen = 'rnbqkbnr/ppp1pppp/8/3pP3/8/8/PPPP1PPP/RNBQKBNR w KQkq d6 0 3'
32
+ expect(PGN::Notation.san(pos(fen), 'e5', 'd6')).to eq('exd6')
33
+ end
34
+
35
+ it 'generates a pawn promotion' do
36
+ fen = '8/P7/6k1/8/8/8/8/4K3 w - - 0 1'
37
+ expect(PGN::Notation.san(pos(fen), 'a7', 'a8', 'q')).to eq('a8=Q')
38
+ end
39
+
40
+ it 'generates a capture promotion' do
41
+ fen = '1n6/P7/6k1/8/8/8/8/4K3 w - - 0 1'
42
+ expect(PGN::Notation.san(pos(fen), 'a7', 'b8', 'q')).to eq('axb8=Q')
43
+ end
44
+
45
+ it 'generates kingside castling' do
46
+ fen = 'r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1'
47
+ expect(PGN::Notation.san(pos(fen), 'e1', 'g1')).to eq('O-O')
48
+ end
49
+
50
+ it 'generates queenside castling' do
51
+ fen = 'r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1'
52
+ expect(PGN::Notation.san(pos(fen), 'e1', 'c1')).to eq('O-O-O')
53
+ end
54
+
55
+ it 'generates kingside castling for black' do
56
+ fen = 'r3k2r/8/8/8/8/8/8/R3K2R b KQkq - 0 1'
57
+ expect(PGN::Notation.san(pos(fen), 'e8', 'g8')).to eq('O-O')
58
+ end
59
+ end
60
+
61
+ describe '.san disambiguation' do
62
+ it 'disambiguates knights by file' do
63
+ # Knights on c3 and d4 both reach b5.
64
+ fen = 'r1bqkb1r/pp1p1ppp/2n1pn2/8/3NP3/2N5/PPP2PPP/R1BQKB1R w KQkq - 3 6'
65
+ expect(PGN::Notation.san(pos(fen), 'd4', 'b5')).to eq('Ndb5')
66
+ expect(PGN::Notation.san(pos(fen), 'c3', 'b5')).to eq('Ncb5')
67
+ end
68
+
69
+ it 'disambiguates rooks by rank when on the same file' do
70
+ # White rooks on a1 and a8 both reach a4 (file a clear).
71
+ fen = 'R7/8/8/4k3/8/8/8/R3K3 w - - 0 1'
72
+ expect(PGN::Notation.san(pos(fen), 'a1', 'a4')).to eq('R1a4')
73
+ expect(PGN::Notation.san(pos(fen), 'a8', 'a4')).to eq('R8a4')
74
+ end
75
+
76
+ it 'disambiguates queens by full square when file and rank both clash' do
77
+ # Queens on c1, f1, f4 all reach c4. The f1 queen shares file f with
78
+ # f4 and rank 1 with c1, so only the full square disambiguates.
79
+ fen = '7k/8/8/8/5Q2/8/8/2Q2Q1K w - - 0 1'
80
+ expect(PGN::Notation.san(pos(fen), 'f1', 'c4')).to eq('Qf1c4')
81
+ end
82
+
83
+ it 'omits disambiguation when only one piece can legally move there' do
84
+ # Two knights both pseudo-reach d2, but the e4 knight is pinned
85
+ # on the e-file (moving it exposes the king on e1 to the rook on e8),
86
+ # so only the b1 knight can legally play Nd2 — no disambiguation.
87
+ fen = '4r2k/8/8/8/4N3/8/8/1N2K3 w - - 0 1'
88
+ expect(PGN::Notation.san(pos(fen), 'b1', 'd2')).to eq('Nd2')
89
+ end
90
+ end
91
+
92
+ describe '.san check and checkmate suffixes' do
93
+ it 'appends + for a checking move' do
94
+ fen = '7k/8/8/8/8/8/6R1/6K1 w - - 0 1'
95
+ expect(PGN::Notation.san(pos(fen), 'g2', 'g8')).to eq('Rg8+')
96
+ end
97
+
98
+ it 'appends # for a checkmating move' do
99
+ fen = '6k1/5ppp/8/8/8/8/8/R6K w - - 0 1'
100
+ expect(PGN::Notation.san(pos(fen), 'a1', 'a8')).to eq('Ra8#')
101
+ end
102
+
103
+ it 'appends # to a promotion that gives checkmate' do
104
+ # White pawn b7 promotes with check on a king cornered on h8.
105
+ fen = '7k/1P6/8/8/8/8/8/6K1 w - - 0 1'
106
+ expect(PGN::Notation.san(pos(fen), 'b7', 'b8', 'q')).to eq('b8=Q+')
107
+ end
108
+
109
+ it 'does not append a suffix for a quiet move' do
110
+ fen = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'
111
+ expect(PGN::Notation.san(pos(fen), 'd2', 'd4')).to eq('d4')
112
+ end
113
+ end
114
+
115
+ describe '.san_from_fen' do
116
+ it 'computes SAN directly from a FEN string' do
117
+ expect(PGN::Notation.san_from_fen('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
118
+ 'e2', 'e4')).to eq('e4')
119
+ end
120
+
121
+ it 'computes SAN for black to move' do
122
+ fen = 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1'
123
+ expect(PGN::Notation.san_from_fen(fen, 'd7', 'd5')).to eq('d5')
124
+ end
125
+ end
126
+ end
metadata CHANGED
@@ -1,15 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pgn2
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stacey Touset
8
8
  - Murilo Vasconcelos
9
- autorequire:
10
9
  bindir: bin
11
10
  cert_chain: []
12
- date: 2026-08-13 00:00:00.000000000 Z
11
+ date: 1980-01-02 00:00:00.000000000 Z
13
12
  dependencies:
14
13
  - !ruby/object:Gem::Dependency
15
14
  name: bundler
@@ -168,6 +167,7 @@ files:
168
167
  - lib/pgn/lexer.rb
169
168
  - lib/pgn/move.rb
170
169
  - lib/pgn/move_calculator.rb
170
+ - lib/pgn/notation.rb
171
171
  - lib/pgn/parser.rb
172
172
  - lib/pgn/pgn_parser.rb
173
173
  - lib/pgn/pgn_parser.y
@@ -181,6 +181,7 @@ files:
181
181
  - spec/lexer_spec.rb
182
182
  - spec/move_calculator_spec.rb
183
183
  - spec/move_spec.rb
184
+ - spec/notation_spec.rb
184
185
  - spec/parser_explicit_spec.rb
185
186
  - spec/parser_spec.rb
186
187
  - spec/pgn_files/alternate_castling.pgn
@@ -208,7 +209,6 @@ metadata:
208
209
  source_code_uri: https://github.com/muriloime/pgn
209
210
  bug_tracker_uri: https://github.com/muriloime/pgn/issues
210
211
  changelog_uri: https://github.com/muriloime/pgn/blob/main/CHANGELOG.md
211
- post_install_message:
212
212
  rdoc_options: []
213
213
  require_paths:
214
214
  - lib
@@ -223,8 +223,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
223
223
  - !ruby/object:Gem::Version
224
224
  version: '0'
225
225
  requirements: []
226
- rubygems_version: 3.5.22
227
- signing_key:
226
+ rubygems_version: 4.0.10
228
227
  specification_version: 4
229
228
  summary: A PGN parser for Ruby
230
229
  test_files:
@@ -234,6 +233,7 @@ test_files:
234
233
  - spec/lexer_spec.rb
235
234
  - spec/move_calculator_spec.rb
236
235
  - spec/move_spec.rb
236
+ - spec/notation_spec.rb
237
237
  - spec/parser_explicit_spec.rb
238
238
  - spec/parser_spec.rb
239
239
  - spec/pgn_files/alternate_castling.pgn