# 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 MSG = 'Prefer `not_to change` over `to %s.by(0)`.' MSG_COMPOUND = 'Prefer %s with compound expectations ' \ 'over `%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 # rubocop:disable Metrics/MethodLength def register_offense(node, change_node) if compound_expectations?(node) add_offense(node.source_range, message: message_compound(change_node)) do |corrector| autocorrect_compound(corrector, node) end else add_offense(node.source_range, message: message(change_node)) do |corrector| autocorrect(corrector, node, change_node) end end end # rubocop:enable Metrics/MethodLength 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) range = node.loc.dot.with(end_pos: node.source_range.end_pos) corrector.remove(range) end end def negated_matcher cop_config['NegatedMatcher'] end def preferred_method negated_matcher ? "`#{negated_matcher}`" : 'negated matchers' end end end end end