sashite-cell 3.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2b6d0de28c9ac29d6db74cc1bc6b143e01dba768e20de71d5f275d9f67aa72d1
4
- data.tar.gz: 53e8dc478b6ec299f1fc4b5ec41f18063052bf86e5700dc13bce9cf3bee5e428
3
+ metadata.gz: ac0688e4dc618a3549c4b3d62db1bad731255cea5b239a87e85ac7017ce47869
4
+ data.tar.gz: 44ed86405deacc70b71982e07ffd2337ce9600682aab31bf524f5e2d4a9ff40d
5
5
  SHA512:
6
- metadata.gz: 79db4a1d8046ffc61bb4f6042cf24b905a53a93f9c9ea3c9e96fe0efbbbc99c97899593043084407e7872a608c54a0532a2ed38d4a344f51725210b582658c04
7
- data.tar.gz: 32747b087363bb7f04537cd15b6e5d19c4b2c755a4b8793366130b6eae636fbfe22b751df8e65a73a1304074a715047a5b96c59762f75ae700a8ec3c6f4db782
6
+ metadata.gz: ee5405dba3da3d7f8825cfd9af6220a42f34b3ca2b90a3f07b1fca3151f30d0e59e8c154468c1c57430d4e9925e531e3db81d521cc98c0756c69ecabae088fbc
7
+ data.tar.gz: 640048dd47362c161dbb1e3f3e6b7ea560fd4f80b915f5de37e5854f870b17ac7d511e6ddc6c96c274c60ca53b4d598abec23ce5ad59d048835e9f6277ace309
data/README.md CHANGED
@@ -52,8 +52,8 @@ coord.dimensions # => 2
52
52
  coord = Sashite::Cell.parse("a1A")
53
53
  coord.indices # => [0, 0, 0]
54
54
 
55
- # Invalid input raises ArgumentError
56
- Sashite::Cell.parse("a0") # => raises ArgumentError
55
+ # Invalid input raises Sashite::Cell::Errors::Argument
56
+ Sashite::Cell.parse("a0") # => raises Sashite::Cell::Errors::Argument
57
57
  ```
58
58
 
59
59
  ### Formatting (Coordinate → String)
@@ -76,7 +76,7 @@ Sashite::Cell.format(2, 2, 2) # => "c3C"
76
76
  Sashite::Cell.valid?("e4") # => true
77
77
 
78
78
  # Detailed error
79
- Sashite::Cell.validate("a0") # => raises ArgumentError, "leading zero"
79
+ Sashite::Cell.validate("a0") # => raises Sashite::Cell::Errors::Argument, "leading zero"
80
80
  ```
81
81
 
82
82
  ### Accessing Coordinate Data
@@ -103,7 +103,7 @@ coord.indices[1] # => 3
103
103
  # Coordinate represents a parsed CELL coordinate with up to 3 dimensions.
104
104
  class Sashite::Cell::Coordinate
105
105
  # Creates a Coordinate from 1 to 3 indices.
106
- # Raises ArgumentError if no indices provided or more than 3.
106
+ # Raises Sashite::Cell::Errors::Argument if no indices provided or more than 3.
107
107
  #
108
108
  # @param indices [Array<Integer>] 0-indexed coordinate values (0-255)
109
109
  # @return [Coordinate]
@@ -129,20 +129,20 @@ end
129
129
  ### Constants
130
130
 
131
131
  ```ruby
132
- Sashite::Cell::Coordinate::MAX_DIMENSIONS = 3
133
- Sashite::Cell::Coordinate::MAX_INDEX_VALUE = 255
134
- Sashite::Cell::Coordinate::MAX_STRING_LENGTH = 7
132
+ Sashite::Cell::Constants::MAX_DIMENSIONS # => 3
133
+ Sashite::Cell::Constants::MAX_INDEX_VALUE # => 255
134
+ Sashite::Cell::Constants::MAX_STRING_LENGTH # => 7
135
135
  ```
136
136
 
137
137
  ### Parsing
138
138
 
139
139
  ```ruby
140
140
  # Parses a CELL string into a Coordinate.
141
- # Raises ArgumentError if the string is not valid.
141
+ # Raises Sashite::Cell::Errors::Argument if the string is not valid.
142
142
  #
143
143
  # @param string [String] CELL coordinate string
144
144
  # @return [Coordinate]
145
- # @raise [ArgumentError] if invalid
145
+ # @raise [Sashite::Cell::Errors::Argument] if invalid
146
146
  def Sashite::Cell.parse(string)
147
147
  ```
148
148
 
@@ -161,11 +161,11 @@ def Sashite::Cell.format(*indices)
161
161
 
162
162
  ```ruby
163
163
  # Validates a CELL string.
164
- # Raises ArgumentError with descriptive message if invalid.
164
+ # Raises Sashite::Cell::Errors::Argument with descriptive message if invalid.
165
165
  #
166
166
  # @param string [String] CELL coordinate string
167
167
  # @return [nil]
168
- # @raise [ArgumentError] if invalid
168
+ # @raise [Sashite::Cell::Errors::Argument] if invalid
169
169
  def Sashite::Cell.validate(string)
170
170
 
171
171
  # Reports whether string is a valid CELL coordinate.
@@ -177,7 +177,7 @@ def Sashite::Cell.valid?(string)
177
177
 
178
178
  ### Errors
179
179
 
180
- All parsing and validation errors raise `ArgumentError` with descriptive messages:
180
+ All parsing and validation errors raise `Sashite::Cell::Errors::Argument` (a subclass of `ArgumentError`) with descriptive messages:
181
181
 
182
182
  | Message | Cause |
183
183
  |---------|-------|
@@ -189,11 +189,27 @@ All parsing and validation errors raise `ArgumentError` with descriptive message
189
189
  | `"exceeds 3 dimensions"` | More than 3 dimensions |
190
190
  | `"index exceeds 255"` | Decoded value out of range |
191
191
 
192
+ ```ruby
193
+ begin
194
+ Sashite::Cell.parse("a0")
195
+ rescue Sashite::Cell::Errors::Argument => e
196
+ puts e.message # => "leading zero"
197
+ end
198
+
199
+ # Also catchable as ArgumentError for compatibility
200
+ begin
201
+ Sashite::Cell.parse("a0")
202
+ rescue ArgumentError => e
203
+ puts e.message # => "leading zero"
204
+ end
205
+ ```
206
+
192
207
  ## Design Principles
193
208
 
194
209
  - **Bounded values**: Index validation prevents overflow
195
210
  - **Object-oriented**: `Coordinate` class enables methods and encapsulation
196
- - **Ruby idioms**: `valid?` predicate, `to_s` conversion, `ArgumentError` for invalid input
211
+ - **Ruby idioms**: `valid?` predicate, `to_s` conversion
212
+ - **Custom error class**: `Errors::Argument` inherits from `ArgumentError` for precise error handling
197
213
  - **Immutable coordinates**: Frozen indices array prevents mutation
198
214
  - **No dependencies**: Pure Ruby standard library only
199
215
 
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sashite
4
+ module Cell
5
+ module Constants
6
+ # Maximum number of dimensions supported by a CELL coordinate.
7
+ # Sufficient for 1D, 2D, and 3D game boards.
8
+ MAX_DIMENSIONS = 3
9
+
10
+ # Maximum value for a single coordinate index.
11
+ # Fits in an 8-bit unsigned integer (0-255).
12
+ MAX_INDEX_VALUE = 255
13
+
14
+ # Maximum length of a CELL string representation.
15
+ # Corresponds to "iv256IV" (worst case for all dimensions at 255).
16
+ MAX_STRING_LENGTH = 7
17
+ end
18
+ end
19
+ end
@@ -1,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "constants"
4
+ require_relative "errors"
5
+ require_relative "formatter"
6
+
3
7
  module Sashite
4
8
  module Cell
5
9
  # Represents a parsed CELL coordinate with up to 3 dimensions.
@@ -17,35 +21,34 @@ module Sashite
17
21
  # coord = Sashite::Cell::Coordinate.new(0, 0, 0)
18
22
  # coord.to_s # => "a1A"
19
23
  class Coordinate
20
- # Maximum number of dimensions supported.
21
- MAX_DIMENSIONS = 3
22
-
23
- # Maximum value for any single index (0-255).
24
- MAX_INDEX_VALUE = 255
25
-
26
- # Maximum length of a CELL coordinate string.
27
- MAX_STRING_LENGTH = 7
28
-
29
24
  # Returns the coordinate indices as a frozen array.
30
25
  #
31
26
  # @return [Array<Integer>] 0-indexed coordinate values
27
+ #
28
+ # @example
29
+ # Sashite::Cell::Coordinate.new(4, 3).indices # => [4, 3]
32
30
  attr_reader :indices
33
31
 
34
32
  # Creates a Coordinate from 1 to 3 indices.
35
33
  #
36
34
  # @param indices [Array<Integer>] 0-indexed coordinate values (0-255 each)
37
- # @raise [ArgumentError] if no indices provided, more than 3, or values out of range
35
+ # @raise [Sashite::Cell::Errors::Argument] if no indices provided, more than 3, or values out of range
38
36
  #
39
37
  # @example
40
38
  # Sashite::Cell::Coordinate.new(4, 3) # 2D coordinate
41
39
  # Sashite::Cell::Coordinate.new(0, 0, 0) # 3D coordinate
42
40
  def initialize(*indices)
43
- raise ::ArgumentError, "empty input" if indices.empty?
44
- raise ::ArgumentError, "exceeds #{MAX_DIMENSIONS} dimensions" if indices.size > MAX_DIMENSIONS
41
+ if indices.empty?
42
+ raise Errors::Argument, Errors::Argument::Messages::NO_INDICES
43
+ end
44
+
45
+ if indices.size > Constants::MAX_DIMENSIONS
46
+ raise Errors::Argument, Errors::Argument::Messages::TOO_MANY_DIMENSIONS
47
+ end
45
48
 
46
49
  indices.each do |index|
47
- unless index.is_a?(::Integer) && index >= 0 && index <= MAX_INDEX_VALUE
48
- raise ::ArgumentError, "index exceeds #{MAX_INDEX_VALUE}"
50
+ unless index.is_a?(::Integer) && index >= 0 && index <= Constants::MAX_INDEX_VALUE
51
+ raise Errors::Argument, Errors::Argument::Messages::INDEX_OUT_OF_RANGE
49
52
  end
50
53
  end
51
54
 
@@ -69,13 +72,16 @@ module Sashite
69
72
  # @example
70
73
  # Sashite::Cell::Coordinate.new(4, 3).to_s # => "e4"
71
74
  def to_s
72
- Dumper.indices_to_string(@indices)
75
+ Formatter.indices_to_string(@indices)
73
76
  end
74
77
 
75
78
  # Checks equality with another Coordinate.
76
79
  #
77
80
  # @param other [Object] object to compare
78
81
  # @return [Boolean] true if equal, false otherwise
82
+ #
83
+ # @example
84
+ # Sashite::Cell::Coordinate.new(4, 3) == Sashite::Cell::Coordinate.new(4, 3) # => true
79
85
  def ==(other)
80
86
  other.is_a?(Coordinate) && @indices == other.indices
81
87
  end
@@ -85,6 +91,10 @@ module Sashite
85
91
  # Returns hash code for use in Hash keys.
86
92
  #
87
93
  # @return [Integer] hash code
94
+ #
95
+ # @example
96
+ # coord = Sashite::Cell::Coordinate.new(4, 3)
97
+ # hash = { coord => "value" }
88
98
  def hash
89
99
  @indices.hash
90
100
  end
@@ -92,6 +102,9 @@ module Sashite
92
102
  # Returns a human-readable representation.
93
103
  #
94
104
  # @return [String] inspection string
105
+ #
106
+ # @example
107
+ # Sashite::Cell::Coordinate.new(4, 3).inspect # => "#<Sashite::Cell::Coordinate e4>"
95
108
  def inspect
96
109
  "#<#{self.class} #{self}>"
97
110
  end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sashite
4
+ module Cell
5
+ module Errors
6
+ class Argument < ::ArgumentError
7
+ # Error messages for validation failures.
8
+ # Kept as constants to ensure consistency across the library.
9
+ module Messages
10
+ EMPTY_INPUT = "empty input"
11
+ INPUT_TOO_LONG = "input exceeds 7 characters"
12
+ INVALID_START = "must start with lowercase letter"
13
+ UNEXPECTED_CHARACTER = "unexpected character"
14
+ LEADING_ZERO = "leading zero"
15
+ TOO_MANY_DIMENSIONS = "exceeds 3 dimensions"
16
+ INDEX_OUT_OF_RANGE = "index exceeds 255"
17
+ NO_INDICES = "at least one index required"
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "argument/messages"
4
+
5
+ module Sashite
6
+ module Cell
7
+ module Errors
8
+ # Custom error class for CELL parsing and validation failures.
9
+ #
10
+ # Inherits from ArgumentError to maintain semantic meaning
11
+ # while allowing specific rescue of CELL-related errors.
12
+ #
13
+ # @example Rescuing specific CELL errors
14
+ # begin
15
+ # Sashite::Cell.parse("invalid")
16
+ # rescue Sashite::Cell::Errors::Argument => e
17
+ # puts "CELL error: #{e.message}"
18
+ # end
19
+ #
20
+ # @example Rescuing as ArgumentError
21
+ # begin
22
+ # Sashite::Cell.parse("invalid")
23
+ # rescue ArgumentError => e
24
+ # puts "Argument error: #{e.message}"
25
+ # end
26
+ class Argument < ::ArgumentError
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors/argument"
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sashite
4
+ module Cell
5
+ # Formats index arrays into CELL coordinate strings.
6
+ #
7
+ # This module handles the conversion from numeric indices to their
8
+ # CELL string representation following the cyclic pattern:
9
+ # - Dimension 1, 4, 7...: lowercase letters (a-z, aa-iv)
10
+ # - Dimension 2, 5, 8...: positive integers (1-256)
11
+ # - Dimension 3, 6, 9...: uppercase letters (A-Z, AA-IV)
12
+ #
13
+ # @example
14
+ # Formatter.indices_to_string([4, 3]) # => "e4"
15
+ # Formatter.indices_to_string([0, 0, 0]) # => "a1A"
16
+ #
17
+ # @api private
18
+ module Formatter
19
+ # Formats an indices array to a CELL string.
20
+ #
21
+ # @param indices [Array<Integer>] 0-indexed coordinate values
22
+ # @return [String] CELL coordinate string
23
+ #
24
+ # @example
25
+ # Formatter.indices_to_string([4, 3]) # => "e4"
26
+ # Formatter.indices_to_string([255, 255, 255]) # => "iv256IV"
27
+ def self.indices_to_string(indices)
28
+ result = +""
29
+
30
+ indices.each_with_index do |index, i|
31
+ dimension_type = i % 3
32
+
33
+ result << case dimension_type
34
+ when 0 then encode_to_lower(index)
35
+ when 1 then encode_to_number(index)
36
+ when 2 then encode_to_upper(index)
37
+ end
38
+ end
39
+
40
+ result.freeze
41
+ end
42
+
43
+ # Encodes an index (0-255) as lowercase letters (a-z, aa-iv).
44
+ #
45
+ # @param index [Integer] 0-indexed value (0-255)
46
+ # @return [String] lowercase letter sequence
47
+ #
48
+ # @example
49
+ # encode_to_lower(0) # => "a"
50
+ # encode_to_lower(25) # => "z"
51
+ # encode_to_lower(26) # => "aa"
52
+ # encode_to_lower(255) # => "iv"
53
+ private_class_method def self.encode_to_lower(index)
54
+ encode_to_letters(index, base: "a")
55
+ end
56
+
57
+ # Encodes an index (0-255) as uppercase letters (A-Z, AA-IV).
58
+ #
59
+ # @param index [Integer] 0-indexed value (0-255)
60
+ # @return [String] uppercase letter sequence
61
+ #
62
+ # @example
63
+ # encode_to_upper(0) # => "A"
64
+ # encode_to_upper(25) # => "Z"
65
+ # encode_to_upper(26) # => "AA"
66
+ # encode_to_upper(255) # => "IV"
67
+ private_class_method def self.encode_to_upper(index)
68
+ encode_to_letters(index, base: "A")
69
+ end
70
+
71
+ # Encodes an index to a letter sequence.
72
+ #
73
+ # @param index [Integer] 0-indexed value (0-255)
74
+ # @param base [String] base character ("a" or "A")
75
+ # @return [String] letter sequence
76
+ private_class_method def self.encode_to_letters(index, base:)
77
+ base_ord = base.ord
78
+
79
+ if index < 26
80
+ (base_ord + index).chr
81
+ else
82
+ adjusted = index - 26
83
+ first = adjusted / 26
84
+ second = adjusted % 26
85
+ (base_ord + first).chr + (base_ord + second).chr
86
+ end
87
+ end
88
+
89
+ # Encodes an index (0-255) as a 1-based positive integer string.
90
+ #
91
+ # @param index [Integer] 0-indexed value (0-255)
92
+ # @return [String] number string (1-indexed)
93
+ #
94
+ # @example
95
+ # encode_to_number(0) # => "1"
96
+ # encode_to_number(255) # => "256"
97
+ private_class_method def self.encode_to_number(index)
98
+ (index + 1).to_s
99
+ end
100
+ end
101
+ end
102
+ end
@@ -1,9 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "constants"
4
+ require_relative "errors"
5
+
3
6
  module Sashite
4
7
  module Cell
5
8
  # Parses CELL coordinate strings into index arrays.
6
9
  #
10
+ # This module handles the conversion from CELL string representation
11
+ # to numeric indices, following the cyclic pattern:
12
+ # - Dimension 1, 4, 7...: lowercase letters (a-z, aa-iv)
13
+ # - Dimension 2, 5, 8...: positive integers (1-256)
14
+ # - Dimension 3, 6, 9...: uppercase letters (A-Z, AA-IV)
15
+ #
16
+ # Security considerations:
17
+ # - Character-by-character parsing (no regex, no ReDoS risk)
18
+ # - Fail-fast on invalid input
19
+ # - Bounded iteration (max 7 characters)
20
+ # - Explicit ASCII validation
21
+ #
7
22
  # @example
8
23
  # Parser.parse_to_indices("e4") # => [4, 3]
9
24
  # Parser.parse_to_indices("a1A") # => [0, 0, 0]
@@ -14,106 +29,205 @@ module Sashite
14
29
  #
15
30
  # @param string [String] CELL coordinate string
16
31
  # @return [Array<Integer>] 0-indexed coordinate values
17
- # @raise [ArgumentError] if parsing fails
32
+ # @raise [Sashite::Cell::Errors::Argument] if parsing fails
33
+ #
34
+ # @example
35
+ # Parser.parse_to_indices("e4") # => [4, 3]
36
+ # Parser.parse_to_indices("iv256IV") # => [255, 255, 255]
18
37
  def self.parse_to_indices(string)
19
- raise ::ArgumentError, "empty input" if string.empty?
20
- if string.length > Coordinate::MAX_STRING_LENGTH
21
- raise ::ArgumentError, "input exceeds #{Coordinate::MAX_STRING_LENGTH} characters"
22
- end
23
- raise ::ArgumentError, "must start with lowercase letter" unless string[0].match?(/[a-z]/)
38
+ raise Errors::Argument, Errors::Argument::Messages::EMPTY_INPUT if string.empty?
24
39
 
25
- indices = []
26
- remaining = string
27
- dimension = 1
40
+ if string.length > Constants::MAX_STRING_LENGTH
41
+ raise Errors::Argument, Errors::Argument::Messages::INPUT_TOO_LONG
42
+ end
28
43
 
29
- until remaining.empty?
30
- value, consumed = parse_dimension(remaining, dimension)
31
- indices << value
32
- remaining = remaining[consumed..]
33
- dimension += 1
44
+ first_byte = string.getbyte(0)
45
+ unless lowercase?(first_byte)
46
+ raise Errors::Argument, Errors::Argument::Messages::INVALID_START
34
47
  end
35
48
 
36
- if indices.size > Coordinate::MAX_DIMENSIONS
37
- raise ::ArgumentError, "exceeds #{Coordinate::MAX_DIMENSIONS} dimensions"
49
+ indices = []
50
+ pos = 0
51
+ dimension_type = 0 # 0: lowercase, 1: integer, 2: uppercase
52
+
53
+ while pos < string.length
54
+ if indices.size >= Constants::MAX_DIMENSIONS
55
+ raise Errors::Argument, Errors::Argument::Messages::TOO_MANY_DIMENSIONS
56
+ end
57
+
58
+ case dimension_type
59
+ when 0
60
+ value, consumed = parse_lowercase(string, pos)
61
+ indices << value
62
+ pos += consumed
63
+ dimension_type = 1
64
+ when 1
65
+ value, consumed = parse_integer(string, pos)
66
+ indices << value
67
+ pos += consumed
68
+ dimension_type = 2
69
+ when 2
70
+ value, consumed = parse_uppercase(string, pos)
71
+ indices << value
72
+ pos += consumed
73
+ dimension_type = 0
74
+ end
38
75
  end
39
76
 
40
77
  indices
41
78
  end
42
79
 
43
- # Parses a single dimension from the remaining string.
80
+ # Checks if a byte is a lowercase ASCII letter (a-z).
44
81
  #
45
- # @param remaining [String] remaining string to parse
46
- # @param dimension [Integer] current dimension number (1-based)
47
- # @return [Array(Integer, Integer)] parsed value and characters consumed
48
- # @raise [ArgumentError] if parsing fails
49
- private_class_method def self.parse_dimension(remaining, dimension)
50
- case dimension % 3
51
- when 1 then parse_lowercase(remaining)
52
- when 2 then parse_integer(remaining)
53
- when 0 then parse_uppercase(remaining)
54
- end
82
+ # @param byte [Integer, nil] byte value
83
+ # @return [Boolean] true if lowercase letter
84
+ private_class_method def self.lowercase?(byte)
85
+ byte && byte >= 97 && byte <= 122 # 'a' = 97, 'z' = 122
86
+ end
87
+
88
+ # Checks if a byte is an uppercase ASCII letter (A-Z).
89
+ #
90
+ # @param byte [Integer, nil] byte value
91
+ # @return [Boolean] true if uppercase letter
92
+ private_class_method def self.uppercase?(byte)
93
+ byte && byte >= 65 && byte <= 90 # 'A' = 65, 'Z' = 90
94
+ end
95
+
96
+ # Checks if a byte is an ASCII digit (0-9).
97
+ #
98
+ # @param byte [Integer, nil] byte value
99
+ # @return [Boolean] true if digit
100
+ private_class_method def self.digit?(byte)
101
+ byte && byte >= 48 && byte <= 57 # '0' = 48, '9' = 57
55
102
  end
56
103
 
57
- # Parses lowercase letters dimension.
104
+ # Parses lowercase letters starting at position.
58
105
  #
59
- # @param remaining [String] remaining string to parse
60
- # @return [Array(Integer, Integer)] parsed value and characters consumed
61
- # @raise [ArgumentError] if parsing fails
62
- private_class_method def self.parse_lowercase(remaining)
63
- match = remaining.match(/\A([a-z]+)/)
64
- raise ::ArgumentError, "unexpected character" unless match
106
+ # @param string [String] input string
107
+ # @param pos [Integer] starting position
108
+ # @return [Array(Integer, Integer)] decoded value and characters consumed
109
+ # @raise [Sashite::Cell::Errors::Argument] if parsing fails
110
+ private_class_method def self.parse_lowercase(string, pos)
111
+ byte = string.getbyte(pos)
112
+ unless lowercase?(byte)
113
+ raise Errors::Argument, Errors::Argument::Messages::UNEXPECTED_CHARACTER
114
+ end
65
115
 
66
- value = decode_letters(match[1])
67
- raise ::ArgumentError, "index exceeds #{Coordinate::MAX_INDEX_VALUE}" if value > Coordinate::MAX_INDEX_VALUE
116
+ chars = [byte]
117
+ pos += 1
68
118
 
69
- [value, match[1].length]
119
+ while pos < string.length && lowercase?(string.getbyte(pos))
120
+ chars << string.getbyte(pos)
121
+ pos += 1
122
+ end
123
+
124
+ value = decode_lowercase(chars)
125
+ if value > Constants::MAX_INDEX_VALUE
126
+ raise Errors::Argument, Errors::Argument::Messages::INDEX_OUT_OF_RANGE
127
+ end
128
+
129
+ [value, chars.size]
70
130
  end
71
131
 
72
- # Parses positive integer dimension.
132
+ # Parses a positive integer starting at position.
73
133
  #
74
- # @param remaining [String] remaining string to parse
75
- # @return [Array(Integer, Integer)] parsed value and characters consumed
76
- # @raise [ArgumentError] if parsing fails
77
- private_class_method def self.parse_integer(remaining)
78
- match = remaining.match(/\A(0|[1-9][0-9]*)/)
79
- raise ::ArgumentError, "unexpected character" unless match
80
- raise ::ArgumentError, "leading zero" if match[1] == "0"
81
-
82
- value = match[1].to_i - 1
83
- raise ::ArgumentError, "index exceeds #{Coordinate::MAX_INDEX_VALUE}" if value > Coordinate::MAX_INDEX_VALUE
84
-
85
- [value, match[1].length]
134
+ # @param string [String] input string
135
+ # @param pos [Integer] starting position
136
+ # @return [Array(Integer, Integer)] decoded value and characters consumed
137
+ # @raise [Sashite::Cell::Errors::Argument] if parsing fails
138
+ private_class_method def self.parse_integer(string, pos)
139
+ byte = string.getbyte(pos)
140
+ unless digit?(byte)
141
+ raise Errors::Argument, Errors::Argument::Messages::UNEXPECTED_CHARACTER
142
+ end
143
+
144
+ # Check for leading zero
145
+ if byte == 48 # '0'
146
+ raise Errors::Argument, Errors::Argument::Messages::LEADING_ZERO
147
+ end
148
+
149
+ chars = [byte]
150
+ pos += 1
151
+
152
+ while pos < string.length && digit?(string.getbyte(pos))
153
+ chars << string.getbyte(pos)
154
+ pos += 1
155
+ end
156
+
157
+ value = decode_integer(chars)
158
+ if value < 0 || value > Constants::MAX_INDEX_VALUE
159
+ raise Errors::Argument, Errors::Argument::Messages::INDEX_OUT_OF_RANGE
160
+ end
161
+
162
+ [value, chars.size]
86
163
  end
87
164
 
88
- # Parses uppercase letters dimension.
165
+ # Parses uppercase letters starting at position.
89
166
  #
90
- # @param remaining [String] remaining string to parse
91
- # @return [Array(Integer, Integer)] parsed value and characters consumed
92
- # @raise [ArgumentError] if parsing fails
93
- private_class_method def self.parse_uppercase(remaining)
94
- match = remaining.match(/\A([A-Z]+)/)
95
- raise ::ArgumentError, "unexpected character" unless match
167
+ # @param string [String] input string
168
+ # @param pos [Integer] starting position
169
+ # @return [Array(Integer, Integer)] decoded value and characters consumed
170
+ # @raise [Sashite::Cell::Errors::Argument] if parsing fails
171
+ private_class_method def self.parse_uppercase(string, pos)
172
+ byte = string.getbyte(pos)
173
+ unless uppercase?(byte)
174
+ raise Errors::Argument, Errors::Argument::Messages::UNEXPECTED_CHARACTER
175
+ end
96
176
 
97
- value = decode_letters(match[1].downcase)
98
- raise ::ArgumentError, "index exceeds #{Coordinate::MAX_INDEX_VALUE}" if value > Coordinate::MAX_INDEX_VALUE
177
+ chars = [byte]
178
+ pos += 1
99
179
 
100
- [value, match[1].length]
180
+ while pos < string.length && uppercase?(string.getbyte(pos))
181
+ chars << string.getbyte(pos)
182
+ pos += 1
183
+ end
184
+
185
+ value = decode_uppercase(chars)
186
+ if value > Constants::MAX_INDEX_VALUE
187
+ raise Errors::Argument, Errors::Argument::Messages::INDEX_OUT_OF_RANGE
188
+ end
189
+
190
+ [value, chars.size]
191
+ end
192
+
193
+ # Decodes lowercase letter bytes to an index.
194
+ #
195
+ # @param bytes [Array<Integer>] byte values
196
+ # @return [Integer] decoded index (0-255)
197
+ private_class_method def self.decode_lowercase(bytes)
198
+ if bytes.size == 1
199
+ bytes[0] - 97 # 'a' = 97
200
+ else
201
+ first = bytes[0] - 97
202
+ second = bytes[1] - 97
203
+ 26 + (first * 26) + second
204
+ end
101
205
  end
102
206
 
103
- # Decodes letter sequence to index (a=0, z=25, aa=26, ...).
207
+ # Decodes uppercase letter bytes to an index.
104
208
  #
105
- # @param letters [String] lowercase letter sequence
106
- # @return [Integer] decoded index
107
- private_class_method def self.decode_letters(letters)
108
- letters = letters.downcase
109
- return letters.ord - "a".ord if letters.length == 1
110
-
111
- # Multi-letter: aa=26, ab=27, ..., zz=701
112
- result = 0
113
- letters.each_char do |char|
114
- result = (result * 26) + (char.ord - "a".ord)
115
- end
116
- result + 26
209
+ # @param bytes [Array<Integer>] byte values
210
+ # @return [Integer] decoded index (0-255)
211
+ private_class_method def self.decode_uppercase(bytes)
212
+ if bytes.size == 1
213
+ bytes[0] - 65 # 'A' = 65
214
+ else
215
+ first = bytes[0] - 65
216
+ second = bytes[1] - 65
217
+ 26 + (first * 26) + second
218
+ end
219
+ end
220
+
221
+ # Decodes digit bytes to an index (1-based to 0-based).
222
+ #
223
+ # @param bytes [Array<Integer>] byte values
224
+ # @return [Integer] decoded index (0-255)
225
+ private_class_method def self.decode_integer(bytes)
226
+ value = 0
227
+ bytes.each do |byte|
228
+ value = (value * 10) + (byte - 48) # '0' = 48
229
+ end
230
+ value - 1 # Convert from 1-based to 0-based
117
231
  end
118
232
  end
119
233
  end
data/lib/sashite/cell.rb CHANGED
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "cell/errors"
4
+ require_relative "cell/formatter"
3
5
  require_relative "cell/coordinate"
4
- require_relative "cell/dumper"
5
6
  require_relative "cell/parser"
6
7
 
7
8
  module Sashite
@@ -28,11 +29,12 @@ module Sashite
28
29
  #
29
30
  # @param string [String] CELL coordinate string
30
31
  # @return [Coordinate] parsed coordinate
31
- # @raise [ArgumentError] if the string is not a valid CELL coordinate
32
+ # @raise [Sashite::Cell::Errors::Argument] if the string is not a valid CELL coordinate
32
33
  #
33
34
  # @example
34
35
  # Sashite::Cell.parse("e4") # => #<Sashite::Cell::Coordinate e4>
35
- # Sashite::Cell.parse("a0") # => raises ArgumentError
36
+ # Sashite::Cell.parse("a1A") # => #<Sashite::Cell::Coordinate a1A>
37
+ # Sashite::Cell.parse("a0") # => raises Sashite::Cell::Errors::Argument
36
38
  def self.parse(string)
37
39
  Coordinate.new(*Parser.parse_to_indices(string))
38
40
  end
@@ -41,7 +43,7 @@ module Sashite
41
43
  #
42
44
  # @param indices [Array<Integer>] 0-indexed coordinate values (0-255)
43
45
  # @return [String] CELL coordinate string
44
- # @raise [ArgumentError] if indices are invalid
46
+ # @raise [Sashite::Cell::Errors::Argument] if indices are invalid
45
47
  #
46
48
  # @example
47
49
  # Sashite::Cell.format(4, 3) # => "e4"
@@ -54,11 +56,11 @@ module Sashite
54
56
  #
55
57
  # @param string [String] CELL coordinate string
56
58
  # @return [nil]
57
- # @raise [ArgumentError] if the string is not a valid CELL coordinate
59
+ # @raise [Sashite::Cell::Errors::Argument] if the string is not a valid CELL coordinate
58
60
  #
59
61
  # @example
60
62
  # Sashite::Cell.validate("e4") # => nil
61
- # Sashite::Cell.validate("a0") # => raises ArgumentError
63
+ # Sashite::Cell.validate("a0") # => raises Sashite::Cell::Errors::Argument
62
64
  def self.validate(string)
63
65
  Parser.parse_to_indices(string)
64
66
  nil
@@ -75,7 +77,7 @@ module Sashite
75
77
  def self.valid?(string)
76
78
  validate(string)
77
79
  true
78
- rescue ::ArgumentError
80
+ rescue Errors::Argument
79
81
  false
80
82
  end
81
83
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sashite-cell
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.0
4
+ version: 4.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cyril Kato
@@ -22,8 +22,12 @@ files:
22
22
  - README.md
23
23
  - lib/sashite-cell.rb
24
24
  - lib/sashite/cell.rb
25
+ - lib/sashite/cell/constants.rb
25
26
  - lib/sashite/cell/coordinate.rb
26
- - lib/sashite/cell/dumper.rb
27
+ - lib/sashite/cell/errors.rb
28
+ - lib/sashite/cell/errors/argument.rb
29
+ - lib/sashite/cell/errors/argument/messages.rb
30
+ - lib/sashite/cell/formatter.rb
27
31
  - lib/sashite/cell/parser.rb
28
32
  homepage: https://github.com/sashite/cell.rb
29
33
  licenses:
@@ -1,55 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Sashite
4
- module Cell
5
- # Formats index arrays into CELL coordinate strings.
6
- #
7
- # @example
8
- # Dumper.indices_to_string([4, 3]) # => "e4"
9
- # Dumper.indices_to_string([0, 0, 0]) # => "a1A"
10
- #
11
- # @api private
12
- module Dumper
13
- # Formats indices array to CELL string.
14
- #
15
- # @param indices [Array<Integer>] 0-indexed coordinate values
16
- # @return [String] CELL coordinate string
17
- def self.indices_to_string(indices)
18
- indices.each_with_index.map do |index, i|
19
- dimension = i + 1
20
- case dimension % 3
21
- when 1 then encode_letters(index, uppercase: false)
22
- when 2 then encode_integer(index)
23
- when 0 then encode_letters(index, uppercase: true)
24
- end
25
- end.join
26
- end
27
-
28
- # Encodes index to letter sequence (0=a, 25=z, 26=aa, ...).
29
- #
30
- # @param index [Integer] 0-indexed value
31
- # @param uppercase [Boolean] whether to use uppercase letters
32
- # @return [String] letter sequence
33
- private_class_method def self.encode_letters(index, uppercase:)
34
- base_char = uppercase ? "A" : "a"
35
-
36
- if index < 26
37
- (base_char.ord + index).chr
38
- else
39
- adjusted = index - 26
40
- first = adjusted / 26
41
- second = adjusted % 26
42
- (base_char.ord + first).chr + (base_char.ord + second).chr
43
- end
44
- end
45
-
46
- # Encodes index to number string (0=1, 1=2, ...).
47
- #
48
- # @param index [Integer] 0-indexed value
49
- # @return [String] number string (1-indexed)
50
- private_class_method def self.encode_integer(index)
51
- (index + 1).to_s
52
- end
53
- end
54
- end
55
- end