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.
@@ -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 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).
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
- Placement.new(ranks, dimension, section_sizes)
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
- # Counts consecutive separators to determine dimension:
48
- # - "/" = 2D
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 2)
76
+ # @return [Integer] Board dimension (minimum 1)
54
77
  #
55
- # @example 2D board
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
- max_consecutive = string.scan(/\/+/).map(&:length).max || 0
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
- # Split placement string into rank strings based on dimension.
96
+ # Parse string while preserving exact separators.
66
97
  #
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
98
+ # Uses split with capture group to preserve both ranks and separators.
99
+ # The regex /(\/+)/ captures one or more consecutive "/" characters.
70
100
  #
71
- # @example 1D board
72
- # split_ranks("k+p4+PK", 1) # => [["k+p4+PK"], nil]
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
- # @example 2D board
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
- # @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
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
- # @param rank_str [String] Single rank string (e.g., "rnbqkbnr" or "4p3")
106
- # @return [Array] Array containing piece objects and nils
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 Rank with pieces
111
- # parse_rank("rnbqkbnr") # => [piece, piece, ..., piece]
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") # => [nil, nil, nil, nil, piece, nil, nil, nil]
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") # => [nil, nil, ..., nil] (100 times)
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
- # 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
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
- raise ::Sashite::Feen::Error::Syntax, "invalid empty square count: #{count_str}" if count < 1
194
+ validate_empty_count!(count, num_str)
145
195
 
196
+ # Add empty squares
146
197
  count.times { result << nil }
147
198
  i += consumed
148
- elsif char == "[" || letter?(char) || char == "+" || char == "-"
149
- # EPIN piece notation (bare or bracketed)
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
- # Extract a complete number (all consecutive digits) from character array.
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
- # Implements greedy parsing: reads all consecutive digits until a non-digit
167
- # character is encountered.
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 number of characters consumed
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 number
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(['1', '0', '0', '/'], 0) # => ["100", 3]
237
+ # extract_number(['9', '9', '9', '9', '9'], 0) # => ["99999", 5]
181
238
  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
239
+ num_str = chars[start_index]
240
+ i = start_index + 1
190
241
 
191
- # Subsequent digits can be 0-9
192
- while i < chars.size && any_digit?(chars[i])
193
- digits << chars[i]
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
- # Check if character is a non-zero digit (1-9).
204
- # Used for the first digit of a number.
252
+ # Validate empty square count.
205
253
  #
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.
254
+ # Count must be at least 1 (FEEN doesn't allow "0" for zero squares).
214
255
  #
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"
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
- # Handles state prefixes (+/-), base letter, and derivation suffix (').
224
- # Also supports bracketed EPIN notation for legacy compatibility.
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 number of characters consumed
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 with derivation
235
- # extract_epin(['+', 'R', "'", 'B'], 0) # => ["+R'", 3]
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 Bracketed piece (legacy)
238
- # extract_epin(['[', 'K', ']', 'Q'], 0) # => ["K", 3]
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 chars[i] == "+" || chars[i] == "-"
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, "expected letter in EPIN notation at position #{start_index}"
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("K") # => Epin::Identifier
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, "invalid EPIN notation: #{epin_str}"
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, "failed to parse EPIN '#{epin_str}': #{e.message}"
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 && (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
@@ -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