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.
- checksums.yaml +4 -4
- data/README.md +243 -418
- data/lib/sashite/feen/dumper/hands.rb +105 -0
- data/lib/sashite/feen/dumper/piece_placement.rb +75 -143
- data/lib/sashite/feen/dumper/style_turn.rb +15 -30
- data/lib/sashite/feen/dumper.rb +12 -42
- data/lib/sashite/feen/error.rb +5 -73
- data/lib/sashite/feen/errors/cardinality_error.rb +18 -0
- data/lib/sashite/feen/errors/hands_error.rb +20 -0
- data/lib/sashite/feen/errors/parse_error.rb +20 -0
- data/lib/sashite/feen/errors/piece_placement_error.rb +25 -0
- data/lib/sashite/feen/errors/style_turn_error.rb +18 -0
- data/lib/sashite/feen/parser/hand.rb +243 -0
- data/lib/sashite/feen/parser/hands.rb +102 -0
- data/lib/sashite/feen/parser/piece_placement.rb +422 -311
- data/lib/sashite/feen/parser/style_turn.rb +80 -76
- data/lib/sashite/feen/parser.rb +187 -56
- data/lib/sashite/feen/shared/ascii.rb +52 -0
- data/lib/sashite/feen/shared/limits.rb +24 -0
- data/lib/sashite/feen/shared/separators.rb +18 -0
- data/lib/sashite/feen.rb +23 -50
- data/lib/sashite-feen.rb +0 -11
- metadata +22 -38
- data/LICENSE.md +0 -21
- data/lib/sashite/feen/dumper/pieces_in_hand.rb +0 -149
- data/lib/sashite/feen/hands.rb +0 -80
- data/lib/sashite/feen/parser/pieces_in_hand.rb +0 -243
- data/lib/sashite/feen/placement.rb +0 -307
- data/lib/sashite/feen/position.rb +0 -77
- data/lib/sashite/feen/styles.rb +0 -72
|
@@ -1,358 +1,469 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "../
|
|
4
|
-
require_relative "../
|
|
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
|
|
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
|
-
#
|
|
20
|
-
#
|
|
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
|
-
# @
|
|
17
|
+
# @api private
|
|
23
18
|
module PiecePlacement
|
|
24
|
-
#
|
|
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
|
-
# @
|
|
47
|
-
#
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
76
|
-
# @return [
|
|
77
|
-
#
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
93
|
-
max_consecutive + 1
|
|
50
|
+
raise_specific_error!(input)
|
|
94
51
|
end
|
|
95
52
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
306
|
-
|
|
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
|
-
|
|
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
|
-
|
|
315
|
-
|
|
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
|
-
|
|
318
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|