lib/rubocop/cop/sorbet/signatures/enforce_signatures.rb



# frozen_string_literal: true

require "stringio"

module RuboCop
  module Cop
    module Sorbet
      # Checks that every method definition and attribute accessor has a Sorbet signature.
      #
      # It also suggest an autocorrect with placeholders so the following code:
      #
      # ```
      # def foo(a, b, c); end
      # ```
      #
      # Will be corrected as:
      #
      # ```
      # sig { params(a: T.untyped, b: T.untyped, c: T.untyped).returns(T.untyped)
      # def foo(a, b, c); end
      # ```
      #
      # You can configure the placeholders used by changing the following options:
      #
      # * `ParameterTypePlaceholder`: placeholders used for parameter types (default: 'T.untyped')
      # * `ReturnTypePlaceholder`: placeholders used for return types (default: 'T.untyped')
      class EnforceSignatures < ::RuboCop::Cop::Base
        extend AutoCorrector
        include SignatureHelp

        RBS_COMMENT_REGEX = /^#:.*$/

        def initialize(config = nil, options = nil)
          super(config, options)
          @last_sig_for_scope = {}
        end

        # @!method accessor?(node)
        def_node_matcher(:accessor?, <<-PATTERN)
          (send nil? {:attr_reader :attr_writer :attr_accessor} ...)
        PATTERN

        def on_def(node)
          check_node(node)
        end

        def on_defs(node)
          check_node(node)
        end

        def on_send(node)
          check_node(node) if accessor?(node)
        end

        def on_signature(node)
          @last_sig_for_scope[scope(node)] = node
        end

        def scope(node)
          return unless node.parent
          return node.parent if [:begin, :block, :class, :module].include?(node.parent.type)

          scope(node.parent)
        end

        private

        def check_node(node)
          scope = self.scope(node)
          unless @last_sig_for_scope[scope] || has_rbs_comment?(node)
            add_offense(
              node,
              message: "Each method is required to have a signature.",
            ) do |corrector|
              autocorrect(corrector, node)
            end
          end
          @last_sig_for_scope[scope] = nil
        end

        def has_rbs_comment?(node)
          return false unless cop_config["AllowRBS"] == true

          node = node.parent while RuboCop::AST::SendNode === node.parent
          return false if (comments = preceeding_comments(node)).empty?

          last_comment = comments.last
          return false if last_comment.loc.line + 1 < node.loc.line

          comments.any? { |comment| RBS_COMMENT_REGEX.match?(comment.text) }
        end

        def preceeding_comments(node)
          processed_source.ast_with_comments[node].select { |comment| comment.loc.line < node.loc.line }
        end

        def autocorrect(corrector, node)
          suggest = SigSuggestion.new(node.loc.column, param_type_placeholder, return_type_placeholder)

          if node.is_a?(RuboCop::AST::DefNode) # def something
            node.arguments.each do |arg|
              suggest.params << arg.children.first
            end
          elsif accessor?(node) # attr reader, writer, accessor
            method = node.children[1]
            symbol = node.children[2]
            suggest.params << symbol.value if symbol && (method == :attr_writer || method == :attr_accessor)
            suggest.returns = "void" if method == :attr_writer
          end

          corrector.insert_before(node, suggest.to_autocorrect)
        end

        def param_type_placeholder
          cop_config["ParameterTypePlaceholder"] || "T.untyped"
        end

        def return_type_placeholder
          cop_config["ReturnTypePlaceholder"] || "T.untyped"
        end

        class SigSuggestion
          attr_accessor :params, :returns

          def initialize(indent, param_placeholder, return_placeholder)
            @params = []
            @returns = nil
            @indent = indent
            @param_placeholder = param_placeholder
            @return_placeholder = return_placeholder
          end

          def to_autocorrect
            out = StringIO.new
            out << "sig { "
            out << generate_params
            out << generate_return
            out << " }\n"
            out << " " * @indent # preserve indent for the next line
            out.string
          end

          private

          def generate_params
            return if @params.empty?

            out = StringIO.new
            out << "params("
            out << @params.map do |param|
              "#{param}: #{@param_placeholder}"
            end.join(", ")
            out << ")."
            out.string
          end

          def generate_return
            return "returns(#{@return_placeholder})" if @returns.nil?
            return @returns if @returns == "void"

            "returns(#{@returns})"
          end
        end
      end
    end
  end
end