pgn2 2.0.1 → 2.0.3

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: d6363546a03960706a76a2048b8e88aff387e1f475eb76add1e1cfd615c6b6ed
4
- data.tar.gz: 3944288b73d63291f31c0218902206eb39e2d8173fca7a91bb80aea59e0981a5
3
+ metadata.gz: 26cc37d3385d1f5516a2cff916f3219198914295b2ea83b587f1a8a6ee267480
4
+ data.tar.gz: 7efabc9471ace989a11a48879fb496f3999d35dbb42b3c8c138bdc392beeb597
5
5
  SHA512:
6
- metadata.gz: 1bb3c90fcc6afcf5a2a7ca8a285518035de7645a7627c8d64ed89200e16e364f4f687dfe2107a3b3d6f2b2d994ce8cac5620f2f2d9418bdcd874cc84d07dddc5
7
- data.tar.gz: b8b0e3a0254a29397d2ac263e16ea51ed524d886e2a9c69107d9cfa3c3ae4b6ba5cbbbfd8d4a14d73d74c5525f03102cd4aacbcee93ce5a9263288b05fd4e089
6
+ metadata.gz: 3622fadf7c7918121f2c3e6700d1bb4284d9a258d76f56a2b47d20813f7f837ad357ff45892e252da96b6c52694909241809f203b97872b5bdb3dbe5a2b33023
7
+ data.tar.gz: e3205c5033d63601f1a64e175e7e1799ab9c698e736d216e15408c4eb358851ba8bf415139bf66780dbc5bd559fde6468ca06cf81a29df056d50c6ba52f9b6b9
data/CHANGELOG.md CHANGED
@@ -1,5 +1,78 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.0.3 (2026-08-20)
4
+
5
+ ### Summary
6
+
7
+ Parser fix: a move may now be followed by any number of comments, NAGs,
8
+ and variations, in any order (PGN spec §3.2.5). Previously the grammar
9
+ accepted only a single trailing comment before a variation, so common
10
+ real-world annotations like `1... Kxh7 {a} {[%cal ...]} (20... Kf8)` raised
11
+ `parse error on value "("` and whole files were skipped. No public API
12
+ changes; `MoveText#comment` and `#variations` keep their existing shapes.
13
+
14
+ ### Changed
15
+
16
+ - **`PGN::PgnParser` (grammar)** — replace the single-comment
17
+ `move_trailer` + separate `variation_list` with a `move_suffix` list of
18
+ `[:comment, c] | [:nags, a] | [:variation, v]` items folded onto the
19
+ move by the new `build_move` helper. Multiple comments on one move are
20
+ concatenated with a space (each cleaned individually); variations keep
21
+ the legacy reversed storage order so round-trip and serializer behavior
22
+ are unchanged.
23
+ - **`PGN::MoveText.clean_text`** — extracted as a class method so the
24
+ parser can clean each trailing comment before merging; the instance
25
+ method delegates to it (behavior unchanged).
26
+
27
+ ### Fixed
28
+
29
+ - Two (or more) comments before a variation no longer abort parsing.
30
+ - A comment appearing after a variation now attaches to the move it
31
+ follows instead of becoming a stray game comment.
32
+ - `PGN.parse` no longer mutates the caller's input string (it dups before
33
+ `force_encoding`), so frozen-string-literal callers no longer raise
34
+ FrozenError on the target Ruby.
35
+
36
+ ### Known issues
37
+
38
+ - A lone backslash inside a comment that is not part of `\\`, `\{`, or
39
+ `\}` (e.g. `{/\Ba3#}`) still raises an "Unmatched input" lexer error;
40
+ the comment escape grammar remains stricter than the PGN spec.
41
+
42
+ ---
43
+
44
+ ## 2.0.2 (2026-08-18)
45
+
46
+ ### Summary
47
+
48
+ Internal refactor and performance pass — no public API changes. The
49
+ `pgn2-bitboard` adapter drops its hand-rolled `Move`/`MoveList`/`uci_parse`
50
+ layer in favor of `chessie`'s own `MoveList` and `Move: PartialEq<str>`
51
+ (UCI comparison), and the Ruby side DRYs shared FEN/EPD parsing, memoizes
52
+ `Position#in_check?`, and maintains `Game`'s position cache incrementally.
53
+
54
+ ### Changed
55
+
56
+ - **`ext/pgn2_native`** — remove `pgn2-bitboard/src/moves.rs`;
57
+ `Board#legal_moves` returns `chessie::MoveList` directly and
58
+ `Engine#legal?` compares via `chessie::Move`'s `PartialEq<str>` (by
59
+ `to_uci()`), eliminating the separate UCI parser.
60
+ - **`PGN::PositionFields`** — shared FEN/EPD module for board-string and
61
+ castling/en-passant field parsing; `FEN` and `EPD` now include it instead
62
+ of duplicating the logic.
63
+ - **`PGN::Attack.attacked?`** — short-circuits pawn/knight/king checks before
64
+ the slider ray-walk (cheapest first), matching the previous result.
65
+ - **`PGN::Position#in_check?`** — memoizes its result on first call.
66
+ - **`PGN::Game`** — `push`/`pop` now grow/shrink the memoized `@positions`
67
+ list in step rather than invalidating it; `threefold?` reuses `#positions`.
68
+ - **`PGN::MoveText.normalize_castling`** — castling `0`→`O` normalization
69
+ extracted as a class method (was a private `Node` helper).
70
+ - **`PGN::Node`** — `promote`/`demote`/`promote_to_main`/`demote_to_last`/
71
+ `delete` share a `mutate_sibling` prologue/epilogue and a `splice_line!`
72
+ helper; behavior unchanged.
73
+
74
+ ---
75
+
3
76
  ## 2.0.1 (2026-08-15)
4
77
 
5
78
  ### Summary
@@ -1,5 +1,3 @@
1
- use crate::moves::{Move, MoveList};
2
-
3
1
  /// A chess position backed by `chessie::Game`. Holds no chess logic of
4
2
  /// its own; every operation delegates. `Copy` because `chessie::Game`
5
3
  /// is `Copy`; `Default` because the magnus `Engine` wraps it in a
@@ -20,13 +18,7 @@ impl Board {
20
18
  self.game.perft(depth as usize)
21
19
  }
22
20
 
23
- pub fn legal_moves(&self) -> MoveList {
24
- let moves: Vec<Move> = self
25
- .game
26
- .get_legal_moves()
27
- .into_iter()
28
- .map(Move::from_chessie)
29
- .collect();
30
- MoveList(moves)
21
+ pub fn legal_moves(&self) -> chessie::MoveList {
22
+ self.game.get_legal_moves()
31
23
  }
32
24
  }
@@ -5,8 +5,6 @@
5
5
  //! Ruby in the loop.
6
6
 
7
7
  pub mod board;
8
- pub mod moves;
9
8
  pub mod perft;
10
9
 
11
10
  pub use board::Board;
12
- pub use moves::{Move, MoveList};
@@ -32,10 +32,10 @@ impl Engine {
32
32
  }
33
33
 
34
34
  fn legal_p(&self, uci: String) -> bool {
35
- match pgn2_bitboard::moves::uci_parse(&uci) {
36
- Some(parsed) => self.0.borrow().legal_moves().iter().any(|m| m.same_target(parsed)),
37
- None => false,
38
- }
35
+ // `chessie::Move`'s `PartialEq<str>` compares by `to_uci()`, so this
36
+ // matches on castle/promotion notation the same way `legal_moves_ruby`
37
+ // renders it — no separate UCI parser needed.
38
+ self.0.borrow().legal_moves().iter().any(|m| m == &uci)
39
39
  }
40
40
  }
41
41
 
@@ -49,6 +49,6 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
49
49
  engine.define_method("initialize", method!(Engine::initialize, 1))?;
50
50
  engine.define_method("perft", method!(Engine::perft, 1))?;
51
51
  engine.define_method("legal_moves", method!(Engine::legal_moves_ruby, 0))?;
52
- engine.define_method("legal?", method!(Engine::legal_p, 1))?;;
52
+ engine.define_method("legal?", method!(Engine::legal_p, 1))?;
53
53
  Ok(())
54
54
  }
data/lib/pgn/attack.rb CHANGED
@@ -14,7 +14,7 @@ module PGN
14
14
 
15
15
  # The 0x88 index of the `color` king on +board+, or nil if absent.
16
16
  def self.king_idx(board, color)
17
- king = color == 'w' ? 'K' : 'k'
17
+ king = piece_letter('K', color)
18
18
  (0...128).each do |idx|
19
19
  next if idx.anybits?(0x88)
20
20
 
@@ -24,8 +24,13 @@ module PGN
24
24
  end
25
25
 
26
26
  # Whether +target+ (a 0x88 index) is attacked by any `color` piece.
27
+ # Checked (and short-circuited) piece type by piece type, cheapest first,
28
+ # so a hit skips the pricier ray-walk sliders scan entirely.
27
29
  def self.attacked?(board, target, color)
28
- attackers(board, target, color).any?
30
+ pawn_attackers(board, target, color).any? ||
31
+ knight_attackers(board, target, color).any? ||
32
+ king_attackers(board, target, color).any? ||
33
+ slider_attackers(board, target, color).any?
29
34
  end
30
35
 
31
36
  # The algebraic squares of every `color` piece on +board+ that attacks
@@ -40,9 +45,14 @@ module PGN
40
45
  class << self
41
46
  private
42
47
 
48
+ # 'w' -> the uppercase letter, 'b' -> the lowercase letter.
49
+ def piece_letter(letter, color)
50
+ color == 'w' ? letter : letter.downcase
51
+ end
52
+
43
53
  def pawn_attackers(board, target, color)
44
54
  offs = color == 'w' ? [-15, -17] : [15, 17]
45
- pawn = color == 'w' ? 'P' : 'p'
55
+ pawn = piece_letter('P', color)
46
56
  offs.each_with_object([]) do |off, a|
47
57
  i = target + off
48
58
  a << board.square_name(i) if i.nobits?(0x88) && board.at_index(i) == pawn
@@ -50,23 +60,23 @@ module PGN
50
60
  end
51
61
 
52
62
  def knight_attackers(board, target, color)
53
- knight = color == 'w' ? 'N' : 'n'
63
+ knight = piece_letter('N', color)
54
64
  Board::KNIGHT_ATTACKS[target].each_with_object([]) do |i, a|
55
65
  a << board.square_name(i) if board.at_index(i) == knight
56
66
  end
57
67
  end
58
68
 
59
69
  def king_attackers(board, target, color)
60
- king = color == 'w' ? 'K' : 'k'
70
+ king = piece_letter('K', color)
61
71
  Board::KING_ATTACKS[target].each_with_object([]) do |i, a|
62
72
  a << board.square_name(i) if board.at_index(i) == king
63
73
  end
64
74
  end
65
75
 
66
76
  def slider_attackers(board, target, color)
67
- bishop = color == 'w' ? 'B' : 'b'
68
- rook = color == 'w' ? 'R' : 'r'
69
- queen = color == 'w' ? 'Q' : 'q'
77
+ bishop = piece_letter('B', color)
78
+ rook = piece_letter('R', color)
79
+ queen = piece_letter('Q', color)
70
80
  squares = []
71
81
  ray_attackers(board, target, BISHOP_DIRS) do |piece, sq|
72
82
  squares << sq if piece == bishop || piece == queen
data/lib/pgn/epd.rb CHANGED
@@ -14,6 +14,8 @@ module PGN
14
14
  # PGN::FEN.start.to_epd #=> "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq -"
15
15
  #
16
16
  class EPD
17
+ include PositionFields
18
+
17
19
  attr_accessor :board, :active, :ops
18
20
  attr_reader :castling, :en_passant
19
21
 
@@ -31,40 +33,11 @@ module PGN
31
33
  self.ops = fields[4]
32
34
  end
33
35
 
34
- def castling=(val)
35
- @castling = val.nil? || val.empty? ? '-' : val
36
- end
37
-
38
- def en_passant=(val)
39
- @en_passant = val.nil? ? '-' : val
40
- end
41
-
42
- # @param board_fen [String] the FEN/EPD representation of the board
43
- #
44
- def board_string=(board_fen)
45
- squares = board_fen.gsub(/\d/) { |match| '_' * match.to_i }
46
- .split('/')
47
- .map(&:chars)
48
- .map { |row| row.map { |e| e == '_' ? nil : e } }
49
- .reverse
50
- .transpose
51
- self.board = PGN::Board.new(squares)
52
- end
53
-
54
- # @return [String] the EPD board-string portion
55
- #
56
- def board_string
57
- board.fen_board_string
58
- end
59
-
60
36
  # @return [PGN::Position] a {PGN::Position} for this EPD. Halfmove and
61
37
  # fullmove default to 0 and 1 (EPD does not carry them).
62
38
  #
63
39
  def to_position
64
- player = active == 'w' ? :white : :black
65
- castling_rights = castling.chars - ['-']
66
- ep = en_passant == '-' ? nil : en_passant
67
-
40
+ player, castling_rights, ep = position_fields
68
41
  PGN::Position.new(board, player, castling_rights, ep, 0, 1)
69
42
  end
70
43
 
data/lib/pgn/fen.rb CHANGED
@@ -1,4 +1,44 @@
1
1
  module PGN
2
+ # Shared FEN/EPD board-field parsing and serialization: the piece-placement
3
+ # field, and the '-'-normalized castling/en-passant fields. {PGN::FEN} and
4
+ # {PGN::EPD} both include this rather than each carrying their own copy.
5
+ module PositionFields
6
+ def castling=(val)
7
+ @castling = val.nil? || val.empty? ? '-' : val
8
+ end
9
+
10
+ def en_passant=(val)
11
+ @en_passant = val.nil? ? '-' : val
12
+ end
13
+
14
+ # @param board_fen [String] the FEN/EPD representation of the board
15
+ def board_string=(board_fen)
16
+ squares = board_fen.gsub(/\d/) { |match| '_' * match.to_i }
17
+ .split('/')
18
+ .map(&:chars)
19
+ .map { |row| row.map { |e| e == '_' ? nil : e } }
20
+ .reverse
21
+ .transpose
22
+ self.board = PGN::Board.new(squares)
23
+ end
24
+
25
+ # @return [String] the FEN/EPD board-string portion
26
+ def board_string
27
+ board.fen_board_string
28
+ end
29
+
30
+ private
31
+
32
+ # The player/castling-rights/en-passant args shared by FEN#to_position
33
+ # and EPD#to_position (which differ only in halfmove/fullmove).
34
+ def position_fields
35
+ player = active == 'w' ? :white : :black
36
+ castling_rights = castling.chars - ['-']
37
+ ep = en_passant == '-' ? nil : en_passant
38
+ [player, castling_rights, ep]
39
+ end
40
+ end
41
+
2
42
  # {PGN::FEN} is responsible for translating between strings in FEN
3
43
  # notation and an internal representation of the board.
4
44
  #
@@ -35,14 +75,17 @@ module PGN
35
75
  # plays.
36
76
  #
37
77
  class FEN
78
+ include PositionFields
79
+
38
80
  # The FEN string representing the starting position in chess
39
81
  #
40
82
  INITIAL = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'.freeze
41
83
 
42
84
  attr_accessor :board, :active, :halfmove, :fullmove
43
- # `castling` and `en_passant` have custom writers below (normalize nil/empty
44
- # to '-'), so expose readers here and define the writers explicitly to avoid
45
- # redefining the accessors generated by attr_accessor (Lint/DuplicateMethods).
85
+ # `castling` and `en_passant` have custom writers in {PositionFields}
86
+ # (normalize nil/empty to '-'), so expose readers here and rely on the
87
+ # module for the writers, to avoid redefining the accessors generated by
88
+ # attr_accessor (Lint/DuplicateMethods).
46
89
  attr_reader :castling, :en_passant
47
90
 
48
91
  # @return [PGN::FEN] a {PGN::FEN} object representing the starting
@@ -75,52 +118,12 @@ module PGN
75
118
  self.fullmove = fen_string.split
76
119
  end
77
120
 
78
- def en_passant=(val)
79
- @en_passant = val.nil? ? '-' : val
80
- end
81
-
82
- def castling=(val)
83
- @castling = val.nil? || val.empty? ? '-' : val
84
- end
85
-
86
- # @param board_fen [String] the fen representation of the board
87
- # @example
88
- # fen.board_string = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
89
- #
90
- def board_string=(board_fen)
91
- squares = board_fen.gsub(/\d/) { |match| '_' * match.to_i }
92
- .split('/')
93
- .map(&:chars)
94
- .map { |row| row.map { |e| e == '_' ? nil : e } }
95
- .reverse
96
- .transpose
97
- self.board = PGN::Board.new(squares)
98
- end
99
-
100
- # @return [String] the fen representation of the board
101
- # @example
102
- # PGN::FEN.start.board_string #=> "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
103
- #
104
- def board_string
105
- board.fen_board_string
106
- end
107
-
108
121
  # @return [PGN::Position] a {PGN::Position} representing the current
109
122
  # position
110
123
  #
111
124
  def to_position
112
- player = active == 'w' ? :white : :black
113
- castling = self.castling.chars - ['-']
114
- en_passant = self.en_passant == '-' ? nil : self.en_passant
115
-
116
- PGN::Position.new(
117
- board,
118
- player,
119
- castling,
120
- en_passant,
121
- halfmove.to_i,
122
- fullmove.to_i
123
- )
125
+ player, castling_rights, ep = position_fields
126
+ PGN::Position.new(board, player, castling_rights, ep, halfmove.to_i, fullmove.to_i)
124
127
  end
125
128
 
126
129
  # @return [String] the EPD string for this position (the first four FEN
data/lib/pgn/game.rb CHANGED
@@ -9,7 +9,7 @@ module PGN
9
9
  def initialize(notation, annotation = nil, comment = nil, variations = [])
10
10
  @notation = notation
11
11
  @annotation = annotation
12
- @comment = clean_text(comment)
12
+ @comment = MoveText.clean_text(comment)
13
13
  @variations = variations
14
14
  end
15
15
 
@@ -29,16 +29,27 @@ module PGN
29
29
  @notation
30
30
  end
31
31
 
32
- def clean_text(text)
32
+ # Normalize UCI-style castling ('0-0') to canonical SAN ('O-O').
33
+ def self.normalize_castling(san)
34
+ san.include?('0') ? san.gsub('0', 'O') : san
35
+ end
36
+
37
+ # Strip the single outermost brace pair, then unescape \{ \} \\ so
38
+ # the internal representation is the raw comment body. {Serializer}
39
+ # re-escapes on output, so parse -> serialize -> parse is byte-stable.
40
+ # Exposed as a class method so the parser can clean each trailing comment
41
+ # individually before concatenating multiple comments on one move.
42
+ def self.clean_text(text)
33
43
  return unless text
34
44
 
35
- # Strip the single outermost brace pair, then unescape \{ \} \\ so
36
- # the internal representation is the raw comment body. {Serializer}
37
- # re-escapes on output, so parse -> serialize -> parse is byte-stable.
38
45
  text = text[1..-2] if text.start_with?('{') && text.end_with?('}')
39
46
  text = text.gsub(/\\([\\{}])/, '\1')
40
47
  text.gsub(/\s+/, ' ').strip
41
48
  end
49
+
50
+ def clean_text(text)
51
+ MoveText.clean_text(text)
52
+ end
42
53
  end
43
54
 
44
55
  # {PGN::Game} holds all of the information about a game. It is either
@@ -150,7 +161,7 @@ module PGN
150
161
 
151
162
  # Append a move in SAN, validating legality when the native engine is
152
163
  # available. Raises ArgumentError for an illegal move (when the engine is
153
- # loaded) and invalidates the memoized position list.
164
+ # loaded). Grows the memoized position list in step, if it is populated.
154
165
  #
155
166
  # @param san [String, PGN::MoveText] the move to append
156
167
  # @return [self]
@@ -161,31 +172,31 @@ module PGN
161
172
  end
162
173
 
163
174
  @moves << move
164
- @positions = nil
175
+ @positions << @positions.last.move(move.notation) if @positions
165
176
  self
166
177
  end
167
178
 
168
- # Remove and return the last move, or nil if there are none. Invalidates
169
- # the memoized position list.
179
+ # Remove and return the last move, or nil if there are none. Shrinks the
180
+ # memoized position list in step, if it is populated.
170
181
  #
171
182
  # @return [PGN::MoveText, nil]
172
183
  def pop
173
184
  return nil if @moves.empty?
174
185
 
175
186
  move = @moves.pop
176
- @positions = nil
187
+ @positions&.pop
177
188
  move
178
189
  end
179
190
 
180
191
  # Whether any position has occurred three times in this game (the
181
192
  # threefold-repetition draw). Uses {PGN::Position#hash} (the Zobrist
182
- # hash of the FEN-relevant state), streaming {#each_position} so the
183
- # full position array need not be materialized for this check.
193
+ # hash of the FEN-relevant state) over {#positions}, so a prior or
194
+ # subsequent call that also needs the position list shares the replay.
184
195
  #
185
196
  # @return [Boolean]
186
197
  def threefold?
187
198
  counts = Hash.new(0)
188
- each_position { |position| counts[position.hash] += 1 }
199
+ positions.each { |position| counts[position.hash] += 1 }
189
200
  counts.any? { |_, count| count >= 3 }
190
201
  end
191
202
 
@@ -245,9 +256,9 @@ module PGN
245
256
  # (it only strips a *single* outermost brace pair), so reusing or rebuilding
246
257
  # a MoveText never corrupts a comment that still contains inner braces.
247
258
  def standardize_castling(entry)
248
- return MoveText.new(entry.include?('0') ? entry.gsub('0', 'O') : entry) if entry.is_a?(String)
259
+ return MoveText.new(MoveText.normalize_castling(entry)) if entry.is_a?(String)
249
260
 
250
- notation = entry.notation.include?('0') ? entry.notation.gsub('0', 'O') : entry.notation
261
+ notation = MoveText.normalize_castling(entry.notation)
251
262
  return entry if notation.equal?(entry.notation)
252
263
 
253
264
  MoveText.new(notation, entry.annotation, entry.comment, entry.variations)