feen 5.0.0.beta2 → 5.0.0.beta3

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