feen 5.0.0.beta7 → 5.0.0.beta9

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.
@@ -3,629 +3,303 @@
3
3
  module Feen
4
4
  module Parser
5
5
  # Handles parsing of the piece placement section of a FEEN string
6
+ #
7
+ # This module is responsible for converting the first field of a FEEN string
8
+ # (piece placement) into a hierarchical array structure representing the board.
9
+ # It supports arbitrary dimensions and handles both pieces (with optional PNN modifiers)
10
+ # and empty squares (represented by numbers).
11
+ #
12
+ # @see https://sashite.dev/documents/feen/1.0.0/ FEEN Specification
13
+ # @see https://sashite.dev/documents/pnn/1.0.0/ PNN Specification
6
14
  module PiecePlacement
7
- # Error messages
15
+ # Simplified error messages
8
16
  ERRORS = {
9
- invalid_type: "Piece placement must be a string, got %s",
10
- empty_string: "Piece placement string cannot be empty",
11
- invalid_format: "Invalid piece placement format",
12
- invalid_prefix: "Expected piece identifier after prefix",
13
- invalid_piece: "Invalid piece identifier at position %d: %s",
14
- trailing_separator: "Unexpected separator at the end of string or dimension group",
15
- inconsistent_dimension: "Inconsistent dimension structure: expected %s, got %s",
16
- inconsistent_rank_size: "Inconsistent rank size: expected %d cells, got %d cells in rank '%s'",
17
- inconsistent_dimensions: "Inconsistent number of dimensions within structure",
18
- mixed_separators: "Mixed separator depths within the same level are not allowed: %s"
17
+ invalid_type: "Piece placement must be a string, got %s",
18
+ empty_string: "Piece placement string cannot be empty",
19
+ invalid_format: "Invalid piece placement format"
19
20
  }.freeze
20
21
 
21
- # Empty string for initialization
22
- EMPTY_STRING = ""
23
-
24
22
  # Dimension separator character
25
23
  DIMENSION_SEPARATOR = "/"
26
24
 
27
- # Piece prefixes
28
- PREFIX_PROMOTION = "+"
29
- PREFIX_DIMINISHED = "-"
30
- VALID_PREFIXES = [PREFIX_PROMOTION, PREFIX_DIMINISHED].freeze
31
-
32
- # Valid piece suffixes
33
- SUFFIX_INTERMEDIATE = "'"
34
- VALID_SUFFIXES = [SUFFIX_INTERMEDIATE].freeze
35
-
36
- # Build validation pattern step by step to match BNF
37
- # <letter> ::= <letter-lowercase> | <letter-uppercase>
38
- LETTER = "[a-zA-Z]"
39
-
40
- # <prefix> ::= "+" | "-"
41
- PREFIX = "[+-]"
42
-
43
- # <suffix> ::= "'"
44
- SUFFIX = "[']"
45
-
46
- # <piece> ::= <letter> | <prefix> <letter> | <letter> <suffix> | <prefix> <letter> <suffix>
47
- PIECE = "(?:#{PREFIX}?#{LETTER}#{SUFFIX}?)"
48
-
49
- # <number> ::= <non-zero-digit> | <non-zero-digit> <digits>
50
- NUMBER = "[1-9][0-9]*"
51
-
52
- # <cell> ::= <piece> | <number>
53
- CELL = "(?:#{PIECE}|#{NUMBER})"
54
-
55
- # <rank> ::= <cell> | <cell> <rank>
56
- RANK = "#{CELL}+"
57
-
58
- # <dim-separator> ::= <slash> <separator-tail>
59
- # <separator-tail> ::= "" | <slash> <separator-tail>
60
- # This creates patterns like /, //, ///, etc.
61
- DIM_SEPARATOR = "/+"
62
-
63
- # <dim-element> ::= <dim-group> | <rank>
64
- # <dim-group> ::= <dim-element> | <dim-element> <dim-separator> <dim-group>
65
- # <piece-placement> ::= <dim-group>
66
- # This recursive pattern matches: rank or rank/rank or rank//rank, etc.
67
- VALID_PIECE_PLACEMENT_PATTERN = /\A#{RANK}(?:#{DIM_SEPARATOR}#{RANK})*\z/
68
-
69
25
  # Parses the piece placement section of a FEEN string
70
26
  #
27
+ # Converts a FEEN piece placement string into a hierarchical array structure
28
+ # representing the board where empty squares are represented by empty strings
29
+ # and pieces are represented by strings containing their PNN identifier and
30
+ # optional modifiers.
31
+ #
71
32
  # @param piece_placement_str [String] FEEN piece placement string
72
33
  # @return [Array] Hierarchical array structure representing the board where:
73
34
  # - Empty squares are represented by empty strings ("")
74
35
  # - Pieces are represented by strings containing their identifier and optional modifiers
75
36
  # @raise [ArgumentError] If the input string is invalid
76
- def self.parse(piece_placement_str)
77
- validate_piece_placement_string(piece_placement_str)
78
-
79
- # Check for trailing separators that don't contribute to dimension structure
80
- raise ArgumentError, ERRORS[:trailing_separator] if piece_placement_str.end_with?(DIMENSION_SEPARATOR)
81
-
82
- # Analyze separator structure for consistency
83
- detect_separator_inconsistencies(piece_placement_str)
84
-
85
- # Find all separator types present in the string
86
- separator_types = find_separator_types(piece_placement_str)
87
-
88
- # Parse the structure based on the separator types found
89
- result = if separator_types.empty?
90
- # Single rank, no separators
91
- parse_rank(piece_placement_str)
92
- else
93
- # Multiple dimensions with separators
94
- parse_dimension_group(piece_placement_str, separator_types)
95
- end
96
-
97
- # Validate the structure for dimensional consistency
98
- validate_structure(result)
99
-
100
- result
101
- end
102
-
103
- # Detects inconsistencies in separator usage
104
37
  #
105
- # @param str [String] FEEN piece placement string
106
- # @raise [ArgumentError] If separator usage is inconsistent
107
- # @return [void]
108
- def self.detect_separator_inconsistencies(str)
109
- # Parse the content into segments based on separators
110
- segments = extract_hierarchical_segments(str)
111
-
112
- # Validate that separators at each level have consistent depth
113
- validate_separator_segments(segments)
114
- end
115
-
116
- # Extracts hierarchical segments based on separator depths
38
+ # @example Parse a simple 2D chess position (initial position)
39
+ # PiecePlacement.parse("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR")
40
+ # # => [
41
+ # # ["r", "n", "b", "q", "k", "b", "n", "r"],
42
+ # # ["p", "p", "p", "p", "p", "p", "p", "p"],
43
+ # # ["", "", "", "", "", "", "", ""],
44
+ # # ["", "", "", "", "", "", "", ""],
45
+ # # ["", "", "", "", "", "", "", ""],
46
+ # # ["", "", "", "", "", "", "", ""],
47
+ # # ["P", "P", "P", "P", "P", "P", "P", "P"],
48
+ # # ["R", "N", "B", "Q", "K", "B", "N", "R"]
49
+ # # ]
117
50
  #
118
- # @param str [String] FEEN piece placement string
119
- # @return [Hash] Hierarchical structure of segments and their separators
120
- def self.extract_hierarchical_segments(str)
121
- # Locate all separators in the string
122
- separator_positions = []
123
- str.scan(%r{/+}) do
124
- separator_positions << {
125
- start: Regexp.last_match.begin(0),
126
- end: Regexp.last_match.end(0) - 1,
127
- depth: Regexp.last_match[0].length,
128
- content: Regexp.last_match[0]
129
- }
130
- end
131
-
132
- # Return early if no separators
133
- if separator_positions.empty?
134
- return { segments: [{ content: str, start: 0, end: str.length - 1 }], separators: [] }
135
- end
136
-
137
- # Group separators by depth
138
- separators_by_depth = separator_positions.group_by { |s| s[:depth] }
139
- max_depth = separators_by_depth.keys.max
140
-
141
- # Start with the top level (deepest separators)
142
- top_level_separators = separators_by_depth[max_depth].sort_by { |s| s[:start] }
143
-
144
- # Extract top level segments
145
- top_segments = []
146
-
147
- # Add first segment if it exists
148
- if top_level_separators.first && top_level_separators.first[:start] > 0
149
- top_segments << {
150
- content: str[0...top_level_separators.first[:start]],
151
- start: 0,
152
- end: top_level_separators.first[:start] - 1
153
- }
154
- end
155
-
156
- # Add segments between separators
157
- top_level_separators.each_with_index do |sep, idx|
158
- next_sep = top_level_separators[idx + 1]
159
- if next_sep
160
- segment_start = sep[:end] + 1
161
- segment_end = next_sep[:start] - 1
162
-
163
- if segment_end >= segment_start
164
- top_segments << {
165
- content: str[segment_start..segment_end],
166
- start: segment_start,
167
- end: segment_end
168
- }
169
- end
170
- else
171
- # Last segment after final separator
172
- segment_start = sep[:end] + 1
173
- if segment_start < str.length
174
- top_segments << {
175
- content: str[segment_start..],
176
- start: segment_start,
177
- end: str.length - 1
178
- }
179
- end
180
- end
181
- end
182
-
183
- # Process each segment recursively
184
- processed_segments = top_segments.map do |segment|
185
- # Check if this segment contains separators of lower depths
186
- subsegment = extract_hierarchical_segments(segment[:content])
187
- segment.merge(subsegments: subsegment[:segments], subseparators: subsegment[:separators])
188
- end
189
-
190
- { segments: processed_segments, separators: top_level_separators }
191
- end
192
-
193
- # Validates that separators are used consistently
51
+ # @example Parse a single rank with mixed pieces and empty squares
52
+ # PiecePlacement.parse("r2k1r")
53
+ # # => ["r", "", "", "k", "", "r"]
194
54
  #
195
- # @param segment_data [Hash] Hierarchical structure of segments and separators
196
- # @raise [ArgumentError] If separators are inconsistent
197
- # @return [void]
198
- def self.validate_separator_segments(segment_data)
199
- segments = segment_data[:segments]
200
- separators = segment_data[:separators]
201
-
202
- # Nothing to validate if no separators
203
- return if separators.empty?
204
-
205
- # Check that all separators at this level have the same depth
206
- separator_depths = separators.map { |s| s[:depth] }.uniq
207
- raise ArgumentError, format(ERRORS[:mixed_separators], separator_depths.inspect) if separator_depths.size > 1
208
-
209
- # Check that sibling segments have consistent separator structure
210
- if segments.size > 1
211
- # Extract separator depths from each segment
212
- segment_separator_depths = segments.map do |segment|
213
- segment[:subseparators]&.map { |s| s[:depth] }&.uniq || []
214
- end
215
-
216
- # All segments should have the same separator depth pattern
217
- reference_depths = segment_separator_depths.first
218
- segment_separator_depths.each do |depths|
219
- next unless depths != reference_depths
220
-
221
- raise ArgumentError, format(
222
- ERRORS[:mixed_separators],
223
- "Inconsistent separator depths between segments"
224
- )
225
- end
226
- end
227
-
228
- # Recursively validate each segment's subsegments
229
- segments.each do |segment|
230
- next unless segment[:subsegments] && !segment[:subsegments].empty?
231
-
232
- validate_separator_segments(
233
- segments: segment[:subsegments],
234
- separators: segment[:subseparators] || []
235
- )
236
- end
55
+ # @example Parse pieces with PNN modifiers (promoted pieces in Shogi)
56
+ # PiecePlacement.parse("+P+Bk")
57
+ # # => ["+P", "+B", "k"]
58
+ #
59
+ # @example Parse a 3D board structure (2 planes of 2x2)
60
+ # PiecePlacement.parse("rn/pp//RN/PP")
61
+ # # => [
62
+ # # [["r", "n"], ["p", "p"]],
63
+ # # [["R", "N"], ["P", "P"]]
64
+ # # ]
65
+ #
66
+ # @example Parse complex Shogi position with promoted pieces
67
+ # PiecePlacement.parse("9/9/9/9/4+P4/9/5+B3/9/9")
68
+ # # => [
69
+ # # ["", "", "", "", "", "", "", "", ""],
70
+ # # ["", "", "", "", "", "", "", "", ""],
71
+ # # ["", "", "", "", "", "", "", "", ""],
72
+ # # ["", "", "", "", "", "", "", "", ""],
73
+ # # ["", "", "", "", "+P", "", "", "", ""],
74
+ # # ["", "", "", "", "", "", "", "", ""],
75
+ # # ["", "", "", "", "", "+B", "", "", ""],
76
+ # # ["", "", "", "", "", "", "", "", ""],
77
+ # # ["", "", "", "", "", "", "", "", ""]
78
+ # # ]
79
+ #
80
+ # @example Parse irregular board shapes (different rank sizes)
81
+ # PiecePlacement.parse("rnbqkbnr/ppppppp/8")
82
+ # # => [
83
+ # # ["r", "n", "b", "q", "k", "b", "n", "r"], # 8 cells
84
+ # # ["p", "p", "p", "p", "p", "p", "p"], # 7 cells
85
+ # # ["", "", "", "", "", "", "", ""] # 8 cells
86
+ # # ]
87
+ #
88
+ # @example Parse large numbers of empty squares
89
+ # PiecePlacement.parse("15")
90
+ # # => ["", "", "", "", "", "", "", "", "", "", "", "", "", "", ""]
91
+ #
92
+ # @example Parse pieces with all PNN modifier types
93
+ # PiecePlacement.parse("+P'-R'k")
94
+ # # => ["+P'", "-R'", "k"]
95
+ # # Where:
96
+ # # - "+P'" = enhanced state with intermediate suffix
97
+ # # - "-R'" = diminished state with intermediate suffix
98
+ # # - "k" = base piece without modifiers
99
+ def self.parse(piece_placement_str)
100
+ validate_input(piece_placement_str)
101
+ parse_structure(piece_placement_str)
237
102
  end
238
103
 
239
- # Validates the piece placement string for basic syntax
104
+ # Validates the input string for basic requirements
240
105
  #
241
- # @param str [String] FEEN piece placement string
106
+ # Ensures the input is a non-empty string containing only valid FEEN characters.
107
+ # Valid characters include: letters (a-z, A-Z), digits (0-9), and modifiers (+, -, ').
108
+ #
109
+ # @param str [String] Input string to validate
242
110
  # @raise [ArgumentError] If the string is invalid
243
111
  # @return [void]
244
- def self.validate_piece_placement_string(str)
112
+ #
113
+ # @example Valid input
114
+ # validate_input("rnbqkbnr/pppppppp/8/8")
115
+ # # => (no error)
116
+ #
117
+ # @example Invalid input (empty string)
118
+ # validate_input("")
119
+ # # => ArgumentError: Piece placement string cannot be empty
120
+ #
121
+ # @example Invalid input (wrong type)
122
+ # validate_input(123)
123
+ # # => ArgumentError: Piece placement must be a string, got Integer
124
+ def self.validate_input(str)
245
125
  raise ArgumentError, format(ERRORS[:invalid_type], str.class) unless str.is_a?(String)
246
126
  raise ArgumentError, ERRORS[:empty_string] if str.empty?
247
127
 
248
- # Validate against the complete BNF pattern
249
- raise ArgumentError, ERRORS[:invalid_format] unless str.match?(VALID_PIECE_PLACEMENT_PATTERN)
250
- end
128
+ # Basic format validation
129
+ return if str.match?(%r{\A[a-zA-Z0-9+\-'/]+\z})
251
130
 
252
- # Finds all separator types present in the string (e.g., /, //, ///)
253
- #
254
- # @param str [String] FEEN dimension group string
255
- # @return [Array<Integer>] Sorted array of separator depths (1 for /, 2 for //, etc.)
256
- def self.find_separator_types(str)
257
- # Find all consecutive sequences of '/'
258
- separators = str.scan(%r{/+})
259
- return [] if separators.empty?
260
-
261
- # Return a unique sorted array of separator lengths
262
- separators.map(&:length).uniq.sort
131
+ raise ArgumentError, ERRORS[:invalid_format]
263
132
  end
264
133
 
265
- # Recursively parses a dimension group
266
- #
267
- # @param str [String] FEEN dimension group string
268
- # @param separator_types [Array<Integer>] Array of separator depths found in the string
269
- # @return [Array] Hierarchical array structure representing the dimension group
270
- def self.parse_dimension_group(str, separator_types = nil)
271
- # Check for trailing separators at each level
272
- raise ArgumentError, ERRORS[:trailing_separator] if str.end_with?(DIMENSION_SEPARATOR)
273
-
274
- # Find all separator types if not provided
275
- separator_types ||= find_separator_types(str)
276
- return parse_rank(str) if separator_types.empty?
277
-
278
- # Start with the deepest separator (largest number of consecutive /)
279
- max_depth = separator_types.max
280
- separator = DIMENSION_SEPARATOR * max_depth
281
-
282
- # Split the string by this separator depth
283
- parts = split_by_separator(str, separator)
284
-
285
- # Validate consistency of sub-parts
286
- validate_parts_consistency(parts, max_depth)
287
-
288
- # Create the hierarchical structure
289
- parts.map do |part|
290
- # Check each part for trailing separators of lower depths
291
- raise ArgumentError, ERRORS[:trailing_separator] if part.end_with?(DIMENSION_SEPARATOR)
292
-
293
- if max_depth == 1
294
- # If this is the lowest level separator, parse as ranks
295
- parse_rank(part)
296
- else
297
- # Otherwise, continue recursively with lower level separators
298
- remaining_types = separator_types.reject { |t| t == max_depth }
299
- parse_dimension_group(part, remaining_types)
300
- end
301
- end
302
- end
303
-
304
- # Validates that all parts are consistent in structure
134
+ # Parses the structure recursively
305
135
  #
306
- # @param parts [Array<String>] Parts of the dimension after splitting
307
- # @param depth [Integer] Depth of the current separator
308
- # @raise [ArgumentError] If parts are inconsistent
309
- # @return [void]
310
- def self.validate_parts_consistency(parts, depth)
311
- return if parts.empty? || parts.size == 1
312
-
313
- # If we're splitting on separators of depth > 1, make sure all parts
314
- # have consistent internal structure
315
- if depth > 1
316
- first_part_seps = find_separator_types(parts.first)
317
-
318
- parts.each_with_index do |part, index|
319
- next if index == 0 # Skip first part (already checked)
320
-
321
- part_seps = find_separator_types(part)
322
- next unless part_seps != first_part_seps
323
-
324
- raise ArgumentError, format(
325
- ERRORS[:inconsistent_dimension],
326
- first_part_seps.inspect,
327
- part_seps.inspect
328
- )
329
- end
330
- end
331
-
332
- # For lowest level separators, verify rank sizes are consistent
333
- return unless depth == 1
136
+ # Determines the dimensionality of the board by analyzing separator patterns
137
+ # and recursively parses nested structures. Uses the longest separator sequence
138
+ # to determine the highest dimension level.
139
+ #
140
+ # @param str [String] FEEN piece placement string
141
+ # @return [Array] Parsed structure (1D array for ranks, nested arrays for higher dimensions)
142
+ #
143
+ # @example 1D structure (single rank)
144
+ # parse_structure("rnbq")
145
+ # # => ["r", "n", "b", "q"]
146
+ #
147
+ # @example 2D structure (multiple ranks)
148
+ # parse_structure("rn/pq")
149
+ # # => [["r", "n"], ["p", "q"]]
150
+ #
151
+ # @example 3D structure (multiple planes)
152
+ # parse_structure("r/p//R/P")
153
+ # # => [[["r"], ["p"]], [["R"], ["P"]]]
154
+ private_class_method def self.parse_structure(str)
155
+ # Handle trailing separators
156
+ raise ArgumentError, ERRORS[:invalid_format] if str.end_with?(DIMENSION_SEPARATOR)
334
157
 
335
- expected_size = calculate_rank_size(parts.first)
158
+ # Find the longest separator sequence to determine dimension depth
159
+ separators = str.scan(%r{/+}).uniq.sort_by(&:length).reverse
336
160
 
337
- parts.each_with_index do |part, index|
338
- next if index == 0 # Skip first part (already checked)
161
+ return parse_rank(str) if separators.empty?
339
162
 
340
- size = calculate_rank_size(part)
341
- next unless size != expected_size
163
+ # Use the longest separator to split at the highest dimension
164
+ main_separator = separators.first
165
+ parts = smart_split(str, main_separator)
342
166
 
343
- raise ArgumentError, format(
344
- ERRORS[:inconsistent_rank_size],
345
- expected_size,
346
- size,
347
- part
348
- )
349
- end
167
+ # Recursively parse each part
168
+ parts.map { |part| parse_structure(part) }
350
169
  end
351
170
 
352
- # Splits a string by a given separator, preserving separators of different depths
171
+ # Splits string by separator while preserving shorter separators
172
+ #
173
+ # Intelligently splits a string by a specific separator pattern while
174
+ # ensuring that shorter separator patterns within the string are preserved
175
+ # for recursive parsing of nested dimensions.
353
176
  #
354
177
  # @param str [String] String to split
355
- # @param separator [String] Separator to split by (e.g., "/", "//")
356
- # @return [Array<String>] Array of split parts
357
- def self.split_by_separator(str, separator)
178
+ # @param separator [String] Separator to split by (e.g., "/", "//", "///")
179
+ # @return [Array<String>] Split parts, with empty parts removed
180
+ #
181
+ # @example Split by single separator
182
+ # smart_split("a/b/c", "/")
183
+ # # => ["a", "b", "c"]
184
+ #
185
+ # @example Split by double separator, preserving single separators
186
+ # smart_split("a/b//c/d", "//")
187
+ # # => ["a/b", "c/d"]
188
+ private_class_method def self.smart_split(str, separator)
358
189
  return [str] unless str.include?(separator)
359
190
 
360
- parts = []
361
- current_part = ""
362
- i = 0
363
-
364
- while i < str.length
365
- # If we find the start of a potential separator
366
- if str[i] == DIMENSION_SEPARATOR[0]
367
- # Check if it's our exact separator
368
- if i <= str.length - separator.length && str[i, separator.length] == separator
369
- # It's our separator, add the current part to the list
370
- parts << current_part unless current_part.empty?
371
- current_part = ""
372
- i += separator.length
373
- else
374
- # It's not our exact separator, count consecutive '/' characters
375
- start = i
376
- j = i
377
- j += 1 while j < str.length && str[j] == DIMENSION_SEPARATOR[0]
378
-
379
- # Add these '/' to the current part
380
- current_part += str[start...j]
381
- i = j
382
- end
383
- else
384
- # Normal character, add it to the current part
385
- current_part += str[i]
386
- i += 1
387
- end
388
- end
389
-
390
- # Add the last part if it's not empty
391
- parts << current_part unless current_part.empty?
392
-
393
- parts
191
+ parts = str.split(separator)
192
+ parts.reject(&:empty?)
394
193
  end
395
194
 
396
195
  # Parses a rank (sequence of cells)
397
196
  #
398
- # @param str [String] FEEN rank string
399
- # @return [Array] Array of cells (empty string for empty squares, full piece string for pieces)
400
- def self.parse_rank(str)
401
- return [] if str.nil? || str.empty?
402
-
403
- parse_rank_recursive(str, 0, [])
404
- end
405
-
406
- # Recursively parses a rank string
197
+ # Processes a 1D sequence of cells, expanding numeric values to empty squares
198
+ # and extracting pieces with their PNN modifiers. Numbers represent consecutive
199
+ # empty squares, while letters (with optional modifiers) represent pieces.
407
200
  #
408
201
  # @param str [String] FEEN rank string
409
- # @param index [Integer] Current index in the string
410
- # @param cells [Array<String>] Accumulated cells
411
- # @return [Array<String>] Complete array of cells
412
- def self.parse_rank_recursive(str, index, cells)
413
- return cells if index >= str.length
414
-
415
- char = str[index]
416
-
417
- if char.match?(/[1-9]/)
418
- # Handle empty cells (digits represent consecutive empty squares)
419
- empty_count_info = extract_empty_count(str, index)
420
- new_cells = cells + Array.new(empty_count_info[:count], "")
421
- parse_rank_recursive(str, empty_count_info[:next_index], new_cells)
422
- else
423
- # Handle pieces
424
- piece_info = extract_piece(str, index)
425
- new_cells = cells + [piece_info[:piece]]
426
- parse_rank_recursive(str, piece_info[:next_index], new_cells)
427
- end
428
- end
429
-
430
- # Extracts an empty count from the rank string
202
+ # @return [Array<String>] Array of cells (empty strings for empty squares, piece strings for pieces)
431
203
  #
432
- # @param str [String] FEEN rank string
433
- # @param index [Integer] Starting index
434
- # @return [Hash] Count and next index
435
- def self.extract_empty_count(str, index)
436
- empty_count = ""
437
- current_index = index
438
-
439
- while current_index < str.length && str[current_index].match?(/[0-9]/)
440
- empty_count += str[current_index]
441
- current_index += 1
442
- end
443
-
444
- {
445
- count: empty_count.to_i,
446
- next_index: current_index
447
- }
448
- end
449
-
450
- # Extracts a piece from the rank string
204
+ # @example Simple pieces
205
+ # parse_rank("rnbq")
206
+ # # => ["r", "n", "b", "q"]
451
207
  #
452
- # @param str [String] FEEN rank string
453
- # @param index [Integer] Starting index
454
- # @return [Hash] Piece string and next index
455
- # @raise [ArgumentError] If the piece format is invalid
456
- def self.extract_piece(str, index)
457
- piece_string = ""
458
- current_index = index
459
- char = str[current_index]
460
-
461
- # Check for prefix
462
- if VALID_PREFIXES.include?(char)
463
- piece_string += char
464
- current_index += 1
465
-
466
- # Ensure there's a piece identifier after the prefix
467
- if current_index >= str.length || !str[current_index].match?(/[a-zA-Z]/)
468
- raise ArgumentError, ERRORS[:invalid_prefix]
469
- end
470
-
471
- char = str[current_index]
472
- end
208
+ # @example Mixed pieces and empty squares
209
+ # parse_rank("r2k1r")
210
+ # # => ["r", "", "", "k", "", "r"]
211
+ #
212
+ # @example All empty squares
213
+ # parse_rank("8")
214
+ # # => ["", "", "", "", "", "", "", ""]
215
+ #
216
+ # @example Pieces with modifiers
217
+ # parse_rank("+P-R'")
218
+ # # => ["+P", "-R'"]
219
+ private_class_method def self.parse_rank(str)
220
+ return [] if str.empty?
473
221
 
474
- # Get the piece identifier
475
- raise ArgumentError, format(ERRORS[:invalid_piece], current_index, char) unless char.match?(/[a-zA-Z]/)
222
+ cells = []
223
+ i = 0
476
224
 
477
- piece_string += char
478
- current_index += 1
225
+ while i < str.length
226
+ char = str[i]
227
+
228
+ case char
229
+ when /[1-9]/
230
+ # Parse number for empty cells
231
+ number_str = ""
232
+ while i < str.length && str[i].match?(/[0-9]/)
233
+ number_str += str[i]
234
+ i += 1
235
+ end
479
236
 
480
- # Check for suffix
481
- if current_index < str.length && VALID_SUFFIXES.include?(str[current_index])
482
- piece_string += str[current_index]
483
- current_index += 1
237
+ # Add empty cells
238
+ empty_count = number_str.to_i
239
+ cells.concat(Array.new(empty_count, ""))
240
+ when /[a-zA-Z+\-']/
241
+ # Parse piece
242
+ piece = extract_piece(str, i)
243
+ cells << piece[:piece]
244
+ i = piece[:next_index]
245
+ else
246
+ raise ArgumentError, ERRORS[:invalid_format]
247
+ end
484
248
  end
485
249
 
486
- {
487
- piece: piece_string,
488
- next_index: current_index
489
- }
250
+ cells
490
251
  end
491
252
 
492
- # Calculates the size of a rank based on its string representation
253
+ # Extracts a piece starting at given position
493
254
  #
494
- # @param rank_str [String] String representation of a rank
495
- # @return [Integer] Number of cells in the rank
496
- def self.calculate_rank_size(rank_str)
497
- calculate_rank_size_recursive(rank_str, 0, 0)
498
- end
499
-
500
- # Recursively calculates the size of a rank
255
+ # Parses a piece identifier with optional PNN modifiers starting at the specified
256
+ # position in the string. Handles prefix modifiers (+, -), the required letter,
257
+ # and suffix modifiers (').
501
258
  #
502
- # @param str [String] FEEN rank string
503
- # @param index [Integer] Current index
504
- # @param size [Integer] Accumulated size
505
- # @return [Integer] Final rank size
506
- def self.calculate_rank_size_recursive(str, index, size)
507
- return size if index >= str.length
508
-
509
- char = str[index]
510
-
511
- if char.match?(/[1-9]/)
512
- # Handle empty cells
513
- empty_count_info = extract_empty_count(str, index)
514
- calculate_rank_size_recursive(
515
- str,
516
- empty_count_info[:next_index],
517
- size + empty_count_info[:count]
518
- )
519
- else
520
- # Handle pieces
521
- piece_end = find_piece_end(str, index)
522
- calculate_rank_size_recursive(str, piece_end, size + 1)
523
- end
524
- end
525
-
526
- # Finds the end position of a piece in the string
259
+ # @param str [String] String to parse
260
+ # @param start_index [Integer] Starting position in the string
261
+ # @return [Hash] Hash with :piece and :next_index keys
262
+ # - :piece [String] The complete piece identifier with modifiers
263
+ # - :next_index [Integer] Position after the piece in the string
527
264
  #
528
- # @param str [String] FEEN rank string
529
- # @param index [Integer] Starting position
530
- # @return [Integer] End position of the piece
531
- def self.find_piece_end(str, index)
532
- current_index = index
533
-
534
- # Skip prefix if present
535
- current_index += 1 if current_index < str.length && VALID_PREFIXES.include?(str[current_index])
536
-
537
- # Skip piece identifier
538
- current_index += 1 if current_index < str.length
539
-
540
- # Skip suffix if present
541
- current_index += 1 if current_index < str.length && VALID_SUFFIXES.include?(str[current_index])
542
-
543
- current_index
544
- end
545
-
546
- # Validates that the parsed structure has consistent dimensions
265
+ # @example Extract simple piece
266
+ # extract_piece("Kqr", 0)
267
+ # # => { piece: "K", next_index: 1 }
547
268
  #
548
- # @param structure [Array] Parsed structure to validate
549
- # @raise [ArgumentError] If the structure is inconsistent
550
- # @return [void]
551
- def self.validate_structure(structure)
552
- # Single rank or empty array - no need to validate further
553
- return if !structure.is_a?(Array) || structure.empty?
554
-
555
- # Validate dimensional consistency
556
- validate_dimensional_consistency(structure)
557
- end
558
-
559
- # Validates that all elements at the same level have the same structure
269
+ # @example Extract piece with prefix modifier
270
+ # extract_piece("+Pqr", 0)
271
+ # # => { piece: "+P", next_index: 2 }
560
272
  #
561
- # @param structure [Array] Structure to validate
562
- # @raise [ArgumentError] If the structure is inconsistent
563
- # @return [void]
564
- def self.validate_dimensional_consistency(structure)
565
- return unless structure.is_a?(Array)
566
-
567
- # If it's an array of strings or empty strings, it's a rank - no need to validate further
568
- return if structure.all? { |item| item.is_a?(String) }
569
-
570
- # If it's a multi-dimensional array, check that all elements are arrays
571
- raise ArgumentError, ERRORS[:inconsistent_dimensions] unless structure.all? { |item| item.is_a?(Array) }
572
-
573
- # Check that all elements have the same length
574
- first_length = structure.first.size
575
- structure.each do |subarray|
576
- unless subarray.size == first_length
577
- raise ArgumentError, format(
578
- ERRORS[:inconsistent_rank_size],
579
- first_length,
580
- subarray.size,
581
- format_array_for_error(subarray)
582
- )
583
- end
584
-
585
- # Recursively validate each sub-structure
586
- validate_dimensional_consistency(subarray)
587
- end
588
- end
589
-
590
- # Formats an array for error messages in a more readable way
591
- #
592
- # @param array [Array] Array to format
593
- # @return [String] Formatted string representation
594
- def self.format_array_for_error(array)
595
- # For simple ranks, just join pieces
596
- if array.all? { |item| item.is_a?(String) }
597
- format_rank_for_error(array)
598
- else
599
- # For nested structures, use inspect (limited to keep error messages manageable)
600
- array.inspect[0..50] + (array.inspect.length > 50 ? "..." : "")
273
+ # @example Extract piece with suffix modifier
274
+ # extract_piece("K'qr", 0)
275
+ # # => { piece: "K'", next_index: 2 }
276
+ #
277
+ # @example Extract piece with both prefix and suffix modifiers
278
+ # extract_piece("+P'qr", 0)
279
+ # # => { piece: "+P'", next_index: 3 }
280
+ private_class_method def self.extract_piece(str, start_index)
281
+ piece = ""
282
+ i = start_index
283
+
284
+ # Optional prefix
285
+ if i < str.length && ["+", "-"].include?(str[i])
286
+ piece += str[i]
287
+ i += 1
601
288
  end
602
- end
603
289
 
604
- # Formats a rank for error messages
605
- #
606
- # @param rank [Array<String>] Rank to format
607
- # @return [String] Formatted rank
608
- def self.format_rank_for_error(rank)
609
- result = ""
610
- empty_count = 0
290
+ # Required letter
291
+ raise ArgumentError, ERRORS[:invalid_format] unless i < str.length && str[i].match?(/[a-zA-Z]/)
611
292
 
612
- rank.each do |cell|
613
- if cell.empty?
614
- empty_count += 1
615
- else
616
- # Output accumulated empty cells
617
- result += empty_count.to_s if empty_count > 0
618
- empty_count = 0
293
+ piece += str[i]
294
+ i += 1
619
295
 
620
- # Output the piece
621
- result += cell
622
- end
296
+ # Optional suffix
297
+ if i < str.length && str[i] == "'"
298
+ piece += str[i]
299
+ i += 1
623
300
  end
624
301
 
625
- # Handle trailing empty cells
626
- result += empty_count.to_s if empty_count > 0
627
-
628
- result
302
+ { piece: piece, next_index: i }
629
303
  end
630
304
  end
631
305
  end