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.
@@ -176,12 +176,51 @@ RSpec.describe PGN, '.parse (racc parser)' do
176
176
  end
177
177
  end
178
178
 
179
+ describe 'move suffix (comments, NAGs, variations in any order)' do
180
+ it 'attaches two comments before a variation to the move' do
181
+ game = PGN.parse('[White "A"] 1. Bxh7+ Kxh7 {a} {b} (20... Kf8) 2. Ng5+ *').first
182
+ move = game.moves[1]
183
+ expect(move.notation).to eq('Kxh7')
184
+ expect(move.comment).to eq('a b')
185
+ expect(variations_as(move)).to eq([%w[Kf8]])
186
+ end
187
+
188
+ it 'attaches a comment after a variation to the same move' do
189
+ game = PGN.parse('[White "A"] 1. e4 (1... c5) {a} (2... f5) 2. Nf3 *').first
190
+ move = game.moves.first
191
+ expect(move.comment).to eq('a')
192
+ expect(variations_as(move).sort).to eq([%w[c5], %w[f5]].sort)
193
+ end
194
+
195
+ it 'merges an arbitrary mix of comments, NAGs, and variations' do
196
+ game = PGN.parse('[White "A"] 1. e4 {c1} $1 (1... d5) {c2} $2 *').first
197
+ move = game.moves.first
198
+ expect(move.comment).to eq('c1 c2')
199
+ expect(move.annotation).to eq(%w[$1 $2])
200
+ expect(variations_as(move)).to eq([%w[d5]])
201
+ end
202
+
203
+ it 'round-trips a multi-comment move through parse -> serialize -> parse' do
204
+ input = +"[White \"A\"]\n\n1. e4 {a} {b} (1... d5) *\n"
205
+ once = PGN.parse(input, Encoding::UTF_8).first.to_pgn
206
+ twice = PGN.parse(once, Encoding::UTF_8).first.to_pgn
207
+ expect(twice).to eq(once)
208
+ end
209
+ end
210
+
179
211
  describe 'pgn (verbatim raw text)' do
180
212
  it 'returns the verbatim text for a single game' do
181
213
  input = "[White \"A\"] 1. e4 1-0\n"
182
214
  expect(PGN.parse(input).first.pgn).to eq(input)
183
215
  end
184
216
 
217
+ it 'does not mutate a frozen input string' do
218
+ input = "[White \"A\"] 1. e4 1-0\n".freeze
219
+ expect { PGN.parse(input, Encoding::UTF_8) }.not_to raise_error
220
+ expect(input.encoding).to eq(Encoding::UTF_8)
221
+ expect(PGN.parse(input, Encoding::UTF_8).first.pgn).to eq(input)
222
+ end
223
+
185
224
  it 'partitions consecutive games contiguously' do
186
225
  input = "[White \"A\"] 1. e4 1-0\n\n[White \"B\"] 1. d4 0-1\n"
187
226
  games = PGN.parse(input)
data/spec/spec_helper.rb CHANGED
@@ -1,5 +1,22 @@
1
1
  require 'pgn'
2
2
 
3
+ # Helpers for order-independent variation comparison. The parser's
4
+ # `variation_list` rule is right-recursive, so a move's variations are stored
5
+ # in reverse of their PGN-text order; round-tripping flips that order each
6
+ # parse. We therefore compare variation trees as sorted signatures, and
7
+ # reference variations by notation rather than by index.
8
+ def node_sig(move)
9
+ [move.notation, (move.variations || []).map { line_sig(_1) }.sort]
10
+ end
11
+
12
+ def line_sig(line)
13
+ line.map { node_sig(_1) }
14
+ end
15
+
16
+ def tree_sig(game)
17
+ game.moves.map { node_sig(_1) }
18
+ end
19
+
3
20
  # This file was generated by the `rspec --init` command. Conventionally, all
4
21
  # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
5
22
  # Require this file using `require "spec_helper"` to ensure that it is only
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pgn2
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.1
4
+ version: 2.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stacey Touset
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2026-08-15 00:00:00.000000000 Z
12
+ date: 2026-08-20 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rb_sys
@@ -242,7 +242,6 @@ files:
242
242
  - ext/pgn2_native/pgn2-bitboard/Cargo.toml
243
243
  - ext/pgn2_native/pgn2-bitboard/src/board.rs
244
244
  - ext/pgn2_native/pgn2-bitboard/src/lib.rs
245
- - ext/pgn2_native/pgn2-bitboard/src/moves.rs
246
245
  - ext/pgn2_native/pgn2-bitboard/src/perft.rs
247
246
  - ext/pgn2_native/pgn2_native/Cargo.toml
248
247
  - ext/pgn2_native/pgn2_native/src/lib.rs
@@ -1,121 +0,0 @@
1
- /// Adapter move carrying exactly what `to_uci` and `same_target` need:
2
- /// from-square, to-square, and optional promotion kind. No flag bits,
3
- /// so a position-free `uci_parse` can produce a comparable token — this
4
- /// preserves the existing `same_target` semantics (match on
5
- /// from + to + promo only), so `legal?("e1g1")` finds the castle and
6
- /// `legal?("e7e8q")` finds that exact promotion.
7
- #[derive(Clone, Copy, PartialEq, Eq)]
8
- pub struct Move {
9
- from: u8, // 0..=63, index = rank * 8 + file
10
- to: u8,
11
- promo: Option<Promo>,
12
- }
13
-
14
- #[derive(Clone, Copy, PartialEq, Eq)]
15
- enum Promo {
16
- Knight,
17
- Bishop,
18
- Rook,
19
- Queen,
20
- }
21
-
22
- impl Move {
23
- /// Build an adapter `Move` from a `chessie::Move` (a legal move).
24
- ///
25
- /// chessie encodes castling internally as "King takes Rook"
26
- /// (`to` = the rook's square, Chess960 style). `into_standard_castle`
27
- /// rewrites the `to` square to the king's destination (`e1g1`/`e1c1`),
28
- /// matching the standard-UCI output the previous engine produced and
29
- /// that `uci_parse` / `bitboard_spec.rb` expect. No-op for non-castle
30
- /// moves.
31
- pub(crate) fn from_chessie(m: chessie::Move) -> Self {
32
- let m = m.into_standard_castle();
33
- Move {
34
- from: m.from().index() as u8,
35
- to: m.to().index() as u8,
36
- promo: m.promotion().map(|kind| match kind {
37
- chessie::PieceKind::Knight => Promo::Knight,
38
- chessie::PieceKind::Bishop => Promo::Bishop,
39
- chessie::PieceKind::Rook => Promo::Rook,
40
- chessie::PieceKind::Queen => Promo::Queen,
41
- // Pawns/Kings never appear as a promotion kind.
42
- _ => unreachable!("non-promotion PieceKind in Move::promotion"),
43
- }),
44
- }
45
- }
46
-
47
- /// UCI string: `"e2e4"`, `"e1g1"` (castle, king from→to),
48
- /// `"e7e8q"` (promotion). Equal to `chessie::Move::to_uci` for
49
- /// every legal move (castle and en-passant both reduce to from+to
50
- /// in UCI), so the sorted-UCI output is unchanged.
51
- pub fn to_uci(self) -> String {
52
- let mut s = String::with_capacity(5);
53
- s.push_str(&sq_name(self.from));
54
- s.push_str(&sq_name(self.to));
55
- if let Some(p) = self.promo {
56
- s.push(match p {
57
- Promo::Knight => 'n',
58
- Promo::Bishop => 'b',
59
- Promo::Rook => 'r',
60
- Promo::Queen => 'q',
61
- });
62
- }
63
- s
64
- }
65
-
66
- /// Match on from + to + promo (the pre-existing semantics).
67
- pub fn same_target(self, other: Move) -> bool {
68
- self.from == other.from && self.to == other.to && self.promo == other.promo
69
- }
70
- }
71
-
72
- /// Thin wrapper around `Vec<Move>`; the binding calls `.iter()` on it.
73
- pub struct MoveList(pub Vec<Move>);
74
-
75
- impl MoveList {
76
- pub fn iter(&self) -> std::slice::Iter<'_, Move> {
77
- self.0.iter()
78
- }
79
- }
80
-
81
- /// Parse a UCI string into a `Move` token **without** a position.
82
- /// Only `to_uci`/`same_target` consume the result, so from + to + promo
83
- /// is all that is needed. Returns `None` on any malformed input.
84
- pub fn uci_parse(s: &str) -> Option<Move> {
85
- let b = s.as_bytes();
86
- if b.len() < 4 {
87
- return None;
88
- }
89
- let from = parse_sq(&b[0..2])?;
90
- let to = parse_sq(&b[2..4])?;
91
- let promo = if b.len() >= 5 {
92
- Some(match b[4] {
93
- b'n' => Promo::Knight,
94
- b'b' => Promo::Bishop,
95
- b'r' => Promo::Rook,
96
- b'q' => Promo::Queen,
97
- _ => return None,
98
- })
99
- } else {
100
- None
101
- };
102
- Some(Move { from, to, promo })
103
- }
104
-
105
- fn parse_sq(t: &[u8]) -> Option<u8> {
106
- let f = t[0].checked_sub(b'a')?;
107
- let r = t[1].checked_sub(b'1')?;
108
- if f > 7 || r > 7 {
109
- return None;
110
- }
111
- Some(r * 8 + f)
112
- }
113
-
114
- fn sq_name(idx: u8) -> String {
115
- let file = (b'a' + (idx & 7)) as char;
116
- let rank = (b'1' + (idx >> 3)) as char;
117
- let mut s = String::with_capacity(2);
118
- s.push(file);
119
- s.push(rank);
120
- s
121
- }