sashite-feen 0.1.0 → 0.3.0

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.
@@ -1,152 +1,358 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../error"
4
+ require_relative "../placement"
5
+
6
+ require "sashite/epin"
7
+
3
8
  module Sashite
4
9
  module Feen
5
10
  module Parser
11
+ # Parser for the piece placement field (first field of FEEN).
12
+ #
13
+ # Converts a FEEN piece placement string into a Placement object,
14
+ # decoding board configuration from EPIN notation with:
15
+ # - Empty square compression (numbers → consecutive nils)
16
+ # - Multi-dimensional separator preservation (exact "/" counts)
17
+ # - Support for any irregular board structure
18
+ #
19
+ # The parser preserves the exact separator structure, enabling
20
+ # perfect round-trip conversion (parse → dump → parse).
21
+ #
22
+ # @see https://sashite.dev/specs/feen/1.0.0/
6
23
  module PiecePlacement
7
- module_function
8
-
9
- # Parse the piece placement field into a Placement value object.
10
- #
11
- # Grammar (pragmatic):
12
- # placement := rank ( '/' rank | newline rank )*
13
- # rank := ( int | '.' | epin | sep )*
14
- # int := [1-9][0-9]* # run-length of empty cells
15
- # sep := (',' | whitespace)*
16
- # epin := bracketed_epin | bare_epin
17
- # bracketed_epin := '[' ... ']' # balanced brackets
18
- # bare_epin := /[A-Za-z0-9:+\-^~@']+/
19
- #
20
- # @param field [String]
21
- # @return [Sashite::Feen::Placement]
22
- def parse(field)
23
- src = String(field)
24
- raise Error::Syntax, "empty piece placement field" if src.strip.empty?
25
-
26
- ranks = src.split(%r{(?:/|\R)}).map(&:strip)
27
- raise Error::Syntax, "no ranks in piece placement" if ranks.empty?
28
-
29
- grid = []
30
- width = nil
31
-
32
- ranks.each_with_index do |rank, r_idx|
33
- row = _parse_rank(rank, r_idx)
34
- width ||= row.length
35
- raise Error::Bounds, "rank #{r_idx + 1} has zero width" if width.zero?
36
-
37
- if row.length != width
38
- raise Error::Bounds,
39
- "inconsistent rank width at rank #{r_idx + 1} (expected #{width}, got #{row.length})"
40
- end
41
- grid << row.freeze
24
+ # Rank separator character.
25
+ RANK_SEPARATOR = "/"
26
+
27
+ # Pattern to match EPIN pieces (optional state, letter, optional derivation).
28
+ EPIN_PATTERN = /\A[-+]?[A-Za-z]'?\z/
29
+
30
+ # Parse a FEEN piece placement string into a Placement object.
31
+ #
32
+ # Supports any valid FEEN structure:
33
+ # - 1D: Single rank, no separators (e.g., "K2P")
34
+ # - 2D: Ranks separated by "/" (e.g., "8/8/8")
35
+ # - 3D+: Ranks separated by multiple "/" (e.g., "5/5//5/5")
36
+ # - Irregular: Any combination of rank sizes and separators
37
+ #
38
+ # @param string [String] FEEN piece placement field string
39
+ # @return [Placement] Parsed placement object
40
+ # @raise [Error::Syntax] If placement format is invalid
41
+ # @raise [Error::Piece] If EPIN notation is invalid
42
+ #
43
+ # @example Chess starting position
44
+ # parse("+rnbq+kbn+r/+p+p+p+p+p+p+p+p/8/8/8/8/+P+P+P+P+P+P+P+P/+RNBQ+KBN+R")
45
+ #
46
+ # @example Empty 8x8 board
47
+ # parse("8/8/8/8/8/8/8/8")
48
+ #
49
+ # @example 1D board
50
+ # parse("K2P3k")
51
+ #
52
+ # @example Irregular structure
53
+ # parse("99999/3///K/k//r")
54
+ def self.parse(string)
55
+ # Detect dimension before parsing
56
+ dimension = detect_dimension(string)
57
+
58
+ # Handle 1D case (no separators)
59
+ if dimension == 1
60
+ rank = parse_rank(string)
61
+ return Placement.new([rank], [], 1)
42
62
  end
43
63
 
44
- raise Error::Bounds, "empty grid" if grid.empty?
64
+ # Parse multi-dimensional structure with separators
65
+ ranks, separators = parse_with_separators(string)
45
66
 
46
- Placement.new(grid.freeze)
67
+ Placement.new(ranks, separators, dimension)
47
68
  end
48
69
 
49
- # -- internals ---------------------------------------------------------
70
+ # Detect board dimensionality from separator patterns.
71
+ #
72
+ # Scans for consecutive "/" characters and returns:
73
+ # 1 + (maximum consecutive "/" count)
74
+ #
75
+ # @param string [String] Piece placement string
76
+ # @return [Integer] Board dimension (minimum 1)
77
+ #
78
+ # @example 1D board (no separators)
79
+ # detect_dimension("K2P") # => 1
80
+ #
81
+ # @example 2D board (single "/")
82
+ # detect_dimension("8/8") # => 2
83
+ #
84
+ # @example 3D board (contains "//")
85
+ # detect_dimension("5/5/5//5/5/5") # => 3
86
+ #
87
+ # @example 4D board (contains "///")
88
+ # detect_dimension("2/2///2/2") # => 4
89
+ private_class_method def self.detect_dimension(string)
90
+ return 1 unless string.include?(RANK_SEPARATOR)
50
91
 
51
- # Accepts:
52
- # - digits => run of empties
53
- # - '.' => single empty
54
- # - '['...']' => EPIN token (balanced)
55
- # - bare token composed of epin-safe chars
56
- # - commas/whitespace ignored
57
- def _parse_rank(rank_str, r_idx)
58
- i = 0
59
- n = rank_str.length
60
- cells = []
92
+ max_consecutive = string.scan(%r{/+}).map(&:length).max || 0
93
+ max_consecutive + 1
94
+ end
61
95
 
62
- while i < n
63
- ch = rank_str[i]
96
+ # Parse string while preserving exact separators.
97
+ #
98
+ # Uses split with capture group to preserve both ranks and separators.
99
+ # The regex /(\/+)/ captures one or more consecutive "/" characters.
100
+ #
101
+ # Result pattern: [rank, separator, rank, separator, ..., rank]
102
+ # - Even indices (0, 2, 4, ...) are ranks
103
+ # - Odd indices (1, 3, 5, ...) are separators
104
+ #
105
+ # Empty strings are parsed as empty ranks (valid in FEEN).
106
+ #
107
+ # @param string [String] Piece placement string
108
+ # @return [Array<Array<Array>, Array<String>>] [ranks, separators]
109
+ #
110
+ # @example Simple split
111
+ # parse_with_separators("K/Q/R")
112
+ # # => [[[K], [Q], [R]], ["/", "/"]]
113
+ #
114
+ # @example Multi-dimensional split
115
+ # parse_with_separators("K//Q/R")
116
+ # # => [[[K], [Q], [R]], ["//", "/"]]
117
+ #
118
+ # @example Trailing separator (empty rank at end)
119
+ # parse_with_separators("K///")
120
+ # # => [[[K], []], ["///"]]
121
+ #
122
+ # @example Leading separator (empty rank at start)
123
+ # parse_with_separators("///K")
124
+ # # => [[[], [K]], ["///"]]
125
+ private_class_method def self.parse_with_separators(string)
126
+ ranks = []
127
+ separators = []
64
128
 
65
- # Skip separators
66
- if ch == "," || ch =~ /\s/
67
- i += 1
68
- next
69
- end
129
+ # Split with capture group to preserve separators
130
+ # Use limit=-1 to include trailing empty substrings
131
+ parts = string.split(%r{(/+)}, -1)
70
132
 
71
- # Dot => single empty
72
- if ch == "."
73
- cells << nil
74
- i += 1
75
- next
133
+ parts.each_with_index do |part, idx|
134
+ if idx.even?
135
+ # Even index = rank content (can be empty string)
136
+ ranks << parse_rank(part)
137
+ else
138
+ # Odd index = separator
139
+ separators << part
76
140
  end
141
+ end
77
142
 
78
- # Number => run of empties
79
- if /\d/.match?(ch)
80
- j = i + 1
81
- j += 1 while j < n && rank_str[j] =~ /\d/
82
- count = rank_str[i...j].to_i
83
- raise Error::Count, "empty run must be >= 1 at rank #{r_idx + 1}" if count <= 0
143
+ [ranks, separators]
144
+ end
84
145
 
85
- cells.concat([nil] * count)
86
- i = j
87
- next
88
- end
146
+ # Parse a single rank string into an array of pieces and nils.
147
+ #
148
+ # Processes rank content character by character:
149
+ # - Empty string → empty rank (empty array)
150
+ # - Digits (1-9) start a number → count of empty squares (nils)
151
+ # - Letters (A-Z, a-z) start EPIN → piece object
152
+ # - Numbers are parsed greedily (123 = one hundred twenty-three)
153
+ #
154
+ # @param rank_str [String] Single rank string (e.g., "rnbqkbnr" or "4p3" or "")
155
+ # @return [Array] Array containing piece objects and nils (empty if rank_str is empty)
156
+ # @raise [Error::Syntax] If rank format is invalid
157
+ # @raise [Error::Piece] If EPIN notation is invalid
158
+ #
159
+ # @example Empty rank
160
+ # parse_rank("")
161
+ # # => []
162
+ #
163
+ # @example Rank with only pieces
164
+ # parse_rank("rnbqkbnr")
165
+ # # => [r, n, b, q, k, b, n, r]
166
+ #
167
+ # @example Rank with empty squares
168
+ # parse_rank("4p3")
169
+ # # => [nil, nil, nil, nil, p, nil, nil, nil]
170
+ #
171
+ # @example Rank with large empty count
172
+ # parse_rank("100")
173
+ # # => [nil, nil, ..., nil] (100 nils)
174
+ #
175
+ # @example Mixed rank
176
+ # parse_rank("+K2+Q")
177
+ # # => [+K, nil, nil, +Q]
178
+ private_class_method def self.parse_rank(rank_str)
179
+ # Handle empty rank (valid in FEEN)
180
+ return [] if rank_str.empty?
89
181
 
90
- # Bracketed EPIN token (balanced)
91
- if ch == "["
92
- token, j = _consume_bracketed(rank_str, i, r_idx)
93
- cells << _parse_epin(token, r_idx, cells.length + 1)
94
- i = j
95
- next
96
- end
182
+ result = []
183
+ chars = rank_str.chars
184
+ i = 0
185
+
186
+ while i < chars.size
187
+ char = chars[i]
188
+
189
+ if digit?(char)
190
+ # Parse complete number (greedy)
191
+ num_str, consumed = extract_number(chars, i)
192
+ count = num_str.to_i
97
193
 
98
- # Bare EPIN token
99
- token, j = _consume_bare(rank_str, i)
100
- if token.empty?
101
- raise Error::Piece,
102
- "unexpected character #{rank_str[i].inspect} at rank #{r_idx + 1}, col #{cells.length + 1}"
194
+ validate_empty_count!(count, num_str)
195
+
196
+ # Add empty squares
197
+ count.times { result << nil }
198
+ i += consumed
199
+ else
200
+ # Parse EPIN piece notation
201
+ piece_str, consumed = extract_epin(chars, i)
202
+ piece = parse_piece(piece_str)
203
+ result << piece
204
+ i += consumed
103
205
  end
104
- cells << _parse_epin(token, r_idx, cells.length + 1)
105
- i = j
106
206
  end
107
207
 
108
- cells
208
+ result
109
209
  end
110
- module_function :_parse_rank
111
- private_class_method :_parse_rank
112
-
113
- # Consume a balanced bracketed token starting at index i (where str[i] == '[')
114
- # Returns [token_without_brackets, next_index_after_closing_bracket]
115
- def _consume_bracketed(str, i, r_idx)
116
- j = i + 1
117
- depth = 1
118
- while j < str.length && depth.positive?
119
- case str[j]
120
- when "[" then depth += 1
121
- when "]" then depth -= 1
122
- end
123
- j += 1
210
+
211
+ # Check if character is a digit (1-9 for starting digit).
212
+ #
213
+ # Note: Leading zero not allowed in FEEN numbers.
214
+ #
215
+ # @param char [String] Single character
216
+ # @return [Boolean] True if character is 1-9
217
+ private_class_method def self.digit?(char)
218
+ char >= "1" && char <= "9"
219
+ end
220
+
221
+ # Extract a complete number from character array (greedy parsing).
222
+ #
223
+ # Reads all consecutive digits starting from start_index.
224
+ # First digit must be 1-9, subsequent digits can be 0-9.
225
+ #
226
+ # @param chars [Array<String>] Array of characters
227
+ # @param start_index [Integer] Starting index
228
+ # @return [Array(String, Integer)] Number string and characters consumed
229
+ #
230
+ # @example Single digit
231
+ # extract_number(['8', 'K'], 0) # => ["8", 1]
232
+ #
233
+ # @example Multi-digit
234
+ # extract_number(['1', '2', '3', 'K'], 0) # => ["123", 3]
235
+ #
236
+ # @example Large number
237
+ # extract_number(['9', '9', '9', '9', '9'], 0) # => ["99999", 5]
238
+ private_class_method def self.extract_number(chars, start_index)
239
+ num_str = chars[start_index]
240
+ i = start_index + 1
241
+
242
+ # Continue reading digits (including 0)
243
+ while i < chars.size && chars[i] >= "0" && chars[i] <= "9"
244
+ num_str += chars[i]
245
+ i += 1
246
+ end
247
+
248
+ consumed = i - start_index
249
+ [num_str, consumed]
250
+ end
251
+
252
+ # Validate empty square count.
253
+ #
254
+ # Count must be at least 1 (FEEN doesn't allow "0" for zero squares).
255
+ #
256
+ # @param count [Integer] Parsed count value
257
+ # @param count_str [String] Original string for error message
258
+ # @raise [Error::Syntax] If count is less than 1
259
+ private_class_method def self.validate_empty_count!(count, count_str)
260
+ return if count >= 1
261
+
262
+ raise ::Sashite::Feen::Error::Syntax,
263
+ "Empty square count must be at least 1, got #{count_str}"
264
+ end
265
+
266
+ # Extract EPIN notation from character array.
267
+ #
268
+ # EPIN format: [state][letter][derivation]
269
+ # - state: optional "+" or "-" prefix
270
+ # - letter: required A-Z or a-z
271
+ # - derivation: optional "'" suffix
272
+ #
273
+ # @param chars [Array<String>] Array of characters
274
+ # @param start_index [Integer] Starting index
275
+ # @return [Array(String, Integer)] EPIN string and characters consumed
276
+ # @raise [Error::Syntax] If EPIN format is incomplete or invalid
277
+ #
278
+ # @example Simple piece
279
+ # extract_epin(['K', 'Q'], 0) # => ["K", 1]
280
+ #
281
+ # @example Enhanced piece
282
+ # extract_epin(['+', 'R', 'B'], 0) # => ["+R", 2]
283
+ #
284
+ # @example Foreign piece
285
+ # extract_epin(['K', "'", 'Q'], 0) # => ["K'", 2]
286
+ #
287
+ # @example Complex piece
288
+ # extract_epin(['-', 'p', "'", 'K'], 0) # => ["-p'", 3]
289
+ private_class_method def self.extract_epin(chars, start_index)
290
+ i = start_index
291
+ piece_chars = []
292
+
293
+ # Optional state prefix (+ or -)
294
+ if i < chars.size && ["+", "-"].include?(chars[i])
295
+ piece_chars << chars[i]
296
+ i += 1
124
297
  end
125
- raise Error::Piece, "unterminated EPIN bracket at rank #{r_idx + 1}, index #{i}" unless depth.zero?
126
298
 
127
- [str[(i + 1)...(j - 1)], j]
299
+ # Base letter (required)
300
+ if i >= chars.size || !letter?(chars[i])
301
+ raise ::Sashite::Feen::Error::Syntax,
302
+ "Expected letter in EPIN notation at position #{start_index}"
303
+ end
304
+
305
+ piece_chars << chars[i]
306
+ i += 1
307
+
308
+ # Optional derivation suffix (')
309
+ if i < chars.size && chars[i] == "'"
310
+ piece_chars << chars[i]
311
+ i += 1
312
+ end
313
+
314
+ piece_str = piece_chars.join
315
+ consumed = i - start_index
316
+
317
+ [piece_str, consumed]
128
318
  end
129
- private_class_method :_consume_bracketed
130
-
131
- # Consume a run of bare EPIN-safe characters.
132
- # We choose a wide, permissive class to avoid rejecting valid EPINs that include
133
- # promotions/suffixes: letters, digits, + : - ^ ~ @ '
134
- def _consume_bare(str, i)
135
- j = i
136
- # Autorisés : lettres + modificateurs (+ - : ^ ~ @ ')
137
- j += 1 while j < str.length && str[j] =~ /[A-Za-z:+\-^~@']/
138
- [str[i...j], j]
319
+
320
+ # Check if character is a letter.
321
+ #
322
+ # @param char [String] Single character
323
+ # @return [Boolean] True if character is A-Z or a-z
324
+ private_class_method def self.letter?(char)
325
+ (char >= "A" && char <= "Z") || (char >= "a" && char <= "z")
139
326
  end
140
327
 
141
- private_class_method :_consume_bare
328
+ # Parse EPIN string into a piece identifier object.
329
+ #
330
+ # Delegates to Sashite::Epin for actual parsing and validation.
331
+ #
332
+ # @param epin_str [String] EPIN notation string
333
+ # @return [Object] Piece identifier object (Epin::Identifier)
334
+ # @raise [Error::Piece] If EPIN is invalid or parsing fails
335
+ #
336
+ # @example Valid pieces
337
+ # parse_piece("K") # => Epin::Identifier (King)
338
+ # parse_piece("+R") # => Epin::Identifier (Enhanced Rook)
339
+ # parse_piece("-p'") # => Epin::Identifier (Diminished foreign pawn)
340
+ #
341
+ # @example Invalid piece
342
+ # parse_piece("X#") # => raises Error::Piece
343
+ private_class_method def self.parse_piece(epin_str)
344
+ # Pre-validate format
345
+ unless EPIN_PATTERN.match?(epin_str)
346
+ raise ::Sashite::Feen::Error::Piece,
347
+ "Invalid EPIN notation: #{epin_str}"
348
+ end
142
349
 
143
- def _parse_epin(token, r_idx, c_idx)
144
- ::Sashite::Epin.parse(token)
145
- rescue StandardError => e
146
- raise Error::Piece,
147
- "invalid EPIN token at (rank #{r_idx + 1}, col #{c_idx}): #{token.inspect} (#{e.message})"
350
+ # Parse using EPIN library
351
+ ::Sashite::Epin.parse(epin_str)
352
+ rescue ::StandardError => e
353
+ raise ::Sashite::Feen::Error::Piece,
354
+ "Failed to parse EPIN '#{epin_str}': #{e.message}"
148
355
  end
149
- private_class_method :_parse_epin
150
356
  end
151
357
  end
152
358
  end