rspec-let-analyzer 0.1.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 +7 -0
- data/README.md +183 -0
- data/bin/rspec-let-analyzer +117 -0
- data/lib/rspec_let_analyzer/adapter.rb +48 -0
- data/lib/rspec_let_analyzer/adapters/parser_adapter.rb +27 -0
- data/lib/rspec_let_analyzer/adapters/prism_adapter.rb +21 -0
- data/lib/rspec_let_analyzer/analyzer.rb +144 -0
- data/lib/rspec_let_analyzer/formatters/ascii_formatter.rb +128 -0
- data/lib/rspec_let_analyzer/formatters/html_formatter.rb +211 -0
- data/lib/rspec_let_analyzer/formatters/json_formatter.rb +42 -0
- data/lib/rspec_let_analyzer/formatters/llm_formatter.rb +145 -0
- data/lib/rspec_let_analyzer/progress_reporter.rb +32 -0
- data/lib/rspec_let_analyzer/version.rb +5 -0
- data/lib/rspec_let_analyzer/visitors/parser_visitor.rb +172 -0
- data/lib/rspec_let_analyzer/visitors/prism_visitor.rb +125 -0
- data/lib/rspec_let_analyzer.rb +16 -0
- metadata +103 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: a5a75ffed6c9e25926117f6412a8a12bd0bdf73824ae4683a75520dfd8eab28d
|
4
|
+
data.tar.gz: 14ae2f4a6ca4296a51f61f7a4acc75575c02b47fff5d7b6bdb0fa16c6f31ba47
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 3d4c1d18085b7e31231d5376a6accde175352b34dd84722f344d1972d16673dd72f6b369ed340daa4dcd42701a1d5da474634677875e8b6847135c702af374a5
|
7
|
+
data.tar.gz: a6f0c225e2d97a56c1f3bd5c1d0788db9a30f3f6a91f6a30cc85183bce52b20944cfa023d8f5a687d0e090d9b08cbb735c2374a1c53012aadc46a81e5c10cef6
|
data/README.md
ADDED
@@ -0,0 +1,183 @@
|
|
1
|
+
# RSpec Let Analyzer
|
2
|
+
|
3
|
+
A Ruby gem that analyzes RSpec test files to identify opportunities for refactoring `let` and `let!` declarations to use [test-prof](https://github.com/test-prof/test-prof)'s `let_it_be` helper for improved test performance.
|
4
|
+
|
5
|
+
## Background
|
6
|
+
|
7
|
+
The `let_it_be` helper from the test-prof gem creates test data once per example group instead of once per example, which can significantly speed up test suites. This tool helps you identify which `let` declarations are good candidates for conversion to `let_it_be`.
|
8
|
+
|
9
|
+
## Installation
|
10
|
+
|
11
|
+
Add this line to your application's Gemfile:
|
12
|
+
|
13
|
+
```ruby
|
14
|
+
gem 'rspec-let-analyzer'
|
15
|
+
```
|
16
|
+
|
17
|
+
And then execute:
|
18
|
+
|
19
|
+
```bash
|
20
|
+
bundle install
|
21
|
+
```
|
22
|
+
|
23
|
+
Or install it yourself as:
|
24
|
+
|
25
|
+
```bash
|
26
|
+
gem install rspec-let-analyzer
|
27
|
+
```
|
28
|
+
|
29
|
+
## Usage
|
30
|
+
|
31
|
+
Run the analyzer on your RSpec test suite:
|
32
|
+
|
33
|
+
```bash
|
34
|
+
# Analyze specs in current directory
|
35
|
+
rspec-let-analyzer
|
36
|
+
|
37
|
+
# Analyze specs in a different directory
|
38
|
+
rspec-let-analyzer /path/to/project
|
39
|
+
rspec-let-analyzer --directory /path/to/project
|
40
|
+
```
|
41
|
+
|
42
|
+
This will scan all files matching `spec/**/*_spec.rb` in the specified directory (or current directory if not specified) and display a table showing:
|
43
|
+
|
44
|
+
- **Total**: Combined score of all metrics (Root + it blocks + Nest columns + Redef + Before Creates)
|
45
|
+
- **Root**: Number of `let`/`let!` declarations at the root level of each describe block
|
46
|
+
- **it blocks**: Number of test examples in each file
|
47
|
+
- **Nest 1, Nest 2, etc.**: Number of `let` declarations at different nesting depths
|
48
|
+
- **Redef**: Number of `let` redefinitions (where a nested context redefines a `let` from a parent scope)
|
49
|
+
- **Bef Cr**: Number of `create` calls in `before` blocks
|
50
|
+
|
51
|
+
### Command Line Options
|
52
|
+
|
53
|
+
```bash
|
54
|
+
# Show top 20 files instead of default 10
|
55
|
+
rspec-let-analyzer -n 20
|
56
|
+
|
57
|
+
# Output in different formats
|
58
|
+
rspec-let-analyzer --format ascii # Default: ASCII table
|
59
|
+
rspec-let-analyzer --format json # JSON for further processing
|
60
|
+
rspec-let-analyzer --format html # HTML report with Bootstrap styling
|
61
|
+
rspec-let-analyzer --format llm # LLM-friendly JSON for AI-assisted refactoring
|
62
|
+
|
63
|
+
# Disable progress indicator
|
64
|
+
rspec-let-analyzer --no-progress
|
65
|
+
|
66
|
+
# Track nesting depth up to 5 levels (default: 3)
|
67
|
+
rspec-let-analyzer --nesting 5
|
68
|
+
|
69
|
+
# Disable nesting tracking entirely
|
70
|
+
rspec-let-analyzer --no-nesting
|
71
|
+
|
72
|
+
# Sort by different fields
|
73
|
+
rspec-let-analyzer --sort total # Sort by total score (default)
|
74
|
+
rspec-let-analyzer --sort root # Sort by root lets
|
75
|
+
rspec-let-analyzer --sort it # Sort by number of it blocks
|
76
|
+
rspec-let-analyzer --sort redef # Sort by number of redefinitions
|
77
|
+
rspec-let-analyzer --sort before # Sort by before creates
|
78
|
+
|
79
|
+
# Track FactoryBot usage statistics
|
80
|
+
rspec-let-analyzer --factories
|
81
|
+
|
82
|
+
# Analyze a different directory
|
83
|
+
rspec-let-analyzer --directory /path/to/project
|
84
|
+
rspec-let-analyzer /path/to/project # Positional argument also works
|
85
|
+
|
86
|
+
# Display runtime performance metrics
|
87
|
+
rspec-let-analyzer --runtime-metrics
|
88
|
+
|
89
|
+
# Use a different adapter (default: prism)
|
90
|
+
rspec-let-analyzer --adapter parser # Requires 'gem install parser'
|
91
|
+
|
92
|
+
# Generate reports and save to file
|
93
|
+
rspec-let-analyzer --format html --output report.html
|
94
|
+
rspec-let-analyzer --format llm --output refactor-guide.json
|
95
|
+
|
96
|
+
# Show help
|
97
|
+
rspec-let-analyzer --help
|
98
|
+
```
|
99
|
+
|
100
|
+
### LLM-Assisted Refactoring
|
101
|
+
|
102
|
+
The `--format llm` option generates AI-friendly output designed for use with Large Language Models (like Claude, ChatGPT, etc.) to assist with refactoring:
|
103
|
+
|
104
|
+
```bash
|
105
|
+
# Generate refactoring guide
|
106
|
+
rspec-let-analyzer --format llm --output refactor-guide.json -n 10
|
107
|
+
|
108
|
+
# Then provide this to your AI assistant with a prompt like:
|
109
|
+
# "Review the top 5 files in this report and suggest let_it_be refactorings"
|
110
|
+
```
|
111
|
+
|
112
|
+
The LLM format includes:
|
113
|
+
- Prioritized list of refactoring candidates with scores
|
114
|
+
- Step-by-step refactoring workflow
|
115
|
+
- Decision criteria (when to keep/ask/revert changes)
|
116
|
+
- Guidelines for safe conversions and common fixes
|
117
|
+
- Context about factory usage patterns
|
118
|
+
|
119
|
+
### Understanding the Output
|
120
|
+
|
121
|
+
**High Total score** indicates files with the most opportunities for optimization, combining all metrics for a comprehensive view of complexity.
|
122
|
+
|
123
|
+
**High numbers in the Root column** indicate files with many `let` declarations at the top level. These are prime candidates for `let_it_be` if:
|
124
|
+
- The data doesn't change between tests
|
125
|
+
- The tests don't modify the created objects
|
126
|
+
|
127
|
+
**High numbers in Redef column** indicate files where `let` declarations are frequently overridden in nested contexts. These may be more complex to refactor since the redefinitions might be intentional for test isolation.
|
128
|
+
|
129
|
+
**High numbers in Bef Cr column** indicate files with many `create` calls in `before` blocks, which can be converted to `let_it_be` or `before(:all)` blocks.
|
130
|
+
|
131
|
+
**Factory tracking** shows which FactoryBot factories are used most frequently across your test suite, helping identify optimization opportunities.
|
132
|
+
|
133
|
+
## Example Output
|
134
|
+
|
135
|
+
```
|
136
|
+
+------------------------------------------------------------+----------+----------+----------+----------+----------+----------+----------+----------+
|
137
|
+
| File | Total | Root | it blocks| Nest 1 | Nest 2 | Nest 3+| Redef | Bef Cr |
|
138
|
+
+------------------------------------------------------------+----------+----------+----------+----------+----------+----------+----------+----------+
|
139
|
+
| spec/models/user_spec.rb | 50 | 12 | 25 | 8 | 3 | 0 | 2 | 0 |
|
140
|
+
| spec/controllers/posts_controller_spec.rb | 38 | 10 | 18 | 6 | 2 | 1 | 1 | 0 |
|
141
|
+
+------------------------------------------------------------+----------+----------+----------+----------+----------+----------+----------+----------+
|
142
|
+
| TOTAL (all 50 files) | 600 | 150 | 300 | | | | 15 | 0 |
|
143
|
+
+------------------------------------------------------------+----------+----------+----------+----------+----------+----------+----------+----------+
|
144
|
+
```
|
145
|
+
|
146
|
+
## Development
|
147
|
+
|
148
|
+
After checking out the repo, run:
|
149
|
+
|
150
|
+
```bash
|
151
|
+
bundle install
|
152
|
+
```
|
153
|
+
|
154
|
+
To run the test suite:
|
155
|
+
|
156
|
+
```bash
|
157
|
+
bundle exec rspec
|
158
|
+
```
|
159
|
+
|
160
|
+
To install this gem onto your local machine:
|
161
|
+
|
162
|
+
```bash
|
163
|
+
bundle exec rake install
|
164
|
+
```
|
165
|
+
|
166
|
+
## How It Works
|
167
|
+
|
168
|
+
The gem uses Ruby's [Prism](https://github.com/ruby/prism) parser to analyze RSpec files and identify:
|
169
|
+
|
170
|
+
1. `let` and `let!` declarations at various nesting levels
|
171
|
+
2. Context nesting depth for each declaration
|
172
|
+
3. Cases where `let` declarations are redefined in nested scopes
|
173
|
+
4. FactoryBot usage patterns (when enabled)
|
174
|
+
|
175
|
+
This information helps you prioritize which test files to refactor for better performance using `let_it_be`.
|
176
|
+
|
177
|
+
## Contributing
|
178
|
+
|
179
|
+
Bug reports and pull requests are welcome on GitHub.
|
180
|
+
|
181
|
+
## License
|
182
|
+
|
183
|
+
The gem is available as open source under the terms of the MIT License.
|
@@ -0,0 +1,117 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require_relative '../lib/rspec_let_analyzer'
|
5
|
+
require 'optparse'
|
6
|
+
|
7
|
+
# Parse command line options
|
8
|
+
options = { limit: 10, format: 'ascii', progress: true, nesting: 3, sort: 'total', factories: false, directory: nil,
|
9
|
+
runtime_metrics: false, adapter: 'prism', output: nil }
|
10
|
+
OptionParser.new do |opts|
|
11
|
+
opts.banner = 'Usage: rspec-let-analyzer [options] [DIRECTORY]'
|
12
|
+
|
13
|
+
opts.on('-n', '--number N', Integer, 'Number of spec files to show (default: 10)') do |n|
|
14
|
+
options[:limit] = n
|
15
|
+
end
|
16
|
+
|
17
|
+
opts.on('-f', '--format FORMAT', %w[ascii json html llm], 'Output format: ascii, json, html, llm (default: ascii)') do |f|
|
18
|
+
options[:format] = f
|
19
|
+
end
|
20
|
+
|
21
|
+
opts.on('--[no-]progress', 'Show progress while parsing (default: true)') do |p|
|
22
|
+
options[:progress] = p
|
23
|
+
end
|
24
|
+
|
25
|
+
opts.on('--nesting DEPTH', Integer, 'Max nesting depth to track (default: 3)') do |d|
|
26
|
+
if d <= 0
|
27
|
+
puts 'Error: nesting depth must be greater than 0'
|
28
|
+
puts 'Use --no-nesting to disable nesting tracking'
|
29
|
+
exit 1
|
30
|
+
end
|
31
|
+
options[:nesting] = d
|
32
|
+
end
|
33
|
+
|
34
|
+
opts.on('--no-nesting', 'Disable nesting tracking') do
|
35
|
+
options[:nesting] = nil
|
36
|
+
end
|
37
|
+
|
38
|
+
opts.on('-s', '--sort FIELD', %w[root it redef before total], 'Sort by field: root, it, redef, before, total (default: total)') do |s|
|
39
|
+
options[:sort] = s
|
40
|
+
end
|
41
|
+
|
42
|
+
opts.on('--factories', 'Track FactoryBot usage statistics') do
|
43
|
+
options[:factories] = true
|
44
|
+
end
|
45
|
+
|
46
|
+
opts.on('-d', '--directory DIR', 'Directory to analyze (default: current directory)') do |d|
|
47
|
+
options[:directory] = d
|
48
|
+
end
|
49
|
+
|
50
|
+
opts.on('--runtime-metrics', 'Display runtime performance metrics') do
|
51
|
+
options[:runtime_metrics] = true
|
52
|
+
end
|
53
|
+
|
54
|
+
opts.on('-a', '--adapter ADAPTER', %w[prism parser], 'Adapter to use: prism, parser (default: prism)') do |a|
|
55
|
+
options[:adapter] = a
|
56
|
+
end
|
57
|
+
|
58
|
+
opts.on('-o', '--output FILE', 'Write output to FILE instead of stdout') do |o|
|
59
|
+
options[:output] = o
|
60
|
+
end
|
61
|
+
|
62
|
+
opts.on('-h', '--help', 'Show this help message') do
|
63
|
+
puts opts
|
64
|
+
exit
|
65
|
+
end
|
66
|
+
end.parse!
|
67
|
+
|
68
|
+
# Accept directory as positional argument if not specified with --directory
|
69
|
+
options[:directory] = ARGV[0] if ARGV.length.positive? && options[:directory].nil?
|
70
|
+
|
71
|
+
# Run analysis
|
72
|
+
progress_reporter = RSpecLetAnalyzer::ProgressReporter.new(options[:progress])
|
73
|
+
analyzer = RSpecLetAnalyzer::Analyzer.new(
|
74
|
+
progress_reporter: progress_reporter,
|
75
|
+
nesting_depth: options[:nesting],
|
76
|
+
track_factories: options[:factories],
|
77
|
+
parser_type: options[:adapter].to_sym
|
78
|
+
)
|
79
|
+
|
80
|
+
analyze_start = Time.now
|
81
|
+
analyzer.analyze(options[:directory])
|
82
|
+
analyze_time = Time.now - analyze_start
|
83
|
+
|
84
|
+
# Format and output
|
85
|
+
formatter = case options[:format]
|
86
|
+
when 'json'
|
87
|
+
RSpecLetAnalyzer::Formatters::JsonFormatter.new
|
88
|
+
when 'html'
|
89
|
+
RSpecLetAnalyzer::Formatters::HtmlFormatter.new
|
90
|
+
when 'llm'
|
91
|
+
RSpecLetAnalyzer::Formatters::LlmFormatter.new
|
92
|
+
else
|
93
|
+
RSpecLetAnalyzer::Formatters::AsciiFormatter.new
|
94
|
+
end
|
95
|
+
|
96
|
+
format_start = Time.now
|
97
|
+
output = formatter.format(analyzer: analyzer, limit: options[:limit], sort_by: options[:sort])
|
98
|
+
format_time = Time.now - format_start
|
99
|
+
|
100
|
+
# Add runtime metrics if requested
|
101
|
+
if options[:runtime_metrics]
|
102
|
+
runtime_metrics = {
|
103
|
+
analyze_time: analyze_time,
|
104
|
+
format_time: format_time,
|
105
|
+
total_time: analyze_time + format_time
|
106
|
+
}
|
107
|
+
|
108
|
+
output = formatter.add_runtime_metrics(output, runtime_metrics)
|
109
|
+
end
|
110
|
+
|
111
|
+
# Write output to file or stdout
|
112
|
+
if options[:output]
|
113
|
+
File.write(options[:output], output)
|
114
|
+
puts "Report written to #{options[:output]}"
|
115
|
+
else
|
116
|
+
puts output
|
117
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RSpecLetAnalyzer
|
4
|
+
# Base class for parser adapters
|
5
|
+
# Each adapter must implement: parse_file, visit_tree
|
6
|
+
class Adapter
|
7
|
+
class << self
|
8
|
+
def create(type)
|
9
|
+
case type
|
10
|
+
when 'prism', :prism
|
11
|
+
require_relative 'adapters/prism_adapter'
|
12
|
+
Adapters::PrismAdapter.new
|
13
|
+
when 'parser', :parser
|
14
|
+
require_relative 'adapters/parser_adapter'
|
15
|
+
Adapters::ParserAdapter.new
|
16
|
+
else
|
17
|
+
# :nocov:
|
18
|
+
raise ArgumentError, "Unknown parser type: #{type}"
|
19
|
+
# :nocov:
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
# :nocov:
|
25
|
+
# Parse a file and return an AST
|
26
|
+
# @param file_path [String] path to the file to parse
|
27
|
+
# @return [Object] AST representation
|
28
|
+
def parse_file(_file_path)
|
29
|
+
raise NotImplementedError, "#{self.class} must implement #parse_file"
|
30
|
+
end
|
31
|
+
|
32
|
+
# Parse code string and return an AST
|
33
|
+
# @param code [String] Ruby code to parse
|
34
|
+
# @return [Object] AST representation
|
35
|
+
def parse(_code)
|
36
|
+
raise NotImplementedError, "#{self.class} must implement #parse"
|
37
|
+
end
|
38
|
+
|
39
|
+
# Visit the AST and collect statistics
|
40
|
+
# @param ast [Object] the AST to visit
|
41
|
+
# @param visitor [Visitor] the visitor to use
|
42
|
+
# @return [void]
|
43
|
+
def visit_tree(_ast, _visitor)
|
44
|
+
raise NotImplementedError, "#{self.class} must implement #visit_tree"
|
45
|
+
end
|
46
|
+
# :nocov:
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
begin
|
4
|
+
require 'parser/current'
|
5
|
+
rescue LoadError
|
6
|
+
# :nocov:
|
7
|
+
raise LoadError, "The 'parser' gem is not installed. Install it with: gem install parser"
|
8
|
+
# :nocov:
|
9
|
+
end
|
10
|
+
|
11
|
+
module RSpecLetAnalyzer
|
12
|
+
module Adapters
|
13
|
+
class ParserAdapter < Adapter
|
14
|
+
def parse_file(file_path)
|
15
|
+
Parser::CurrentRuby.parse_file(file_path)
|
16
|
+
end
|
17
|
+
|
18
|
+
def parse(code)
|
19
|
+
Parser::CurrentRuby.parse(code)
|
20
|
+
end
|
21
|
+
|
22
|
+
def visit_tree(ast, visitor)
|
23
|
+
visitor.process(ast)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'prism'
|
4
|
+
|
5
|
+
module RSpecLetAnalyzer
|
6
|
+
module Adapters
|
7
|
+
class PrismAdapter < Adapter
|
8
|
+
def parse_file(file_path)
|
9
|
+
Prism.parse_file(file_path)
|
10
|
+
end
|
11
|
+
|
12
|
+
def parse(code)
|
13
|
+
Prism.parse(code)
|
14
|
+
end
|
15
|
+
|
16
|
+
def visit_tree(ast, visitor)
|
17
|
+
ast.value.accept(visitor)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,144 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RSpecLetAnalyzer
|
4
|
+
class Analyzer
|
5
|
+
attr_reader :results, :totals, :nesting_depth, :factory_stats
|
6
|
+
|
7
|
+
def initialize(progress_reporter:, nesting_depth:, track_factories:, parser_type: :prism)
|
8
|
+
@results = []
|
9
|
+
@nesting_depth = nesting_depth
|
10
|
+
@track_factories = track_factories
|
11
|
+
@totals = build_totals_hash
|
12
|
+
@factory_stats = Hash.new(0) if track_factories
|
13
|
+
@progress_reporter = progress_reporter
|
14
|
+
@adapter = Adapter.create(parser_type)
|
15
|
+
@parser_type = parser_type
|
16
|
+
end
|
17
|
+
|
18
|
+
def analyze(path = nil)
|
19
|
+
# Build pattern based on path
|
20
|
+
# If path looks like a glob pattern (contains * or ends with .rb), use it directly
|
21
|
+
# Otherwise treat it as a directory and append spec/**/*_spec.rb
|
22
|
+
pattern = if path.nil?
|
23
|
+
'spec/**/*_spec.rb'
|
24
|
+
elsif path.include?('*') || path.end_with?('.rb')
|
25
|
+
# It's a glob pattern or specific file, use as-is
|
26
|
+
path
|
27
|
+
else
|
28
|
+
# It's a directory, append spec pattern
|
29
|
+
File.join(path, 'spec/**/*_spec.rb')
|
30
|
+
end
|
31
|
+
|
32
|
+
spec_files = Dir.glob(pattern)
|
33
|
+
total_files = spec_files.size
|
34
|
+
|
35
|
+
@results = spec_files.each_with_index.map do |file, index|
|
36
|
+
@progress_reporter&.update(current: index + 1, total: total_files, file: file)
|
37
|
+
|
38
|
+
ast = @adapter.parse_file(file)
|
39
|
+
visitor = create_visitor
|
40
|
+
@adapter.visit_tree(ast, visitor)
|
41
|
+
|
42
|
+
# Aggregate factory usage
|
43
|
+
if @track_factories && visitor.factory_usage
|
44
|
+
visitor.factory_usage.each do |factory, count|
|
45
|
+
@factory_stats[factory] += count
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
file_result = {
|
50
|
+
file: file,
|
51
|
+
root_lets: visitor.root_lets,
|
52
|
+
it_blocks: visitor.total_its,
|
53
|
+
redefinitions: visitor.redefinitions,
|
54
|
+
before_creates: visitor.before_creates
|
55
|
+
}
|
56
|
+
|
57
|
+
if @nesting_depth
|
58
|
+
visitor.nesting_counts.each_with_index do |count, i|
|
59
|
+
file_result[:"nesting_#{i + 1}"] = count
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
# Calculate total score
|
64
|
+
file_result[:total_score] = calculate_file_total_score(file_result, visitor)
|
65
|
+
|
66
|
+
file_result
|
67
|
+
end
|
68
|
+
|
69
|
+
@progress_reporter&.clear
|
70
|
+
calculate_totals
|
71
|
+
end
|
72
|
+
|
73
|
+
def top(limit, sort_by)
|
74
|
+
sort_key = case sort_by
|
75
|
+
when 'root'
|
76
|
+
:root_lets
|
77
|
+
when 'it'
|
78
|
+
:it_blocks
|
79
|
+
when 'redef'
|
80
|
+
:redefinitions
|
81
|
+
when 'before'
|
82
|
+
:before_creates
|
83
|
+
when 'total'
|
84
|
+
:total_score
|
85
|
+
end
|
86
|
+
|
87
|
+
@results.sort_by { |r| -r[sort_key] }.take(limit)
|
88
|
+
end
|
89
|
+
|
90
|
+
def top_factories(limit)
|
91
|
+
return nil unless @track_factories
|
92
|
+
|
93
|
+
@factory_stats.sort_by { |_factory, count| -count }.take(limit)
|
94
|
+
end
|
95
|
+
|
96
|
+
private
|
97
|
+
|
98
|
+
def create_visitor
|
99
|
+
case @parser_type
|
100
|
+
when :prism, 'prism'
|
101
|
+
Visitors::PrismVisitor.new(@nesting_depth, @track_factories)
|
102
|
+
when :parser, 'parser'
|
103
|
+
Visitors::ParserVisitor.new(@nesting_depth, @track_factories)
|
104
|
+
else
|
105
|
+
raise ArgumentError, "Unknown parser type: #{@parser_type}"
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
def build_totals_hash
|
110
|
+
totals = { root_lets: 0, it_blocks: 0, redefinitions: 0, before_creates: 0, total_score: 0 }
|
111
|
+
@nesting_depth&.times do |i|
|
112
|
+
totals[:"nesting_#{i + 1}"] = 0
|
113
|
+
end
|
114
|
+
totals
|
115
|
+
end
|
116
|
+
|
117
|
+
def calculate_totals
|
118
|
+
@totals = @results.each_with_object(build_totals_hash) do |r, acc|
|
119
|
+
acc[:root_lets] += r[:root_lets]
|
120
|
+
acc[:it_blocks] += r[:it_blocks]
|
121
|
+
acc[:redefinitions] += r[:redefinitions]
|
122
|
+
acc[:before_creates] += r[:before_creates]
|
123
|
+
acc[:total_score] += r[:total_score]
|
124
|
+
|
125
|
+
@nesting_depth&.times do |i|
|
126
|
+
acc[:"nesting_#{i + 1}"] += r[:"nesting_#{i + 1}"]
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
def calculate_file_total_score(file_result, visitor)
|
132
|
+
total = file_result[:root_lets] + file_result[:it_blocks] +
|
133
|
+
file_result[:redefinitions] + file_result[:before_creates]
|
134
|
+
|
135
|
+
if @nesting_depth
|
136
|
+
visitor.nesting_counts.each do |count|
|
137
|
+
total += count
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
total
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
@@ -0,0 +1,128 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RSpecLetAnalyzer
|
4
|
+
module Formatters
|
5
|
+
class AsciiFormatter
|
6
|
+
def format(analyzer:, limit:, sort_by:)
|
7
|
+
sorted = analyzer.top(limit, sort_by)
|
8
|
+
totals = analyzer.totals
|
9
|
+
total_files = analyzer.results.size
|
10
|
+
nesting_depth = analyzer.nesting_depth
|
11
|
+
|
12
|
+
# Calculate column widths
|
13
|
+
base_width = 60
|
14
|
+
col_width = 10
|
15
|
+
|
16
|
+
# Build header
|
17
|
+
output = []
|
18
|
+
|
19
|
+
# Calculate total table width
|
20
|
+
num_cols = 5 + (nesting_depth || 0) # Root, it blocks, Redef, Before Creates, Total + nesting columns
|
21
|
+
separator = "+#{(['-' * base_width] + (['-' * col_width] * num_cols)).join('+')}+"
|
22
|
+
|
23
|
+
output << separator
|
24
|
+
|
25
|
+
# Header row
|
26
|
+
headers = ['File', 'Total', 'Root', 'it blocks']
|
27
|
+
if nesting_depth
|
28
|
+
(1...nesting_depth).each { |i| headers << "Nest #{i}" }
|
29
|
+
headers << "Nest #{nesting_depth}+"
|
30
|
+
end
|
31
|
+
headers << 'Redef'
|
32
|
+
headers << 'Bef Cr'
|
33
|
+
|
34
|
+
header_format = "| %-58s |#{' %8s |' * (headers.size - 1)}"
|
35
|
+
output << (header_format % headers)
|
36
|
+
output << separator
|
37
|
+
|
38
|
+
# Data rows
|
39
|
+
sorted.each do |result|
|
40
|
+
file_display = result[:file].size > 58 ? "...#{result[:file][-55..]}" : result[:file]
|
41
|
+
values = [file_display, result[:total_score], result[:root_lets], result[:it_blocks]]
|
42
|
+
|
43
|
+
nesting_depth&.times do |i|
|
44
|
+
values << result[:"nesting_#{i + 1}"]
|
45
|
+
end
|
46
|
+
|
47
|
+
values << result[:redefinitions]
|
48
|
+
values << result[:before_creates]
|
49
|
+
|
50
|
+
row_format = "| %-58s |#{' %8d |' * (values.size - 1)}"
|
51
|
+
output << (row_format % values)
|
52
|
+
end
|
53
|
+
|
54
|
+
# Totals row
|
55
|
+
output << separator
|
56
|
+
total_values = ["TOTAL (all #{total_files} files)", totals[:total_score], totals[:root_lets], totals[:it_blocks]]
|
57
|
+
|
58
|
+
nesting_depth&.times { total_values << '' }
|
59
|
+
|
60
|
+
total_values << totals[:redefinitions]
|
61
|
+
total_values << totals[:before_creates]
|
62
|
+
|
63
|
+
total_format = '| %-58s | %8d | %8d | %8d |'
|
64
|
+
total_format += (' %8s |' * nesting_depth) if nesting_depth
|
65
|
+
total_format += ' %8d | %8d |'
|
66
|
+
|
67
|
+
output << (total_format % total_values)
|
68
|
+
output << separator
|
69
|
+
|
70
|
+
formatted_output = output.join("\n")
|
71
|
+
|
72
|
+
# Add FactoryBot section if enabled
|
73
|
+
formatted_output += "\n\n#{format_factory_stats(analyzer, limit)}" if analyzer.factory_stats
|
74
|
+
|
75
|
+
formatted_output
|
76
|
+
end
|
77
|
+
|
78
|
+
private
|
79
|
+
|
80
|
+
def format_factory_stats(analyzer, limit)
|
81
|
+
top_factories = analyzer.top_factories(limit)
|
82
|
+
return '' if top_factories.nil? || top_factories.empty?
|
83
|
+
|
84
|
+
output = []
|
85
|
+
output << "FactoryBot Usage (Top #{limit})"
|
86
|
+
output << "+#{'-' * 40}+#{'-' * 15}+"
|
87
|
+
output << '| Factory | Usage Count |'
|
88
|
+
output << "+#{'-' * 40}+#{'-' * 15}+"
|
89
|
+
|
90
|
+
top_factories.each do |factory, count|
|
91
|
+
factory_display = factory.to_s.size > 38 ? "#{factory.to_s[0..34]}..." : factory.to_s
|
92
|
+
output << Kernel.format('| %-38s | %13d |', factory_display, count)
|
93
|
+
end
|
94
|
+
|
95
|
+
output << "+#{'-' * 40}+#{'-' * 15}+"
|
96
|
+
|
97
|
+
total_usage = analyzer.factory_stats.values.sum
|
98
|
+
total_unique = analyzer.factory_stats.size
|
99
|
+
output << ''
|
100
|
+
output << "Total unique factories: #{total_unique}"
|
101
|
+
output << "Total factory usage: #{total_usage}"
|
102
|
+
|
103
|
+
output.join("\n")
|
104
|
+
end
|
105
|
+
|
106
|
+
public
|
107
|
+
|
108
|
+
def add_runtime_metrics(output, metrics)
|
109
|
+
output += "\n\n"
|
110
|
+
output += "Runtime Metrics:\n"
|
111
|
+
output += " Analysis: #{format_duration(metrics[:analyze_time])}\n"
|
112
|
+
output += " Formatting: #{format_duration(metrics[:format_time])}\n"
|
113
|
+
output += " Total: #{format_duration(metrics[:total_time])}"
|
114
|
+
output
|
115
|
+
end
|
116
|
+
|
117
|
+
private
|
118
|
+
|
119
|
+
def format_duration(seconds)
|
120
|
+
if seconds < 1
|
121
|
+
"#{(seconds * 1000).round(2)}ms"
|
122
|
+
else
|
123
|
+
"#{seconds.round(2)}s"
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|