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.
@@ -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 empty square
15
- # compression and multi-dimensional separator support.
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 for 2D boards.
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
- Placement.new(ranks, dimension, section_sizes)
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
- # Counts consecutive separators to determine dimension:
48
- # - "/" = 2D
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 2)
73
+ # @return [Integer] Board dimension (minimum 1)
54
74
  #
55
- # @example 2D board
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
- max_consecutive = string.scan(/\/+/).map(&:length).max || 0
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
- # Split placement string into rank strings based on dimension.
93
+ # Parse string while preserving exact separators.
66
94
  #
67
- # @param string [String] Piece placement string
68
- # @param dimension [Integer] Board dimensionality
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
- # @example 1D board
72
- # split_ranks("k+p4+PK", 1) # => [["k+p4+PK"], nil]
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
- # @example 2D board
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
- # @example 3D board
78
- # split_ranks("5/5//5/5", 3) # => [["5", "5", "5", "5"], [2, 2]]
79
- private_class_method def self.split_ranks(string, dimension)
80
- if dimension == 1
81
- # 1D board: single rank, no separators
82
- [[string], nil]
83
- elsif dimension == 2
84
- # 2D board: split by single separator
85
- [string.split(RANK_SEPARATOR), nil]
86
- else
87
- # Multi-dimensional: split by dimension separator, track section sizes
88
- dimension_separator = RANK_SEPARATOR * (dimension - 1)
89
- sections = string.split(dimension_separator)
90
-
91
- # Each section contains ranks separated by single "/"
92
- section_sizes = []
93
- all_ranks = sections.flat_map do |section|
94
- ranks = section.split(RANK_SEPARATOR)
95
- section_sizes << ranks.size
96
- ranks
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
- # @param rank_str [String] Single rank string (e.g., "rnbqkbnr" or "4p3")
106
- # @return [Array] Array containing piece objects and nils
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 Rank with pieces
111
- # parse_rank("rnbqkbnr") # => [piece, piece, ..., piece]
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") # => [nil, nil, nil, nil, piece, nil, nil, nil]
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") # => [nil, nil, ..., nil] (100 times)
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
- # Skip whitespace and separators (commas)
127
- if char =~ /\s/ || char == ","
128
- i += 1
129
- next
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
- raise ::Sashite::Feen::Error::Syntax, "invalid empty square count: #{count_str}" if count < 1
191
+ validate_empty_count!(count, num_str)
145
192
 
193
+ # Add empty squares
146
194
  count.times { result << nil }
147
195
  i += consumed
148
- elsif char == "[" || letter?(char) || char == "+" || char == "-"
149
- # EPIN piece notation (bare or bracketed)
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
- # Extract a complete number (all consecutive digits) from character array.
208
+ # Check if character is a digit (1-9 for starting digit).
165
209
  #
166
- # Implements greedy parsing: reads all consecutive digits until a non-digit
167
- # character is encountered.
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 number of characters consumed
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 number
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(['1', '0', '0', '/'], 0) # => ["100", 3]
234
+ # extract_number(['9', '9', '9', '9', '9'], 0) # => ["99999", 5]
181
235
  private_class_method def self.extract_number(chars, start_index)
182
- i = start_index
183
- digits = []
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
- # Subsequent digits can be 0-9
192
- while i < chars.size && any_digit?(chars[i])
193
- digits << chars[i]
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
- # Check if character is a non-zero digit (1-9).
204
- # Used for the first digit of a number.
249
+ # Validate empty square count.
205
250
  #
206
- # @param char [String] Single character
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 char [String] Single character
216
- # @return [Boolean] True if character is 0-9
217
- private_class_method def self.any_digit?(char)
218
- char >= "0" && char <= "9"
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
- # Handles state prefixes (+/-), base letter, and derivation suffix (').
224
- # Also supports bracketed EPIN notation for legacy compatibility.
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 number of characters consumed
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 with derivation
235
- # extract_epin(['+', 'R', "'", 'B'], 0) # => ["+R'", 3]
279
+ # @example Enhanced piece
280
+ # extract_epin(['+', 'R', 'B'], 0) # => ["+R", 2]
236
281
  #
237
- # @example Bracketed piece (legacy)
238
- # extract_epin(['[', 'K', ']', 'Q'], 0) # => ["K", 3]
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 chars[i] == "+" || chars[i] == "-"
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, "expected letter in EPIN notation at position #{start_index}"
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 derivation suffix
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
- piece_str = piece_chars.join
270
- consumed = i - start_index
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
- 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
324
+ piece_str = piece_chars.join
325
+ consumed = i - start_index
312
326
 
313
- [content, consumed]
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("K") # => Epin::Identifier
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
- unless EPIN_PATTERN.match?(epin_str)
335
- raise ::Sashite::Feen::Error::Piece, "invalid EPIN notation: #{epin_str}"
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, "failed to parse EPIN '#{epin_str}': #{e.message}"
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 && (chars[i] == "+" || chars[i] == "-")
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
- if count > 999
224
- raise Error::Count, "piece count too large: #{count_str}"
225
- end
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
@@ -9,7 +9,7 @@ require_relative "position"
9
9
 
10
10
  module Sashite
11
11
  module Feen
12
- # Parser for FEEN (Forsyth–Edwards Enhanced Notation) strings.
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
- fields = string.split(FIELD_SEPARATOR, FIELD_COUNT)
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
- raise Error::Syntax, "FEEN must have exactly #{FIELD_COUNT} space-separated fields, got #{fields.size}" unless fields.size == FIELD_COUNT
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