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.
- checksums.yaml +4 -4
- data/README.md +107 -86
- data/lib/feen/dumper/games_turn.rb +40 -65
- data/lib/feen/dumper/piece_placement.rb +102 -89
- data/lib/feen/dumper/pieces_in_hand/errors.rb +12 -0
- data/lib/feen/dumper/pieces_in_hand/no_pieces.rb +10 -0
- data/lib/feen/dumper/pieces_in_hand.rb +57 -41
- data/lib/feen/dumper.rb +63 -32
- data/lib/feen/parser/games_turn/errors.rb +14 -0
- data/lib/feen/parser/games_turn/valid_games_turn_pattern.rb +24 -0
- data/lib/feen/parser/games_turn.rb +32 -110
- data/lib/feen/parser/piece_placement.rb +490 -77
- data/lib/feen/parser/pieces_in_hand/errors.rb +14 -0
- data/lib/feen/parser/pieces_in_hand/no_pieces.rb +10 -0
- data/lib/feen/parser/pieces_in_hand/valid_format_pattern.rb +15 -0
- data/lib/feen/parser/pieces_in_hand.rb +53 -44
- data/lib/feen/parser.rb +67 -30
- data/lib/feen.rb +42 -76
- metadata +9 -6
- data/lib/feen/converter/from_fen.rb +0 -170
- data/lib/feen/converter/to_fen.rb +0 -153
- data/lib/feen/converter.rb +0 -16
- data/lib/feen/sanitizer.rb +0 -119
@@ -6,25 +6,28 @@ module Feen
|
|
6
6
|
module PiecePlacement
|
7
7
|
# Error messages
|
8
8
|
ERRORS = {
|
9
|
-
invalid_type:
|
10
|
-
empty_string:
|
11
|
-
|
12
|
-
invalid_prefix:
|
13
|
-
invalid_piece:
|
14
|
-
trailing_separator:
|
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
|
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
|
-
|
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
|
-
#
|
60
|
-
|
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
|
97
|
-
separator_types
|
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.
|
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
|
-
|
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
|
-
#
|
367
|
+
# If we find the start of a potential separator
|
136
368
|
if str[i] == DIMENSION_SEPARATOR[0]
|
137
|
-
#
|
369
|
+
# Check if it's our exact separator
|
138
370
|
if i <= str.length - separator.length && str[i, separator.length] == separator
|
139
|
-
#
|
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
|
-
#
|
376
|
+
# It's not our exact separator, count consecutive '/' characters
|
145
377
|
start = i
|
146
|
-
|
147
|
-
|
148
|
-
|
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
|
-
#
|
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
|
-
#
|
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 (
|
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
|
-
|
171
|
-
|
405
|
+
parse_rank_recursive(str, 0, [])
|
406
|
+
end
|
172
407
|
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
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
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
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
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
446
|
+
{
|
447
|
+
count: empty_count.to_i,
|
448
|
+
next_index: current_index
|
449
|
+
}
|
450
|
+
end
|
193
451
|
|
194
|
-
|
195
|
-
|
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
|
-
|
198
|
-
|
473
|
+
char = str[current_index]
|
474
|
+
end
|
199
475
|
|
200
|
-
|
201
|
-
|
476
|
+
# Get the piece identifier
|
477
|
+
raise ArgumentError, format(ERRORS[:invalid_piece], current_index, char) unless char.match?(/[a-zA-Z]/)
|
202
478
|
|
203
|
-
|
204
|
-
|
479
|
+
piece_string += char
|
480
|
+
current_index += 1
|
205
481
|
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
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
|
-
|
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
|
-
|
630
|
+
result
|
218
631
|
end
|
219
632
|
end
|
220
633
|
end
|