lib/rubocop/cop/style/invertible_unless_condition.rb
# frozen_string_literal: true
module RuboCop
module Cop
module Style
# Checks for usages of `unless` which can be replaced by `if` with inverted condition.
# Code without `unless` is easier to read, but that is subjective, so this cop
# is disabled by default.
#
# Methods that can be inverted should be defined in `InverseMethods`. Note that
# the relationship of inverse methods needs to be defined in both directions.
# For example,
#
# [source,yaml]
# ----
# InverseMethods:
# :!=: :==
# :even?: :odd?
# :odd?: :even?
# ----
#
# will suggest both `even?` and `odd?` to be inverted, but only `!=` (and not `==`).
#
# @safety
# This cop is unsafe because it cannot be guaranteed that the method
# and its inverse method are both defined on receiver, and also are
# actually inverse of each other.
#
# @example
# # bad (simple condition)
# foo unless !bar
# foo unless x != y
# foo unless x >= 10
# foo unless x.even?
# foo unless odd?
#
# # good
# foo if bar
# foo if x == y
# foo if x < 10
# foo if x.odd?
# foo if even?
#
# # bad (complex condition)
# foo unless x != y || x.even?
#
# # good
# foo if x == y && x.odd?
#
# # good (if)
# foo if !condition
#
class InvertibleUnlessCondition < Base
extend AutoCorrector
MSG = 'Prefer `%<prefer>s` over `%<current>s`.'
def on_if(node)
return unless node.unless?
condition = node.condition
return unless invertible?(condition)
message = format(MSG, prefer: "#{node.inverse_keyword} #{preferred_condition(condition)}",
current: "#{node.keyword} #{condition.source}")
add_offense(node, message: message) do |corrector|
corrector.replace(node.loc.keyword, node.inverse_keyword)
autocorrect(corrector, condition)
end
end
private
def invertible?(node)
case node.type
when :begin
invertible?(node.children.first)
when :send
return false if inheritance_check?(node)
node.method?(:!) || inverse_methods.key?(node.method_name)
when :or, :and
invertible?(node.lhs) && invertible?(node.rhs)
else
false
end
end
def inheritance_check?(node)
argument = node.first_argument
node.method?(:<) &&
(argument.const_type? && argument.short_name.to_s.upcase != argument.short_name.to_s)
end
def preferred_condition(node)
case node.type
when :begin then "(#{preferred_condition(node.children.first)})"
when :send then preferred_send_condition(node)
when :or, :and then preferred_logical_condition(node)
end
end
def preferred_send_condition(node) # rubocop:disable Metrics/CyclomaticComplexity
receiver_source = node.receiver&.source
return receiver_source if node.method?(:!)
# receiver may be implicit (self)
dotted_receiver_source = receiver_source ? "#{receiver_source}." : ''
inverse_method_name = inverse_methods[node.method_name]
return "#{dotted_receiver_source}#{inverse_method_name}" unless node.arguments?
argument_list = node.arguments.map(&:source).join(', ')
if node.operator_method?
return "#{receiver_source} #{inverse_method_name} #{argument_list}"
end
if node.parenthesized?
return "#{dotted_receiver_source}#{inverse_method_name}(#{argument_list})"
end
"#{dotted_receiver_source}#{inverse_method_name} #{argument_list}"
end
def preferred_logical_condition(node)
preferred_lhs = preferred_condition(node.lhs)
preferred_rhs = preferred_condition(node.rhs)
"#{preferred_lhs} #{node.inverse_operator} #{preferred_rhs}"
end
def autocorrect(corrector, node)
case node.type
when :begin
autocorrect(corrector, node.children.first)
when :send
autocorrect_send_node(corrector, node)
when :or, :and
corrector.replace(node.loc.operator, node.inverse_operator)
autocorrect(corrector, node.lhs)
autocorrect(corrector, node.rhs)
end
end
def autocorrect_send_node(corrector, node)
if node.method?(:!)
corrector.remove(node.loc.selector)
else
corrector.replace(node.loc.selector, inverse_methods[node.method_name])
end
end
def inverse_methods
@inverse_methods ||= cop_config['InverseMethods']
end
end
end
end
end