troessner/reek

View on GitHub
lib/reek/smell_detectors/duplicate_method_call.rb

Summary

Maintainability
A
25 mins
Test Coverage
# frozen_string_literal: true

require_relative 'base_detector'

module Reek
  module SmellDetectors
    #
    # Duplication occurs when two fragments of code look nearly identical,
    # or when two fragments of code have nearly identical effects
    # at some conceptual level.
    #
    # +DuplicateMethodCall+ checks for repeated identical method calls
    # within any one method definition. For example, the following method
    # will report a warning:
    #
    #   def double_thing()
    #     @other.thing + @other.thing
    #   end
    #
    # See {file:docs/Duplicate-Method-Call.md} for details.
    class DuplicateMethodCall < BaseDetector
      # The name of the config field that sets the maximum number of
      # identical calls to be permitted within any single method.
      MAX_ALLOWED_CALLS_KEY = 'max_calls'
      DEFAULT_MAX_CALLS = 1

      # The name of the config field that sets the names of any
      # methods for which identical calls should be to be permitted
      # within any single method.
      ALLOW_CALLS_KEY = 'allow_calls'
      DEFAULT_ALLOW_CALLS = [].freeze

      def self.default_config
        super.merge(
          MAX_ALLOWED_CALLS_KEY => DEFAULT_MAX_CALLS,
          ALLOW_CALLS_KEY => DEFAULT_ALLOW_CALLS)
      end

      #
      # Looks for duplicate calls within the body of the method context.
      #
      # @return [Array<SmellWarning>]
      #
      def sniff
        collector = CallCollector.new(context, max_allowed_calls, allow_calls)
        collector.smelly_calls.map do |found_call|
          call = found_call.call
          occurs = found_call.occurs
          smell_warning(
            lines: found_call.lines,
            message: "calls '#{call}' #{occurs} times",
            parameters: { name: call, count: occurs })
        end
      end

      private

      def max_allowed_calls
        value(MAX_ALLOWED_CALLS_KEY, context)
      end

      def allow_calls
        value(ALLOW_CALLS_KEY, context)
      end

      # Collects information about a single found call
      class FoundCall
        def initialize(call_node)
          @call_node = call_node
          @occurrences = []
        end

        def record(occurence)
          occurrences.push occurence
        end

        def call
          @call ||= call_node.format_to_ruby
        end

        def occurs
          occurrences.length
        end

        def lines
          occurrences.map(&:line)
        end

        private

        attr_reader :call_node, :occurrences
      end

      private_constant :FoundCall

      # Collects all calls in a given context
      class CallCollector
        attr_reader :context

        def initialize(context, max_allowed_calls, allow_calls)
          @context = context
          @max_allowed_calls = max_allowed_calls
          @allow_calls = allow_calls
        end

        def calls
          result = Hash.new { |hash, key| hash[key] = FoundCall.new(key) }
          collect_calls(result)
          result.values.sort_by(&:call)
        end

        def smelly_calls
          calls.select { |found_call| smelly_call? found_call }
        end

        private

        attr_reader :allow_calls, :max_allowed_calls

        # @quality :reek:TooManyStatements { max_statements: 6 }
        # @quality :reek:DuplicateMethodCall { max_calls: 2 }
        def collect_calls(result)
          context.local_nodes(:send, [:mlhs]) do |call_node|
            next if call_node.object_creation_call?
            next if simple_method_call? call_node

            result[call_node].record(call_node)
          end
          context.local_nodes(:block) do |call_node|
            result[call_node].record(call_node)
          end
        end

        def smelly_call?(found_call)
          found_call.occurs > max_allowed_calls && !allow_calls?(found_call.call)
        end

        # @quality :reek:UtilityFunction
        def simple_method_call?(call_node)
          !call_node.receiver && call_node.args.empty?
        end

        def allow_calls?(method)
          allow_calls.any? { |allow| /#{allow}/ =~ method }
        end
      end

      private_constant :CallCollector
    end
  end
end