sashite-sin 3.1.0 → 3.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '080b65573c2e27e8efe5c2ed79009daf31124b35c88ddc7bbe4023e6fe553acf'
4
- data.tar.gz: fc25ce0ac44111a41d764aa8badac8ca2727a163e00accd5d8bf52ca301198d0
3
+ metadata.gz: 611d7fa815981b0a68443f7641f2f780afe46bca6fd8fed0f6204f35b0c2721b
4
+ data.tar.gz: c414565ed46cb55375c7ec599424dea459999c2162e458ee62be81a49dcf601b
5
5
  SHA512:
6
- metadata.gz: 93caf9df6d423fa40562f43971b8ea1c1a0c1bba1d4de9d11ee59184d2906638e2d4aa25639ed40010090dfa2929a658e9ba52f714bb46d1616639b6f2983685
7
- data.tar.gz: 3d8c978b778d1210e71c288a41daf9f80a750a5a1f975c22a1536f8499f280a7302f0a51c6641d72522b3cc18b88bca5bb94ab7fdac5f8c7de1920b5444fc700
6
+ metadata.gz: e8e1fa4bbc10f70ba9e17f01292228d538d639f3de6a4e1fc30207f436a1d72133edf502eef557405fce096ecb30c4c9125cd6919f3dc03cb5005e356fcb11f4
7
+ data.tar.gz: b3904a0fa64f33251e6db3cf6f454578a3dadccb75f8e3b3e885de834ecd6c9b0e0dd53aeaa62587fe9dde5979b991f0b54dc93e10b835ab9bfdad8cd31f4136
data/README.md CHANGED
@@ -11,6 +11,18 @@
11
11
 
12
12
  This library implements the [SIN Specification v1.0.0](https://sashite.dev/specs/sin/1.0.0/).
13
13
 
14
+ SIN is a single-character, ASCII-only token format encoding a **Player Identity**: the tuple (**Player Side**, **Player Style**). Uppercase indicates the first player, lowercase indicates the second player.
15
+
16
+ ### Implementation Constraints
17
+
18
+ | Constraint | Value | Rationale |
19
+ |------------|-------|-----------|
20
+ | Token length | Exactly 1 | Single ASCII letter per spec |
21
+ | Character space | A–Z, a–z | 52 total tokens (26 abbreviations × 2 sides) |
22
+ | Instance pool | 52 objects | All identifiers are pre-instantiated and frozen |
23
+
24
+ The closed domain of 52 possible values enables a flyweight architecture with zero allocation on the hot path.
25
+
14
26
  ## Installation
15
27
 
16
28
  ```ruby
@@ -43,33 +55,70 @@ sin = Sashite::Sin.parse("c")
43
55
  sin.abbr # => :C
44
56
  sin.side # => :second
45
57
 
58
+ # Returns a cached instance — no allocation
59
+ Sashite::Sin.parse("C").equal?(Sashite::Sin.parse("C")) # => true
60
+
46
61
  # Invalid input raises ArgumentError
47
62
  Sashite::Sin.parse("") # => raises ArgumentError
48
63
  Sashite::Sin.parse("CC") # => raises ArgumentError
49
64
  ```
50
65
 
66
+ ### Safe Parsing (String → Identifier | nil)
67
+
68
+ Parse without raising exceptions. Returns `nil` on invalid input.
69
+
70
+ ```ruby
71
+ # Valid input returns an Identifier
72
+ Sashite::Sin.safe_parse("C") # => #<Sashite::Sin::Identifier C>
73
+ Sashite::Sin.safe_parse("c") # => #<Sashite::Sin::Identifier c>
74
+
75
+ # Invalid input returns nil — no exception allocated
76
+ Sashite::Sin.safe_parse("") # => nil
77
+ Sashite::Sin.safe_parse("CC") # => nil
78
+ Sashite::Sin.safe_parse("1") # => nil
79
+ Sashite::Sin.safe_parse(nil) # => nil
80
+ ```
81
+
82
+ ### Fetching by Components (Symbol, Symbol → Identifier)
83
+
84
+ Retrieve a cached identifier directly by abbreviation and side, bypassing string parsing entirely.
85
+
86
+ ```ruby
87
+ # Direct lookup — no string parsing, no allocation
88
+ Sashite::Sin.fetch(:C, :first) # => #<Sashite::Sin::Identifier C>
89
+ Sashite::Sin.fetch(:C, :second) # => #<Sashite::Sin::Identifier c>
90
+
91
+ # Same cached instance as parse
92
+ Sashite::Sin.fetch(:C, :first).equal?(Sashite::Sin.parse("C")) # => true
93
+
94
+ # Invalid components raise ArgumentError
95
+ Sashite::Sin.fetch(:CC, :first) # => raises ArgumentError
96
+ Sashite::Sin.fetch(:C, :third) # => raises ArgumentError
97
+ ```
98
+
51
99
  ### Formatting (Identifier → String)
52
100
 
53
101
  Convert an `Identifier` back to a SIN string.
54
102
 
55
103
  ```ruby
56
- # From Identifier object
57
- sin = Sashite::Sin::Identifier.new(:C, :first)
104
+ sin = Sashite::Sin.parse("C")
58
105
  sin.to_s # => "C"
59
106
 
60
- sin = Sashite::Sin::Identifier.new(:C, :second)
107
+ sin = Sashite::Sin.parse("c")
61
108
  sin.to_s # => "c"
62
109
  ```
63
110
 
64
111
  ### Validation
65
112
 
66
113
  ```ruby
67
- # Boolean check
114
+ # Boolean check (never raises)
115
+ # Uses an exception-free code path internally for performance.
68
116
  Sashite::Sin.valid?("C") # => true
69
117
  Sashite::Sin.valid?("c") # => true
70
118
  Sashite::Sin.valid?("") # => false
71
119
  Sashite::Sin.valid?("CC") # => false
72
120
  Sashite::Sin.valid?("1") # => false
121
+ Sashite::Sin.valid?(nil) # => false
73
122
  ```
74
123
 
75
124
  ### Queries
@@ -89,22 +138,55 @@ sin.same_side?(other) # => false
89
138
 
90
139
  ## API Reference
91
140
 
92
- ### Types
141
+ ### Module Methods
142
+
143
+ ```ruby
144
+ # Parses a SIN string into a cached Identifier.
145
+ # Returns a pre-instantiated, frozen instance.
146
+ # Raises ArgumentError if the string is not valid.
147
+ #
148
+ # @param string [String] SIN string
149
+ # @return [Identifier]
150
+ # @raise [ArgumentError] if invalid
151
+ def Sashite::Sin.parse(string)
152
+
153
+ # Parses a SIN string without raising.
154
+ # Returns a cached Identifier on success, nil on failure.
155
+ # Never allocates exception objects or captures backtraces.
156
+ #
157
+ # @param string [String] SIN string
158
+ # @return [Identifier, nil]
159
+ def Sashite::Sin.safe_parse(string)
160
+
161
+ # Retrieves a cached Identifier by abbreviation and side.
162
+ # Bypasses string parsing entirely — direct hash lookup.
163
+ # Raises ArgumentError if components are invalid.
164
+ #
165
+ # @param abbr [Symbol] Style abbreviation (:A through :Z)
166
+ # @param side [Symbol] Player side (:first or :second)
167
+ # @return [Identifier]
168
+ # @raise [ArgumentError] if invalid
169
+ def Sashite::Sin.fetch(abbr, side)
170
+
171
+ # Reports whether string is a valid SIN identifier.
172
+ # Never raises; returns false for any invalid input.
173
+ # Uses an exception-free code path internally for performance.
174
+ #
175
+ # @param string [String] SIN string
176
+ # @return [Boolean]
177
+ def Sashite::Sin.valid?(string)
178
+ ```
179
+
180
+ ### Identifier
93
181
 
94
182
  ```ruby
95
183
  # Identifier represents a parsed SIN identifier with abbreviation and side.
184
+ # All instances are frozen and pre-instantiated — never construct directly,
185
+ # use Sashite::Sin.parse, .safe_parse, or .fetch instead.
96
186
  class Sashite::Sin::Identifier
97
- # Creates an Identifier from abbreviation and side.
98
- # Raises ArgumentError if attributes are invalid.
99
- #
100
- # @param abbr [Symbol] Style abbreviation (:A through :Z)
101
- # @param side [Symbol] Player side (:first or :second)
102
- # @return [Identifier]
103
- def initialize(abbr, side)
104
-
105
187
  # Returns the style abbreviation as an uppercase symbol.
106
188
  #
107
- # @return [Symbol]
189
+ # @return [Symbol] :A through :Z
108
190
  def abbr
109
191
 
110
192
  # Returns the player side.
@@ -119,35 +201,6 @@ class Sashite::Sin::Identifier
119
201
  end
120
202
  ```
121
203
 
122
- ### Constants
123
-
124
- ```ruby
125
- Sashite::Sin::Identifier::VALID_ABBRS # => [:A, :B, ..., :Z]
126
- Sashite::Sin::Identifier::VALID_SIDES # => [:first, :second]
127
- ```
128
-
129
- ### Parsing
130
-
131
- ```ruby
132
- # Parses a SIN string into an Identifier.
133
- # Raises ArgumentError if the string is not valid.
134
- #
135
- # @param string [String] SIN string
136
- # @return [Identifier]
137
- # @raise [ArgumentError] if invalid
138
- def Sashite::Sin.parse(string)
139
- ```
140
-
141
- ### Validation
142
-
143
- ```ruby
144
- # Reports whether string is a valid SIN identifier.
145
- #
146
- # @param string [String] SIN string
147
- # @return [Boolean]
148
- def Sashite::Sin.valid?(string)
149
- ```
150
-
151
204
  ### Queries
152
205
 
153
206
  ```ruby
@@ -160,9 +213,16 @@ def same_abbr?(other) # => Boolean
160
213
  def same_side?(other) # => Boolean
161
214
  ```
162
215
 
216
+ ### Constants
217
+
218
+ ```ruby
219
+ Sashite::Sin::Identifier::VALID_ABBRS # => [:A, :B, ..., :Z]
220
+ Sashite::Sin::Identifier::VALID_SIDES # => [:first, :second]
221
+ ```
222
+
163
223
  ### Errors
164
224
 
165
- All parsing and validation errors raise `ArgumentError` with descriptive messages:
225
+ All errors raise `ArgumentError` with descriptive messages:
166
226
 
167
227
  | Message | Cause |
168
228
  |---------|-------|
@@ -172,12 +232,28 @@ All parsing and validation errors raise `ArgumentError` with descriptive message
172
232
 
173
233
  ## Design Principles
174
234
 
175
- - **Bounded values**: Explicit validation of abbreviations and sides
176
- - **Object-oriented**: `Identifier` class enables methods and encapsulation
235
+ - **Spec conformance**: Strict adherence to SIN v1.0.0
236
+ - **Flyweight identifiers**: All 52 possible instances are pre-built and frozen; parsing and fetching return cached objects with zero allocation
237
+ - **Performance-oriented internals**: Exception-free validation path; exceptions only at the public API boundary
177
238
  - **Ruby idioms**: `valid?` predicate, `to_s` conversion, `ArgumentError` for invalid input
178
- - **Immutable identifiers**: Instances are frozen after creation
239
+ - **Immutable identifiers**: All instances are frozen after creation
179
240
  - **No dependencies**: Pure Ruby standard library only
180
241
 
242
+ ### Performance Architecture
243
+
244
+ SIN has a closed domain of exactly 52 valid tokens (26 letters × 2 cases). The implementation exploits this constraint through two complementary strategies.
245
+
246
+ **Flyweight instance pool** — All 52 `Identifier` objects are pre-instantiated and frozen at load time. `parse`, `safe_parse`, and `fetch` return these cached instances via hash lookup. No `Identifier` is ever allocated after the module loads. This makes SIN essentially free to call from FEEN or any other hot loop — every call is a hash lookup returning a pre-existing frozen object.
247
+
248
+ **Dual-path parsing** — Parsing is split into two layers to avoid using exceptions for control flow:
249
+
250
+ - **Validation layer** — `safe_parse` performs all validation and returns the cached `Identifier` on success, or `nil` on failure, without raising, without allocating exception objects, and without capturing backtraces.
251
+ - **Public API layer** — `parse` calls `safe_parse` internally. On failure, it raises `ArgumentError` exactly once, at the boundary. `valid?` calls `safe_parse` and returns a boolean directly, never raising.
252
+
253
+ **Direct component lookup** — `fetch` bypasses string parsing entirely. Given a symbol pair `(:C, :first)`, it performs a single hash lookup into the instance pool. This is the fastest path for callers that already have structured data (e.g., FEEN's dumper reconstructing a style–turn field from `Qi` attributes).
254
+
255
+ This architecture ensures that SIN never becomes a bottleneck when called from higher-level parsers like FEEN, where it may be invoked multiple times per position.
256
+
181
257
  ## Related Specifications
182
258
 
183
259
  - [Game Protocol](https://sashite.dev/game-protocol/) — Conceptual foundation
@@ -4,13 +4,8 @@ module Sashite
4
4
  module Sin
5
5
  # Constants for the SIN (Style Identifier Notation) specification.
6
6
  #
7
- # Defines valid values for abbreviations and sides, as well as formatting constants.
8
- #
9
- # @example Accessing valid abbreviations
10
- # Constants::VALID_ABBRS # => [:A, :B, ..., :Z]
11
- #
12
- # @example Accessing valid sides
13
- # Constants::VALID_SIDES # => [:first, :second]
7
+ # Provides public-facing domain constants and pre-computed lookup tables
8
+ # used internally for zero-allocation parsing.
14
9
  #
15
10
  # @see https://sashite.dev/specs/sin/1.0.0/
16
11
  module Constants
@@ -24,15 +19,38 @@ module Sashite
24
19
  # @return [Array<Symbol>] Array of valid side symbols
25
20
  VALID_SIDES = %i[first second].freeze
26
21
 
27
- # Maximum length of a valid SIN string.
22
+ # Pre-computed byte uppercase Symbol lookup table.
23
+ #
24
+ # Maps every valid ASCII letter byte (A-Z, a-z) to its uppercase Symbol
25
+ # abbreviation. Used by the parser for O(1) extraction with no intermediate
26
+ # String allocation.
28
27
  #
29
- # @return [Integer] Maximum string length (1)
30
- MAX_STRING_LENGTH = 1
28
+ # @example
29
+ # BYTE_TO_ABBR[0x43] # => :C (byte for 'C')
30
+ # BYTE_TO_ABBR[0x63] # => :C (byte for 'c')
31
+ # BYTE_TO_ABBR[0x31] # => nil (byte for '1')
32
+ #
33
+ # @return [Hash{Integer => Symbol}] Frozen byte-to-symbol mapping
34
+ BYTE_TO_ABBR = {}.tap { |h|
35
+ (0x41..0x5A).each { |b| h[b] = b.chr.to_sym }
36
+ (0x61..0x7A).each { |b| h[b] = (b - 32).chr.to_sym }
37
+ }.freeze
31
38
 
32
- # Empty string constant for internal use.
39
+ # Pre-computed byte side lookup table.
40
+ #
41
+ # Maps every valid ASCII letter byte to its corresponding side.
42
+ # Uppercase letters map to :first, lowercase to :second.
43
+ #
44
+ # @example
45
+ # BYTE_TO_SIDE[0x43] # => :first (byte for 'C')
46
+ # BYTE_TO_SIDE[0x63] # => :second (byte for 'c')
47
+ # BYTE_TO_SIDE[0x31] # => nil (byte for '1')
33
48
  #
34
- # @return [String] Empty string
35
- EMPTY_STRING = ""
49
+ # @return [Hash{Integer => Symbol}] Frozen byte-to-side mapping
50
+ BYTE_TO_SIDE = {}.tap { |h|
51
+ (0x41..0x5A).each { |b| h[b] = :first }
52
+ (0x61..0x7A).each { |b| h[b] = :second }
53
+ }.freeze
36
54
  end
37
55
  end
38
56
  end
@@ -11,15 +11,16 @@ module Sashite
11
11
  # - Abbr: the style abbreviation (A-Z as uppercase symbol)
12
12
  # - Side: the player side (:first or :second)
13
13
  #
14
- # Instances are immutable (frozen after creation).
14
+ # All 52 possible instances are pre-instantiated and frozen at load time.
15
+ # Never construct directly — use {Sashite::Sin.parse}, {Sashite::Sin.safe_parse},
16
+ # or {Sashite::Sin.fetch} instead.
15
17
  #
16
- # @example Creating identifiers
17
- # sin = Identifier.new(:C, :first)
18
- # sin = Identifier.new(:S, :second)
18
+ # @example Obtaining identifiers
19
+ # Sashite::Sin.parse("C") # => #<Sashite::Sin::Identifier C>
20
+ # Sashite::Sin.fetch(:C, :first) # => #<Sashite::Sin::Identifier C>
19
21
  #
20
- # @example String conversion
21
- # Identifier.new(:C, :first).to_s # => "C"
22
- # Identifier.new(:C, :second).to_s # => "c"
22
+ # @example Identity guarantee (flyweight)
23
+ # Sashite::Sin.parse("C").equal?(Sashite::Sin.fetch(:C, :first)) # => true
23
24
  #
24
25
  # @see https://sashite.dev/specs/sin/1.0.0/
25
26
  class Identifier
@@ -42,15 +43,16 @@ module Sashite
42
43
  # @return [Identifier] A new frozen Identifier instance
43
44
  # @raise [Errors::Argument] If any attribute is invalid
44
45
  #
45
- # @example
46
- # Identifier.new(:C, :first)
47
- # Identifier.new(:S, :second)
46
+ # @api private
48
47
  def initialize(abbr, side)
49
48
  validate_abbr!(abbr)
50
49
  validate_side!(side)
51
50
 
52
51
  @abbr = abbr
53
52
  @side = side
53
+ @string = (side.equal?(:first) ? abbr.to_s : abbr.to_s.downcase).freeze
54
+ @hash = [abbr, side].hash
55
+ @inspect = "#<#{self.class} #{@string}>".freeze
54
56
 
55
57
  freeze
56
58
  end
@@ -61,18 +63,15 @@ module Sashite
61
63
 
62
64
  # Returns the SIN string representation.
63
65
  #
66
+ # Returns a pre-computed, frozen string — zero allocation per call.
67
+ #
64
68
  # @return [String] The single-character SIN string
65
69
  #
66
70
  # @example
67
- # Identifier.new(:C, :first).to_s # => "C"
68
- # Identifier.new(:C, :second).to_s # => "c"
71
+ # Sashite::Sin.parse("C").to_s # => "C"
72
+ # Sashite::Sin.parse("c").to_s # => "c"
69
73
  def to_s
70
- base = String(abbr)
71
-
72
- case side
73
- when :first then base.upcase
74
- when :second then base.downcase
75
- end
74
+ @string
76
75
  end
77
76
 
78
77
  # ========================================================================
@@ -84,7 +83,7 @@ module Sashite
84
83
  # @return [Boolean] true if first player
85
84
  #
86
85
  # @example
87
- # Identifier.new(:C, :first).first_player? # => true
86
+ # Sashite::Sin.parse("C").first_player? # => true
88
87
  def first_player?
89
88
  side.equal?(:first)
90
89
  end
@@ -94,7 +93,7 @@ module Sashite
94
93
  # @return [Boolean] true if second player
95
94
  #
96
95
  # @example
97
- # Identifier.new(:C, :second).second_player? # => true
96
+ # Sashite::Sin.parse("c").second_player? # => true
98
97
  def second_player?
99
98
  side.equal?(:second)
100
99
  end
@@ -109,8 +108,8 @@ module Sashite
109
108
  # @return [Boolean] true if same abbreviation
110
109
  #
111
110
  # @example
112
- # sin1 = Identifier.new(:C, :first)
113
- # sin2 = Identifier.new(:C, :second)
111
+ # sin1 = Sashite::Sin.parse("C")
112
+ # sin2 = Sashite::Sin.parse("c")
114
113
  # sin1.same_abbr?(sin2) # => true
115
114
  def same_abbr?(other)
116
115
  abbr.equal?(other.abbr)
@@ -122,8 +121,8 @@ module Sashite
122
121
  # @return [Boolean] true if same side
123
122
  #
124
123
  # @example
125
- # sin1 = Identifier.new(:C, :first)
126
- # sin2 = Identifier.new(:S, :first)
124
+ # sin1 = Sashite::Sin.parse("C")
125
+ # sin2 = Sashite::Sin.parse("S")
127
126
  # sin1.same_side?(sin2) # => true
128
127
  def same_side?(other)
129
128
  side.equal?(other.side)
@@ -135,36 +134,36 @@ module Sashite
135
134
 
136
135
  # Checks equality with another Identifier.
137
136
  #
137
+ # With the flyweight pool, equal identifiers are always the same object
138
+ # (i.e., == implies equal?). Value-based comparison is provided for
139
+ # correctness when comparing across different object sources.
140
+ #
138
141
  # @param other [Object] The object to compare
139
142
  # @return [Boolean] true if equal
140
143
  #
141
144
  # @example
142
- # sin1 = Identifier.new(:C, :first)
143
- # sin2 = Identifier.new(:C, :first)
144
- # sin1 == sin2 # => true
145
+ # Sashite::Sin.parse("C") == Sashite::Sin.fetch(:C, :first) # => true
145
146
  def ==(other)
146
- return false unless self.class === other
147
-
148
- abbr.equal?(other.abbr) && side.equal?(other.side)
147
+ equal?(other) || (self.class === other && abbr.equal?(other.abbr) && side.equal?(other.side))
149
148
  end
150
149
 
151
150
  alias eql? ==
152
151
 
153
- # Returns a hash code for the Identifier.
152
+ # Returns a pre-computed hash code for the Identifier.
154
153
  #
155
154
  # @return [Integer] Hash code
156
155
  def hash
157
- [abbr, side].hash
156
+ @hash
158
157
  end
159
158
 
160
- # Returns an inspect string for the Identifier.
159
+ # Returns a pre-computed inspect string for the Identifier.
161
160
  #
162
161
  # @return [String] Inspect representation
163
162
  #
164
163
  # @example
165
- # Identifier.new(:C, :first).inspect # => "#<Sashite::Sin::Identifier C>"
164
+ # Sashite::Sin.parse("C").inspect # => "#<Sashite::Sin::Identifier C>"
166
165
  def inspect
167
- "#<#{self.class} #{self}>"
166
+ @inspect
168
167
  end
169
168
 
170
169
  private
@@ -184,6 +183,38 @@ module Sashite
184
183
 
185
184
  raise Errors::Argument, Errors::Argument::Messages::INVALID_SIDE
186
185
  end
186
+
187
+ # ========================================================================
188
+ # Flyweight Instance Pool
189
+ # ========================================================================
190
+
191
+ public
192
+
193
+ # Component-keyed pool: [abbr, side] → Identifier.
194
+ #
195
+ # Used by {Sashite::Sin.fetch} for direct lookup by structured components.
196
+ #
197
+ # @return [Hash{Array(Symbol, Symbol) => Identifier}]
198
+ POOL = {}.tap { |pool|
199
+ Constants::VALID_ABBRS.each do |abbr|
200
+ Constants::VALID_SIDES.each do |side|
201
+ pool[[abbr, side]] = new(abbr, side)
202
+ end
203
+ end
204
+ }.freeze
205
+
206
+ # Byte-keyed pool: ASCII byte → Identifier.
207
+ #
208
+ # Used by the parser for O(1) lookup from a validated byte.
209
+ # Returns nil for non-letter bytes, doubling as implicit validation.
210
+ #
211
+ # @return [Hash{Integer => Identifier}]
212
+ BYTE_POOL = {}.tap { |pool|
213
+ (0x41..0x5A).each { |b| pool[b] = POOL[[b.chr.to_sym, :first]] }
214
+ (0x61..0x7A).each { |b| pool[b] = POOL[[(b - 32).chr.to_sym, :second]] }
215
+ }.freeze
216
+
217
+ private_class_method :new
187
218
  end
188
219
  end
189
220
  end
@@ -1,18 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "constants"
4
3
  require_relative "errors"
4
+ require_relative "identifier"
5
5
 
6
6
  module Sashite
7
7
  module Sin
8
8
  # Parses SIN (Style Identifier Notation) strings.
9
9
  #
10
- # The parser uses byte-level validation to ensure security against
11
- # malformed input, Unicode lookalikes, and injection attacks.
10
+ # Implements a dual-path architecture for maximum performance:
11
+ #
12
+ # - {.safe_parse} validates and returns a cached Identifier or nil,
13
+ # without ever raising or allocating exception objects.
14
+ # - {.parse} delegates to safe_parse and raises exactly once at the
15
+ # API boundary on failure.
16
+ # - {.valid?} delegates to safe_parse and converts to boolean.
17
+ #
18
+ # All valid inputs resolve to a pre-instantiated flyweight Identifier
19
+ # via a single byte-indexed Hash lookup.
12
20
  #
13
21
  # @example Parsing a valid SIN string
14
- # Parser.parse("C") # => { abbr: :C, side: :first }
15
- # Parser.parse("c") # => { abbr: :C, side: :second }
22
+ # Parser.parse("C") # => #<Sashite::Sin::Identifier C>
23
+ # Parser.parse("c") # => #<Sashite::Sin::Identifier c>
24
+ #
25
+ # @example Safe parsing
26
+ # Parser.safe_parse("C") # => #<Sashite::Sin::Identifier C>
27
+ # Parser.safe_parse("1") # => nil
28
+ # Parser.safe_parse(nil) # => nil
16
29
  #
17
30
  # @example Validation
18
31
  # Parser.valid?("C") # => true
@@ -20,28 +33,45 @@ module Sashite
20
33
  #
21
34
  # @see https://sashite.dev/specs/sin/1.0.0/
22
35
  module Parser
23
- # Parses a SIN string into its components.
36
+ # Parses a SIN string into a cached Identifier.
37
+ #
38
+ # Delegates to {.safe_parse} internally. On failure, raises a single
39
+ # ArgumentError with a descriptive message.
24
40
  #
25
41
  # @param input [String] The SIN string to parse
26
- # @return [Hash] Hash with :abbr and :side keys
42
+ # @return [Identifier] A pre-instantiated, frozen Identifier
27
43
  # @raise [Errors::Argument] If the input is invalid
28
44
  #
29
45
  # @example
30
- # Parser.parse("C") # => { abbr: :C, side: :first }
31
- # Parser.parse("s") # => { abbr: :S, side: :second }
46
+ # Parser.parse("C") # => #<Sashite::Sin::Identifier C>
47
+ # Parser.parse("s") # => #<Sashite::Sin::Identifier s>
32
48
  def self.parse(input)
33
- validate_input_type!(input)
34
- validate_not_empty!(input)
35
- validate_length!(input)
36
-
37
- byte = input.getbyte(0)
38
- validate_letter!(byte)
49
+ safe_parse(input) || raise(Errors::Argument, error_message_for(input))
50
+ end
39
51
 
40
- extract_components(byte)
52
+ # Parses a SIN string without raising.
53
+ #
54
+ # Returns a cached Identifier on success, nil on failure.
55
+ # Never allocates exception objects or captures backtraces.
56
+ #
57
+ # @param input [String] The string to parse
58
+ # @return [Identifier, nil] A cached Identifier, or nil if invalid
59
+ #
60
+ # @example
61
+ # Parser.safe_parse("C") # => #<Sashite::Sin::Identifier C>
62
+ # Parser.safe_parse("") # => nil
63
+ # Parser.safe_parse(nil) # => nil
64
+ def self.safe_parse(input)
65
+ return unless ::String === input
66
+ return unless input.bytesize == 1
67
+
68
+ Identifier::BYTE_POOL[input.getbyte(0)]
41
69
  end
42
70
 
43
71
  # Reports whether the input is a valid SIN string.
44
72
  #
73
+ # Never raises; returns false for any invalid input including non-String.
74
+ #
45
75
  # @param input [String] The string to validate
46
76
  # @return [Boolean] true if valid, false otherwise
47
77
  #
@@ -51,87 +81,20 @@ module Sashite
51
81
  # Parser.valid?("") # => false
52
82
  # Parser.valid?("CC") # => false
53
83
  def self.valid?(input)
54
- parse(input)
55
- true
56
- rescue Errors::Argument
57
- false
84
+ !safe_parse(input).nil?
58
85
  end
59
86
 
60
- # @!group Private Class Methods
61
-
62
- # Validates that input is a String.
87
+ # Determines the appropriate error message for an invalid input.
63
88
  #
64
- # @param input [Object] The input to validate
65
- # @raise [Errors::Argument] If input is not a String
66
- # @return [void]
67
- private_class_method def self.validate_input_type!(input)
68
- return if ::String === input
69
-
70
- raise Errors::Argument, Errors::Argument::Messages::MUST_BE_LETTER
89
+ # @param input [Object] The invalid input
90
+ # @return [String] A descriptive error message
91
+ private_class_method def self.error_message_for(input)
92
+ return Errors::Argument::Messages::MUST_BE_LETTER unless ::String === input
93
+ return Errors::Argument::Messages::EMPTY_INPUT if input.empty?
94
+ return Errors::Argument::Messages::INPUT_TOO_LONG if input.bytesize > 1
95
+
96
+ Errors::Argument::Messages::MUST_BE_LETTER
71
97
  end
72
-
73
- # Validates that input is not empty.
74
- #
75
- # @param input [String] The input to validate
76
- # @raise [Errors::Argument] If input is empty
77
- # @return [void]
78
- private_class_method def self.validate_not_empty!(input)
79
- return unless input.empty?
80
-
81
- raise Errors::Argument, Errors::Argument::Messages::EMPTY_INPUT
82
- end
83
-
84
- # Validates that input does not exceed maximum length.
85
- #
86
- # @param input [String] The input to validate
87
- # @raise [Errors::Argument] If input exceeds maximum length
88
- # @return [void]
89
- private_class_method def self.validate_length!(input)
90
- return if input.bytesize <= Constants::MAX_STRING_LENGTH
91
-
92
- raise Errors::Argument, Errors::Argument::Messages::INPUT_TOO_LONG
93
- end
94
-
95
- # Validates that byte is an ASCII letter.
96
- #
97
- # @param byte [Integer] The byte to validate
98
- # @raise [Errors::Argument] If byte is not a letter
99
- # @return [void]
100
- private_class_method def self.validate_letter!(byte)
101
- return if uppercase_letter?(byte) || lowercase_letter?(byte)
102
-
103
- raise Errors::Argument, Errors::Argument::Messages::MUST_BE_LETTER
104
- end
105
-
106
- # Extracts abbr and side from a validated byte.
107
- #
108
- # @param byte [Integer] A validated ASCII letter byte
109
- # @return [Hash] Hash with :abbr and :side keys
110
- private_class_method def self.extract_components(byte)
111
- if uppercase_letter?(byte)
112
- { abbr: byte.chr.to_sym, side: :first }
113
- else
114
- { abbr: byte.chr.upcase.to_sym, side: :second }
115
- end
116
- end
117
-
118
- # Reports whether byte is an uppercase ASCII letter (A-Z).
119
- #
120
- # @param byte [Integer] The byte to check
121
- # @return [Boolean] true if A-Z
122
- private_class_method def self.uppercase_letter?(byte)
123
- byte >= 0x41 && byte <= 0x5A
124
- end
125
-
126
- # Reports whether byte is a lowercase ASCII letter (a-z).
127
- #
128
- # @param byte [Integer] The byte to check
129
- # @return [Boolean] true if a-z
130
- private_class_method def self.lowercase_letter?(byte)
131
- byte >= 0x61 && byte <= 0x7A
132
- end
133
-
134
- # @!endgroup
135
98
  end
136
99
  end
137
100
  end
data/lib/sashite/sin.rb CHANGED
@@ -15,15 +15,26 @@ module Sashite
15
15
  # - Uppercase (A-Z) indicates first player
16
16
  # - Lowercase (a-z) indicates second player
17
17
  #
18
+ # All 52 valid identifiers are pre-instantiated and frozen at load time.
19
+ # Every public method returns a cached instance — zero allocation on the
20
+ # hot path.
21
+ #
18
22
  # @example Parsing SIN strings
19
23
  # sin = Sashite::Sin.parse("C")
20
24
  # sin.abbr # => :C
21
25
  # sin.side # => :first
22
26
  # sin.to_s # => "C"
23
27
  #
24
- # @example Creating identifiers directly
25
- # sin = Sashite::Sin::Identifier.new(:C, :first)
26
- # sin.to_s # => "C"
28
+ # @example Safe parsing (no exceptions)
29
+ # Sashite::Sin.safe_parse("C") # => #<Sashite::Sin::Identifier C>
30
+ # Sashite::Sin.safe_parse("1") # => nil
31
+ # Sashite::Sin.safe_parse(nil) # => nil
32
+ #
33
+ # @example Direct component lookup (fastest path)
34
+ # Sashite::Sin.fetch(:C, :first) # => #<Sashite::Sin::Identifier C>
35
+ #
36
+ # @example Identity guarantee (flyweight)
37
+ # Sashite::Sin.parse("C").equal?(Sashite::Sin.fetch(:C, :first)) # => true
27
38
  #
28
39
  # @example Validation
29
40
  # Sashite::Sin.valid?("C") # => true
@@ -31,10 +42,13 @@ module Sashite
31
42
  #
32
43
  # @see https://sashite.dev/specs/sin/1.0.0/
33
44
  module Sin
34
- # Parses a SIN string into an Identifier.
45
+ # Parses a SIN string into a cached Identifier.
46
+ #
47
+ # Returns a pre-instantiated, frozen instance.
48
+ # Raises ArgumentError if the string is not valid.
35
49
  #
36
- # @param input [String] The SIN string to parse
37
- # @return [Identifier] The parsed identifier
50
+ # @param input [String] SIN string
51
+ # @return [Identifier] A cached, frozen Identifier
38
52
  # @raise [Errors::Argument] If the input is invalid
39
53
  #
40
54
  # @example Parsing uppercase (first player)
@@ -47,14 +61,55 @@ module Sashite
47
61
  # sin.abbr # => :C
48
62
  # sin.side # => :second
49
63
  def self.parse(input)
50
- components = Parser.parse(input)
64
+ Parser.parse(input)
65
+ end
51
66
 
52
- Identifier.new(components.fetch(:abbr), components.fetch(:side))
67
+ # Parses a SIN string without raising.
68
+ #
69
+ # Returns a cached Identifier on success, nil on failure.
70
+ # Never allocates exception objects or captures backtraces.
71
+ #
72
+ # @param input [String] SIN string
73
+ # @return [Identifier, nil] A cached Identifier, or nil if invalid
74
+ #
75
+ # @example
76
+ # Sashite::Sin.safe_parse("C") # => #<Sashite::Sin::Identifier C>
77
+ # Sashite::Sin.safe_parse("") # => nil
78
+ # Sashite::Sin.safe_parse(nil) # => nil
79
+ def self.safe_parse(input)
80
+ Parser.safe_parse(input)
81
+ end
82
+
83
+ # Retrieves a cached Identifier by abbreviation and side.
84
+ #
85
+ # Bypasses string parsing entirely — direct hash lookup into the
86
+ # flyweight pool. This is the fastest path for callers that already
87
+ # have structured data.
88
+ #
89
+ # @param abbr [Symbol] Style abbreviation (:A through :Z)
90
+ # @param side [Symbol] Player side (:first or :second)
91
+ # @return [Identifier] A cached, frozen Identifier
92
+ # @raise [Errors::Argument] If components are invalid
93
+ #
94
+ # @example
95
+ # Sashite::Sin.fetch(:C, :first) # => #<Sashite::Sin::Identifier C>
96
+ # Sashite::Sin.fetch(:C, :second) # => #<Sashite::Sin::Identifier c>
97
+ def self.fetch(abbr, side)
98
+ Identifier::POOL.fetch([abbr, side]) do
99
+ if !Identifier::VALID_ABBRS.include?(abbr)
100
+ raise Errors::Argument, Errors::Argument::Messages::INVALID_ABBR
101
+ else
102
+ raise Errors::Argument, Errors::Argument::Messages::INVALID_SIDE
103
+ end
104
+ end
53
105
  end
54
106
 
55
- # Reports whether the input is a valid SIN string.
107
+ # Reports whether string is a valid SIN identifier.
108
+ #
109
+ # Never raises; returns false for any invalid input including non-String.
110
+ # Uses an exception-free code path internally for performance.
56
111
  #
57
- # @param input [String] The string to validate
112
+ # @param input [String] SIN string
58
113
  # @return [Boolean] true if valid, false otherwise
59
114
  #
60
115
  # @example
@@ -63,6 +118,7 @@ module Sashite
63
118
  # Sashite::Sin.valid?("") # => false
64
119
  # Sashite::Sin.valid?("CC") # => false
65
120
  # Sashite::Sin.valid?("1") # => false
121
+ # Sashite::Sin.valid?(nil) # => false
66
122
  def self.valid?(input)
67
123
  Parser.valid?(input)
68
124
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sashite-sin
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.1.0
4
+ version: 3.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cyril Kato
@@ -53,7 +53,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
53
53
  - !ruby/object:Gem::Version
54
54
  version: '0'
55
55
  requirements: []
56
- rubygems_version: 4.0.3
56
+ rubygems_version: 4.0.5
57
57
  specification_version: 4
58
58
  summary: SIN (Style Identifier Notation) implementation for Ruby with immutable identifier
59
59
  objects