igata 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7be289ddd3a6be5183b8f8e64f3e5f175b9457a04c28d406a429646e8b6095c9
4
- data.tar.gz: 92e060e8fb1751978e6dc579f3e7d5f66bb8cabbcc938c36ea63313b829a23e1
3
+ metadata.gz: 78199aa96fa495664cb461c46bbabdce3afa9a21f3083e5f49e41cb47e66f909
4
+ data.tar.gz: e2a0d35b071882c6b28ea13d1b88f846e3bb74adcf1ca92d7700aa7be635ee44
5
5
  SHA512:
6
- metadata.gz: 942277698a8f0a1aaa4cb06a330d213e0e32494d9421fcdf4dd2220c4db936887ad553835406ef8fa3e4b93ca33ee2d18d6e75d78c674ec74c9311d6add62000
7
- data.tar.gz: 1a30f0198426b5be82687c0b1492e3e18cb88e3eced706b36f4e5ecb64c54e4e4468dfc0ba5b0ce96da4d80e6549174b6eb1c08132ff07f34f15c2ae231f5902
6
+ metadata.gz: 4c5b75401c77c515dfdcb5968d44c7c4e8569ed10ebc03c5e7a7a8ab7e8225aa086cabcb66c99c7519f3c35fc5fb9f2789409d56d5d4961995c07ae79192df94
7
+ data.tar.gz: eb80ea990597cda4110a8b7e431d41d6c19c8af8bf5387e2e8ad270ea273e1b39f913107069ec8da2c8b836dbbe1efe8fe885aae9fc07afb26a53568efb49f26
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.4.7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,96 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.2.0] - 2025-10-26
4
+
5
+ ### Added
6
+
7
+ - Introduced Formatter architecture for supporting multiple test frameworks:
8
+ - `Formatters::Base`: Abstract base class for all formatters
9
+ - `Formatters::Minitest`: Minitest-specific formatter implementation
10
+ - `Formatters::RSpec`: RSpec-specific formatter implementation
11
+ - `MethodNotOverriddenError`: Custom error for abstract method violations
12
+ - Separated `Igata::Error` into dedicated file
13
+ - Added CLI formatter option:
14
+ - `-f, --formatter FORMATTER`: Specify test framework formatter (minitest or rspec)
15
+ - Added RSpec support:
16
+ - RSpec formatter generates `describe` and `it` blocks
17
+ - RSpec templates (class.erb and method.erb) for RSpec-style test generation
18
+ - Supports all branch and comparison analysis features in RSpec format
19
+ - Branch and comparison comments in RSpec tests (e.g., `# Branches: if (value > 0)`)
20
+ - Unit tests for RSpec formatter (6 tests)
21
+ - Integration tests for RSpec formatter (3 tests)
22
+ - Added branch analysis functionality:
23
+ - `BranchInfo` value object: Stores branch information (type, condition)
24
+ - `BranchAnalyzer` extractor: Detects if/unless/case statements in methods
25
+ - `MethodInfo` now includes `branches` field for branch information
26
+ - Minitest template generates branch comments with condition information (e.g., `# Branches: if (age >= 18), unless (user.valid?)`)
27
+ - Extracts condition expressions for if/unless/case statements
28
+ - Handles method calls, comparisons, and variable references in conditions
29
+ - Added comparison analysis functionality:
30
+ - `ComparisonInfo` value object: Stores comparison information (operator, left, right, context)
31
+ - `ComparisonAnalyzer` extractor: Detects comparison operators (>=, <=, >, <, ==, !=) in methods
32
+ - `MethodInfo` now includes `comparisons` field for comparison information
33
+ - Minitest template generates comparison comments (e.g., `# Comparisons: >= (age >= 18)`)
34
+ - Supports multiple comparisons with AND/OR operators (e.g., `value >= 0 && value <= 150`)
35
+ - Detects comparisons in various contexts: direct returns, if/unless conditions, nested expressions
36
+ - Handles different expression types: local variables, instance variables, integers, strings, symbols
37
+ - Added comprehensive unit tests:
38
+ - Extractor tests: `ConstantPath` (10 tests), `MethodNames` (6 tests), `BranchAnalyzer` (6 tests), `ComparisonAnalyzer` (6 tests)
39
+ - Formatter tests: `Base` (3 tests), `Minitest` (6 tests)
40
+ - Total: 37 new unit tests added
41
+ - Added integration tests for branch analysis:
42
+ - `class_with_branches` test fixture with if/unless/case statements
43
+ - 4 integration tests verifying branch comment generation
44
+ - Added integration tests for comparison analysis:
45
+ - `class_with_comparisons` test fixture with various comparison operators
46
+ - 6 integration tests verifying comparison comment generation
47
+ - Added ERB templates (class.erb and method.erb) to generate Minitest test code skeleton
48
+ - Implemented test generation supporting various class/module nesting patterns:
49
+ - Simple classes: `class User`
50
+ - Regular nested classes: `class User; class Profile; end; end`
51
+ - Compact nested classes: `class User::Profile`
52
+ - Deep nested classes: `class App::User::Profile` (supports any depth)
53
+ - Mixed nested patterns: `class App::User; class Profile; end; end`
54
+ - Double compact nested: `class App::Model; class User::Profile; end; end`
55
+ - Triple nested with mixed patterns: `class App::Model; class Admin::User; class Profile; end; end; end`
56
+ - Compact nested modules: `module User::Updator`
57
+ - Generated test files include properly indented method definitions with skip statements
58
+ - Test methods are separated by blank lines for better readability
59
+ - Implemented CLI with OptionParser support
60
+ - `-o, --output FILE`: Write output to file instead of stdout
61
+ - `-h, --help`: Display help message
62
+ - `-v, --version`: Display version information
63
+ - File argument support: `igata file.rb`
64
+ - Stdin support: `cat file.rb | igata`
65
+ - Added CLI regression tests (test/cli_test.rb) covering all command-line options and input methods
66
+
67
+ ### Changed
68
+
69
+ - Reorganized project structure for multi-formatter support:
70
+ - Moved templates to `lib/igata/formatters/templates/minitest/`
71
+ - Reorganized fixtures to `test/fixtures/integration/minitest/`
72
+ - Test type (integration/unit) is now the primary hierarchy, formatter (minitest) is secondary
73
+ - Separated tests into `test/integration/` and `test/unit/`
74
+ - Refactored code generation to use Formatter pattern:
75
+ - `Igata` class delegates to formatter for code generation
76
+ - Accepts `formatter:` parameter (default: `:minitest`)
77
+ - Supports custom formatter classes
78
+ - Refactored AST analysis logic into Extractor pattern for better separation of concerns
79
+ - Introduced Value Objects (`ConstantPath`, `MethodInfo`, `BranchInfo`) using `Data.define` for immutable data structures
80
+ - Implemented recursive traversal for deeply nested class/module definitions
81
+ - AST extraction is now handled by specialized extractors:
82
+ - `Extractors::ConstantPath`: Extracts full constant paths from nested structures
83
+ - `Extractors::MethodNames`: Extracts method information from class nodes
84
+ - `Extractors::BranchAnalyzer`: Analyzes control flow branches in methods
85
+ - `Extractors::ComparisonAnalyzer`: Analyzes comparison operators in methods
86
+ - `MethodNames` extractor now automatically runs `BranchAnalyzer` and `ComparisonAnalyzer` for each method
87
+
88
+ ### Fixed
89
+
90
+ - Fixed empty class handling in Extractors:
91
+ - `ConstantPath` now handles BeginNode (empty classes) correctly
92
+ - `MethodNames` returns empty array for BeginNode instead of raising error
93
+
3
94
  ## [0.1.0] - 2025-10-25
4
95
 
5
96
  - Initial release
data/QUICKSTART.md ADDED
@@ -0,0 +1,168 @@
1
+ # Quick Start Guide
2
+
3
+ Get started with Igata in 5 minutes.
4
+
5
+ ## What is Igata?
6
+
7
+ Igata (鋳型 = mold/template) generates test code templates from your Ruby classes. It analyzes your code structure and creates test skeletons with helpful comments about branches and comparisons, so you can focus on writing test logic instead of boilerplate.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ gem install igata
13
+ ```
14
+
15
+ Or add to your Gemfile:
16
+
17
+ ```bash
18
+ bundle add igata
19
+ ```
20
+
21
+ ## Your First Test Generation
22
+
23
+ ### 1. Create a sample Ruby class
24
+
25
+ Create a file `user.rb`:
26
+
27
+ ```ruby
28
+ class User
29
+ def initialize(name, age)
30
+ @name = name
31
+ @age = age
32
+ end
33
+
34
+ def adult?
35
+ @age >= 18
36
+ end
37
+
38
+ def greeting
39
+ if @name
40
+ "Hello, #{@name}!"
41
+ else
42
+ "Hello, Guest!"
43
+ end
44
+ end
45
+ end
46
+ ```
47
+
48
+ ### 2. Generate Minitest (default)
49
+
50
+ ```bash
51
+ igata user.rb > test/test_user.rb
52
+ ```
53
+
54
+ Output:
55
+
56
+ ```ruby
57
+ # frozen_string_literal: true
58
+
59
+ require "test_helper"
60
+
61
+ class UserTest < Minitest::Test
62
+ def test_initialize
63
+ skip "Not implemented yet"
64
+ end
65
+
66
+ def test_adult?
67
+ # Comparisons: >= (@age >= 18)
68
+ skip "Not implemented yet"
69
+ end
70
+
71
+ def test_greeting
72
+ # Branches: if (@name)
73
+ skip "Not implemented yet"
74
+ end
75
+ end
76
+ ```
77
+
78
+ ### 3. Generate RSpec
79
+
80
+ ```bash
81
+ igata user.rb -f rspec > spec/user_spec.rb
82
+ ```
83
+
84
+ Output:
85
+
86
+ ```ruby
87
+ # frozen_string_literal: true
88
+
89
+ require "spec_helper"
90
+
91
+ RSpec.describe User do
92
+ describe "#initialize" do
93
+ it "works correctly" do
94
+ pending "Not implemented yet"
95
+ end
96
+ end
97
+
98
+ describe "#adult?" do
99
+ # Comparisons: >= (@age >= 18)
100
+ it "works correctly" do
101
+ pending "Not implemented yet"
102
+ end
103
+ end
104
+
105
+ describe "#greeting" do
106
+ # Branches: if (@name)
107
+ it "works correctly" do
108
+ pending "Not implemented yet"
109
+ end
110
+ end
111
+ end
112
+ ```
113
+
114
+ ## Common Usage Patterns
115
+
116
+ ### Generate from stdin
117
+
118
+ ```bash
119
+ cat lib/user.rb | igata
120
+ ```
121
+
122
+ ### Specify output file
123
+
124
+ ```bash
125
+ igata lib/user.rb -o test/test_user.rb
126
+ ```
127
+
128
+ ### Multiple formatters
129
+
130
+ ```bash
131
+ # Minitest (default)
132
+ igata lib/user.rb > test/test_user.rb
133
+
134
+ # RSpec
135
+ igata lib/user.rb -f rspec > spec/user_spec.rb
136
+ ```
137
+
138
+ ## Understanding the Output
139
+
140
+ Igata adds helpful comments to guide your test implementation:
141
+
142
+ - **`# Branches:`** - Shows conditional logic (if/unless/case) with conditions
143
+ - **`# Comparisons:`** - Shows comparison operators (>=, <=, >, <, ==, !=) suggesting boundary value tests
144
+
145
+ These comments help you:
146
+ 1. Identify edge cases to test
147
+ 2. Write comprehensive test coverage
148
+ 3. Understand method complexity at a glance
149
+ 4. **Assist LLMs** - GitHub Copilot, Claude, ChatGPT, and other LLMs may use these comments as context to generate more appropriate test code
150
+
151
+ ## Next Steps
152
+
153
+ - See [README.md](README.md) for complete documentation
154
+ - Check [CHANGELOG.md](CHANGELOG.md) for feature details
155
+ - Write your test logic inside the generated skeleton
156
+ - Remove `skip` / `pending` statements as you implement tests
157
+
158
+ ## Tips
159
+
160
+ 1. **Nested classes work**: `class User::Profile` generates proper test structure
161
+ 2. **Run tests first**: Generated tests use `skip`/`pending` so they won't fail
162
+ 3. **Combine with TDD**: Generate template → Write test logic → Implement feature
163
+ 4. **Use comments**: Branch and comparison comments guide your test cases
164
+
165
+ ## Need Help?
166
+
167
+ - [GitHub Issues](https://github.com/S-H-GAMELINKS/igata/issues)
168
+ - [Full Documentation](README.md)
data/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Generate test code from AST node produces by Ruby's Parser
4
4
 
5
+ **[📚 Quick Start Guide](QUICKSTART.md)** | [Full Documentation](#usage) | [Changelog](CHANGELOG.md)
6
+
5
7
  ## Installation
6
8
 
7
9
  Install the gem and add to the application's Gemfile by executing:
@@ -18,10 +20,86 @@ gem install igata
18
20
 
19
21
  ## Usage
20
22
 
23
+ ### Basic Usage
24
+
25
+ Generate Minitest tests (default):
26
+
21
27
  ```bash
22
28
  bundle exec igata lib/user.rb > test/test_user.rb
23
29
  ```
24
30
 
31
+ Generate RSpec tests:
32
+
33
+ ```bash
34
+ bundle exec igata lib/user.rb -f rspec > spec/user_spec.rb
35
+ # or
36
+ bundle exec igata lib/user.rb --formatter rspec > spec/user_spec.rb
37
+ ```
38
+
39
+ ### Supported Formatters
40
+
41
+ - `minitest` (default): Generates Minitest-style tests
42
+ - `rspec`: Generates RSpec-style tests
43
+
44
+ ### Example
45
+
46
+ Given a Ruby class:
47
+
48
+ ```ruby
49
+ class User
50
+ def initialize(name, age)
51
+ @name = name
52
+ @age = age
53
+ end
54
+
55
+ def adult?
56
+ @age >= 18
57
+ end
58
+ end
59
+ ```
60
+
61
+ Minitest output:
62
+
63
+ ```ruby
64
+ # frozen_string_literal: true
65
+
66
+ require "test_helper"
67
+
68
+ class UserTest < Minitest::Test
69
+ def test_initialize
70
+ skip "Not implemented yet"
71
+ end
72
+
73
+ def test_adult?
74
+ # Comparisons: >= (@age >= 18)
75
+ skip "Not implemented yet"
76
+ end
77
+ end
78
+ ```
79
+
80
+ RSpec output:
81
+
82
+ ```ruby
83
+ # frozen_string_literal: true
84
+
85
+ require "spec_helper"
86
+
87
+ RSpec.describe User do
88
+ describe "#initialize" do
89
+ it "works correctly" do
90
+ pending "Not implemented yet"
91
+ end
92
+ end
93
+
94
+ describe "#adult?" do
95
+ # Comparisons: >= (@age >= 18)
96
+ it "works correctly" do
97
+ pending "Not implemented yet"
98
+ end
99
+ end
100
+ end
101
+ ```
102
+
25
103
  ## Development
26
104
 
27
105
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
data/exe/igata CHANGED
@@ -2,3 +2,47 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "igata"
5
+ require "optparse"
6
+
7
+ options = { formatter: :minitest }
8
+ OptionParser.new do |opts|
9
+ opts.banner = "Usage: igata [options] [file]"
10
+ opts.version = Igata::VERSION
11
+
12
+ opts.on("-f", "--formatter FORMATTER", %i[minitest rspec],
13
+ "Test framework formatter (minitest, rspec)") do |formatter|
14
+ options[:formatter] = formatter
15
+ end
16
+
17
+ opts.on("-o", "--output FILE", "Write output to FILE instead of stdout") do |file|
18
+ options[:output] = file
19
+ end
20
+
21
+ opts.on("-h", "--help", "Show this help message") do
22
+ puts opts
23
+ exit
24
+ end
25
+
26
+ opts.on("-v", "--version", "Show version") do
27
+ puts Igata::VERSION
28
+ exit
29
+ end
30
+ end.parse!
31
+
32
+ # Read input from file or stdin
33
+ source = if ARGV.empty?
34
+ $stdin.read
35
+ else
36
+ File.read(ARGV[0])
37
+ end
38
+
39
+ # Generate test code
40
+ igata = Igata.new(source, formatter: options[:formatter])
41
+ output = igata.generate
42
+
43
+ # Write output to file or stdout
44
+ if options[:output]
45
+ File.write(options[:output], output)
46
+ else
47
+ puts output
48
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Igata
4
+ class Error < StandardError; end
5
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Igata
4
+ module Extractors
5
+ class BranchAnalyzer
6
+ def self.extract(method_node)
7
+ new(method_node).extract
8
+ end
9
+
10
+ def initialize(method_node)
11
+ @method_node = method_node
12
+ end
13
+
14
+ def extract
15
+ return [] unless @method_node
16
+ return [] unless @method_node.respond_to?(:defn)
17
+
18
+ branches = []
19
+ # DefinitionNode has a defn field that contains ScopeNode
20
+ defn_node = @method_node.defn
21
+ traverse_node(defn_node, branches) if defn_node
22
+ branches
23
+ end
24
+
25
+ private
26
+
27
+ def traverse_node(node, branches) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
28
+ return unless node
29
+
30
+ # Handle IfStatementNode (if/elsif/else)
31
+ if node.is_a?(Kanayago::IfStatementNode)
32
+ branches << Values::BranchInfo.new(
33
+ type: :if,
34
+ condition: extract_condition(node)
35
+ )
36
+ # Continue traversing child nodes
37
+ traverse_node(node.body, branches) if node.respond_to?(:body)
38
+ traverse_node(node.elsif, branches) if node.respond_to?(:elsif)
39
+ traverse_node(node.else, branches) if node.respond_to?(:else)
40
+ # Handle UnlessStatementNode
41
+ elsif node.is_a?(Kanayago::UnlessStatementNode)
42
+ branches << Values::BranchInfo.new(
43
+ type: :unless,
44
+ condition: extract_condition(node)
45
+ )
46
+ traverse_node(node.body, branches) if node.respond_to?(:body)
47
+ traverse_node(node.else, branches) if node.respond_to?(:else)
48
+ # Handle CaseNode (case/when)
49
+ elsif node.is_a?(Kanayago::CaseNode)
50
+ branches << Values::BranchInfo.new(
51
+ type: :case,
52
+ condition: extract_condition(node)
53
+ )
54
+ # Traverse when branches
55
+ traverse_node(node.body, branches) if node.respond_to?(:body)
56
+ traverse_node(node.else, branches) if node.respond_to?(:else)
57
+ # Handle ScopeNode (container node)
58
+ elsif node.is_a?(Kanayago::ScopeNode)
59
+ traverse_node(node.body, branches) if node.respond_to?(:body)
60
+ # Handle BlockNode (container for multiple statements)
61
+ elsif node.is_a?(Kanayago::BlockNode)
62
+ node.each { |child| traverse_node(child, branches) } if node.respond_to?(:each)
63
+ # Handle other container nodes
64
+ elsif node.respond_to?(:body) && node.body.respond_to?(:each)
65
+ node.body.each { |child| traverse_node(child, branches) }
66
+ end
67
+ end
68
+
69
+ def extract_condition(branch_node) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
70
+ return nil unless branch_node
71
+
72
+ # Extract condition based on branch type
73
+ condition_node = if branch_node.is_a?(Kanayago::IfStatementNode) || branch_node.is_a?(Kanayago::UnlessStatementNode)
74
+ branch_node.cond if branch_node.respond_to?(:cond)
75
+ elsif branch_node.is_a?(Kanayago::CaseNode)
76
+ branch_node.head if branch_node.respond_to?(:head)
77
+ end
78
+
79
+ return nil unless condition_node
80
+
81
+ extract_expression(condition_node)
82
+ end
83
+
84
+ def extract_expression(node) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
85
+ return "" unless node
86
+
87
+ # Handle different node types for source reconstruction
88
+ if node.is_a?(Kanayago::LocalVariableNode)
89
+ node.vid.to_s
90
+ elsif node.is_a?(Kanayago::InstanceVariableNode)
91
+ node.vid.to_s
92
+ elsif node.is_a?(Kanayago::IntegerNode)
93
+ node.val.to_s
94
+ elsif node.is_a?(Kanayago::StringNode)
95
+ "\"#{node.val}\""
96
+ elsif node.is_a?(Kanayago::SymbolNode)
97
+ ":#{node.ptr}"
98
+ elsif node.is_a?(Kanayago::OperatorCallNode)
99
+ # Handle comparison operators like age >= 18
100
+ left = extract_expression(node.recv)
101
+ operator = node.mid.to_s
102
+ right = extract_expression(node.args&.val&.first)
103
+ "#{left} #{operator} #{right}"
104
+ elsif node.is_a?(Kanayago::CallNode)
105
+ # Handle method calls like user.valid?
106
+ if node.respond_to?(:recv) && node.recv
107
+ receiver = extract_expression(node.recv)
108
+ method_name = node.mid.to_s
109
+ "#{receiver}.#{method_name}"
110
+ elsif node.respond_to?(:mid)
111
+ node.mid.to_s
112
+ else
113
+ "call"
114
+ end
115
+ elsif node.respond_to?(:mid)
116
+ node.mid.to_s
117
+ elsif node.is_a?(Integer) || node.is_a?(String)
118
+ node.to_s
119
+ else
120
+ node.class.name.split("::").last.gsub("Node", "").downcase
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Igata
4
+ module Extractors
5
+ class ComparisonAnalyzer
6
+ COMPARISON_OPERATORS = %i[>= <= > < == !=].freeze
7
+
8
+ def self.extract(method_node)
9
+ new(method_node).extract
10
+ end
11
+
12
+ def initialize(method_node)
13
+ @method_node = method_node
14
+ end
15
+
16
+ def extract
17
+ return [] unless @method_node
18
+ return [] unless @method_node.respond_to?(:defn)
19
+
20
+ comparisons = []
21
+ # DefinitionNode has a defn field that contains ScopeNode
22
+ defn_node = @method_node.defn
23
+ traverse_node(defn_node, comparisons) if defn_node
24
+ comparisons
25
+ end
26
+
27
+ private
28
+
29
+ def traverse_node(node, comparisons) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
30
+ return unless node
31
+
32
+ # Handle OperatorCallNode (comparison operators are OperatorCallNode in Kanayago)
33
+ if node.is_a?(Kanayago::OperatorCallNode) && COMPARISON_OPERATORS.include?(node.mid)
34
+ comparisons << Values::ComparisonInfo.new(
35
+ operator: node.mid,
36
+ left: extract_expression(node.recv),
37
+ right: extract_expression(node.args&.val&.first),
38
+ context: build_context(node)
39
+ )
40
+ # Handle AndNode and OrNode (traverse both sides)
41
+ elsif node.is_a?(Kanayago::AndNode) || node.is_a?(Kanayago::OrNode)
42
+ traverse_node(node.first, comparisons) if node.respond_to?(:first)
43
+ traverse_node(node.second, comparisons) if node.respond_to?(:second)
44
+ # Handle IfStatementNode (traverse condition and body)
45
+ elsif node.is_a?(Kanayago::IfStatementNode)
46
+ traverse_node(node.cond, comparisons) if node.respond_to?(:cond)
47
+ traverse_node(node.body, comparisons) if node.respond_to?(:body)
48
+ traverse_node(node.else, comparisons) if node.respond_to?(:else)
49
+ # Handle UnlessStatementNode
50
+ elsif node.is_a?(Kanayago::UnlessStatementNode)
51
+ traverse_node(node.cond, comparisons) if node.respond_to?(:cond)
52
+ traverse_node(node.body, comparisons) if node.respond_to?(:body)
53
+ traverse_node(node.else, comparisons) if node.respond_to?(:else)
54
+ # Handle CaseNode
55
+ elsif node.is_a?(Kanayago::CaseNode)
56
+ traverse_node(node.body, comparisons) if node.respond_to?(:body)
57
+ traverse_node(node.else, comparisons) if node.respond_to?(:else)
58
+ # Handle ScopeNode (container node)
59
+ elsif node.is_a?(Kanayago::ScopeNode)
60
+ traverse_node(node.body, comparisons) if node.respond_to?(:body)
61
+ # Handle BlockNode (container for multiple statements)
62
+ elsif node.is_a?(Kanayago::BlockNode)
63
+ node.each { |child| traverse_node(child, comparisons) } if node.respond_to?(:each)
64
+ # Handle other container nodes
65
+ elsif node.respond_to?(:body) && node.body.respond_to?(:each)
66
+ node.body.each { |child| traverse_node(child, comparisons) }
67
+ end
68
+ end
69
+
70
+ def extract_expression(node) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
71
+ return "" unless node
72
+
73
+ # Handle different node types for source reconstruction
74
+ if node.is_a?(Kanayago::LocalVariableNode)
75
+ node.vid.to_s
76
+ elsif node.is_a?(Kanayago::InstanceVariableNode)
77
+ node.vid.to_s
78
+ elsif node.is_a?(Kanayago::IntegerNode)
79
+ node.val.to_s
80
+ elsif node.is_a?(Kanayago::StringNode)
81
+ "\"#{node.val}\""
82
+ elsif node.is_a?(Kanayago::SymbolNode)
83
+ ":#{node.ptr}"
84
+ elsif node.respond_to?(:mid)
85
+ node.mid.to_s
86
+ elsif node.is_a?(Integer) || node.is_a?(String)
87
+ node.to_s
88
+ else
89
+ node.class.name.split("::").last.gsub("Node", "").downcase
90
+ end
91
+ end
92
+
93
+ def build_context(call_node)
94
+ left = extract_expression(call_node.recv)
95
+ operator = call_node.mid.to_s
96
+ right = extract_expression(call_node.args&.val&.first)
97
+ "#{left} #{operator} #{right}"
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Igata
4
+ module Extractors
5
+ class ConstantPath
6
+ def self.extract(ast)
7
+ new(ast).extract
8
+ end
9
+
10
+ def initialize(ast)
11
+ @ast = ast
12
+ end
13
+
14
+ def extract # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
15
+ if compact_nested? && nested?
16
+ # Mixed pattern: class App::User; class Profile; end; end
17
+ # Inner class may also be compact nested: class App::Model; class User::Profile; end
18
+ # May be deeply nested: class App::Model; class Admin::User; class Profile; end; end; end
19
+ compact_path = extract_compact_nested_path
20
+ nested_path = build_nested_path(@ast.body)
21
+ path = "#{compact_path}::#{nested_path}"
22
+
23
+ Values::ConstantPath.new(
24
+ path: path,
25
+ nested: true, # Has nested class inside
26
+ compact: true # Outer is compact nested
27
+ )
28
+ elsif compact_nested?
29
+ # Compact nested pattern (no inner nesting): class User::Profile
30
+ path = extract_compact_nested_path
31
+ Values::ConstantPath.new(
32
+ path: path,
33
+ nested: false, # No nested class inside
34
+ compact: true
35
+ )
36
+ elsif nested?
37
+ # Regular nested pattern: class User; class Profile; end; end
38
+ # Inner class may also be compact nested: class User; class App::Profile; end
39
+ # May be deeply nested: class User; class Admin::User; class Profile; end; end; end
40
+ namespace = @ast.body.cpath.mid.to_s
41
+ nested_path = build_nested_path(@ast.body)
42
+ path = "#{namespace}::#{nested_path}"
43
+
44
+ Values::ConstantPath.new(
45
+ path: path,
46
+ nested: true, # Has nested class inside
47
+ compact: false
48
+ )
49
+ else
50
+ # Simple pattern: class User
51
+ Values::ConstantPath.new(
52
+ path: @ast.body.cpath.mid.to_s,
53
+ nested: false,
54
+ compact: false
55
+ )
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def compact_nested?
62
+ # For compact nested pattern (class User::Profile), cpath is Colon2Node with non-nil head
63
+ @ast.body.cpath.is_a?(Kanayago::Colon2Node) && !@ast.body.cpath.head.nil?
64
+ end
65
+
66
+ def extract_compact_nested_path
67
+ # Recursively traverse cpath to build complete path
68
+ build_constant_path(@ast.body.cpath)
69
+ end
70
+
71
+ def build_constant_path(node) # rubocop:disable Metrics/MethodLength
72
+ case node
73
+ when Kanayago::Colon2Node
74
+ if node.head.nil?
75
+ # For "class User", head is nil so return only mid
76
+ node.mid.to_s
77
+ else
78
+ # For "User::Profile", combine head(User) + mid(Profile)
79
+ "#{build_constant_path(node.head)}::#{node.mid}"
80
+ end
81
+ when Kanayago::ConstantNode
82
+ # Top-level constant name
83
+ node.vid.to_s
84
+ else
85
+ node.to_s
86
+ end
87
+ end
88
+
89
+ def nested?
90
+ class_body = @ast.body.body.body
91
+ # For empty classes, class_body is BeginNode which doesn't have any?
92
+ return false unless class_body.respond_to?(:any?)
93
+
94
+ class_body.any? { |node| constant_definition_node?(node) }
95
+ end
96
+
97
+ def constant_definition_node?(node)
98
+ node.is_a?(Kanayago::ClassNode) || node.is_a?(Kanayago::ModuleNode)
99
+ end
100
+
101
+ def build_nested_path(parent_node) # rubocop:disable Metrics/MethodLength
102
+ # Find direct child class/module under the parent node
103
+ class_body = parent_node.body.body
104
+ # For empty classes, class_body is BeginNode which doesn't have find
105
+ return nil unless class_body.respond_to?(:find)
106
+
107
+ child_node = class_body.find { |node| constant_definition_node?(node) }
108
+ return nil unless child_node
109
+
110
+ # Get child class name (considering compact nesting)
111
+ child_class_path = build_constant_path(child_node.cpath)
112
+
113
+ # Check if there's deeper nesting
114
+ deeper_path = build_nested_path(child_node)
115
+
116
+ # Concatenate if deeper nesting exists, otherwise return current path
117
+ if deeper_path
118
+ "#{child_class_path}::#{deeper_path}"
119
+ else
120
+ child_class_path
121
+ end
122
+ end
123
+
124
+ def find_nested_constant_node
125
+ # Recursively find the deepest (innermost) class/module definition
126
+ find_deepest_nested_constant_node(@ast.body)
127
+ end
128
+
129
+ def find_deepest_nested_constant_node(parent_node)
130
+ # Find direct child class/module under the current node
131
+ direct_child = parent_node.body.body.find { |node| constant_definition_node?(node) }
132
+ return nil unless direct_child
133
+
134
+ # Check if there's deeper nesting in the child node
135
+ deeper_child = find_deepest_nested_constant_node(direct_child)
136
+
137
+ # Return deeper nesting if exists, otherwise return current child
138
+ deeper_child || direct_child
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Igata
4
+ module Extractors
5
+ class MethodNames
6
+ def self.extract(class_node)
7
+ new(class_node).extract
8
+ end
9
+
10
+ def initialize(class_node)
11
+ @class_node = class_node
12
+ end
13
+
14
+ def extract
15
+ class_body = @class_node.body.body
16
+ # For empty classes, class_body is BeginNode which doesn't have filter_map
17
+ return [] unless class_body.respond_to?(:filter_map)
18
+
19
+ class_body.filter_map do |node|
20
+ next unless node.is_a?(Kanayago::DefinitionNode)
21
+
22
+ # Extract branch information for each method
23
+ branches = BranchAnalyzer.extract(node)
24
+
25
+ # Extract comparison information for each method
26
+ comparisons = ComparisonAnalyzer.extract(node)
27
+
28
+ Values::MethodInfo.new(name: node.mid.to_s, branches: branches, comparisons: comparisons)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../error"
4
+
5
+ class Igata
6
+ module Formatters
7
+ class MethodNotOverriddenError < Igata::Error; end
8
+
9
+ class Base
10
+ def initialize(constant_info, method_infos)
11
+ @constant_info = constant_info
12
+ @method_infos = method_infos
13
+ end
14
+
15
+ def generate
16
+ raise MethodNotOverriddenError, "#{self.class}#generate must be implemented"
17
+ end
18
+
19
+ private
20
+
21
+ def template_path(name)
22
+ File.join(templates_dir, "#{name}.erb")
23
+ end
24
+
25
+ def templates_dir
26
+ raise MethodNotOverriddenError, "#{self.class}#templates_dir must be implemented"
27
+ end
28
+
29
+ def render_template(template_file, binding_context)
30
+ template_content = File.read(template_file)
31
+ ERB.new(template_content, trim_mode: "<>").result(binding_context)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ class Igata
6
+ module Formatters
7
+ class Minitest < Base
8
+ def generate
9
+ class_name = @constant_info.path
10
+ methods = generate_methods
11
+
12
+ template = ERB.new(File.read(template_path("class")), trim_mode: "<>")
13
+ template.result(binding)
14
+ end
15
+
16
+ private
17
+
18
+ def templates_dir
19
+ File.join(__dir__, "templates", "minitest")
20
+ end
21
+
22
+ def generate_methods
23
+ @method_infos.map do |method_info|
24
+ method_name = method_info.name
25
+ branches = method_info.branches
26
+ comparisons = method_info.comparisons
27
+ ERB.new(File.read(template_path("method")), trim_mode: "<>").result(binding)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ class Igata
6
+ module Formatters
7
+ class RSpec < Base
8
+ def generate
9
+ class_name = @constant_info.path
10
+ methods = generate_methods
11
+
12
+ template = ERB.new(File.read(template_path("class")), trim_mode: "<>")
13
+ template.result(binding)
14
+ end
15
+
16
+ private
17
+
18
+ def templates_dir
19
+ File.join(__dir__, "templates", "rspec")
20
+ end
21
+
22
+ def generate_methods
23
+ @method_infos.map do |method_info|
24
+ method_name = method_info.name
25
+ branches = method_info.branches
26
+ comparisons = method_info.comparisons
27
+ ERB.new(File.read(template_path("method")), trim_mode: "<>").result(binding)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class <%= class_name %>Test < Minitest::Test
6
+ <% methods.each_with_index do |method, index| %>
7
+ <%= method %>
8
+ <% if index < methods.length - 1 %>
9
+
10
+ <% end %>
11
+ <% end %>
12
+ end
@@ -0,0 +1,9 @@
1
+ def test_<%= method_name %>
2
+ <% if branches && !branches.empty? %>
3
+ # Branches: <%= branches.map { |b| b.condition ? "#{b.type} (#{b.condition})" : b.type.to_s }.join(", ") %>
4
+ <% end %>
5
+ <% if comparisons && !comparisons.empty? %>
6
+ # Comparisons: <%= comparisons.map { |c| "#{c.operator} (#{c.context})" }.join(", ") %>
7
+ <% end %>
8
+ skip "Not implemented yet"
9
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe <%= class_name %> do
6
+ <% methods.each_with_index do |method, index| %>
7
+ <%= method %>
8
+ <% if index < methods.length - 1 %>
9
+
10
+ <% end %>
11
+ <% end %>
12
+ end
@@ -0,0 +1,11 @@
1
+ describe "#<%= method_name %>" do
2
+ <% if branches && !branches.empty? %>
3
+ # Branches: <%= branches.map { |b| b.condition ? "#{b.type} (#{b.condition})" : b.type.to_s }.join(", ") %>
4
+ <% end %>
5
+ <% if comparisons && !comparisons.empty? %>
6
+ # Comparisons: <%= comparisons.map { |c| "#{c.operator} (#{c.context})" }.join(", ") %>
7
+ <% end %>
8
+ it "works correctly" do
9
+ pending "Not implemented yet"
10
+ end
11
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Igata
4
+ module Values
5
+ ConstantPath = Data.define(
6
+ :path, # "User::Profile"
7
+ :nested, # true/false
8
+ :compact # true/false (compact nested like "class User::Profile")
9
+ )
10
+
11
+ MethodInfo = Data.define(
12
+ :name, # "initialize"
13
+ :branches, # Array of BranchInfo (default: [])
14
+ :comparisons # Array of ComparisonInfo (default: [])
15
+ ) do
16
+ def initialize(name:, branches: [], comparisons: [])
17
+ super(name: name, branches: branches, comparisons: comparisons)
18
+ end
19
+ end
20
+
21
+ BranchInfo = Data.define(
22
+ :type, # :if, :unless, :case, :ternary
23
+ :condition # condition expression as string
24
+ )
25
+
26
+ ComparisonInfo = Data.define(
27
+ :operator, # :>=, :<=, :>, :<, :==, :!=
28
+ :left, # left side expression
29
+ :right, # right side expression
30
+ :context # full expression as string (e.g., "age >= 18")
31
+ )
32
+ end
33
+ end
data/lib/igata/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Igata
4
- VERSION = "0.1.0"
3
+ class Igata
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/igata.rb CHANGED
@@ -1,8 +1,73 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "kanayago"
4
+ require "erb"
5
+
3
6
  require_relative "igata/version"
7
+ require_relative "igata/error"
8
+ require_relative "igata/values"
9
+ require_relative "igata/extractors/constant_path"
10
+ require_relative "igata/extractors/method_names"
11
+ require_relative "igata/extractors/branch_analyzer"
12
+ require_relative "igata/extractors/comparison_analyzer"
13
+ require_relative "igata/formatters/minitest"
14
+ require_relative "igata/formatters/rspec"
15
+
16
+ class Igata
17
+ def initialize(source, formatter: :minitest)
18
+ @source = source
19
+ @ast = Kanayago.parse(source)
20
+ @formatter = formatter
21
+ end
22
+
23
+ def generate
24
+ constant_info = Extractors::ConstantPath.extract(@ast)
25
+ target_node = find_target_class_node(constant_info)
26
+ method_infos = Extractors::MethodNames.extract(target_node)
27
+
28
+ formatter_class = resolve_formatter(@formatter)
29
+ formatter_class.new(constant_info, method_infos).generate
30
+ end
31
+
32
+ private
33
+
34
+ def resolve_formatter(formatter)
35
+ case formatter
36
+ when :minitest
37
+ Formatters::Minitest
38
+ when :rspec
39
+ Formatters::RSpec
40
+ when Class
41
+ formatter
42
+ else
43
+ raise Error, "Unknown formatter: #{formatter}"
44
+ end
45
+ end
46
+
47
+ def find_target_class_node(constant_info)
48
+ if constant_info.nested
49
+ # When nested is true, recursively find the deepest (innermost) class
50
+ # - class User; class Profile; end; end
51
+ # - class App::User; class Profile; end; end (mixed pattern)
52
+ # - class App::Model; class Admin::User; class Profile; end; end; end (3+ levels)
53
+ find_deepest_class_node(@ast.body)
54
+ else
55
+ # When nested is false, @ast.body itself is the target
56
+ # - class User
57
+ # - class User::Profile
58
+ @ast.body
59
+ end
60
+ end
61
+
62
+ def find_deepest_class_node(parent_node)
63
+ # Find direct child class/module under the current node
64
+ direct_child = parent_node.body.body.find { |node| node.is_a?(Kanayago::ClassNode) || node.is_a?(Kanayago::ModuleNode) }
65
+ return nil unless direct_child
66
+
67
+ # Check if there's deeper nesting in the child node
68
+ deeper_child = find_deepest_class_node(direct_child)
4
69
 
5
- module Igata
6
- class Error < StandardError; end
7
- # Your code goes here...
70
+ # Return deeper nesting if exists, otherwise return current child
71
+ deeper_child || direct_child
72
+ end
8
73
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: igata
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - S-H-GAMELINKS
@@ -17,12 +17,27 @@ executables:
17
17
  extensions: []
18
18
  extra_rdoc_files: []
19
19
  files:
20
+ - ".ruby-version"
20
21
  - CHANGELOG.md
21
22
  - LICENSE.txt
23
+ - QUICKSTART.md
22
24
  - README.md
23
25
  - Rakefile
24
26
  - exe/igata
25
27
  - lib/igata.rb
28
+ - lib/igata/error.rb
29
+ - lib/igata/extractors/branch_analyzer.rb
30
+ - lib/igata/extractors/comparison_analyzer.rb
31
+ - lib/igata/extractors/constant_path.rb
32
+ - lib/igata/extractors/method_names.rb
33
+ - lib/igata/formatters/base.rb
34
+ - lib/igata/formatters/minitest.rb
35
+ - lib/igata/formatters/rspec.rb
36
+ - lib/igata/formatters/templates/minitest/class.erb
37
+ - lib/igata/formatters/templates/minitest/method.erb
38
+ - lib/igata/formatters/templates/rspec/class.erb
39
+ - lib/igata/formatters/templates/rspec/method.erb
40
+ - lib/igata/values.rb
26
41
  - lib/igata/version.rb
27
42
  - sig/igata.rbs
28
43
  homepage: https://github.com/S-H-GAMELINKS/igata
@@ -32,6 +47,7 @@ metadata:
32
47
  homepage_uri: https://github.com/S-H-GAMELINKS/igata
33
48
  source_code_uri: https://github.com/S-H-GAMELINKS/igata
34
49
  changelog_uri: https://github.com/S-H-GAMELINKS/igata/blob/main/CHANGELOG.md
50
+ rubygems_mfa_required: 'true'
35
51
  rdoc_options: []
36
52
  require_paths:
37
53
  - lib
@@ -46,7 +62,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
46
62
  - !ruby/object:Gem::Version
47
63
  version: '0'
48
64
  requirements: []
49
- rubygems_version: 3.8.0.dev
65
+ rubygems_version: 3.6.9
50
66
  specification_version: 4
51
67
  summary: Generate test code from AST node produces by Ruby's Parser
52
68
  test_files: []