reek 1.3.4 → 1.3.5

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 (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