sashite-cell 2.0.2 → 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 +4 -4
- data/LICENSE +201 -0
- data/README.md +142 -265
- data/lib/sashite/cell/constants.rb +19 -0
- data/lib/sashite/cell/coordinate.rb +113 -0
- data/lib/sashite/cell/errors/argument/messages.rb +22 -0
- data/lib/sashite/cell/errors/argument.rb +30 -0
- data/lib/sashite/cell/errors.rb +3 -0
- data/lib/sashite/cell/formatter.rb +102 -0
- data/lib/sashite/cell/parser.rb +234 -0
- data/lib/sashite/cell.rb +57 -237
- data/lib/sashite-cell.rb +0 -11
- metadata +13 -6
- data/LICENSE.md +0 -22
|
@@ -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
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "constants"
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
|
|
6
|
+
module Sashite
|
|
7
|
+
module Cell
|
|
8
|
+
# Parses CELL coordinate strings into index arrays.
|
|
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
|
+
#
|
|
22
|
+
# @example
|
|
23
|
+
# Parser.parse_to_indices("e4") # => [4, 3]
|
|
24
|
+
# Parser.parse_to_indices("a1A") # => [0, 0, 0]
|
|
25
|
+
#
|
|
26
|
+
# @api private
|
|
27
|
+
module Parser
|
|
28
|
+
# Parses a CELL string into an array of indices.
|
|
29
|
+
#
|
|
30
|
+
# @param string [String] CELL coordinate string
|
|
31
|
+
# @return [Array<Integer>] 0-indexed coordinate values
|
|
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]
|
|
37
|
+
def self.parse_to_indices(string)
|
|
38
|
+
raise Errors::Argument, Errors::Argument::Messages::EMPTY_INPUT if string.empty?
|
|
39
|
+
|
|
40
|
+
if string.length > Constants::MAX_STRING_LENGTH
|
|
41
|
+
raise Errors::Argument, Errors::Argument::Messages::INPUT_TOO_LONG
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
first_byte = string.getbyte(0)
|
|
45
|
+
unless lowercase?(first_byte)
|
|
46
|
+
raise Errors::Argument, Errors::Argument::Messages::INVALID_START
|
|
47
|
+
end
|
|
48
|
+
|
|
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
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
indices
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Checks if a byte is a lowercase ASCII letter (a-z).
|
|
81
|
+
#
|
|
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
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Parses lowercase letters starting at position.
|
|
105
|
+
#
|
|
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
|
|
115
|
+
|
|
116
|
+
chars = [byte]
|
|
117
|
+
pos += 1
|
|
118
|
+
|
|
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]
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Parses a positive integer starting at position.
|
|
133
|
+
#
|
|
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]
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Parses uppercase letters starting at position.
|
|
166
|
+
#
|
|
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
|
|
176
|
+
|
|
177
|
+
chars = [byte]
|
|
178
|
+
pos += 1
|
|
179
|
+
|
|
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
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Decodes uppercase letter bytes to an index.
|
|
208
|
+
#
|
|
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
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
data/lib/sashite/cell.rb
CHANGED
|
@@ -1,264 +1,84 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "cell/errors"
|
|
4
|
+
require_relative "cell/formatter"
|
|
5
|
+
require_relative "cell/coordinate"
|
|
6
|
+
require_relative "cell/parser"
|
|
7
|
+
|
|
3
8
|
module Sashite
|
|
4
|
-
# CELL (Coordinate Encoding for Layered Locations) implementation
|
|
9
|
+
# CELL (Coordinate Encoding for Layered Locations) implementation.
|
|
10
|
+
#
|
|
11
|
+
# Provides parsing, formatting, and validation of CELL coordinates
|
|
12
|
+
# for multi-dimensional game boards (up to 3 dimensions).
|
|
13
|
+
#
|
|
14
|
+
# @example Parsing a coordinate
|
|
15
|
+
# coord = Sashite::Cell.parse("e4")
|
|
16
|
+
# coord.indices # => [4, 3]
|
|
17
|
+
# coord.dimensions # => 2
|
|
18
|
+
#
|
|
19
|
+
# @example Formatting indices
|
|
20
|
+
# Sashite::Cell.format(4, 3) # => "e4"
|
|
5
21
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
22
|
+
# @example Validation
|
|
23
|
+
# Sashite::Cell.valid?("e4") # => true
|
|
24
|
+
# Sashite::Cell.valid?("a0") # => false
|
|
8
25
|
#
|
|
9
|
-
#
|
|
10
|
-
# @see https://sashite.dev/specs/cell/1.0.0/ CELL Specification v1.0.0
|
|
26
|
+
# @see https://sashite.dev/specs/cell/1.0.0/
|
|
11
27
|
module Cell
|
|
12
|
-
#
|
|
13
|
-
# Note: Line breaks must be rejected separately (see valid?)
|
|
14
|
-
REGEX = /^[a-z]+(?:[1-9][0-9]*[A-Z]+[a-z]+)*(?:[1-9][0-9]*[A-Z]*)?$/
|
|
15
|
-
|
|
16
|
-
# Check if a string represents a valid CELL coordinate
|
|
17
|
-
#
|
|
18
|
-
# Implements full-string matching as required by the CELL specification.
|
|
19
|
-
# Rejects any input containing line breaks (\r or \n).
|
|
28
|
+
# Parses a CELL string into a Coordinate.
|
|
20
29
|
#
|
|
21
|
-
# @param string [String]
|
|
22
|
-
# @return [
|
|
30
|
+
# @param string [String] CELL coordinate string
|
|
31
|
+
# @return [Coordinate] parsed coordinate
|
|
32
|
+
# @raise [Sashite::Cell::Errors::Argument] if the string is not a valid CELL coordinate
|
|
23
33
|
#
|
|
24
34
|
# @example
|
|
25
|
-
# Sashite::Cell.
|
|
26
|
-
# Sashite::Cell.
|
|
27
|
-
# Sashite::Cell.
|
|
28
|
-
# Sashite::Cell.valid?("a0") # => false
|
|
29
|
-
# Sashite::Cell.valid?("a1\n") # => false
|
|
30
|
-
def self.valid?(string)
|
|
31
|
-
return false unless string.is_a?(String)
|
|
32
|
-
return false if string.empty?
|
|
33
|
-
return false if string.include?("\r") || string.include?("\n")
|
|
34
|
-
|
|
35
|
-
string.match?(REGEX)
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
# Get the number of dimensions in a coordinate
|
|
39
|
-
#
|
|
40
|
-
# @param string [String] the coordinate string
|
|
41
|
-
# @return [Integer] the number of dimensions
|
|
42
|
-
#
|
|
43
|
-
# @example
|
|
44
|
-
# Sashite::Cell.dimensions("a1") # => 2
|
|
45
|
-
# Sashite::Cell.dimensions("a1A") # => 3
|
|
46
|
-
# Sashite::Cell.dimensions("foobar") # => 1
|
|
47
|
-
def self.dimensions(string)
|
|
48
|
-
return 0 unless valid?(string)
|
|
49
|
-
|
|
50
|
-
parse(string).length
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
# Parse a coordinate string into dimensional components
|
|
54
|
-
#
|
|
55
|
-
# @param string [String] the coordinate string to parse
|
|
56
|
-
# @return [Array<String>] array of dimensional components
|
|
57
|
-
#
|
|
58
|
-
# @example
|
|
59
|
-
# Sashite::Cell.parse("a1A") # => ["a", "1", "A"]
|
|
60
|
-
# Sashite::Cell.parse("h8Hh8") # => ["h", "8", "H", "h", "8"]
|
|
61
|
-
# Sashite::Cell.parse("foobar") # => ["foobar"] (if valid single dimension)
|
|
35
|
+
# Sashite::Cell.parse("e4") # => #<Sashite::Cell::Coordinate e4>
|
|
36
|
+
# Sashite::Cell.parse("a1A") # => #<Sashite::Cell::Coordinate a1A>
|
|
37
|
+
# Sashite::Cell.parse("a0") # => raises Sashite::Cell::Errors::Argument
|
|
62
38
|
def self.parse(string)
|
|
63
|
-
|
|
64
|
-
return [] if string.empty?
|
|
65
|
-
return [] unless valid?(string)
|
|
66
|
-
|
|
67
|
-
parse_recursive(string, 1)
|
|
39
|
+
Coordinate.new(*Parser.parse_to_indices(string))
|
|
68
40
|
end
|
|
69
41
|
|
|
70
|
-
#
|
|
42
|
+
# Formats indices into a CELL string.
|
|
71
43
|
#
|
|
72
|
-
# @param
|
|
73
|
-
# @return [
|
|
44
|
+
# @param indices [Array<Integer>] 0-indexed coordinate values (0-255)
|
|
45
|
+
# @return [String] CELL coordinate string
|
|
46
|
+
# @raise [Sashite::Cell::Errors::Argument] if indices are invalid
|
|
74
47
|
#
|
|
75
48
|
# @example
|
|
76
|
-
# Sashite::Cell.
|
|
77
|
-
# Sashite::Cell.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
return [] unless valid?(string)
|
|
81
|
-
|
|
82
|
-
parse(string).map.with_index do |component, index|
|
|
83
|
-
dimension_type = dimension_type(index + 1)
|
|
84
|
-
component_to_index(component, dimension_type)
|
|
85
|
-
end
|
|
49
|
+
# Sashite::Cell.format(4, 3) # => "e4"
|
|
50
|
+
# Sashite::Cell.format(0, 0, 0) # => "a1A"
|
|
51
|
+
def self.format(*indices)
|
|
52
|
+
Coordinate.new(*indices).to_s
|
|
86
53
|
end
|
|
87
54
|
|
|
88
|
-
#
|
|
55
|
+
# Validates a CELL string.
|
|
89
56
|
#
|
|
90
|
-
# @param
|
|
91
|
-
# @return [
|
|
57
|
+
# @param string [String] CELL coordinate string
|
|
58
|
+
# @return [nil]
|
|
59
|
+
# @raise [Sashite::Cell::Errors::Argument] if the string is not a valid CELL coordinate
|
|
92
60
|
#
|
|
93
61
|
# @example
|
|
94
|
-
# Sashite::Cell.
|
|
95
|
-
# Sashite::Cell.
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
result = indices.map.with_index do |index, dimension|
|
|
101
|
-
dimension_type = dimension_type(dimension + 1)
|
|
102
|
-
index_to_component(index, dimension_type)
|
|
103
|
-
end.join
|
|
104
|
-
|
|
105
|
-
# Verify the result is valid according to CELL specification
|
|
106
|
-
valid?(result) ? result : ""
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
# Get the validation regular expression
|
|
110
|
-
#
|
|
111
|
-
# Note: This regex alone does not guarantee full compliance. The valid?
|
|
112
|
-
# method additionally rejects strings containing line breaks, as required
|
|
113
|
-
# by the specification's anchoring requirements.
|
|
114
|
-
#
|
|
115
|
-
# @return [Regexp] the CELL validation regex from specification v1.0.0
|
|
116
|
-
def self.regex
|
|
117
|
-
REGEX
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
# Recursively parse a coordinate string into components
|
|
121
|
-
# following the strict CELL specification cyclical pattern
|
|
122
|
-
#
|
|
123
|
-
# @param string [String] the remaining string to parse
|
|
124
|
-
# @param dimension [Integer] the current dimension (1-indexed)
|
|
125
|
-
# @return [Array<String>] array of dimensional components
|
|
126
|
-
def self.parse_recursive(string, dimension)
|
|
127
|
-
return [] if string.empty?
|
|
128
|
-
|
|
129
|
-
expected_type = dimension_type(dimension)
|
|
130
|
-
component = extract_component(string, expected_type)
|
|
131
|
-
|
|
132
|
-
return [] if component.nil?
|
|
133
|
-
|
|
134
|
-
# Extract component and recursively parse the rest
|
|
135
|
-
remaining = string[component.length..]
|
|
136
|
-
[component] + parse_recursive(remaining, dimension + 1)
|
|
62
|
+
# Sashite::Cell.validate("e4") # => nil
|
|
63
|
+
# Sashite::Cell.validate("a0") # => raises Sashite::Cell::Errors::Argument
|
|
64
|
+
def self.validate(string)
|
|
65
|
+
Parser.parse_to_indices(string)
|
|
66
|
+
nil
|
|
137
67
|
end
|
|
138
68
|
|
|
139
|
-
#
|
|
140
|
-
# Following CELL specification cyclical system: dimension n % 3 determines character set
|
|
69
|
+
# Reports whether string is a valid CELL coordinate.
|
|
141
70
|
#
|
|
142
|
-
# @param
|
|
143
|
-
# @return [
|
|
144
|
-
def self.dimension_type(dimension)
|
|
145
|
-
case dimension % 3
|
|
146
|
-
when 1 then :lowercase # n % 3 = 1: Latin lowercase letters
|
|
147
|
-
when 2 then :numeric # n % 3 = 2: Arabic numerals
|
|
148
|
-
when 0 then :uppercase # n % 3 = 0: Latin uppercase letters
|
|
149
|
-
end
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
# Extract the next component from a string based on expected type
|
|
153
|
-
# Strictly follows CELL specification patterns
|
|
154
|
-
#
|
|
155
|
-
# @param string [String] the string to extract from
|
|
156
|
-
# @param type [Symbol] the expected component type
|
|
157
|
-
# @return [String, nil] the extracted component or nil if invalid
|
|
158
|
-
def self.extract_component(string, type)
|
|
159
|
-
case type
|
|
160
|
-
when :lowercase
|
|
161
|
-
# Latin lowercase letters: [a-z]+
|
|
162
|
-
match = string.match(/^([a-z]+)/)
|
|
163
|
-
match ? match[1] : nil
|
|
164
|
-
when :numeric
|
|
165
|
-
# Arabic numerals: [1-9][0-9]* (CELL specification requires positive integers only)
|
|
166
|
-
match = string.match(/^([1-9][0-9]*)/)
|
|
167
|
-
match ? match[1] : nil
|
|
168
|
-
when :uppercase
|
|
169
|
-
# Latin uppercase letters: [A-Z]+
|
|
170
|
-
match = string.match(/^([A-Z]+)/)
|
|
171
|
-
match ? match[1] : nil
|
|
172
|
-
end
|
|
173
|
-
end
|
|
174
|
-
|
|
175
|
-
# Convert a component to its 0-indexed position
|
|
176
|
-
#
|
|
177
|
-
# @param component [String] the component
|
|
178
|
-
# @param type [Symbol] the component type
|
|
179
|
-
# @return [Integer] the 0-indexed position
|
|
180
|
-
def self.component_to_index(component, type)
|
|
181
|
-
case type
|
|
182
|
-
when :lowercase
|
|
183
|
-
letters_to_index(component)
|
|
184
|
-
when :numeric
|
|
185
|
-
component.to_i - 1
|
|
186
|
-
when :uppercase
|
|
187
|
-
letters_to_index(component.downcase)
|
|
188
|
-
end
|
|
189
|
-
end
|
|
190
|
-
|
|
191
|
-
# Convert a 0-indexed position to a component
|
|
192
|
-
#
|
|
193
|
-
# @param index [Integer] the 0-indexed position
|
|
194
|
-
# @param type [Symbol] the component type
|
|
195
|
-
# @return [String] the component
|
|
196
|
-
def self.index_to_component(index, type)
|
|
197
|
-
case type
|
|
198
|
-
when :lowercase
|
|
199
|
-
index_to_letters(index)
|
|
200
|
-
when :numeric
|
|
201
|
-
(index + 1).to_s
|
|
202
|
-
when :uppercase
|
|
203
|
-
index_to_letters(index).upcase
|
|
204
|
-
end
|
|
205
|
-
end
|
|
206
|
-
|
|
207
|
-
# Convert letter sequence to 0-indexed position
|
|
208
|
-
# Extended alphabet per CELL specification: a=0, b=1, ..., z=25, aa=26, ab=27, ..., zz=701, aaa=702, etc.
|
|
71
|
+
# @param string [String] CELL coordinate string
|
|
72
|
+
# @return [Boolean] true if valid, false otherwise
|
|
209
73
|
#
|
|
210
|
-
# @
|
|
211
|
-
#
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
index += 26**len
|
|
219
|
-
end
|
|
220
|
-
|
|
221
|
-
# Add position within current length
|
|
222
|
-
letters.each_char.with_index do |char, pos|
|
|
223
|
-
index += (char.ord - 97) * (26**(length - pos - 1))
|
|
224
|
-
end
|
|
225
|
-
|
|
226
|
-
index
|
|
227
|
-
end
|
|
228
|
-
|
|
229
|
-
# Convert 0-indexed position to letter sequence
|
|
230
|
-
# Extended alphabet per CELL specification: 0=a, 1=b, ..., 25=z, 26=aa, 27=ab, ..., 701=zz, 702=aaa, etc.
|
|
231
|
-
#
|
|
232
|
-
# @param index [Integer] the 0-indexed position
|
|
233
|
-
# @return [String] the letter sequence
|
|
234
|
-
def self.index_to_letters(index)
|
|
235
|
-
# Find the length of the result
|
|
236
|
-
length = 1
|
|
237
|
-
base = 0
|
|
238
|
-
|
|
239
|
-
loop do
|
|
240
|
-
range_size = 26**length
|
|
241
|
-
break if index < base + range_size
|
|
242
|
-
|
|
243
|
-
base += range_size
|
|
244
|
-
length += 1
|
|
245
|
-
end
|
|
246
|
-
|
|
247
|
-
# Convert within the found length
|
|
248
|
-
adjusted_index = index - base
|
|
249
|
-
result = ""
|
|
250
|
-
|
|
251
|
-
length.times do |pos|
|
|
252
|
-
char_index = adjusted_index / (26**(length - pos - 1))
|
|
253
|
-
result += (char_index + 97).chr
|
|
254
|
-
adjusted_index %= (26**(length - pos - 1))
|
|
255
|
-
end
|
|
256
|
-
|
|
257
|
-
result
|
|
74
|
+
# @example
|
|
75
|
+
# Sashite::Cell.valid?("e4") # => true
|
|
76
|
+
# Sashite::Cell.valid?("a0") # => false
|
|
77
|
+
def self.valid?(string)
|
|
78
|
+
validate(string)
|
|
79
|
+
true
|
|
80
|
+
rescue Errors::Argument
|
|
81
|
+
false
|
|
258
82
|
end
|
|
259
|
-
|
|
260
|
-
private_class_method :parse_recursive, :dimension_type, :extract_component
|
|
261
|
-
private_class_method :component_to_index, :index_to_component
|
|
262
|
-
private_class_method :letters_to_index, :index_to_letters
|
|
263
83
|
end
|
|
264
84
|
end
|
data/lib/sashite-cell.rb
CHANGED
|
@@ -1,14 +1,3 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "sashite/cell"
|
|
4
|
-
|
|
5
|
-
# Sashité namespace for board game notation libraries
|
|
6
|
-
#
|
|
7
|
-
# Sashité provides a collection of libraries for representing and manipulating
|
|
8
|
-
# board game concepts according to the Sashité Protocol specifications.
|
|
9
|
-
#
|
|
10
|
-
# @see https://sashite.dev/protocol/ Sashité Protocol
|
|
11
|
-
# @see https://sashite.dev/specs/ Sashité Specifications
|
|
12
|
-
# @author Sashité
|
|
13
|
-
module Sashite
|
|
14
|
-
end
|