# frozen_string_literal: true
module RuboCop
module Cop
module Sorbet
# Ensures that callback conditionals are bound to the right type
# so that they are type checked properly.
#
# Auto-correction is unsafe because other libraries define similar style callbacks as Rails, but don't always need
# binding to the attached class. Auto-correcting those usages can lead to false positives and auto-correction
# introduces new typing errors.
#
# @example
#
# # bad
# class Post < ApplicationRecord
# before_create :do_it, if: -> { should_do_it? }
#
# def should_do_it?
# true
# end
# end
#
# # good
# class Post < ApplicationRecord
# before_create :do_it, if: -> {
# T.bind(self, Post)
# should_do_it?
# }
#
# def should_do_it?
# true
# end
# end
class CallbackConditionalsBinding < RuboCop::Cop::Cop # rubocop:todo InternalAffairs/InheritDeprecatedCopClass
CALLBACKS = [
:validate,
:validates,
:validates_with,
:before_validation,
:around_validation,
:before_create,
:before_save,
:before_destroy,
:before_update,
:after_create,
:after_save,
:after_destroy,
:after_update,
:after_touch,
:after_initialize,
:after_find,
:around_create,
:around_save,
:around_destroy,
:around_update,
:before_commit,
:after_commit,
:after_create_commit,
:after_destroy_commit,
:after_rollback,
:after_save_commit,
:after_update_commit,
:before_action,
:prepend_before_action,
:append_before_action,
:around_action,
:prepend_around_action,
:append_around_action,
:after_action,
:prepend_after_action,
:append_after_action,
].freeze
def autocorrect(node)
lambda do |corrector|
options = node.each_child_node.find(&:hash_type?)
conditional = nil
options.each_pair do |keyword, block|
if keyword.value == :if || keyword.value == :unless
conditional = block
break
end
end
_, _, block = conditional.child_nodes
# Find the class node and check if it includes a namespace on the
# same line e.g.: Namespace::Class, which will require the fully
# qualified name
klass = node.ancestors.find(&:class_type?)
expected_class = if klass.children.first.children.first.nil?
node.parent_module_name.split("::").last
else
klass.identifier.source
end
do_end_lambda = conditional.source.include?("do") && conditional.source.include?("end")
unless do_end_lambda
# We are converting a one line lambda into a multiline
# Remove the space after the `{`
if /{\s/.match?(conditional.source)
corrector.remove_preceding(block, 1)
end
# Remove the last space and `}` and re-add it with a line break
# and the correct indentation
base_indentation = " " * node.loc.column
chars_to_remove = /\s}/.match?(conditional.source) ? 2 : 1
corrector.remove_trailing(conditional, chars_to_remove)
corrector.insert_after(block, "\n#{base_indentation}}")
end
# Add the T.bind
indentation = " " * (node.loc.column + 2)
line_start = do_end_lambda ? "" : "\n#{indentation}"
bind = "#{line_start}T.bind(self, #{expected_class})\n#{indentation}"
corrector.insert_before(block, bind)
end
end
def on_send(node)
return unless CALLBACKS.include?(node.method_name)
options = node.each_child_node.find(&:hash_type?)
return if options.nil?
conditional = nil
options.each_pair do |keyword, block|
next unless keyword.sym_type?
if keyword.value == :if || keyword.value == :unless
conditional = block
break
end
end
return if conditional.nil? || conditional.array_type? || conditional.child_nodes.empty?
return unless conditional.arguments.empty?
type, _, block = conditional.child_nodes
return unless type.lambda_or_proc? || type.block_literal?
klass = node.ancestors.find(&:class_type?)
expected_class = if klass&.children&.first&.children&.first.nil?
node.parent_module_name&.split("::")&.last
else
klass.identifier.source
end
return if expected_class.nil?
unless block.source.include?("T.bind(self")
add_offense(
node,
message: "Callback conditionals should be bound to the right type. Use T.bind(self, #{expected_class})",
)
end
end
end
end
end
end