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.
@@ -0,0 +1,243 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../shared/ascii"
4
+ require_relative "../errors/hands_error"
5
+
6
+ module Sashite
7
+ module Feen
8
+ module Parser
9
+ # Parser for a single hand within the FEEN Hands field.
10
+ #
11
+ # Provides a dual-path API:
12
+ # - {.safe_parse} returns nil on invalid input (no exceptions)
13
+ # - {.parse} raises {HandsError} on invalid input
14
+ #
15
+ # @api private
16
+ module Hand
17
+ # Parses a single hand string, returning nil on failure.
18
+ #
19
+ # @param input [String] The hand string to parse
20
+ # @return [Array<String>, nil] Expanded piece array, or nil if invalid
21
+ def self.safe_parse(input)
22
+ return [] if input.empty?
23
+
24
+ items = []
25
+ pos = 0
26
+ len = input.bytesize
27
+ prev_count = 0
28
+ prev_key = 0
29
+ prev_piece = nil
30
+
31
+ while pos < len
32
+ # -- Extract optional count --
33
+ byte = input.getbyte(pos)
34
+
35
+ if byte >= Ascii::ZERO && byte <= Ascii::NINE
36
+ count_start = pos
37
+ pos += 1
38
+ pos += 1 while pos < len && (b = input.getbyte(pos)) && b >= Ascii::ZERO && b <= Ascii::NINE
39
+
40
+ # Leading zeros forbidden
41
+ return nil if (pos - count_start) > 1 && input.getbyte(count_start) == Ascii::ZERO
42
+
43
+ count = input.byteslice(count_start, pos - count_start).to_i
44
+
45
+ # Explicit count must be >= 2
46
+ return nil if count < 2
47
+
48
+ byte = input.getbyte(pos)
49
+ else
50
+ count = 1
51
+ end
52
+
53
+ # -- Extract EPIN token + compute sort key inline --
54
+ piece_start = pos
55
+
56
+ # Optional state modifier: - (0), + (1), none (2)
57
+ if byte == Ascii::MINUS
58
+ state = 0
59
+ pos += 1
60
+ byte = input.getbyte(pos)
61
+ elsif byte == Ascii::PLUS
62
+ state = 1
63
+ pos += 1
64
+ byte = input.getbyte(pos)
65
+ else
66
+ state = 2
67
+ end
68
+
69
+ # Required letter (inline letter? check)
70
+ return nil unless byte
71
+
72
+ lowered = byte | 0x20
73
+ return nil if lowered < Ascii::LOWER_A || lowered > Ascii::LOWER_Z
74
+
75
+ case_rank = byte < Ascii::LOWER_A ? 0 : 1 # uppercase = 0
76
+ pos += 1
77
+ byte = input.getbyte(pos)
78
+
79
+ # Optional terminal marker ^
80
+ if byte == Ascii::CARET
81
+ terminal = 1
82
+ pos += 1
83
+ byte = input.getbyte(pos)
84
+ else
85
+ terminal = 0
86
+ end
87
+
88
+ # Optional derivation marker '
89
+ if byte == Ascii::APOSTROPHE
90
+ derived = 1
91
+ pos += 1
92
+ else
93
+ derived = 0
94
+ end
95
+
96
+ piece = input.byteslice(piece_start, pos - piece_start)
97
+
98
+ # Sort key encodes all 5 ordering criteria into a single integer:
99
+ # bits 9..5: base letter (a=1..z=26)
100
+ # bit 4: case rank (0=upper, 1=lower)
101
+ # bits 3..2: state (0=diminished, 1=enhanced, 2=normal)
102
+ # bit 1: terminal (0=absent, 1=present)
103
+ # bit 0: derived (0=absent, 1=present)
104
+ sort_key = ((lowered & 0x1F) << 5) | (case_rank << 4) | (state << 2) | (terminal << 1) | derived
105
+
106
+ # -- Inline aggregation + canonical order check --
107
+ if prev_piece
108
+ return nil if piece == prev_piece # not aggregated
109
+
110
+ # Canonical order: count descending, then sort_key ascending
111
+ if prev_count == count
112
+ return nil if prev_key >= sort_key
113
+ else
114
+ return nil if prev_count < count
115
+ end
116
+ end
117
+
118
+ items << [count, piece]
119
+ prev_count = count
120
+ prev_key = sort_key
121
+ prev_piece = piece
122
+ end
123
+
124
+ # Expand into flat array
125
+ result = []
126
+ items.each { |c, p| c.times { result << p } }
127
+ result
128
+ end
129
+
130
+ # Parses a single hand string, raising on failure.
131
+ #
132
+ # @param input [String] The hand string to parse
133
+ # @return [Array<String>] Expanded piece array
134
+ # @raise [HandsError] If the input is not valid
135
+ def self.parse(input)
136
+ result = safe_parse(input)
137
+ return result unless result.nil?
138
+
139
+ raise_specific_error!(input)
140
+ end
141
+
142
+ class << self
143
+ private
144
+
145
+ # Re-validates to produce the specific error message.
146
+ # Only called on the error path (cold).
147
+ def raise_specific_error!(input)
148
+ items = []
149
+ pos = 0
150
+ len = input.bytesize
151
+
152
+ while pos < len
153
+ byte = input.getbyte(pos)
154
+
155
+ # Extract count
156
+ if byte >= Ascii::ZERO && byte <= Ascii::NINE
157
+ count_start = pos
158
+ pos += 1
159
+ pos += 1 while pos < len && (b = input.getbyte(pos)) && b >= Ascii::ZERO && b <= Ascii::NINE
160
+
161
+ count_str = input.byteslice(count_start, pos - count_start)
162
+ if count_str.bytesize > 1 && count_str.getbyte(0) == Ascii::ZERO
163
+ raise HandsError, HandsError::INVALID_COUNT
164
+ end
165
+
166
+ count = count_str.to_i
167
+ raise HandsError, HandsError::INVALID_COUNT if count < 2
168
+
169
+ byte = input.getbyte(pos)
170
+ else
171
+ count = 1
172
+ end
173
+
174
+ # Extract EPIN token
175
+ piece_start = pos
176
+
177
+ if byte == Ascii::PLUS || byte == Ascii::MINUS
178
+ pos += 1
179
+ byte = input.getbyte(pos)
180
+ end
181
+
182
+ if byte && (byte | 0x20) >= Ascii::LOWER_A && (byte | 0x20) <= Ascii::LOWER_Z
183
+ pos += 1
184
+ byte = input.getbyte(pos)
185
+ end
186
+
187
+ byte == Ascii::CARET && (pos += 1) && (byte = input.getbyte(pos))
188
+ byte == Ascii::APOSTROPHE && (pos += 1)
189
+
190
+ piece = input.byteslice(piece_start, pos - piece_start)
191
+ raise HandsError, HandsError::INVALID_PIECE_TOKEN if piece.empty? || !valid_epin?(piece)
192
+
193
+ items << [count, piece]
194
+ end
195
+
196
+ # Check aggregation
197
+ if items.size > 1
198
+ seen = {}
199
+ items.each do |_c, p|
200
+ raise HandsError, HandsError::NOT_AGGREGATED if seen[p]
201
+
202
+ seen[p] = true
203
+ end
204
+ end
205
+
206
+ # Check canonical order
207
+ raise HandsError, HandsError::NOT_CANONICAL
208
+ end
209
+
210
+ # Inline EPIN validation: [-+]?[A-Za-z]^?'?
211
+ def valid_epin?(str)
212
+ pos = 0
213
+ byte = str.getbyte(pos)
214
+
215
+ if byte == Ascii::PLUS || byte == Ascii::MINUS
216
+ pos += 1
217
+ byte = str.getbyte(pos)
218
+ end
219
+
220
+ return false unless byte && (byte | 0x20) >= Ascii::LOWER_A && (byte | 0x20) <= Ascii::LOWER_Z
221
+
222
+ pos += 1
223
+ byte = str.getbyte(pos)
224
+
225
+ if byte == Ascii::CARET
226
+ pos += 1
227
+ byte = str.getbyte(pos)
228
+ end
229
+
230
+ pos += 1 if byte == Ascii::APOSTROPHE
231
+
232
+ pos == str.bytesize
233
+ end
234
+ end
235
+
236
+ private_class_method :raise_specific_error!,
237
+ :valid_epin?
238
+
239
+ freeze
240
+ end
241
+ end
242
+ end
243
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../shared/ascii"
4
+ require_relative "../errors/hands_error"
5
+ require_relative "hand"
6
+
7
+ module Sashite
8
+ module Feen
9
+ module Parser
10
+ # Parser for the FEEN Hands field (Field 2).
11
+ #
12
+ # Format: <FIRST-HAND>/<SECOND-HAND>
13
+ # Exactly one "/" delimiter must be present.
14
+ #
15
+ # Provides a dual-path API:
16
+ # - {.safe_parse} returns nil on invalid input (no exceptions)
17
+ # - {.parse} raises {HandsError} on invalid input
18
+ #
19
+ # @api private
20
+ module Hands
21
+ # Parses the Hands field, returning nil on failure.
22
+ #
23
+ # @param input [String] The hands field string
24
+ # @return [Hash{Symbol => Array<String>}, nil] Parsed hands or nil
25
+ def self.safe_parse(input)
26
+ # Find the single "/" delimiter by scanning bytes
27
+ slash_pos = nil
28
+ i = 0
29
+ len = input.bytesize
30
+
31
+ while i < len
32
+ if input.getbyte(i) == Ascii::SLASH
33
+ return nil if slash_pos # second slash => invalid
34
+
35
+ slash_pos = i
36
+ end
37
+ i += 1
38
+ end
39
+
40
+ return nil unless slash_pos # no slash => invalid
41
+
42
+ first_str = slash_pos == 0 ? "" : input.byteslice(0, slash_pos)
43
+ second_str = slash_pos == len - 1 ? "" : input.byteslice(slash_pos + 1, len - slash_pos - 1)
44
+
45
+ first = Hand.safe_parse(first_str)
46
+ return nil if first.nil?
47
+
48
+ second = Hand.safe_parse(second_str)
49
+ return nil if second.nil?
50
+
51
+ { first: first, second: second }
52
+ end
53
+
54
+ # Parses the Hands field, raising on failure.
55
+ #
56
+ # @param input [String] The hands field string
57
+ # @return [Hash{Symbol => Array<String>}] Parsed hands
58
+ # @raise [HandsError] If the input is not valid
59
+ def self.parse(input)
60
+ result = safe_parse(input)
61
+ return result unless result.nil?
62
+
63
+ raise_specific_error!(input)
64
+ end
65
+
66
+ class << self
67
+ private
68
+
69
+ # Re-validates to produce the specific error message.
70
+ def raise_specific_error!(input)
71
+ # Validate delimiter
72
+ slash_pos = nil
73
+ i = 0
74
+ len = input.bytesize
75
+
76
+ while i < len
77
+ if input.getbyte(i) == Ascii::SLASH
78
+ raise HandsError, HandsError::INVALID_DELIMITER if slash_pos
79
+
80
+ slash_pos = i
81
+ end
82
+ i += 1
83
+ end
84
+
85
+ raise HandsError, HandsError::INVALID_DELIMITER unless slash_pos
86
+
87
+ first_str = slash_pos == 0 ? "" : input.byteslice(0, slash_pos)
88
+ second_str = slash_pos == len - 1 ? "" : input.byteslice(slash_pos + 1, len - slash_pos - 1)
89
+
90
+ # Delegate to Hand.parse for specific error
91
+ Hand.parse(first_str)
92
+ Hand.parse(second_str)
93
+ end
94
+ end
95
+
96
+ private_class_method :raise_specific_error!
97
+
98
+ freeze
99
+ end
100
+ end
101
+ end
102
+ end