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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +50 -0
- data/.github/workflows/publish.yml +75 -0
- data/.rubocop.yml +38 -0
- data/CHANGELOG.md +52 -0
- data/README.md +104 -4
- data/Rakefile +20 -0
- data/bench/.keep +0 -0
- data/bench/IMPROVEMENTS.md +75 -0
- data/bench/baseline_moves.pre-optimization.txt +22 -0
- data/bench/baseline_moves.txt +22 -0
- data/bench/baseline_parse.pre-optimization.txt +25 -0
- data/bench/baseline_parse.racc.txt +25 -0
- data/bench/baseline_parse.txt +25 -0
- data/bench/profile_moves.rb +53 -0
- data/bench/profile_parse.rb +44 -0
- data/docs/superpowers/plans/2026-08-12-efficiency-optimizations.md +573 -0
- data/docs/superpowers/plans/2026-08-12-efficiency-tests-and-profiling.md +1091 -0
- data/docs/superpowers/plans/2026-08-12-to-pgn-serialization.md +162 -0
- data/docs/superpowers/plans/2026-08-13-whittle-to-racc-migration.md +130 -0
- data/docs/superpowers/specs/2026-08-12-to-pgn-serialization-design.md +217 -0
- data/lib/pgn/board.rb +33 -15
- data/lib/pgn/fen.rb +16 -8
- data/lib/pgn/game.rb +11 -2
- data/lib/pgn/lexer.rb +201 -0
- data/lib/pgn/move.rb +7 -3
- data/lib/pgn/move_calculator.rb +18 -17
- data/lib/pgn/parser.rb +19 -199
- data/lib/pgn/pgn_parser.rb +392 -0
- data/lib/pgn/pgn_parser.y +142 -0
- data/lib/pgn/serializer.rb +141 -0
- data/lib/pgn/version.rb +1 -1
- data/lib/pgn.rb +3 -0
- data/pgn2.gemspec +12 -2
- data/spec/board_spec.rb +111 -0
- data/spec/fen_spec.rb +25 -0
- data/spec/game_spec.rb +74 -0
- data/spec/lexer_spec.rb +153 -0
- data/spec/move_calculator_spec.rb +226 -0
- data/spec/move_spec.rb +136 -0
- data/spec/parser_explicit_spec.rb +210 -0
- data/spec/parser_spec.rb +15 -0
- data/spec/pgn_files/doublequotes.pgn +21 -0
- data/spec/pgn_files/specialcharacters.pgn +79 -0
- data/spec/position_spec.rb +73 -0
- data/spec/serializer_spec.rb +89 -0
- data/spec/spec_helper.rb +0 -1
- 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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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)
|
data/lib/pgn/move_calculator.rb
CHANGED
|
@@ -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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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(
|
|
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
|
-
|
|
323
|
+
return [file, rank] if board.at(file, rank) == king
|
|
323
324
|
end
|
|
324
325
|
end
|
|
325
326
|
|
|
326
|
-
|
|
327
|
+
nil
|
|
327
328
|
end
|
|
328
329
|
|
|
329
330
|
def valid_square?(file, rank)
|
|
330
|
-
(0..7)
|
|
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 '
|
|
1
|
+
require 'pgn/pgn_parser'
|
|
2
2
|
|
|
3
3
|
module PGN
|
|
4
|
-
# {PGN::Parser}
|
|
5
|
-
#
|
|
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
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|