sashite-feen 0.1.0 → 0.3.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/README.md +412 -137
- data/lib/sashite/feen/dumper/piece_placement.rb +144 -64
- data/lib/sashite/feen/dumper/pieces_in_hand.rb +124 -34
- data/lib/sashite/feen/dumper/style_turn.rb +29 -45
- data/lib/sashite/feen/dumper.rb +40 -30
- data/lib/sashite/feen/error.rb +72 -29
- data/lib/sashite/feen/hands.rb +62 -20
- data/lib/sashite/feen/parser/piece_placement.rb +324 -118
- data/lib/sashite/feen/parser/pieces_in_hand.rb +210 -44
- data/lib/sashite/feen/parser/style_turn.rb +81 -41
- data/lib/sashite/feen/parser.rb +60 -15
- data/lib/sashite/feen/placement.rb +295 -19
- data/lib/sashite/feen/position.rb +64 -13
- data/lib/sashite/feen/styles.rb +54 -57
- data/lib/sashite/feen.rb +57 -96
- metadata +1 -2
- data/lib/sashite/feen/ordering.rb +0 -16
|
@@ -1,152 +1,358 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../error"
|
|
4
|
+
require_relative "../placement"
|
|
5
|
+
|
|
6
|
+
require "sashite/epin"
|
|
7
|
+
|
|
3
8
|
module Sashite
|
|
4
9
|
module Feen
|
|
5
10
|
module Parser
|
|
11
|
+
# Parser for the piece placement field (first field of FEEN).
|
|
12
|
+
#
|
|
13
|
+
# Converts a FEEN piece placement string into a Placement object,
|
|
14
|
+
# decoding board configuration from EPIN notation with:
|
|
15
|
+
# - Empty square compression (numbers → consecutive nils)
|
|
16
|
+
# - Multi-dimensional separator preservation (exact "/" counts)
|
|
17
|
+
# - Support for any irregular board structure
|
|
18
|
+
#
|
|
19
|
+
# The parser preserves the exact separator structure, enabling
|
|
20
|
+
# perfect round-trip conversion (parse → dump → parse).
|
|
21
|
+
#
|
|
22
|
+
# @see https://sashite.dev/specs/feen/1.0.0/
|
|
6
23
|
module PiecePlacement
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
#
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
# @
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
24
|
+
# Rank separator character.
|
|
25
|
+
RANK_SEPARATOR = "/"
|
|
26
|
+
|
|
27
|
+
# Pattern to match EPIN pieces (optional state, letter, optional derivation).
|
|
28
|
+
EPIN_PATTERN = /\A[-+]?[A-Za-z]'?\z/
|
|
29
|
+
|
|
30
|
+
# Parse a FEEN piece placement string into a Placement object.
|
|
31
|
+
#
|
|
32
|
+
# Supports any valid FEEN structure:
|
|
33
|
+
# - 1D: Single rank, no separators (e.g., "K2P")
|
|
34
|
+
# - 2D: Ranks separated by "/" (e.g., "8/8/8")
|
|
35
|
+
# - 3D+: Ranks separated by multiple "/" (e.g., "5/5//5/5")
|
|
36
|
+
# - Irregular: Any combination of rank sizes and separators
|
|
37
|
+
#
|
|
38
|
+
# @param string [String] FEEN piece placement field string
|
|
39
|
+
# @return [Placement] Parsed placement object
|
|
40
|
+
# @raise [Error::Syntax] If placement format is invalid
|
|
41
|
+
# @raise [Error::Piece] If EPIN notation is invalid
|
|
42
|
+
#
|
|
43
|
+
# @example Chess starting position
|
|
44
|
+
# parse("+rnbq+kbn+r/+p+p+p+p+p+p+p+p/8/8/8/8/+P+P+P+P+P+P+P+P/+RNBQ+KBN+R")
|
|
45
|
+
#
|
|
46
|
+
# @example Empty 8x8 board
|
|
47
|
+
# parse("8/8/8/8/8/8/8/8")
|
|
48
|
+
#
|
|
49
|
+
# @example 1D board
|
|
50
|
+
# parse("K2P3k")
|
|
51
|
+
#
|
|
52
|
+
# @example Irregular structure
|
|
53
|
+
# parse("99999/3///K/k//r")
|
|
54
|
+
def self.parse(string)
|
|
55
|
+
# Detect dimension before parsing
|
|
56
|
+
dimension = detect_dimension(string)
|
|
57
|
+
|
|
58
|
+
# Handle 1D case (no separators)
|
|
59
|
+
if dimension == 1
|
|
60
|
+
rank = parse_rank(string)
|
|
61
|
+
return Placement.new([rank], [], 1)
|
|
42
62
|
end
|
|
43
63
|
|
|
44
|
-
|
|
64
|
+
# Parse multi-dimensional structure with separators
|
|
65
|
+
ranks, separators = parse_with_separators(string)
|
|
45
66
|
|
|
46
|
-
Placement.new(
|
|
67
|
+
Placement.new(ranks, separators, dimension)
|
|
47
68
|
end
|
|
48
69
|
|
|
49
|
-
#
|
|
70
|
+
# Detect board dimensionality from separator patterns.
|
|
71
|
+
#
|
|
72
|
+
# Scans for consecutive "/" characters and returns:
|
|
73
|
+
# 1 + (maximum consecutive "/" count)
|
|
74
|
+
#
|
|
75
|
+
# @param string [String] Piece placement string
|
|
76
|
+
# @return [Integer] Board dimension (minimum 1)
|
|
77
|
+
#
|
|
78
|
+
# @example 1D board (no separators)
|
|
79
|
+
# detect_dimension("K2P") # => 1
|
|
80
|
+
#
|
|
81
|
+
# @example 2D board (single "/")
|
|
82
|
+
# detect_dimension("8/8") # => 2
|
|
83
|
+
#
|
|
84
|
+
# @example 3D board (contains "//")
|
|
85
|
+
# detect_dimension("5/5/5//5/5/5") # => 3
|
|
86
|
+
#
|
|
87
|
+
# @example 4D board (contains "///")
|
|
88
|
+
# detect_dimension("2/2///2/2") # => 4
|
|
89
|
+
private_class_method def self.detect_dimension(string)
|
|
90
|
+
return 1 unless string.include?(RANK_SEPARATOR)
|
|
50
91
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
# - '['...']' => EPIN token (balanced)
|
|
55
|
-
# - bare token composed of epin-safe chars
|
|
56
|
-
# - commas/whitespace ignored
|
|
57
|
-
def _parse_rank(rank_str, r_idx)
|
|
58
|
-
i = 0
|
|
59
|
-
n = rank_str.length
|
|
60
|
-
cells = []
|
|
92
|
+
max_consecutive = string.scan(%r{/+}).map(&:length).max || 0
|
|
93
|
+
max_consecutive + 1
|
|
94
|
+
end
|
|
61
95
|
|
|
62
|
-
|
|
63
|
-
|
|
96
|
+
# Parse string while preserving exact separators.
|
|
97
|
+
#
|
|
98
|
+
# Uses split with capture group to preserve both ranks and separators.
|
|
99
|
+
# The regex /(\/+)/ captures one or more consecutive "/" characters.
|
|
100
|
+
#
|
|
101
|
+
# Result pattern: [rank, separator, rank, separator, ..., rank]
|
|
102
|
+
# - Even indices (0, 2, 4, ...) are ranks
|
|
103
|
+
# - Odd indices (1, 3, 5, ...) are separators
|
|
104
|
+
#
|
|
105
|
+
# Empty strings are parsed as empty ranks (valid in FEEN).
|
|
106
|
+
#
|
|
107
|
+
# @param string [String] Piece placement string
|
|
108
|
+
# @return [Array<Array<Array>, Array<String>>] [ranks, separators]
|
|
109
|
+
#
|
|
110
|
+
# @example Simple split
|
|
111
|
+
# parse_with_separators("K/Q/R")
|
|
112
|
+
# # => [[[K], [Q], [R]], ["/", "/"]]
|
|
113
|
+
#
|
|
114
|
+
# @example Multi-dimensional split
|
|
115
|
+
# parse_with_separators("K//Q/R")
|
|
116
|
+
# # => [[[K], [Q], [R]], ["//", "/"]]
|
|
117
|
+
#
|
|
118
|
+
# @example Trailing separator (empty rank at end)
|
|
119
|
+
# parse_with_separators("K///")
|
|
120
|
+
# # => [[[K], []], ["///"]]
|
|
121
|
+
#
|
|
122
|
+
# @example Leading separator (empty rank at start)
|
|
123
|
+
# parse_with_separators("///K")
|
|
124
|
+
# # => [[[], [K]], ["///"]]
|
|
125
|
+
private_class_method def self.parse_with_separators(string)
|
|
126
|
+
ranks = []
|
|
127
|
+
separators = []
|
|
64
128
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
next
|
|
69
|
-
end
|
|
129
|
+
# Split with capture group to preserve separators
|
|
130
|
+
# Use limit=-1 to include trailing empty substrings
|
|
131
|
+
parts = string.split(%r{(/+)}, -1)
|
|
70
132
|
|
|
71
|
-
|
|
72
|
-
if
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
133
|
+
parts.each_with_index do |part, idx|
|
|
134
|
+
if idx.even?
|
|
135
|
+
# Even index = rank content (can be empty string)
|
|
136
|
+
ranks << parse_rank(part)
|
|
137
|
+
else
|
|
138
|
+
# Odd index = separator
|
|
139
|
+
separators << part
|
|
76
140
|
end
|
|
141
|
+
end
|
|
77
142
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
j = i + 1
|
|
81
|
-
j += 1 while j < n && rank_str[j] =~ /\d/
|
|
82
|
-
count = rank_str[i...j].to_i
|
|
83
|
-
raise Error::Count, "empty run must be >= 1 at rank #{r_idx + 1}" if count <= 0
|
|
143
|
+
[ranks, separators]
|
|
144
|
+
end
|
|
84
145
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
146
|
+
# Parse a single rank string into an array of pieces and nils.
|
|
147
|
+
#
|
|
148
|
+
# Processes rank content character by character:
|
|
149
|
+
# - Empty string → empty rank (empty array)
|
|
150
|
+
# - Digits (1-9) start a number → count of empty squares (nils)
|
|
151
|
+
# - Letters (A-Z, a-z) start EPIN → piece object
|
|
152
|
+
# - Numbers are parsed greedily (123 = one hundred twenty-three)
|
|
153
|
+
#
|
|
154
|
+
# @param rank_str [String] Single rank string (e.g., "rnbqkbnr" or "4p3" or "")
|
|
155
|
+
# @return [Array] Array containing piece objects and nils (empty if rank_str is empty)
|
|
156
|
+
# @raise [Error::Syntax] If rank format is invalid
|
|
157
|
+
# @raise [Error::Piece] If EPIN notation is invalid
|
|
158
|
+
#
|
|
159
|
+
# @example Empty rank
|
|
160
|
+
# parse_rank("")
|
|
161
|
+
# # => []
|
|
162
|
+
#
|
|
163
|
+
# @example Rank with only pieces
|
|
164
|
+
# parse_rank("rnbqkbnr")
|
|
165
|
+
# # => [r, n, b, q, k, b, n, r]
|
|
166
|
+
#
|
|
167
|
+
# @example Rank with empty squares
|
|
168
|
+
# parse_rank("4p3")
|
|
169
|
+
# # => [nil, nil, nil, nil, p, nil, nil, nil]
|
|
170
|
+
#
|
|
171
|
+
# @example Rank with large empty count
|
|
172
|
+
# parse_rank("100")
|
|
173
|
+
# # => [nil, nil, ..., nil] (100 nils)
|
|
174
|
+
#
|
|
175
|
+
# @example Mixed rank
|
|
176
|
+
# parse_rank("+K2+Q")
|
|
177
|
+
# # => [+K, nil, nil, +Q]
|
|
178
|
+
private_class_method def self.parse_rank(rank_str)
|
|
179
|
+
# Handle empty rank (valid in FEEN)
|
|
180
|
+
return [] if rank_str.empty?
|
|
89
181
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
182
|
+
result = []
|
|
183
|
+
chars = rank_str.chars
|
|
184
|
+
i = 0
|
|
185
|
+
|
|
186
|
+
while i < chars.size
|
|
187
|
+
char = chars[i]
|
|
188
|
+
|
|
189
|
+
if digit?(char)
|
|
190
|
+
# Parse complete number (greedy)
|
|
191
|
+
num_str, consumed = extract_number(chars, i)
|
|
192
|
+
count = num_str.to_i
|
|
97
193
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
194
|
+
validate_empty_count!(count, num_str)
|
|
195
|
+
|
|
196
|
+
# Add empty squares
|
|
197
|
+
count.times { result << nil }
|
|
198
|
+
i += consumed
|
|
199
|
+
else
|
|
200
|
+
# Parse EPIN piece notation
|
|
201
|
+
piece_str, consumed = extract_epin(chars, i)
|
|
202
|
+
piece = parse_piece(piece_str)
|
|
203
|
+
result << piece
|
|
204
|
+
i += consumed
|
|
103
205
|
end
|
|
104
|
-
cells << _parse_epin(token, r_idx, cells.length + 1)
|
|
105
|
-
i = j
|
|
106
206
|
end
|
|
107
207
|
|
|
108
|
-
|
|
208
|
+
result
|
|
109
209
|
end
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
#
|
|
114
|
-
#
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
210
|
+
|
|
211
|
+
# Check if character is a digit (1-9 for starting digit).
|
|
212
|
+
#
|
|
213
|
+
# Note: Leading zero not allowed in FEEN numbers.
|
|
214
|
+
#
|
|
215
|
+
# @param char [String] Single character
|
|
216
|
+
# @return [Boolean] True if character is 1-9
|
|
217
|
+
private_class_method def self.digit?(char)
|
|
218
|
+
char >= "1" && char <= "9"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Extract a complete number from character array (greedy parsing).
|
|
222
|
+
#
|
|
223
|
+
# Reads all consecutive digits starting from start_index.
|
|
224
|
+
# First digit must be 1-9, subsequent digits can be 0-9.
|
|
225
|
+
#
|
|
226
|
+
# @param chars [Array<String>] Array of characters
|
|
227
|
+
# @param start_index [Integer] Starting index
|
|
228
|
+
# @return [Array(String, Integer)] Number string and characters consumed
|
|
229
|
+
#
|
|
230
|
+
# @example Single digit
|
|
231
|
+
# extract_number(['8', 'K'], 0) # => ["8", 1]
|
|
232
|
+
#
|
|
233
|
+
# @example Multi-digit
|
|
234
|
+
# extract_number(['1', '2', '3', 'K'], 0) # => ["123", 3]
|
|
235
|
+
#
|
|
236
|
+
# @example Large number
|
|
237
|
+
# extract_number(['9', '9', '9', '9', '9'], 0) # => ["99999", 5]
|
|
238
|
+
private_class_method def self.extract_number(chars, start_index)
|
|
239
|
+
num_str = chars[start_index]
|
|
240
|
+
i = start_index + 1
|
|
241
|
+
|
|
242
|
+
# Continue reading digits (including 0)
|
|
243
|
+
while i < chars.size && chars[i] >= "0" && chars[i] <= "9"
|
|
244
|
+
num_str += chars[i]
|
|
245
|
+
i += 1
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
consumed = i - start_index
|
|
249
|
+
[num_str, consumed]
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Validate empty square count.
|
|
253
|
+
#
|
|
254
|
+
# Count must be at least 1 (FEEN doesn't allow "0" for zero squares).
|
|
255
|
+
#
|
|
256
|
+
# @param count [Integer] Parsed count value
|
|
257
|
+
# @param count_str [String] Original string for error message
|
|
258
|
+
# @raise [Error::Syntax] If count is less than 1
|
|
259
|
+
private_class_method def self.validate_empty_count!(count, count_str)
|
|
260
|
+
return if count >= 1
|
|
261
|
+
|
|
262
|
+
raise ::Sashite::Feen::Error::Syntax,
|
|
263
|
+
"Empty square count must be at least 1, got #{count_str}"
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Extract EPIN notation from character array.
|
|
267
|
+
#
|
|
268
|
+
# EPIN format: [state][letter][derivation]
|
|
269
|
+
# - state: optional "+" or "-" prefix
|
|
270
|
+
# - letter: required A-Z or a-z
|
|
271
|
+
# - derivation: optional "'" suffix
|
|
272
|
+
#
|
|
273
|
+
# @param chars [Array<String>] Array of characters
|
|
274
|
+
# @param start_index [Integer] Starting index
|
|
275
|
+
# @return [Array(String, Integer)] EPIN string and characters consumed
|
|
276
|
+
# @raise [Error::Syntax] If EPIN format is incomplete or invalid
|
|
277
|
+
#
|
|
278
|
+
# @example Simple piece
|
|
279
|
+
# extract_epin(['K', 'Q'], 0) # => ["K", 1]
|
|
280
|
+
#
|
|
281
|
+
# @example Enhanced piece
|
|
282
|
+
# extract_epin(['+', 'R', 'B'], 0) # => ["+R", 2]
|
|
283
|
+
#
|
|
284
|
+
# @example Foreign piece
|
|
285
|
+
# extract_epin(['K', "'", 'Q'], 0) # => ["K'", 2]
|
|
286
|
+
#
|
|
287
|
+
# @example Complex piece
|
|
288
|
+
# extract_epin(['-', 'p', "'", 'K'], 0) # => ["-p'", 3]
|
|
289
|
+
private_class_method def self.extract_epin(chars, start_index)
|
|
290
|
+
i = start_index
|
|
291
|
+
piece_chars = []
|
|
292
|
+
|
|
293
|
+
# Optional state prefix (+ or -)
|
|
294
|
+
if i < chars.size && ["+", "-"].include?(chars[i])
|
|
295
|
+
piece_chars << chars[i]
|
|
296
|
+
i += 1
|
|
124
297
|
end
|
|
125
|
-
raise Error::Piece, "unterminated EPIN bracket at rank #{r_idx + 1}, index #{i}" unless depth.zero?
|
|
126
298
|
|
|
127
|
-
|
|
299
|
+
# Base letter (required)
|
|
300
|
+
if i >= chars.size || !letter?(chars[i])
|
|
301
|
+
raise ::Sashite::Feen::Error::Syntax,
|
|
302
|
+
"Expected letter in EPIN notation at position #{start_index}"
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
piece_chars << chars[i]
|
|
306
|
+
i += 1
|
|
307
|
+
|
|
308
|
+
# Optional derivation suffix (')
|
|
309
|
+
if i < chars.size && chars[i] == "'"
|
|
310
|
+
piece_chars << chars[i]
|
|
311
|
+
i += 1
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
piece_str = piece_chars.join
|
|
315
|
+
consumed = i - start_index
|
|
316
|
+
|
|
317
|
+
[piece_str, consumed]
|
|
128
318
|
end
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
#
|
|
132
|
-
#
|
|
133
|
-
#
|
|
134
|
-
def
|
|
135
|
-
|
|
136
|
-
# Autorisés : lettres + modificateurs (+ - : ^ ~ @ ')
|
|
137
|
-
j += 1 while j < str.length && str[j] =~ /[A-Za-z:+\-^~@']/
|
|
138
|
-
[str[i...j], j]
|
|
319
|
+
|
|
320
|
+
# Check if character is a letter.
|
|
321
|
+
#
|
|
322
|
+
# @param char [String] Single character
|
|
323
|
+
# @return [Boolean] True if character is A-Z or a-z
|
|
324
|
+
private_class_method def self.letter?(char)
|
|
325
|
+
(char >= "A" && char <= "Z") || (char >= "a" && char <= "z")
|
|
139
326
|
end
|
|
140
327
|
|
|
141
|
-
|
|
328
|
+
# Parse EPIN string into a piece identifier object.
|
|
329
|
+
#
|
|
330
|
+
# Delegates to Sashite::Epin for actual parsing and validation.
|
|
331
|
+
#
|
|
332
|
+
# @param epin_str [String] EPIN notation string
|
|
333
|
+
# @return [Object] Piece identifier object (Epin::Identifier)
|
|
334
|
+
# @raise [Error::Piece] If EPIN is invalid or parsing fails
|
|
335
|
+
#
|
|
336
|
+
# @example Valid pieces
|
|
337
|
+
# parse_piece("K") # => Epin::Identifier (King)
|
|
338
|
+
# parse_piece("+R") # => Epin::Identifier (Enhanced Rook)
|
|
339
|
+
# parse_piece("-p'") # => Epin::Identifier (Diminished foreign pawn)
|
|
340
|
+
#
|
|
341
|
+
# @example Invalid piece
|
|
342
|
+
# parse_piece("X#") # => raises Error::Piece
|
|
343
|
+
private_class_method def self.parse_piece(epin_str)
|
|
344
|
+
# Pre-validate format
|
|
345
|
+
unless EPIN_PATTERN.match?(epin_str)
|
|
346
|
+
raise ::Sashite::Feen::Error::Piece,
|
|
347
|
+
"Invalid EPIN notation: #{epin_str}"
|
|
348
|
+
end
|
|
142
349
|
|
|
143
|
-
|
|
144
|
-
::Sashite::Epin.parse(
|
|
145
|
-
rescue StandardError => e
|
|
146
|
-
raise Error::Piece,
|
|
147
|
-
"
|
|
350
|
+
# Parse using EPIN library
|
|
351
|
+
::Sashite::Epin.parse(epin_str)
|
|
352
|
+
rescue ::StandardError => e
|
|
353
|
+
raise ::Sashite::Feen::Error::Piece,
|
|
354
|
+
"Failed to parse EPIN '#{epin_str}': #{e.message}"
|
|
148
355
|
end
|
|
149
|
-
private_class_method :_parse_epin
|
|
150
356
|
end
|
|
151
357
|
end
|
|
152
358
|
end
|