feen 5.0.0.beta1 → 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/LICENSE.md +1 -1
- data/README.md +170 -44
- data/lib/feen/dumper/games_turn.rb +67 -0
- data/lib/feen/dumper/piece_placement.rb +117 -68
- 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 +72 -0
- data/lib/feen/dumper.rb +73 -23
- 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 +58 -0
- data/lib/feen/parser/piece_placement.rb +634 -0
- 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 +84 -0
- data/lib/feen/parser.rb +74 -36
- data/lib/feen.rb +58 -43
- metadata +17 -9
- data/lib/feen/parser/board_shape.rb +0 -39
@@ -0,0 +1,634 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Feen
|
4
|
+
module Parser
|
5
|
+
# Handles parsing of the piece placement section of a FEEN string
|
6
|
+
module PiecePlacement
|
7
|
+
# Error messages
|
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"
|
19
|
+
}.freeze
|
20
|
+
|
21
|
+
# Empty string for initialization
|
22
|
+
EMPTY_STRING = ""
|
23
|
+
|
24
|
+
# Dimension separator character
|
25
|
+
DIMENSION_SEPARATOR = "/"
|
26
|
+
|
27
|
+
# Piece prefixes
|
28
|
+
PREFIX_PROMOTION = "+"
|
29
|
+
PREFIX_DIMINISHED = "-"
|
30
|
+
VALID_PREFIXES = [PREFIX_PROMOTION, PREFIX_DIMINISHED].freeze
|
31
|
+
|
32
|
+
# Valid piece suffixes
|
33
|
+
SUFFIX_EQUALS = "="
|
34
|
+
SUFFIX_LEFT = "<"
|
35
|
+
SUFFIX_RIGHT = ">"
|
36
|
+
VALID_SUFFIXES = [SUFFIX_EQUALS, SUFFIX_LEFT, SUFFIX_RIGHT].freeze
|
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
|
+
|
71
|
+
# Parses the piece placement section of a FEEN string
|
72
|
+
#
|
73
|
+
# @param piece_placement_str [String] FEEN piece placement string
|
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
|
77
|
+
# @raise [ArgumentError] If the input string is invalid
|
78
|
+
def self.parse(piece_placement_str)
|
79
|
+
validate_piece_placement_string(piece_placement_str)
|
80
|
+
|
81
|
+
# Check for trailing separators that don't contribute to dimension structure
|
82
|
+
raise ArgumentError, ERRORS[:trailing_separator] if piece_placement_str.end_with?(DIMENSION_SEPARATOR)
|
83
|
+
|
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
|
239
|
+
end
|
240
|
+
|
241
|
+
# Validates the piece placement string for basic syntax
|
242
|
+
#
|
243
|
+
# @param str [String] FEEN piece placement string
|
244
|
+
# @raise [ArgumentError] If the string is invalid
|
245
|
+
# @return [void]
|
246
|
+
def self.validate_piece_placement_string(str)
|
247
|
+
raise ArgumentError, format(ERRORS[:invalid_type], str.class) unless str.is_a?(String)
|
248
|
+
raise ArgumentError, ERRORS[:empty_string] if str.empty?
|
249
|
+
|
250
|
+
# Validate against the complete BNF pattern
|
251
|
+
raise ArgumentError, ERRORS[:invalid_format] unless str.match?(VALID_PIECE_PLACEMENT_PATTERN)
|
252
|
+
end
|
253
|
+
|
254
|
+
# Finds all separator types present in the string (e.g., /, //, ///)
|
255
|
+
#
|
256
|
+
# @param str [String] FEEN dimension group string
|
257
|
+
# @return [Array<Integer>] Sorted array of separator depths (1 for /, 2 for //, etc.)
|
258
|
+
def self.find_separator_types(str)
|
259
|
+
# Find all consecutive sequences of '/'
|
260
|
+
separators = str.scan(%r{/+})
|
261
|
+
return [] if separators.empty?
|
262
|
+
|
263
|
+
# Return a unique sorted array of separator lengths
|
264
|
+
separators.map(&:length).uniq.sort
|
265
|
+
end
|
266
|
+
|
267
|
+
# Recursively parses a dimension group
|
268
|
+
#
|
269
|
+
# @param str [String] FEEN dimension group string
|
270
|
+
# @param separator_types [Array<Integer>] Array of separator depths found in the string
|
271
|
+
# @return [Array] Hierarchical array structure representing the dimension group
|
272
|
+
def self.parse_dimension_group(str, separator_types = nil)
|
273
|
+
# Check for trailing separators at each level
|
274
|
+
raise ArgumentError, ERRORS[:trailing_separator] if str.end_with?(DIMENSION_SEPARATOR)
|
275
|
+
|
276
|
+
# Find all separator types if not provided
|
277
|
+
separator_types ||= find_separator_types(str)
|
278
|
+
return parse_rank(str) if separator_types.empty?
|
279
|
+
|
280
|
+
# Start with the deepest separator (largest number of consecutive /)
|
281
|
+
max_depth = separator_types.max
|
282
|
+
separator = DIMENSION_SEPARATOR * max_depth
|
283
|
+
|
284
|
+
# Split the string by this separator depth
|
285
|
+
parts = split_by_separator(str, separator)
|
286
|
+
|
287
|
+
# Validate consistency of sub-parts
|
288
|
+
validate_parts_consistency(parts, max_depth)
|
289
|
+
|
290
|
+
# Create the hierarchical structure
|
291
|
+
parts.map do |part|
|
292
|
+
# Check each part for trailing separators of lower depths
|
293
|
+
raise ArgumentError, ERRORS[:trailing_separator] if part.end_with?(DIMENSION_SEPARATOR)
|
294
|
+
|
295
|
+
if max_depth == 1
|
296
|
+
# If this is the lowest level separator, parse as ranks
|
297
|
+
parse_rank(part)
|
298
|
+
else
|
299
|
+
# Otherwise, continue recursively with lower level separators
|
300
|
+
remaining_types = separator_types.reject { |t| t == max_depth }
|
301
|
+
parse_dimension_group(part, remaining_types)
|
302
|
+
end
|
303
|
+
end
|
304
|
+
end
|
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
|
+
|
354
|
+
# Splits a string by a given separator, preserving separators of different depths
|
355
|
+
#
|
356
|
+
# @param str [String] String to split
|
357
|
+
# @param separator [String] Separator to split by (e.g., "/", "//")
|
358
|
+
# @return [Array<String>] Array of split parts
|
359
|
+
def self.split_by_separator(str, separator)
|
360
|
+
return [str] unless str.include?(separator)
|
361
|
+
|
362
|
+
parts = []
|
363
|
+
current_part = ""
|
364
|
+
i = 0
|
365
|
+
|
366
|
+
while i < str.length
|
367
|
+
# If we find the start of a potential separator
|
368
|
+
if str[i] == DIMENSION_SEPARATOR[0]
|
369
|
+
# Check if it's our exact separator
|
370
|
+
if i <= str.length - separator.length && str[i, separator.length] == separator
|
371
|
+
# It's our separator, add the current part to the list
|
372
|
+
parts << current_part unless current_part.empty?
|
373
|
+
current_part = ""
|
374
|
+
i += separator.length
|
375
|
+
else
|
376
|
+
# It's not our exact separator, count consecutive '/' characters
|
377
|
+
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
|
384
|
+
end
|
385
|
+
else
|
386
|
+
# Normal character, add it to the current part
|
387
|
+
current_part += str[i]
|
388
|
+
i += 1
|
389
|
+
end
|
390
|
+
end
|
391
|
+
|
392
|
+
# Add the last part if it's not empty
|
393
|
+
parts << current_part unless current_part.empty?
|
394
|
+
|
395
|
+
parts
|
396
|
+
end
|
397
|
+
|
398
|
+
# Parses a rank (sequence of cells)
|
399
|
+
#
|
400
|
+
# @param str [String] FEEN rank string
|
401
|
+
# @return [Array] Array of cells (empty string for empty squares, full piece string for pieces)
|
402
|
+
def self.parse_rank(str)
|
403
|
+
return [] if str.nil? || str.empty?
|
404
|
+
|
405
|
+
parse_rank_recursive(str, 0, [])
|
406
|
+
end
|
407
|
+
|
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
|
431
|
+
|
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
|
445
|
+
|
446
|
+
{
|
447
|
+
count: empty_count.to_i,
|
448
|
+
next_index: current_index
|
449
|
+
}
|
450
|
+
end
|
451
|
+
|
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
|
472
|
+
|
473
|
+
char = str[current_index]
|
474
|
+
end
|
475
|
+
|
476
|
+
# Get the piece identifier
|
477
|
+
raise ArgumentError, format(ERRORS[:invalid_piece], current_index, char) unless char.match?(/[a-zA-Z]/)
|
478
|
+
|
479
|
+
piece_string += char
|
480
|
+
current_index += 1
|
481
|
+
|
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?
|
556
|
+
|
557
|
+
# Validate dimensional consistency
|
558
|
+
validate_dimensional_consistency(structure)
|
559
|
+
end
|
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
|
+
)
|
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 ? "..." : "")
|
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
|
629
|
+
|
630
|
+
result
|
631
|
+
end
|
632
|
+
end
|
633
|
+
end
|
634
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Feen
|
4
|
+
module Parser
|
5
|
+
module PiecesInHand
|
6
|
+
# Error messages for validation
|
7
|
+
Errors = {
|
8
|
+
invalid_type: "Pieces in hand must be a string, got %s",
|
9
|
+
empty_string: "Pieces in hand string cannot be empty",
|
10
|
+
invalid_format: "Invalid pieces in hand format: %s"
|
11
|
+
}.freeze
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Feen
|
4
|
+
module Parser
|
5
|
+
module PiecesInHand
|
6
|
+
# Valid pattern for pieces in hand based on BNF:
|
7
|
+
# <pieces-in-hand> ::= "-" | <A-part> <B-part> ... <Z-part> <a-part> <b-part> ... <z-part>
|
8
|
+
# where each part can be empty or contain repetitions of the same letter
|
9
|
+
ValidFormatPattern = /\A(?:-|
|
10
|
+
A*B*C*D*E*F*G*H*I*J*K*L*M*N*O*P*Q*R*S*T*U*V*W*X*Y*Z*
|
11
|
+
a*b*c*d*e*f*g*h*i*j*k*l*m*n*o*p*q*r*s*t*u*v*w*x*y*z*
|
12
|
+
)\z/x
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|