sashite-feen 0.1.0 → 0.2.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,344 @@
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 empty square
15
+ # compression and multi-dimensional separator support.
16
+ #
17
+ # @see https://sashite.dev/specs/feen/1.0.0/
6
18
  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
42
- end
19
+ # Rank separator for 2D boards.
20
+ RANK_SEPARATOR = "/"
21
+
22
+ # Pattern to match EPIN pieces (optional state prefix, letter, optional derivation suffix).
23
+ EPIN_PATTERN = /\A[-+]?[A-Za-z]'?\z/
43
24
 
44
- raise Error::Bounds, "empty grid" if grid.empty?
25
+ # Parse a FEEN piece placement string into a Placement object.
26
+ #
27
+ # @param string [String] FEEN piece placement field string
28
+ # @return [Placement] Parsed placement object
29
+ # @raise [Error::Syntax] If placement format is invalid
30
+ # @raise [Error::Piece] If EPIN notation is invalid
31
+ #
32
+ # @example Chess starting position
33
+ # 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")
34
+ #
35
+ # @example Empty 8x8 board
36
+ # parse("8/8/8/8/8/8/8/8")
37
+ def self.parse(string)
38
+ dimension = detect_dimension(string)
39
+ rank_strings, section_sizes = split_ranks(string, dimension)
40
+ ranks = rank_strings.map { |rank_str| parse_rank(rank_str) }
41
+
42
+ Placement.new(ranks, dimension, section_sizes)
43
+ end
45
44
 
46
- Placement.new(grid.freeze)
45
+ # Detect board dimensionality from separator patterns.
46
+ #
47
+ # Counts consecutive separators to determine dimension:
48
+ # - "/" = 2D
49
+ # - "//" = 3D
50
+ # - "///" = 4D
51
+ #
52
+ # @param string [String] Piece placement string
53
+ # @return [Integer] Board dimension (minimum 2)
54
+ #
55
+ # @example 2D board
56
+ # detect_dimension("8/8") # => 2
57
+ #
58
+ # @example 3D board
59
+ # detect_dimension("5/5/5//5/5/5") # => 3
60
+ private_class_method def self.detect_dimension(string)
61
+ max_consecutive = string.scan(/\/+/).map(&:length).max || 0
62
+ max_consecutive + 1
47
63
  end
48
64
 
49
- # -- internals ---------------------------------------------------------
65
+ # Split placement string into rank strings based on dimension.
66
+ #
67
+ # @param string [String] Piece placement string
68
+ # @param dimension [Integer] Board dimensionality
69
+ # @return [Array<String>, Array<Integer>] Array of rank strings and section sizes
70
+ #
71
+ # @example 1D board
72
+ # split_ranks("k+p4+PK", 1) # => [["k+p4+PK"], nil]
73
+ #
74
+ # @example 2D board
75
+ # split_ranks("8/8/8", 2) # => [["8", "8", "8"], nil]
76
+ #
77
+ # @example 3D board
78
+ # split_ranks("5/5//5/5", 3) # => [["5", "5", "5", "5"], [2, 2]]
79
+ private_class_method def self.split_ranks(string, dimension)
80
+ if dimension == 1
81
+ # 1D board: single rank, no separators
82
+ [[string], nil]
83
+ elsif dimension == 2
84
+ # 2D board: split by single separator
85
+ [string.split(RANK_SEPARATOR), nil]
86
+ else
87
+ # Multi-dimensional: split by dimension separator, track section sizes
88
+ dimension_separator = RANK_SEPARATOR * (dimension - 1)
89
+ sections = string.split(dimension_separator)
90
+
91
+ # Each section contains ranks separated by single "/"
92
+ section_sizes = []
93
+ all_ranks = sections.flat_map do |section|
94
+ ranks = section.split(RANK_SEPARATOR)
95
+ section_sizes << ranks.size
96
+ ranks
97
+ end
98
+
99
+ [all_ranks, section_sizes]
100
+ end
101
+ end
50
102
 
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)
103
+ # Parse a single rank string into an array of pieces and nils.
104
+ #
105
+ # @param rank_str [String] Single rank string (e.g., "rnbqkbnr" or "4p3")
106
+ # @return [Array] Array containing piece objects and nils
107
+ # @raise [Error::Syntax] If rank format is invalid
108
+ # @raise [Error::Piece] If EPIN notation is invalid
109
+ #
110
+ # @example Rank with pieces
111
+ # parse_rank("rnbqkbnr") # => [piece, piece, ..., piece]
112
+ #
113
+ # @example Rank with empty squares
114
+ # parse_rank("4p3") # => [nil, nil, nil, nil, piece, nil, nil, nil]
115
+ #
116
+ # @example Rank with large empty count
117
+ # parse_rank("100") # => [nil, nil, ..., nil] (100 times)
118
+ private_class_method def self.parse_rank(rank_str)
119
+ result = []
120
+ chars = rank_str.chars
58
121
  i = 0
59
- n = rank_str.length
60
- cells = []
61
122
 
62
- while i < n
63
- ch = rank_str[i]
123
+ while i < chars.size
124
+ char = chars[i]
64
125
 
65
- # Skip separators
66
- if ch == "," || ch =~ /\s/
126
+ # Skip whitespace and separators (commas)
127
+ if char =~ /\s/ || char == ","
67
128
  i += 1
68
129
  next
69
130
  end
70
131
 
71
- # Dot => single empty
72
- if ch == "."
73
- cells << nil
132
+ # Dot represents single empty square (legacy support)
133
+ if char == "."
134
+ result << nil
74
135
  i += 1
75
136
  next
76
137
  end
77
138
 
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
139
+ if first_digit?(char)
140
+ # Empty squares - extract all consecutive digits
141
+ count_str, consumed = extract_number(chars, i)
142
+ count = count_str.to_i
84
143
 
85
- cells.concat([nil] * count)
86
- i = j
87
- next
88
- end
144
+ raise ::Sashite::Feen::Error::Syntax, "invalid empty square count: #{count_str}" if count < 1
89
145
 
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
146
+ count.times { result << nil }
147
+ i += consumed
148
+ elsif char == "[" || letter?(char) || char == "+" || char == "-"
149
+ # EPIN piece notation (bare or bracketed)
150
+ piece_str, consumed = extract_epin(chars, i)
151
+ piece = parse_piece(piece_str)
152
+ result << piece
153
+ i += consumed
154
+ else
155
+ # Invalid character
156
+ raise ::Sashite::Feen::Error::Syntax,
157
+ "unexpected character #{char.inspect} at position #{i} in rank"
96
158
  end
159
+ end
97
160
 
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}"
103
- end
104
- cells << _parse_epin(token, r_idx, cells.length + 1)
105
- i = j
161
+ result
162
+ end
163
+
164
+ # Extract a complete number (all consecutive digits) from character array.
165
+ #
166
+ # Implements greedy parsing: reads all consecutive digits until a non-digit
167
+ # character is encountered.
168
+ #
169
+ # @param chars [Array<String>] Array of characters
170
+ # @param start_index [Integer] Starting index
171
+ # @return [Array(String, Integer)] Number string and number of characters consumed
172
+ #
173
+ # @example Single digit
174
+ # extract_number(['8', 'K'], 0) # => ["8", 1]
175
+ #
176
+ # @example Multi-digit number
177
+ # extract_number(['1', '2', '3', 'K'], 0) # => ["123", 3]
178
+ #
179
+ # @example Large number
180
+ # extract_number(['1', '0', '0', '/'], 0) # => ["100", 3]
181
+ private_class_method def self.extract_number(chars, start_index)
182
+ i = start_index
183
+ digits = []
184
+
185
+ # First digit must be 1-9 (non-zero)
186
+ if i < chars.size && first_digit?(chars[i])
187
+ digits << chars[i]
188
+ i += 1
106
189
  end
107
190
 
108
- cells
191
+ # Subsequent digits can be 0-9
192
+ while i < chars.size && any_digit?(chars[i])
193
+ digits << chars[i]
194
+ i += 1
195
+ end
196
+
197
+ number_str = digits.join
198
+ consumed = i - start_index
199
+
200
+ [number_str, consumed]
201
+ end
202
+
203
+ # Check if character is a non-zero digit (1-9).
204
+ # Used for the first digit of a number.
205
+ #
206
+ # @param char [String] Single character
207
+ # @return [Boolean] True if character is 1-9
208
+ private_class_method def self.first_digit?(char)
209
+ char >= "1" && char <= "9"
210
+ end
211
+
212
+ # Check if character is any digit (0-9).
213
+ # Used for subsequent digits after the first.
214
+ #
215
+ # @param char [String] Single character
216
+ # @return [Boolean] True if character is 0-9
217
+ private_class_method def self.any_digit?(char)
218
+ char >= "0" && char <= "9"
219
+ end
220
+
221
+ # Extract EPIN notation from character array.
222
+ #
223
+ # Handles state prefixes (+/-), base letter, and derivation suffix (').
224
+ # Also supports bracketed EPIN notation for legacy compatibility.
225
+ #
226
+ # @param chars [Array<String>] Array of characters
227
+ # @param start_index [Integer] Starting index
228
+ # @return [Array(String, Integer)] EPIN string and number of characters consumed
229
+ # @raise [Error::Syntax] If EPIN format is incomplete
230
+ #
231
+ # @example Simple piece
232
+ # extract_epin(['K', 'Q'], 0) # => ["K", 1]
233
+ #
234
+ # @example Enhanced piece with derivation
235
+ # extract_epin(['+', 'R', "'", 'B'], 0) # => ["+R'", 3]
236
+ #
237
+ # @example Bracketed piece (legacy)
238
+ # extract_epin(['[', 'K', ']', 'Q'], 0) # => ["K", 3]
239
+ private_class_method def self.extract_epin(chars, start_index)
240
+ i = start_index
241
+
242
+ # Check for bracketed EPIN (legacy support)
243
+ if chars[i] == "["
244
+ return extract_bracketed_epin(chars, start_index)
245
+ end
246
+
247
+ piece_chars = []
248
+
249
+ # Optional state prefix
250
+ if chars[i] == "+" || chars[i] == "-"
251
+ piece_chars << chars[i]
252
+ i += 1
253
+ end
254
+
255
+ # Base letter (required)
256
+ if i >= chars.size || !letter?(chars[i])
257
+ raise ::Sashite::Feen::Error::Syntax, "expected letter in EPIN notation at position #{start_index}"
258
+ end
259
+
260
+ piece_chars << chars[i]
261
+ i += 1
262
+
263
+ # Optional derivation suffix
264
+ if i < chars.size && chars[i] == "'"
265
+ piece_chars << chars[i]
266
+ i += 1
267
+ end
268
+
269
+ piece_str = piece_chars.join
270
+ consumed = i - start_index
271
+
272
+ [piece_str, consumed]
109
273
  end
110
- module_function :_parse_rank
111
- private_class_method :_parse_rank
112
274
 
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
275
+ # Extract bracketed EPIN notation (legacy support).
276
+ #
277
+ # Supports balanced brackets for complex piece identifiers.
278
+ #
279
+ # @param chars [Array<String>] Array of characters
280
+ # @param start_index [Integer] Starting index (should point to '[')
281
+ # @return [Array(String, Integer)] EPIN string (without brackets) and chars consumed
282
+ # @raise [Error::Syntax] If brackets are unbalanced
283
+ #
284
+ # @example
285
+ # extract_bracketed_epin(['[', 'K', 'i', 'n', 'g', ']'], 0) # => ["King", 6]
286
+ private_class_method def self.extract_bracketed_epin(chars, start_index)
287
+ i = start_index + 1 # Skip opening '['
117
288
  depth = 1
118
- while j < str.length && depth.positive?
119
- case str[j]
120
- when "[" then depth += 1
121
- when "]" then depth -= 1
289
+ content_chars = []
290
+
291
+ while i < chars.size && depth.positive?
292
+ case chars[i]
293
+ when "["
294
+ depth += 1
295
+ content_chars << chars[i] if depth > 1
296
+ when "]"
297
+ depth -= 1
298
+ content_chars << chars[i] if depth.positive?
299
+ else
300
+ content_chars << chars[i]
122
301
  end
123
- j += 1
302
+ i += 1
124
303
  end
125
- raise Error::Piece, "unterminated EPIN bracket at rank #{r_idx + 1}, index #{i}" unless depth.zero?
126
304
 
127
- [str[(i + 1)...(j - 1)], j]
305
+ if depth.positive?
306
+ raise ::Sashite::Feen::Error::Syntax,
307
+ "unterminated bracket at position #{start_index}"
308
+ end
309
+
310
+ content = content_chars.join
311
+ consumed = i - start_index
312
+
313
+ [content, consumed]
128
314
  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]
315
+
316
+ # Check if character is a letter.
317
+ #
318
+ # @param char [String] Single character
319
+ # @return [Boolean] True if character is A-Z or a-z
320
+ private_class_method def self.letter?(char)
321
+ (char >= "A" && char <= "Z") || (char >= "a" && char <= "z")
139
322
  end
140
323
 
141
- private_class_method :_consume_bare
324
+ # Parse EPIN string into a piece object.
325
+ #
326
+ # @param epin_str [String] EPIN notation string
327
+ # @return [Object] Piece identifier object
328
+ # @raise [Error::Piece] If EPIN is invalid
329
+ #
330
+ # @example
331
+ # parse_piece("K") # => Epin::Identifier
332
+ # parse_piece("+R'") # => Epin::Identifier
333
+ private_class_method def self.parse_piece(epin_str)
334
+ unless EPIN_PATTERN.match?(epin_str)
335
+ raise ::Sashite::Feen::Error::Piece, "invalid EPIN notation: #{epin_str}"
336
+ end
142
337
 
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})"
338
+ ::Sashite::Epin.parse(epin_str)
339
+ rescue ::StandardError => e
340
+ raise ::Sashite::Feen::Error::Piece, "failed to parse EPIN '#{epin_str}': #{e.message}"
148
341
  end
149
- private_class_method :_parse_epin
150
342
  end
151
343
  end
152
344
  end