reek 1.3.4 → 1.3.5

Sign up to get free protection for your applications and to get access to all the features.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG +5 -0
  3. data/README.md +27 -2
  4. data/features/command_line_interface/options.feature +5 -2
  5. data/features/command_line_interface/smells_count.feature +43 -45
  6. data/features/command_line_interface/stdin.feature +9 -15
  7. data/features/configuration_files/masking_smells.feature +9 -17
  8. data/features/rake_task/rake_task.feature +4 -4
  9. data/features/reports/reports.feature +80 -21
  10. data/features/samples.feature +8 -18
  11. data/features/step_definitions/reek_steps.rb +4 -0
  12. data/lib/reek/cli/application.rb +3 -6
  13. data/lib/reek/cli/command_line.rb +16 -6
  14. data/lib/reek/cli/reek_command.rb +4 -12
  15. data/lib/reek/cli/report.rb +61 -19
  16. data/lib/reek/config_file_exception.rb +5 -0
  17. data/lib/reek/smells/control_parameter.rb +45 -14
  18. data/lib/reek/smells/data_clump.rb +15 -39
  19. data/lib/reek/smells/duplicate_method_call.rb +76 -26
  20. data/lib/reek/source/config_file.rb +30 -19
  21. data/lib/reek/source/sexp_extensions.rb +139 -0
  22. data/lib/reek/source/sexp_node.rb +64 -0
  23. data/lib/reek/source/source_code.rb +1 -1
  24. data/lib/reek/source/tree_dresser.rb +30 -175
  25. data/lib/reek/spec/should_reek.rb +2 -5
  26. data/lib/reek/version.rb +1 -1
  27. data/reek.gemspec +1 -1
  28. data/spec/matchers/smell_of_matcher.rb +12 -15
  29. data/spec/reek/cli/report_spec.rb +10 -6
  30. data/spec/reek/core/code_parser_spec.rb +0 -6
  31. data/spec/reek/smells/control_parameter_spec.rb +195 -8
  32. data/spec/reek/smells/data_clump_spec.rb +28 -3
  33. data/spec/reek/smells/uncommunicative_method_name_spec.rb +7 -7
  34. data/spec/reek/source/sexp_extensions_spec.rb +290 -0
  35. data/spec/reek/source/sexp_node_spec.rb +28 -0
  36. data/spec/reek/source/source_code_spec.rb +59 -19
  37. data/spec/reek/source/tree_dresser_spec.rb +7 -314
  38. data/spec/reek/spec/should_reek_spec.rb +51 -64
  39. data/spec/samples/all_but_one_masked/dirty.rb +2 -2
  40. data/spec/samples/corrupt_config_file/dirty.rb +1 -0
  41. data/spec/samples/masked/dirty.rb +1 -1
  42. data/spec/samples/masked_by_dotfile/dirty.rb +2 -2
  43. data/spec/samples/no_config_file/dirty.rb +8 -0
  44. data/spec/samples/not_quite_masked/dirty.rb +0 -3
  45. data/spec/samples/three_smelly_files/dirty_one.rb +3 -0
  46. data/spec/samples/three_smelly_files/dirty_three.rb +5 -0
  47. data/spec/samples/three_smelly_files/dirty_two.rb +4 -0
  48. data/spec/spec_helper.rb +5 -0
  49. metadata +145 -137
  50. data/spec/reek/cli/reek_command_spec.rb +0 -46
@@ -44,6 +44,10 @@ Then /^stderr reports:$/ do |report|
44
44
  @last_stderr.should == report
45
45
  end
46
46
 
47
+ Then /^it reports no errors$/ do
48
+ @last_stderr.chomp.should eq ""
49
+ end
50
+
47
51
  Then /^it reports an error$/ do
48
52
  @last_stderr.chomp.should_not be_empty
49
53
  end
@@ -23,7 +23,7 @@ module Reek
23
23
  begin
24
24
  cmd = @options.parse
25
25
  cmd.execute(self)
26
- rescue Exception => error
26
+ rescue OptionParser::InvalidOption, ConfigFileException => error
27
27
  $stderr.puts "Error: #{error}"
28
28
  @status = STATUS_ERROR
29
29
  end
@@ -42,11 +42,8 @@ module Reek
42
42
  @status = STATUS_SMELLS
43
43
  end
44
44
 
45
- def output_smells_total(total_smells_count)
46
- total_smells_message = "#{total_smells_count} total warning"
47
- total_smells_message += 's' unless total_smells_count == 1
48
- total_smells_message += "\n"
49
- output total_smells_message
45
+ def update_status(reporter)
46
+ reporter.has_smells? ? report_smells : report_success
50
47
  end
51
48
  end
52
49
  end
@@ -17,10 +17,11 @@ module Reek
17
17
  def initialize(argv)
18
18
  @argv = argv
19
19
  @parser = OptionParser.new
20
- @report_class = VerboseReport
20
+ @report_class = QuietReport
21
21
  @warning_formatter = WarningFormatterWithLineNumbers
22
22
  @command_class = ReekCommand
23
23
  @config_files = []
24
+ @sort_by_issue_count = false
24
25
  set_options
25
26
  end
26
27
 
@@ -72,15 +73,24 @@ EOB
72
73
  end
73
74
 
74
75
  @parser.separator "\nReport formatting:"
75
- @parser.on("-q", "--[no-]quiet", "Suppress headings for smell-free source files") do |opt|
76
- @report_class = opt ? QuietReport : VerboseReport
76
+ @parser.on("-q", "--quiet", "Suppress headings for smell-free source files (this is the default)") do |opt|
77
+ @report_class = QuietReport
77
78
  end
78
- @parser.on("-n", "--line-number", "Suppress line number(s) from the output.") do
79
+ @parser.on("-V", "--no-quiet", "--verbose", "Show headings for smell-free source files") do |opt|
80
+ @report_class = VerboseReport
81
+ end
82
+ @parser.on("-n", "--no-line-numbers", "Suppress line numbers from the output") do
79
83
  @warning_formatter = SimpleWarningFormatter
80
84
  end
85
+ @parser.on("--line-numbers", "Show line numbers in the output (this is the default)") do
86
+ @warning_formatter = WarningFormatterWithLineNumbers
87
+ end
81
88
  @parser.on("-s", "--single-line", "Show IDE-compatible single-line-per-warning") do
82
89
  @warning_formatter = SingleLineWarningFormatter
83
90
  end
91
+ @parser.on("-S", "--sort-by-issue-count", 'Sort by "issue-count", listing the "smelliest" files first') do
92
+ @sort_by_issue_count = true
93
+ end
84
94
  @parser.on("-y", "--yaml", "Report smells in YAML format") do
85
95
  @command_class = YamlCommand
86
96
  # SMELL: the args passed to the command should be tested, because it may
@@ -100,8 +110,8 @@ EOB
100
110
  if @command_class == YamlCommand
101
111
  YamlCommand.create(sources, @config_files)
102
112
  else
103
- report = @report_class.new(@warning_formatter)
104
- ReekCommand.create(sources, report, @config_files)
113
+ reporter = @report_class.new(@warning_formatter, ReportFormatter, @sort_by_issue_count)
114
+ ReekCommand.create(sources, reporter, @config_files)
105
115
  end
106
116
  end
107
117
  end
@@ -18,20 +18,12 @@ module Reek
18
18
  @config_files = config_files
19
19
  end
20
20
 
21
- def execute(view)
22
- total_smells_count = 0
21
+ def execute(app)
23
22
  @sources.each do |source|
24
- examiner = Examiner.new(source, @config_files)
25
- total_smells_count += examiner.smells_count
26
- view.output @reporter.report(examiner)
23
+ @reporter.add_examiner(Examiner.new(source, @config_files))
27
24
  end
28
- if total_smells_count > 0
29
- view.report_smells
30
- else
31
- view.report_success
32
- end
33
-
34
- view.output_smells_total(total_smells_count) if @sources.count > 1
25
+ app.update_status(@reporter)
26
+ @reporter.show
35
27
  end
36
28
  end
37
29
  end
@@ -31,36 +31,78 @@ module Reek
31
31
  def self.format(warning)
32
32
  "#{warning.source}:#{warning.lines.first}: #{SimpleWarningFormatter.format(warning)}"
33
33
  end
34
- end
34
+ end
35
+
36
+ class Report
37
+ def initialize(warning_formatter = SimpleWarningFormatter, report_formatter = ReportFormatter, sort_by_issue_count = false)
38
+ @warning_formatter = warning_formatter
39
+ @report_formatter = report_formatter
40
+ @examiners = []
41
+ @total_smell_count = 0
42
+ @sort_by_issue_count = sort_by_issue_count
43
+ end
44
+
45
+ def add_examiner(examiner)
46
+ @total_smell_count += examiner.smells_count
47
+ @examiners << examiner
48
+ self
49
+ end
50
+
51
+ def show
52
+ sort_examiners
53
+ display_summary
54
+ display_total_smell_count
55
+ end
56
+
57
+ def has_smells?
58
+ @total_smell_count > 0
59
+ end
60
+
61
+ private
62
+
63
+ def sort_examiners
64
+ @examiners.sort! {|a, b| b.smells_count <=> a.smells_count } if @sort_by_issue_count
65
+ end
35
66
 
36
- #
37
- # A report that lists every source, including those that have no smells.
38
- #
39
- class VerboseReport
40
- def initialize(warning_formatter = SimpleWarningFormatter, report_formatter = ReportFormatter)
41
- @warning_formatter = warning_formatter
42
- @report_formatter = report_formatter
67
+ def display_summary
68
+ print gather_results.reject(&:empty?).join("\n")
43
69
  end
44
70
 
45
- def report(examiner)
71
+ def display_total_smell_count
72
+ if @examiners.size > 1
73
+ print "\n"
74
+ print total_smell_count_message
75
+ end
76
+ end
77
+
78
+ def total_smell_count_message
79
+ "#{@total_smell_count} total warning#{'s' unless @total_smell_count == 1 }\n"
80
+ end
81
+
82
+ def summarize_single_examiner(examiner)
46
83
  result = @report_formatter.header examiner
47
84
  if examiner.smelly?
48
85
  formatted_list = @report_formatter.format_list examiner.smells, @warning_formatter
49
86
  result += ":\n#{formatted_list}"
50
87
  end
51
- result + "\n"
88
+ result
52
89
  end
53
90
  end
54
91
 
55
- #
56
- # A report that lists a section for each source that has smells.
57
- #
58
- class QuietReport < VerboseReport
59
- def report(examiner)
60
- if examiner.smelly?
61
- super
62
- else
63
- ''
92
+ class VerboseReport < Report
93
+ def gather_results
94
+ @examiners.each_with_object([]) do |examiner, result|
95
+ result << summarize_single_examiner(examiner)
96
+ end
97
+ end
98
+ end
99
+
100
+ class QuietReport < Report
101
+ def gather_results
102
+ @examiners.each_with_object([]) do |examiner, result|
103
+ if examiner.smelly?
104
+ result << summarize_single_examiner(examiner)
105
+ end
64
106
  end
65
107
  end
66
108
  end
@@ -0,0 +1,5 @@
1
+ module Reek
2
+ class ConfigFileException < RuntimeError
3
+
4
+ end
5
+ end
@@ -46,6 +46,7 @@ module Reek
46
46
  SMELL_CLASS = 'ControlCouple'
47
47
  SMELL_SUBCLASS = self.name.split(/::/)[-1]
48
48
  PARAMETER_KEY = 'parameter'
49
+ VALUE_POSITION = 1
49
50
 
50
51
  #
51
52
  # Checks whether the given method chooses its execution path
@@ -54,31 +55,61 @@ module Reek
54
55
  # @return [Array<SmellWarning>]
55
56
  #
56
57
  def examine_context(ctx)
57
- control_parameters(ctx).map do |cond, occurs|
58
- param = cond.format_ruby
58
+ control_parameters(ctx).map do |lvars, occurs|
59
+ param = lvars.format_ruby
59
60
  lines = occurs.map {|exp| exp.line}
60
61
  smell = SmellWarning.new(SMELL_CLASS, ctx.full_name, lines,
61
- "is controlled by argument #{param}",
62
- @source, SMELL_SUBCLASS,
63
- {PARAMETER_KEY => param})
62
+ "is controlled by argument #{param}",
63
+ @source, SMELL_SUBCLASS,
64
+ {PARAMETER_KEY => param})
64
65
  smell
65
66
  end
66
67
  end
67
68
 
68
- private
69
+ private
69
70
 
70
71
  def control_parameters(method_ctx)
71
- params = method_ctx.exp.parameter_names
72
- result = Hash.new {|hash,key| hash[key] = []}
73
- return result if params.empty?
74
- method_ctx.local_nodes(:if) do |if_node|
75
- cond = if_node[1]
76
- if cond[0] == :lvar and params.include?(cond[1])
77
- result[cond].push(cond)
78
- end
72
+ result = Hash.new {|hash, key| hash[key] = []}
73
+ method_ctx.exp.parameter_names.each do |param|
74
+ next if used_outside_conditional?(method_ctx, param)
75
+ find_matchs(method_ctx, param).each {|match| result[match].push(match)}
79
76
  end
80
77
  result
81
78
  end
79
+
80
+ # Returns wether the parameter is used outside of the conditional statement.
81
+ def used_outside_conditional?(method_ctx, param)
82
+ method_ctx.exp.each_node(:lvar, [:if, :case, :and, :or, :args]) do |node|
83
+ return true if node.value == param
84
+ end
85
+ false
86
+ end
87
+
88
+ # Find the use of the param that match the definition of a control parameter.
89
+ def find_matchs(method_ctx, param)
90
+ matchs = []
91
+ [:if, :case, :and, :or].each do |keyword|
92
+ method_ctx.local_nodes(keyword).each do |node|
93
+ return [] if used_besides_in_condition?(node, param)
94
+ node.each_node(:lvar, []) {|inner| matchs.push(inner) if inner.value == param}
95
+ end
96
+ end
97
+ matchs
98
+ end
99
+
100
+ # Returns wether the parameter is used somewhere besides in the condition of the
101
+ # conditional statement.
102
+ def used_besides_in_condition?(node, param)
103
+ times_in_conditional, times_total = 0, 0
104
+ node.each_node(:lvar, [:if, :case]) {|inner| times_total +=1 if inner[VALUE_POSITION] == param}
105
+ if node.condition
106
+ times_in_conditional += 1 if node.condition[VALUE_POSITION] == param
107
+ node.condition.each do |inner|
108
+ times_in_conditional += 1 if inner.class == Sexp && inner[VALUE_POSITION] == param
109
+ end
110
+ end
111
+ return times_total > times_in_conditional
112
+ end
82
113
  end
83
114
  end
84
115
  end
@@ -89,49 +89,29 @@ module Reek
89
89
  def initialize(ctx, min_clump_size, max_copies)
90
90
  @min_clump_size = min_clump_size
91
91
  @max_copies = max_copies
92
- @candidate_methods = ctx.local_nodes(:defn).select do |meth|
93
- meth.arg_names.length >= @min_clump_size
94
- end.map {|defn_node| CandidateMethod.new(defn_node)}
95
- delete_infrequent_parameters
96
- delete_small_methods
92
+ @candidate_methods = ctx.local_nodes(:defn).map {|defn_node|
93
+ CandidateMethod.new(defn_node)}
97
94
  end
98
95
 
99
- def clumps_containing(method, methods, results)
100
- methods.each do |other_method|
101
- clump = method.arg_names & other_method.arg_names
102
- if clump.length >= @min_clump_size
103
- others = methods.select { |other| clump - other.arg_names == [] }
104
- results[clump] += [method] + others
105
- end
106
- end
96
+ def candidate_clumps
97
+ @candidate_methods.each_cons(@max_copies + 1).map do |methods|
98
+ common_argument_names_for(methods)
99
+ end.select do |clump|
100
+ clump.length >= @min_clump_size
101
+ end.uniq
107
102
  end
108
103
 
109
- def collect_clumps_in(methods, results)
110
- return if methods.length <= @max_copies
111
- tail = methods[1..-1]
112
- clumps_containing(methods[0], tail, results)
113
- collect_clumps_in(tail, results)
114
- end
115
-
116
- def clumps
117
- results = Hash.new([])
118
- collect_clumps_in(@candidate_methods, results)
119
- results.each_key { |key| results[key].uniq! }
120
- results
104
+ def common_argument_names_for(methods)
105
+ methods.collect(&:arg_names).inject(:&)
121
106
  end
122
107
 
123
- def delete_small_methods
124
- @candidate_methods = @candidate_methods.select do |meth|
125
- meth.arg_names.length >= @min_clump_size
126
- end
108
+ def methods_containing_clump(clump)
109
+ @candidate_methods.select { |method| clump & method.arg_names == clump }
127
110
  end
128
111
 
129
- def delete_infrequent_parameters
130
- @candidate_methods.each do |meth|
131
- meth.arg_names.each do |param|
132
- occurs = @candidate_methods.inject(0) {|sum, cm| cm.arg_names.include?(param) ? sum+1 : sum}
133
- meth.delete(param) if occurs <= @max_copies
134
- end
112
+ def clumps
113
+ candidate_clumps.map do |clump|
114
+ [clump, methods_containing_clump(clump)]
135
115
  end
136
116
  end
137
117
  end
@@ -148,10 +128,6 @@ module Reek
148
128
  @params
149
129
  end
150
130
 
151
- def delete(param)
152
- @params.delete(param)
153
- end
154
-
155
131
  def line
156
132
  @defn.line
157
133
  end
@@ -18,7 +18,6 @@ module Reek
18
18
  # end
19
19
  #
20
20
  class DuplicateMethodCall < SmellDetector
21
-
22
21
  SMELL_CLASS = 'Duplication'
23
22
  SMELL_SUBCLASS = self.name.split(/::/)[-1]
24
23
 
@@ -51,39 +50,90 @@ module Reek
51
50
  # @return [Array<SmellWarning>]
52
51
  #
53
52
  def examine_context(ctx)
54
- @max_allowed_calls = value(MAX_ALLOWED_CALLS_KEY, ctx, DEFAULT_MAX_CALLS)
55
- @allow_calls = value(ALLOW_CALLS_KEY, ctx, DEFAULT_ALLOW_CALLS)
56
- calls(ctx).select do |call_exp, copies|
57
- copies.length > @max_allowed_calls and not allow_calls?(call_exp.format_ruby)
58
- end.map do |call_exp, copies|
59
- occurs = copies.length
60
- call = call_exp.format_ruby
61
- multiple = occurs == 2 ? 'twice' : "#{occurs} times"
62
- smell = SmellWarning.new(SMELL_CLASS, ctx.full_name, copies.map {|exp| exp.line},
63
- "calls #{call} #{multiple}",
64
- @source, SMELL_SUBCLASS,
65
- {CALL_KEY => call, OCCURRENCES_KEY => occurs})
66
- smell
53
+ max_allowed_calls = value(MAX_ALLOWED_CALLS_KEY, ctx, DEFAULT_MAX_CALLS)
54
+ allow_calls = value(ALLOW_CALLS_KEY, ctx, DEFAULT_ALLOW_CALLS)
55
+
56
+ CallCollector.new(ctx, max_allowed_calls, allow_calls).smelly_calls.map do |found_call|
57
+ SmellWarning.new(SMELL_CLASS, ctx.full_name, found_call.lines,
58
+ found_call.smell_message,
59
+ @source, SMELL_SUBCLASS,
60
+ {CALL_KEY => found_call.call, OCCURRENCES_KEY => found_call.occurs})
67
61
  end
68
62
  end
69
63
 
70
- private
64
+ # Collects information about a single found call
65
+ class FoundCall
66
+ def initialize(call_node)
67
+ @call_node = call_node
68
+ @occurences = []
69
+ end
70
+
71
+ def record(occurence)
72
+ @occurences.push occurence
73
+ end
74
+
75
+ def smell_message
76
+ multiple = occurs == 2 ? 'twice' : "#{occurs} times"
77
+ "calls #{call} #{multiple}"
78
+ end
71
79
 
72
- def calls(method_ctx)
73
- result = Hash.new {|hash,key| hash[key] = []}
74
- method_ctx.local_nodes(:call) do |call_node|
75
- next if call_node.method_name == :new
76
- next if call_node.receiver.nil? && call_node.args.empty?
77
- result[call_node].push(call_node)
80
+ def call
81
+ @call ||= @call_node.format_ruby
78
82
  end
79
- method_ctx.local_nodes(:attrasgn) do |asgn_node|
80
- result[asgn_node].push(asgn_node) unless asgn_node.args.nil?
83
+
84
+ def occurs
85
+ @occurences.length
86
+ end
87
+
88
+ def lines
89
+ @occurences.map {|exp| exp.line}
81
90
  end
82
- result.to_a.sort_by {|call_exp, _| call_exp.format_ruby}
83
91
  end
84
92
 
85
- def allow_calls?(method)
86
- @allow_calls.any? { |allow| /#{allow}/ === method }
93
+ # Collects all calls in a given context
94
+ class CallCollector
95
+ attr_reader :context
96
+
97
+ def initialize(context, max_allowed_calls, allow_calls)
98
+ @context = context
99
+ @max_allowed_calls = max_allowed_calls
100
+ @allow_calls = allow_calls
101
+ end
102
+
103
+ def calls
104
+ result = Hash.new {|hash,key| hash[key] = FoundCall.new(key)}
105
+ collect_calls(result)
106
+ collect_assignments(result)
107
+ result.values.sort_by {|found_call| found_call.call}
108
+ end
109
+
110
+ def smelly_calls
111
+ calls.select {|found_call| smelly_call? found_call }
112
+ end
113
+
114
+ private
115
+
116
+ def collect_assignments(result)
117
+ context.local_nodes(:attrasgn) do |asgn_node|
118
+ result[asgn_node].record(asgn_node) if asgn_node.args
119
+ end
120
+ end
121
+
122
+ def collect_calls(result)
123
+ context.local_nodes(:call) do |call_node|
124
+ next if call_node.method_name == :new
125
+ next if !call_node.receiver && call_node.args.empty?
126
+ result[call_node].record(call_node)
127
+ end
128
+ end
129
+
130
+ def smelly_call?(found_call)
131
+ found_call.occurs > @max_allowed_calls and not allow_calls?(found_call.call)
132
+ end
133
+
134
+ def allow_calls?(method)
135
+ @allow_calls.any? { |allow| /#{allow}/ === method }
136
+ end
87
137
  end
88
138
  end
89
139
  end