backus/rubocop-rspec

View on GitHub
lib/rubocop/cop/rspec/predicate_matcher.rb

Summary

Maintainability
A
1 hr
Test Coverage
# frozen_string_literal: true

module RuboCop
  module Cop
    module RSpec
      # A helper for `inflected` style
      module InflectedHelper
        include RuboCop::RSpec::Language
        extend NodePattern::Macros

        MSG_INFLECTED = 'Prefer using `%<matcher_name>s` matcher over ' \
                        '`%<predicate_name>s`.'

        private

        def check_inflected(node)
          predicate_in_actual?(node) do |predicate, to, matcher|
            msg = message_inflected(predicate)
            add_offense(node, message: msg) do |corrector|
              remove_predicate(corrector, predicate)
              corrector.replace(node.loc.selector,
                                true?(to, matcher) ? 'to' : 'not_to')
              rewrite_matcher(corrector, predicate, matcher)
            end
          end
        end

        # @!method predicate_in_actual?(node)
        def_node_matcher :predicate_in_actual?, <<~PATTERN
          (send
            (send nil? :expect {
              (block $(send !nil? #predicate? ...) ...)
              $(send !nil? #predicate? ...)})
            $#Runners.all
            $#boolean_matcher?)
        PATTERN

        # @!method be_bool?(node)
        def_node_matcher :be_bool?, <<~PATTERN
          (send nil? {:be :eq :eql :equal} {true false})
        PATTERN

        # @!method be_boolthy?(node)
        def_node_matcher :be_boolthy?, <<~PATTERN
          (send nil? {:be_truthy :be_falsey :be_falsy :a_truthy_value :a_falsey_value :a_falsy_value})
        PATTERN

        def boolean_matcher?(node)
          if cop_config['Strict']
            be_boolthy?(node)
          else
            be_bool?(node) || be_boolthy?(node)
          end
        end

        def predicate?(sym)
          sym.to_s.end_with?('?')
        end

        def message_inflected(predicate)
          format(MSG_INFLECTED,
                 predicate_name: predicate.method_name,
                 matcher_name: to_predicate_matcher(predicate.method_name))
        end

        # rubocop:disable Metrics/MethodLength
        def to_predicate_matcher(name)
          case name = name.to_s
          when 'is_a?'
            'be_a'
          when 'instance_of?'
            'be_an_instance_of'
          when 'include?', 'respond_to?'
            name[0..-2]
          when 'exist?', 'exists?'
            'exist'
          when /\Ahas_/
            name.sub('has_', 'have_')[0..-2]
          else
            "be_#{name[0..-2]}"
          end
        end
        # rubocop:enable Metrics/MethodLength

        def remove_predicate(corrector, predicate)
          range = predicate.loc.dot.with(
            end_pos: predicate.source_range.end_pos
          )

          corrector.remove(range)

          block_range = LocationHelp.block_with_whitespace(predicate)
          corrector.remove(block_range) if block_range
        end

        def rewrite_matcher(corrector, predicate, matcher)
          args = LocationHelp.arguments_with_whitespace(predicate).source
          block_loc = LocationHelp.block_with_whitespace(predicate)
          block = block_loc ? block_loc.source : ''

          corrector.replace(
            matcher,
            to_predicate_matcher(predicate.method_name) + args + block
          )
        end

        def true?(to_symbol, matcher)
          result = case matcher.method_name
                   when :be, :eq
                     matcher.first_argument.true_type?
                   when :be_truthy, :a_truthy_value
                     true
                   when :be_falsey, :be_falsy, :a_falsey_value, :a_falsy_value
                     false
                   end
          to_symbol == :to ? result : !result
        end
      end

      # A helper for `explicit` style
      module ExplicitHelper # rubocop:disable Metrics/ModuleLength
        include RuboCop::RSpec::Language
        extend NodePattern::Macros

        MSG_EXPLICIT = 'Prefer using `%<predicate_name>s` over ' \
                       '`%<matcher_name>s` matcher.'
        BUILT_IN_MATCHERS = %w[
          be_truthy be_falsey be_falsy
          have_attributes have_received
          be_between be_within
        ].freeze

        private

        def allowed_explicit_matchers
          cop_config.fetch('AllowedExplicitMatchers', []) + BUILT_IN_MATCHERS
        end

        def check_explicit(node) # rubocop:disable Metrics/MethodLength
          predicate_matcher_block?(node) do |actual, matcher|
            add_offense(node, message: message_explicit(matcher)) do |corrector|
              to_node = node.send_node
              corrector_explicit(corrector, to_node, actual, matcher, to_node)
            end
            ignore_node(node.children.first)
            return
          end

          return if part_of_ignored_node?(node)

          predicate_matcher?(node) do |actual, matcher|
            next unless replaceable_matcher?(matcher)

            add_offense(node, message: message_explicit(matcher)) do |corrector|
              next if uncorrectable_matcher?(node, matcher)

              corrector_explicit(corrector, node, actual, matcher, matcher)
            end
          end
        end

        def replaceable_matcher?(matcher)
          case matcher.method_name.to_s
          when 'include'
            matcher.arguments.one?
          else
            true
          end
        end

        def uncorrectable_matcher?(node, matcher)
          heredoc_argument?(matcher) && !same_line?(node, matcher)
        end

        def heredoc_argument?(matcher)
          matcher.arguments.select do |arg|
            arg.str_type? || arg.dstr_type? || arg.xstr_type?
          end.any?(&:heredoc?)
        end

        # @!method predicate_matcher?(node)
        def_node_matcher :predicate_matcher?, <<~PATTERN
          (send
            (send nil? :expect $!nil?)
            #Runners.all
            {$(send nil? #predicate_matcher_name? ...)
              (block $(send nil? #predicate_matcher_name? ...) ...)})
        PATTERN

        # @!method predicate_matcher_block?(node)
        def_node_matcher :predicate_matcher_block?, <<~PATTERN
          (block
            (send
              (send nil? :expect $!nil?)
              #Runners.all
              $(send nil? #predicate_matcher_name?))
            ...)
        PATTERN

        def predicate_matcher_name?(name)
          name = name.to_s

          return false if allowed_explicit_matchers.include?(name)

          (name.start_with?('be_', 'have_') && !name.end_with?('?')) ||
            %w[include respond_to].include?(name)
        end

        def message_explicit(matcher)
          format(MSG_EXPLICIT,
                 predicate_name: to_predicate_method(matcher.method_name),
                 matcher_name: matcher.method_name)
        end

        def corrector_explicit(corrector, to_node, actual, matcher, block_child)
          replacement_matcher = replacement_matcher(to_node)
          corrector.replace(matcher, replacement_matcher)
          move_predicate(corrector, actual, matcher, block_child)
          corrector.replace(to_node.loc.selector, 'to')
        end

        def move_predicate(corrector, actual, matcher, block_child)
          predicate = to_predicate_method(matcher.method_name)
          args = LocationHelp.arguments_with_whitespace(matcher).source
          block_loc = LocationHelp.block_with_whitespace(block_child)
          block = block_loc ? block_loc.source : ''

          corrector.remove(block_loc) if block_loc
          corrector.insert_after(actual, ".#{predicate}" + args + block)
        end

        # rubocop:disable Metrics/MethodLength
        def to_predicate_method(matcher)
          case matcher = matcher.to_s
          when 'be_a', 'be_an', 'be_a_kind_of', 'a_kind_of', 'be_kind_of'
            'is_a?'
          when 'be_an_instance_of', 'be_instance_of', 'an_instance_of'
            'instance_of?'
          when 'include'
            'include?'
          when 'respond_to'
            'respond_to?'
          when /\Ahave_(.+)/
            "has_#{Regexp.last_match(1)}?"
          else
            "#{matcher[/\Abe_(.+)/, 1]}?"
          end
        end
        # rubocop:enable Metrics/MethodLength

        def replacement_matcher(node)
          case [cop_config['Strict'], node.method?(:to)]
          when [true, true]
            'be(true)'
          when [true, false]
            'be(false)'
          when [false, true]
            'be_truthy'
          when [false, false]
            'be_falsey'
          end
        end
      end

      # Prefer using predicate matcher over using predicate method directly.
      #
      # RSpec defines magic matchers for predicate methods.
      # This cop recommends to use the predicate matcher instead of using
      # predicate method directly.
      #
      # @example Strict: true, EnforcedStyle: inflected (default)
      #   # bad
      #   expect(foo.something?).to be_truthy
      #
      #   # good
      #   expect(foo).to be_something
      #
      #   # also good - It checks "true" strictly.
      #   expect(foo.something?).to be(true)
      #
      # @example Strict: false, EnforcedStyle: inflected
      #   # bad
      #   expect(foo.something?).to be_truthy
      #   expect(foo.something?).to be(true)
      #
      #   # good
      #   expect(foo).to be_something
      #
      # @example Strict: true, EnforcedStyle: explicit
      #   # bad
      #   expect(foo).to be_something
      #
      #   # good - the above code is rewritten to it by this cop
      #   expect(foo.something?).to be(true)
      #
      #   # bad - no autocorrect
      #   expect(foo)
      #     .to be_something(<<~TEXT)
      #       bar
      #     TEXT
      #
      #   # good
      #   expect(foo.something?(<<~TEXT)).to be(true)
      #     bar
      #   TEXT
      #
      # @example Strict: false, EnforcedStyle: explicit
      #   # bad
      #   expect(foo).to be_something
      #
      #   # good - the above code is rewritten to it by this cop
      #   expect(foo.something?).to be_truthy
      #
      class PredicateMatcher < Base
        extend AutoCorrector
        include ConfigurableEnforcedStyle
        include InflectedHelper
        include ExplicitHelper

        RESTRICT_ON_SEND = Runners.all

        def on_send(node)
          case style
          when :inflected
            check_inflected(node)
          when :explicit
            check_explicit(node)
          end
        end

        def on_block(node) # rubocop:disable InternalAffairs/NumblockHandler
          check_explicit(node) if style == :explicit
        end
      end
    end
  end
end