sashite-pin 3.3.0 → 4.0.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/LICENSE +201 -0
- data/README.md +190 -149
- data/lib/sashite/pin/constants.rb +35 -0
- data/lib/sashite/pin/errors/argument/messages.rb +28 -0
- data/lib/sashite/pin/errors/argument.rb +16 -0
- data/lib/sashite/pin/errors.rb +3 -0
- data/lib/sashite/pin/identifier.rb +465 -0
- data/lib/sashite/pin/parser.rb +170 -0
- data/lib/sashite/pin.rb +36 -491
- metadata +15 -8
- data/LICENSE.md +0 -22
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sashite
|
|
4
|
+
module Pin
|
|
5
|
+
module Errors
|
|
6
|
+
class Argument < ::ArgumentError
|
|
7
|
+
# Centralized error messages for PIN parsing and validation.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# raise Errors::Argument, Messages::EMPTY_INPUT
|
|
11
|
+
module Messages
|
|
12
|
+
# Parsing errors
|
|
13
|
+
EMPTY_INPUT = "empty input"
|
|
14
|
+
INPUT_TOO_LONG = "input exceeds 3 characters"
|
|
15
|
+
MUST_CONTAIN_ONE_LETTER = "must contain exactly one letter"
|
|
16
|
+
INVALID_STATE_MODIFIER = "invalid state modifier"
|
|
17
|
+
INVALID_TERMINAL_MARKER = "invalid terminal marker"
|
|
18
|
+
|
|
19
|
+
# Validation errors (constructor)
|
|
20
|
+
INVALID_TYPE = "type must be a symbol from :A to :Z"
|
|
21
|
+
INVALID_SIDE = "side must be :first or :second"
|
|
22
|
+
INVALID_STATE = "state must be :normal, :enhanced, or :diminished"
|
|
23
|
+
INVALID_TERMINAL = "terminal must be true or false"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "argument/messages"
|
|
4
|
+
|
|
5
|
+
module Sashite
|
|
6
|
+
module Pin
|
|
7
|
+
module Errors
|
|
8
|
+
# Error raised when PIN parsing or validation fails.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# raise Argument, Argument::Messages::EMPTY_INPUT
|
|
12
|
+
class Argument < ::ArgumentError
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "constants"
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
|
|
6
|
+
module Sashite
|
|
7
|
+
module Pin
|
|
8
|
+
# Represents a parsed PIN (Piece Identifier Notation) identifier.
|
|
9
|
+
#
|
|
10
|
+
# An Identifier encodes four attributes of a piece:
|
|
11
|
+
# - Type: the piece type (A-Z as uppercase symbol)
|
|
12
|
+
# - Side: the player side (:first or :second)
|
|
13
|
+
# - State: the piece state (:normal, :enhanced, or :diminished)
|
|
14
|
+
# - Terminal: whether the piece is terminal (true or false)
|
|
15
|
+
#
|
|
16
|
+
# Instances are immutable (frozen after creation).
|
|
17
|
+
#
|
|
18
|
+
# @example Creating identifiers
|
|
19
|
+
# pin = Identifier.new(:K, :first)
|
|
20
|
+
# pin = Identifier.new(:R, :second, :enhanced)
|
|
21
|
+
# pin = Identifier.new(:K, :first, :normal, terminal: true)
|
|
22
|
+
#
|
|
23
|
+
# @example String conversion
|
|
24
|
+
# Identifier.new(:K, :first).to_s # => "K"
|
|
25
|
+
# Identifier.new(:R, :second, :enhanced).to_s # => "+r"
|
|
26
|
+
# Identifier.new(:K, :first, :normal, terminal: true).to_s # => "K^"
|
|
27
|
+
#
|
|
28
|
+
# @see https://sashite.dev/specs/pin/1.0.0/
|
|
29
|
+
class Identifier
|
|
30
|
+
# @return [Symbol] Piece type (:A to :Z, always uppercase)
|
|
31
|
+
attr_reader :type
|
|
32
|
+
|
|
33
|
+
# @return [Symbol] Player side (:first or :second)
|
|
34
|
+
attr_reader :side
|
|
35
|
+
|
|
36
|
+
# @return [Symbol] Piece state (:normal, :enhanced, or :diminished)
|
|
37
|
+
attr_reader :state
|
|
38
|
+
|
|
39
|
+
# Creates a new Identifier instance.
|
|
40
|
+
#
|
|
41
|
+
# @param type [Symbol] Piece type (:A to :Z)
|
|
42
|
+
# @param side [Symbol] Player side (:first or :second)
|
|
43
|
+
# @param state [Symbol] Piece state (:normal, :enhanced, or :diminished)
|
|
44
|
+
# @param terminal [Boolean] Terminal status
|
|
45
|
+
# @return [Identifier] A new frozen Identifier instance
|
|
46
|
+
# @raise [Errors::Argument] If any attribute is invalid
|
|
47
|
+
#
|
|
48
|
+
# @example
|
|
49
|
+
# Identifier.new(:K, :first)
|
|
50
|
+
# Identifier.new(:R, :second, :enhanced)
|
|
51
|
+
# Identifier.new(:K, :first, :normal, terminal: true)
|
|
52
|
+
def initialize(type, side, state = :normal, terminal: false)
|
|
53
|
+
validate_type!(type)
|
|
54
|
+
validate_side!(side)
|
|
55
|
+
validate_state!(state)
|
|
56
|
+
validate_terminal!(terminal)
|
|
57
|
+
|
|
58
|
+
@type = type
|
|
59
|
+
@side = side
|
|
60
|
+
@state = state
|
|
61
|
+
@terminal = terminal
|
|
62
|
+
|
|
63
|
+
freeze
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Returns the terminal status.
|
|
67
|
+
#
|
|
68
|
+
# @return [Boolean] true if terminal piece, false otherwise
|
|
69
|
+
#
|
|
70
|
+
# @example
|
|
71
|
+
# Identifier.new(:K, :first).terminal? # => false
|
|
72
|
+
# Identifier.new(:K, :first, :normal, terminal: true).terminal? # => true
|
|
73
|
+
def terminal?
|
|
74
|
+
@terminal
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# ========================================================================
|
|
78
|
+
# String Conversion
|
|
79
|
+
# ========================================================================
|
|
80
|
+
|
|
81
|
+
# Returns the PIN string representation.
|
|
82
|
+
#
|
|
83
|
+
# @return [String] The PIN string
|
|
84
|
+
#
|
|
85
|
+
# @example
|
|
86
|
+
# Identifier.new(:K, :first).to_s # => "K"
|
|
87
|
+
# Identifier.new(:R, :second, :enhanced).to_s # => "+r"
|
|
88
|
+
# Identifier.new(:K, :first, :normal, terminal: true).to_s # => "K^"
|
|
89
|
+
def to_s
|
|
90
|
+
"#{prefix}#{letter}#{suffix}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Returns the letter component of the PIN.
|
|
94
|
+
#
|
|
95
|
+
# @return [String] Uppercase for first player, lowercase for second
|
|
96
|
+
#
|
|
97
|
+
# @example
|
|
98
|
+
# Identifier.new(:K, :first).letter # => "K"
|
|
99
|
+
# Identifier.new(:K, :second).letter # => "k"
|
|
100
|
+
def letter
|
|
101
|
+
case side
|
|
102
|
+
when :first then String(type.upcase)
|
|
103
|
+
when :second then String(type.downcase)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Returns the state prefix of the PIN.
|
|
108
|
+
#
|
|
109
|
+
# @return [String] "+" for enhanced, "-" for diminished, "" for normal
|
|
110
|
+
#
|
|
111
|
+
# @example
|
|
112
|
+
# Identifier.new(:K, :first, :enhanced).prefix # => "+"
|
|
113
|
+
# Identifier.new(:K, :first, :diminished).prefix # => "-"
|
|
114
|
+
# Identifier.new(:K, :first, :normal).prefix # => ""
|
|
115
|
+
def prefix
|
|
116
|
+
case state
|
|
117
|
+
when :enhanced then Constants::ENHANCED_PREFIX
|
|
118
|
+
when :diminished then Constants::DIMINISHED_PREFIX
|
|
119
|
+
else Constants::EMPTY_STRING
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Returns the terminal suffix of the PIN.
|
|
124
|
+
#
|
|
125
|
+
# @return [String] "^" if terminal, "" otherwise
|
|
126
|
+
#
|
|
127
|
+
# @example
|
|
128
|
+
# Identifier.new(:K, :first, :normal, terminal: true).suffix # => "^"
|
|
129
|
+
# Identifier.new(:K, :first).suffix # => ""
|
|
130
|
+
def suffix
|
|
131
|
+
terminal? ? Constants::TERMINAL_SUFFIX : Constants::EMPTY_STRING
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# ========================================================================
|
|
135
|
+
# State Transformations
|
|
136
|
+
# ========================================================================
|
|
137
|
+
|
|
138
|
+
# Returns a new Identifier with enhanced state.
|
|
139
|
+
#
|
|
140
|
+
# @return [Identifier] A new Identifier with :enhanced state
|
|
141
|
+
#
|
|
142
|
+
# @example
|
|
143
|
+
# pin = Identifier.new(:K, :first)
|
|
144
|
+
# pin.enhance.to_s # => "+K"
|
|
145
|
+
def enhance
|
|
146
|
+
return self if enhanced?
|
|
147
|
+
|
|
148
|
+
self.class.new(type, side, :enhanced, terminal: terminal?)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Returns a new Identifier with diminished state.
|
|
152
|
+
#
|
|
153
|
+
# @return [Identifier] A new Identifier with :diminished state
|
|
154
|
+
#
|
|
155
|
+
# @example
|
|
156
|
+
# pin = Identifier.new(:K, :first)
|
|
157
|
+
# pin.diminish.to_s # => "-K"
|
|
158
|
+
def diminish
|
|
159
|
+
return self if diminished?
|
|
160
|
+
|
|
161
|
+
self.class.new(type, side, :diminished, terminal: terminal?)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Returns a new Identifier with normal state.
|
|
165
|
+
#
|
|
166
|
+
# @return [Identifier] A new Identifier with :normal state
|
|
167
|
+
#
|
|
168
|
+
# @example
|
|
169
|
+
# pin = Identifier.new(:K, :first, :enhanced)
|
|
170
|
+
# pin.normalize.to_s # => "K"
|
|
171
|
+
def normalize
|
|
172
|
+
return self if normal?
|
|
173
|
+
|
|
174
|
+
self.class.new(type, side, :normal, terminal: terminal?)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# ========================================================================
|
|
178
|
+
# Side Transformations
|
|
179
|
+
# ========================================================================
|
|
180
|
+
|
|
181
|
+
# Returns a new Identifier with the opposite side.
|
|
182
|
+
#
|
|
183
|
+
# @return [Identifier] A new Identifier with flipped side
|
|
184
|
+
#
|
|
185
|
+
# @example
|
|
186
|
+
# pin = Identifier.new(:K, :first)
|
|
187
|
+
# pin.flip.to_s # => "k"
|
|
188
|
+
def flip
|
|
189
|
+
new_side = first_player? ? :second : :first
|
|
190
|
+
self.class.new(type, new_side, state, terminal: terminal?)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# ========================================================================
|
|
194
|
+
# Terminal Transformations
|
|
195
|
+
# ========================================================================
|
|
196
|
+
|
|
197
|
+
# Returns a new Identifier marked as terminal.
|
|
198
|
+
#
|
|
199
|
+
# @return [Identifier] A new Identifier with terminal: true
|
|
200
|
+
#
|
|
201
|
+
# @example
|
|
202
|
+
# pin = Identifier.new(:K, :first)
|
|
203
|
+
# pin.mark_terminal.to_s # => "K^"
|
|
204
|
+
def mark_terminal
|
|
205
|
+
return self if terminal?
|
|
206
|
+
|
|
207
|
+
self.class.new(type, side, state, terminal: true)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Returns a new Identifier unmarked as terminal.
|
|
211
|
+
#
|
|
212
|
+
# @return [Identifier] A new Identifier with terminal: false
|
|
213
|
+
#
|
|
214
|
+
# @example
|
|
215
|
+
# pin = Identifier.new(:K, :first, :normal, terminal: true)
|
|
216
|
+
# pin.unmark_terminal.to_s # => "K"
|
|
217
|
+
def unmark_terminal
|
|
218
|
+
return self unless terminal?
|
|
219
|
+
|
|
220
|
+
self.class.new(type, side, state, terminal: false)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# ========================================================================
|
|
224
|
+
# Attribute Transformations
|
|
225
|
+
# ========================================================================
|
|
226
|
+
|
|
227
|
+
# Returns a new Identifier with a different type.
|
|
228
|
+
#
|
|
229
|
+
# @param new_type [Symbol] The new piece type (:A to :Z)
|
|
230
|
+
# @return [Identifier] A new Identifier with the specified type
|
|
231
|
+
# @raise [Errors::Argument] If the type is invalid
|
|
232
|
+
#
|
|
233
|
+
# @example
|
|
234
|
+
# pin = Identifier.new(:K, :first)
|
|
235
|
+
# pin.with_type(:Q).to_s # => "Q"
|
|
236
|
+
def with_type(new_type)
|
|
237
|
+
return self if type.equal?(new_type)
|
|
238
|
+
|
|
239
|
+
self.class.new(new_type, side, state, terminal: terminal?)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Returns a new Identifier with a different side.
|
|
243
|
+
#
|
|
244
|
+
# @param new_side [Symbol] The new side (:first or :second)
|
|
245
|
+
# @return [Identifier] A new Identifier with the specified side
|
|
246
|
+
# @raise [Errors::Argument] If the side is invalid
|
|
247
|
+
#
|
|
248
|
+
# @example
|
|
249
|
+
# pin = Identifier.new(:K, :first)
|
|
250
|
+
# pin.with_side(:second).to_s # => "k"
|
|
251
|
+
def with_side(new_side)
|
|
252
|
+
return self if side.equal?(new_side)
|
|
253
|
+
|
|
254
|
+
self.class.new(type, new_side, state, terminal: terminal?)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Returns a new Identifier with a different state.
|
|
258
|
+
#
|
|
259
|
+
# @param new_state [Symbol] The new state (:normal, :enhanced, or :diminished)
|
|
260
|
+
# @return [Identifier] A new Identifier with the specified state
|
|
261
|
+
# @raise [Errors::Argument] If the state is invalid
|
|
262
|
+
#
|
|
263
|
+
# @example
|
|
264
|
+
# pin = Identifier.new(:K, :first)
|
|
265
|
+
# pin.with_state(:enhanced).to_s # => "+K"
|
|
266
|
+
def with_state(new_state)
|
|
267
|
+
return self if state.equal?(new_state)
|
|
268
|
+
|
|
269
|
+
self.class.new(type, side, new_state, terminal: terminal?)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Returns a new Identifier with a different terminal status.
|
|
273
|
+
#
|
|
274
|
+
# @param new_terminal [Boolean] The new terminal status
|
|
275
|
+
# @return [Identifier] A new Identifier with the specified terminal status
|
|
276
|
+
# @raise [Errors::Argument] If the terminal is not a boolean
|
|
277
|
+
#
|
|
278
|
+
# @example
|
|
279
|
+
# pin = Identifier.new(:K, :first)
|
|
280
|
+
# pin.with_terminal(true).to_s # => "K^"
|
|
281
|
+
def with_terminal(new_terminal)
|
|
282
|
+
return self if terminal?.equal?(new_terminal)
|
|
283
|
+
|
|
284
|
+
self.class.new(type, side, state, terminal: new_terminal)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# ========================================================================
|
|
288
|
+
# State Queries
|
|
289
|
+
# ========================================================================
|
|
290
|
+
|
|
291
|
+
# Checks if the Identifier has normal state.
|
|
292
|
+
#
|
|
293
|
+
# @return [Boolean] true if normal state
|
|
294
|
+
#
|
|
295
|
+
# @example
|
|
296
|
+
# Identifier.new(:K, :first).normal? # => true
|
|
297
|
+
def normal?
|
|
298
|
+
state.equal?(:normal)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Checks if the Identifier has enhanced state.
|
|
302
|
+
#
|
|
303
|
+
# @return [Boolean] true if enhanced state
|
|
304
|
+
#
|
|
305
|
+
# @example
|
|
306
|
+
# Identifier.new(:K, :first, :enhanced).enhanced? # => true
|
|
307
|
+
def enhanced?
|
|
308
|
+
state.equal?(:enhanced)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# Checks if the Identifier has diminished state.
|
|
312
|
+
#
|
|
313
|
+
# @return [Boolean] true if diminished state
|
|
314
|
+
#
|
|
315
|
+
# @example
|
|
316
|
+
# Identifier.new(:K, :first, :diminished).diminished? # => true
|
|
317
|
+
def diminished?
|
|
318
|
+
state.equal?(:diminished)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# ========================================================================
|
|
322
|
+
# Side Queries
|
|
323
|
+
# ========================================================================
|
|
324
|
+
|
|
325
|
+
# Checks if the Identifier belongs to the first player.
|
|
326
|
+
#
|
|
327
|
+
# @return [Boolean] true if first player
|
|
328
|
+
#
|
|
329
|
+
# @example
|
|
330
|
+
# Identifier.new(:K, :first).first_player? # => true
|
|
331
|
+
def first_player?
|
|
332
|
+
side.equal?(:first)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Checks if the Identifier belongs to the second player.
|
|
336
|
+
#
|
|
337
|
+
# @return [Boolean] true if second player
|
|
338
|
+
#
|
|
339
|
+
# @example
|
|
340
|
+
# Identifier.new(:K, :second).second_player? # => true
|
|
341
|
+
def second_player?
|
|
342
|
+
side.equal?(:second)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# ========================================================================
|
|
346
|
+
# Comparison Queries
|
|
347
|
+
# ========================================================================
|
|
348
|
+
|
|
349
|
+
# Checks if two Identifiers have the same type.
|
|
350
|
+
#
|
|
351
|
+
# @param other [Identifier] The other Identifier to compare
|
|
352
|
+
# @return [Boolean] true if same type
|
|
353
|
+
#
|
|
354
|
+
# @example
|
|
355
|
+
# pin1 = Identifier.new(:K, :first)
|
|
356
|
+
# pin2 = Identifier.new(:K, :second)
|
|
357
|
+
# pin1.same_type?(pin2) # => true
|
|
358
|
+
def same_type?(other)
|
|
359
|
+
type.equal?(other.type)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# Checks if two Identifiers have the same side.
|
|
363
|
+
#
|
|
364
|
+
# @param other [Identifier] The other Identifier to compare
|
|
365
|
+
# @return [Boolean] true if same side
|
|
366
|
+
#
|
|
367
|
+
# @example
|
|
368
|
+
# pin1 = Identifier.new(:K, :first)
|
|
369
|
+
# pin2 = Identifier.new(:Q, :first)
|
|
370
|
+
# pin1.same_side?(pin2) # => true
|
|
371
|
+
def same_side?(other)
|
|
372
|
+
side.equal?(other.side)
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# Checks if two Identifiers have the same state.
|
|
376
|
+
#
|
|
377
|
+
# @param other [Identifier] The other Identifier to compare
|
|
378
|
+
# @return [Boolean] true if same state
|
|
379
|
+
#
|
|
380
|
+
# @example
|
|
381
|
+
# pin1 = Identifier.new(:K, :first, :enhanced)
|
|
382
|
+
# pin2 = Identifier.new(:Q, :second, :enhanced)
|
|
383
|
+
# pin1.same_state?(pin2) # => true
|
|
384
|
+
def same_state?(other)
|
|
385
|
+
state.equal?(other.state)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# Checks if two Identifiers have the same terminal status.
|
|
389
|
+
#
|
|
390
|
+
# @param other [Identifier] The other Identifier to compare
|
|
391
|
+
# @return [Boolean] true if same terminal status
|
|
392
|
+
#
|
|
393
|
+
# @example
|
|
394
|
+
# pin1 = Identifier.new(:K, :first, :normal, terminal: true)
|
|
395
|
+
# pin2 = Identifier.new(:Q, :second, :normal, terminal: true)
|
|
396
|
+
# pin1.same_terminal?(pin2) # => true
|
|
397
|
+
def same_terminal?(other)
|
|
398
|
+
terminal?.equal?(other.terminal?)
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# ========================================================================
|
|
402
|
+
# Equality
|
|
403
|
+
# ========================================================================
|
|
404
|
+
|
|
405
|
+
# Checks equality with another Identifier.
|
|
406
|
+
#
|
|
407
|
+
# @param other [Object] The object to compare
|
|
408
|
+
# @return [Boolean] true if equal
|
|
409
|
+
def ==(other)
|
|
410
|
+
return false unless self.class === other
|
|
411
|
+
|
|
412
|
+
type.equal?(other.type) &&
|
|
413
|
+
side.equal?(other.side) &&
|
|
414
|
+
state.equal?(other.state) &&
|
|
415
|
+
terminal?.equal?(other.terminal?)
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
alias eql? ==
|
|
419
|
+
|
|
420
|
+
# Returns a hash code for the Identifier.
|
|
421
|
+
#
|
|
422
|
+
# @return [Integer] Hash code
|
|
423
|
+
def hash
|
|
424
|
+
[type, side, state, terminal?].hash
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# Returns an inspect string for the Identifier.
|
|
428
|
+
#
|
|
429
|
+
# @return [String] Inspect representation
|
|
430
|
+
def inspect
|
|
431
|
+
"#<#{self.class} #{self}>"
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
private
|
|
435
|
+
|
|
436
|
+
# ========================================================================
|
|
437
|
+
# Private Validation
|
|
438
|
+
# ========================================================================
|
|
439
|
+
|
|
440
|
+
def validate_type!(type)
|
|
441
|
+
return if Constants::VALID_TYPES.include?(type)
|
|
442
|
+
|
|
443
|
+
raise Errors::Argument, Errors::Argument::Messages::INVALID_TYPE
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def validate_side!(side)
|
|
447
|
+
return if Constants::VALID_SIDES.include?(side)
|
|
448
|
+
|
|
449
|
+
raise Errors::Argument, Errors::Argument::Messages::INVALID_SIDE
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def validate_state!(state)
|
|
453
|
+
return if Constants::VALID_STATES.include?(state)
|
|
454
|
+
|
|
455
|
+
raise Errors::Argument, Errors::Argument::Messages::INVALID_STATE
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
def validate_terminal!(terminal)
|
|
459
|
+
return if ::TrueClass === terminal || ::FalseClass === terminal
|
|
460
|
+
|
|
461
|
+
raise Errors::Argument, Errors::Argument::Messages::INVALID_TERMINAL
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "constants"
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
|
|
6
|
+
module Sashite
|
|
7
|
+
module Pin
|
|
8
|
+
# Secure parser for PIN (Piece Identifier Notation) strings.
|
|
9
|
+
#
|
|
10
|
+
# This parser uses character-by-character validation without regex
|
|
11
|
+
# to prevent ReDoS attacks and ensure strict ASCII compliance.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# Parser.parse("K") # => { type: :K, side: :first, state: :normal, terminal: false }
|
|
15
|
+
# Parser.parse("+r^") # => { type: :R, side: :second, state: :enhanced, terminal: true }
|
|
16
|
+
#
|
|
17
|
+
# @see https://sashite.dev/specs/pin/1.0.0/
|
|
18
|
+
module Parser
|
|
19
|
+
# Parses a PIN string into its components.
|
|
20
|
+
#
|
|
21
|
+
# @param input [String] The PIN string to parse
|
|
22
|
+
# @return [Hash] A hash with :type, :side, :state, and :terminal keys
|
|
23
|
+
# @raise [Errors::Argument] If the input is not a valid PIN string
|
|
24
|
+
def self.parse(input)
|
|
25
|
+
validate_input_type(input)
|
|
26
|
+
validate_not_empty(input)
|
|
27
|
+
validate_length(input)
|
|
28
|
+
|
|
29
|
+
parse_components(input)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Validates a PIN string without raising an exception.
|
|
33
|
+
#
|
|
34
|
+
# @param input [String] The PIN string to validate
|
|
35
|
+
# @return [Boolean] true if valid, false otherwise
|
|
36
|
+
def self.valid?(input)
|
|
37
|
+
return false unless ::String === input
|
|
38
|
+
|
|
39
|
+
parse(input)
|
|
40
|
+
true
|
|
41
|
+
rescue Errors::Argument
|
|
42
|
+
false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
class << self
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# Validates that input is a String.
|
|
49
|
+
#
|
|
50
|
+
# @param input [Object] The input to validate
|
|
51
|
+
# @raise [Errors::Argument] If input is not a String
|
|
52
|
+
def validate_input_type(input)
|
|
53
|
+
return if ::String === input
|
|
54
|
+
|
|
55
|
+
raise Errors::Argument, Errors::Argument::Messages::MUST_CONTAIN_ONE_LETTER
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Validates that input is not empty.
|
|
59
|
+
#
|
|
60
|
+
# @param input [String] The input to validate
|
|
61
|
+
# @raise [Errors::Argument] If input is empty
|
|
62
|
+
def validate_not_empty(input)
|
|
63
|
+
return unless input.empty?
|
|
64
|
+
|
|
65
|
+
raise Errors::Argument, Errors::Argument::Messages::EMPTY_INPUT
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Validates that input does not exceed maximum length.
|
|
69
|
+
#
|
|
70
|
+
# @param input [String] The input to validate
|
|
71
|
+
# @raise [Errors::Argument] If input is too long
|
|
72
|
+
def validate_length(input)
|
|
73
|
+
return if input.bytesize <= Constants::MAX_STRING_LENGTH
|
|
74
|
+
|
|
75
|
+
raise Errors::Argument, Errors::Argument::Messages::INPUT_TOO_LONG
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Parses the PIN string into its components.
|
|
79
|
+
#
|
|
80
|
+
# @param input [String] The validated PIN string
|
|
81
|
+
# @return [Hash] A hash with :type, :side, :state, and :terminal keys
|
|
82
|
+
# @raise [Errors::Argument] If the structure is invalid
|
|
83
|
+
def parse_components(input)
|
|
84
|
+
pos = 0
|
|
85
|
+
state = :normal
|
|
86
|
+
terminal = false
|
|
87
|
+
|
|
88
|
+
# Check for state modifier at position 0
|
|
89
|
+
byte = input.getbyte(pos)
|
|
90
|
+
if state_modifier?(byte)
|
|
91
|
+
state = decode_state_modifier(byte)
|
|
92
|
+
pos += 1
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Must have a letter at current position
|
|
96
|
+
raise Errors::Argument, Errors::Argument::Messages::MUST_CONTAIN_ONE_LETTER if pos >= input.bytesize
|
|
97
|
+
|
|
98
|
+
byte = input.getbyte(pos)
|
|
99
|
+
raise Errors::Argument, Errors::Argument::Messages::MUST_CONTAIN_ONE_LETTER unless ascii_letter?(byte)
|
|
100
|
+
|
|
101
|
+
type = byte.chr.upcase.to_sym
|
|
102
|
+
side = uppercase_letter?(byte) ? :first : :second
|
|
103
|
+
pos += 1
|
|
104
|
+
|
|
105
|
+
# Check for terminal marker
|
|
106
|
+
if pos < input.bytesize
|
|
107
|
+
byte = input.getbyte(pos)
|
|
108
|
+
raise Errors::Argument, Errors::Argument::Messages::INVALID_TERMINAL_MARKER unless terminal_marker?(byte)
|
|
109
|
+
|
|
110
|
+
terminal = true
|
|
111
|
+
pos += 1
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Ensure no extra characters
|
|
115
|
+
raise Errors::Argument, Errors::Argument::Messages::MUST_CONTAIN_ONE_LETTER if pos < input.bytesize
|
|
116
|
+
|
|
117
|
+
{ type: type, side: side, state: state, terminal: terminal }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Checks if a byte is a state modifier (+ or -).
|
|
121
|
+
#
|
|
122
|
+
# @param byte [Integer] The byte to check
|
|
123
|
+
# @return [Boolean] true if state modifier
|
|
124
|
+
def state_modifier?(byte)
|
|
125
|
+
byte == 0x2B || byte == 0x2D # '+' or '-'
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Decodes a state modifier byte to a state symbol.
|
|
129
|
+
#
|
|
130
|
+
# @param byte [Integer] The byte to decode
|
|
131
|
+
# @return [Symbol] :enhanced or :diminished
|
|
132
|
+
def decode_state_modifier(byte)
|
|
133
|
+
byte == 0x2B ? :enhanced : :diminished
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Checks if a byte is an ASCII letter (A-Z or a-z).
|
|
137
|
+
#
|
|
138
|
+
# @param byte [Integer] The byte to check
|
|
139
|
+
# @return [Boolean] true if ASCII letter
|
|
140
|
+
def ascii_letter?(byte)
|
|
141
|
+
uppercase_letter?(byte) || lowercase_letter?(byte)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Checks if a byte is an uppercase ASCII letter (A-Z).
|
|
145
|
+
#
|
|
146
|
+
# @param byte [Integer] The byte to check
|
|
147
|
+
# @return [Boolean] true if uppercase letter
|
|
148
|
+
def uppercase_letter?(byte)
|
|
149
|
+
byte >= 0x41 && byte <= 0x5A # 'A' to 'Z'
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Checks if a byte is a lowercase ASCII letter (a-z).
|
|
153
|
+
#
|
|
154
|
+
# @param byte [Integer] The byte to check
|
|
155
|
+
# @return [Boolean] true if lowercase letter
|
|
156
|
+
def lowercase_letter?(byte)
|
|
157
|
+
byte >= 0x61 && byte <= 0x7A # 'a' to 'z'
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Checks if a byte is a terminal marker (^).
|
|
161
|
+
#
|
|
162
|
+
# @param byte [Integer] The byte to check
|
|
163
|
+
# @return [Boolean] true if terminal marker
|
|
164
|
+
def terminal_marker?(byte)
|
|
165
|
+
byte == 0x5E # '^'
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|