rubocop-hq/rubocop

View on GitHub
lib/rubocop/cop/lint/unreachable_loop.rb

Summary

Maintainability
A
1 hr
Test Coverage
A
100%
# frozen_string_literal: true

module RuboCop
  module Cop
    module Lint
      # Checks for loops that will have at most one iteration.
      #
      # A loop that can never reach the second iteration is a possible error in the code.
      # In rare cases where only one iteration (or at most one iteration) is intended behavior,
      # the code should be refactored to use `if` conditionals.
      #
      # NOTE: Block methods that are used with ``Enumerable``s are considered to be loops.
      #
      # `AllowedPatterns` can be used to match against the block receiver in order to allow
      # code that would otherwise be registered as an offense (eg. `times` used not in an
      # `Enumerable` context).
      #
      # @example
      #   # bad
      #   while node
      #     do_something(node)
      #     node = node.parent
      #     break
      #   end
      #
      #   # good
      #   while node
      #     do_something(node)
      #     node = node.parent
      #   end
      #
      #   # bad
      #   def verify_list(head)
      #     item = head
      #     begin
      #       if verify(item)
      #         return true
      #       else
      #         return false
      #       end
      #     end while(item)
      #   end
      #
      #   # good
      #   def verify_list(head)
      #     item = head
      #     begin
      #       if verify(item)
      #         item = item.next
      #       else
      #         return false
      #       end
      #     end while(item)
      #
      #     true
      #   end
      #
      #   # bad
      #   def find_something(items)
      #     items.each do |item|
      #       if something?(item)
      #         return item
      #       else
      #         raise NotFoundError
      #       end
      #     end
      #   end
      #
      #   # good
      #   def find_something(items)
      #     items.each do |item|
      #       if something?(item)
      #         return item
      #       end
      #     end
      #     raise NotFoundError
      #   end
      #
      #   # bad
      #   2.times { raise ArgumentError }
      #
      # @example AllowedPatterns: ['(exactly|at_least|at_most)\(\d+\)\.times'] (default)
      #
      #   # good
      #   exactly(2).times { raise StandardError }
      class UnreachableLoop < Base
        include AllowedPattern

        MSG = 'This loop will have at most one iteration.'
        CONTINUE_KEYWORDS = %i[next redo].freeze

        def on_while(node)
          check(node)
        end
        alias on_until on_while
        alias on_while_post on_while
        alias on_until_post on_while
        alias on_for on_while

        def on_block(node)
          check(node) if loop_method?(node)
        end

        def on_numblock(node)
          check(node) if loop_method?(node)
        end

        private

        def loop_method?(node)
          return false unless node.block_type? || node.numblock_type?

          send_node = node.send_node
          loopable = send_node.enumerable_method? || send_node.enumerator_method? ||
                     send_node.method?(:loop)
          loopable && !matches_allowed_pattern?(send_node.source)
        end

        def check(node)
          statements = statements(node)
          break_statement = statements.find { |statement| break_statement?(statement) }
          return unless break_statement

          unless preceded_by_continue_statement?(break_statement) ||
                 conditional_continue_keyword?(break_statement)
            add_offense(node)
          end
        end

        def statements(node)
          body = node.body

          if body.nil?
            []
          elsif body.begin_type?
            body.children
          else
            [body]
          end
        end

        # @!method break_command?(node)
        def_node_matcher :break_command?, <<~PATTERN
          {
            return break
            (send
             {nil? (const {nil? cbase} :Kernel)}
             {:raise :fail :throw :exit :exit! :abort}
             ...)
          }
        PATTERN

        def break_statement?(node)
          return true if break_command?(node)

          case node.type
          when :begin, :kwbegin
            statements = *node
            break_statement = statements.find { |statement| break_statement?(statement) }
            break_statement && !preceded_by_continue_statement?(break_statement)
          when :if
            check_if(node)
          when :case, :case_match
            check_case(node)
          else
            false
          end
        end

        def check_if(node)
          if_branch = node.if_branch
          else_branch = node.else_branch
          if_branch && else_branch && break_statement?(if_branch) && break_statement?(else_branch)
        end

        def check_case(node)
          else_branch = node.else_branch
          return false unless else_branch
          return false unless break_statement?(else_branch)

          branches = if node.case_type?
                       node.when_branches
                     else
                       node.in_pattern_branches
                     end

          branches.all? { |branch| branch.body && break_statement?(branch.body) }
        end

        def preceded_by_continue_statement?(break_statement)
          break_statement.left_siblings.any? do |sibling|
            # Numblocks have the arguments count as a number in the AST.
            next if sibling.is_a?(Integer)
            next if sibling.loop_keyword? || loop_method?(sibling)

            sibling.each_descendant(*CONTINUE_KEYWORDS).any?
          end
        end

        def conditional_continue_keyword?(break_statement)
          or_node = break_statement.each_descendant(:or).to_a.last

          or_node && CONTINUE_KEYWORDS.include?(or_node.rhs.type)
        end
      end
    end
  end
end