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.
@@ -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,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors/argument"
@@ -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