sashite-feen 0.1.0 → 0.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.
@@ -1,76 +1,242 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../error"
4
+ require_relative "../hands"
5
+
6
+ require "sashite/epin"
7
+
3
8
  module Sashite
4
9
  module Feen
5
10
  module Parser
11
+ # Parser for the pieces-in-hand field (second field of FEEN).
12
+ #
13
+ # Converts a FEEN pieces-in-hand string into a Hands object,
14
+ # decoding captured pieces held by each player with optional
15
+ # count prefixes.
16
+ #
17
+ # @see https://sashite.dev/specs/feen/1.0.0/
6
18
  module PiecesInHand
7
- module_function
19
+ # Player separator in pieces-in-hand field.
20
+ PLAYER_SEPARATOR = "/"
21
+
22
+ # Pattern to match EPIN pieces (optional state prefix, letter, optional derivation suffix).
23
+ EPIN_PATTERN = /\A[-+]?[A-Za-z]'?\z/
24
+
25
+ # Parse a FEEN pieces-in-hand string into a Hands object.
26
+ #
27
+ # @param string [String] FEEN pieces-in-hand field string
28
+ # @return [Hands] Parsed hands object
29
+ # @raise [Error::Syntax] If hands format is invalid
30
+ # @raise [Error::Piece] If EPIN notation is invalid
31
+ # @raise [Error::Count] If piece counts are invalid
32
+ #
33
+ # @example No pieces in hand
34
+ # parse("/") # => Hands.new([], [])
35
+ #
36
+ # @example First player has pieces
37
+ # parse("2P/p") # => Hands.new([P, P], [p])
38
+ #
39
+ # @example Both players have pieces
40
+ # parse("RBN/2p") # => Hands.new([R, B, N], [p, p])
41
+ def self.parse(string)
42
+ first_str, second_str = split_players(string)
43
+
44
+ first_player_pieces = parse_player_pieces(first_str)
45
+ second_player_pieces = parse_player_pieces(second_str)
46
+
47
+ Hands.new(first_player_pieces, second_player_pieces)
48
+ end
49
+
50
+ # Split pieces-in-hand string into first and second player parts.
51
+ #
52
+ # @param string [String] Pieces-in-hand field string
53
+ # @return [Array(String, String)] First and second player strings
54
+ # @raise [Error::Syntax] If separator is missing
55
+ #
56
+ # @example
57
+ # split_players("2P/p") # => ["2P", "p"]
58
+ # split_players("/") # => ["", ""]
59
+ private_class_method def self.split_players(string)
60
+ parts = string.split(PLAYER_SEPARATOR, 2)
61
+
62
+ raise Error::Syntax, "pieces-in-hand must contain '#{PLAYER_SEPARATOR}' separator" unless parts.size == 2
63
+
64
+ parts
65
+ end
8
66
 
9
- # Parse the pieces-in-hand field into a Hands value object.
67
+ # Parse pieces for a single player.
10
68
  #
11
- # Strictness:
12
- # - "-" => empty hands (valid)
13
- # - "" => invalid (raises Error::Syntax)
69
+ # Extracts pieces with optional count prefixes and expands them
70
+ # into individual piece objects.
14
71
  #
15
- # Grammar (tolerant for counts notation):
16
- # hands := "-" | entry ("," entry)*
17
- # entry := epin | int ("x"|"*") epin | epin ("x"|"*") int
18
- # int := [1-9][0-9]*
72
+ # @param string [String] Player's pieces string (e.g., "2PRB")
73
+ # @return [Array] Array of piece objects
74
+ # @raise [Error::Syntax] If format is invalid
75
+ # @raise [Error::Piece] If EPIN notation is invalid
76
+ # @raise [Error::Count] If counts are invalid
19
77
  #
20
- # @param field [String]
21
- # @return [Sashite::Feen::Hands]
22
- def parse(field)
23
- src = String(field).strip
24
- raise Error::Syntax, "empty hands field" if src.empty?
25
- return Hands.new({}.freeze) if src == "-"
78
+ # @example Single pieces
79
+ # parse_player_pieces("RBN") # => [R, B, N]
80
+ #
81
+ # @example With counts
82
+ # parse_player_pieces("3P2R") # => [P, P, P, R, R]
83
+ #
84
+ # @example Empty
85
+ # parse_player_pieces("") # => []
86
+ private_class_method def self.parse_player_pieces(string)
87
+ return [] if string.empty?
26
88
 
27
- entries = src.split(",").map(&:strip).reject(&:empty?)
28
- raise Error::Syntax, "malformed hands field" if entries.empty?
89
+ pieces = []
90
+ chars = string.chars
91
+ i = 0
29
92
 
30
- counts = Hash.new(0)
93
+ while i < chars.size
94
+ count, epin_str, consumed = extract_piece_with_count(chars, i)
95
+ piece = parse_piece(epin_str)
31
96
 
32
- entries.each_with_index do |entry, idx|
33
- epin_token, qty = _parse_hand_entry(entry, idx)
34
- epin_id = _parse_epin(epin_token, idx)
35
- counts[epin_id] += qty
97
+ count.times { pieces << piece }
98
+ i += consumed
36
99
  end
37
100
 
38
- frozen_counts = {}
39
- counts.each { |k, v| frozen_counts[k] = Integer(v) }
101
+ pieces
102
+ end
103
+
104
+ # Extract a piece with optional count prefix from character array.
105
+ #
106
+ # Handles multi-digit counts and EPIN notation extraction.
107
+ #
108
+ # @param chars [Array<String>] Array of characters
109
+ # @param start_index [Integer] Starting index
110
+ # @return [Array(Integer, String, Integer)] Count, EPIN string, and chars consumed
111
+ # @raise [Error::Syntax] If format is invalid
112
+ # @raise [Error::Count] If count is invalid
113
+ #
114
+ # @example Single piece
115
+ # extract_piece_with_count(['K', 'Q'], 0) # => [1, "K", 1]
116
+ #
117
+ # @example Multiple pieces
118
+ # extract_piece_with_count(['3', 'P', 'R'], 0) # => [3, "P", 2]
119
+ #
120
+ # @example Large count
121
+ # extract_piece_with_count(['1', '2', 'P'], 0) # => [12, "P", 3]
122
+ private_class_method def self.extract_piece_with_count(chars, start_index)
123
+ i = start_index
40
124
 
41
- Hands.new(frozen_counts.freeze)
125
+ # Extract optional count (may be multi-digit)
126
+ count_chars = []
127
+ while i < chars.size && digit?(chars[i])
128
+ count_chars << chars[i]
129
+ i += 1
130
+ end
131
+
132
+ count = if count_chars.empty?
133
+ 1
134
+ else
135
+ count_str = count_chars.join
136
+ count_val = count_str.to_i
137
+ validate_count(count_val, count_str)
138
+ count_val
139
+ end
140
+
141
+ # Extract EPIN piece
142
+ raise Error::Syntax, "expected piece after count at position #{start_index}" if i >= chars.size
143
+
144
+ epin_str, epin_consumed = extract_epin(chars, i)
145
+ i += epin_consumed
146
+
147
+ consumed = i - start_index
148
+ [count, epin_str, consumed]
42
149
  end
43
150
 
44
- # Accepts forms: "P", "2xP", "P*2", "10*R", "[Shogi:P]*3"
45
- def _parse_hand_entry(str, _idx)
46
- s = str.strip
151
+ # Extract EPIN notation from character array.
152
+ #
153
+ # Handles state prefixes (+/-), base letter, and derivation suffix (').
154
+ #
155
+ # @param chars [Array<String>] Array of characters
156
+ # @param start_index [Integer] Starting index
157
+ # @return [Array(String, Integer)] EPIN string and number of characters consumed
158
+ # @raise [Error::Syntax] If EPIN format is incomplete
159
+ #
160
+ # @example Simple piece
161
+ # extract_epin(['K', 'Q'], 0) # => ["K", 1]
162
+ #
163
+ # @example Enhanced piece with derivation
164
+ # extract_epin(['+', 'R', "'", 'B'], 0) # => ["+R'", 3]
165
+ private_class_method def self.extract_epin(chars, start_index)
166
+ i = start_index
167
+ piece_chars = []
47
168
 
48
- if (m = /\A(\d+)\s*[x*]\s*(.+)\z/.match(s))
49
- n = Integer(m[1])
50
- raise Error::Count, "hand count must be >= 1, got #{n}" if n <= 0
169
+ # Optional state prefix
170
+ if i < chars.size && ["+", "-"].include?(chars[i])
171
+ piece_chars << chars[i]
172
+ i += 1
173
+ end
51
174
 
52
- return [m[2].strip, n]
175
+ # Base letter (required)
176
+ if i >= chars.size || !letter?(chars[i])
177
+ raise Error::Syntax, "expected letter in EPIN notation at position #{start_index}"
53
178
  end
54
179
 
55
- if (m = /\A(.+?)\s*[x*]\s*(\d+)\z/.match(s))
56
- n = Integer(m[2])
57
- raise Error::Count, "hand count must be >= 1, got #{n}" if n <= 0
180
+ piece_chars << chars[i]
181
+ i += 1
58
182
 
59
- return [m[1].strip, n]
183
+ # Optional derivation suffix
184
+ if i < chars.size && chars[i] == "'"
185
+ piece_chars << chars[i]
186
+ i += 1
60
187
  end
61
188
 
62
- # Default: single piece
63
- [s, 1]
189
+ piece_str = piece_chars.join
190
+ consumed = i - start_index
191
+
192
+ [piece_str, consumed]
64
193
  end
65
- module_function :_parse_hand_entry
66
- private_class_method :_parse_hand_entry
67
194
 
68
- def _parse_epin(token, idx)
69
- ::Sashite::Epin.parse(token)
195
+ # Check if character is a digit.
196
+ #
197
+ # @param char [String] Single character
198
+ # @return [Boolean] True if character is 0-9
199
+ private_class_method def self.digit?(char)
200
+ char >= "0" && char <= "9"
201
+ end
202
+
203
+ # Check if character is a letter.
204
+ #
205
+ # @param char [String] Single character
206
+ # @return [Boolean] True if character is A-Z or a-z
207
+ private_class_method def self.letter?(char)
208
+ (char >= "A" && char <= "Z") || (char >= "a" && char <= "z")
209
+ end
210
+
211
+ # Validate piece count.
212
+ #
213
+ # @param count [Integer] Piece count
214
+ # @param count_str [String] Original count string for error messages
215
+ # @raise [Error::Count] If count is invalid
216
+ private_class_method def self.validate_count(count, count_str)
217
+ raise Error::Count, "piece count must be at least 1, got #{count_str}" if count < 1
218
+
219
+ return unless count > 999
220
+
221
+ raise Error::Count, "piece count too large: #{count_str}"
222
+ end
223
+
224
+ # Parse EPIN string into a piece object.
225
+ #
226
+ # @param epin_str [String] EPIN notation string
227
+ # @return [Object] Piece identifier object
228
+ # @raise [Error::Piece] If EPIN is invalid
229
+ #
230
+ # @example
231
+ # parse_piece("K") # => Epin::Identifier
232
+ # parse_piece("+R'") # => Epin::Identifier
233
+ private_class_method def self.parse_piece(epin_str)
234
+ raise Error::Piece, "invalid EPIN notation: #{epin_str}" unless EPIN_PATTERN.match?(epin_str)
235
+
236
+ Sashite::Epin.parse(epin_str)
70
237
  rescue StandardError => e
71
- raise Error::Piece, "invalid EPIN token in hands (entry #{idx + 1}): #{token.inspect} (#{e.message})"
238
+ raise Error::Piece, "failed to parse EPIN '#{epin_str}': #{e.message}"
72
239
  end
73
- private_class_method :_parse_epin
74
240
  end
75
241
  end
76
242
  end
@@ -1,62 +1,102 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../error"
4
+ require_relative "../styles"
5
+
6
+ require "sashite/sin"
7
+
3
8
  module Sashite
4
9
  module Feen
5
10
  module Parser
11
+ # Parser for the style-turn field (third field of FEEN).
12
+ #
13
+ # Converts a FEEN style-turn string into a Styles object,
14
+ # decoding game style identifiers and the active player indicator.
15
+ #
16
+ # @see https://sashite.dev/specs/feen/1.0.0/
17
+ # @see https://sashite.dev/specs/sin/1.0.0/
6
18
  module StyleTurn
7
- module_function
19
+ # Style separator in style-turn field.
20
+ STYLE_SEPARATOR = "/"
8
21
 
9
- # Strict FEEN + SIN:
10
- # style_turn := LETTER "/" LETTER # no whitespace
11
- # semantics :
12
- # - Uppercase marks the side to move.
13
- # - Exactly one uppercase among the two.
14
- # - Each letter is a SIN style code (validated via Sashite::Sin.parse).
22
+ # Parse a FEEN style-turn string into a Styles object.
23
+ #
24
+ # @param string [String] FEEN style-turn field string
25
+ # @return [Styles] Parsed styles object
26
+ # @raise [Error::Syntax] If style-turn format is invalid
27
+ # @raise [Error::Style] If SIN notation is invalid
15
28
  #
16
- # Examples (valid):
17
- # "C/c" -> first to move, first_style="C", second_style="C" (Chess vs Chess)
18
- # "c/C" -> second to move
19
- # "S/o" -> first to move, first_style="S" (Shogi), second_style="O" (Ōgi)
29
+ # @example Chess game, white to move
30
+ # parse("C/c") # => Styles.new(sin_C, sin_c)
20
31
  #
21
- # Examples (invalid):
22
- # "w" , "C / c", "Cc", "c/c", "C/C", "x/y " (wrong pattern or spaces)
32
+ # @example Chess game, black to move
33
+ # parse("c/C") # => Styles.new(sin_c, sin_C)
23
34
  #
24
- # @param field [String]
25
- # @return [Sashite::Feen::Styles] with signature Styles.new(first_style, second_style, turn)
26
- def parse(field)
27
- s = String(field)
28
- raise Error::Syntax, "empty style/turn field" if s.empty?
29
- raise Error::Syntax, "whitespace not allowed in style/turn" if s.match?(/\s/)
35
+ # @example Cross-style game, first player to move
36
+ # parse("C/m") # => Styles.new(sin_C, sin_m)
37
+ def self.parse(string)
38
+ active_str, inactive_str = split_styles(string)
30
39
 
31
- m = %r{\A([A-Za-z])/([A-Za-z])\z}.match(s)
32
- raise Error::Syntax, "invalid style/turn format" unless m
40
+ active = parse_style(active_str)
41
+ inactive = parse_style(inactive_str)
33
42
 
34
- a_raw = m[1]
35
- b_raw = m[2]
36
- a_is_up = a_raw.between?("A", "Z")
37
- b_is_up = b_raw.between?("A", "Z")
43
+ Styles.new(active, inactive)
44
+ end
38
45
 
39
- # Exactly one uppercase marks side to move
40
- raise Error::Style, "ambiguous side-to-move: exactly one letter must be uppercase" unless a_is_up ^ b_is_up
46
+ # Split style-turn string into active and inactive parts.
47
+ #
48
+ # @param string [String] Style-turn field string
49
+ # @return [Array(String, String)] Active and inactive style strings
50
+ # @raise [Error::Syntax] If separator is missing or format is invalid
51
+ #
52
+ # @example
53
+ # split_styles("C/c") # => ["C", "c"]
54
+ # split_styles("S/m") # => ["S", "m"]
55
+ private_class_method def self.split_styles(string)
56
+ parts = string.split(STYLE_SEPARATOR, 2)
41
57
 
42
- # Canonical SIN tokens are uppercase (style identity is case-insensitive in FEEN)
43
- a_tok = a_raw.upcase
44
- b_tok = b_raw.upcase
58
+ raise Error::Syntax, "style-turn must contain '#{STYLE_SEPARATOR}' separator" unless parts.size == 2
45
59
 
46
- first_style = begin
47
- ::Sashite::Sin.parse(a_tok)
48
- rescue StandardError => e
49
- raise Error::Style, "invalid SIN token for first side #{a_tok.inspect}: #{e.message}"
50
- end
60
+ raise Error::Syntax, "active style cannot be empty" if parts[0].empty?
61
+ raise Error::Syntax, "inactive style cannot be empty" if parts[1].empty?
62
+
63
+ parts
64
+ end
51
65
 
52
- second_style = begin
53
- ::Sashite::Sin.parse(b_tok)
54
- rescue StandardError => e
55
- raise Error::Style, "invalid SIN token for second side #{b_tok.inspect}: #{e.message}"
66
+ # Parse a SIN string into a style identifier object.
67
+ #
68
+ # @param sin_str [String] SIN notation string (single letter)
69
+ # @return [Object] Style identifier object
70
+ # @raise [Error::Style] If SIN is invalid
71
+ #
72
+ # @example
73
+ # parse_style("C") # => Sin::Identifier (Chess, first player)
74
+ # parse_style("c") # => Sin::Identifier (Chess, second player)
75
+ # parse_style("S") # => Sin::Identifier (Shogi, first player)
76
+ private_class_method def self.parse_style(sin_str)
77
+ unless valid_sin_format?(sin_str)
78
+ raise Error::Style, "invalid SIN notation: '#{sin_str}' (must be a single letter A-Z or a-z)"
56
79
  end
57
80
 
58
- turn = a_is_up ? :first : :second
59
- Styles.new(first_style, second_style, turn)
81
+ Sashite::Sin.parse(sin_str)
82
+ rescue ::StandardError => e
83
+ raise Error::Style, "failed to parse SIN '#{sin_str}': #{e.message}"
84
+ end
85
+
86
+ # Check if string is a valid SIN format.
87
+ #
88
+ # @param string [String] String to validate
89
+ # @return [Boolean] True if string is a single ASCII letter
90
+ private_class_method def self.valid_sin_format?(string)
91
+ string.length == 1 && letter?(string[0])
92
+ end
93
+
94
+ # Check if character is a letter.
95
+ #
96
+ # @param char [String] Single character
97
+ # @return [Boolean] True if character is A-Z or a-z
98
+ private_class_method def self.letter?(char)
99
+ (char >= "A" && char <= "Z") || (char >= "a" && char <= "z")
60
100
  end
61
101
  end
62
102
  end
@@ -4,32 +4,77 @@ require_relative "parser/piece_placement"
4
4
  require_relative "parser/pieces_in_hand"
5
5
  require_relative "parser/style_turn"
6
6
 
7
+ require_relative "error"
8
+ require_relative "position"
9
+
7
10
  module Sashite
8
11
  module Feen
12
+ # Parser for FEEN (Forsyth–Edwards Enhanced Notation) strings.
13
+ #
14
+ # Parses a complete FEEN string by splitting it into three space-separated
15
+ # fields and delegating parsing to specialized parsers for each component.
16
+ #
17
+ # @see https://sashite.dev/specs/feen/1.0.0/
9
18
  module Parser
10
- module_function
19
+ # Field separator in FEEN notation.
20
+ FIELD_SEPARATOR = " "
21
+
22
+ # Number of required fields in a valid FEEN string.
23
+ FIELD_COUNT = 3
11
24
 
12
- def parse(feen)
13
- s = String(feen).strip
14
- raise Error::Syntax, "empty FEEN input" if s.empty?
25
+ # Parse a FEEN string into an immutable Position object.
26
+ #
27
+ # Validates the overall FEEN structure, splits the string into three
28
+ # space-separated fields, and delegates parsing of each field to the
29
+ # appropriate specialized parser.
30
+ #
31
+ # @param string [String] A FEEN notation string
32
+ # @return [Position] Immutable position object
33
+ # @raise [Error::Syntax] If the FEEN structure is malformed
34
+ #
35
+ # @example Parse a complete FEEN string
36
+ # position = Parser.parse("+rnbq+kbn+r/+p+p+p+p+p+p+p+p/8/8/8/8/+P+P+P+P+P+P+P+P/+RNBQ+KBN+R / C/c")
37
+ def self.parse(string)
38
+ fields = split_fields(string)
15
39
 
16
- a, b, c = _split_3_fields(s)
17
- placement = PiecePlacement.parse(a)
18
- hands = PiecesInHand.parse(b)
19
- styles = StyleTurn.parse(c)
40
+ placement = Parser::PiecePlacement.parse(fields[0])
41
+ hands = Parser::PiecesInHand.parse(fields[1])
42
+ styles = Parser::StyleTurn.parse(fields[2])
20
43
 
21
44
  Position.new(placement, hands, styles)
22
45
  end
23
46
 
24
- def _split_3_fields(s)
25
- parts = s.split(/\s+/, 3)
26
- unless parts.length == 3
27
- raise Error::Syntax,
28
- "FEEN must have 3 whitespace-separated fields (got #{parts.length})"
47
+ # Split a FEEN string into its three constituent fields.
48
+ #
49
+ # Validates that exactly three space-separated fields are present.
50
+ # Supports empty piece placement field (board-less positions).
51
+ #
52
+ # @param string [String] A FEEN notation string
53
+ # @return [Array<String>] Array of three field strings
54
+ # @raise [Error::Syntax] If field count is not exactly 3
55
+ #
56
+ # @example Valid FEEN string
57
+ # split_fields("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / C/c")
58
+ # # => ["rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR", "/", "C/c"]
59
+ #
60
+ # @example Empty piece placement field
61
+ # split_fields(" / C/c")
62
+ # # => ["", "/", "C/c"]
63
+ #
64
+ # @example Invalid FEEN string (too few fields)
65
+ # split_fields("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR /")
66
+ # # raises Error::Syntax
67
+ private_class_method def self.split_fields(string)
68
+ # Use regex separator to preserve empty leading fields
69
+ # String#split with " " treats leading spaces specially and discards them
70
+ fields = string.split(/ /, FIELD_COUNT)
71
+
72
+ unless fields.size == FIELD_COUNT
73
+ raise Error::Syntax, "FEEN must have exactly #{FIELD_COUNT} space-separated fields, got #{fields.size}"
29
74
  end
30
- parts
75
+
76
+ fields
31
77
  end
32
- private_class_method :_split_3_fields
33
78
  end
34
79
  end
35
80
  end