fastererer 0.12.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.
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+ require 'English'
5
+
6
+ require_relative 'analyzer'
7
+ require_relative 'config'
8
+ require_relative 'painter'
9
+
10
+ module Fastererer
11
+ class FileTraverser
12
+ CONFIG_FILE_NAME = Config::FILE_NAME
13
+ SPEEDUPS_KEY = Config::SPEEDUPS_KEY
14
+ EXCLUDE_PATHS_KEY = Config::EXCLUDE_PATHS_KEY
15
+
16
+ attr_reader :config, :parse_error_paths
17
+ attr_accessor :offenses_total_count
18
+
19
+ def initialize(path)
20
+ @path = Pathname(path || '.')
21
+ @parse_error_paths = []
22
+ @config = Config.new
23
+ @offenses_total_count = 0
24
+ end
25
+
26
+ def traverse
27
+ traverse_files
28
+ output_parse_errors
29
+ output_statistics
30
+ end
31
+
32
+ def config_file
33
+ config.file
34
+ end
35
+
36
+ def offenses_found?
37
+ !!offenses_found
38
+ end
39
+
40
+ def scannable_files
41
+ all_files - ignored_files
42
+ end
43
+
44
+ private
45
+
46
+ attr_accessor :offenses_found
47
+
48
+ def traverse_files
49
+ if @path.exist?
50
+ scannable_files.each { |ruby_file| scan_file(ruby_file) }
51
+ else
52
+ output_unable_to_find_file(@path)
53
+ end
54
+ end
55
+
56
+ def scan_file(path)
57
+ analyzer = Analyzer.new(path)
58
+ analyzer.scan
59
+ rescue RubyParser::SyntaxError, Racc::ParseError, Timeout::Error => e
60
+ parse_error_paths.push(ErrorData.new(path, e.class, e.message).to_s)
61
+ else
62
+ if offenses_grouped_by_type(analyzer).any?
63
+ output(analyzer)
64
+ self.offenses_found = true
65
+ self.offenses_total_count += analyzer.errors.count
66
+ end
67
+ end
68
+
69
+ def all_files
70
+ if @path.directory?
71
+ Dir[File.join(@path, '**', '*.rb')].map do |ruby_file_path|
72
+ Pathname(ruby_file_path).relative_path_from(root_dir).to_s
73
+ end
74
+ else
75
+ [@path.to_s]
76
+ end
77
+ end
78
+
79
+ def root_dir
80
+ @root_dir ||= Pathname('.')
81
+ end
82
+
83
+ def output(analyzer)
84
+ offenses_grouped_by_type(analyzer).each do |error_group_name, error_occurences|
85
+ error_occurences.map(&:line_number).each do |line|
86
+ file_and_line = "#{analyzer.file_path}:#{line}"
87
+ print "#{Painter.paint(file_and_line, :red)} #{Fastererer::Offense::EXPLANATIONS[error_group_name]}.\n"
88
+ end
89
+ end
90
+
91
+ print "\n"
92
+ end
93
+
94
+ def offenses_grouped_by_type(analyzer)
95
+ analyzer.errors.group_by(&:name).delete_if do |offense_name, _|
96
+ ignored_speedups.include?(offense_name)
97
+ end
98
+ end
99
+
100
+ def output_parse_errors
101
+ return if parse_error_paths.none?
102
+
103
+ puts 'Fastererer was unable to process some files because the'
104
+ puts 'internal parser is not able to read some characters or'
105
+ puts 'has timed out. Unprocessable files were:'
106
+ puts '-----------------------------------------------------'
107
+ puts parse_error_paths
108
+ puts
109
+ end
110
+
111
+ def output_statistics
112
+ puts Statistics.new(self)
113
+ end
114
+
115
+ def output_unable_to_find_file(path)
116
+ puts Painter.paint("No such file or directory - #{path}", :red)
117
+ end
118
+
119
+ def ignored_speedups
120
+ config.ignored_speedups
121
+ end
122
+
123
+ def ignored_files
124
+ config.ignored_files
125
+ end
126
+
127
+ def nil_config_file
128
+ config.nil_file
129
+ end
130
+ end
131
+
132
+ ErrorData = Struct.new(:file_path, :error_class, :error_message) do
133
+ def to_s
134
+ "#{file_path} - #{error_class} - #{error_message}"
135
+ end
136
+ end
137
+
138
+ class Statistics
139
+ def initialize(traverser)
140
+ @files_inspected_count = traverser.scannable_files.count
141
+ @offenses_found_count = traverser.offenses_total_count
142
+ @unparsable_files_count = traverser.parse_error_paths.count
143
+ end
144
+
145
+ def to_s
146
+ [
147
+ inspected_files_output,
148
+ offenses_found_output,
149
+ unparsable_files_output
150
+ ].compact.join(', ')
151
+ end
152
+
153
+ def inspected_files_output
154
+ Painter.paint(
155
+ "#{@files_inspected_count} #{pluralize(@files_inspected_count, 'file')} inspected", :green
156
+ )
157
+ end
158
+
159
+ def offenses_found_output
160
+ color = @offenses_found_count.zero? ? :green : :red
161
+
162
+ Painter.paint(
163
+ "#{@offenses_found_count} #{pluralize(@offenses_found_count, 'offense')} detected", color
164
+ )
165
+ end
166
+
167
+ def unparsable_files_output
168
+ return if @unparsable_files_count.zero?
169
+
170
+ Painter.paint(
171
+ "#{@unparsable_files_count} unparsable #{pluralize(@unparsable_files_count, 'file')} found",
172
+ :red
173
+ )
174
+ end
175
+
176
+ def pluralize(count, singular, plural = nil)
177
+ if count == 1
178
+ singular.to_s
179
+ elsif plural
180
+ plural.to_s
181
+ else
182
+ "#{singular}s"
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fastererer
4
+ class MethodCall
5
+ attr_reader :element, :receiver, :method_name, :arguments, :block_body, :block_argument_names
6
+
7
+ alias name method_name
8
+
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
18
+ end
19
+
20
+ def block?
21
+ @block_present || false
22
+ end
23
+
24
+ def receiver_element
25
+ call_element[1]
26
+ end
27
+
28
+ def arguments_element
29
+ call_element.sexp_body(3) || []
30
+ end
31
+
32
+ def lambda_literal?
33
+ call_element.sexp_type == :lambda
34
+ end
35
+
36
+ private
37
+
38
+ attr_reader :call_element
39
+
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
48
+ end
49
+
50
+ def set_receiver
51
+ @receiver = ReceiverFactory.new(receiver_element)
52
+ end
53
+
54
+ def set_method_name
55
+ @method_name = call_element[2]
56
+ end
57
+
58
+ def set_arguments
59
+ @arguments = arguments_element.map { |argument| Argument.new(argument) }
60
+ end
61
+
62
+ def set_block_presence
63
+ if element.sexp_type == :iter || (arguments.last && arguments.last.type == :block_pass)
64
+ @block_present = true
65
+ end
66
+ end
67
+
68
+ def set_block_body
69
+ @block_body = element[3] if block?
70
+ end
71
+
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 || []
77
+ end
78
+ end
79
+
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)
93
+ end
94
+ end
95
+ end
96
+
97
+ class VariableReference
98
+ attr_reader :name
99
+
100
+ def initialize(reference_info)
101
+ @reference_info = reference_info
102
+ @name = reference_info[1]
103
+ end
104
+ end
105
+
106
+ class Argument
107
+ attr_reader :element
108
+
109
+ def initialize(element)
110
+ @element = element
111
+ end
112
+
113
+ def type
114
+ @type ||= @element[0]
115
+ end
116
+
117
+ def value
118
+ @value ||= @element[1]
119
+ end
120
+ end
121
+
122
+ class Primitive
123
+ attr_reader :element
124
+
125
+ def initialize(element)
126
+ @element = element
127
+ end
128
+
129
+ def type
130
+ @type ||= @element[0]
131
+ end
132
+
133
+ def range?
134
+ %i[dot2 dot3 lit].include?(type)
135
+ end
136
+
137
+ def array?
138
+ type == :array
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fastererer
4
+ class MethodDefinition
5
+ # Exposed for testing purposes.
6
+ attr_reader :element, :method_name, :block_argument_name, :body, :arguments
7
+
8
+ alias name method_name
9
+
10
+ def initialize(element)
11
+ @element = element # Ripper element
12
+ set_method_name
13
+ set_body
14
+ set_arguments
15
+ set_block_argument_name
16
+ end
17
+
18
+ def block?
19
+ !!@block_argument_name
20
+ end
21
+
22
+ def setter?
23
+ name.to_s.end_with?('=')
24
+ end
25
+
26
+ private
27
+
28
+ def arguments_element
29
+ element[2].drop(1) || []
30
+ end
31
+
32
+ def set_method_name
33
+ @method_name = @element[1]
34
+ end
35
+
36
+ def set_arguments
37
+ @arguments = arguments_element.map do |argument_element|
38
+ MethodDefinitionArgument.new(argument_element)
39
+ end
40
+ end
41
+
42
+ def set_body
43
+ @body = @element[3..]
44
+ end
45
+
46
+ def set_block_argument_name
47
+ return unless last_argument_element.to_s.start_with?('&')
48
+
49
+ @block_argument_name = last_argument_element.to_s.delete_prefix('&').to_sym
50
+ end
51
+
52
+ def last_argument_element
53
+ arguments_element.last
54
+ end
55
+ end
56
+
57
+ class MethodDefinitionArgument
58
+ attr_reader :element, :name, :type
59
+
60
+ def initialize(element)
61
+ @element = element
62
+ set_name
63
+ set_argument_type
64
+ end
65
+
66
+ def regular_argument?
67
+ @type == :regular_argument
68
+ end
69
+
70
+ def default_argument?
71
+ @type == :default_argument
72
+ end
73
+
74
+ def keyword_argument?
75
+ @type == :keyword_argument
76
+ end
77
+
78
+ private
79
+
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
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fastererer
4
+ class Offense
5
+ attr_reader :offense_name, :line_number
6
+
7
+ alias name offense_name
8
+ alias line line_number
9
+
10
+ def initialize(offense_name, line_number)
11
+ @offense_name = offense_name
12
+ @line_number = line_number
13
+ explanation # Set explanation right away.
14
+ end
15
+
16
+ def explanation
17
+ @explanation ||= EXPLANATIONS.fetch(offense_name)
18
+ 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
+ end
81
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+
5
+ module Fastererer
6
+ class OffenseCollector
7
+ extend Forwardable
8
+
9
+ def initialize
10
+ @offenses = []
11
+ end
12
+
13
+ def [](offense_name)
14
+ @offenses.select { |offense| offense.name == offense_name }
15
+ end
16
+
17
+ def_delegators :@offenses, :push, :any?, :each, :group_by, :count
18
+ end
19
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fastererer
4
+ module Painter
5
+ COLOR_CODES = {
6
+ red: 31,
7
+ green: 32
8
+ }.freeze
9
+
10
+ @disabled = false
11
+
12
+ def self.paint(string, color)
13
+ # Validate before short-circuit so bad color symbols surface even with --no-color/NO_COLOR.
14
+ color_code = COLOR_CODES[color.to_sym]
15
+ if color_code.nil?
16
+ raise ArgumentError,
17
+ "Color #{color} is not supported. Allowed colors are #{COLOR_CODES.keys.join(', ')}"
18
+ end
19
+
20
+ return string unless colorize?
21
+
22
+ paint_with_code(string, color_code)
23
+ end
24
+
25
+ def self.paint_with_code(string, color_code)
26
+ "\e[#{color_code}m#{string}\e[0m"
27
+ end
28
+
29
+ def self.disable!
30
+ @disabled = true
31
+ end
32
+
33
+ # Re-enables colorization; production never calls this — exists so tests can reset state.
34
+ def self.enable!
35
+ @disabled = false
36
+ end
37
+
38
+ def self.colorize?
39
+ return false unless ENV.fetch('NO_COLOR', '').empty?
40
+ return false if @disabled
41
+
42
+ $stdout.tty?
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ruby_parser'
4
+
5
+ module Fastererer
6
+ class Parser
7
+ PARSER_CLASS = RubyParser
8
+
9
+ def self.parse(ruby_code)
10
+ PARSER_CLASS.for_current_ruby.parse(ruby_code)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fastererer
4
+ class RescueCall
5
+ attr_reader :element, :rescue_classes
6
+
7
+ def initialize(element)
8
+ @element = element
9
+ @rescue_classes = []
10
+ set_rescue_classes
11
+ end
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
20
+ end
21
+ end
22
+ end
23
+ end