fastererer 0.12.0 → 1.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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +75 -2
  3. data/README.md +97 -8
  4. data/config/locales/en.yml +60 -0
  5. data/lib/fastererer/analyzer.rb +28 -53
  6. data/lib/fastererer/cli.rb +21 -4
  7. data/lib/fastererer/explanation.rb +44 -0
  8. data/lib/fastererer/file_traverser.rb +43 -112
  9. data/lib/fastererer/finding.rb +18 -0
  10. data/lib/fastererer/formatters/base.rb +32 -0
  11. data/lib/fastererer/formatters/json_formatter.rb +48 -0
  12. data/lib/fastererer/formatters/rdjsonl_formatter.rb +34 -0
  13. data/lib/fastererer/formatters/text_formatter.rb +63 -0
  14. data/lib/fastererer/formatters/text_statistics.rb +45 -0
  15. data/lib/fastererer/formatters.rb +24 -0
  16. data/lib/fastererer/method_call.rb +139 -75
  17. data/lib/fastererer/method_definition.rb +61 -48
  18. data/lib/fastererer/offense.rb +5 -63
  19. data/lib/fastererer/offense_collector.rb +1 -1
  20. data/lib/fastererer/painter.rb +2 -1
  21. data/lib/fastererer/parser.rb +8 -4
  22. data/lib/fastererer/receiver.rb +14 -0
  23. data/lib/fastererer/report.rb +10 -0
  24. data/lib/fastererer/rescue_call.rb +10 -12
  25. data/lib/fastererer/rule_catalog.rb +83 -0
  26. data/lib/fastererer/rule_name.rb +17 -0
  27. data/lib/fastererer/scanners/for_loop_scanner.rb +23 -0
  28. data/lib/fastererer/scanners/method_call_scanner.rb +13 -17
  29. data/lib/fastererer/scanners/method_definition_scanner.rb +57 -29
  30. data/lib/fastererer/scanners/offensive.rb +2 -6
  31. data/lib/fastererer/scanners/rescue_call_scanner.rb +1 -1
  32. data/lib/fastererer/scanners/symbol_to_proc_check.rb +21 -8
  33. data/lib/fastererer/version.rb +1 -1
  34. metadata +19 -11
  35. data/.fastererer.yml +0 -27
  36. data/.ruby-version +0 -1
  37. data/CODE_OF_CONDUCT.md +0 -18
  38. data/CONTRIBUTING.md +0 -63
  39. data/Rakefile +0 -12
  40. data/SECURITY.md +0 -20
@@ -1,141 +1,205 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'prism'
4
+ require 'fastererer/receiver'
5
+
3
6
  module Fastererer
4
7
  class MethodCall
5
- attr_reader :element, :receiver, :method_name, :arguments, :block_body, :block_argument_names
8
+ include Receiver
6
9
 
7
- alias name method_name
10
+ attr_reader :element
8
11
 
9
- def initialize(element)
10
- @element = element
11
- set_call_element
12
- set_receiver
13
- set_method_name
14
- set_arguments
15
- set_block_presence
16
- set_block_body
17
- set_block_argument_names
12
+ def self.build(node)
13
+ node.is_a?(Prism::LambdaNode) ? LambdaCall.new(node) : new(node)
18
14
  end
19
15
 
20
- def block?
21
- @block_present || false
16
+ def initialize(node)
17
+ @element = node
22
18
  end
23
19
 
24
- def receiver_element
25
- call_element[1]
20
+ def receiver
21
+ @receiver ||= ReceiverFactory.build(element.receiver)
26
22
  end
27
23
 
28
- def arguments_element
29
- call_element.sexp_body(3) || []
24
+ def method_name
25
+ element.name
30
26
  end
31
27
 
32
- def lambda_literal?
33
- call_element.sexp_type == :lambda
28
+ def name
29
+ method_name
34
30
  end
35
31
 
36
- private
32
+ def call_name = method_name
37
33
 
38
- attr_reader :call_element
34
+ def arguments
35
+ @arguments ||= argument_nodes.map { |argument| Argument.new(argument) }
36
+ end
39
37
 
40
- # TODO: explanation
41
- def set_call_element
42
- @call_element = case element.sexp_type
43
- when :call
44
- @element
45
- when :iter
46
- @element[1]
47
- end
38
+ def block_body
39
+ @block_body ||= block_statements
48
40
  end
49
41
 
50
- def set_receiver
51
- @receiver = ReceiverFactory.new(receiver_element)
42
+ def block_argument_names
43
+ @block_argument_names ||= positional_block_parameter_names
52
44
  end
53
45
 
54
- def set_method_name
55
- @method_name = call_element[2]
46
+ def block?
47
+ !block_node.nil?
56
48
  end
57
49
 
58
- def set_arguments
59
- @arguments = arguments_element.map { |argument| Argument.new(argument) }
50
+ def lambda_literal?
51
+ false
60
52
  end
61
53
 
62
- def set_block_presence
63
- if element.sexp_type == :iter || (arguments.last && arguments.last.type == :block_pass)
64
- @block_present = true
65
- end
54
+ # Provably a Hash: bare `Hash.new`/`Hash[...]` (any args — every `Hash[...]` yields a Hash),
55
+ # not qualified `Foo::Hash`. The is_a?(ConstantReadNode) check is load-bearing:
56
+ # ConstantPathNode#name is :Hash too, so the type (not the name) excludes qualified constants.
57
+ # unwrap_parentheses lets `(Hash).new` match, as the receiver factory normalizes its input.
58
+ def hash?
59
+ return false unless %i[new []].include?(method_name)
60
+
61
+ constant = ReceiverFactory.unwrap_parentheses(element.receiver)
62
+ constant.is_a?(Prism::ConstantReadNode) && constant.name == :Hash
63
+ end
64
+
65
+ private
66
+
67
+ def block_node
68
+ @block_node ||= element.block
66
69
  end
67
70
 
68
- def set_block_body
69
- @block_body = element[3] if block?
71
+ def argument_nodes
72
+ element.arguments&.arguments || []
70
73
  end
71
74
 
72
- # TODO: write specs for lambdas and procs
73
- def set_block_argument_names
74
- @block_argument_names = if block? && element[2].is_a?(Sexp) # HACK: for lambdas
75
- element[2].drop(1).map { |argument| argument }
76
- end || []
75
+ def block_statements
76
+ return unless block_node.is_a?(Prism::BlockNode)
77
+
78
+ body = block_node.body
79
+ body.body if body.is_a?(Prism::StatementsNode)
77
80
  end
81
+
82
+ # Only single positional params convert to &:sym, so a splat or keyword param must not count
83
+ # toward the single-name check
84
+ def positional_block_parameter_names
85
+ return [] unless block_node.is_a?(Prism::BlockNode)
86
+
87
+ params = block_node.parameters
88
+ return [] unless params.is_a?(Prism::BlockParametersNode) && params.parameters
89
+
90
+ positional = params.parameters.requireds + params.parameters.optionals
91
+ positional.map { |param| param.is_a?(Prism::MultiTargetNode) ? nil : param.name }
92
+ end
93
+ end
94
+
95
+ # A `-> {}` lambda literal. Checks ignore it, so its call attributes are inert
96
+ class LambdaCall < MethodCall
97
+ def receiver = nil
98
+ def method_name = :lambda
99
+ def arguments = []
100
+ def block_body = nil
101
+ def block_argument_names = []
102
+ def block? = true
103
+ def lambda_literal? = true
78
104
  end
79
105
 
80
- # For now, used for determening if the
81
- # receiver is a reference or a method call.
82
- class ReceiverFactory
83
- def self.new(receiver_info)
84
- return unless receiver_info.is_a?(Sexp)
85
-
86
- case receiver_info.sexp_type
87
- when :lvar
88
- VariableReference.new(receiver_info)
89
- when :call, :iter
90
- MethodCall.new(receiver_info)
91
- when :array, :dot2, :dot3, :lit
92
- Primitive.new(receiver_info)
106
+ module ReceiverFactory
107
+ PRIMITIVE_NODE_TYPES = [Prism::ArrayNode, Prism::RangeNode, Prism::IntegerNode,
108
+ Prism::FloatNode, Prism::SymbolNode, Prism::StringNode,
109
+ Prism::HashNode].freeze
110
+
111
+ def self.build(node)
112
+ return unless node
113
+
114
+ node = unwrap_parentheses(node)
115
+ case node
116
+ # A ConstantPathNode's #name is the unqualified tail only (Foo::Bar => :Bar), which is all
117
+ # the symbol-to-proc check needs; revisit if a check ever needs the fully-qualified path.
118
+ when Prism::LocalVariableReadNode, Prism::ConstantReadNode,
119
+ Prism::ConstantPathNode then VariableReference.new(node)
120
+ when Prism::CallNode then MethodCall.build(node)
121
+ when *PRIMITIVE_NODE_TYPES then Primitive.new(node)
122
+ end
123
+ end
124
+
125
+ # Peels a single parenthesis level, so `((x))` stays wrapped — matches how callers normalize
126
+ def self.unwrap_parentheses(node)
127
+ if node.is_a?(Prism::ParenthesesNode) &&
128
+ node.body.is_a?(Prism::StatementsNode) &&
129
+ node.body.body.size == 1
130
+ node.body.body.first
131
+ else
132
+ node
93
133
  end
94
134
  end
95
135
  end
96
136
 
97
137
  class VariableReference
138
+ include Receiver
139
+
98
140
  attr_reader :name
99
141
 
100
- def initialize(reference_info)
101
- @reference_info = reference_info
102
- @name = reference_info[1]
142
+ def initialize(node)
143
+ @name = node.name
103
144
  end
104
145
  end
105
146
 
106
147
  class Argument
107
148
  attr_reader :element
108
149
 
109
- def initialize(element)
110
- @element = element
111
- end
150
+ def initialize(node)
151
+ @element = node
152
+ end
153
+
154
+ TYPE_BY_NODE_CLASS = {
155
+ Prism::KeywordHashNode => :hash,
156
+ Prism::HashNode => :hash,
157
+ Prism::StringNode => :string,
158
+ Prism::IntegerNode => :integer,
159
+ Prism::SymbolNode => :symbol,
160
+ Prism::FloatNode => :float,
161
+ Prism::RegularExpressionNode => :regexp,
162
+ Prism::NilNode => :nil,
163
+ Prism::TrueNode => :boolean,
164
+ Prism::FalseNode => :boolean,
165
+ Prism::LocalVariableReadNode => :variable,
166
+ Prism::CallNode => :method_call
167
+ }.freeze
112
168
 
113
169
  def type
114
- @type ||= @element[0]
170
+ @type ||= TYPE_BY_NODE_CLASS[element.class] || :unknown
115
171
  end
116
172
 
117
173
  def value
118
- @value ||= @element[1]
174
+ return @value if defined?(@value)
175
+
176
+ @value = case element
177
+ when Prism::StringNode then element.unescaped
178
+ when Prism::IntegerNode, Prism::FloatNode then element.value
179
+ when Prism::SymbolNode then element.unescaped.to_sym
180
+ end
119
181
  end
120
182
  end
121
183
 
122
184
  class Primitive
123
- attr_reader :element
185
+ include Receiver
124
186
 
125
- def initialize(element)
126
- @element = element
127
- end
187
+ attr_reader :element
128
188
 
129
- def type
130
- @type ||= @element[0]
189
+ def initialize(node)
190
+ @element = node
131
191
  end
132
192
 
133
193
  def range?
134
- %i[dot2 dot3 lit].include?(type)
194
+ element.is_a?(Prism::RangeNode)
135
195
  end
136
196
 
137
197
  def array?
138
- type == :array
198
+ element.is_a?(Prism::ArrayNode)
199
+ end
200
+
201
+ def hash?
202
+ element.is_a?(Prism::HashNode)
139
203
  end
140
204
  end
141
205
  end
@@ -1,22 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'prism'
4
+
3
5
  module Fastererer
4
6
  class MethodDefinition
5
- # Exposed for testing purposes.
6
- attr_reader :element, :method_name, :block_argument_name, :body, :arguments
7
+ PARAMETER_CATEGORIES = %i[requireds optionals rest posts keywords keyword_rest block].freeze
8
+ private_constant :PARAMETER_CATEGORIES
9
+
10
+ attr_reader :element
7
11
 
12
+ def initialize(node)
13
+ @element = node
14
+ end
15
+
16
+ def method_name
17
+ element.name
18
+ end
8
19
  alias name method_name
9
20
 
10
- def initialize(element)
11
- @element = element # Ripper element
12
- set_method_name
13
- set_body
14
- set_arguments
15
- set_block_argument_name
21
+ def body
22
+ @body ||= statement_body
23
+ end
24
+
25
+ def arguments
26
+ @arguments ||= parameter_nodes.map { |node| MethodDefinitionArgument.new(node) }
27
+ end
28
+
29
+ def block_argument_name
30
+ block_parameter&.name
16
31
  end
17
32
 
18
33
  def block?
19
- !!@block_argument_name
34
+ !block_parameter.nil?
20
35
  end
21
36
 
22
37
  def setter?
@@ -25,70 +40,68 @@ module Fastererer
25
40
 
26
41
  private
27
42
 
28
- def arguments_element
29
- element[2].drop(1) || []
43
+ def statement_body
44
+ body_node = element.body
45
+ # A rescue/ensure body is a BeginNode wrapping the statements; #statements may be nil
46
+ body_node = body_node.statements if body_node.is_a?(Prism::BeginNode)
47
+ body_node.is_a?(Prism::StatementsNode) ? body_node.body : []
30
48
  end
31
49
 
32
- def set_method_name
33
- @method_name = @element[1]
34
- end
50
+ def parameter_nodes
51
+ params = element.parameters
52
+ return [] unless params
35
53
 
36
- def set_arguments
37
- @arguments = arguments_element.map do |argument_element|
38
- MethodDefinitionArgument.new(argument_element)
39
- end
54
+ PARAMETER_CATEGORIES.flat_map { |category| Array(params.public_send(category)) }
40
55
  end
41
56
 
42
- def set_body
43
- @body = @element[3..]
57
+ def block_parameter
58
+ params = element.parameters
59
+ return unless params&.block.is_a?(Prism::BlockParameterNode)
60
+
61
+ params.block
44
62
  end
63
+ end
45
64
 
46
- def set_block_argument_name
47
- return unless last_argument_element.to_s.start_with?('&')
65
+ class MethodDefinitionArgument
66
+ attr_reader :element
48
67
 
49
- @block_argument_name = last_argument_element.to_s.delete_prefix('&').to_sym
68
+ def initialize(node)
69
+ @element = node
50
70
  end
51
71
 
52
- def last_argument_element
53
- arguments_element.last
72
+ # Prism param nodes expose #name (nil when anonymous); MultiTargetNode has none
73
+ def name
74
+ element.respond_to?(:name) ? element.name : nil
54
75
  end
55
- end
56
76
 
57
- class MethodDefinitionArgument
58
- attr_reader :element, :name, :type
59
-
60
- def initialize(element)
61
- @element = element
62
- set_name
63
- set_argument_type
77
+ def type
78
+ @type ||= argument_type
64
79
  end
65
80
 
66
81
  def regular_argument?
67
- @type == :regular_argument
82
+ type == :regular_argument
68
83
  end
69
84
 
70
85
  def default_argument?
71
- @type == :default_argument
86
+ type == :default_argument
72
87
  end
73
88
 
74
89
  def keyword_argument?
75
- @type == :keyword_argument
90
+ type == :keyword_argument
76
91
  end
77
92
 
78
93
  private
79
94
 
80
- def set_name
81
- @name = element.is_a?(Symbol) ? element : element[1]
82
- end
83
-
84
- def set_argument_type
85
- @type = if element.is_a?(Symbol)
86
- :regular_argument
87
- elsif element.is_a?(Sexp) && element.sexp_type == :lasgn
88
- :default_argument
89
- elsif element.is_a?(Sexp) && element.sexp_type == :kwarg
90
- :keyword_argument
91
- end
95
+ def argument_type
96
+ case element
97
+ when Prism::RequiredParameterNode
98
+ :regular_argument
99
+ when Prism::OptionalParameterNode
100
+ :default_argument
101
+ when Prism::RequiredKeywordParameterNode,
102
+ Prism::OptionalKeywordParameterNode
103
+ :keyword_argument
104
+ end
92
105
  end
93
106
  end
94
107
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'explanation'
4
+ require_relative 'rule_catalog'
5
+
3
6
  module Fastererer
4
7
  class Offense
5
8
  attr_reader :offense_name, :line_number
@@ -10,72 +13,11 @@ module Fastererer
10
13
  def initialize(offense_name, line_number)
11
14
  @offense_name = offense_name
12
15
  @line_number = line_number
13
- explanation # Set explanation right away.
16
+ RuleCatalog.validate!(offense_name)
14
17
  end
15
18
 
16
19
  def explanation
17
- @explanation ||= EXPLANATIONS.fetch(offense_name)
20
+ @explanation ||= Explanation.for(offense_name)
18
21
  end
19
-
20
- EXPLANATIONS = {
21
- rescue_vs_respond_to:
22
- 'Don\'t rescue NoMethodError, rather check with respond_to?',
23
-
24
- module_eval:
25
- 'Using module_eval is slower than define_method',
26
-
27
- shuffle_first_vs_sample:
28
- 'Array#shuffle.first is slower than Array#sample',
29
-
30
- for_loop_vs_each:
31
- 'For loop is slower than using each',
32
-
33
- each_with_index_vs_while:
34
- 'Using each_with_index is slower than while loop',
35
-
36
- map_flatten_vs_flat_map:
37
- 'Array#map.flatten(1) is slower than Array#flat_map',
38
-
39
- reverse_each_vs_reverse_each:
40
- 'Array#reverse.each is slower than Array#reverse_each',
41
-
42
- select_first_vs_detect:
43
- 'Array#select.first is slower than Array#detect',
44
-
45
- sort_vs_sort_by:
46
- 'Enumerable#sort is slower than Enumerable#sort_by',
47
-
48
- fetch_with_argument_vs_block:
49
- 'Hash#fetch with second argument is slower than Hash#fetch with block',
50
-
51
- keys_each_vs_each_key:
52
- 'Hash#keys.each is slower than Hash#each_key. N.B. Hash#each_key cannot be used if ' \
53
- 'the hash is modified during the each block',
54
-
55
- hash_merge_bang_vs_hash_brackets:
56
- 'Hash#merge! with one argument is slower than Hash#[]',
57
-
58
- block_vs_symbol_to_proc:
59
- 'Calling argumentless methods within blocks is slower than using symbol to proc',
60
-
61
- proc_call_vs_yield:
62
- 'Calling blocks with call is slower than yielding',
63
-
64
- gsub_vs_tr:
65
- 'Using tr is faster than gsub when replacing a single character in a string with ' \
66
- 'another single character',
67
-
68
- select_last_vs_reverse_detect:
69
- 'Array#select.last is slower than Array#reverse.detect',
70
-
71
- getter_vs_attr_reader:
72
- 'Use attr_reader for reading ivars',
73
-
74
- setter_vs_attr_writer:
75
- 'Use attr_writer for writing to ivars',
76
-
77
- include_vs_cover_on_range:
78
- 'Use #cover? instead of #include? on ranges'
79
- }.freeze
80
22
  end
81
23
  end
@@ -14,6 +14,6 @@ module Fastererer
14
14
  @offenses.select { |offense| offense.name == offense_name }
15
15
  end
16
16
 
17
- def_delegators :@offenses, :push, :any?, :each, :group_by, :count
17
+ def_delegators :@offenses, :push, :any?, :each, :group_by, :count, :reject
18
18
  end
19
19
  end
@@ -4,7 +4,8 @@ module Fastererer
4
4
  module Painter
5
5
  COLOR_CODES = {
6
6
  red: 31,
7
- green: 32
7
+ green: 32,
8
+ magenta: 35
8
9
  }.freeze
9
10
 
10
11
  @disabled = false
@@ -1,13 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'ruby_parser'
3
+ require 'prism'
4
4
 
5
5
  module Fastererer
6
- class Parser
7
- PARSER_CLASS = RubyParser
6
+ class ParseError < StandardError; end
8
7
 
8
+ # Single seam around Prism.parse: returns the AST root or raises ParseError
9
+ class Parser
9
10
  def self.parse(ruby_code)
10
- PARSER_CLASS.for_current_ruby.parse(ruby_code)
11
+ result = Prism.parse(ruby_code)
12
+ raise ParseError, result.errors.map(&:message).join('; ') if result.failure?
13
+
14
+ result.value
11
15
  end
12
16
  end
13
17
  end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fastererer
4
+ # Query surface every receiver answers; includers override only what is true for their type
5
+ module Receiver
6
+ def call_name = nil
7
+ def name = nil
8
+ def block? = false
9
+ def arguments = []
10
+ def range? = false
11
+ def array? = false
12
+ def hash? = false
13
+ end
14
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fastererer
4
+ # Findings arrive already filtered by ignored speedups; formatters must not filter again
5
+ Report = Data.define(:findings, :files_inspected_count, :unparsable_files, :missing_path) do
6
+ def offenses_detected_count
7
+ findings.count
8
+ end
9
+ end
10
+ end
@@ -1,22 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'prism'
4
+
3
5
  module Fastererer
4
6
  class RescueCall
5
- attr_reader :element, :rescue_classes
7
+ attr_reader :element
6
8
 
7
- def initialize(element)
8
- @element = element
9
- @rescue_classes = []
10
- set_rescue_classes
9
+ def initialize(node)
10
+ @element = node
11
11
  end
12
12
 
13
- private
14
-
15
- def set_rescue_classes
16
- return if element[1].sexp_type != :array
17
-
18
- @rescue_classes = element[1].drop(1).filter_map do |rescue_reference|
19
- rescue_reference[1] if rescue_reference.sexp_type == :const
13
+ # Only unqualified constants: a namespaced `Foo::NoMethodError` is not the core NoMethodError,
14
+ # so it must not match the rescue_vs_respond_to check.
15
+ def rescue_classes
16
+ @rescue_classes ||= element.exceptions.filter_map do |exception|
17
+ exception.name if exception.is_a?(Prism::ConstantReadNode)
20
18
  end
21
19
  end
22
20
  end