fastererer 0.12.0 → 1.0.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.
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Fastererer
6
+ class UnknownRuleError < StandardError; end
7
+
8
+ # Loads, validates, memoizes and looks up the rule catalog from the i18n locale.
9
+ module RuleCatalog
10
+ LOCALE_PATH = File.expand_path('../../config/locales/en.yml', __dir__)
11
+
12
+ class << self
13
+ def all
14
+ @all ||= load
15
+ end
16
+
17
+ def fetch(offense_name)
18
+ all.fetch(offense_name.to_s) do
19
+ raise UnknownRuleError, "Unknown rule: #{offense_name.inspect}"
20
+ end
21
+ end
22
+
23
+ # Guard-only: raises for an unknown rule, returns nil otherwise.
24
+ def validate!(offense_name)
25
+ fetch(offense_name)
26
+ nil
27
+ end
28
+
29
+ private
30
+
31
+ def load
32
+ rows = read_rows
33
+ rows.each { |key, row| validate_row!(key, row) }
34
+ rows.transform_values { |row| row.transform_values(&:freeze).freeze }.freeze
35
+ end
36
+
37
+ def read_rows
38
+ loaded = YAML.safe_load_file(LOCALE_PATH)
39
+ rows = loaded.is_a?(Hash) ? loaded.dig('en', 'fastererer', 'rules') : nil
40
+ return rows if rows.is_a?(Hash)
41
+
42
+ raise "Fastererer locale at #{LOCALE_PATH} is missing the 'en.fastererer.rules' section"
43
+ rescue Errno::ENOENT
44
+ raise "Fastererer locale file not found at #{LOCALE_PATH}"
45
+ rescue Psych::SyntaxError => e
46
+ raise "Fastererer locale at #{LOCALE_PATH} is not valid YAML: #{e.message}"
47
+ end
48
+
49
+ def validate_row!(key, row)
50
+ raise "Fastererer rule #{key} is malformed: #{row.inspect}" unless row.is_a?(Hash)
51
+
52
+ validate_url!(key, row['url'])
53
+ validate_description!(key, row['description'])
54
+ end
55
+
56
+ def validate_url!(key, url)
57
+ unless url.is_a?(String) && url.start_with?('https://')
58
+ raise "Fastererer rule #{key} has a non-https url: #{url.inspect}"
59
+ end
60
+
61
+ return if url.match?(/\A[[:print:]]+\z/)
62
+
63
+ raise "Fastererer rule #{key} has a non-printable url: #{url.inspect}"
64
+ end
65
+
66
+ def validate_description!(key, text)
67
+ return if text.is_a?(String) && text.match?(/\A[[:print:][:space:]]+\z/)
68
+
69
+ raise "Fastererer rule #{key} has a non-printable description: #{text.inspect}"
70
+ end
71
+
72
+ # Test-only hook: clears the memoized catalog so the YAML can be re-read.
73
+ def reset!
74
+ @all = nil
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fastererer
4
+ # Derives a rubocop-style "Performance/PascalCase" display name from a snake_case rule key.
5
+ module RuleName
6
+ DEPARTMENT = 'Performance'
7
+
8
+ def self.from(offense_name)
9
+ "#{DEPARTMENT}/#{pascal_case(offense_name)}"
10
+ end
11
+
12
+ def self.pascal_case(name)
13
+ name.to_s.split('_').filter_map { |part| part.capitalize unless part.empty? }.join
14
+ end
15
+ private_class_method :pascal_case
16
+ end
17
+ end
@@ -20,6 +20,7 @@ module Fastererer
20
20
  flatten: :check_flatten_offense,
21
21
  fetch: :check_fetch_offense,
22
22
  merge!: :check_merge_bang_offense,
23
+ update: :check_update_offense,
23
24
  last: :check_last_offense,
24
25
  include?: :check_range_include_offense
25
26
  }.freeze
@@ -33,7 +34,7 @@ module Fastererer
33
34
  end
34
35
 
35
36
  def method_call
36
- @method_call ||= MethodCall.new(element)
37
+ @method_call ||= MethodCall.build(element)
37
38
  end
38
39
 
39
40
  private
@@ -115,13 +116,16 @@ module Fastererer
115
116
  return unless method_call.arguments.one?
116
117
 
117
118
  first_argument = method_call.arguments.first
118
- return unless first_argument.type == :hash
119
- # each key and value is an item by itself.
120
- return unless first_argument.element.drop(1).count == 2
119
+ return unless first_argument.type == :hash && first_argument.element.elements.one?
121
120
 
122
121
  add_offense(:hash_merge_bang_vs_hash_brackets)
123
122
  end
124
123
 
124
+ # Hash#update is a merge! alias but heavily overloaded, so require a provably-Hash receiver
125
+ def check_update_offense
126
+ check_merge_bang_offense if method_call.receiver&.hash?
127
+ end
128
+
125
129
  def check_last_offense
126
130
  return unless method_call.receiver.is_a?(MethodCall)
127
131
  return unless method_call.receiver.name == :select
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'prism'
3
4
  require 'fastererer/method_definition'
4
5
  require 'fastererer/method_call'
5
6
  require 'fastererer/offense'
@@ -27,33 +28,15 @@ module Fastererer
27
28
  end
28
29
 
29
30
  def scan_block_call_offense
30
- traverse_tree(method_definition.body) do |element|
31
- next unless element.sexp_type == :call
32
-
33
- method_call = MethodCall.new(element)
34
-
35
- if method_call.receiver.is_a?(Fastererer::VariableReference) &&
36
- method_call.receiver.name == method_definition.block_argument_name &&
37
- method_call.method_name == :call
38
-
39
- add_offense(:proc_call_vs_yield) && return
40
- end
41
- end
31
+ visitor = ProcCallVisitor.new(method_definition.block_argument_name)
32
+ method_definition.body.each { |node| node.accept(visitor) }
33
+ add_offense(:proc_call_vs_yield) if visitor.proc_call_found?
42
34
  end
43
35
 
44
36
  def method_definition
45
37
  @method_definition ||= MethodDefinition.new(element)
46
38
  end
47
39
 
48
- def traverse_tree(sexp_tree, &block)
49
- sexp_tree.each do |element|
50
- next unless element.is_a?(Array)
51
-
52
- yield element
53
- traverse_tree(element, &block)
54
- end
55
- end
56
-
57
40
  def scan_getter_and_setter_offense
58
41
  method_definition.setter? ? scan_setter_offense : scan_getter_offense
59
42
  end
@@ -69,12 +52,12 @@ module Fastererer
69
52
  end
70
53
 
71
54
  def trivial_setter?(first_argument)
72
- body_first = method_definition.body.first
73
- expected_ivar = "@#{method_definition.name.to_s.delete_suffix('=')}"
55
+ body_node = method_definition.body.first
56
+ return false unless body_node.is_a?(Prism::InstanceVariableWriteNode)
57
+ return false unless body_node.value.is_a?(Prism::LocalVariableReadNode)
74
58
 
75
- body_first.sexp_type == :iasgn &&
76
- body_first[1].to_s == expected_ivar &&
77
- body_first[2][1] == first_argument.name
59
+ expected_ivar = :"@#{method_definition.name.to_s.delete_suffix('=')}"
60
+ body_node.name == expected_ivar && body_node.value.name == first_argument.name
78
61
  end
79
62
 
80
63
  def scan_getter_offense
@@ -85,10 +68,55 @@ module Fastererer
85
68
  end
86
69
 
87
70
  def trivial_getter?
88
- body_first = method_definition.body.first
71
+ body_node = method_definition.body.first
72
+ expected_ivar = :"@#{method_definition.name}"
89
73
 
90
- body_first.sexp_type == :ivar &&
91
- body_first[1].to_s == "@#{method_definition.name}"
74
+ body_node.is_a?(Prism::InstanceVariableReadNode) && body_node.name == expected_ivar
92
75
  end
93
76
  end
77
+
78
+ # Finds `block_name.call` in a method body, without descending into nested def/class/module scopes
79
+ # where the block parameter is no longer in scope
80
+ class ProcCallVisitor < Prism::Visitor
81
+ def initialize(block_name)
82
+ super()
83
+ @block_name = block_name
84
+ @proc_call_found = false
85
+ end
86
+
87
+ def proc_call_found?
88
+ @proc_call_found
89
+ end
90
+
91
+ def visit_call_node(node)
92
+ @proc_call_found = true if proc_call?(node)
93
+ super
94
+ end
95
+
96
+ # No super on purpose: these open a new scope where the block param is unbound, so we stop
97
+ # descending rather than attribute their inner `name.call`s to the enclosing method.
98
+ def visit_def_node(_node)
99
+ end
100
+
101
+ def visit_class_node(_node)
102
+ end
103
+
104
+ def visit_module_node(_node)
105
+ end
106
+
107
+ def visit_singleton_class_node(_node)
108
+ end
109
+
110
+ private
111
+
112
+ def proc_call?(node)
113
+ return false if @block_name.nil? # an anonymous block param (&) is never invoked by name
114
+
115
+ node.receiver.is_a?(Prism::LocalVariableReadNode) &&
116
+ node.receiver.name == @block_name &&
117
+ node.name == :call
118
+ end
119
+ end
120
+
121
+ private_constant :ProcCallVisitor
94
122
  end
@@ -14,12 +14,8 @@ module Fastererer
14
14
 
15
15
  private
16
16
 
17
- def add_offense(offense_name, element_line_number = element.line)
18
- self.offense = Fastererer::Offense.new(offense_name, element_line_number)
19
- end
20
-
21
- def check_offense
22
- raise NotImplementedError
17
+ def add_offense(offense_name)
18
+ self.offense = Fastererer::Offense.new(offense_name, element.location.start_line)
23
19
  end
24
20
  end
25
21
  end
@@ -18,7 +18,7 @@ module Fastererer
18
18
  private
19
19
 
20
20
  def check_offense
21
- return unless rescue_call.rescue_classes.include? :NoMethodError
21
+ return unless rescue_call.rescue_classes.include?(:NoMethodError)
22
22
 
23
23
  add_offense(:rescue_vs_respond_to)
24
24
  end
@@ -1,16 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'prism'
3
4
  require 'fastererer/method_call'
4
5
 
5
6
  module Fastererer
6
- # Includer must respond to #method_call (returning a MethodCall) and #add_offense(symbol).
7
+ # Includer must respond to #method_call (returning a MethodCall) and #add_offense(symbol)
8
+ # (the latter via the Offensive mixin).
7
9
  module SymbolToProcCheck
8
10
  private
9
11
 
10
12
  def check_symbol_to_proc
11
13
  return unless symbol_to_proc_candidate?
12
14
 
13
- body_call = MethodCall.new(method_call.block_body)
15
+ body_call = MethodCall.build(method_call.block_body.first)
14
16
  return unless symbol_to_proc_body?(body_call)
15
17
 
16
18
  add_offense(:block_vs_symbol_to_proc)
@@ -18,12 +20,18 @@ module Fastererer
18
20
 
19
21
  def symbol_to_proc_candidate?
20
22
  method_call.block_argument_names.one? &&
21
- !method_call.block_body.nil? &&
22
- method_call.block_body.sexp_type == :call &&
23
+ single_call_body? &&
23
24
  method_call.arguments.none? &&
24
25
  !method_call.lambda_literal?
25
26
  end
26
27
 
28
+ # A safe-nav body (foo&.bar) is excluded: arr.map(&:bar) raises on a nil element, so the rewrite
29
+ # would not preserve behavior
30
+ def single_call_body?
31
+ body = method_call.block_body
32
+ body&.size == 1 && body.first.is_a?(Prism::CallNode) && !body.first.safe_navigation?
33
+ end
34
+
27
35
  def symbol_to_proc_body?(body_call)
28
36
  body_call.arguments.none? &&
29
37
  !body_call.block? &&
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fastererer
4
- VERSION = '0.12.0'
4
+ VERSION = '1.0.0'
5
5
  end