feen 5.0.0.beta7 → 5.0.0.beta9

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,22 +1,48 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative File.join("pieces_in_hand", "errors")
4
- require_relative File.join("pieces_in_hand", "pnn_patterns")
5
-
6
3
  module Feen
7
4
  module Parser
8
5
  # Handles parsing of the pieces in hand section of a FEEN string.
9
6
  # Pieces in hand represent pieces available for dropping onto the board.
10
- # This implementation supports full PNN notation including prefixes and suffixes.
7
+ # According to FEEN specification, pieces in hand MUST be in base form only (no modifiers).
11
8
  # Format: "UPPERCASE_PIECES/LOWERCASE_PIECES"
12
9
  module PiecesInHand
10
+ # Error messages for validation
11
+ Errors = {
12
+ invalid_type: "Pieces in hand must be a string, got %s",
13
+ empty_string: "Pieces in hand string cannot be empty",
14
+ invalid_format: "Invalid pieces in hand format: %s",
15
+ missing_separator: "Pieces in hand format must contain exactly one '/' separator. Got: %s",
16
+ modifiers_not_allowed: 'Pieces in hand cannot contain modifiers: "%s"'
17
+ }.freeze
18
+
19
+ # Base piece pattern: single letter only (no modifiers allowed in hand)
20
+ BASE_PIECE_PATTERN = /\A[a-zA-Z]\z/
21
+
22
+ # Valid count pattern: 2-9 or any number with 2+ digits (no 0, 1, or leading zeros)
23
+ VALID_COUNT_PATTERN = /\A(?:[2-9]|[1-9]\d+)\z/
24
+
25
+ # Pattern for piece with optional count in pieces in hand
26
+ PIECE_WITH_COUNT_PATTERN = /(?:([2-9]|[1-9]\d+))?([-+]?[a-zA-Z]'?)/
27
+
28
+ # Complete validation pattern for pieces in hand string
29
+ VALID_FORMAT_PATTERN = %r{\A
30
+ (?: # Uppercase section (optional)
31
+ (?:(?:[2-9]|[1-9]\d+)?[-+]?[A-Z]'?)* # Zero or more uppercase pieces with optional counts and modifiers
32
+ )
33
+ / # Mandatory separator
34
+ (?: # Lowercase section (optional)
35
+ (?:(?:[2-9]|[1-9]\d+)?[-+]?[a-z]'?)* # Zero or more lowercase pieces with optional counts and modifiers
36
+ )
37
+ \z}x
38
+
13
39
  # Parses the pieces in hand section of a FEEN string.
14
40
  #
15
41
  # @param pieces_in_hand_str [String] FEEN pieces in hand string in format "UPPERCASE/lowercase"
16
- # @return [Array<String>] Array of piece identifiers in full PNN format,
42
+ # @return [Array<String>] Array of piece identifiers in base form only,
17
43
  # expanded based on their counts and sorted alphabetically.
18
44
  # Empty array if no pieces are in hand.
19
- # @raise [ArgumentError] If the input string is invalid
45
+ # @raise [ArgumentError] If the input string is invalid or contains modifiers
20
46
  #
21
47
  # @example Parse no pieces in hand
22
48
  # PiecesInHand.parse("/")
@@ -26,13 +52,10 @@ module Feen
26
52
  # PiecesInHand.parse("3P2B/p")
27
53
  # # => ["B", "B", "P", "P", "P", "p"]
28
54
  #
29
- # @example Parse complex pieces with counts and modifiers
30
- # PiecesInHand.parse("10P5K3B/2p'+p-pbq")
31
- # # => ["+p", "-p", "B", "B", "B", "K", "K", "K", "K", "K",
32
- # # "P", "P", "P", "P", "P", "P", "P", "P", "P", "P",
33
- # # "b", "p'", "p'", "q"]
55
+ # @example Invalid - modifiers not allowed in hand
56
+ # PiecesInHand.parse("+P/p")
57
+ # # => ArgumentError: Pieces in hand cannot contain modifiers: '+P'
34
58
  def self.parse(pieces_in_hand_str)
35
- # Validate input
36
59
  validate_input_type(pieces_in_hand_str)
37
60
  validate_format(pieces_in_hand_str)
38
61
 
@@ -42,7 +65,7 @@ module Feen
42
65
  # Split by the separator to get uppercase and lowercase sections
43
66
  uppercase_section, lowercase_section = pieces_in_hand_str.split("/", 2)
44
67
 
45
- # Parse each section separately
68
+ # Parse each section separately and validate no modifiers
46
69
  uppercase_pieces = parse_pieces_section(uppercase_section || "", :uppercase)
47
70
  lowercase_pieces = parse_pieces_section(lowercase_section || "", :lowercase)
48
71
 
@@ -62,103 +85,36 @@ module Feen
62
85
  end
63
86
 
64
87
  # Validates that the input string matches the expected format according to FEEN specification.
65
- # Format must be: "UPPERCASE_PIECES/LOWERCASE_PIECES"
88
+ # Format must be: "UPPERCASE_PIECES/LOWERCASE_PIECES" with base pieces only (no modifiers).
66
89
  #
67
90
  # @param str [String] Input string to validate
68
- # @raise [ArgumentError] If format is invalid
91
+ # @raise [ArgumentError] If format is invalid or contains modifiers
69
92
  # @return [void]
70
93
  private_class_method def self.validate_format(str)
71
94
  # Must contain exactly one "/" separator
72
95
  parts_count = str.count("/")
73
- raise ::ArgumentError, format(Errors[:invalid_format], str) unless parts_count == 1
74
-
75
- uppercase_section, lowercase_section = str.split("/", 2)
96
+ raise ::ArgumentError, format(Errors[:missing_separator], parts_count) unless parts_count == 1
76
97
 
77
- # Each section can be empty, but if not empty, must follow PNN patterns
78
- validate_section_format(uppercase_section, :uppercase) unless uppercase_section.empty?
79
- validate_section_format(lowercase_section, :lowercase) unless lowercase_section.empty?
80
- end
98
+ # Must match the overall pattern (including potential modifiers for detection)
99
+ raise ::ArgumentError, format(Errors[:invalid_format], str) unless str.match?(VALID_FORMAT_PATTERN)
81
100
 
82
- # Validates the format of a specific section (uppercase or lowercase)
83
- #
84
- # @param section [String] The section to validate
85
- # @param case_type [Symbol] Either :uppercase or :lowercase
86
- # @raise [ArgumentError] If the section format is invalid
87
- # @return [void]
88
- private_class_method def self.validate_section_format(section, case_type)
89
- return if section.empty?
90
-
91
- # Build the appropriate pattern based on case type
92
- case_pattern = case case_type
93
- when :uppercase
94
- PnnPatterns::UPPERCASE_SECTION_PATTERN
95
- when :lowercase
96
- PnnPatterns::LOWERCASE_SECTION_PATTERN
97
- else
98
- raise ArgumentError, "Invalid case type: #{case_type}"
99
- end
100
-
101
- # Validate overall section pattern
102
- raise ::ArgumentError, format(Errors[:invalid_format], section) unless section.match?(case_pattern)
103
-
104
- # Validate individual pieces in the section
105
- validate_individual_pieces_in_section(section, case_type)
101
+ # Additional validation: check for any modifiers (forbidden in hand)
102
+ validate_no_modifiers(str)
106
103
  end
107
104
 
108
- # Validates each individual piece in a section for PNN compliance
105
+ # Validates that no modifiers are present in the pieces in hand string
109
106
  #
110
- # @param section [String] FEEN pieces section string
111
- # @param case_type [Symbol] Either :uppercase or :lowercase
112
- # @raise [ArgumentError] If any piece is invalid PNN format
107
+ # @param str [String] Input string to validate
108
+ # @raise [ArgumentError] If modifiers are found
113
109
  # @return [void]
114
- private_class_method def self.validate_individual_pieces_in_section(section, case_type)
115
- position = 0
110
+ private_class_method def self.validate_no_modifiers(str)
111
+ # Check for any modifier characters that are forbidden in pieces in hand
112
+ return unless str.match?(/[+\-']/)
116
113
 
117
- while position < section.length
118
- match = section[position..].match(PnnPatterns::PIECE_WITH_COUNT_PATTERN)
114
+ # Find the specific invalid piece to provide a better error message
115
+ invalid_pieces = str.scan(/(?:[2-9]|[1-9]\d+)?[-+]?[a-zA-Z]'?/).grep(/[+\-']/)
119
116
 
120
- unless match
121
- remaining = section[position..]
122
- raise ::ArgumentError, format(Errors[:invalid_format], remaining)
123
- end
124
-
125
- count_str, piece = match.captures
126
-
127
- # Skip empty matches (shouldn't happen with our pattern, but safety check)
128
- if piece.nil? || piece.empty?
129
- position += 1
130
- next
131
- end
132
-
133
- # Validate the piece follows PNN specification
134
- unless piece.match?(PnnPatterns::PNN_PIECE_PATTERN)
135
- raise ::ArgumentError, format(Errors[:invalid_pnn_piece], piece)
136
- end
137
-
138
- # Validate count format (no "0" or "1" prefixes allowed)
139
- if count_str && !count_str.match?(PnnPatterns::VALID_COUNT_PATTERN)
140
- raise ::ArgumentError, format(Errors[:invalid_count], count_str)
141
- end
142
-
143
- # Validate that the piece matches the expected case
144
- piece_case = piece_is_uppercase?(piece) ? :uppercase : :lowercase
145
- unless piece_case == case_type
146
- case_name = case_type == :uppercase ? "uppercase" : "lowercase"
147
- raise ::ArgumentError, "#{case_name.capitalize} section contains #{piece_case} piece: '#{piece}'"
148
- end
149
-
150
- position += match[0].length
151
- end
152
- end
153
-
154
- # Determines if a piece belongs to the uppercase group
155
- #
156
- # @param piece [String] Piece identifier (e.g., "P", "+P", "P'", "+P'")
157
- # @return [Boolean] True if the piece's main letter is uppercase
158
- private_class_method def self.piece_is_uppercase?(piece)
159
- # Extract the main letter (skip prefixes like + or -)
160
- main_letter = piece.gsub(/\A[+-]/, "").gsub(/'\z/, "")
161
- main_letter.match?(/[A-Z]/)
117
+ raise ::ArgumentError, format(Errors[:modifiers_not_allowed], invalid_pieces.first)
162
118
  end
163
119
 
164
120
  # Parses a specific section (uppercase or lowercase) and returns expanded pieces
@@ -166,33 +122,55 @@ module Feen
166
122
  # @param section [String] The section string to parse
167
123
  # @param case_type [Symbol] Either :uppercase or :lowercase (for validation)
168
124
  # @return [Array<String>] Array of expanded pieces from this section
169
- private_class_method def self.parse_pieces_section(section, _case_type)
125
+ private_class_method def self.parse_pieces_section(section, case_type)
170
126
  return [] if section.empty?
171
127
 
172
128
  # Extract pieces with their counts
173
- pieces_with_counts = extract_pieces_with_counts_from_section(section)
129
+ pieces_with_counts = extract_pieces_with_counts_from_section(section, case_type)
174
130
 
175
- # Expand the pieces into an array (no canonical order validation needed)
131
+ # Expand the pieces into an array
176
132
  expand_pieces(pieces_with_counts)
177
133
  end
178
134
 
179
135
  # Extracts pieces with their counts from a section string.
180
136
  #
181
137
  # @param section [String] FEEN pieces section string
138
+ # @param case_type [Symbol] Either :uppercase or :lowercase
182
139
  # @return [Array<Hash>] Array of hashes with :piece and :count keys
183
- private_class_method def self.extract_pieces_with_counts_from_section(section)
140
+ # @raise [ArgumentError] If pieces don't match the expected case or contain modifiers
141
+ private_class_method def self.extract_pieces_with_counts_from_section(section, case_type)
184
142
  result = []
185
143
  position = 0
186
144
 
187
145
  while position < section.length
188
- match = section[position..].match(PnnPatterns::PIECE_WITH_COUNT_PATTERN)
146
+ match = section[position..].match(PIECE_WITH_COUNT_PATTERN)
189
147
  break unless match
190
148
 
191
- count_str, piece = match.captures
149
+ count_str, piece_with_modifiers = match.captures
192
150
  count = count_str ? count_str.to_i : 1
193
151
 
152
+ # Extract just the base piece (remove any modifiers)
153
+ base_piece = extract_base_piece(piece_with_modifiers)
154
+
155
+ # Validate piece is base form only (single letter)
156
+ unless base_piece.match?(BASE_PIECE_PATTERN)
157
+ raise ::ArgumentError, "Pieces in hand must be base form only: '#{base_piece}'"
158
+ end
159
+
160
+ # Validate count format
161
+ if count_str && !count_str.match?(VALID_COUNT_PATTERN)
162
+ raise ::ArgumentError, "Invalid count format: '#{count_str}'. Count cannot be '0' or '1', use the piece without count instead"
163
+ end
164
+
165
+ # Validate that the piece matches the expected case
166
+ piece_case = base_piece.match?(/[A-Z]/) ? :uppercase : :lowercase
167
+ unless piece_case == case_type
168
+ case_name = case_type == :uppercase ? "uppercase" : "lowercase"
169
+ raise ::ArgumentError, "Piece '#{base_piece}' has wrong case for #{case_name} section"
170
+ end
171
+
194
172
  # Add to our result with piece type and count
195
- result << { piece: piece, count: count }
173
+ result << { piece: base_piece, count: count }
196
174
 
197
175
  # Move position forward
198
176
  position += match[0].length
@@ -201,6 +179,18 @@ module Feen
201
179
  result
202
180
  end
203
181
 
182
+ # Extracts the base piece from a piece string that may contain modifiers
183
+ #
184
+ # @param piece_str [String] Piece string potentially with modifiers
185
+ # @return [String] Base piece without modifiers
186
+ private_class_method def self.extract_base_piece(piece_str)
187
+ # Remove prefix modifiers (+ or -)
188
+ without_prefix = piece_str.gsub(/^[-+]/, "")
189
+
190
+ # Remove suffix modifiers (')
191
+ without_prefix.gsub(/'$/, "")
192
+ end
193
+
204
194
  # Expands the pieces based on their counts into an array.
205
195
  #
206
196
  # @param pieces_with_counts [Array<Hash>] Array of pieces with their counts
data/lib/feen/parser.rb CHANGED
@@ -21,12 +21,12 @@ module Feen
21
21
  # @param feen_string [String] Complete FEEN notation string
22
22
  # @return [Hash] Hash containing the parsed position data with the following keys:
23
23
  # - :piece_placement [Array] - Hierarchical array structure representing the board
24
- # - :pieces_in_hand [Array<String>] - Pieces available for dropping onto the board
24
+ # - :pieces_in_hand [Array<String>] - Pieces available for dropping onto the board (base form only)
25
25
  # - :games_turn [Array<String>] - A two-element array with [active_variant, inactive_variant]
26
26
  # @raise [ArgumentError] If the FEEN string is invalid
27
27
  #
28
28
  # @example Parsing a standard chess initial position
29
- # feen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR - CHESS/chess"
29
+ # feen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / CHESS/chess"
30
30
  # result = Feen::Parser.parse(feen)
31
31
  # # => {
32
32
  # # piece_placement: [
@@ -76,7 +76,7 @@ module Feen
76
76
  # @return [Hash, nil] Hash containing the parsed position data or nil if parsing fails
77
77
  #
78
78
  # @example Parsing a valid FEEN string
79
- # feen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR - CHESS/chess"
79
+ # feen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / CHESS/chess"
80
80
  # result = Feen::Parser.safe_parse(feen)
81
81
  # # => {piece_placement: [...], pieces_in_hand: [...], games_turn: [...]}
82
82
  #
data/lib/feen.rb CHANGED
@@ -16,11 +16,12 @@ module Feen
16
16
  #
17
17
  # @param piece_placement [Array] Board position data structure representing the spatial
18
18
  # distribution of pieces across the board
19
- # @param pieces_in_hand [Array<String>] Pieces available for dropping onto the board
19
+ # @param pieces_in_hand [Array<String>] Pieces available for dropping onto the board.
20
+ # MUST be in base form only (no modifiers allowed)
20
21
  # @param games_turn [Array<String>] A two-element array where the first element is the
21
22
  # active player's variant and the second is the inactive player's variant
22
23
  # @return [String] FEEN notation string
23
- # @raise [ArgumentError] If any parameter is invalid
24
+ # @raise [ArgumentError] If any parameter is invalid or pieces_in_hand contains modifiers
24
25
  # @example
25
26
  # piece_placement = [
26
27
  # ["r", "n", "b", "q", "k", "b", "n", "r"],
@@ -37,7 +38,7 @@ module Feen
37
38
  # pieces_in_hand: [],
38
39
  # games_turn: ["CHESS", "chess"]
39
40
  # )
40
- # # => "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR - CHESS/chess"
41
+ # # => "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / CHESS/chess"
41
42
  def self.dump(piece_placement:, pieces_in_hand:, games_turn:)
42
43
  Dumper.dump(piece_placement:, pieces_in_hand:, games_turn:)
43
44
  end
@@ -47,11 +48,11 @@ module Feen
47
48
  # @param feen_string [String] Complete FEEN notation string
48
49
  # @return [Hash] Hash containing the parsed position data with the following keys:
49
50
  # - :piece_placement [Array] - Hierarchical array structure representing the board
50
- # - :pieces_in_hand [Array<String>] - Pieces available for dropping onto the board
51
+ # - :pieces_in_hand [Array<String>] - Pieces available for dropping onto the board (base form only)
51
52
  # - :games_turn [Array<String>] - A two-element array with [active_variant, inactive_variant]
52
53
  # @raise [ArgumentError] If the FEEN string is invalid
53
54
  # @example
54
- # feen_string = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR - CHESS/chess"
55
+ # feen_string = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / CHESS/chess"
55
56
  # Feen.parse(feen_string)
56
57
  # # => {
57
58
  # # piece_placement: [
@@ -80,7 +81,7 @@ module Feen
80
81
  # @return [Hash, nil] Hash containing the parsed position data or nil if parsing fails
81
82
  # @example
82
83
  # # Valid FEEN string
83
- # Feen.safe_parse("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR - CHESS/chess")
84
+ # Feen.safe_parse("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / CHESS/chess")
84
85
  # # => {piece_placement: [...], pieces_in_hand: [...], games_turn: [...]}
85
86
  #
86
87
  # # Invalid FEEN string
@@ -100,17 +101,27 @@ module Feen
100
101
  # This approach guarantees that the string not only follows FEEN syntax
101
102
  # but is also in its most compact, canonical representation.
102
103
  #
104
+ # According to FEEN specification:
105
+ # - Pieces in hand must be in base form only (no modifiers like +, -, ')
106
+ # - Pieces in hand must be sorted canonically within each case section:
107
+ # 1. By quantity (descending)
108
+ # 2. By piece letter (alphabetically ascending)
109
+ # - Case separation is enforced (uppercase/lowercase)
110
+ #
103
111
  # @param feen_string [String] FEEN string to validate
104
112
  # @return [Boolean] True if the string is a valid and canonical FEEN string
105
113
  # @example
106
114
  # # Canonical form
107
- # Feen.valid?("lnsgk3l/5g3/p1ppB2pp/9/8B/2P6/P2PPPPPP/3K3R1/5rSNL 2g2s5PNln SHOGI/shogi") # => true
115
+ # Feen.valid?("lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL / SHOGI/shogi") # => true
108
116
  #
109
117
  # # Invalid syntax
110
118
  # Feen.valid?("invalid feen string") # => false
111
119
  #
112
- # # Valid syntax but non-canonical form (pieces in hand not in lexicographic order)
113
- # Feen.valid?("lnsgk3l/5g3/p1ppB2pp/9/8B/2P6/P2PPPPPP/3K3R1/5rSNL N5P2gn2sl SHOGI/shogi") # => false
120
+ # # Valid syntax but non-canonical form (pieces in hand with modifiers)
121
+ # Feen.valid?("8/8/8/8/8/8/8/8 +P/ FOO/bar") # => false
122
+ #
123
+ # # Valid syntax but non-canonical form (wrong ordering in pieces in hand)
124
+ # Feen.valid?("8/8/8/8/8/8/8/8 P3K/ FOO/bar") # => false
114
125
  def self.valid?(feen_string)
115
126
  # First check: Basic syntax validation
116
127
  begin
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: feen
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.0.0.beta7
4
+ version: 5.0.0.beta9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cyril Kato
@@ -25,15 +25,10 @@ files:
25
25
  - lib/feen/dumper/games_turn.rb
26
26
  - lib/feen/dumper/piece_placement.rb
27
27
  - lib/feen/dumper/pieces_in_hand.rb
28
- - lib/feen/dumper/pieces_in_hand/errors.rb
29
28
  - lib/feen/parser.rb
30
29
  - lib/feen/parser/games_turn.rb
31
- - lib/feen/parser/games_turn/errors.rb
32
- - lib/feen/parser/games_turn/valid_games_turn_pattern.rb
33
30
  - lib/feen/parser/piece_placement.rb
34
31
  - lib/feen/parser/pieces_in_hand.rb
35
- - lib/feen/parser/pieces_in_hand/errors.rb
36
- - lib/feen/parser/pieces_in_hand/pnn_patterns.rb
37
32
  homepage: https://github.com/sashite/feen.rb
38
33
  licenses:
39
34
  - MIT
@@ -44,6 +39,8 @@ metadata:
44
39
  source_code_uri: https://github.com/sashite/feen.rb
45
40
  specification_uri: https://sashite.dev/documents/feen/1.0.0/
46
41
  rubygems_mfa_required: 'true'
42
+ keywords: board, board-games, chess, deserialization, feen, fen, game, makruk, notation,
43
+ serialization, shogi, xiangqi"
47
44
  rdoc_options: []
48
45
  require_paths:
49
46
  - lib
@@ -1,12 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Feen
4
- module Dumper
5
- module PiecesInHand
6
- Errors = {
7
- invalid_type: "Piece at index: %<index>s must be a String, got type: %<type>s",
8
- invalid_format: "Piece at index: %<index>s has an invalid PNN format: '%<value>s'. Expected format: [prefix]letter[suffix] where prefix is + or -, suffix is ', and letter is a-z or A-Z"
9
- }.freeze
10
- end
11
- end
12
- end
@@ -1,14 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Feen
4
- module Parser
5
- module GamesTurn
6
- # Error messages for games turn parsing
7
- Errors = {
8
- invalid_type: "Games turn must be a string, got %s",
9
- empty_string: "Games turn string cannot be empty",
10
- invalid_format: "Invalid games turn format. Expected format: UPPERCASE/lowercase or lowercase/UPPERCASE"
11
- }.freeze
12
- end
13
- end
14
- end
@@ -1,24 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Feen
4
- module Parser
5
- module GamesTurn
6
- # Complete pattern matching the BNF specification with named groups
7
- # <games-turn> ::= <game-id-uppercase> "/" <game-id-lowercase>
8
- # | <game-id-lowercase> "/" <game-id-uppercase>
9
- ValidGamesTurnPattern = %r{
10
- \A # Start of string
11
- (?: # Non-capturing group for alternatives
12
- (?<uppercase_first>[A-Z]+) # Named group: uppercase identifier first
13
- / # Separator
14
- (?<lowercase_second>[a-z]+) # Named group: lowercase identifier second
15
- | # OR
16
- (?<lowercase_first>[a-z]+) # Named group: lowercase identifier first
17
- / # Separator
18
- (?<uppercase_second>[A-Z]+) # Named group: uppercase identifier second
19
- )
20
- \z # End of string
21
- }x
22
- end
23
- end
24
- end
@@ -1,20 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Feen
4
- module Parser
5
- module PiecesInHand
6
- # Error messages for validation
7
- Errors = {
8
- invalid_type: "Pieces in hand must be a string, got %s",
9
- empty_string: "Pieces in hand string cannot be empty",
10
- invalid_format: "Invalid pieces in hand format: %s",
11
- invalid_pnn_piece: "Invalid PNN piece format: '%s'. Expected format: [prefix]letter[suffix] where prefix is + or -, suffix is ', and letter is a-z or A-Z",
12
- invalid_count: "Invalid count format: '%s'. Count cannot be '0' or '1', use the piece without count instead",
13
- canonical_order_violation: "Pieces in hand must be in canonical order (by quantity descending, then alphabetically). Got: '%<actual>s', expected: '%<expected>s'",
14
- missing_separator: "Pieces in hand format must contain exactly one '/' separator. Got: %s",
15
- wrong_case_in_section: "Piece '%<piece>s' has wrong case for %<section>s section",
16
- invalid_section_format: "Invalid format in %<section>s section: %<content>s"
17
- }.freeze
18
- end
19
- end
20
- end
@@ -1,89 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Feen
4
- module Parser
5
- module PiecesInHand
6
- # Patterns for PNN (Piece Name Notation) validation and parsing
7
- module PnnPatterns
8
- # Basic PNN piece pattern following the specification:
9
- # <piece> ::= <letter> | <prefix> <letter> | <letter> <suffix> | <prefix> <letter> <suffix>
10
- # <prefix> ::= "+" | "-"
11
- # <suffix> ::= "'"
12
- # <letter> ::= [a-zA-Z]
13
- PNN_PIECE_PATTERN = /\A[-+]?[a-zA-Z]'?\z/
14
-
15
- # Pattern for valid count prefixes according to FEEN specification:
16
- # - Cannot be "0" or "1" (use piece without prefix instead)
17
- # - Can be 2-9 or any number with 2+ digits
18
- VALID_COUNT_PATTERN = /\A(?:[2-9]|\d{2,})\z/
19
-
20
- # Pattern to extract piece with optional count from pieces in hand string
21
- # Matches: optional count followed by complete PNN piece
22
- # Groups: (count_str, piece_str)
23
- # Note: We need to handle the full PNN piece including modifiers
24
- PIECE_WITH_COUNT_PATTERN = /(?:([2-9]|\d{2,}))?([-+]?[a-zA-Z]'?)/
25
-
26
- # Pattern for uppercase pieces only (used for uppercase section validation)
27
- UPPERCASE_PIECE_PATTERN = /[-+]?[A-Z]'?/
28
-
29
- # Pattern for lowercase pieces only (used for lowercase section validation)
30
- LOWERCASE_PIECE_PATTERN = /[-+]?[a-z]'?/
31
-
32
- # Pattern for uppercase section: sequence of uppercase pieces with optional counts
33
- # Format: [count]piece[count]piece... where pieces are uppercase
34
- UPPERCASE_SECTION_PATTERN = /\A
35
- (?:
36
- (?:[2-9]|\d{2,})? # Optional count (2-9 or 10+)
37
- [-+]? # Optional single prefix (+ or -)
38
- [A-Z] # Required uppercase letter
39
- '? # Optional single suffix (')
40
- )+ # One or more uppercase pieces
41
- \z/x
42
-
43
- # Pattern for lowercase section: sequence of lowercase pieces with optional counts
44
- # Format: [count]piece[count]piece... where pieces are lowercase
45
- LOWERCASE_SECTION_PATTERN = /\A
46
- (?:
47
- (?:[2-9]|\d{2,})? # Optional count (2-9 or 10+)
48
- [-+]? # Optional single prefix (+ or -)
49
- [a-z] # Required lowercase letter
50
- '? # Optional single suffix (')
51
- )+ # One or more lowercase pieces
52
- \z/x
53
-
54
- # Complete validation pattern for pieces in hand string with case separation
55
- # Based on the FEEN BNF specification with PNN support
56
- # Format: "UPPERCASE_PIECES/LOWERCASE_PIECES"
57
- # Either section can be empty, but the "/" separator is mandatory
58
- VALID_FORMAT_PATTERN = %r{\A
59
- (?:
60
- (?: # Uppercase section (optional)
61
- (?:[2-9]|\d{2,})? # Optional count (2-9 or 10+)
62
- [-+]? # Optional single prefix (+ or -)
63
- [A-Z] # Required uppercase letter
64
- '? # Optional single suffix (')
65
- )* # Zero or more uppercase pieces
66
- )
67
- / # Mandatory separator
68
- (?:
69
- (?: # Lowercase section (optional)
70
- (?:[2-9]|\d{2,})? # Optional count (2-9 or 10+)
71
- [-+]? # Optional single prefix (+ or -)
72
- [a-z] # Required lowercase letter
73
- '? # Optional single suffix (')
74
- )* # Zero or more lowercase pieces
75
- )
76
- \z}x
77
-
78
- # Pattern for extracting all pieces globally (used for comprehensive validation)
79
- GLOBAL_PIECE_EXTRACTION_PATTERN = /(?:([2-9]|\d{2,}))?([-+]?[a-zA-Z]'?)/
80
-
81
- # Pattern specifically for uppercase pieces with counts (for section parsing)
82
- UPPERCASE_PIECE_WITH_COUNT_PATTERN = /(?:([2-9]|\d{2,}))?([-+]?[A-Z]'?)/
83
-
84
- # Pattern specifically for lowercase pieces with counts (for section parsing)
85
- LOWERCASE_PIECE_WITH_COUNT_PATTERN = /(?:([2-9]|\d{2,}))?([-+]?[a-z]'?)/
86
- end
87
- end
88
- end
89
- end