sashite-feen 0.2.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 +436 -42
- data/lib/sashite/feen/dumper/piece_placement.rb +107 -50
- data/lib/sashite/feen/dumper/pieces_in_hand.rb +1 -0
- data/lib/sashite/feen/hands.rb +2 -2
- data/lib/sashite/feen/parser/piece_placement.rb +190 -176
- data/lib/sashite/feen/parser/pieces_in_hand.rb +7 -13
- data/lib/sashite/feen/parser.rb +11 -2
- data/lib/sashite/feen/placement.rb +260 -30
- metadata +1 -1
|
@@ -11,19 +11,30 @@ 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
|
|
27
|
+
# Pattern to match EPIN pieces (optional state, letter, optional derivation).
|
|
23
28
|
EPIN_PATTERN = /\A[-+]?[A-Za-z]'?\z/
|
|
24
29
|
|
|
25
30
|
# Parse a FEEN piece placement string into a Placement object.
|
|
26
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
|
+
#
|
|
27
38
|
# @param string [String] FEEN piece placement field string
|
|
28
39
|
# @return [Placement] Parsed placement object
|
|
29
40
|
# @raise [Error::Syntax] If placement format is invalid
|
|
@@ -34,88 +45,140 @@ module Sashite
|
|
|
34
45
|
#
|
|
35
46
|
# @example Empty 8x8 board
|
|
36
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")
|
|
37
54
|
def self.parse(string)
|
|
55
|
+
# Detect dimension before parsing
|
|
38
56
|
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
57
|
|
|
42
|
-
|
|
58
|
+
# Handle 1D case (no separators)
|
|
59
|
+
if dimension == 1
|
|
60
|
+
rank = parse_rank(string)
|
|
61
|
+
return Placement.new([rank], [], 1)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Parse multi-dimensional structure with separators
|
|
65
|
+
ranks, separators = parse_with_separators(string)
|
|
66
|
+
|
|
67
|
+
Placement.new(ranks, separators, dimension)
|
|
43
68
|
end
|
|
44
69
|
|
|
45
70
|
# Detect board dimensionality from separator patterns.
|
|
46
71
|
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
# - "//" = 3D
|
|
50
|
-
# - "///" = 4D
|
|
72
|
+
# Scans for consecutive "/" characters and returns:
|
|
73
|
+
# 1 + (maximum consecutive "/" count)
|
|
51
74
|
#
|
|
52
75
|
# @param string [String] Piece placement string
|
|
53
|
-
# @return [Integer] Board dimension (minimum
|
|
76
|
+
# @return [Integer] Board dimension (minimum 1)
|
|
54
77
|
#
|
|
55
|
-
# @example
|
|
78
|
+
# @example 1D board (no separators)
|
|
79
|
+
# detect_dimension("K2P") # => 1
|
|
80
|
+
#
|
|
81
|
+
# @example 2D board (single "/")
|
|
56
82
|
# detect_dimension("8/8") # => 2
|
|
57
83
|
#
|
|
58
|
-
# @example 3D board
|
|
84
|
+
# @example 3D board (contains "//")
|
|
59
85
|
# detect_dimension("5/5/5//5/5/5") # => 3
|
|
86
|
+
#
|
|
87
|
+
# @example 4D board (contains "///")
|
|
88
|
+
# detect_dimension("2/2///2/2") # => 4
|
|
60
89
|
private_class_method def self.detect_dimension(string)
|
|
61
|
-
|
|
90
|
+
return 1 unless string.include?(RANK_SEPARATOR)
|
|
91
|
+
|
|
92
|
+
max_consecutive = string.scan(%r{/+}).map(&:length).max || 0
|
|
62
93
|
max_consecutive + 1
|
|
63
94
|
end
|
|
64
95
|
|
|
65
|
-
#
|
|
96
|
+
# Parse string while preserving exact separators.
|
|
66
97
|
#
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
# @return [Array<String>, Array<Integer>] Array of rank strings and section sizes
|
|
98
|
+
# Uses split with capture group to preserve both ranks and separators.
|
|
99
|
+
# The regex /(\/+)/ captures one or more consecutive "/" characters.
|
|
70
100
|
#
|
|
71
|
-
#
|
|
72
|
-
#
|
|
101
|
+
# Result pattern: [rank, separator, rank, separator, ..., rank]
|
|
102
|
+
# - Even indices (0, 2, 4, ...) are ranks
|
|
103
|
+
# - Odd indices (1, 3, 5, ...) are separators
|
|
73
104
|
#
|
|
74
|
-
#
|
|
75
|
-
# split_ranks("8/8/8", 2) # => [["8", "8", "8"], nil]
|
|
105
|
+
# Empty strings are parsed as empty ranks (valid in FEEN).
|
|
76
106
|
#
|
|
77
|
-
# @
|
|
78
|
-
#
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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 = []
|
|
128
|
+
|
|
129
|
+
# Split with capture group to preserve separators
|
|
130
|
+
# Use limit=-1 to include trailing empty substrings
|
|
131
|
+
parts = string.split(%r{(/+)}, -1)
|
|
132
|
+
|
|
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
|
|
97
140
|
end
|
|
98
|
-
|
|
99
|
-
[all_ranks, section_sizes]
|
|
100
141
|
end
|
|
142
|
+
|
|
143
|
+
[ranks, separators]
|
|
101
144
|
end
|
|
102
145
|
|
|
103
146
|
# Parse a single rank string into an array of pieces and nils.
|
|
104
147
|
#
|
|
105
|
-
#
|
|
106
|
-
#
|
|
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)
|
|
107
156
|
# @raise [Error::Syntax] If rank format is invalid
|
|
108
157
|
# @raise [Error::Piece] If EPIN notation is invalid
|
|
109
158
|
#
|
|
110
|
-
# @example
|
|
111
|
-
# parse_rank("
|
|
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]
|
|
112
166
|
#
|
|
113
167
|
# @example Rank with empty squares
|
|
114
|
-
# parse_rank("4p3")
|
|
168
|
+
# parse_rank("4p3")
|
|
169
|
+
# # => [nil, nil, nil, nil, p, nil, nil, nil]
|
|
115
170
|
#
|
|
116
171
|
# @example Rank with large empty count
|
|
117
|
-
# parse_rank("100")
|
|
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]
|
|
118
178
|
private_class_method def self.parse_rank(rank_str)
|
|
179
|
+
# Handle empty rank (valid in FEEN)
|
|
180
|
+
return [] if rank_str.empty?
|
|
181
|
+
|
|
119
182
|
result = []
|
|
120
183
|
chars = rank_str.chars
|
|
121
184
|
i = 0
|
|
@@ -123,144 +186,126 @@ module Sashite
|
|
|
123
186
|
while i < chars.size
|
|
124
187
|
char = chars[i]
|
|
125
188
|
|
|
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
|
|
189
|
+
if digit?(char)
|
|
190
|
+
# Parse complete number (greedy)
|
|
191
|
+
num_str, consumed = extract_number(chars, i)
|
|
192
|
+
count = num_str.to_i
|
|
143
193
|
|
|
144
|
-
|
|
194
|
+
validate_empty_count!(count, num_str)
|
|
145
195
|
|
|
196
|
+
# Add empty squares
|
|
146
197
|
count.times { result << nil }
|
|
147
198
|
i += consumed
|
|
148
|
-
|
|
149
|
-
# EPIN piece notation
|
|
199
|
+
else
|
|
200
|
+
# Parse EPIN piece notation
|
|
150
201
|
piece_str, consumed = extract_epin(chars, i)
|
|
151
202
|
piece = parse_piece(piece_str)
|
|
152
203
|
result << piece
|
|
153
204
|
i += consumed
|
|
154
|
-
else
|
|
155
|
-
# Invalid character
|
|
156
|
-
raise ::Sashite::Feen::Error::Syntax,
|
|
157
|
-
"unexpected character #{char.inspect} at position #{i} in rank"
|
|
158
205
|
end
|
|
159
206
|
end
|
|
160
207
|
|
|
161
208
|
result
|
|
162
209
|
end
|
|
163
210
|
|
|
164
|
-
#
|
|
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).
|
|
165
222
|
#
|
|
166
|
-
#
|
|
167
|
-
#
|
|
223
|
+
# Reads all consecutive digits starting from start_index.
|
|
224
|
+
# First digit must be 1-9, subsequent digits can be 0-9.
|
|
168
225
|
#
|
|
169
226
|
# @param chars [Array<String>] Array of characters
|
|
170
227
|
# @param start_index [Integer] Starting index
|
|
171
|
-
# @return [Array(String, Integer)] Number string and
|
|
228
|
+
# @return [Array(String, Integer)] Number string and characters consumed
|
|
172
229
|
#
|
|
173
230
|
# @example Single digit
|
|
174
231
|
# extract_number(['8', 'K'], 0) # => ["8", 1]
|
|
175
232
|
#
|
|
176
|
-
# @example Multi-digit
|
|
233
|
+
# @example Multi-digit
|
|
177
234
|
# extract_number(['1', '2', '3', 'K'], 0) # => ["123", 3]
|
|
178
235
|
#
|
|
179
236
|
# @example Large number
|
|
180
|
-
# extract_number(['
|
|
237
|
+
# extract_number(['9', '9', '9', '9', '9'], 0) # => ["99999", 5]
|
|
181
238
|
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
|
|
239
|
+
num_str = chars[start_index]
|
|
240
|
+
i = start_index + 1
|
|
190
241
|
|
|
191
|
-
#
|
|
192
|
-
while i < chars.size &&
|
|
193
|
-
|
|
242
|
+
# Continue reading digits (including 0)
|
|
243
|
+
while i < chars.size && chars[i] >= "0" && chars[i] <= "9"
|
|
244
|
+
num_str += chars[i]
|
|
194
245
|
i += 1
|
|
195
246
|
end
|
|
196
247
|
|
|
197
|
-
number_str = digits.join
|
|
198
248
|
consumed = i - start_index
|
|
199
|
-
|
|
200
|
-
[number_str, consumed]
|
|
249
|
+
[num_str, consumed]
|
|
201
250
|
end
|
|
202
251
|
|
|
203
|
-
#
|
|
204
|
-
# Used for the first digit of a number.
|
|
252
|
+
# Validate empty square count.
|
|
205
253
|
#
|
|
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.
|
|
254
|
+
# Count must be at least 1 (FEEN doesn't allow "0" for zero squares).
|
|
214
255
|
#
|
|
215
|
-
# @param
|
|
216
|
-
# @
|
|
217
|
-
|
|
218
|
-
|
|
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}"
|
|
219
264
|
end
|
|
220
265
|
|
|
221
266
|
# Extract EPIN notation from character array.
|
|
222
267
|
#
|
|
223
|
-
#
|
|
224
|
-
#
|
|
268
|
+
# EPIN format: [state][letter][derivation]
|
|
269
|
+
# - state: optional "+" or "-" prefix
|
|
270
|
+
# - letter: required A-Z or a-z
|
|
271
|
+
# - derivation: optional "'" suffix
|
|
225
272
|
#
|
|
226
273
|
# @param chars [Array<String>] Array of characters
|
|
227
274
|
# @param start_index [Integer] Starting index
|
|
228
|
-
# @return [Array(String, Integer)] EPIN string and
|
|
229
|
-
# @raise [Error::Syntax] If EPIN format is incomplete
|
|
275
|
+
# @return [Array(String, Integer)] EPIN string and characters consumed
|
|
276
|
+
# @raise [Error::Syntax] If EPIN format is incomplete or invalid
|
|
230
277
|
#
|
|
231
278
|
# @example Simple piece
|
|
232
279
|
# extract_epin(['K', 'Q'], 0) # => ["K", 1]
|
|
233
280
|
#
|
|
234
|
-
# @example Enhanced piece
|
|
235
|
-
# extract_epin(['+', 'R',
|
|
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]
|
|
236
286
|
#
|
|
237
|
-
# @example
|
|
238
|
-
# extract_epin(['
|
|
287
|
+
# @example Complex piece
|
|
288
|
+
# extract_epin(['-', 'p', "'", 'K'], 0) # => ["-p'", 3]
|
|
239
289
|
private_class_method def self.extract_epin(chars, start_index)
|
|
240
290
|
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
291
|
piece_chars = []
|
|
248
292
|
|
|
249
|
-
# Optional state prefix
|
|
250
|
-
if
|
|
293
|
+
# Optional state prefix (+ or -)
|
|
294
|
+
if i < chars.size && ["+", "-"].include?(chars[i])
|
|
251
295
|
piece_chars << chars[i]
|
|
252
296
|
i += 1
|
|
253
297
|
end
|
|
254
298
|
|
|
255
299
|
# Base letter (required)
|
|
256
300
|
if i >= chars.size || !letter?(chars[i])
|
|
257
|
-
raise ::Sashite::Feen::Error::Syntax,
|
|
301
|
+
raise ::Sashite::Feen::Error::Syntax,
|
|
302
|
+
"Expected letter in EPIN notation at position #{start_index}"
|
|
258
303
|
end
|
|
259
304
|
|
|
260
305
|
piece_chars << chars[i]
|
|
261
306
|
i += 1
|
|
262
307
|
|
|
263
|
-
# Optional derivation suffix
|
|
308
|
+
# Optional derivation suffix (')
|
|
264
309
|
if i < chars.size && chars[i] == "'"
|
|
265
310
|
piece_chars << chars[i]
|
|
266
311
|
i += 1
|
|
@@ -272,47 +317,6 @@ module Sashite
|
|
|
272
317
|
[piece_str, consumed]
|
|
273
318
|
end
|
|
274
319
|
|
|
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
|
|
302
|
-
i += 1
|
|
303
|
-
end
|
|
304
|
-
|
|
305
|
-
if depth.positive?
|
|
306
|
-
raise ::Sashite::Feen::Error::Syntax,
|
|
307
|
-
"unterminated bracket at position #{start_index}"
|
|
308
|
-
end
|
|
309
|
-
|
|
310
|
-
content = content_chars.join
|
|
311
|
-
consumed = i - start_index
|
|
312
|
-
|
|
313
|
-
[content, consumed]
|
|
314
|
-
end
|
|
315
|
-
|
|
316
320
|
# Check if character is a letter.
|
|
317
321
|
#
|
|
318
322
|
# @param char [String] Single character
|
|
@@ -321,23 +325,33 @@ module Sashite
|
|
|
321
325
|
(char >= "A" && char <= "Z") || (char >= "a" && char <= "z")
|
|
322
326
|
end
|
|
323
327
|
|
|
324
|
-
# Parse EPIN string into a piece object.
|
|
328
|
+
# Parse EPIN string into a piece identifier object.
|
|
329
|
+
#
|
|
330
|
+
# Delegates to Sashite::Epin for actual parsing and validation.
|
|
325
331
|
#
|
|
326
332
|
# @param epin_str [String] EPIN notation string
|
|
327
|
-
# @return [Object] Piece identifier object
|
|
328
|
-
# @raise [Error::Piece] If EPIN is invalid
|
|
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)
|
|
329
340
|
#
|
|
330
|
-
# @example
|
|
331
|
-
# parse_piece("
|
|
332
|
-
# parse_piece("+R'") # => Epin::Identifier
|
|
341
|
+
# @example Invalid piece
|
|
342
|
+
# parse_piece("X#") # => raises Error::Piece
|
|
333
343
|
private_class_method def self.parse_piece(epin_str)
|
|
344
|
+
# Pre-validate format
|
|
334
345
|
unless EPIN_PATTERN.match?(epin_str)
|
|
335
|
-
raise ::Sashite::Feen::Error::Piece,
|
|
346
|
+
raise ::Sashite::Feen::Error::Piece,
|
|
347
|
+
"Invalid EPIN notation: #{epin_str}"
|
|
336
348
|
end
|
|
337
349
|
|
|
350
|
+
# Parse using EPIN library
|
|
338
351
|
::Sashite::Epin.parse(epin_str)
|
|
339
352
|
rescue ::StandardError => e
|
|
340
|
-
raise ::Sashite::Feen::Error::Piece,
|
|
353
|
+
raise ::Sashite::Feen::Error::Piece,
|
|
354
|
+
"Failed to parse EPIN '#{epin_str}': #{e.message}"
|
|
341
355
|
end
|
|
342
356
|
end
|
|
343
357
|
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
|
@@ -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
|