lib/ruby_lsp/requests/support/rubocop_runner.rb



# typed: strict
# frozen_string_literal: true

# If there's no top level Gemfile, don't load RuboCop from a global installation
begin
  Bundler.with_original_env { Bundler.default_gemfile }
rescue Bundler::GemfileNotFound
  return
end

# Ensure that RuboCop is available
begin
  require "rubocop"
rescue LoadError
  return
end

# Remember to update the version in the documentation (usage/dependency-compatibility section) if you change this
# Ensure that RuboCop is at least version 1.4.0
begin
  gem("rubocop", ">= 1.4.0")
rescue LoadError
  $stderr.puts "Incompatible RuboCop version. Ruby LSP requires >= 1.4.0"
  return
end

if RuboCop.const_defined?(:LSP) # This condition will be removed when requiring RuboCop >= 1.61.
  RuboCop::LSP.enable
end

module RubyLsp
  module Requests
    module Support
      class InternalRuboCopError < StandardError
        MESSAGE = <<~EOS
          An internal error occurred %s.
          Updating to a newer version of RuboCop may solve this.
          For more details, run RuboCop on the command line.
        EOS

        #: ((::RuboCop::ErrorWithAnalyzedFileLocation | StandardError) rubocop_error) -> void
        def initialize(rubocop_error)
          message = case rubocop_error
          when ::RuboCop::ErrorWithAnalyzedFileLocation
            format(MESSAGE, "for the #{rubocop_error.cop.name} cop")
          when StandardError
            format(MESSAGE, rubocop_error.message)
          end
          super(message)
        end
      end

      # :nodoc:
      class RuboCopRunner < ::RuboCop::Runner
        class ConfigurationError < StandardError; end

        DEFAULT_ARGS = [
          "--stderr", # Print any output to stderr so that our stdout does not get polluted
          "--force-exclusion",
          "--format",
          "RuboCop::Formatter::BaseFormatter", # Suppress any output by using the base formatter
        ] #: Array[String]

        #: Array[::RuboCop::Cop::Offense]
        attr_reader :offenses

        #: ::RuboCop::Config
        attr_reader :config_for_working_directory

        begin
          ::RuboCop::Options.new.parse(["--raise-cop-error"])
          DEFAULT_ARGS << "--raise-cop-error"
        rescue OptionParser::InvalidOption
          # older versions of RuboCop don't support this flag
        end
        DEFAULT_ARGS.freeze

        #: (*String args) -> void
        def initialize(*args)
          @options = {} #: Hash[Symbol, untyped]
          @offenses = [] #: Array[::RuboCop::Cop::Offense]
          @errors = [] #: Array[String]
          @warnings = [] #: Array[String]
          # @prism_result = nil #: Prism::ParseLexResult?

          args += DEFAULT_ARGS
          rubocop_options = ::RuboCop::Options.new.parse(args).first

          config_store = ::RuboCop::ConfigStore.new
          config_store.options_config = rubocop_options[:config] if rubocop_options[:config]
          @config_for_working_directory = config_store.for_pwd #: ::RuboCop::Config

          super(rubocop_options, config_store)
        end

        #: (String, String, Prism::ParseLexResult) -> void
        def run(path, contents, prism_result)
          # Clear Runner state between runs since we get a single instance of this class
          # on every use site.
          @errors = []
          @warnings = []
          @offenses = []
          @options[:stdin] = contents

          # Setting the Prism result before running the RuboCop runner makes it reuse the existing AST and avoids
          # double-parsing. Unfortunately, this leads to a bunch of cops failing to execute properly under LSP mode.
          # Uncomment this once reusing the Prism result is more stable
          # @prism_result = prism_result

          super([path])

          # RuboCop rescues interrupts and then sets the `@aborting` variable to true. We don't want them to be rescued,
          # so here we re-raise in case RuboCop received an interrupt.
          raise Interrupt if aborting?
        rescue ::RuboCop::Runner::InfiniteCorrectionLoop => error
          raise Formatting::Error, error.message
        rescue ::RuboCop::ValidationError => error
          raise ConfigurationError, error.message
        rescue StandardError => error
          # Maintain the original backtrace so that debugging cops that are breaking is easier, but re-raise as a
          # different error class
          internal_error = InternalRuboCopError.new(error)
          internal_error.set_backtrace(error.backtrace)
          raise internal_error
        end

        #: -> String
        def formatted_source
          @options[:stdin]
        end

        class << self
          #: (String cop_name) -> singleton(::RuboCop::Cop::Base)?
          def find_cop_by_name(cop_name)
            cop_registry[cop_name]&.first
          end

          private

          #: -> Hash[String, [singleton(::RuboCop::Cop::Base)]]
          def cop_registry
            @cop_registry ||= ::RuboCop::Cop::Registry.global.to_h #: Hash[String, [singleton(::RuboCop::Cop::Base)]]?
          end
        end

        private

        #: (String _file, Array[::RuboCop::Cop::Offense] offenses) -> void
        def file_finished(_file, offenses)
          @offenses = offenses
        end
      end
    end
  end
end