feen 5.0.0.beta8 → 5.0.0.beta10

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,48 +1,47 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "pnn"
4
+
3
5
  module Feen
4
6
  module Dumper
5
7
  # Handles conversion of pieces in hand data to FEEN notation string
6
8
  module PiecesInHand
7
9
  # Error messages for validation
8
10
  ERRORS = {
9
- invalid_type: "Piece at index %d must be a String, got %s",
10
- invalid_format: "Piece at index %d must be base form only (single letter): '%s'",
11
- has_modifiers: "Piece at index %d cannot contain modifiers: '%s'. Pieces in hand must be base form only"
11
+ invalid_type: "Piece at index %d must be a String, got %s",
12
+ invalid_pnn: "Piece at index %d must be valid PNN notation: '%s'"
12
13
  }.freeze
13
14
 
14
15
  # Converts an array of piece identifiers to a FEEN-formatted pieces in hand string
15
16
  #
16
- # @param piece_chars [Array<String>] Array of piece identifiers in base form only (e.g., ["P", "p", "B", "B", "p"])
17
- # @return [String] FEEN-formatted pieces in hand string following the format:
17
+ # @param piece_chars [Array<String>] Array of piece identifiers following PNN notation.
18
+ # May include modifiers (per FEEN v1.0.0 specification): prefixes (+, -) and suffixes (')
19
+ # @return [String] FEEN-formatted pieces in hand string following the canonical sorting:
18
20
  # - Groups pieces by case: uppercase first, then lowercase, separated by "/"
19
- # - Within each group, sorts by quantity (descending), then alphabetically (ascending)
21
+ # - Within each group, sorts by quantity (descending), then base letter (ascending),
22
+ # then prefix (-, +, none), then suffix (none, ')
20
23
  # - Uses count notation for quantities > 1 (e.g., "3P" instead of "PPP")
21
- # @raise [ArgumentError] If any piece identifier is invalid or contains modifiers
24
+ # @raise [ArgumentError] If any piece identifier is invalid PNN notation
25
+ #
26
+ # @example Valid pieces in hand with modifiers
27
+ # PiecesInHand.dump("+B", "+B", "B", "B", "B", "B", "B", "K", "-P", "-P", "-P", "-P'", "+P'", "+P'", "+P'", "P", "P", "P", "P", "P", "P", "P", "P", "P", "R", "S", "S", "S'", "b", "p")
28
+ # # => "2+B5BK3-P-P'3+P'9PR2SS'/bp"
22
29
  #
23
- # @example Valid pieces in hand
30
+ # @example Valid pieces in hand without modifiers
24
31
  # PiecesInHand.dump("P", "P", "P", "B", "B", "p", "p", "p", "p", "p")
25
32
  # # => "3P2B/5p"
26
33
  #
27
- # @example Valid pieces in hand with mixed order
28
- # PiecesInHand.dump("p", "P", "B")
29
- # # => "BP/p"
30
- #
31
34
  # @example No pieces in hand
32
35
  # PiecesInHand.dump()
33
36
  # # => "/"
34
- #
35
- # @example Invalid - modifiers not allowed
36
- # PiecesInHand.dump("+P", "p")
37
- # # => ArgumentError: Piece at index 0 cannot contain modifiers: '+P'
38
37
  def self.dump(*piece_chars)
39
- # Validate each piece character according to FEEN specification (base form only)
38
+ # Validate each piece character according to PNN specification
40
39
  validated_chars = validate_piece_chars(piece_chars)
41
40
 
42
41
  # Group pieces by case
43
42
  uppercase_pieces, lowercase_pieces = group_pieces_by_case(validated_chars)
44
43
 
45
- # Format each group according to FEEN specification
44
+ # Format each group according to FEEN canonical sorting specification
46
45
  uppercase_formatted = format_pieces_group(uppercase_pieces)
47
46
  lowercase_formatted = format_pieces_group(lowercase_pieces)
48
47
 
@@ -50,30 +49,36 @@ module Feen
50
49
  "#{uppercase_formatted}/#{lowercase_formatted}"
51
50
  end
52
51
 
53
- # Groups pieces by case (uppercase vs lowercase)
52
+ # Groups pieces by case (uppercase vs lowercase base letter)
54
53
  #
55
54
  # @param pieces [Array<String>] Array of validated piece identifiers
56
55
  # @return [Array<Array<String>, Array<String>>] Two arrays: [uppercase_pieces, lowercase_pieces]
57
56
  private_class_method def self.group_pieces_by_case(pieces)
58
- uppercase_pieces = pieces.grep(/[A-Z]/)
59
- lowercase_pieces = pieces.grep(/[a-z]/)
57
+ uppercase_pieces = pieces.select { |piece| extract_base_letter(piece).match?(/[A-Z]/) }
58
+ lowercase_pieces = pieces.select { |piece| extract_base_letter(piece).match?(/[a-z]/) }
60
59
 
61
60
  [uppercase_pieces, lowercase_pieces]
62
61
  end
63
62
 
64
- # Formats a group of pieces according to FEEN specification
63
+ # Formats a group of pieces according to FEEN canonical sorting specification
64
+ #
65
+ # Sorting algorithm (FEEN v1.0.0):
66
+ # 1. By quantity (descending)
67
+ # 2. By base letter (ascending)
68
+ # 3. By prefix (-, +, none)
69
+ # 4. By suffix (none, ')
65
70
  #
66
71
  # @param pieces [Array<String>] Array of pieces from the same case group
67
- # @return [String] Formatted string for this group (e.g., "3P2B", "5pq")
72
+ # @return [String] Formatted string for this group (e.g., "2+B5BK3-P-P'3+P'9PR2SS'")
68
73
  private_class_method def self.format_pieces_group(pieces)
69
74
  return "" if pieces.empty?
70
75
 
71
- # Count occurrences of each piece type
72
- piece_counts = pieces.each_with_object(Hash.new(0)) do |piece, counts|
76
+ # Count occurrences of each unique piece (including modifiers)
77
+ piece_counts = pieces.each_with_object(::Hash.new(0)) do |piece, counts|
73
78
  counts[piece] += 1
74
79
  end
75
80
 
76
- # Sort by count (descending) then alphabetically (ascending)
81
+ # Sort according to FEEN canonical sorting algorithm
77
82
  sorted_pieces = piece_counts.sort do |a, b|
78
83
  piece_a, count_a = a
79
84
  piece_b, count_b = b
@@ -82,8 +87,22 @@ module Feen
82
87
  count_comparison = count_b <=> count_a
83
88
  next count_comparison unless count_comparison.zero?
84
89
 
85
- # Secondary sort: by piece name (ascending)
86
- piece_a <=> piece_b
90
+ # Secondary sort: by base letter (ascending)
91
+ base_a = extract_base_letter(piece_a)
92
+ base_b = extract_base_letter(piece_b)
93
+ base_comparison = base_a <=> base_b
94
+ next base_comparison unless base_comparison.zero?
95
+
96
+ # Tertiary sort: by prefix (-, +, none)
97
+ prefix_a = extract_prefix(piece_a)
98
+ prefix_b = extract_prefix(piece_b)
99
+ prefix_comparison = compare_prefixes(prefix_a, prefix_b)
100
+ next prefix_comparison unless prefix_comparison.zero?
101
+
102
+ # Quaternary sort: by suffix (none, ')
103
+ suffix_a = extract_suffix(piece_a)
104
+ suffix_b = extract_suffix(piece_b)
105
+ compare_suffixes(suffix_a, suffix_b)
87
106
  end
88
107
 
89
108
  # Format each piece with its count
@@ -96,34 +115,76 @@ module Feen
96
115
  end.join
97
116
  end
98
117
 
99
- # Validates all piece characters according to FEEN specification (base form only)
118
+ # Extracts the base letter from a PNN piece identifier
119
+ #
120
+ # @param piece [String] PNN piece identifier (e.g., "+P'", "-R", "K")
121
+ # @return [String] Base letter (e.g., "P", "R", "K")
122
+ private_class_method def self.extract_base_letter(piece)
123
+ piece.match(/[a-zA-Z]/)[0]
124
+ end
125
+
126
+ # Extracts the prefix from a PNN piece identifier
127
+ #
128
+ # @param piece [String] PNN piece identifier
129
+ # @return [String, nil] Prefix ("+" or "-") or nil if no prefix
130
+ private_class_method def self.extract_prefix(piece)
131
+ match = piece.match(/\A([+-])/)
132
+ match ? match[1] : nil
133
+ end
134
+
135
+ # Extracts the suffix from a PNN piece identifier
136
+ #
137
+ # @param piece [String] PNN piece identifier
138
+ # @return [String, nil] Suffix ("'") or nil if no suffix
139
+ private_class_method def self.extract_suffix(piece)
140
+ piece.end_with?("'") ? "'" : nil
141
+ end
142
+
143
+ # Compares prefixes according to FEEN sorting order: -, +, none
144
+ #
145
+ # @param prefix_a [String, nil] First prefix
146
+ # @param prefix_b [String, nil] Second prefix
147
+ # @return [Integer] Comparison result (-1, 0, 1)
148
+ private_class_method def self.compare_prefixes(prefix_a, prefix_b)
149
+ prefix_order = { "-" => 0, "+" => 1, nil => 2 }
150
+ prefix_order[prefix_a] <=> prefix_order[prefix_b]
151
+ end
152
+
153
+ # Compares suffixes according to FEEN sorting order: none, '
154
+ #
155
+ # @param suffix_a [String, nil] First suffix
156
+ # @param suffix_b [String, nil] Second suffix
157
+ # @return [Integer] Comparison result (-1, 0, 1)
158
+ private_class_method def self.compare_suffixes(suffix_a, suffix_b)
159
+ suffix_order = { nil => 0, "'" => 1 }
160
+ suffix_order[suffix_a] <=> suffix_order[suffix_b]
161
+ end
162
+
163
+ # Validates all piece characters according to PNN specification
100
164
  #
101
165
  # @param piece_chars [Array<Object>] Array of piece character candidates
102
166
  # @return [Array<String>] Array of validated piece characters
103
- # @raise [ArgumentError] If any piece character is invalid or contains modifiers
167
+ # @raise [ArgumentError] If any piece character is invalid PNN notation
104
168
  private_class_method def self.validate_piece_chars(piece_chars)
105
169
  piece_chars.each_with_index.map do |char, index|
106
170
  validate_piece_char(char, index)
107
171
  end
108
172
  end
109
173
 
110
- # Validates a single piece character according to FEEN specification
111
- # For pieces in hand, only base form is allowed: single letter (a-z or A-Z)
112
- # NO modifiers (+, -, ') are allowed in pieces in hand
174
+ # Validates a single piece character according to PNN specification
175
+ # Per FEEN v1.0.0: pieces in hand may include PNN modifiers (prefixes and suffixes)
113
176
  #
114
177
  # @param char [Object] Piece character candidate
115
178
  # @param index [Integer] Index of the character in the original array
116
179
  # @return [String] Validated piece character
117
- # @raise [ArgumentError] If the piece character is invalid or contains modifiers
180
+ # @raise [ArgumentError] If the piece character is invalid PNN notation
118
181
  private_class_method def self.validate_piece_char(char, index)
119
182
  # Validate type
120
- raise ArgumentError, format(ERRORS[:invalid_type], index, char.class) unless char.is_a?(String)
121
-
122
- # Check for forbidden modifiers first (clearer error message)
123
- raise ArgumentError, format(ERRORS[:has_modifiers], index, char) if char.match?(/[+\-']/)
183
+ raise ::ArgumentError, format(ERRORS[:invalid_type], index, char.class) unless char.is_a?(::String)
124
184
 
125
- # Validate format: must be exactly one letter (base form only)
126
- raise ArgumentError, format(ERRORS[:invalid_format], index, char) unless char.match?(/\A[a-zA-Z]\z/)
185
+ # Validate PNN format using the PNN gem
186
+ # @see https://rubygems.org/gems/pnn
187
+ raise ::ArgumentError, format(ERRORS[:invalid_pnn], index, char) unless ::Pnn.valid?(char)
127
188
 
128
189
  char
129
190
  end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sashite-snn"
4
+
5
+ module Feen
6
+ module Dumper
7
+ # Handles conversion of style turn data to FEEN notation string
8
+ module StyleTurn
9
+ # Error messages for validation
10
+ ERRORS = {
11
+ invalid_type: "%s must be a String, got %s",
12
+ empty_string: "%s cannot be empty",
13
+ invalid_snn: "%s must be valid SNN notation: %s",
14
+ same_casing: "One style must be uppercase and the other lowercase"
15
+ }.freeze
16
+
17
+ # Converts the active and inactive style identifiers to a FEEN-formatted style turn string
18
+ #
19
+ # @param active_style [String] Identifier for the player to move and their style
20
+ # @param inactive_style [String] Identifier for the opponent and their style
21
+ # @return [String] FEEN-formatted style turn string
22
+ # @raise [ArgumentError] If the style identifiers are invalid
23
+ #
24
+ # @example Valid style turn
25
+ # StyleTurn.dump("CHESS", "chess")
26
+ # # => "CHESS/chess"
27
+ #
28
+ # @example Valid style turn with variants
29
+ # StyleTurn.dump("CHESS960", "makruk")
30
+ # # => "CHESS960/makruk"
31
+ #
32
+ # @example Invalid - same casing
33
+ # StyleTurn.dump("CHESS", "MAKRUK")
34
+ # # => ArgumentError: One style must be uppercase and the other lowercase
35
+ def self.dump(active_style, inactive_style)
36
+ validate_styles(active_style, inactive_style)
37
+ "#{active_style}/#{inactive_style}"
38
+ end
39
+
40
+ # Validates the style identifiers according to SNN specification
41
+ #
42
+ # @param active [String] The active player's style identifier
43
+ # @param inactive [String] The inactive player's style identifier
44
+ # @raise [ArgumentError] If the style identifiers are invalid
45
+ # @return [void]
46
+ private_class_method def self.validate_styles(active, inactive)
47
+ # Validate basic type and SNN format
48
+ [["Active style", active], ["Inactive style", inactive]].each do |name, style|
49
+ # Type validation
50
+ raise ArgumentError, format(ERRORS[:invalid_type], name, style.class) unless style.is_a?(::String)
51
+
52
+ # Empty validation
53
+ raise ArgumentError, format(ERRORS[:empty_string], name) if style.empty?
54
+
55
+ # SNN format validation using the sashite-snn gem
56
+ # @see https://rubygems.org/gems/sashite-snn
57
+ raise ArgumentError, format(ERRORS[:invalid_snn], name, style) unless ::Sashite::Snn.valid?(style)
58
+ end
59
+
60
+ # Casing difference validation
61
+ active_is_uppercase = active == active.upcase
62
+ inactive_is_uppercase = inactive == inactive.upcase
63
+
64
+ # Both styles must have different casing
65
+ return unless active_is_uppercase == inactive_is_uppercase
66
+
67
+ raise ArgumentError, ERRORS[:same_casing]
68
+ end
69
+ end
70
+ end
71
+ end
data/lib/feen/dumper.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative File.join("dumper", "games_turn")
3
+ require_relative File.join("dumper", "style_turn")
4
4
  require_relative File.join("dumper", "piece_placement")
5
5
  require_relative File.join("dumper", "pieces_in_hand")
6
6
 
@@ -14,7 +14,7 @@ module Feen
14
14
  # Error messages for validation
15
15
  ERRORS = {
16
16
  invalid_piece_placement_type: "Piece placement must be an Array, got %s",
17
- invalid_games_turn_type: "Games turn must be an Array with exactly two elements, got %s",
17
+ invalid_style_turn_type: "Style turn must be an Array with exactly two elements, got %s",
18
18
  invalid_pieces_in_hand_type: "Pieces in hand must be an Array, got %s"
19
19
  }.freeze
20
20
 
@@ -33,52 +33,52 @@ module Feen
33
33
  # ["R", "N", "B", "Q", "K", "B", "N", "R"]
34
34
  # ],
35
35
  # pieces_in_hand: [],
36
- # games_turn: ["CHESS", "chess"]
36
+ # style_turn: ["CHESS", "chess"]
37
37
  # )
38
38
  # # => "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / CHESS/chess"
39
39
  #
40
40
  # @param piece_placement [Array] Board position data structure representing the spatial
41
41
  # distribution of pieces across the board, where each cell
42
42
  # is represented by a String (or empty string for empty cells)
43
- # @param pieces_in_hand [Array<String>] Pieces available for dropping onto the board,
44
- # each represented as a single character string (base form only)
45
- # @param games_turn [Array<String>] A two-element array where the first element is the
46
- # active player's variant and the second is the inactive player's variant
43
+ # @param pieces_in_hand [Array<String>] Pieces available for dropping onto the board.
44
+ # May include PNN modifiers (per FEEN v1.0.0 specification)
45
+ # @param style_turn [Array<String>] A two-element array where the first element is the
46
+ # active player's style and the second is the inactive player's style
47
47
  # @return [String] Complete FEEN string representation compliant with the specification
48
48
  # @raise [ArgumentError] If any input parameter is invalid
49
49
  # @see https://sashite.dev/documents/feen/1.0.0/ FEEN Specification v1.0.0
50
- def self.dump(piece_placement:, pieces_in_hand:, games_turn:)
50
+ def self.dump(piece_placement:, pieces_in_hand:, style_turn:)
51
51
  # Validate input types
52
- validate_inputs(piece_placement, games_turn, pieces_in_hand)
52
+ validate_inputs(piece_placement, style_turn, pieces_in_hand)
53
53
 
54
54
  # Process each component with appropriate submodule and combine into final FEEN string
55
55
  [
56
56
  PiecePlacement.dump(piece_placement),
57
57
  PiecesInHand.dump(*pieces_in_hand),
58
- GamesTurn.dump(*games_turn)
58
+ StyleTurn.dump(*style_turn)
59
59
  ].join(FIELD_SEPARATOR)
60
60
  end
61
61
 
62
62
  # Validates the input parameters for type and structure
63
63
  #
64
64
  # @param piece_placement [Object] Piece placement parameter to validate
65
- # @param games_turn [Object] Games turn parameter to validate
65
+ # @param style_turn [Object] Style turn parameter to validate
66
66
  # @param pieces_in_hand [Object] Pieces in hand parameter to validate
67
67
  # @raise [ArgumentError] If any parameter is invalid
68
68
  # @return [void]
69
- private_class_method def self.validate_inputs(piece_placement, games_turn, pieces_in_hand)
69
+ private_class_method def self.validate_inputs(piece_placement, style_turn, pieces_in_hand)
70
70
  # Validate piece_placement is an Array
71
- unless piece_placement.is_a?(Array)
71
+ unless piece_placement.is_a?(::Array)
72
72
  raise ArgumentError, format(ERRORS[:invalid_piece_placement_type], piece_placement.class)
73
73
  end
74
74
 
75
- # Validate games_turn is an Array with exactly 2 elements
76
- unless games_turn.is_a?(Array) && games_turn.size == 2
77
- raise ArgumentError, format(ERRORS[:invalid_games_turn_type], games_turn.inspect)
75
+ # Validate style_turn is an Array with exactly 2 elements
76
+ unless style_turn.is_a?(::Array) && style_turn.size == 2
77
+ raise ArgumentError, format(ERRORS[:invalid_style_turn_type], style_turn.inspect)
78
78
  end
79
79
 
80
80
  # Validate pieces_in_hand is an Array
81
- return if pieces_in_hand.is_a?(Array)
81
+ return if pieces_in_hand.is_a?(::Array)
82
82
 
83
83
  raise ArgumentError, format(ERRORS[:invalid_pieces_in_hand_type], pieces_in_hand.class)
84
84
  end
@@ -3,6 +3,14 @@
3
3
  module Feen
4
4
  module Parser
5
5
  # Handles parsing of the piece placement section of a FEEN string
6
+ #
7
+ # This module is responsible for converting the first field of a FEEN string
8
+ # (piece placement) into a hierarchical array structure representing the board.
9
+ # It supports arbitrary dimensions and handles both pieces (with optional PNN modifiers)
10
+ # and empty squares (represented by numbers).
11
+ #
12
+ # @see https://sashite.dev/documents/feen/1.0.0/ FEEN Specification
13
+ # @see https://sashite.dev/documents/pnn/1.0.0/ PNN Specification
6
14
  module PiecePlacement
7
15
  # Simplified error messages
8
16
  ERRORS = {
@@ -16,11 +24,78 @@ module Feen
16
24
 
17
25
  # Parses the piece placement section of a FEEN string
18
26
  #
27
+ # Converts a FEEN piece placement string into a hierarchical array structure
28
+ # representing the board where empty squares are represented by empty strings
29
+ # and pieces are represented by strings containing their PNN identifier and
30
+ # optional modifiers.
31
+ #
19
32
  # @param piece_placement_str [String] FEEN piece placement string
20
33
  # @return [Array] Hierarchical array structure representing the board where:
21
34
  # - Empty squares are represented by empty strings ("")
22
35
  # - Pieces are represented by strings containing their identifier and optional modifiers
23
36
  # @raise [ArgumentError] If the input string is invalid
37
+ #
38
+ # @example Parse a simple 2D chess position (initial position)
39
+ # PiecePlacement.parse("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR")
40
+ # # => [
41
+ # # ["r", "n", "b", "q", "k", "b", "n", "r"],
42
+ # # ["p", "p", "p", "p", "p", "p", "p", "p"],
43
+ # # ["", "", "", "", "", "", "", ""],
44
+ # # ["", "", "", "", "", "", "", ""],
45
+ # # ["", "", "", "", "", "", "", ""],
46
+ # # ["", "", "", "", "", "", "", ""],
47
+ # # ["P", "P", "P", "P", "P", "P", "P", "P"],
48
+ # # ["R", "N", "B", "Q", "K", "B", "N", "R"]
49
+ # # ]
50
+ #
51
+ # @example Parse a single rank with mixed pieces and empty squares
52
+ # PiecePlacement.parse("r2k1r")
53
+ # # => ["r", "", "", "k", "", "r"]
54
+ #
55
+ # @example Parse pieces with PNN modifiers (promoted pieces in Shogi)
56
+ # PiecePlacement.parse("+P+Bk")
57
+ # # => ["+P", "+B", "k"]
58
+ #
59
+ # @example Parse a 3D board structure (2 planes of 2x2)
60
+ # PiecePlacement.parse("rn/pp//RN/PP")
61
+ # # => [
62
+ # # [["r", "n"], ["p", "p"]],
63
+ # # [["R", "N"], ["P", "P"]]
64
+ # # ]
65
+ #
66
+ # @example Parse complex Shogi position with promoted pieces
67
+ # PiecePlacement.parse("9/9/9/9/4+P4/9/5+B3/9/9")
68
+ # # => [
69
+ # # ["", "", "", "", "", "", "", "", ""],
70
+ # # ["", "", "", "", "", "", "", "", ""],
71
+ # # ["", "", "", "", "", "", "", "", ""],
72
+ # # ["", "", "", "", "", "", "", "", ""],
73
+ # # ["", "", "", "", "+P", "", "", "", ""],
74
+ # # ["", "", "", "", "", "", "", "", ""],
75
+ # # ["", "", "", "", "", "+B", "", "", ""],
76
+ # # ["", "", "", "", "", "", "", "", ""],
77
+ # # ["", "", "", "", "", "", "", "", ""]
78
+ # # ]
79
+ #
80
+ # @example Parse irregular board shapes (different rank sizes)
81
+ # PiecePlacement.parse("rnbqkbnr/ppppppp/8")
82
+ # # => [
83
+ # # ["r", "n", "b", "q", "k", "b", "n", "r"], # 8 cells
84
+ # # ["p", "p", "p", "p", "p", "p", "p"], # 7 cells
85
+ # # ["", "", "", "", "", "", "", ""] # 8 cells
86
+ # # ]
87
+ #
88
+ # @example Parse large numbers of empty squares
89
+ # PiecePlacement.parse("15")
90
+ # # => ["", "", "", "", "", "", "", "", "", "", "", "", "", "", ""]
91
+ #
92
+ # @example Parse pieces with all PNN modifier types
93
+ # PiecePlacement.parse("+P'-R'k")
94
+ # # => ["+P'", "-R'", "k"]
95
+ # # Where:
96
+ # # - "+P'" = enhanced state with intermediate suffix
97
+ # # - "-R'" = diminished state with intermediate suffix
98
+ # # - "k" = base piece without modifiers
24
99
  def self.parse(piece_placement_str)
25
100
  validate_input(piece_placement_str)
26
101
  parse_structure(piece_placement_str)
@@ -28,9 +103,24 @@ module Feen
28
103
 
29
104
  # Validates the input string for basic requirements
30
105
  #
106
+ # Ensures the input is a non-empty string containing only valid FEEN characters.
107
+ # Valid characters include: letters (a-z, A-Z), digits (0-9), and modifiers (+, -, ').
108
+ #
31
109
  # @param str [String] Input string to validate
32
110
  # @raise [ArgumentError] If the string is invalid
33
111
  # @return [void]
112
+ #
113
+ # @example Valid input
114
+ # validate_input("rnbqkbnr/pppppppp/8/8")
115
+ # # => (no error)
116
+ #
117
+ # @example Invalid input (empty string)
118
+ # validate_input("")
119
+ # # => ArgumentError: Piece placement string cannot be empty
120
+ #
121
+ # @example Invalid input (wrong type)
122
+ # validate_input(123)
123
+ # # => ArgumentError: Piece placement must be a string, got Integer
34
124
  def self.validate_input(str)
35
125
  raise ArgumentError, format(ERRORS[:invalid_type], str.class) unless str.is_a?(String)
36
126
  raise ArgumentError, ERRORS[:empty_string] if str.empty?
@@ -43,9 +133,25 @@ module Feen
43
133
 
44
134
  # Parses the structure recursively
45
135
  #
136
+ # Determines the dimensionality of the board by analyzing separator patterns
137
+ # and recursively parses nested structures. Uses the longest separator sequence
138
+ # to determine the highest dimension level.
139
+ #
46
140
  # @param str [String] FEEN piece placement string
47
- # @return [Array] Parsed structure
48
- def self.parse_structure(str)
141
+ # @return [Array] Parsed structure (1D array for ranks, nested arrays for higher dimensions)
142
+ #
143
+ # @example 1D structure (single rank)
144
+ # parse_structure("rnbq")
145
+ # # => ["r", "n", "b", "q"]
146
+ #
147
+ # @example 2D structure (multiple ranks)
148
+ # parse_structure("rn/pq")
149
+ # # => [["r", "n"], ["p", "q"]]
150
+ #
151
+ # @example 3D structure (multiple planes)
152
+ # parse_structure("r/p//R/P")
153
+ # # => [[["r"], ["p"]], [["R"], ["P"]]]
154
+ private_class_method def self.parse_structure(str)
49
155
  # Handle trailing separators
50
156
  raise ArgumentError, ERRORS[:invalid_format] if str.end_with?(DIMENSION_SEPARATOR)
51
157
 
@@ -64,10 +170,22 @@ module Feen
64
170
 
65
171
  # Splits string by separator while preserving shorter separators
66
172
  #
173
+ # Intelligently splits a string by a specific separator pattern while
174
+ # ensuring that shorter separator patterns within the string are preserved
175
+ # for recursive parsing of nested dimensions.
176
+ #
67
177
  # @param str [String] String to split
68
- # @param separator [String] Separator to split by
69
- # @return [Array<String>] Split parts
70
- def self.smart_split(str, separator)
178
+ # @param separator [String] Separator to split by (e.g., "/", "//", "///")
179
+ # @return [Array<String>] Split parts, with empty parts removed
180
+ #
181
+ # @example Split by single separator
182
+ # smart_split("a/b/c", "/")
183
+ # # => ["a", "b", "c"]
184
+ #
185
+ # @example Split by double separator, preserving single separators
186
+ # smart_split("a/b//c/d", "//")
187
+ # # => ["a/b", "c/d"]
188
+ private_class_method def self.smart_split(str, separator)
71
189
  return [str] unless str.include?(separator)
72
190
 
73
191
  parts = str.split(separator)
@@ -76,9 +194,29 @@ module Feen
76
194
 
77
195
  # Parses a rank (sequence of cells)
78
196
  #
197
+ # Processes a 1D sequence of cells, expanding numeric values to empty squares
198
+ # and extracting pieces with their PNN modifiers. Numbers represent consecutive
199
+ # empty squares, while letters (with optional modifiers) represent pieces.
200
+ #
79
201
  # @param str [String] FEEN rank string
80
- # @return [Array] Array of cells
81
- def self.parse_rank(str)
202
+ # @return [Array<String>] Array of cells (empty strings for empty squares, piece strings for pieces)
203
+ #
204
+ # @example Simple pieces
205
+ # parse_rank("rnbq")
206
+ # # => ["r", "n", "b", "q"]
207
+ #
208
+ # @example Mixed pieces and empty squares
209
+ # parse_rank("r2k1r")
210
+ # # => ["r", "", "", "k", "", "r"]
211
+ #
212
+ # @example All empty squares
213
+ # parse_rank("8")
214
+ # # => ["", "", "", "", "", "", "", ""]
215
+ #
216
+ # @example Pieces with modifiers
217
+ # parse_rank("+P-R'")
218
+ # # => ["+P", "-R'"]
219
+ private_class_method def self.parse_rank(str)
82
220
  return [] if str.empty?
83
221
 
84
222
  cells = []
@@ -114,10 +252,32 @@ module Feen
114
252
 
115
253
  # Extracts a piece starting at given position
116
254
  #
255
+ # Parses a piece identifier with optional PNN modifiers starting at the specified
256
+ # position in the string. Handles prefix modifiers (+, -), the required letter,
257
+ # and suffix modifiers (').
258
+ #
117
259
  # @param str [String] String to parse
118
- # @param start_index [Integer] Starting position
260
+ # @param start_index [Integer] Starting position in the string
119
261
  # @return [Hash] Hash with :piece and :next_index keys
120
- def self.extract_piece(str, start_index)
262
+ # - :piece [String] The complete piece identifier with modifiers
263
+ # - :next_index [Integer] Position after the piece in the string
264
+ #
265
+ # @example Extract simple piece
266
+ # extract_piece("Kqr", 0)
267
+ # # => { piece: "K", next_index: 1 }
268
+ #
269
+ # @example Extract piece with prefix modifier
270
+ # extract_piece("+Pqr", 0)
271
+ # # => { piece: "+P", next_index: 2 }
272
+ #
273
+ # @example Extract piece with suffix modifier
274
+ # extract_piece("K'qr", 0)
275
+ # # => { piece: "K'", next_index: 2 }
276
+ #
277
+ # @example Extract piece with both prefix and suffix modifiers
278
+ # extract_piece("+P'qr", 0)
279
+ # # => { piece: "+P'", next_index: 3 }
280
+ private_class_method def self.extract_piece(str, start_index)
121
281
  piece = ""
122
282
  i = start_index
123
283