lib/rubocop/cop/rspec/change_by_zero.rb



# frozen_string_literal: true

module RuboCop
  module Cop
    module RSpec
      # Prefer negated matchers over `to change.by(0)`.
      #
      # In the case of composite expectations, cop suggest using the
      # negation matchers of `RSpec::Matchers#change`.
      #
      # By default the cop does not support autocorrect of
      # compound expectations, but if you set the
      # negated matcher for `change`, e.g. `not_change` with
      # the `NegatedMatcher` option, the cop will perform the autocorrection.
      #
      # @example NegatedMatcher: ~ (default)
      #   # bad
      #   expect { run }.to change(Foo, :bar).by(0)
      #   expect { run }.to change { Foo.bar }.by(0)
      #
      #   # bad - compound expectations (does not support autocorrection)
      #   expect { run }
      #     .to change(Foo, :bar).by(0)
      #     .and change(Foo, :baz).by(0)
      #   expect { run }
      #     .to change { Foo.bar }.by(0)
      #     .and change { Foo.baz }.by(0)
      #
      #   # good
      #   expect { run }.not_to change(Foo, :bar)
      #   expect { run }.not_to change { Foo.bar }
      #
      #   # good - compound expectations
      #   define_negated_matcher :not_change, :change
      #   expect { run }
      #     .to not_change(Foo, :bar)
      #     .and not_change(Foo, :baz)
      #   expect { run }
      #     .to not_change { Foo.bar }
      #     .and not_change { Foo.baz }
      #
      # @example NegatedMatcher: not_change
      #   # bad (support autocorrection to good case)
      #   expect { run }
      #     .to change(Foo, :bar).by(0)
      #     .and change(Foo, :baz).by(0)
      #   expect { run }
      #     .to change { Foo.bar }.by(0)
      #     .and change { Foo.baz }.by(0)
      #
      #   # good
      #   define_negated_matcher :not_change, :change
      #   expect { run }
      #     .to not_change(Foo, :bar)
      #     .and not_change(Foo, :baz)
      #   expect { run }
      #     .to not_change { Foo.bar }
      #     .and not_change { Foo.baz }
      #
      class ChangeByZero < Base
        extend AutoCorrector
        include RangeHelp

        MSG = 'Prefer `not_to change` over `to %<method>s.by(0)`.'
        MSG_COMPOUND = 'Prefer %<preferred>s with compound expectations ' \
                       'over `%<method>s.by(0)`.'
        CHANGE_METHODS = Set[:change, :a_block_changing, :changing].freeze
        RESTRICT_ON_SEND = CHANGE_METHODS.freeze

        # @!method expect_change_with_arguments(node)
        def_node_matcher :expect_change_with_arguments, <<~PATTERN
          (send
            $(send nil? CHANGE_METHODS ...) :by
            (int 0))
        PATTERN

        # @!method expect_change_with_block(node)
        def_node_matcher :expect_change_with_block, <<~PATTERN
          (send
            (block
              $(send nil? CHANGE_METHODS)
              (args)
              (send (...) _)) :by
            (int 0))
        PATTERN

        # @!method change_nodes(node)
        def_node_search :change_nodes, <<~PATTERN
          $(send nil? CHANGE_METHODS ...)
        PATTERN

        def on_send(node)
          expect_change_with_arguments(node.parent) do |change|
            register_offense(node.parent, change)
          end

          expect_change_with_block(node.parent.parent) do |change|
            register_offense(node.parent.parent, change)
          end
        end

        private

        def register_offense(node, change_node)
          return unless node.parent.send_type?

          if compound_expectations?(node)
            add_offense(node,
                        message: message_compound(change_node)) do |corrector|
              autocorrect_compound(corrector, node)
            end
          else
            add_offense(node,
                        message: message(change_node)) do |corrector|
              autocorrect(corrector, node, change_node)
            end
          end
        end

        def compound_expectations?(node)
          %i[and or & |].include?(node.parent.method_name)
        end

        def message(change_node)
          format(MSG, method: change_node.method_name)
        end

        def message_compound(change_node)
          format(MSG_COMPOUND, preferred: preferred_method,
                               method: change_node.method_name)
        end

        def autocorrect(corrector, node, change_node)
          corrector.replace(node.parent.loc.selector, 'not_to')
          corrector.replace(change_node.loc.selector, 'change')
          range = node.loc.dot.with(end_pos: node.source_range.end_pos)
          corrector.remove(range)
        end

        def autocorrect_compound(corrector, node)
          return unless negated_matcher

          change_nodes(node) do |change_node|
            corrector.replace(change_node.loc.selector, negated_matcher)
            insert_operator(corrector, node, change_node)
            remove_by_zero(corrector, node, change_node)
          end
        end

        def insert_operator(corrector, node, change_node)
          operator = node.right_siblings.first
          return unless %i[& |].include?(operator)

          corrector.insert_after(
            replace_node(node, change_node), " #{operator}"
          )
        end

        def replace_node(node, change_node)
          expect_change_with_arguments(node) ? change_node : change_node.parent
        end

        def remove_by_zero(corrector, node, change_node)
          range = node.loc.dot.with(end_pos: node.source_range.end_pos)
          if change_node.loc.line == range.line
            corrector.remove(range)
          else
            corrector.remove(
              range_by_whole_lines(range, include_final_newline: true)
            )
          end
        end

        def negated_matcher
          cop_config['NegatedMatcher']
        end

        def preferred_method
          negated_matcher ? "`#{negated_matcher}`" : 'negated matchers'
        end
      end
    end
  end
end