feen 5.0.0.beta9 → 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.
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sashite-snn"
4
+
5
+ module Feen
6
+ module Parser
7
+ # Handles parsing of the style turn section of a FEEN string
8
+ module StyleTurn
9
+ # Error messages for style turn parsing
10
+ ERRORS = {
11
+ invalid_type: "Style turn must be a string, got %s",
12
+ empty_string: "Style turn string cannot be empty",
13
+ invalid_format: "Invalid style turn format. Expected format: UPPERCASE/lowercase or lowercase/UPPERCASE",
14
+ invalid_snn: "Invalid SNN notation in style turn: %s"
15
+ }.freeze
16
+
17
+ # Pattern matching the FEEN specification for style turn
18
+ # <style-turn> ::= <style-id-uppercase> "/" <style-id-lowercase>
19
+ # | <style-id-lowercase> "/" <style-id-uppercase>
20
+ VALID_STYLE_TURN_PATTERN = %r{
21
+ \A # Start of string
22
+ (?: # Non-capturing group for alternatives
23
+ (?<uppercase_first>[A-Z][A-Z0-9]*) # Named group: uppercase style identifier first
24
+ / # Separator
25
+ (?<lowercase_second>[a-z][a-z0-9]*) # Named group: lowercase style identifier second
26
+ | # OR
27
+ (?<lowercase_first>[a-z][a-z0-9]*) # Named group: lowercase style identifier first
28
+ / # Separator
29
+ (?<uppercase_second>[A-Z][A-Z0-9]*) # Named group: uppercase style identifier second
30
+ )
31
+ \z # End of string
32
+ }x
33
+
34
+ # Parses the style turn section of a FEEN string
35
+ #
36
+ # @param style_turn_str [String] FEEN style turn string
37
+ # @return [Array<String>] Array containing [active_style, inactive_style]
38
+ # @raise [ArgumentError] If the input string is invalid
39
+ #
40
+ # @example Valid style turn string with uppercase first
41
+ # StyleTurn.parse("CHESS/shogi")
42
+ # # => ["CHESS", "shogi"]
43
+ #
44
+ # @example Valid style turn string with lowercase first
45
+ # StyleTurn.parse("chess/SHOGI")
46
+ # # => ["chess", "SHOGI"]
47
+ #
48
+ # @example Valid style turn with numeric identifiers
49
+ # StyleTurn.parse("CHESS960/makruk")
50
+ # # => ["CHESS960", "makruk"]
51
+ def self.parse(style_turn_str)
52
+ validate_input_type(style_turn_str)
53
+
54
+ match = VALID_STYLE_TURN_PATTERN.match(style_turn_str)
55
+ raise ::ArgumentError, ERRORS[:invalid_format] unless match
56
+
57
+ style_identifiers = extract_style_identifiers(match)
58
+ validate_snn_compliance(style_identifiers)
59
+
60
+ style_identifiers
61
+ end
62
+
63
+ # Validates that the input is a non-empty string
64
+ #
65
+ # @param str [String] Input string to validate
66
+ # @raise [ArgumentError] If input is not a string or is empty
67
+ # @return [void]
68
+ private_class_method def self.validate_input_type(str)
69
+ raise ::ArgumentError, format(ERRORS[:invalid_type], str.class) unless str.is_a?(::String)
70
+ raise ::ArgumentError, ERRORS[:empty_string] if str.empty?
71
+ end
72
+
73
+ # Extracts style identifiers from regexp match captures
74
+ #
75
+ # @param match [MatchData] Regexp match data with named captures
76
+ # @return [Array<String>] Array containing [active_style, inactive_style]
77
+ private_class_method def self.extract_style_identifiers(match)
78
+ captures = match.named_captures
79
+
80
+ if captures["uppercase_first"]
81
+ [captures["uppercase_first"], captures["lowercase_second"]]
82
+ else
83
+ [captures["lowercase_first"], captures["uppercase_second"]]
84
+ end
85
+ end
86
+
87
+ # Validates that both style identifiers comply with SNN specification
88
+ #
89
+ # @param identifiers [Array<String>] Array of style identifiers to validate
90
+ # @raise [ArgumentError] If any identifier is invalid SNN notation
91
+ # @return [void]
92
+ private_class_method def self.validate_snn_compliance(identifiers)
93
+ identifiers.each do |identifier|
94
+ # Validate using the sashite-snn gem
95
+ # @see https://rubygems.org/gems/sashite-snn
96
+ raise ::ArgumentError, format(ERRORS[:invalid_snn], identifier) unless ::Sashite::Snn.valid?(identifier)
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
data/lib/feen/parser.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative File.join("parser", "games_turn")
3
+ require_relative File.join("parser", "style_turn")
4
4
  require_relative File.join("parser", "piece_placement")
5
5
  require_relative File.join("parser", "pieces_in_hand")
6
6
 
@@ -21,8 +21,8 @@ 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 (base form only)
25
- # - :games_turn [Array<String>] - A two-element array with [active_variant, inactive_variant]
24
+ # - :pieces_in_hand [Array<String>] - Pieces available for dropping onto the board (may include modifiers)
25
+ # - :style_turn [Array<String>] - A two-element array with [active_style, inactive_style]
26
26
  # @raise [ArgumentError] If the FEEN string is invalid
27
27
  #
28
28
  # @example Parsing a standard chess initial position
@@ -40,7 +40,7 @@ module Feen
40
40
  # # ["R", "N", "B", "Q", "K", "B", "N", "R"]
41
41
  # # ],
42
42
  # # pieces_in_hand: [],
43
- # # games_turn: ["CHESS", "chess"]
43
+ # # style_turn: ["CHESS", "chess"]
44
44
  # # }
45
45
  def self.parse(feen_string)
46
46
  feen_string = String(feen_string)
@@ -52,18 +52,18 @@ module Feen
52
52
  raise ::ArgumentError, INVALID_FORMAT_ERROR unless match
53
53
 
54
54
  # Capture the three distinct parts
55
- piece_placement_string, pieces_in_hand_string, games_turn_string = match.captures
55
+ piece_placement_string, pieces_in_hand_string, style_turn_string = match.captures
56
56
 
57
57
  # Parse each field using the appropriate submodule
58
58
  piece_placement = PiecePlacement.parse(piece_placement_string)
59
59
  pieces_in_hand = PiecesInHand.parse(pieces_in_hand_string)
60
- games_turn = GamesTurn.parse(games_turn_string)
60
+ style_turn = StyleTurn.parse(style_turn_string)
61
61
 
62
62
  # Create a structured representation of the position
63
63
  {
64
64
  piece_placement:,
65
65
  pieces_in_hand:,
66
- games_turn:
66
+ style_turn:
67
67
  }
68
68
  end
69
69
 
@@ -78,7 +78,7 @@ module Feen
78
78
  # @example Parsing a valid FEEN string
79
79
  # feen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / CHESS/chess"
80
80
  # result = Feen::Parser.safe_parse(feen)
81
- # # => {piece_placement: [...], pieces_in_hand: [...], games_turn: [...]}
81
+ # # => {piece_placement: [...], pieces_in_hand: [...], style_turn: [...]}
82
82
  #
83
83
  # @example Parsing an invalid FEEN string
84
84
  # feen = "invalid feen string"
data/lib/feen.rb CHANGED
@@ -17,11 +17,11 @@ module Feen
17
17
  # @param piece_placement [Array] Board position data structure representing the spatial
18
18
  # distribution of pieces across the board
19
19
  # @param pieces_in_hand [Array<String>] Pieces available for dropping onto the board.
20
- # MUST be in base form only (no modifiers allowed)
21
- # @param games_turn [Array<String>] A two-element array where the first element is the
22
- # active player's variant and the second is the inactive player's variant
20
+ # May include PNN modifiers (per FEEN v1.0.0 specification)
21
+ # @param style_turn [Array<String>] A two-element array where the first element is the
22
+ # active player's style and the second is the inactive player's style
23
23
  # @return [String] FEEN notation string
24
- # @raise [ArgumentError] If any parameter is invalid or pieces_in_hand contains modifiers
24
+ # @raise [ArgumentError] If any parameter is invalid
25
25
  # @example
26
26
  # piece_placement = [
27
27
  # ["r", "n", "b", "q", "k", "b", "n", "r"],
@@ -36,11 +36,11 @@ module Feen
36
36
  # Feen.dump(
37
37
  # piece_placement: piece_placement,
38
38
  # pieces_in_hand: [],
39
- # games_turn: ["CHESS", "chess"]
39
+ # style_turn: ["CHESS", "chess"]
40
40
  # )
41
41
  # # => "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / CHESS/chess"
42
- def self.dump(piece_placement:, pieces_in_hand:, games_turn:)
43
- Dumper.dump(piece_placement:, pieces_in_hand:, games_turn:)
42
+ def self.dump(piece_placement:, pieces_in_hand:, style_turn:)
43
+ Dumper.dump(piece_placement:, pieces_in_hand:, style_turn:)
44
44
  end
45
45
 
46
46
  # Parses a FEEN string into position components.
@@ -48,8 +48,8 @@ module Feen
48
48
  # @param feen_string [String] Complete FEEN notation string
49
49
  # @return [Hash] Hash containing the parsed position data with the following keys:
50
50
  # - :piece_placement [Array] - Hierarchical array structure representing the board
51
- # - :pieces_in_hand [Array<String>] - Pieces available for dropping onto the board (base form only)
52
- # - :games_turn [Array<String>] - A two-element array with [active_variant, inactive_variant]
51
+ # - :pieces_in_hand [Array<String>] - Pieces available for dropping onto the board (may include modifiers)
52
+ # - :style_turn [Array<String>] - A two-element array with [active_style, inactive_style]
53
53
  # @raise [ArgumentError] If the FEEN string is invalid
54
54
  # @example
55
55
  # feen_string = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / CHESS/chess"
@@ -66,7 +66,7 @@ module Feen
66
66
  # # ["R", "N", "B", "Q", "K", "B", "N", "R"]
67
67
  # # ],
68
68
  # # pieces_in_hand: [],
69
- # # games_turn: ["CHESS", "chess"]
69
+ # # style_turn: ["CHESS", "chess"]
70
70
  # # }
71
71
  def self.parse(feen_string)
72
72
  Parser.parse(feen_string)
@@ -82,7 +82,7 @@ module Feen
82
82
  # @example
83
83
  # # Valid FEEN string
84
84
  # Feen.safe_parse("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / CHESS/chess")
85
- # # => {piece_placement: [...], pieces_in_hand: [...], games_turn: [...]}
85
+ # # => {piece_placement: [...], pieces_in_hand: [...], style_turn: [...]}
86
86
  #
87
87
  # # Invalid FEEN string
88
88
  # Feen.safe_parse("invalid feen string")
@@ -101,12 +101,15 @@ module Feen
101
101
  # This approach guarantees that the string not only follows FEEN syntax
102
102
  # but is also in its most compact, canonical representation.
103
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)
104
+ # According to FEEN v1.0.0 specification:
105
+ # - Pieces in hand may include PNN modifiers (prefixes and suffixes)
106
+ # - Pieces in hand must be sorted canonically according to the sorting algorithm:
107
+ # 1. By player (uppercase/lowercase separated by '/')
108
+ # 2. By quantity (descending)
109
+ # 3. By base letter (ascending)
110
+ # 4. By prefix (specific order: '-', '+', then no prefix)
111
+ # 5. By suffix (specific order: no suffix, then "'")
112
+ # - Style identifiers must follow SNN specification with semantic casing
110
113
  #
111
114
  # @param feen_string [String] FEEN string to validate
112
115
  # @return [Boolean] True if the string is a valid and canonical FEEN string
@@ -117,24 +120,21 @@ module Feen
117
120
  # # Invalid syntax
118
121
  # Feen.valid?("invalid feen string") # => false
119
122
  #
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
123
  # # Valid syntax but non-canonical form (wrong ordering in pieces in hand)
124
124
  # Feen.valid?("8/8/8/8/8/8/8/8 P3K/ FOO/bar") # => false
125
+ #
126
+ # # Canonical form with modifiers in pieces in hand
127
+ # Feen.valid?("8/8/8/8/8/8/8/8 2+B5BK3-P-P'3+P'9PR2SS'/ FOO/bar") # => true
125
128
  def self.valid?(feen_string)
126
129
  # First check: Basic syntax validation
127
- begin
128
- parsed_data = parse(feen_string)
129
- rescue ::ArgumentError
130
- return false
131
- end
130
+ parsed_data = safe_parse(feen_string)
131
+ return false if parsed_data.nil?
132
132
 
133
133
  # Second check: Canonicity validation through round-trip conversion
134
134
  # Generate a fresh FEEN string from the parsed data
135
135
  canonical_feen = dump(**parsed_data)
136
136
 
137
- # Compare the original string with the canonical form
138
- feen_string == canonical_feen
137
+ # Compare the canonical form with the original string
138
+ canonical_feen == feen_string
139
139
  end
140
140
  end
metadata CHANGED
@@ -1,17 +1,45 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: feen
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.0.0.beta9
4
+ version: 5.0.0.beta10
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cyril Kato
8
8
  bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
- dependencies: []
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: pnn
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: 2.0.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: 2.0.0
26
+ - !ruby/object:Gem::Dependency
27
+ name: sashite-snn
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: 1.0.0
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: 1.0.0
12
40
  description: A Ruby interface for data serialization and deserialization in FEEN format.
13
41
  FEEN is a compact, canonical, and rule-agnostic textual format for representing
14
- static board positions in two-player piece-placement games like Chess, Shogi, Xiangqi,
42
+ static board positions in two-player piece-placement games like Chess, Shōgi, Xiangqi,
15
43
  and others.
16
44
  email: contact@cyril.email
17
45
  executables: []
@@ -22,13 +50,13 @@ files:
22
50
  - README.md
23
51
  - lib/feen.rb
24
52
  - lib/feen/dumper.rb
25
- - lib/feen/dumper/games_turn.rb
26
53
  - lib/feen/dumper/piece_placement.rb
27
54
  - lib/feen/dumper/pieces_in_hand.rb
55
+ - lib/feen/dumper/style_turn.rb
28
56
  - lib/feen/parser.rb
29
- - lib/feen/parser/games_turn.rb
30
57
  - lib/feen/parser/piece_placement.rb
31
58
  - lib/feen/parser/pieces_in_hand.rb
59
+ - lib/feen/parser/style_turn.rb
32
60
  homepage: https://github.com/sashite/feen.rb
33
61
  licenses:
34
62
  - MIT
@@ -39,8 +67,6 @@ metadata:
39
67
  source_code_uri: https://github.com/sashite/feen.rb
40
68
  specification_uri: https://sashite.dev/documents/feen/1.0.0/
41
69
  rubygems_mfa_required: 'true'
42
- keywords: board, board-games, chess, deserialization, feen, fen, game, makruk, notation,
43
- serialization, shogi, xiangqi"
44
70
  rdoc_options: []
45
71
  require_paths:
46
72
  - lib
@@ -1,70 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Feen
4
- module Dumper
5
- # Handles conversion of games turn data to FEEN notation string
6
- module GamesTurn
7
- # Error messages for validation
8
- ERRORS = {
9
- invalid_type: "%s must be a String, got %s",
10
- empty_string: "%s cannot be empty",
11
- invalid_chars: "%s must contain only alphabetic characters (a-z, A-Z)",
12
- mixed_case: "%s has mixed case: %s",
13
- same_casing: "One variant must be uppercase and the other lowercase"
14
- }.freeze
15
-
16
- # Converts the active and inactive variant identifiers to a FEEN-formatted games turn string
17
- #
18
- # @param active_variant [String] Identifier for the player to move and their game variant
19
- # @param inactive_variant [String] Identifier for the opponent and their game variant
20
- # @return [String] FEEN-formatted games turn string
21
- # @raise [ArgumentError] If the variant identifiers are invalid
22
- #
23
- # @example Valid games turn
24
- # GamesTurn.dump("CHESS", "chess")
25
- # # => "CHESS/chess"
26
- #
27
- # @example Invalid - same casing
28
- # GamesTurn.dump("CHESS", "MAKRUK")
29
- # # => ArgumentError: One variant must be uppercase and the other lowercase
30
- def self.dump(active_variant, inactive_variant)
31
- validate_variants(active_variant, inactive_variant)
32
- "#{active_variant}/#{inactive_variant}"
33
- end
34
-
35
- # Validates the game variant identifiers
36
- #
37
- # @param active [String] The active player's variant identifier
38
- # @param inactive [String] The inactive player's variant identifier
39
- # @raise [ArgumentError] If the variant identifiers are invalid
40
- # @return [void]
41
- private_class_method def self.validate_variants(active, inactive)
42
- # Validate basic type, presence and format
43
- [["Active variant", active], ["Inactive variant", inactive]].each do |name, variant|
44
- # Type validation
45
- raise ArgumentError, format(ERRORS[:invalid_type], name, variant.class) unless variant.is_a?(String)
46
-
47
- # Empty validation
48
- raise ArgumentError, format(ERRORS[:empty_string], name) if variant.empty?
49
-
50
- # Character validation
51
- raise ArgumentError, format(ERRORS[:invalid_chars], name) unless variant.match?(/\A[a-zA-Z]+\z/)
52
-
53
- # Mixed case validation
54
- unless variant == variant.upcase || variant == variant.downcase
55
- raise ArgumentError, format(ERRORS[:mixed_case], name, variant)
56
- end
57
- end
58
-
59
- # Casing difference validation
60
- active_is_uppercase = active == active.upcase
61
- inactive_is_uppercase = inactive == inactive.upcase
62
-
63
- # Both variants must have different casing
64
- return unless active_is_uppercase == inactive_is_uppercase
65
-
66
- raise ArgumentError, ERRORS[:same_casing]
67
- end
68
- end
69
- end
70
- end
@@ -1,78 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Feen
4
- module Parser
5
- # Handles parsing of the games turn section of a FEEN string
6
- module GamesTurn
7
- # Error messages for games turn parsing
8
- ERRORS = {
9
- invalid_type: "Games turn must be a string, got %s",
10
- empty_string: "Games turn string cannot be empty",
11
- invalid_format: "Invalid games turn format. Expected format: UPPERCASE/lowercase or lowercase/UPPERCASE"
12
- }.freeze
13
-
14
- # Pattern matching the FEEN specification for games turn
15
- # <games-turn> ::= <game-id-uppercase> "/" <game-id-lowercase>
16
- # | <game-id-lowercase> "/" <game-id-uppercase>
17
- VALID_GAMES_TURN_PATTERN = %r{
18
- \A # Start of string
19
- (?: # Non-capturing group for alternatives
20
- (?<uppercase_first>[A-Z]+) # Named group: uppercase identifier first
21
- / # Separator
22
- (?<lowercase_second>[a-z]+) # Named group: lowercase identifier second
23
- | # OR
24
- (?<lowercase_first>[a-z]+) # Named group: lowercase identifier first
25
- / # Separator
26
- (?<uppercase_second>[A-Z]+) # Named group: uppercase identifier second
27
- )
28
- \z # End of string
29
- }x
30
-
31
- # Parses the games turn section of a FEEN string
32
- #
33
- # @param games_turn_str [String] FEEN games turn string
34
- # @return [Array<String>] Array containing [active_player, inactive_player]
35
- # @raise [ArgumentError] If the input string is invalid
36
- #
37
- # @example Valid games turn string with uppercase first
38
- # GamesTurn.parse("CHESS/ogi")
39
- # # => ["CHESS", "ogi"]
40
- #
41
- # @example Valid games turn string with lowercase first
42
- # GamesTurn.parse("chess/OGI")
43
- # # => ["chess", "OGI"]
44
- def self.parse(games_turn_str)
45
- validate_input_type(games_turn_str)
46
-
47
- match = VALID_GAMES_TURN_PATTERN.match(games_turn_str)
48
- raise ::ArgumentError, ERRORS[:invalid_format] unless match
49
-
50
- extract_game_identifiers(match)
51
- end
52
-
53
- # Validates that the input is a non-empty string
54
- #
55
- # @param str [String] Input string to validate
56
- # @raise [ArgumentError] If input is not a string or is empty
57
- # @return [void]
58
- private_class_method def self.validate_input_type(str)
59
- raise ::ArgumentError, format(ERRORS[:invalid_type], str.class) unless str.is_a?(::String)
60
- raise ::ArgumentError, ERRORS[:empty_string] if str.empty?
61
- end
62
-
63
- # Extracts game identifiers from regexp match captures
64
- #
65
- # @param match [MatchData] Regexp match data with named captures
66
- # @return [Array<String>] Array containing [active_player, inactive_player]
67
- private_class_method def self.extract_game_identifiers(match)
68
- captures = match.named_captures
69
-
70
- if captures["uppercase_first"]
71
- [captures["uppercase_first"], captures["lowercase_second"]]
72
- else
73
- [captures["lowercase_first"], captures["uppercase_second"]]
74
- end
75
- end
76
- end
77
- end
78
- end