sashite-qpi 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sashite
4
+ module Qpi
5
+ # Constants for QPI (Qualified Piece Identifier).
6
+ #
7
+ # This module defines the structural constants for QPI tokens.
8
+ module Constants
9
+ # Separator between SIN and PIN components.
10
+ SEPARATOR = ":"
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sashite
4
+ module Qpi
5
+ module Errors
6
+ class Argument < ::ArgumentError
7
+ # Centralized error messages for QPI parsing and validation.
8
+ #
9
+ # @example
10
+ # raise Errors::Argument, Messages::EMPTY_INPUT
11
+ module Messages
12
+ # Parsing errors
13
+ EMPTY_INPUT = "empty input"
14
+ MISSING_SEPARATOR = "missing colon separator"
15
+ MISSING_SIN = "missing SIN component"
16
+ MISSING_PIN = "missing PIN component"
17
+ INVALID_SIN = "invalid SIN component"
18
+ INVALID_PIN = "invalid PIN component"
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "argument/messages"
4
+
5
+ module Sashite
6
+ module Qpi
7
+ module Errors
8
+ # Error raised when QPI parsing or validation fails.
9
+ #
10
+ # @example
11
+ # raise Argument, Argument::Messages::EMPTY_INPUT
12
+ class Argument < ::ArgumentError
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors/argument"
@@ -1,95 +1,62 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "sashite/pin"
4
- require "sashite/sin"
3
+ require_relative "constants"
4
+ require_relative "errors"
5
5
 
6
6
  module Sashite
7
7
  module Qpi
8
- # Represents an identifier in QPI (Qualified Piece Identifier) format.
8
+ # Represents a parsed QPI (Qualified Piece Identifier) identifier.
9
9
  #
10
- # QPI is pure composition of SIN and PIN primitives with one constraint:
11
- # both components must represent the same player (side).
10
+ # A QPI identifier encodes complete Piece Identity by combining:
11
+ # - A SIN (Style Identifier Notation) component for Piece Style
12
+ # - A PIN (Piece Identifier Notation) component for Piece Name, Side, State, and Terminal Status
12
13
  #
13
- # ## Minimal API Design
14
+ # Additionally, QPI defines a Native/Derived relationship based on case comparison
15
+ # between the SIN and PIN letters.
14
16
  #
15
- # The Identifier class provides only 5 core methods:
16
- # 1. new(sin, pin) — create from components with validation
17
- # 2. sin — access SIN component
18
- # 3. pin — access PIN component
19
- # 4. to_s — serialize to QPI string
20
- # 5. flip — flip both components (only convenience method)
17
+ # Instances are immutable (frozen after creation).
21
18
  #
22
- # Additionally, component replacement methods:
23
- # - with_sin(new_sin) — create identifier with different SIN
24
- # - with_pin(new_pin) — create identifier with different PIN
25
- #
26
- # All other operations use the component APIs directly:
27
- # - qpi.sin.family — access Piece Style
28
- # - qpi.sin.side — access Piece Side
29
- # - qpi.pin.type — access Piece Name
30
- # - qpi.pin.state — access Piece State
31
- # - qpi.pin.terminal? — access Terminal Status
32
- #
33
- # ## Why Only flip as Convenience?
34
- #
35
- # flip is the ONLY transformation that naturally operates on both
36
- # SIN and PIN components simultaneously. All other transformations
37
- # work through component replacement:
38
- #
39
- # qpi.with_sin(qpi.sin.with_family(:S)) # Transform SIN
40
- # qpi.with_pin(qpi.pin.with_type(:Q)) # Transform PIN
41
- # qpi.with_pin(qpi.pin.with_terminal(true)) # Transform PIN
42
- #
43
- # This avoids arbitrary conveniences and maintains a clear principle.
44
- #
45
- # @example Pure composition
19
+ # @example Creating identifiers
46
20
  # sin = Sashite::Sin.parse("C")
47
21
  # pin = Sashite::Pin.parse("K^")
48
- # qpi = Sashite::Qpi::Identifier.new(sin, pin)
49
- # qpi.to_s # => "C:K^"
50
- # qpi.sin # => SIN::Identifier
51
- # qpi.pin # => PIN::Identifier
22
+ # qpi = Identifier.new(sin, pin)
52
23
  #
53
- # @example Access attributes via components
54
- # qpi.sin.family # => :C (Piece Style)
55
- # qpi.pin.type # => :K (Piece Name)
56
- # qpi.sin.side # => :first (Piece Side)
57
- # qpi.pin.state # => :normal (Piece State)
58
- # qpi.pin.terminal? # => true (Terminal Status)
24
+ # @example Accessing components
25
+ # qpi.sin.style # => :C
26
+ # qpi.pin.type # => :K
27
+ # qpi.pin.side # => :first
28
+ # qpi.pin.state # => :normal
29
+ # qpi.pin.terminal? # => true
59
30
  #
60
- # @example Transform via components
61
- # qpi.with_sin(qpi.sin.with_family(:S)) # => "S:K^"
62
- # qpi.with_pin(qpi.pin.with_type(:Q)) # => "C:Q^"
63
- # qpi.flip # => "c:k^"
31
+ # @example Native/Derived relationship
32
+ # qpi = Identifier.new(Sin.parse("C"), Pin.parse("K"))
33
+ # qpi.native? # => true (both uppercase/first)
64
34
  #
65
- # @see https://sashite.dev/specs/qpi/1.0.0/ QPI Specification v1.0.0
35
+ # qpi = Identifier.new(Sin.parse("C"), Pin.parse("k"))
36
+ # qpi.derived? # => true (SIN uppercase, PIN lowercase)
37
+ #
38
+ # @see https://sashite.dev/specs/qpi/1.0.0/
66
39
  class Identifier
67
- # Component separator for string representation
68
- SEPARATOR = ":"
69
-
70
- # Error messages
71
- ERROR_INVALID_QPI = "Invalid QPI string: %s"
72
- ERROR_SEMANTIC_MISMATCH = "SIN and PIN components must have same side: sin.side=%s, pin.side=%s"
73
- ERROR_MISSING_SEPARATOR = "QPI string must contain exactly one colon separator: %s"
74
-
75
- # @return [Sin::Identifier] the SIN component
40
+ # @return [Sashite::Sin::Identifier] The SIN component
76
41
  attr_reader :sin
77
42
 
78
- # @return [Pin::Identifier] the PIN component
43
+ # @return [Sashite::Pin::Identifier] The PIN component
79
44
  attr_reader :pin
80
45
 
81
- # Create a new identifier from SIN and PIN components
46
+ # Creates a new Identifier instance.
82
47
  #
83
- # @param sin [Sin::Identifier] SIN component
84
- # @param pin [Pin::Identifier] PIN component
85
- # @raise [ArgumentError] if components have different sides
48
+ # @param sin [Sashite::Sin::Identifier] The SIN component
49
+ # @param pin [Sashite::Pin::Identifier] The PIN component
50
+ # @return [Identifier] A new frozen Identifier instance
51
+ # @raise [Errors::Argument] If components are invalid
86
52
  #
87
53
  # @example
88
54
  # sin = Sashite::Sin.parse("C")
89
55
  # pin = Sashite::Pin.parse("K^")
90
- # qpi = Sashite::Qpi::Identifier.new(sin, pin)
56
+ # Identifier.new(sin, pin)
91
57
  def initialize(sin, pin)
92
- validate_semantic_consistency(sin, pin)
58
+ validate_sin!(sin)
59
+ validate_pin!(pin)
93
60
 
94
61
  @sin = sin
95
62
  @pin = pin
@@ -97,144 +64,180 @@ module Sashite
97
64
  freeze
98
65
  end
99
66
 
100
- # Parse a QPI string into an Identifier object
67
+ # ========================================================================
68
+ # String Conversion
69
+ # ========================================================================
70
+
71
+ # Returns the QPI string representation.
101
72
  #
102
- # @param qpi_string [String] QPI notation string (format: sin:pin)
103
- # @return [Identifier] new identifier instance
104
- # @raise [ArgumentError] if invalid or semantically inconsistent
73
+ # @return [String] The QPI string in format "SIN:PIN"
105
74
  #
106
75
  # @example
107
- # qpi = Sashite::Qpi::Identifier.parse("C:K^")
108
- # qpi.sin.family # => :C
109
- # qpi.pin.type # => :K
110
- def self.parse(qpi_string)
111
- string_value = String(qpi_string)
112
- sin_part, pin_part = split_components(string_value)
76
+ # qpi.to_s # => "C:K^"
77
+ def to_s
78
+ "#{sin}#{Constants::SEPARATOR}#{pin}"
79
+ end
113
80
 
114
- sin_identifier = Sin::Identifier.parse(sin_part)
115
- pin_identifier = Pin::Identifier.parse(pin_part)
81
+ # ========================================================================
82
+ # Native/Derived Relationship
83
+ # ========================================================================
116
84
 
117
- new(sin_identifier, pin_identifier)
85
+ # Checks if the identifier is native.
86
+ #
87
+ # A QPI is native when sin.side equals pin.side (same case).
88
+ #
89
+ # @return [Boolean] true if native
90
+ #
91
+ # @example
92
+ # Identifier.new(Sin.parse("C"), Pin.parse("K")).native? # => true
93
+ # Identifier.new(Sin.parse("c"), Pin.parse("k")).native? # => true
94
+ # Identifier.new(Sin.parse("C"), Pin.parse("k")).native? # => false
95
+ def native?
96
+ sin.side.equal?(pin.side)
118
97
  end
119
98
 
120
- # Check if a string is a valid QPI notation
99
+ # Checks if the identifier is derived.
121
100
  #
122
- # @param qpi_string [String] the string to validate
123
- # @return [Boolean] true if valid QPI, false otherwise
101
+ # A QPI is derived when sin.side differs from pin.side (different case).
102
+ #
103
+ # @return [Boolean] true if derived
124
104
  #
125
105
  # @example
126
- # Sashite::Qpi::Identifier.valid?("C:K^") # => true
127
- # Sashite::Qpi::Identifier.valid?("C:k") # => false (side mismatch)
128
- def self.valid?(qpi_string)
129
- return false unless qpi_string.is_a?(::String)
130
-
131
- sin_part, pin_part = split_components(qpi_string)
132
- return false unless Sashite::Sin.valid?(sin_part) && Sashite::Pin.valid?(pin_part)
133
-
134
- sin_identifier = Sashite::Sin.parse(sin_part)
135
- pin_identifier = Sashite::Pin.parse(pin_part)
136
- sin_identifier.side == pin_identifier.side
137
- rescue ArgumentError
138
- false
106
+ # Identifier.new(Sin.parse("C"), Pin.parse("k")).derived? # => true
107
+ # Identifier.new(Sin.parse("c"), Pin.parse("K")).derived? # => true
108
+ # Identifier.new(Sin.parse("C"), Pin.parse("K")).derived? # => false
109
+ def derived?
110
+ !native?
139
111
  end
140
112
 
141
- # Convert the identifier to its QPI string representation
113
+ # ========================================================================
114
+ # Native/Derived Transformations
115
+ # ========================================================================
116
+
117
+ # Returns a new Identifier with PIN case aligned to SIN case.
118
+ #
119
+ # If already native, returns self.
142
120
  #
143
- # @return [String] QPI notation string (format: sin:pin)
121
+ # @return [Identifier] A native Identifier
144
122
  #
145
123
  # @example
146
- # qpi.to_s # => "C:K^"
147
- def to_s
148
- "#{@sin}#{SEPARATOR}#{@pin}"
124
+ # qpi = Identifier.new(Sin.parse("C"), Pin.parse("r"))
125
+ # qpi.native.to_s # => "C:R"
126
+ #
127
+ # qpi = Identifier.new(Sin.parse("C"), Pin.parse("R"))
128
+ # qpi.native.to_s # => "C:R" (unchanged)
129
+ def native
130
+ return self if native?
131
+
132
+ self.class.new(sin, pin.with_side(sin.side))
149
133
  end
150
134
 
151
- # Create a new identifier with different SIN component
135
+ # Returns a new Identifier with PIN case opposite to SIN case.
136
+ #
137
+ # If already derived, returns self.
152
138
  #
153
- # @param new_sin [Sin::Identifier] new SIN component
154
- # @return [Identifier] new identifier instance
155
- # @raise [ArgumentError] if new SIN has different side than PIN
139
+ # @return [Identifier] A derived Identifier
156
140
  #
157
141
  # @example
158
- # qpi.with_sin(qpi.sin.with_family(:S)) # => "S:K^"
159
- def with_sin(new_sin)
160
- return self if @sin == new_sin
142
+ # qpi = Identifier.new(Sin.parse("C"), Pin.parse("R"))
143
+ # qpi.derive.to_s # => "C:r"
144
+ #
145
+ # qpi = Identifier.new(Sin.parse("C"), Pin.parse("r"))
146
+ # qpi.derive.to_s # => "C:r" (unchanged)
147
+ def derive
148
+ return self if derived?
161
149
 
162
- self.class.new(new_sin, @pin)
150
+ opposite_side = sin.side.equal?(:first) ? :second : :first
151
+ self.class.new(sin, pin.with_side(opposite_side))
163
152
  end
164
153
 
165
- # Create a new identifier with different PIN component
154
+ # ========================================================================
155
+ # Component Transformations
156
+ # ========================================================================
157
+
158
+ # Returns a new Identifier with a different SIN component.
166
159
  #
167
- # @param new_pin [Pin::Identifier] new PIN component
168
- # @return [Identifier] new identifier instance
169
- # @raise [ArgumentError] if new PIN has different side than SIN
160
+ # @param new_sin [Sashite::Sin::Identifier] The new SIN component
161
+ # @return [Identifier] A new Identifier with the specified SIN
162
+ # @raise [Errors::Argument] If the SIN is invalid
170
163
  #
171
164
  # @example
172
- # qpi.with_pin(qpi.pin.with_type(:Q)) # => "C:Q^"
173
- def with_pin(new_pin)
174
- return self if @pin == new_pin
175
-
176
- self.class.new(@sin, new_pin)
165
+ # qpi = Identifier.new(Sin.parse("C"), Pin.parse("K"))
166
+ # qpi.with_sin(Sin.parse("S")).to_s # => "S:K"
167
+ def with_sin(new_sin)
168
+ self.class.new(new_sin, pin)
177
169
  end
178
170
 
179
- # Create a new identifier with both components flipped
171
+ # Returns a new Identifier with a different PIN component.
180
172
  #
181
- # This is the ONLY convenience method because it's the only
182
- # transformation that naturally operates on both components.
173
+ # @param new_pin [Sashite::Pin::Identifier] The new PIN component
174
+ # @return [Identifier] A new Identifier with the specified PIN
175
+ # @raise [Errors::Argument] If the PIN is invalid
176
+ #
177
+ # @example
178
+ # qpi = Identifier.new(Sin.parse("C"), Pin.parse("K"))
179
+ # qpi.with_pin(Pin.parse("+Q^")).to_s # => "C:+Q^"
180
+ def with_pin(new_pin)
181
+ self.class.new(sin, new_pin)
182
+ end
183
+
184
+ # Returns a new Identifier with both components flipped.
183
185
  #
184
- # @return [Identifier] new identifier with both components flipped
186
+ # @return [Identifier] A new Identifier with both SIN and PIN flipped
185
187
  #
186
188
  # @example
187
- # qpi = Sashite::Qpi.parse("C:K^")
188
- # qpi.flip # => "c:k^"
189
+ # qpi = Identifier.new(Sin.parse("C"), Pin.parse("K^"))
190
+ # qpi.flip.to_s # => "c:k^"
189
191
  def flip
190
- self.class.new(@sin.flip, @pin.flip)
192
+ self.class.new(sin.flip, pin.flip)
191
193
  end
192
194
 
193
- # Custom equality comparison
195
+ # ========================================================================
196
+ # Equality
197
+ # ========================================================================
198
+
199
+ # Checks equality with another Identifier.
194
200
  #
195
- # @param other [Object] object to compare with
196
- # @return [Boolean] true if both SIN and PIN components are equal
201
+ # @param other [Object] The object to compare
202
+ # @return [Boolean] true if equal
197
203
  def ==(other)
198
- return false unless other.is_a?(self.class)
204
+ return false unless self.class === other
199
205
 
200
- @sin == other.sin && @pin == other.pin
206
+ sin == other.sin && pin == other.pin
201
207
  end
202
208
 
203
- # Alias for == to ensure Set functionality works correctly
204
209
  alias eql? ==
205
210
 
206
- # Custom hash implementation for use in collections
211
+ # Returns a hash code for the Identifier.
207
212
  #
208
- # @return [Integer] hash value
213
+ # @return [Integer] Hash code
209
214
  def hash
210
- [self.class, @sin, @pin].hash
215
+ [sin, pin].hash
216
+ end
217
+
218
+ # Returns an inspect string for the Identifier.
219
+ #
220
+ # @return [String] Inspect representation
221
+ def inspect
222
+ "#<#{self.class} #{self}>"
211
223
  end
212
224
 
213
225
  private
214
226
 
215
- # Split QPI string into SIN and PIN components
216
- #
217
- # @param qpi_string [String] QPI string to split
218
- # @return [Array<String>] array containing [sin_part, pin_part]
219
- # @raise [ArgumentError] if string doesn't contain exactly one separator
220
- def self.split_components(qpi_string)
221
- parts = qpi_string.split(SEPARATOR, 2)
222
- raise ::ArgumentError, format(ERROR_MISSING_SEPARATOR, qpi_string) unless parts.size == 2
227
+ # ========================================================================
228
+ # Private Validation
229
+ # ========================================================================
223
230
 
224
- parts
225
- end
231
+ def validate_sin!(sin)
232
+ return if sin.is_a?(Sashite::Sin::Identifier)
226
233
 
227
- private_class_method :split_components
234
+ raise Errors::Argument, Errors::Argument::Messages::INVALID_SIN
235
+ end
228
236
 
229
- # Validate that SIN and PIN components have consistent sides
230
- #
231
- # @param sin [Sin::Identifier] SIN component to validate
232
- # @param pin [Pin::Identifier] PIN component to validate
233
- # @raise [ArgumentError] if sides don't match
234
- def validate_semantic_consistency(sin, pin)
235
- return if sin.side == pin.side
237
+ def validate_pin!(pin)
238
+ return if pin.is_a?(Sashite::Pin::Identifier)
236
239
 
237
- raise ::ArgumentError, format(ERROR_SEMANTIC_MISMATCH, sin.side, pin.side)
240
+ raise Errors::Argument, Errors::Argument::Messages::INVALID_PIN
238
241
  end
239
242
  end
240
243
  end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sashite/sin"
4
+ require "sashite/pin"
5
+
6
+ require_relative "constants"
7
+ require_relative "errors"
8
+
9
+ module Sashite
10
+ module Qpi
11
+ # Parser for QPI (Qualified Piece Identifier) strings.
12
+ #
13
+ # This parser splits the QPI string on the colon separator and delegates
14
+ # parsing of each component to the SIN and PIN libraries.
15
+ #
16
+ # @example
17
+ # Parser.parse("C:K") # => { sin: <Sin::Identifier>, pin: <Pin::Identifier> }
18
+ # Parser.parse("s:+r^") # => { sin: <Sin::Identifier>, pin: <Pin::Identifier> }
19
+ #
20
+ # @see https://sashite.dev/specs/qpi/1.0.0/
21
+ module Parser
22
+ # Parses a QPI string into its components.
23
+ #
24
+ # @param input [String] The QPI string to parse
25
+ # @return [Hash] A hash with :sin and :pin keys containing Identifier instances
26
+ # @raise [Errors::Argument] If the input is not a valid QPI string
27
+ def self.parse(input)
28
+ validate_input_type(input)
29
+ validate_not_empty(input)
30
+
31
+ sin_string, pin_string = split_components(input)
32
+
33
+ sin = parse_sin(sin_string)
34
+ pin = parse_pin(pin_string)
35
+
36
+ { sin: sin, pin: pin }
37
+ end
38
+
39
+ # Validates a QPI string without raising an exception.
40
+ #
41
+ # @param input [String] The QPI string to validate
42
+ # @return [Boolean] true if valid, false otherwise
43
+ def self.valid?(input)
44
+ return false unless ::String === input
45
+
46
+ parse(input)
47
+ true
48
+ rescue Errors::Argument
49
+ false
50
+ end
51
+
52
+ class << self
53
+ private
54
+
55
+ # Validates that input is a String.
56
+ #
57
+ # @param input [Object] The input to validate
58
+ # @raise [Errors::Argument] If input is not a String
59
+ def validate_input_type(input)
60
+ return if ::String === input
61
+
62
+ raise Errors::Argument, Errors::Argument::Messages::EMPTY_INPUT
63
+ end
64
+
65
+ # Validates that input is not empty.
66
+ #
67
+ # @param input [String] The input to validate
68
+ # @raise [Errors::Argument] If input is empty
69
+ def validate_not_empty(input)
70
+ return unless input.empty?
71
+
72
+ raise Errors::Argument, Errors::Argument::Messages::EMPTY_INPUT
73
+ end
74
+
75
+ # Splits the input into SIN and PIN components.
76
+ #
77
+ # @param input [String] The QPI string to split
78
+ # @return [Array<String>] An array with [sin_string, pin_string]
79
+ # @raise [Errors::Argument] If the separator is missing or components are empty
80
+ def split_components(input)
81
+ unless input.include?(Constants::SEPARATOR)
82
+ raise Errors::Argument, Errors::Argument::Messages::MISSING_SEPARATOR
83
+ end
84
+
85
+ sin_string, pin_string = input.split(Constants::SEPARATOR, 2)
86
+
87
+ if sin_string.nil? || sin_string.empty?
88
+ raise Errors::Argument, Errors::Argument::Messages::MISSING_SIN
89
+ end
90
+
91
+ if pin_string.nil? || pin_string.empty?
92
+ raise Errors::Argument, Errors::Argument::Messages::MISSING_PIN
93
+ end
94
+
95
+ [sin_string, pin_string]
96
+ end
97
+
98
+ # Parses the SIN component.
99
+ #
100
+ # @param sin_string [String] The SIN string to parse
101
+ # @return [Sashite::Sin::Identifier] The parsed SIN identifier
102
+ # @raise [Errors::Argument] If the SIN is invalid
103
+ def parse_sin(sin_string)
104
+ Sashite::Sin.parse(sin_string)
105
+ rescue ::ArgumentError => e
106
+ raise Errors::Argument, "#{Errors::Argument::Messages::INVALID_SIN}: #{e.message}"
107
+ end
108
+
109
+ # Parses the PIN component.
110
+ #
111
+ # @param pin_string [String] The PIN string to parse
112
+ # @return [Sashite::Pin::Identifier] The parsed PIN identifier
113
+ # @raise [Errors::Argument] If the PIN is invalid
114
+ def parse_pin(pin_string)
115
+ Sashite::Pin.parse(pin_string)
116
+ rescue ::ArgumentError => e
117
+ raise Errors::Argument, "#{Errors::Argument::Messages::INVALID_PIN}: #{e.message}"
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end