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