sashite-epin 2.2.1 → 2.3.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: b8b167c50614f455cefd311bd88371733b9814812cf2f4d088df9e236f3c1750
4
- data.tar.gz: 92584852d10ecb7c170c487c8aa6dce7de4f93a554cb65d9b598b465a6045568
3
+ metadata.gz: 4ac0199440bdaa03bc8fc740c81d9b2dcc3981696cae3918912514f6cf7f9432
4
+ data.tar.gz: 92c9b3f52c29c94927cdf34cb5693f7809d163b48d5a53afe96c22f58899aa0f
5
5
  SHA512:
6
- metadata.gz: 53e1413b668e75ae17fdb287b414ca7674da8d8f3b850235901b945ddd9f8d3bc8fa6565b6ab39d93c6b9d967b2989693036a9961656245059195d3ff63015d9
7
- data.tar.gz: ac63eae948c8c503d68361ded298793c7798064a17c3e4dc009bf1d757c6651379e0585bd3aa76f3b0617b6a77375101c05e3914bb50dfa78a184d6d0ae030c9
6
+ metadata.gz: 6164416ce6ebc9a9c3c5b279e065dbf29209b7567b0e0da87d6a10c56801396d1383edcfe91807fb70d79bb776f0bf5059b1f7a589a0b19c0c36385a445b93f3
7
+ data.tar.gz: 795d504a06ef64d1acc3c246fe3e0ef3055b55b19373baf756a3399f93050bb5c9da6604d917b44a59bd7bda6d547db6d7d4189c8a04bc7b154ffe49d80ee6af
data/README.md CHANGED
@@ -11,7 +11,17 @@
11
11
 
12
12
  This library implements the [EPIN Specification v1.0.0](https://sashite.dev/specs/epin/1.0.0/).
13
13
 
14
- EPIN extends [PIN](https://sashite.dev/specs/pin/1.0.0/) with an optional derivation marker (`'`) that flags whether a piece uses a native or derived style.
14
+ EPIN extends [PIN](https://sashite.dev/specs/pin/1.0.0/) with an optional derivation marker (`'`) that flags whether a piece uses a native or derived style. Every valid PIN token is also a valid EPIN token (native by default).
15
+
16
+ ### Implementation Constraints
17
+
18
+ | Constraint | Value | Rationale |
19
+ |------------|-------|-----------|
20
+ | Token length | 1–4 characters | `[+-]?[A-Za-z]\^?'?` per spec |
21
+ | Character space | 624 tokens | 312 PIN tokens × 2 derivation statuses |
22
+ | Instance pool | 624 objects | All identifiers are pre-instantiated and frozen |
23
+
24
+ The closed domain of 624 possible values enables a flyweight architecture with zero allocation on the hot path.
15
25
 
16
26
  ## Installation
17
27
 
@@ -55,60 +65,78 @@ epin.pin.terminal? # => true
55
65
  epin.derived? # => true
56
66
  epin.native? # => false
57
67
 
58
- # PIN component is a full Sashite::Pin::Identifier instance
68
+ # PIN component is a cached Sashite::Pin::Identifier instance
59
69
  epin.pin.enhanced? # => false
60
70
  epin.pin.first_player? # => true
61
71
 
72
+ # Returns a cached instance — no allocation
73
+ Sashite::Epin.parse("K^'").equal?(Sashite::Epin.parse("K^'")) # => true
74
+
62
75
  # Invalid input raises ArgumentError
63
76
  Sashite::Epin.parse("invalid") # => raises ArgumentError
64
77
  ```
65
78
 
66
- ### Formatting (IdentifierString)
79
+ ### Safe Parsing (StringIdentifier | nil)
67
80
 
68
- Convert an `Identifier` back to an EPIN string.
81
+ Parse without raising exceptions. Returns `nil` on invalid input.
69
82
 
70
83
  ```ruby
71
- # From PIN component
84
+ # Valid input returns an Identifier
85
+ Sashite::Epin.safe_parse("K^'") # => #<Sashite::Epin::Identifier K^'>
86
+ Sashite::Epin.safe_parse("+R") # => #<Sashite::Epin::Identifier +R>
87
+
88
+ # Invalid input returns nil — no exception allocated
89
+ Sashite::Epin.safe_parse("") # => nil
90
+ Sashite::Epin.safe_parse("invalid") # => nil
91
+ Sashite::Epin.safe_parse("K''") # => nil
92
+ Sashite::Epin.safe_parse(nil) # => nil
93
+ ```
94
+
95
+ ### Fetching by Components (Pin::Identifier, ... → Identifier)
96
+
97
+ Retrieve a cached identifier directly by components, bypassing string parsing entirely.
98
+
99
+ ```ruby
100
+ # From a cached PIN instance — direct hash lookup, no allocation
72
101
  pin = Sashite::Pin.parse("K^")
73
- epin = Sashite::Epin::Identifier.new(pin)
74
- epin.to_s # => "K^"
102
+ Sashite::Epin.fetch(pin) # => #<Sashite::Epin::Identifier K^>
103
+ Sashite::Epin.fetch(pin, derived: true) # => #<Sashite::Epin::Identifier K^'>
104
+
105
+ # Same cached instance as parse
106
+ Sashite::Epin.fetch(pin, derived: true).equal?(Sashite::Epin.parse("K^'")) # => true
107
+
108
+ # Invalid PIN raises ArgumentError
109
+ Sashite::Epin.fetch(nil) # => raises ArgumentError
110
+ ```
111
+
112
+ ### Formatting (Identifier → String)
75
113
 
76
- # With derivation
77
- epin = Sashite::Epin::Identifier.new(pin, derived: true)
114
+ Convert an `Identifier` back to an EPIN string.
115
+
116
+ ```ruby
117
+ epin = Sashite::Epin.parse("K^'")
78
118
  epin.to_s # => "K^'"
119
+
120
+ epin = Sashite::Epin.parse("+r")
121
+ epin.to_s # => "+r"
79
122
  ```
80
123
 
81
124
  ### Validation
82
125
 
83
126
  ```ruby
84
- # Boolean check
127
+ # Boolean check (never raises)
128
+ # Uses an exception-free code path internally for performance.
85
129
  Sashite::Epin.valid?("K") # => true
86
130
  Sashite::Epin.valid?("+R^'") # => true
87
131
  Sashite::Epin.valid?("invalid") # => false
88
132
  Sashite::Epin.valid?("K''") # => false
89
133
  Sashite::Epin.valid?("K'^") # => false
90
- ```
91
-
92
- ### Accessing Components
93
-
94
- ```ruby
95
- epin = Sashite::Epin.parse("+R^'")
96
-
97
- # Get PIN component
98
- epin.pin # => #<Sashite::Pin::Identifier +R^>
99
- epin.pin.to_s # => "+R^"
100
-
101
- # Check derivation
102
- epin.derived? # => true
103
- epin.native? # => false
104
-
105
- # Serialize
106
- epin.to_s # => "+R^'"
134
+ Sashite::Epin.valid?(nil) # => false
107
135
  ```
108
136
 
109
137
  ### Transformations
110
138
 
111
- All transformations return new immutable instances.
139
+ All transformations return cached instances from the flyweight pool — no new object is ever allocated.
112
140
 
113
141
  ```ruby
114
142
  epin = Sashite::Epin.parse("K^")
@@ -120,10 +148,15 @@ epin.native.to_s # => "K^"
120
148
  # Replace PIN component
121
149
  new_pin = Sashite::Pin.parse("+Q^")
122
150
  epin.with_pin(new_pin).to_s # => "+Q^"
151
+
152
+ # Transformations return cached instances
153
+ epin.derive.equal?(Sashite::Epin.parse("K^'")) # => true
123
154
  ```
124
155
 
125
156
  ### Transform via PIN Component
126
157
 
158
+ PIN transformations also return cached instances, so the entire chain is allocation-free.
159
+
127
160
  ```ruby
128
161
  epin = Sashite::Epin.parse("K^'")
129
162
 
@@ -140,6 +173,23 @@ epin.with_pin(epin.pin.flip).to_s # => "k^'"
140
173
  epin.with_pin(epin.pin.non_terminal).to_s # => "K'"
141
174
  ```
142
175
 
176
+ ### Accessing Components
177
+
178
+ ```ruby
179
+ epin = Sashite::Epin.parse("+R^'")
180
+
181
+ # Get PIN component (cached Pin::Identifier instance)
182
+ epin.pin # => #<Sashite::Pin::Identifier +R^>
183
+ epin.pin.to_s # => "+R^"
184
+
185
+ # Check derivation
186
+ epin.derived? # => true
187
+ epin.native? # => false
188
+
189
+ # Serialize
190
+ epin.to_s # => "+R^'"
191
+ ```
192
+
143
193
  ### Component Queries
144
194
 
145
195
  Use the PIN API directly:
@@ -166,22 +216,68 @@ epin.pin.same_state?(other.pin) # => true
166
216
  epin.same_derived?(other) # => false
167
217
  ```
168
218
 
219
+ ### PIN Compatibility
220
+
221
+ Every valid PIN is a valid EPIN (native by default):
222
+
223
+ ```ruby
224
+ %w[K +R -p K^ +R^].each do |pin_token|
225
+ epin = Sashite::Epin.parse(pin_token)
226
+ epin.native? # => true
227
+ epin.to_s # => pin_token
228
+ end
229
+ ```
230
+
169
231
  ## API Reference
170
232
 
171
- ### Types
233
+ ### Module Methods
234
+
235
+ ```ruby
236
+ # Parses an EPIN string into a cached Identifier.
237
+ # Returns a pre-instantiated, frozen instance.
238
+ # Raises ArgumentError if the string is not valid.
239
+ #
240
+ # @param string [String] EPIN string
241
+ # @return [Identifier]
242
+ # @raise [ArgumentError] if invalid
243
+ def Sashite::Epin.parse(string)
244
+
245
+ # Parses an EPIN string without raising.
246
+ # Returns a cached Identifier on success, nil on failure.
247
+ # Never allocates exception objects or captures backtraces.
248
+ # Delegates to Pin.safe_parse for the PIN component.
249
+ #
250
+ # @param string [String] EPIN string
251
+ # @return [Identifier, nil]
252
+ def Sashite::Epin.safe_parse(string)
253
+
254
+ # Retrieves a cached Identifier by PIN component and derivation status.
255
+ # Bypasses string parsing entirely — direct hash lookup.
256
+ # Raises ArgumentError if the PIN is invalid.
257
+ #
258
+ # @param pin [Sashite::Pin::Identifier] PIN component
259
+ # @param derived [Boolean] Derived status
260
+ # @return [Identifier]
261
+ # @raise [ArgumentError] if invalid
262
+ def Sashite::Epin.fetch(pin, derived: false)
263
+
264
+ # Reports whether string is a valid EPIN.
265
+ # Never raises; returns false for any invalid input.
266
+ # Uses an exception-free code path internally for performance.
267
+ #
268
+ # @param string [String] EPIN string
269
+ # @return [Boolean]
270
+ def Sashite::Epin.valid?(string)
271
+ ```
272
+
273
+ ### Identifier
172
274
 
173
275
  ```ruby
174
276
  # Identifier represents a parsed EPIN combining PIN with derivation status.
277
+ # All instances are frozen and pre-instantiated — never construct directly,
278
+ # use Sashite::Epin.parse, .safe_parse, or .fetch instead.
175
279
  class Sashite::Epin::Identifier
176
- # Creates an Identifier from a PIN component.
177
- # Raises ArgumentError if the PIN is invalid.
178
- #
179
- # @param pin [Sashite::Pin::Identifier] PIN component
180
- # @param derived [Boolean] Derived status
181
- # @return [Identifier]
182
- def initialize(pin, derived: false)
183
-
184
- # Returns the PIN component.
280
+ # Returns the PIN component (a cached Sashite::Pin::Identifier instance).
185
281
  #
186
282
  # @return [Sashite::Pin::Identifier]
187
283
  def pin
@@ -203,32 +299,12 @@ class Sashite::Epin::Identifier
203
299
  end
204
300
  ```
205
301
 
206
- ### Parsing
207
-
208
- ```ruby
209
- # Parses an EPIN string into an Identifier.
210
- # Raises ArgumentError if the string is not valid.
211
- #
212
- # @param string [String] EPIN string
213
- # @return [Identifier]
214
- # @raise [ArgumentError] if invalid
215
- def Sashite::Epin.parse(string)
216
- ```
217
-
218
- ### Validation
219
-
220
- ```ruby
221
- # Reports whether string is a valid EPIN.
222
- #
223
- # @param string [String] EPIN string
224
- # @return [Boolean]
225
- def Sashite::Epin.valid?(string)
226
- ```
227
-
228
302
  ### Transformations
229
303
 
304
+ All transformations return cached `Sashite::Epin::Identifier` instances from the flyweight pool:
305
+
230
306
  ```ruby
231
- # PIN replacement (returns new Identifier)
307
+ # PIN replacement (returns cached Identifier)
232
308
  def with_pin(new_pin) # => Identifier with different PIN
233
309
 
234
310
  # Derivation transformations
@@ -238,32 +314,39 @@ def native # => Identifier with derived: false
238
314
 
239
315
  ### Errors
240
316
 
241
- All parsing and validation errors raise `ArgumentError` with descriptive messages:
317
+ All errors raise `ArgumentError` with descriptive messages:
242
318
 
243
319
  | Message | Cause |
244
320
  |---------|-------|
245
321
  | `"invalid derivation marker"` | Derivation marker misplaced or duplicated |
246
322
  | `"invalid PIN component: ..."` | PIN parsing failed |
247
323
 
248
- ## PIN Compatibility
249
-
250
- Every valid PIN is a valid EPIN (native by default):
251
-
252
- ```ruby
253
- %w[K +R -p K^ +R^].each do |pin_token|
254
- epin = Sashite::Epin.parse(pin_token)
255
- epin.native? # => true
256
- epin.to_s # => pin_token
257
- end
258
- ```
259
-
260
324
  ## Design Principles
261
325
 
262
- - **Pure composition**: EPIN composes PIN without reimplementing features
263
- - **Minimal API**: Core methods (`pin`, `derived?`, `native?`, `to_s`) plus transformations
326
+ - **Spec conformance**: Strict adherence to EPIN v1.0.0
327
+ - **Flyweight identifiers**: All 624 possible instances are pre-built and frozen; parsing, fetching, and transformations return cached objects with zero allocation
328
+ - **Pure composition**: EPIN composes PIN without reimplementing features; each EPIN instance holds a cached `Pin::Identifier` from PIN's own flyweight pool
329
+ - **Performance-oriented internals**: Exception-free validation path; exceptions only at the public API boundary
264
330
  - **Component transparency**: Access PIN directly, no wrapper methods
265
- - **Immutable identifiers**: Frozen instances prevent mutation
266
331
  - **Ruby idioms**: `valid?` predicate, `to_s` conversion, `ArgumentError` for invalid input
332
+ - **Immutable identifiers**: All instances are frozen after creation
333
+
334
+ ### Performance Architecture
335
+
336
+ EPIN has a closed domain of exactly 624 valid tokens (312 PIN tokens × 2 derivation statuses). The implementation exploits this constraint through three complementary strategies, composing cleanly with PIN's own flyweight pool.
337
+
338
+ **Flyweight instance pool** — All 624 `Identifier` objects are pre-instantiated and frozen at load time. Each holds a reference to a cached `Pin::Identifier` from PIN's own pool — no duplication of PIN objects. `parse`, `safe_parse`, `fetch`, and all transformation methods return these cached instances via hash lookup. No `Identifier` is ever allocated after the module loads.
339
+
340
+ **Dual-path parsing** — Parsing is split into two layers to avoid using exceptions for control flow:
341
+
342
+ - **Validation layer** — `safe_parse` strips the trailing `'` if present, delegates to `Pin.safe_parse` for the core token, and returns the cached `Identifier` on success or `nil` on failure. The entire path — EPIN and PIN combined — never raises, never allocates exception objects, and never captures backtraces.
343
+ - **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.
344
+
345
+ **Zero-allocation transformations** — `derive`, `native`, and `with_pin` compute the target components and perform a direct lookup into the instance pool. Since PIN transformations (`flip`, `enhance`, etc.) also return cached instances, chaining like `epin.with_pin(epin.pin.flip.enhance).derive` performs only hash lookups across both pools — zero allocations end to end.
346
+
347
+ **Direct component lookup** — `fetch` accepts a `Pin::Identifier` and a derivation flag, performing a single hash lookup. This is the fastest path for callers that already have structured data (e.g., FEEN's piece placement parser constructing EPIN identifiers from already-parsed PIN components).
348
+
349
+ This architecture ensures that the full SIN → PIN → EPIN → FEEN stack maintains allocation-free hot paths from bottom to top.
267
350
 
268
351
  ## Related Specifications
269
352
 
@@ -5,11 +5,17 @@ module Sashite
5
5
  # Constants for EPIN (Extended Piece Identifier Notation).
6
6
  #
7
7
  # EPIN extends PIN with a single derivation marker.
8
- # PIN constants (VALID_TYPES, VALID_SIDES, VALID_STATES, etc.)
8
+ # PIN constants (VALID_ABBRS, VALID_SIDES, VALID_STATES, etc.)
9
9
  # are accessed through the sashite-pin dependency.
10
10
  module Constants
11
11
  # Derivation marker suffix.
12
12
  DERIVATION_SUFFIX = "'"
13
+
14
+ # Maximum length of a valid EPIN string: [+-]?[A-Za-z]\^?'?
15
+ MAX_STRING_LENGTH = 4
16
+
17
+ # Total number of valid EPIN tokens (312 PIN tokens × 2 derivation statuses).
18
+ POOL_SIZE = 624
13
19
  end
14
20
  end
15
21
  end
@@ -13,17 +13,20 @@ module Sashite
13
13
  # - PIN: encodes abbr, side, state, and terminal status
14
14
  # - Derived: indicates whether the piece uses native or derived style
15
15
  #
16
- # Instances are immutable (frozen after creation).
16
+ # All 624 possible instances (312 PIN tokens × 2 derivation statuses) are
17
+ # pre-instantiated and frozen at load time. Parsing, fetching, and all
18
+ # transformation methods return these cached instances via hash lookup —
19
+ # no Identifier is ever allocated after the module loads.
17
20
  #
18
- # @example Creating identifiers
21
+ # @example Access via module methods (recommended)
22
+ # epin = Sashite::Epin.parse("K^'")
23
+ # epin = Sashite::Epin.fetch(pin, derived: true)
24
+ #
25
+ # @example Direct construction (mainly for tests / pool building)
19
26
  # pin = Sashite::Pin.parse("K^")
20
27
  # epin = Identifier.new(pin)
21
28
  # epin = Identifier.new(pin, derived: true)
22
29
  #
23
- # @example String conversion
24
- # Identifier.new(pin).to_s # => "K^"
25
- # Identifier.new(pin, derived: true).to_s # => "K^'"
26
- #
27
30
  # @see https://sashite.dev/specs/epin/1.0.0/
28
31
  class Identifier
29
32
  # @return [Sashite::Pin::Identifier] PIN component
@@ -41,11 +44,13 @@ module Sashite
41
44
  # Identifier.new(pin)
42
45
  # Identifier.new(pin, derived: true)
43
46
  def initialize(pin, derived: false)
44
- validate_pin!(pin)
45
- validate_derived!(derived)
47
+ raise Errors::Argument, Errors::Argument::Messages::INVALID_PIN unless ::Sashite::Pin::Identifier === pin
48
+ raise Errors::Argument, Errors::Argument::Messages::INVALID_DERIVED unless true == derived || false == derived
46
49
 
47
50
  @pin = pin
48
51
  @derived = derived
52
+ @string = (derived ? "#{pin}#{Constants::DERIVATION_SUFFIX}" : pin.to_s).freeze
53
+ @hash = (pin.hash ^ (derived ? 1 : 0).hash).freeze
49
54
 
50
55
  freeze
51
56
  end
@@ -77,6 +82,7 @@ module Sashite
77
82
  # ========================================================================
78
83
 
79
84
  # Returns the EPIN string representation.
85
+ # Pre-computed at construction time — zero allocation.
80
86
  #
81
87
  # @return [String] The EPIN string
82
88
  #
@@ -84,53 +90,55 @@ module Sashite
84
90
  # Identifier.new(pin).to_s # => "K^"
85
91
  # Identifier.new(pin, derived: true).to_s # => "K^'"
86
92
  def to_s
87
- derived? ? "#{pin}#{Constants::DERIVATION_SUFFIX}" : pin.to_s
93
+ @string
88
94
  end
89
95
 
90
96
  # ========================================================================
91
- # Transformations
97
+ # Transformations (pool lookups — zero allocation)
92
98
  # ========================================================================
93
99
 
94
- # Returns a new Identifier with a different PIN component.
100
+ # Returns a cached Identifier with a different PIN component.
95
101
  #
96
102
  # @param new_pin [Sashite::Pin::Identifier] The new PIN component
97
- # @return [Identifier] A new Identifier with the specified PIN
103
+ # @return [Identifier] A cached Identifier with the specified PIN
98
104
  # @raise [Errors::Argument] If the PIN is invalid
99
105
  #
100
106
  # @example
101
- # epin = Identifier.new(pin, derived: true)
107
+ # epin = Sashite::Epin.parse("K^'")
102
108
  # new_pin = Sashite::Pin.parse("+Q^")
103
109
  # epin.with_pin(new_pin).to_s # => "+Q^'"
104
110
  def with_pin(new_pin)
105
- return self if pin == new_pin
111
+ return self if pin.equal?(new_pin)
106
112
 
107
- self.class.new(new_pin, derived: @derived)
113
+ raise Errors::Argument, Errors::Argument::Messages::INVALID_PIN unless ::Sashite::Pin::Identifier === new_pin
114
+
115
+ self.class.fetch(new_pin, @derived)
108
116
  end
109
117
 
110
- # Returns a new Identifier marked as derived.
118
+ # Returns a cached Identifier marked as derived.
111
119
  #
112
- # @return [Identifier] A new Identifier with derived: true
120
+ # @return [Identifier] A cached Identifier with derived: true
113
121
  #
114
122
  # @example
115
- # epin = Identifier.new(pin)
123
+ # epin = Sashite::Epin.parse("K^")
116
124
  # epin.derive.to_s # => "K^'"
117
125
  def derive
118
- return self if derived?
126
+ return self if @derived
119
127
 
120
- self.class.new(pin, derived: true)
128
+ self.class.fetch(pin, true)
121
129
  end
122
130
 
123
- # Returns a new Identifier marked as native.
131
+ # Returns a cached Identifier marked as native.
124
132
  #
125
- # @return [Identifier] A new Identifier with derived: false
133
+ # @return [Identifier] A cached Identifier with derived: false
126
134
  #
127
135
  # @example
128
- # epin = Identifier.new(pin, derived: true)
136
+ # epin = Sashite::Epin.parse("K^'")
129
137
  # epin.native.to_s # => "K^"
130
138
  def native
131
- return self if native?
139
+ return self if !@derived
132
140
 
133
- self.class.new(pin, derived: false)
141
+ self.class.fetch(pin, false)
134
142
  end
135
143
 
136
144
  # ========================================================================
@@ -143,8 +151,8 @@ module Sashite
143
151
  # @return [Boolean] true if same derived status
144
152
  #
145
153
  # @example
146
- # epin1 = Identifier.new(pin1, derived: true)
147
- # epin2 = Identifier.new(pin2, derived: true)
154
+ # epin1 = Sashite::Epin.parse("K'")
155
+ # epin2 = Sashite::Epin.parse("Q'")
148
156
  # epin1.same_derived?(epin2) # => true
149
157
  def same_derived?(other)
150
158
  @derived == other.derived?
@@ -166,11 +174,11 @@ module Sashite
166
174
 
167
175
  alias eql? ==
168
176
 
169
- # Returns a hash code for the Identifier.
177
+ # Returns a pre-computed hash code for the Identifier.
170
178
  #
171
179
  # @return [Integer] Hash code
172
180
  def hash
173
- [pin, @derived].hash
181
+ @hash
174
182
  end
175
183
 
176
184
  # Returns an inspect string for the Identifier.
@@ -180,23 +188,43 @@ module Sashite
180
188
  "#<#{self.class} #{self}>"
181
189
  end
182
190
 
183
- private
184
-
185
191
  # ========================================================================
186
- # Private Validation
192
+ # Flyweight Pool (class-level)
187
193
  # ========================================================================
188
194
 
189
- def validate_pin!(pin)
190
- return if ::Sashite::Pin::Identifier === pin
191
-
192
- raise Errors::Argument, Errors::Argument::Messages::INVALID_PIN
195
+ class << self
196
+ # Retrieves a cached Identifier from the flyweight pool.
197
+ # Direct hash lookup — no validation, no allocation.
198
+ #
199
+ # @param pin [Sashite::Pin::Identifier] PIN component (must be from PIN's pool)
200
+ # @param derived [Boolean] Derived status
201
+ # @return [Identifier] A cached Identifier
202
+ # @api private
203
+ def fetch(pin, derived)
204
+ derived ? @derived_pool[pin] : @native_pool[pin]
205
+ end
193
206
  end
194
207
 
195
- def validate_derived!(derived)
196
- return if ::TrueClass === derived || ::FalseClass === derived
197
-
198
- raise Errors::Argument, Errors::Argument::Messages::INVALID_DERIVED
208
+ # Build the flyweight pool at load time.
209
+ # Two separate hashes (native/derived) keyed by PIN instance
210
+ # for single-lookup access with no Array allocation for the key.
211
+ @native_pool = {}
212
+ @derived_pool = {}
213
+
214
+ ::Sashite::Pin::Constants::VALID_ABBRS.each do |abbr|
215
+ ::Sashite::Pin::Constants::VALID_SIDES.each do |side|
216
+ ::Sashite::Pin::Constants::VALID_STATES.each do |state|
217
+ [false, true].each do |terminal|
218
+ pin = ::Sashite::Pin.fetch(abbr, side, state, terminal: terminal)
219
+ @native_pool[pin] = new(pin, derived: false)
220
+ @derived_pool[pin] = new(pin, derived: true)
221
+ end
222
+ end
223
+ end
199
224
  end
225
+
226
+ @native_pool.freeze
227
+ @derived_pool.freeze
200
228
  end
201
229
  end
202
230
  end
@@ -4,40 +4,69 @@ require "sashite/pin"
4
4
 
5
5
  require_relative "constants"
6
6
  require_relative "errors"
7
+ require_relative "identifier"
7
8
 
8
9
  module Sashite
9
10
  module Epin
10
11
  # Parser for EPIN (Extended Piece Identifier Notation) strings.
11
12
  #
12
- # This parser extracts the derivation marker and delegates PIN parsing
13
- # to the sashite-pin library.
13
+ # Dual-path architecture for performance:
14
+ # - {safe_parse}: core path — returns a cached Identifier or nil.
15
+ # Never raises, never allocates exceptions, never captures backtraces.
16
+ # - {parse}: public API — delegates to safe_parse, raises once at boundary on failure.
17
+ # - {valid?}: boolean wrapper around safe_parse — never raises.
14
18
  #
15
19
  # @example
16
- # Parser.parse("K") # => { pin: { abbr: :K, side: :first, ... }, derived: false }
17
- # Parser.parse("K^'") # => { pin: { abbr: :K, side: :first, ..., terminal: true }, derived: true }
20
+ # Parser.safe_parse("K^'") # => #<Sashite::Epin::Identifier K^'>
21
+ # Parser.safe_parse("bad") # => nil
22
+ # Parser.parse("K^'") # => #<Sashite::Epin::Identifier K^'>
23
+ # Parser.valid?("K^'") # => true
18
24
  #
19
25
  # @see https://sashite.dev/specs/epin/1.0.0/
20
26
  module Parser
21
- # Parses an EPIN string into its components.
27
+ # ASCII byte value of the derivation marker.
28
+ APOSTROPHE_BYTE = 39 # ' = 0x27
29
+
30
+ private_constant :APOSTROPHE_BYTE
31
+
32
+ # Parses an EPIN string without raising an exception.
33
+ # This is the core parsing path — all other methods delegate here.
22
34
  #
23
35
  # @param input [String] The EPIN string to parse
24
- # @return [Hash] A hash with :pin (PIN components hash) and :derived keys
25
- # @raise [Errors::Argument] If the input is not a valid EPIN string
26
- def self.parse(input)
27
- validate_string!(input)
36
+ # @return [Identifier, nil] A cached Identifier on success, nil on failure
37
+ def self.safe_parse(input)
38
+ return nil unless ::String === input
39
+
40
+ len = input.bytesize
41
+ return nil if len == 0 || len > Constants::MAX_STRING_LENGTH
28
42
 
29
- derived = has_derivation_marker?(input)
43
+ # Check derivation marker: single byte check on last position.
44
+ if input.getbyte(len - 1) == APOSTROPHE_BYTE
45
+ pin = ::Sashite::Pin.safe_parse(input.byteslice(0, len - 1))
46
+ return nil if pin.nil?
30
47
 
31
- if derived
32
- validate_derivation_marker!(input)
33
- pin_string = input.chop
48
+ Identifier.fetch(pin, true)
34
49
  else
35
- pin_string = input
50
+ pin = ::Sashite::Pin.safe_parse(input)
51
+ return nil if pin.nil?
52
+
53
+ Identifier.fetch(pin, false)
36
54
  end
55
+ end
37
56
 
38
- pin_components = parse_pin_component(pin_string)
57
+ # Parses an EPIN string into a cached Identifier.
58
+ # Delegates to {safe_parse} for the happy path.
59
+ # On failure, performs detailed validation to raise a precise error.
60
+ #
61
+ # @param input [String] The EPIN string to parse
62
+ # @return [Identifier] A cached Identifier
63
+ # @raise [Errors::Argument] If the input is not a valid EPIN string
64
+ def self.parse(input)
65
+ result = safe_parse(input)
66
+ return result unless result.nil?
39
67
 
40
- { pin: pin_components, derived: derived }
68
+ # Slow path: detailed validation for precise error messages.
69
+ raise_parse_error!(input)
41
70
  end
42
71
 
43
72
  # Validates an EPIN string without raising an exception.
@@ -45,57 +74,45 @@ module Sashite
45
74
  # @param input [String] The EPIN string to validate
46
75
  # @return [Boolean] true if valid, false otherwise
47
76
  def self.valid?(input)
48
- return false unless ::String === input
49
-
50
- parse(input)
51
- true
52
- rescue Errors::Argument
53
- false
77
+ !safe_parse(input).nil?
54
78
  end
55
79
 
56
80
  class << self
57
81
  private
58
82
 
59
- # Validates that input is a String.
60
- #
61
- # @param input [Object] The input to validate
62
- # @raise [Errors::Argument] If input is not a String
63
- def validate_string!(input)
64
- return if ::String === input
65
-
66
- raise Errors::Argument, "invalid PIN component: must contain exactly one letter"
67
- end
68
-
69
- # Checks if the input contains a derivation marker.
70
- #
71
- # @param input [String] The input to check
72
- # @return [Boolean] true if contains derivation marker
73
- def has_derivation_marker?(input)
74
- input.include?(Constants::DERIVATION_SUFFIX)
75
- end
76
-
77
- # Validates derivation marker position and uniqueness.
78
- #
79
- # @param input [String] The input to validate
80
- # @raise [Errors::Argument] If derivation marker is invalid
81
- def validate_derivation_marker!(input)
82
- count = input.count(Constants::DERIVATION_SUFFIX)
83
- last_char = input[-1]
84
-
85
- return if count == 1 && last_char == Constants::DERIVATION_SUFFIX
86
-
87
- raise Errors::Argument, Errors::Argument::Messages::INVALID_DERIVATION_MARKER
88
- end
89
-
90
- # Parses the PIN component using sashite-pin.
83
+ # Performs detailed validation on an already-known-invalid input
84
+ # to produce a precise error message.
91
85
  #
92
- # @param pin_string [String] The PIN string to parse
93
- # @return [Hash] PIN components hash
94
- # @raise [Errors::Argument] If PIN parsing fails
95
- def parse_pin_component(pin_string)
96
- ::Sashite::Pin::Parser.parse(pin_string)
97
- rescue ::Sashite::Pin::Errors::Argument => e
98
- raise Errors::Argument, "invalid PIN component: #{e.message}"
86
+ # @param input [Object] The invalid input
87
+ # @raise [Errors::Argument] Always raises with a descriptive message
88
+ def raise_parse_error!(input)
89
+ unless ::String === input
90
+ raise Errors::Argument, "invalid PIN component: must contain exactly one letter"
91
+ end
92
+
93
+ # Check derivation marker issues first.
94
+ if input.include?(Constants::DERIVATION_SUFFIX)
95
+ count = input.count(Constants::DERIVATION_SUFFIX)
96
+
97
+ unless count == 1 && input.getbyte(input.bytesize - 1) == APOSTROPHE_BYTE
98
+ raise Errors::Argument, Errors::Argument::Messages::INVALID_DERIVATION_MARKER
99
+ end
100
+
101
+ pin_string = input.chop
102
+ else
103
+ pin_string = input
104
+ end
105
+
106
+ # Delegate to PIN's raising parser for a precise PIN error message.
107
+ begin
108
+ ::Sashite::Pin::Parser.parse(pin_string)
109
+ rescue ::Sashite::Pin::Errors::Argument => e
110
+ raise Errors::Argument, "invalid PIN component: #{e.message}"
111
+ end
112
+
113
+ # Unreachable in normal operation: if safe_parse returned nil,
114
+ # one of the checks above should have raised. Guard against edge cases.
115
+ raise Errors::Argument, "invalid EPIN token"
99
116
  end
100
117
  end
101
118
  end
data/lib/sashite/epin.rb CHANGED
@@ -13,6 +13,10 @@ module Sashite
13
13
  # EPIN extends PIN with an optional derivation marker (') that flags
14
14
  # whether a piece uses a native or derived style.
15
15
  #
16
+ # All 624 possible identifiers (312 PIN tokens × 2 derivation statuses)
17
+ # are pre-instantiated and frozen at load time. Every public method
18
+ # returns a cached instance — zero allocation on the hot path.
19
+ #
16
20
  # == Format
17
21
  #
18
22
  # <pin>[']
@@ -36,10 +40,12 @@ module Sashite
36
40
  #
37
41
  # @see https://sashite.dev/specs/epin/1.0.0/
38
42
  module Epin
39
- # Parses an EPIN string into an Identifier.
43
+ # Parses an EPIN string into a cached Identifier.
44
+ # Returns a pre-instantiated, frozen instance.
45
+ # Raises ArgumentError if the string is not valid.
40
46
  #
41
47
  # @param string [String] The EPIN string to parse
42
- # @return [Identifier] A new Identifier instance
48
+ # @return [Identifier] A cached Identifier instance
43
49
  # @raise [Errors::Argument] If the string is not a valid EPIN
44
50
  #
45
51
  # @example
@@ -52,27 +58,59 @@ module Sashite
52
58
  # Sashite::Epin.parse("invalid")
53
59
  # # => raises Errors::Argument
54
60
  def self.parse(string)
55
- components = Parser.parse(string)
61
+ Parser.parse(string)
62
+ end
56
63
 
57
- pin = ::Sashite::Pin::Identifier.new(
58
- components[:pin][:abbr],
59
- components[:pin][:side],
60
- components[:pin][:state],
61
- terminal: components[:pin][:terminal]
62
- )
64
+ # Parses an EPIN string without raising.
65
+ # Returns a cached Identifier on success, nil on failure.
66
+ # Never allocates exception objects or captures backtraces.
67
+ # Delegates to Pin.safe_parse for the PIN component.
68
+ #
69
+ # @param string [String] The EPIN string to parse
70
+ # @return [Identifier, nil] A cached Identifier or nil
71
+ #
72
+ # @example
73
+ # Sashite::Epin.safe_parse("K^'") # => #<Sashite::Epin::Identifier K^'>
74
+ # Sashite::Epin.safe_parse("+R") # => #<Sashite::Epin::Identifier +R>
75
+ # Sashite::Epin.safe_parse("") # => nil
76
+ # Sashite::Epin.safe_parse("K''") # => nil
77
+ # Sashite::Epin.safe_parse(nil) # => nil
78
+ def self.safe_parse(string)
79
+ Parser.safe_parse(string)
80
+ end
81
+
82
+ # Retrieves a cached Identifier by PIN component and derivation status.
83
+ # Bypasses string parsing entirely — direct hash lookup.
84
+ # Raises ArgumentError if the PIN is invalid.
85
+ #
86
+ # @param pin [Sashite::Pin::Identifier] PIN component
87
+ # @param derived [Boolean] Derived status (default: false)
88
+ # @return [Identifier] A cached Identifier
89
+ # @raise [Errors::Argument] If the PIN is not a Sashite::Pin::Identifier
90
+ #
91
+ # @example
92
+ # pin = Sashite::Pin.parse("K^")
93
+ # Sashite::Epin.fetch(pin) # => #<Sashite::Epin::Identifier K^>
94
+ # Sashite::Epin.fetch(pin, derived: true) # => #<Sashite::Epin::Identifier K^'>
95
+ def self.fetch(pin, derived: false)
96
+ raise Errors::Argument, Errors::Argument::Messages::INVALID_PIN unless ::Sashite::Pin::Identifier === pin
97
+ raise Errors::Argument, Errors::Argument::Messages::INVALID_DERIVED unless true == derived || false == derived
63
98
 
64
- Identifier.new(pin, derived: components[:derived])
99
+ Identifier.fetch(pin, derived)
65
100
  end
66
101
 
67
- # Checks if a string is a valid EPIN notation.
102
+ # Reports whether string is a valid EPIN.
103
+ # Never raises; returns false for any invalid input.
104
+ # Uses an exception-free code path internally for performance.
68
105
  #
69
- # @param string [String] The string to validate
106
+ # @param string [String] The EPIN string to validate
70
107
  # @return [Boolean] true if valid, false otherwise
71
108
  #
72
109
  # @example
73
110
  # Sashite::Epin.valid?("K") # => true
74
111
  # Sashite::Epin.valid?("K^'") # => true
75
112
  # Sashite::Epin.valid?("invalid") # => false
113
+ # Sashite::Epin.valid?(nil) # => false
76
114
  def self.valid?(string)
77
115
  Parser.valid?(string)
78
116
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sashite-epin
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.2.1
4
+ version: 2.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cyril Kato
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: 4.1.0
18
+ version: 4.2.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: 4.1.0
25
+ version: 4.2.0
26
26
  description: |
27
27
  EPIN (Extended Piece Identifier Notation) implementation for Ruby.
28
28
  Extends PIN by adding a derivation marker to track piece style in cross-style
@@ -43,7 +43,7 @@ files:
43
43
  - lib/sashite/epin/parser.rb
44
44
  homepage: https://github.com/sashite/epin.rb
45
45
  licenses:
46
- - MIT
46
+ - Apache-2.0
47
47
  metadata:
48
48
  bug_tracker_uri: https://github.com/sashite/epin.rb/issues
49
49
  documentation_uri: https://rubydoc.info/github/sashite/epin.rb/main
@@ -65,7 +65,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
65
65
  - !ruby/object:Gem::Version
66
66
  version: '0'
67
67
  requirements: []
68
- rubygems_version: 4.0.3
68
+ rubygems_version: 4.0.5
69
69
  specification_version: 4
70
70
  summary: EPIN (Extended Piece Identifier Notation) implementation for Ruby extending
71
71
  PIN with style derivation markers.