troessner/reek

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

Summary

Maintainability
A
35 mins
Test Coverage
# frozen_string_literal: true

require_relative 'base_detector'

module Reek
  module SmellDetectors
    #
    # A Nested Iterator occurs when a block contains another block.
    #
    # +NestedIterators+ reports failing methods only once.
    #
    # See {file:docs/Nested-Iterators.md} for details.
    class NestedIterators < BaseDetector
      # Struct for conveniently associating iterators with their depth (that is, their nesting).
      Iterator = Struct.new :exp, :depth do
        def line
          exp.line
        end
      end

      # The name of the config field that sets the maximum depth
      # of nested iterators to be permitted within any single method.
      MAX_ALLOWED_NESTING_KEY = 'max_allowed_nesting'
      DEFAULT_MAX_ALLOWED_NESTING = 1

      # The name of the config field that sets the names of any
      # methods for which nesting should not be considered
      IGNORE_ITERATORS_KEY = 'ignore_iterators'
      DEFAULT_IGNORE_ITERATORS = ['tap', 'Tempfile.create'].freeze

      def self.default_config
        super.merge(
          MAX_ALLOWED_NESTING_KEY => DEFAULT_MAX_ALLOWED_NESTING,
          IGNORE_ITERATORS_KEY => DEFAULT_IGNORE_ITERATORS)
      end

      # Generates a smell warning for each independent deepest nesting depth
      # that is greater than our allowed maximum. This means if two iterators
      # with the same depth were found, we combine them into one warning and
      # merge the line information.
      #
      # @return [Array<SmellWarning>]
      #
      def sniff
        find_violations.group_by(&:depth).map do |depth, group|
          lines = group.map(&:line)
          smell_warning(
            lines: lines,
            message: "contains iterators nested #{depth} deep",
            parameters: { depth: depth })
        end
      end

      private

      # Finds the set of independent most deeply nested iterators that are
      # nested more deeply than allowed.
      #
      # Here, independent means that if iterator A is contained within iterator
      # B, we only include A. But if iterators A and B are both contained in
      # iterator C, but A is not contained in B, nor B in A, both A and B are
      # included.
      #
      # @return [Array<Iterator>]
      #
      def find_violations
        find_candidates.select { |it| it.depth > max_allowed_nesting }
      end

      # Finds the set of independent most deeply nested iterators regardless of
      # nesting depth.
      #
      # @return [Array<Iterator>]
      #
      def find_candidates
        scout(exp: expression, depth: 0)
      end

      # A little digression into parser's sexp is necessary here:
      #
      # Given
      #   foo.each() do ... end
      # this will end up as:
      #
      # "foo.each() do ... end" -> one of the :block nodes
      # "each()"                -> the node's "call"
      # "do ... end"            -> the node's "block"
      #
      # @param exp [AST::Node]
      #   The given expression to analyze.
      #
      # @param depth [Integer]
      #
      # @return [Array<Iterator>]
      #
      # @quality :reek:TooManyStatements { max_statements: 6 }
      def scout(exp:, depth:)
        return [] unless exp

        # Find all non-nested blocks in this expression
        exp.each_node([:block], [:block]).flat_map do |iterator|
          new_depth = increment_depth(iterator, depth)
          # 1st case: we recurse down the given block of the iterator. In this case
          # we need to check if we should increment the depth.
          # 2nd case: we recurse down the associated call of the iterator. In this case
          # the depth stays the same.
          nested_iterators = scout(exp: iterator.block, depth: new_depth) +
            scout(exp: iterator.call, depth: depth)
          if nested_iterators.empty?
            Iterator.new(iterator, new_depth)
          else
            nested_iterators
          end
        end
      end

      def ignore_iterators
        @ignore_iterators ||= value(IGNORE_ITERATORS_KEY, context)
      end

      def increment_depth(iterator, depth)
        ignored_iterator?(iterator) ? depth : depth + 1
      end

      def max_allowed_nesting
        @max_allowed_nesting ||= value(MAX_ALLOWED_NESTING_KEY, context)
      end

      # @quality :reek:FeatureEnvy
      def ignored_iterator?(exp)
        ignore_iterators.any? do |pattern|
          /#{pattern}/ =~ exp.call.format_to_ruby
        end || exp.without_block_arguments?
      end
    end
  end
end