feen 5.0.0.beta7 → 5.0.0.beta9
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 +40 -20
- data/lib/feen/parser/piece_placement.rb +232 -558
- data/lib/feen/parser/pieces_in_hand.rb +93 -103
- data/lib/feen/parser.rb +3 -3
- data/lib/feen.rb +20 -9
- metadata +3 -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
@@ -3,629 +3,303 @@
|
|
3
3
|
module Feen
|
4
4
|
module Parser
|
5
5
|
# Handles parsing of the piece placement section of a FEEN string
|
6
|
+
#
|
7
|
+
# This module is responsible for converting the first field of a FEEN string
|
8
|
+
# (piece placement) into a hierarchical array structure representing the board.
|
9
|
+
# It supports arbitrary dimensions and handles both pieces (with optional PNN modifiers)
|
10
|
+
# and empty squares (represented by numbers).
|
11
|
+
#
|
12
|
+
# @see https://sashite.dev/documents/feen/1.0.0/ FEEN Specification
|
13
|
+
# @see https://sashite.dev/documents/pnn/1.0.0/ PNN Specification
|
6
14
|
module PiecePlacement
|
7
|
-
#
|
15
|
+
# Simplified error messages
|
8
16
|
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"
|
17
|
+
invalid_type: "Piece placement must be a string, got %s",
|
18
|
+
empty_string: "Piece placement string cannot be empty",
|
19
|
+
invalid_format: "Invalid piece placement format"
|
19
20
|
}.freeze
|
20
21
|
|
21
|
-
# Empty string for initialization
|
22
|
-
EMPTY_STRING = ""
|
23
|
-
|
24
22
|
# Dimension separator character
|
25
23
|
DIMENSION_SEPARATOR = "/"
|
26
24
|
|
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
25
|
# Parses the piece placement section of a FEEN string
|
70
26
|
#
|
27
|
+
# Converts a FEEN piece placement string into a hierarchical array structure
|
28
|
+
# representing the board where empty squares are represented by empty strings
|
29
|
+
# and pieces are represented by strings containing their PNN identifier and
|
30
|
+
# optional modifiers.
|
31
|
+
#
|
71
32
|
# @param piece_placement_str [String] FEEN piece placement string
|
72
33
|
# @return [Array] Hierarchical array structure representing the board where:
|
73
34
|
# - Empty squares are represented by empty strings ("")
|
74
35
|
# - Pieces are represented by strings containing their identifier and optional modifiers
|
75
36
|
# @raise [ArgumentError] If the input string is invalid
|
76
|
-
def self.parse(piece_placement_str)
|
77
|
-
validate_piece_placement_string(piece_placement_str)
|
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
|
101
|
-
end
|
102
|
-
|
103
|
-
# Detects inconsistencies in separator usage
|
104
37
|
#
|
105
|
-
# @
|
106
|
-
#
|
107
|
-
#
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
#
|
38
|
+
# @example Parse a simple 2D chess position (initial position)
|
39
|
+
# PiecePlacement.parse("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR")
|
40
|
+
# # => [
|
41
|
+
# # ["r", "n", "b", "q", "k", "b", "n", "r"],
|
42
|
+
# # ["p", "p", "p", "p", "p", "p", "p", "p"],
|
43
|
+
# # ["", "", "", "", "", "", "", ""],
|
44
|
+
# # ["", "", "", "", "", "", "", ""],
|
45
|
+
# # ["", "", "", "", "", "", "", ""],
|
46
|
+
# # ["", "", "", "", "", "", "", ""],
|
47
|
+
# # ["P", "P", "P", "P", "P", "P", "P", "P"],
|
48
|
+
# # ["R", "N", "B", "Q", "K", "B", "N", "R"]
|
49
|
+
# # ]
|
117
50
|
#
|
118
|
-
# @
|
119
|
-
#
|
120
|
-
|
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
|
51
|
+
# @example Parse a single rank with mixed pieces and empty squares
|
52
|
+
# PiecePlacement.parse("r2k1r")
|
53
|
+
# # => ["r", "", "", "k", "", "r"]
|
194
54
|
#
|
195
|
-
# @
|
196
|
-
#
|
197
|
-
#
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
55
|
+
# @example Parse pieces with PNN modifiers (promoted pieces in Shogi)
|
56
|
+
# PiecePlacement.parse("+P+Bk")
|
57
|
+
# # => ["+P", "+B", "k"]
|
58
|
+
#
|
59
|
+
# @example Parse a 3D board structure (2 planes of 2x2)
|
60
|
+
# PiecePlacement.parse("rn/pp//RN/PP")
|
61
|
+
# # => [
|
62
|
+
# # [["r", "n"], ["p", "p"]],
|
63
|
+
# # [["R", "N"], ["P", "P"]]
|
64
|
+
# # ]
|
65
|
+
#
|
66
|
+
# @example Parse complex Shogi position with promoted pieces
|
67
|
+
# PiecePlacement.parse("9/9/9/9/4+P4/9/5+B3/9/9")
|
68
|
+
# # => [
|
69
|
+
# # ["", "", "", "", "", "", "", "", ""],
|
70
|
+
# # ["", "", "", "", "", "", "", "", ""],
|
71
|
+
# # ["", "", "", "", "", "", "", "", ""],
|
72
|
+
# # ["", "", "", "", "", "", "", "", ""],
|
73
|
+
# # ["", "", "", "", "+P", "", "", "", ""],
|
74
|
+
# # ["", "", "", "", "", "", "", "", ""],
|
75
|
+
# # ["", "", "", "", "", "+B", "", "", ""],
|
76
|
+
# # ["", "", "", "", "", "", "", "", ""],
|
77
|
+
# # ["", "", "", "", "", "", "", "", ""]
|
78
|
+
# # ]
|
79
|
+
#
|
80
|
+
# @example Parse irregular board shapes (different rank sizes)
|
81
|
+
# PiecePlacement.parse("rnbqkbnr/ppppppp/8")
|
82
|
+
# # => [
|
83
|
+
# # ["r", "n", "b", "q", "k", "b", "n", "r"], # 8 cells
|
84
|
+
# # ["p", "p", "p", "p", "p", "p", "p"], # 7 cells
|
85
|
+
# # ["", "", "", "", "", "", "", ""] # 8 cells
|
86
|
+
# # ]
|
87
|
+
#
|
88
|
+
# @example Parse large numbers of empty squares
|
89
|
+
# PiecePlacement.parse("15")
|
90
|
+
# # => ["", "", "", "", "", "", "", "", "", "", "", "", "", "", ""]
|
91
|
+
#
|
92
|
+
# @example Parse pieces with all PNN modifier types
|
93
|
+
# PiecePlacement.parse("+P'-R'k")
|
94
|
+
# # => ["+P'", "-R'", "k"]
|
95
|
+
# # Where:
|
96
|
+
# # - "+P'" = enhanced state with intermediate suffix
|
97
|
+
# # - "-R'" = diminished state with intermediate suffix
|
98
|
+
# # - "k" = base piece without modifiers
|
99
|
+
def self.parse(piece_placement_str)
|
100
|
+
validate_input(piece_placement_str)
|
101
|
+
parse_structure(piece_placement_str)
|
237
102
|
end
|
238
103
|
|
239
|
-
# Validates the
|
104
|
+
# Validates the input string for basic requirements
|
240
105
|
#
|
241
|
-
#
|
106
|
+
# Ensures the input is a non-empty string containing only valid FEEN characters.
|
107
|
+
# Valid characters include: letters (a-z, A-Z), digits (0-9), and modifiers (+, -, ').
|
108
|
+
#
|
109
|
+
# @param str [String] Input string to validate
|
242
110
|
# @raise [ArgumentError] If the string is invalid
|
243
111
|
# @return [void]
|
244
|
-
|
112
|
+
#
|
113
|
+
# @example Valid input
|
114
|
+
# validate_input("rnbqkbnr/pppppppp/8/8")
|
115
|
+
# # => (no error)
|
116
|
+
#
|
117
|
+
# @example Invalid input (empty string)
|
118
|
+
# validate_input("")
|
119
|
+
# # => ArgumentError: Piece placement string cannot be empty
|
120
|
+
#
|
121
|
+
# @example Invalid input (wrong type)
|
122
|
+
# validate_input(123)
|
123
|
+
# # => ArgumentError: Piece placement must be a string, got Integer
|
124
|
+
def self.validate_input(str)
|
245
125
|
raise ArgumentError, format(ERRORS[:invalid_type], str.class) unless str.is_a?(String)
|
246
126
|
raise ArgumentError, ERRORS[:empty_string] if str.empty?
|
247
127
|
|
248
|
-
#
|
249
|
-
|
250
|
-
end
|
128
|
+
# Basic format validation
|
129
|
+
return if str.match?(%r{\A[a-zA-Z0-9+\-'/]+\z})
|
251
130
|
|
252
|
-
|
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
|
131
|
+
raise ArgumentError, ERRORS[:invalid_format]
|
263
132
|
end
|
264
133
|
|
265
|
-
#
|
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
|
281
|
-
|
282
|
-
# Split the string by this separator depth
|
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
|
302
|
-
end
|
303
|
-
|
304
|
-
# Validates that all parts are consistent in structure
|
134
|
+
# Parses the structure recursively
|
305
135
|
#
|
306
|
-
#
|
307
|
-
#
|
308
|
-
#
|
309
|
-
#
|
310
|
-
|
311
|
-
|
312
|
-
|
313
|
-
|
314
|
-
|
315
|
-
|
316
|
-
|
317
|
-
|
318
|
-
|
319
|
-
|
320
|
-
|
321
|
-
|
322
|
-
|
323
|
-
|
324
|
-
|
325
|
-
|
326
|
-
|
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
|
136
|
+
# Determines the dimensionality of the board by analyzing separator patterns
|
137
|
+
# and recursively parses nested structures. Uses the longest separator sequence
|
138
|
+
# to determine the highest dimension level.
|
139
|
+
#
|
140
|
+
# @param str [String] FEEN piece placement string
|
141
|
+
# @return [Array] Parsed structure (1D array for ranks, nested arrays for higher dimensions)
|
142
|
+
#
|
143
|
+
# @example 1D structure (single rank)
|
144
|
+
# parse_structure("rnbq")
|
145
|
+
# # => ["r", "n", "b", "q"]
|
146
|
+
#
|
147
|
+
# @example 2D structure (multiple ranks)
|
148
|
+
# parse_structure("rn/pq")
|
149
|
+
# # => [["r", "n"], ["p", "q"]]
|
150
|
+
#
|
151
|
+
# @example 3D structure (multiple planes)
|
152
|
+
# parse_structure("r/p//R/P")
|
153
|
+
# # => [[["r"], ["p"]], [["R"], ["P"]]]
|
154
|
+
private_class_method def self.parse_structure(str)
|
155
|
+
# Handle trailing separators
|
156
|
+
raise ArgumentError, ERRORS[:invalid_format] if str.end_with?(DIMENSION_SEPARATOR)
|
334
157
|
|
335
|
-
|
158
|
+
# Find the longest separator sequence to determine dimension depth
|
159
|
+
separators = str.scan(%r{/+}).uniq.sort_by(&:length).reverse
|
336
160
|
|
337
|
-
|
338
|
-
next if index == 0 # Skip first part (already checked)
|
161
|
+
return parse_rank(str) if separators.empty?
|
339
162
|
|
340
|
-
|
341
|
-
|
163
|
+
# Use the longest separator to split at the highest dimension
|
164
|
+
main_separator = separators.first
|
165
|
+
parts = smart_split(str, main_separator)
|
342
166
|
|
343
|
-
|
344
|
-
|
345
|
-
expected_size,
|
346
|
-
size,
|
347
|
-
part
|
348
|
-
)
|
349
|
-
end
|
167
|
+
# Recursively parse each part
|
168
|
+
parts.map { |part| parse_structure(part) }
|
350
169
|
end
|
351
170
|
|
352
|
-
# Splits
|
171
|
+
# Splits string by separator while preserving shorter separators
|
172
|
+
#
|
173
|
+
# Intelligently splits a string by a specific separator pattern while
|
174
|
+
# ensuring that shorter separator patterns within the string are preserved
|
175
|
+
# for recursive parsing of nested dimensions.
|
353
176
|
#
|
354
177
|
# @param str [String] String to split
|
355
|
-
# @param separator [String] Separator to split by (e.g., "/", "//")
|
356
|
-
# @return [Array<String>]
|
357
|
-
|
178
|
+
# @param separator [String] Separator to split by (e.g., "/", "//", "///")
|
179
|
+
# @return [Array<String>] Split parts, with empty parts removed
|
180
|
+
#
|
181
|
+
# @example Split by single separator
|
182
|
+
# smart_split("a/b/c", "/")
|
183
|
+
# # => ["a", "b", "c"]
|
184
|
+
#
|
185
|
+
# @example Split by double separator, preserving single separators
|
186
|
+
# smart_split("a/b//c/d", "//")
|
187
|
+
# # => ["a/b", "c/d"]
|
188
|
+
private_class_method def self.smart_split(str, separator)
|
358
189
|
return [str] unless str.include?(separator)
|
359
190
|
|
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
|
191
|
+
parts = str.split(separator)
|
192
|
+
parts.reject(&:empty?)
|
394
193
|
end
|
395
194
|
|
396
195
|
# Parses a rank (sequence of cells)
|
397
196
|
#
|
398
|
-
#
|
399
|
-
#
|
400
|
-
|
401
|
-
return [] if str.nil? || str.empty?
|
402
|
-
|
403
|
-
parse_rank_recursive(str, 0, [])
|
404
|
-
end
|
405
|
-
|
406
|
-
# Recursively parses a rank string
|
197
|
+
# Processes a 1D sequence of cells, expanding numeric values to empty squares
|
198
|
+
# and extracting pieces with their PNN modifiers. Numbers represent consecutive
|
199
|
+
# empty squares, while letters (with optional modifiers) represent pieces.
|
407
200
|
#
|
408
201
|
# @param str [String] FEEN rank string
|
409
|
-
# @
|
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
|
202
|
+
# @return [Array<String>] Array of cells (empty strings for empty squares, piece strings for pieces)
|
431
203
|
#
|
432
|
-
# @
|
433
|
-
#
|
434
|
-
#
|
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
|
443
|
-
|
444
|
-
{
|
445
|
-
count: empty_count.to_i,
|
446
|
-
next_index: current_index
|
447
|
-
}
|
448
|
-
end
|
449
|
-
|
450
|
-
# Extracts a piece from the rank string
|
204
|
+
# @example Simple pieces
|
205
|
+
# parse_rank("rnbq")
|
206
|
+
# # => ["r", "n", "b", "q"]
|
451
207
|
#
|
452
|
-
# @
|
453
|
-
#
|
454
|
-
#
|
455
|
-
#
|
456
|
-
|
457
|
-
|
458
|
-
|
459
|
-
|
460
|
-
|
461
|
-
|
462
|
-
|
463
|
-
|
464
|
-
|
465
|
-
|
466
|
-
# Ensure there's a piece identifier after the prefix
|
467
|
-
if current_index >= str.length || !str[current_index].match?(/[a-zA-Z]/)
|
468
|
-
raise ArgumentError, ERRORS[:invalid_prefix]
|
469
|
-
end
|
470
|
-
|
471
|
-
char = str[current_index]
|
472
|
-
end
|
208
|
+
# @example Mixed pieces and empty squares
|
209
|
+
# parse_rank("r2k1r")
|
210
|
+
# # => ["r", "", "", "k", "", "r"]
|
211
|
+
#
|
212
|
+
# @example All empty squares
|
213
|
+
# parse_rank("8")
|
214
|
+
# # => ["", "", "", "", "", "", "", ""]
|
215
|
+
#
|
216
|
+
# @example Pieces with modifiers
|
217
|
+
# parse_rank("+P-R'")
|
218
|
+
# # => ["+P", "-R'"]
|
219
|
+
private_class_method def self.parse_rank(str)
|
220
|
+
return [] if str.empty?
|
473
221
|
|
474
|
-
|
475
|
-
|
222
|
+
cells = []
|
223
|
+
i = 0
|
476
224
|
|
477
|
-
|
478
|
-
|
225
|
+
while i < str.length
|
226
|
+
char = str[i]
|
227
|
+
|
228
|
+
case char
|
229
|
+
when /[1-9]/
|
230
|
+
# Parse number for empty cells
|
231
|
+
number_str = ""
|
232
|
+
while i < str.length && str[i].match?(/[0-9]/)
|
233
|
+
number_str += str[i]
|
234
|
+
i += 1
|
235
|
+
end
|
479
236
|
|
480
|
-
|
481
|
-
|
482
|
-
|
483
|
-
|
237
|
+
# Add empty cells
|
238
|
+
empty_count = number_str.to_i
|
239
|
+
cells.concat(Array.new(empty_count, ""))
|
240
|
+
when /[a-zA-Z+\-']/
|
241
|
+
# Parse piece
|
242
|
+
piece = extract_piece(str, i)
|
243
|
+
cells << piece[:piece]
|
244
|
+
i = piece[:next_index]
|
245
|
+
else
|
246
|
+
raise ArgumentError, ERRORS[:invalid_format]
|
247
|
+
end
|
484
248
|
end
|
485
249
|
|
486
|
-
|
487
|
-
piece: piece_string,
|
488
|
-
next_index: current_index
|
489
|
-
}
|
250
|
+
cells
|
490
251
|
end
|
491
252
|
|
492
|
-
#
|
253
|
+
# Extracts a piece starting at given position
|
493
254
|
#
|
494
|
-
#
|
495
|
-
#
|
496
|
-
|
497
|
-
calculate_rank_size_recursive(rank_str, 0, 0)
|
498
|
-
end
|
499
|
-
|
500
|
-
# Recursively calculates the size of a rank
|
255
|
+
# Parses a piece identifier with optional PNN modifiers starting at the specified
|
256
|
+
# position in the string. Handles prefix modifiers (+, -), the required letter,
|
257
|
+
# and suffix modifiers (').
|
501
258
|
#
|
502
|
-
# @param str [String]
|
503
|
-
# @param
|
504
|
-
# @
|
505
|
-
#
|
506
|
-
|
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
|
-
end
|
524
|
-
end
|
525
|
-
|
526
|
-
# Finds the end position of a piece in the string
|
259
|
+
# @param str [String] String to parse
|
260
|
+
# @param start_index [Integer] Starting position in the string
|
261
|
+
# @return [Hash] Hash with :piece and :next_index keys
|
262
|
+
# - :piece [String] The complete piece identifier with modifiers
|
263
|
+
# - :next_index [Integer] Position after the piece in the string
|
527
264
|
#
|
528
|
-
# @
|
529
|
-
#
|
530
|
-
#
|
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
|
-
|
540
|
-
# Skip suffix if present
|
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
|
265
|
+
# @example Extract simple piece
|
266
|
+
# extract_piece("Kqr", 0)
|
267
|
+
# # => { piece: "K", next_index: 1 }
|
547
268
|
#
|
548
|
-
# @
|
549
|
-
#
|
550
|
-
#
|
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)
|
557
|
-
end
|
558
|
-
|
559
|
-
# Validates that all elements at the same level have the same structure
|
269
|
+
# @example Extract piece with prefix modifier
|
270
|
+
# extract_piece("+Pqr", 0)
|
271
|
+
# # => { piece: "+P", next_index: 2 }
|
560
272
|
#
|
561
|
-
# @
|
562
|
-
#
|
563
|
-
#
|
564
|
-
|
565
|
-
|
566
|
-
|
567
|
-
|
568
|
-
|
569
|
-
|
570
|
-
|
571
|
-
|
572
|
-
|
573
|
-
|
574
|
-
|
575
|
-
|
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
|
584
|
-
|
585
|
-
# Recursively validate each sub-structure
|
586
|
-
validate_dimensional_consistency(subarray)
|
587
|
-
end
|
588
|
-
end
|
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 ? "..." : "")
|
273
|
+
# @example Extract piece with suffix modifier
|
274
|
+
# extract_piece("K'qr", 0)
|
275
|
+
# # => { piece: "K'", next_index: 2 }
|
276
|
+
#
|
277
|
+
# @example Extract piece with both prefix and suffix modifiers
|
278
|
+
# extract_piece("+P'qr", 0)
|
279
|
+
# # => { piece: "+P'", next_index: 3 }
|
280
|
+
private_class_method def self.extract_piece(str, start_index)
|
281
|
+
piece = ""
|
282
|
+
i = start_index
|
283
|
+
|
284
|
+
# Optional prefix
|
285
|
+
if i < str.length && ["+", "-"].include?(str[i])
|
286
|
+
piece += str[i]
|
287
|
+
i += 1
|
601
288
|
end
|
602
|
-
end
|
603
289
|
|
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
|
290
|
+
# Required letter
|
291
|
+
raise ArgumentError, ERRORS[:invalid_format] unless i < str.length && str[i].match?(/[a-zA-Z]/)
|
611
292
|
|
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
|
293
|
+
piece += str[i]
|
294
|
+
i += 1
|
619
295
|
|
620
|
-
|
621
|
-
|
622
|
-
|
296
|
+
# Optional suffix
|
297
|
+
if i < str.length && str[i] == "'"
|
298
|
+
piece += str[i]
|
299
|
+
i += 1
|
623
300
|
end
|
624
301
|
|
625
|
-
|
626
|
-
result += empty_count.to_s if empty_count > 0
|
627
|
-
|
628
|
-
result
|
302
|
+
{ piece: piece, next_index: i }
|
629
303
|
end
|
630
304
|
end
|
631
305
|
end
|