why-classes 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/LICENSE.txt +21 -0
- data/README.md +128 -0
- data/config/default.yml +69 -0
- data/exe/why-classes +6 -0
- data/lib/why_classes/ast/class_shape.rb +106 -0
- data/lib/why_classes/ast/node_helpers.rb +186 -0
- data/lib/why_classes/cli.rb +97 -0
- data/lib/why_classes/config_loader.rb +51 -0
- data/lib/why_classes/configuration.rb +68 -0
- data/lib/why_classes/correction.rb +44 -0
- data/lib/why_classes/correctors/data_bucket_corrector.rb +79 -0
- data/lib/why_classes/disable_comments.rb +59 -0
- data/lib/why_classes/file_finder.rb +43 -0
- data/lib/why_classes/formatters/base_formatter.rb +30 -0
- data/lib/why_classes/formatters/clang_formatter.rb +19 -0
- data/lib/why_classes/formatters/diff_formatter.rb +107 -0
- data/lib/why_classes/formatters/json_formatter.rb +42 -0
- data/lib/why_classes/formatters/progress_formatter.rb +38 -0
- data/lib/why_classes/offense.rb +29 -0
- data/lib/why_classes/options.rb +32 -0
- data/lib/why_classes/parser.rb +32 -0
- data/lib/why_classes/registry.rb +39 -0
- data/lib/why_classes/rule.rb +89 -0
- data/lib/why_classes/rules/data_bucket.rb +79 -0
- data/lib/why_classes/rules/function_bucket.rb +55 -0
- data/lib/why_classes/rules/inheritance_for_mixins.rb +37 -0
- data/lib/why_classes/rules/invalid_initial_state.rb +56 -0
- data/lib/why_classes/rules/single_attribute_reader.rb +38 -0
- data/lib/why_classes/rules/stateless_singleton_methods.rb +50 -0
- data/lib/why_classes/runner.rb +115 -0
- data/lib/why_classes/source_file.rb +45 -0
- data/lib/why_classes/version.rb +5 -0
- data/lib/why_classes.rb +32 -0
- metadata +113 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "offense"
|
|
4
|
+
require_relative "registry"
|
|
5
|
+
require_relative "ast/node_helpers"
|
|
6
|
+
require_relative "ast/class_shape"
|
|
7
|
+
|
|
8
|
+
module WhyClasses
|
|
9
|
+
# Base class for every detector. A rule walks the parser-style AST, dispatches
|
|
10
|
+
# to `on_<type>` handlers a subclass defines, and emits Offenses via
|
|
11
|
+
# #add_offense. Rules never mutate source; a correctable offense carries a
|
|
12
|
+
# `corrector` callable that queues edits on a Parser::Source::TreeRewriter.
|
|
13
|
+
class Rule
|
|
14
|
+
H = AST::NodeHelpers
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
def rule_name
|
|
18
|
+
name.split("::").last
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Human-readable one-liner shown by `--list-rules`. Override in subclasses.
|
|
22
|
+
def description
|
|
23
|
+
""
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Whether this rule can ever autocorrect. Advice-only rules return false.
|
|
27
|
+
def autocorrectable?
|
|
28
|
+
false
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def inherited(subclass)
|
|
32
|
+
super
|
|
33
|
+
# Anonymous subclasses (in specs) are not registered.
|
|
34
|
+
Registry.register(subclass) unless subclass.name.nil?
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def initialize(configuration)
|
|
39
|
+
@configuration = configuration
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Run this rule over a SourceFile and return its offenses.
|
|
43
|
+
def call(source_file)
|
|
44
|
+
@source_file = source_file
|
|
45
|
+
@offenses = []
|
|
46
|
+
walk(source_file.ast)
|
|
47
|
+
@offenses
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
attr_reader :configuration, :source_file
|
|
53
|
+
|
|
54
|
+
def rails?
|
|
55
|
+
configuration.rails?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def rule_option(key, default = nil)
|
|
59
|
+
configuration.rule_option(self.class.rule_name, key, default)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def shape(class_node)
|
|
63
|
+
AST::ClassShape.new(class_node)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def walk(node)
|
|
67
|
+
return unless node.is_a?(::Parser::AST::Node)
|
|
68
|
+
|
|
69
|
+
handler = "on_#{node.type}"
|
|
70
|
+
send(handler, node) if respond_to?(handler, true)
|
|
71
|
+
node.children.each { |child| walk(child) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def add_offense(node, message:, suggestion:, severity: :convention, corrector: nil)
|
|
75
|
+
range = node.location.expression
|
|
76
|
+
@offenses << Offense.new(
|
|
77
|
+
rule_name: self.class.rule_name,
|
|
78
|
+
message: message,
|
|
79
|
+
suggestion: suggestion,
|
|
80
|
+
path: source_file.path,
|
|
81
|
+
line: range.line,
|
|
82
|
+
column: range.column,
|
|
83
|
+
severity: severity,
|
|
84
|
+
correctable: !corrector.nil?,
|
|
85
|
+
corrector: corrector
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../rule"
|
|
4
|
+
require_relative "../correctors/data_bucket_corrector"
|
|
5
|
+
|
|
6
|
+
module WhyClasses
|
|
7
|
+
module Rules
|
|
8
|
+
# Rule 4 (summary): "If it's just a data bucket, use a Struct, Data object, or
|
|
9
|
+
# a Hash." A class that only declares attr_* fields and an initialize that
|
|
10
|
+
# assigns them, with no behaviour, is a data bucket.
|
|
11
|
+
class DataBucket < Rule
|
|
12
|
+
def self.description
|
|
13
|
+
"Class that only holds data (attr_* + field-assigning initialize) -> use Struct or Data."
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.autocorrectable?
|
|
17
|
+
true
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def on_class(node)
|
|
21
|
+
s = shape(node)
|
|
22
|
+
return if s.name.nil?
|
|
23
|
+
return if configuration.framework_base_class?(s.superclass)
|
|
24
|
+
return unless data_bucket?(s)
|
|
25
|
+
|
|
26
|
+
analysis = Correctors::DataBucketCorrector.analyze(s, prefer_data: rule_option("PreferData", true))
|
|
27
|
+
corrector = build_corrector(node, s, analysis)
|
|
28
|
+
|
|
29
|
+
add_offense(
|
|
30
|
+
node,
|
|
31
|
+
message: "#{s.name} is a data bucket: it only stores fields and has no behaviour.",
|
|
32
|
+
suggestion: suggestion_for(s, analysis),
|
|
33
|
+
corrector: corrector
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
# Loose detection (for advice): has attr fields, no real behaviour, and
|
|
40
|
+
# either no initialize or an initialize that only assigns fields.
|
|
41
|
+
def data_bucket?(s)
|
|
42
|
+
return false if s.attr_fields.empty? && s.initialize_method.nil?
|
|
43
|
+
return false unless s.behavior_methods.empty?
|
|
44
|
+
return false unless s.singleton_methods.empty?
|
|
45
|
+
return false if s.uses_metaprogramming?
|
|
46
|
+
|
|
47
|
+
init = s.initialize_method
|
|
48
|
+
return true if init.nil? && !s.attr_fields.empty?
|
|
49
|
+
|
|
50
|
+
H.initialize_only_assigns_params?(s.node)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def build_corrector(node, _shape, analysis)
|
|
54
|
+
return nil if analysis.nil?
|
|
55
|
+
return nil unless configuration.autocorrect?(self.class.rule_name)
|
|
56
|
+
|
|
57
|
+
name = H.class_name(node)
|
|
58
|
+
replacement = analysis.replacement(name)
|
|
59
|
+
->(rewriter) { rewriter.replace(node.location.expression, replacement) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def suggestion_for(s, analysis)
|
|
63
|
+
if analysis
|
|
64
|
+
target = analysis.replacement(s.name)
|
|
65
|
+
caveat =
|
|
66
|
+
if analysis.kind == :data
|
|
67
|
+
"Data objects are immutable and compare by value; `.new` keeps the same fields."
|
|
68
|
+
else
|
|
69
|
+
"Struct adds value equality plus `to_a`/`[]`/`each`; it stays mutable."
|
|
70
|
+
end
|
|
71
|
+
"Replace the class with:\n #{target}\n #{caveat}"
|
|
72
|
+
else
|
|
73
|
+
"Consider a Struct or Data.define for these fields instead of a hand-written class " \
|
|
74
|
+
"(couldn't rewrite automatically: a superclass, default args, or extra logic is present)."
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../rule"
|
|
4
|
+
|
|
5
|
+
module WhyClasses
|
|
6
|
+
module Rules
|
|
7
|
+
# Rule 2 (summary): "If it's named after a design pattern, it's probably a
|
|
8
|
+
# workaround for a less flexible language." The classic service object -- a
|
|
9
|
+
# class with a single `call`/`perform`/`run` instance method and no state --
|
|
10
|
+
# is a bucket for a function.
|
|
11
|
+
class FunctionBucket < Rule
|
|
12
|
+
def self.description
|
|
13
|
+
"Class with a single call/perform/run method and no state -> use a module function."
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def on_class(node)
|
|
17
|
+
s = shape(node)
|
|
18
|
+
return if s.name.nil?
|
|
19
|
+
return if configuration.framework_base_class?(s.superclass)
|
|
20
|
+
return if s.superclass? # framework/base subclasses handled above; other inheritance is separate
|
|
21
|
+
return unless function_bucket?(s)
|
|
22
|
+
|
|
23
|
+
method = s.behavior_methods.first.children[0]
|
|
24
|
+
add_offense(
|
|
25
|
+
node,
|
|
26
|
+
message: "#{s.name} is a bucket for a single function (`##{method}`) with no state.",
|
|
27
|
+
suggestion: "Prefer a module function or a plain method:\n" \
|
|
28
|
+
" module #{s.name}\n module_function\n\n" \
|
|
29
|
+
" def #{method}(...)\n ...\n end\n end\n" \
|
|
30
|
+
" Callers become `#{s.name}.#{method}(...)` with no throwaway instance."
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def function_bucket?(s)
|
|
37
|
+
return false if s.uses_metaprogramming?
|
|
38
|
+
|
|
39
|
+
behavior = s.behavior_methods
|
|
40
|
+
return false unless behavior.size == 1
|
|
41
|
+
return false unless method_names.include?(behavior.first.children[0])
|
|
42
|
+
return false unless s.singleton_methods.empty?
|
|
43
|
+
return false unless s.attr_fields.empty?
|
|
44
|
+
|
|
45
|
+
# No real state: either no initialize, or an initialize that only stashes
|
|
46
|
+
# collaborators (still a smell, but we require truly stateless here).
|
|
47
|
+
s.initialize_method.nil?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def method_names
|
|
51
|
+
Array(rule_option("MethodNames", %w[call perform run execute])).map(&:to_sym)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../rule"
|
|
4
|
+
|
|
5
|
+
module WhyClasses
|
|
6
|
+
module Rules
|
|
7
|
+
# Thomas criticises inheriting from a fat base class purely to inject
|
|
8
|
+
# behaviour (e.g. `< ActiveRecord::Base` pulls in hundreds of methods) rather
|
|
9
|
+
# than to specialise. Advice-only: safe rewriting needs whole-program
|
|
10
|
+
# knowledge of which inherited methods are actually used.
|
|
11
|
+
class InheritanceForMixins < Rule
|
|
12
|
+
def self.description
|
|
13
|
+
"Inheriting a fat base class to inject methods -> include only the mixins you use."
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def on_class(node)
|
|
17
|
+
return unless rails?
|
|
18
|
+
|
|
19
|
+
s = shape(node)
|
|
20
|
+
return if s.name.nil?
|
|
21
|
+
return unless s.superclass?
|
|
22
|
+
|
|
23
|
+
mixins = configuration.mixin_suggestions_for(s.superclass)
|
|
24
|
+
return if mixins.nil?
|
|
25
|
+
|
|
26
|
+
add_offense(
|
|
27
|
+
node,
|
|
28
|
+
message: "#{s.name} < #{s.superclass} inherits a large surface just to gain behaviour.",
|
|
29
|
+
suggestion: "If you only need part of it, include the specific mixins instead:\n" \
|
|
30
|
+
" class #{s.name}\n" +
|
|
31
|
+
mixins.map { |m| " include #{m}" }.join("\n") +
|
|
32
|
+
"\n end\n Inherit only when you are genuinely specialising #{s.superclass}."
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../rule"
|
|
4
|
+
|
|
5
|
+
module WhyClasses
|
|
6
|
+
module Rules
|
|
7
|
+
# A red-flag pattern: initialize leaves a field nil, so the object is invalid
|
|
8
|
+
# until a setter is called after `.new`. A signal the "object" is really a
|
|
9
|
+
# sequence of function calls in disguise.
|
|
10
|
+
class InvalidInitialState < Rule
|
|
11
|
+
def self.description
|
|
12
|
+
"Object constructed in an invalid state (initialize sets a field to nil, needs a setter first)."
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def on_class(node)
|
|
16
|
+
s = shape(node)
|
|
17
|
+
return if s.name.nil?
|
|
18
|
+
|
|
19
|
+
init = s.initialize_method
|
|
20
|
+
return if init.nil?
|
|
21
|
+
|
|
22
|
+
writers = s.attr_macros[:attr_writer] + s.attr_macros[:attr_accessor]
|
|
23
|
+
nils = nil_assigned_ivars(init)
|
|
24
|
+
return if nils.empty?
|
|
25
|
+
|
|
26
|
+
nils.each do |ivar|
|
|
27
|
+
field = ivar.to_s.delete_prefix("@").to_sym
|
|
28
|
+
next unless writers.include?(field)
|
|
29
|
+
next unless field_read_in_behavior?(s, ivar)
|
|
30
|
+
|
|
31
|
+
add_offense(
|
|
32
|
+
node,
|
|
33
|
+
message: "#{s.name}#initialize sets #{ivar} = nil and exposes a setter, so a " \
|
|
34
|
+
"#{s.name} is invalid until `#{field}=` is called.",
|
|
35
|
+
suggestion: "Require #{field} up front instead:\n" \
|
|
36
|
+
" def initialize(#{field})\n @#{field} = #{field}\n end\n" \
|
|
37
|
+
" or drop the object entirely and pass #{field} to a module function."
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def nil_assigned_ivars(init)
|
|
45
|
+
H.body_statements(init.children[2]).select do |stmt|
|
|
46
|
+
H.node?(stmt) && stmt.type == :ivasgn &&
|
|
47
|
+
(val = stmt.children[1]) && H.node?(val) && val.type == :nil
|
|
48
|
+
end.map { |stmt| stmt.children[0] }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def field_read_in_behavior?(s, ivar)
|
|
52
|
+
s.behavior_methods.any? { |m| H.ivars_read(m).include?(ivar) }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../rule"
|
|
4
|
+
|
|
5
|
+
module WhyClasses
|
|
6
|
+
module Rules
|
|
7
|
+
# Decoupled polymorphism: an instance method that only reads a single field
|
|
8
|
+
# could be a freestanding function that works on anything responding to that
|
|
9
|
+
# field. Off by default -- it is a stylistic nudge and can be noisy.
|
|
10
|
+
class SingleAttributeReader < Rule
|
|
11
|
+
def self.description
|
|
12
|
+
"Instance method that only reads one field -> consider a freestanding polymorphic function."
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def on_class(node)
|
|
16
|
+
s = shape(node)
|
|
17
|
+
return if s.name.nil?
|
|
18
|
+
return if s.uses_metaprogramming?
|
|
19
|
+
|
|
20
|
+
s.behavior_methods.each do |method|
|
|
21
|
+
read = H.ivars_read(method)
|
|
22
|
+
written = H.ivars_assigned(method)
|
|
23
|
+
next unless written.empty?
|
|
24
|
+
next unless read.size == 1
|
|
25
|
+
|
|
26
|
+
field = read.first.to_s.delete_prefix("@")
|
|
27
|
+
add_offense(
|
|
28
|
+
method,
|
|
29
|
+
message: "#{s.name}##{method.children[0]} only reads @#{field}; it isn't tied to #{s.name}.",
|
|
30
|
+
suggestion: "A freestanding function is more flexible and works on any object " \
|
|
31
|
+
"exposing `#{field}`:\n" \
|
|
32
|
+
" def #{method.children[0]}(#{field})\n # use #{field} directly\n end"
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../rule"
|
|
4
|
+
|
|
5
|
+
module WhyClasses
|
|
6
|
+
module Rules
|
|
7
|
+
# Rule 3 (summary): "If it has no state, it should not be a class." A class
|
|
8
|
+
# whose body is only `self.` methods is a namespace for functions, not an
|
|
9
|
+
# object factory -- it should be a module.
|
|
10
|
+
class StatelessSingletonMethods < Rule
|
|
11
|
+
def self.description
|
|
12
|
+
"Class with only class methods and no state -> use a module with `extend self`."
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def on_class(node)
|
|
16
|
+
s = shape(node)
|
|
17
|
+
return if s.name.nil?
|
|
18
|
+
return if s.superclass? # inheritance is a separate concern (see InheritanceForMixins)
|
|
19
|
+
return if configuration.framework_base_class?(s.superclass)
|
|
20
|
+
return unless only_singleton_methods?(s)
|
|
21
|
+
|
|
22
|
+
add_offense(
|
|
23
|
+
node,
|
|
24
|
+
message: "#{s.name} has only class methods and no state; it's a bucket of functions, " \
|
|
25
|
+
"not an object factory.",
|
|
26
|
+
suggestion: "Make it a module and expose the methods with `extend self`:\n" \
|
|
27
|
+
" module #{s.name}\n def #{example_method(s)}(...)\n ...\n end\n\n" \
|
|
28
|
+
" private\n # helper methods here are now genuinely private\n\n" \
|
|
29
|
+
" extend self\n end"
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def only_singleton_methods?(s)
|
|
36
|
+
return false if s.singleton_methods.empty?
|
|
37
|
+
return false unless s.instance_methods.empty?
|
|
38
|
+
return false unless s.attr_fields.empty?
|
|
39
|
+
return false if s.uses_metaprogramming?
|
|
40
|
+
|
|
41
|
+
# Allow only constants / comments as other statements.
|
|
42
|
+
s.other_statements.all? { |n| %i[casgn].include?(n.type) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def example_method(s)
|
|
46
|
+
s.singleton_method_names.first || "call"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "config_loader"
|
|
4
|
+
require_relative "registry"
|
|
5
|
+
require_relative "source_file"
|
|
6
|
+
require_relative "file_finder"
|
|
7
|
+
require_relative "disable_comments"
|
|
8
|
+
require_relative "correction"
|
|
9
|
+
require_relative "formatters/progress_formatter"
|
|
10
|
+
require_relative "formatters/clang_formatter"
|
|
11
|
+
require_relative "formatters/json_formatter"
|
|
12
|
+
require_relative "formatters/diff_formatter"
|
|
13
|
+
|
|
14
|
+
module WhyClasses
|
|
15
|
+
# Orchestrates a run: find files, analyse each with the active rules, optionally
|
|
16
|
+
# apply corrections, hand the results to a formatter, and compute an exit code.
|
|
17
|
+
class Runner
|
|
18
|
+
FileReport = Data.define(:path, :offenses, :original_source, :corrected_source, :applied) do
|
|
19
|
+
def changed?
|
|
20
|
+
corrected_source != original_source
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
SEVERITY_RANK = { none: 0, convention: 1, warning: 2 }.freeze
|
|
25
|
+
|
|
26
|
+
FORMATTERS = {
|
|
27
|
+
"progress" => Formatters::ProgressFormatter,
|
|
28
|
+
"clang" => Formatters::ClangFormatter,
|
|
29
|
+
"json" => Formatters::JsonFormatter,
|
|
30
|
+
"diff" => Formatters::DiffFormatter
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
def initialize(options, configuration: nil)
|
|
34
|
+
@options = options
|
|
35
|
+
@configuration = configuration || build_configuration
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
attr_reader :configuration
|
|
39
|
+
|
|
40
|
+
def run(io: $stdout)
|
|
41
|
+
reports = analyze_files(FileFinder.find(@options.paths, @configuration))
|
|
42
|
+
formatter_for(@options.mode, io).call(reports)
|
|
43
|
+
exit_code(reports)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Analyse a single already-built SourceFile and return its filtered,
|
|
47
|
+
# position-sorted offenses. Handy for specs.
|
|
48
|
+
def offenses_for(source_file)
|
|
49
|
+
return [] unless source_file.parsed?
|
|
50
|
+
|
|
51
|
+
rules = Registry.active(configuration: @configuration, only: @options.only, except: @options.except)
|
|
52
|
+
raw = rules.flat_map { |rule_class| rule_class.new(@configuration).call(source_file) }
|
|
53
|
+
DisableComments.filter(raw, source_file.comments).sort_by(&:position)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def analyze_files(paths)
|
|
59
|
+
paths.map { |path| process(SourceFile.from_path(path)) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def process(source_file)
|
|
63
|
+
offenses = offenses_for(source_file)
|
|
64
|
+
corrected, applied = maybe_correct(source_file, offenses)
|
|
65
|
+
FileReport.new(
|
|
66
|
+
path: source_file.path,
|
|
67
|
+
offenses: offenses,
|
|
68
|
+
original_source: source_file.source,
|
|
69
|
+
corrected_source: corrected,
|
|
70
|
+
applied: applied
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def maybe_correct(source_file, offenses)
|
|
75
|
+
return [source_file.source, 0] unless correcting?
|
|
76
|
+
|
|
77
|
+
correctable = offenses.select do |offense|
|
|
78
|
+
offense.correctable? && @configuration.autocorrect?(offense.rule_name)
|
|
79
|
+
end
|
|
80
|
+
result = Correction.apply(source_file, correctable)
|
|
81
|
+
write_file(source_file.path, result.source) if @options.mode == :fix && result.source != source_file.source
|
|
82
|
+
[result.source, result.applied]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def correcting?
|
|
86
|
+
%i[fix diff].include?(@options.mode)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def write_file(path, source)
|
|
90
|
+
File.write(path, source)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def formatter_for(mode, io)
|
|
94
|
+
klass = mode == :diff ? Formatters::DiffFormatter : FORMATTERS.fetch(@options.format, Formatters::ProgressFormatter)
|
|
95
|
+
klass.new(io)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def exit_code(reports)
|
|
99
|
+
threshold = SEVERITY_RANK.fetch(@options.fail_level.to_sym, 1)
|
|
100
|
+
return 0 if threshold.zero?
|
|
101
|
+
|
|
102
|
+
offending = reports.any? do |report|
|
|
103
|
+
report.offenses.any? { |o| SEVERITY_RANK.fetch(o.severity, 1) >= threshold }
|
|
104
|
+
end
|
|
105
|
+
offending ? 1 : 0
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def build_configuration
|
|
109
|
+
config = ConfigLoader.load(explicit_path: @options.config_path)
|
|
110
|
+
return config if @options.rails.nil?
|
|
111
|
+
|
|
112
|
+
Configuration.new(ConfigLoader.deep_merge(config.raw, { "AllRules" => { "Rails" => @options.rails } }))
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "parser"
|
|
4
|
+
|
|
5
|
+
module WhyClasses
|
|
6
|
+
# A single Ruby source file: its path, raw source and lazily-parsed AST.
|
|
7
|
+
class SourceFile
|
|
8
|
+
attr_reader :path
|
|
9
|
+
|
|
10
|
+
def self.from_path(path)
|
|
11
|
+
new(path, File.read(path))
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize(path, source)
|
|
15
|
+
@path = path
|
|
16
|
+
@source = source
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
attr_reader :source
|
|
20
|
+
|
|
21
|
+
def result
|
|
22
|
+
@result ||= Parser.parse(@source, @path)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def ast
|
|
26
|
+
result.ast
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def buffer
|
|
30
|
+
result.buffer
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def comments
|
|
34
|
+
result.comments
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def parsed?
|
|
38
|
+
!ast.nil?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def parse_error
|
|
42
|
+
result.error
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
data/lib/why_classes.rb
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "why_classes/version"
|
|
4
|
+
require_relative "why_classes/parser"
|
|
5
|
+
require_relative "why_classes/source_file"
|
|
6
|
+
require_relative "why_classes/offense"
|
|
7
|
+
require_relative "why_classes/ast/node_helpers"
|
|
8
|
+
require_relative "why_classes/ast/class_shape"
|
|
9
|
+
require_relative "why_classes/registry"
|
|
10
|
+
require_relative "why_classes/rule"
|
|
11
|
+
require_relative "why_classes/configuration"
|
|
12
|
+
require_relative "why_classes/config_loader"
|
|
13
|
+
require_relative "why_classes/disable_comments"
|
|
14
|
+
require_relative "why_classes/correction"
|
|
15
|
+
require_relative "why_classes/file_finder"
|
|
16
|
+
|
|
17
|
+
# Rules self-register with the Registry when required.
|
|
18
|
+
require_relative "why_classes/rules/data_bucket"
|
|
19
|
+
require_relative "why_classes/rules/stateless_singleton_methods"
|
|
20
|
+
require_relative "why_classes/rules/function_bucket"
|
|
21
|
+
require_relative "why_classes/rules/invalid_initial_state"
|
|
22
|
+
require_relative "why_classes/rules/inheritance_for_mixins"
|
|
23
|
+
require_relative "why_classes/rules/single_attribute_reader"
|
|
24
|
+
|
|
25
|
+
require_relative "why_classes/options"
|
|
26
|
+
require_relative "why_classes/runner"
|
|
27
|
+
require_relative "why_classes/cli"
|
|
28
|
+
|
|
29
|
+
# why-classes detects the over-use of classes in Ruby (per Dave Thomas's talk)
|
|
30
|
+
# and suggests/applies refactorings toward modules, composition, Struct and Data.
|
|
31
|
+
module WhyClasses
|
|
32
|
+
end
|