sashite-feen 0.3.0 → 0.5.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,358 +1,469 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../error"
4
- require_relative "../placement"
5
-
6
- require "sashite/epin"
3
+ require_relative "../shared/ascii"
4
+ require_relative "../shared/limits"
5
+ require_relative "../errors/piece_placement_error"
7
6
 
8
7
  module Sashite
9
8
  module Feen
10
9
  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
10
+ # Parser for the FEEN Piece Placement field (Field 1).
18
11
  #
19
- # The parser preserves the exact separator structure, enabling
20
- # perfect round-trip conversion (parse dump → parse).
12
+ # Parses into a dimensioned board structure:
13
+ # - 1D: flat Array of squares
14
+ # - 2D: Array of rank Arrays
15
+ # - 3D: Array of layer Arrays of rank Arrays
21
16
  #
22
- # @see https://sashite.dev/specs/feen/1.0.0/
17
+ # @api private
23
18
  module PiecePlacement
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")
19
+ # Parses a Piece Placement field, returning nil on failure.
45
20
  #
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)
62
- end
21
+ # @param input [String] The Piece Placement field string
22
+ # @return [Array, nil] Dimensioned board structure or nil
23
+ def self.safe_parse(input)
24
+ return nil if input.empty?
63
25
 
64
- # Parse multi-dimensional structure with separators
65
- ranks, separators = parse_with_separators(string)
26
+ len = input.bytesize
27
+ return nil if input.getbyte(0) == Ascii::SLASH
28
+ return nil if input.getbyte(len - 1) == Ascii::SLASH
66
29
 
67
- Placement.new(ranks, separators, dimension)
30
+ max_sep = scan_max_separator(input, len)
31
+ dims = max_sep + 1
32
+ return nil if dims > Limits::MAX_DIMENSIONS
33
+
34
+ case dims
35
+ when 1 then parse_1d(input, len)
36
+ when 2 then parse_2d(input, len)
37
+ when 3 then parse_3d(input, len)
38
+ end
68
39
  end
69
40
 
70
- # Detect board dimensionality from separator patterns.
71
- #
72
- # Scans for consecutive "/" characters and returns:
73
- # 1 + (maximum consecutive "/" count)
41
+ # Parses a Piece Placement field, raising on failure.
74
42
  #
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)
43
+ # @param input [String] The Piece Placement field string
44
+ # @return [Array] Dimensioned board structure
45
+ # @raise [PiecePlacementError] If the input is not valid
46
+ def self.parse(input)
47
+ result = safe_parse(input)
48
+ return result unless result.nil?
91
49
 
92
- max_consecutive = string.scan(%r{/+}).map(&:length).max || 0
93
- max_consecutive + 1
50
+ raise_specific_error!(input)
94
51
  end
95
52
 
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 = []
128
-
129
- # Split with capture group to preserve separators
130
- # Use limit=-1 to include trailing empty substrings
131
- parts = string.split(%r{(/+)}, -1)
132
-
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
53
+ class << self
54
+ private
55
+
56
+ # Finds the maximum separator group length in the input.
57
+ def scan_max_separator(input, len)
58
+ max = 0
59
+ cur = 0
60
+ i = 0
61
+
62
+ while i < len
63
+ if input.getbyte(i) == Ascii::SLASH
64
+ cur += 1
65
+ else
66
+ max = cur if cur > max
67
+ cur = 0
68
+ end
69
+ i += 1
140
70
  end
71
+
72
+ max = cur if cur > max
73
+ max
141
74
  end
142
75
 
143
- [ranks, separators]
144
- end
76
+ # Parses a 1D board (no separators).
77
+ def parse_1d(input, len)
78
+ squares = parse_segment(input, 0, len)
79
+ return nil if squares.nil?
80
+ return nil if squares.size > Limits::MAX_DIMENSION_SIZE
145
81
 
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?
181
-
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
193
-
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
82
+ squares
83
+ end
84
+
85
+ # Parses a 2D board (ranks separated by "/").
86
+ # Scans for "/" byte positions and parses segments by range.
87
+ def parse_2d(input, len)
88
+ ranks = []
89
+ seg_start = 0
90
+ i = 0
91
+
92
+ while i <= len
93
+ if i == len || input.getbyte(i) == Ascii::SLASH
94
+ squares = parse_segment(input, seg_start, i)
95
+ return nil if squares.nil?
96
+ return nil if squares.size > Limits::MAX_DIMENSION_SIZE
97
+
98
+ ranks << squares
99
+ seg_start = i + 1
100
+ end
101
+ i += 1
205
102
  end
103
+
104
+ return nil if ranks.size > Limits::MAX_DIMENSION_SIZE
105
+
106
+ ranks
206
107
  end
207
108
 
208
- result
209
- end
109
+ # Parses a 3D board in a single scan.
110
+ # Detects "//" layer boundaries and "/" rank boundaries.
111
+ def parse_3d(input, len)
112
+ layers = []
113
+ ranks = []
114
+ seg_start = 0
115
+ ranks_per_layer = nil
116
+ i = 0
117
+
118
+ while i < len
119
+ if input.getbyte(i) == Ascii::SLASH
120
+ # Parse the pending segment
121
+ squares = parse_segment(input, seg_start, i)
122
+ return nil if squares.nil?
123
+ return nil if squares.size > Limits::MAX_DIMENSION_SIZE
124
+
125
+ ranks << squares
126
+
127
+ # Check for "//" (layer boundary)
128
+ if (i + 1) < len && input.getbyte(i + 1) == Ascii::SLASH
129
+ # Dimensional coherence: each layer must have >= 2 ranks
130
+ return nil if ranks.size < 2
131
+
132
+ if ranks_per_layer.nil?
133
+ ranks_per_layer = ranks.size
134
+ return nil if ranks_per_layer > Limits::MAX_DIMENSION_SIZE
135
+ else
136
+ return nil unless ranks.size == ranks_per_layer
137
+ end
138
+
139
+ layers << ranks
140
+ ranks = []
141
+ seg_start = i + 2
142
+ i += 2
143
+ else
144
+ seg_start = i + 1
145
+ i += 1
146
+ end
147
+ else
148
+ i += 1
149
+ end
150
+ end
210
151
 
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
152
+ # Last segment + last layer
153
+ squares = parse_segment(input, seg_start, len)
154
+ return nil if squares.nil?
155
+ return nil if squares.size > Limits::MAX_DIMENSION_SIZE
220
156
 
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
157
+ ranks << squares
158
+
159
+ return nil if ranks.size < 2
160
+
161
+ if ranks_per_layer.nil?
162
+ ranks_per_layer = ranks.size
163
+ return nil if ranks_per_layer > Limits::MAX_DIMENSION_SIZE
164
+ else
165
+ return nil unless ranks.size == ranks_per_layer
166
+ end
167
+
168
+ layers << ranks
169
+ return nil if layers.size > Limits::MAX_DIMENSION_SIZE
170
+
171
+ layers
246
172
  end
247
173
 
248
- consumed = i - start_index
249
- [num_str, consumed]
250
- end
174
+ # Parses a single segment (rank) by byte range [seg_start, seg_stop).
175
+ # Returns Array of (String | nil) or nil on failure.
176
+ # Inlines EPIN validation and integer conversion.
177
+ def parse_segment(input, seg_start, seg_stop)
178
+ return nil if seg_start >= seg_stop
179
+
180
+ squares = []
181
+ pos = seg_start
182
+ last_was_empty = false
183
+
184
+ while pos < seg_stop
185
+ byte = input.getbyte(pos)
186
+
187
+ if byte >= Ascii::ZERO && byte <= Ascii::NINE
188
+ # Empty count token
189
+ return nil if last_was_empty
190
+
191
+ count_start = pos
192
+ pos += 1
193
+ while pos < seg_stop
194
+ b = input.getbyte(pos)
195
+ break unless b >= Ascii::ZERO && b <= Ascii::NINE
196
+
197
+ pos += 1
198
+ end
199
+
200
+ # Leading zeros forbidden
201
+ return nil if (pos - count_start) > 1 && input.getbyte(count_start) == Ascii::ZERO
202
+
203
+ # Inline integer conversion (avoids byteslice + to_i)
204
+ count = 0
205
+ j = count_start
206
+ while j < pos
207
+ count = count * 10 + (input.getbyte(j) - Ascii::ZERO)
208
+ j += 1
209
+ end
210
+
211
+ return nil if count < 1
212
+
213
+ count.times { squares << nil }
214
+ last_was_empty = true
215
+ else
216
+ # EPIN token — inline validation: [+-]?[A-Za-z]^?'?
217
+ piece_start = pos
218
+
219
+ # Optional state modifier
220
+ if byte == Ascii::MINUS || byte == Ascii::PLUS
221
+ pos += 1
222
+ return nil if pos >= seg_stop
223
+
224
+ byte = input.getbyte(pos)
225
+ end
226
+
227
+ # Required letter (bit trick)
228
+ lowered = byte | 0x20
229
+ return nil if lowered < Ascii::LOWER_A || lowered > Ascii::LOWER_Z
230
+
231
+ pos += 1
232
+
233
+ # Optional terminal ^ then optional derivation '
234
+ if pos < seg_stop
235
+ byte = input.getbyte(pos)
236
+
237
+ if byte == Ascii::CARET
238
+ pos += 1
239
+
240
+ if pos < seg_stop && input.getbyte(pos) == Ascii::APOSTROPHE
241
+ pos += 1
242
+ end
243
+ elsif byte == Ascii::APOSTROPHE
244
+ pos += 1
245
+ end
246
+ end
247
+
248
+ squares << input.byteslice(piece_start, pos - piece_start)
249
+ last_was_empty = false
250
+ end
251
+ end
251
252
 
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
253
+ squares
254
+ end
265
255
 
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
256
+ # ----------------------------------------------------------------
257
+ # Error path (cold): re-validates for specific error messages
258
+ # ----------------------------------------------------------------
259
+
260
+ def raise_specific_error!(input)
261
+ if input.empty?
262
+ raise PiecePlacementError, PiecePlacementError::EMPTY
263
+ end
264
+
265
+ if input.getbyte(0) == Ascii::SLASH
266
+ raise PiecePlacementError, PiecePlacementError::STARTS_WITH_SEPARATOR
267
+ end
268
+
269
+ if input.getbyte(input.bytesize - 1) == Ascii::SLASH
270
+ raise PiecePlacementError, PiecePlacementError::ENDS_WITH_SEPARATOR
271
+ end
272
+
273
+ len = input.bytesize
274
+ max_sep = scan_max_separator(input, len)
275
+ dims = max_sep + 1
276
+
277
+ if dims > Limits::MAX_DIMENSIONS
278
+ raise PiecePlacementError, PiecePlacementError::EXCEEDS_MAX_DIMENSIONS
279
+ end
280
+
281
+ case dims
282
+ when 1 then raise_1d_errors!(input, len)
283
+ when 2 then raise_2d_errors!(input, len)
284
+ when 3 then raise_3d_errors!(input, len)
285
+ end
297
286
  end
298
287
 
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}"
288
+ def raise_1d_errors!(input, len)
289
+ squares = raise_parse_segment!(input, 0, len)
290
+ raise_dimension_size!(squares.size)
303
291
  end
304
292
 
305
- piece_chars << chars[i]
306
- i += 1
293
+ def raise_2d_errors!(input, len)
294
+ seg_start = 0
295
+ rank_count = 0
296
+ i = 0
297
+
298
+ while i <= len
299
+ if i == len || input.getbyte(i) == Ascii::SLASH
300
+ squares = raise_parse_segment!(input, seg_start, i)
301
+ raise_dimension_size!(squares.size)
302
+ rank_count += 1
303
+ seg_start = i + 1
304
+ end
305
+ i += 1
306
+ end
307
307
 
308
- # Optional derivation suffix (')
309
- if i < chars.size && chars[i] == "'"
310
- piece_chars << chars[i]
311
- i += 1
308
+ raise_dimension_size!(rank_count)
312
309
  end
313
310
 
314
- piece_str = piece_chars.join
315
- consumed = i - start_index
311
+ def raise_3d_errors!(input, len)
312
+ ranks_in_layer = 0
313
+ ranks_per_layer = nil
314
+ layer_count = 0
315
+ seg_start = 0
316
+ i = 0
317
+
318
+ while i < len
319
+ if input.getbyte(i) == Ascii::SLASH
320
+ squares = raise_parse_segment!(input, seg_start, i)
321
+ raise_dimension_size!(squares.size)
322
+ ranks_in_layer += 1
323
+
324
+ if (i + 1) < len && input.getbyte(i + 1) == Ascii::SLASH
325
+ if ranks_in_layer < 2
326
+ raise PiecePlacementError, PiecePlacementError::DIMENSIONAL_COHERENCE
327
+ end
328
+
329
+ if ranks_per_layer.nil?
330
+ ranks_per_layer = ranks_in_layer
331
+ raise_dimension_size!(ranks_per_layer)
332
+ elsif ranks_in_layer != ranks_per_layer
333
+ raise PiecePlacementError, PiecePlacementError::DIMENSIONAL_COHERENCE
334
+ end
335
+
336
+ layer_count += 1
337
+ ranks_in_layer = 0
338
+ seg_start = i + 2
339
+ i += 2
340
+ else
341
+ seg_start = i + 1
342
+ i += 1
343
+ end
344
+ else
345
+ i += 1
346
+ end
347
+ end
316
348
 
317
- [piece_str, consumed]
318
- end
349
+ # Last segment + last layer
350
+ squares = raise_parse_segment!(input, seg_start, len)
351
+ raise_dimension_size!(squares.size)
352
+ ranks_in_layer += 1
319
353
 
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")
326
- end
354
+ if ranks_in_layer < 2
355
+ raise PiecePlacementError, PiecePlacementError::DIMENSIONAL_COHERENCE
356
+ end
327
357
 
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}"
358
+ if ranks_per_layer && ranks_in_layer != ranks_per_layer
359
+ raise PiecePlacementError, PiecePlacementError::DIMENSIONAL_COHERENCE
360
+ end
361
+
362
+ layer_count += 1
363
+ raise_dimension_size!(layer_count)
364
+ end
365
+
366
+ # Parses a segment on the error path, raising specific errors.
367
+ def raise_parse_segment!(input, seg_start, seg_stop)
368
+ if seg_start >= seg_stop
369
+ raise PiecePlacementError, PiecePlacementError::EMPTY_SEGMENT
370
+ end
371
+
372
+ squares = []
373
+ pos = seg_start
374
+ last_was_empty = false
375
+
376
+ while pos < seg_stop
377
+ byte = input.getbyte(pos)
378
+
379
+ if byte >= Ascii::ZERO && byte <= Ascii::NINE
380
+ if last_was_empty
381
+ raise PiecePlacementError, PiecePlacementError::CONSECUTIVE_EMPTY_COUNTS
382
+ end
383
+
384
+ count_start = pos
385
+ pos += 1
386
+ while pos < seg_stop
387
+ b = input.getbyte(pos)
388
+ break unless b >= Ascii::ZERO && b <= Ascii::NINE
389
+
390
+ pos += 1
391
+ end
392
+
393
+ if (pos - count_start) > 1 && input.getbyte(count_start) == Ascii::ZERO
394
+ raise PiecePlacementError, PiecePlacementError::INVALID_EMPTY_COUNT
395
+ end
396
+
397
+ count = 0
398
+ j = count_start
399
+ while j < pos
400
+ count = count * 10 + (input.getbyte(j) - Ascii::ZERO)
401
+ j += 1
402
+ end
403
+
404
+ if count < 1
405
+ raise PiecePlacementError, PiecePlacementError::INVALID_EMPTY_COUNT
406
+ end
407
+
408
+ count.times { squares << nil }
409
+ last_was_empty = true
410
+ else
411
+ piece_start = pos
412
+
413
+ if byte == Ascii::MINUS || byte == Ascii::PLUS
414
+ pos += 1
415
+ byte = pos < seg_stop ? input.getbyte(pos) : nil
416
+ end
417
+
418
+ valid_letter = byte && (byte | 0x20) >= Ascii::LOWER_A && (byte | 0x20) <= Ascii::LOWER_Z
419
+
420
+ if valid_letter
421
+ pos += 1
422
+ if pos < seg_stop
423
+ byte = input.getbyte(pos)
424
+ if byte == Ascii::CARET
425
+ pos += 1
426
+ if pos < seg_stop && input.getbyte(pos) == Ascii::APOSTROPHE
427
+ pos += 1
428
+ end
429
+ elsif byte == Ascii::APOSTROPHE
430
+ pos += 1
431
+ end
432
+ end
433
+ end
434
+
435
+ unless valid_letter && (pos - piece_start) >= 1
436
+ raise PiecePlacementError, PiecePlacementError::INVALID_PIECE_TOKEN
437
+ end
438
+
439
+ squares << input.byteslice(piece_start, pos - piece_start)
440
+ last_was_empty = false
441
+ end
442
+ end
443
+
444
+ squares
348
445
  end
349
446
 
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}"
447
+ def raise_dimension_size!(size)
448
+ return if size <= Limits::MAX_DIMENSION_SIZE
449
+
450
+ raise PiecePlacementError, PiecePlacementError::DIMENSION_SIZE_EXCEEDED
451
+ end
355
452
  end
453
+
454
+ private_class_method :scan_max_separator,
455
+ :parse_1d,
456
+ :parse_2d,
457
+ :parse_3d,
458
+ :parse_segment,
459
+ :raise_specific_error!,
460
+ :raise_1d_errors!,
461
+ :raise_2d_errors!,
462
+ :raise_3d_errors!,
463
+ :raise_parse_segment!,
464
+ :raise_dimension_size!
465
+
466
+ freeze
356
467
  end
357
468
  end
358
469
  end