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 +4 -4
- data/README.md +160 -77
- data/lib/sashite/epin/constants.rb +7 -1
- data/lib/sashite/epin/identifier.rb +68 -40
- data/lib/sashite/epin/parser.rb +78 -61
- data/lib/sashite/epin.rb +50 -12
- metadata +5 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4ac0199440bdaa03bc8fc740c81d9b2dcc3981696cae3918912514f6cf7f9432
|
|
4
|
+
data.tar.gz: 92c9b3f52c29c94927cdf34cb5693f7809d163b48d5a53afe96c22f58899aa0f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
###
|
|
79
|
+
### Safe Parsing (String → Identifier | nil)
|
|
67
80
|
|
|
68
|
-
|
|
81
|
+
Parse without raising exceptions. Returns `nil` on invalid input.
|
|
69
82
|
|
|
70
83
|
```ruby
|
|
71
|
-
#
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
|
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
|
-
###
|
|
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
|
-
#
|
|
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
|
|
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
|
|
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
|
-
- **
|
|
263
|
-
- **
|
|
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 (
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
93
|
+
@string
|
|
88
94
|
end
|
|
89
95
|
|
|
90
96
|
# ========================================================================
|
|
91
|
-
# Transformations
|
|
97
|
+
# Transformations (pool lookups — zero allocation)
|
|
92
98
|
# ========================================================================
|
|
93
99
|
|
|
94
|
-
# Returns a
|
|
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
|
|
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 =
|
|
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
|
|
111
|
+
return self if pin.equal?(new_pin)
|
|
106
112
|
|
|
107
|
-
|
|
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
|
|
118
|
+
# Returns a cached Identifier marked as derived.
|
|
111
119
|
#
|
|
112
|
-
# @return [Identifier] A
|
|
120
|
+
# @return [Identifier] A cached Identifier with derived: true
|
|
113
121
|
#
|
|
114
122
|
# @example
|
|
115
|
-
# epin =
|
|
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.
|
|
128
|
+
self.class.fetch(pin, true)
|
|
121
129
|
end
|
|
122
130
|
|
|
123
|
-
# Returns a
|
|
131
|
+
# Returns a cached Identifier marked as native.
|
|
124
132
|
#
|
|
125
|
-
# @return [Identifier] A
|
|
133
|
+
# @return [Identifier] A cached Identifier with derived: false
|
|
126
134
|
#
|
|
127
135
|
# @example
|
|
128
|
-
# epin =
|
|
136
|
+
# epin = Sashite::Epin.parse("K^'")
|
|
129
137
|
# epin.native.to_s # => "K^"
|
|
130
138
|
def native
|
|
131
|
-
return self if
|
|
139
|
+
return self if !@derived
|
|
132
140
|
|
|
133
|
-
self.class.
|
|
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 =
|
|
147
|
-
# epin2 =
|
|
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
|
-
|
|
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
|
-
#
|
|
192
|
+
# Flyweight Pool (class-level)
|
|
187
193
|
# ========================================================================
|
|
188
194
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
data/lib/sashite/epin/parser.rb
CHANGED
|
@@ -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
|
-
#
|
|
13
|
-
#
|
|
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.
|
|
17
|
-
# Parser.
|
|
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
|
-
#
|
|
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 [
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
validate_derivation_marker!(input)
|
|
33
|
-
pin_string = input.chop
|
|
48
|
+
Identifier.fetch(pin, true)
|
|
34
49
|
else
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
93
|
-
# @
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
61
|
+
Parser.parse(string)
|
|
62
|
+
end
|
|
56
63
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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.
|
|
99
|
+
Identifier.fetch(pin, derived)
|
|
65
100
|
end
|
|
66
101
|
|
|
67
|
-
#
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
-
|
|
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.
|
|
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.
|