require 'reek/smells/smell_detector' require 'reek/smell_warning' module Reek module Smells # # Control Coupling occurs when a method or block checks the value of # a parameter in order to decide which execution path to take. The # offending parameter is often called a Control Couple. # # A simple example would be the quoted parameter # in the following method: # # def write(quoted) # if quoted # write_quoted(@value) # else # puts @value # end # end # # Control Coupling is a kind of duplication, because the calling method # already knows which path should be taken. # # Control Coupling reduces the code's flexibility by creating a # dependency between the caller and callee: # any change to the possible values of the controlling parameter must # be reflected on both sides of the call. # # A Control Couple also reveals a loss of simplicity: the called # method probably has more than one responsibility, # because it includes at least two different code paths. # # One possible solution is to use the Strategy Pattern # to pass into the callee what must be done. This is # not considered to be control coupling because the # callee will do the same thing with the strategy, # whatever it happens to be. Sometimes in Ruby the # strategy may actually just be a block passed in, and # that remains next to where the caller invokes it in # the source code. # class ControlParameter < SmellDetector SMELL_CLASS = 'ControlCouple' SMELL_SUBCLASS = self.name.split(/::/)[-1] PARAMETER_KEY = 'parameter' VALUE_POSITION = 1 # # Checks whether the given method chooses its execution path # by testing the value of one of its parameters. # # @return [Array] # def examine_context(ctx) ControlParameterCollector.new(ctx).control_parameters.map do |control_parameter| SmellWarning.new(SMELL_CLASS, ctx.full_name, control_parameter.lines, control_parameter.smell_message, @source, SMELL_SUBCLASS, {PARAMETER_KEY => control_parameter.name}) end end # # Collects information about a single control parameter. # class FoundControlParameter def initialize(param) @param = param @occurences = [] end def record(occurences) @occurences.concat occurences end def smell_message "is controlled by argument #{name}" end def lines @occurences.map(&:line) end def name @param.to_s end end # # Collects all control parameters in a given context. # class ControlParameterCollector def initialize(context) @context = context end def control_parameters result = Hash.new {|hash, key| hash[key] = FoundControlParameter.new(key)} potential_parameters.each do |param| matches = find_matches(param) result[param].record(matches) if matches.any? end result.values end private # Returns parameters that aren't used outside of a conditional statements and that # could be good candidates for being a control parameter. def potential_parameters @context.exp.parameter_names.select {|param| !used_outside_conditional?(param)} end # Returns wether the parameter is used outside of the conditional statement. def used_outside_conditional?(param) nodes = @context.exp.each_node(:lvar, [:if, :case, :and, :or, :args]) nodes.any? {|node| node.value == param} end # Find the use of the param that match the definition of a control parameter. def find_matches(param) matches = [] [:if, :case, :and, :or].each do |keyword| @context.local_nodes(keyword).each do |node| return [] if used_besides_in_condition?(node, param) node.each_node(:lvar, []) {|inner| matches.push(inner) if inner.value == param} end end matches end # Returns wether the parameter is used somewhere besides in the condition of the # conditional statement. def used_besides_in_condition?(node, param) times_in_conditional, times_total = 0, 0 node.each_node(:lvar, [:if, :case]) {|lvar| times_total +=1 if lvar.value == param} if node.condition times_in_conditional += 1 if node.condition[VALUE_POSITION] == param times_in_conditional += node.condition.count {|inner| inner.class == Sexp && inner[VALUE_POSITION] == param} end return times_total > times_in_conditional end end end end end