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.
@@ -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,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Feen
4
+ module Parser
5
+ module PiecesInHand
6
+ # Character used to represent no pieces in hand
7
+ NoPieces = "-"
8
+ end
9
+ end
10
+ 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