feen 5.0.0.beta7 → 5.0.0.beta8

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.
@@ -4,68 +4,16 @@ module Feen
4
4
  module Parser
5
5
  # Handles parsing of the piece placement section of a FEEN string
6
6
  module PiecePlacement
7
- # Error messages
7
+ # Simplified error messages
8
8
  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"
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"
19
12
  }.freeze
20
13
 
21
- # Empty string for initialization
22
- EMPTY_STRING = ""
23
-
24
14
  # Dimension separator character
25
15
  DIMENSION_SEPARATOR = "/"
26
16
 
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
17
  # Parses the piece placement section of a FEEN string
70
18
  #
71
19
  # @param piece_placement_str [String] FEEN piece placement string
@@ -74,558 +22,124 @@ module Feen
74
22
  # - Pieces are represented by strings containing their identifier and optional modifiers
75
23
  # @raise [ArgumentError] If the input string is invalid
76
24
  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
25
+ validate_input(piece_placement_str)
26
+ parse_structure(piece_placement_str)
101
27
  end
102
28
 
103
- # Detects inconsistencies in separator usage
29
+ # Validates the input string for basic requirements
104
30
  #
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
117
- #
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
194
- #
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
237
- end
238
-
239
- # Validates the piece placement string for basic syntax
240
- #
241
- # @param str [String] FEEN piece placement string
31
+ # @param str [String] Input string to validate
242
32
  # @raise [ArgumentError] If the string is invalid
243
33
  # @return [void]
244
- def self.validate_piece_placement_string(str)
34
+ def self.validate_input(str)
245
35
  raise ArgumentError, format(ERRORS[:invalid_type], str.class) unless str.is_a?(String)
246
36
  raise ArgumentError, ERRORS[:empty_string] if str.empty?
247
37
 
248
- # Validate against the complete BNF pattern
249
- raise ArgumentError, ERRORS[:invalid_format] unless str.match?(VALID_PIECE_PLACEMENT_PATTERN)
250
- end
251
-
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
263
- end
264
-
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
38
+ # Basic format validation
39
+ return if str.match?(%r{\A[a-zA-Z0-9+\-'/]+\z})
281
40
 
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
41
+ raise ArgumentError, ERRORS[:invalid_format]
302
42
  end
303
43
 
304
- # Validates that all parts are consistent in structure
44
+ # Parses the structure recursively
305
45
  #
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
46
+ # @param str [String] FEEN piece placement string
47
+ # @return [Array] Parsed structure
48
+ def self.parse_structure(str)
49
+ # Handle trailing separators
50
+ raise ArgumentError, ERRORS[:invalid_format] if str.end_with?(DIMENSION_SEPARATOR)
334
51
 
335
- expected_size = calculate_rank_size(parts.first)
52
+ # Find the longest separator sequence to determine dimension depth
53
+ separators = str.scan(%r{/+}).uniq.sort_by(&:length).reverse
336
54
 
337
- parts.each_with_index do |part, index|
338
- next if index == 0 # Skip first part (already checked)
55
+ return parse_rank(str) if separators.empty?
339
56
 
340
- size = calculate_rank_size(part)
341
- next unless size != expected_size
57
+ # Use the longest separator to split at the highest dimension
58
+ main_separator = separators.first
59
+ parts = smart_split(str, main_separator)
342
60
 
343
- raise ArgumentError, format(
344
- ERRORS[:inconsistent_rank_size],
345
- expected_size,
346
- size,
347
- part
348
- )
349
- end
61
+ # Recursively parse each part
62
+ parts.map { |part| parse_structure(part) }
350
63
  end
351
64
 
352
- # Splits a string by a given separator, preserving separators of different depths
65
+ # Splits string by separator while preserving shorter separators
353
66
  #
354
67
  # @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)
68
+ # @param separator [String] Separator to split by
69
+ # @return [Array<String>] Split parts
70
+ def self.smart_split(str, separator)
358
71
  return [str] unless str.include?(separator)
359
72
 
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
73
+ parts = str.split(separator)
74
+ parts.reject(&:empty?)
394
75
  end
395
76
 
396
77
  # Parses a rank (sequence of cells)
397
78
  #
398
79
  # @param str [String] FEEN rank string
399
- # @return [Array] Array of cells (empty string for empty squares, full piece string for pieces)
80
+ # @return [Array] Array of cells
400
81
  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
407
- #
408
- # @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
431
- #
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
82
+ return [] if str.empty?
443
83
 
444
- {
445
- count: empty_count.to_i,
446
- next_index: current_index
447
- }
448
- end
449
-
450
- # Extracts a piece from the rank string
451
- #
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]
84
+ cells = []
85
+ i = 0
460
86
 
461
- # Check for prefix
462
- if VALID_PREFIXES.include?(char)
463
- piece_string += char
464
- current_index += 1
87
+ while i < str.length
88
+ char = str[i]
89
+
90
+ case char
91
+ when /[1-9]/
92
+ # Parse number for empty cells
93
+ number_str = ""
94
+ while i < str.length && str[i].match?(/[0-9]/)
95
+ number_str += str[i]
96
+ i += 1
97
+ end
465
98
 
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]
99
+ # Add empty cells
100
+ empty_count = number_str.to_i
101
+ cells.concat(Array.new(empty_count, ""))
102
+ when /[a-zA-Z+\-']/
103
+ # Parse piece
104
+ piece = extract_piece(str, i)
105
+ cells << piece[:piece]
106
+ i = piece[:next_index]
107
+ else
108
+ raise ArgumentError, ERRORS[:invalid_format]
469
109
  end
470
-
471
- char = str[current_index]
472
- end
473
-
474
- # Get the piece identifier
475
- raise ArgumentError, format(ERRORS[:invalid_piece], current_index, char) unless char.match?(/[a-zA-Z]/)
476
-
477
- piece_string += char
478
- current_index += 1
479
-
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
484
- end
485
-
486
- {
487
- piece: piece_string,
488
- next_index: current_index
489
- }
490
- end
491
-
492
- # Calculates the size of a rank based on its string representation
493
- #
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
501
- #
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
110
  end
524
- end
525
-
526
- # Finds the end position of a piece in the string
527
- #
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
111
 
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
547
- #
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)
112
+ cells
557
113
  end
558
114
 
559
- # Validates that all elements at the same level have the same structure
115
+ # Extracts a piece starting at given position
560
116
  #
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
117
+ # @param str [String] String to parse
118
+ # @param start_index [Integer] Starting position
119
+ # @return [Hash] Hash with :piece and :next_index keys
120
+ def self.extract_piece(str, start_index)
121
+ piece = ""
122
+ i = start_index
584
123
 
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 ? "..." : "")
124
+ # Optional prefix
125
+ if i < str.length && ["+", "-"].include?(str[i])
126
+ piece += str[i]
127
+ i += 1
601
128
  end
602
- end
603
129
 
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
130
+ # Required letter
131
+ raise ArgumentError, ERRORS[:invalid_format] unless i < str.length && str[i].match?(/[a-zA-Z]/)
611
132
 
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
133
+ piece += str[i]
134
+ i += 1
619
135
 
620
- # Output the piece
621
- result += cell
622
- end
136
+ # Optional suffix
137
+ if i < str.length && str[i] == "'"
138
+ piece += str[i]
139
+ i += 1
623
140
  end
624
141
 
625
- # Handle trailing empty cells
626
- result += empty_count.to_s if empty_count > 0
627
-
628
- result
142
+ { piece: piece, next_index: i }
629
143
  end
630
144
  end
631
145
  end