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.
- checksums.yaml +4 -4
- data/README.md +381 -245
- data/lib/feen/dumper/games_turn.rb +32 -29
- data/lib/feen/dumper/piece_placement.rb +85 -63
- data/lib/feen/dumper/pieces_in_hand.rb +32 -54
- data/lib/feen/dumper.rb +2 -2
- data/lib/feen/parser/games_turn.rb +36 -16
- data/lib/feen/parser/piece_placement.rb +78 -564
- data/lib/feen/parser/pieces_in_hand.rb +75 -101
- data/lib/feen/parser.rb +3 -3
- data/lib/feen.rb +20 -9
- metadata +1 -6
- data/lib/feen/dumper/pieces_in_hand/errors.rb +0 -12
- data/lib/feen/parser/games_turn/errors.rb +0 -14
- data/lib/feen/parser/games_turn/valid_games_turn_pattern.rb +0 -24
- data/lib/feen/parser/pieces_in_hand/errors.rb +0 -20
- data/lib/feen/parser/pieces_in_hand/pnn_patterns.rb +0 -89
@@ -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
|
-
#
|
7
|
+
# Simplified error messages
|
8
8
|
ERRORS = {
|
9
|
-
invalid_type:
|
10
|
-
empty_string:
|
11
|
-
invalid_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
|
-
|
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
|
-
#
|
29
|
+
# Validates the input string for basic requirements
|
104
30
|
#
|
105
|
-
# @param str [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.
|
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
|
-
#
|
249
|
-
|
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
|
-
|
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
|
-
#
|
44
|
+
# Parses the structure recursively
|
305
45
|
#
|
306
|
-
# @param
|
307
|
-
# @
|
308
|
-
|
309
|
-
|
310
|
-
|
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
|
-
|
52
|
+
# Find the longest separator sequence to determine dimension depth
|
53
|
+
separators = str.scan(%r{/+}).uniq.sort_by(&:length).reverse
|
336
54
|
|
337
|
-
|
338
|
-
next if index == 0 # Skip first part (already checked)
|
55
|
+
return parse_rank(str) if separators.empty?
|
339
56
|
|
340
|
-
|
341
|
-
|
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
|
-
|
344
|
-
|
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
|
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
|
356
|
-
# @return [Array<String>]
|
357
|
-
def self.
|
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
|
-
|
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
|
80
|
+
# @return [Array] Array of cells
|
400
81
|
def self.parse_rank(str)
|
401
|
-
return [] if str.
|
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
|
-
|
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
|
-
|
462
|
-
|
463
|
-
|
464
|
-
|
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
|
-
|
467
|
-
|
468
|
-
|
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
|
-
|
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
|
-
#
|
115
|
+
# Extracts a piece starting at given position
|
560
116
|
#
|
561
|
-
# @param
|
562
|
-
# @
|
563
|
-
# @return [
|
564
|
-
def self.
|
565
|
-
|
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
|
-
|
586
|
-
|
587
|
-
|
588
|
-
|
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
|
-
|
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
|
-
|
613
|
-
|
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
|
-
|
621
|
-
|
622
|
-
|
136
|
+
# Optional suffix
|
137
|
+
if i < str.length && str[i] == "'"
|
138
|
+
piece += str[i]
|
139
|
+
i += 1
|
623
140
|
end
|
624
141
|
|
625
|
-
|
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
|