lib/rubocop/cop/sorbet/callback_conditionals_binding.rb



# 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