diff --git a/Gemfile b/Gemfile index 0dd0555d66d4..c064b972448e 100644 --- a/Gemfile +++ b/Gemfile @@ -14,6 +14,7 @@ gem 'rspec', '~> 3.7' gem 'rubocop-performance', '~> 1.23.0' gem 'rubocop-rake', '~> 0.6.0' gem 'rubocop-rspec', '~> 3.3.0' +gem 'ruby-lsp', '~> 0.17' gem 'simplecov', '~> 0.20' gem 'stackprof', platform: :mri gem 'test-queue' diff --git a/changelog/new_support_ruby_lsp_addon.md b/changelog/new_support_ruby_lsp_addon.md new file mode 100644 index 000000000000..e20f7a212dd5 --- /dev/null +++ b/changelog/new_support_ruby_lsp_addon.md @@ -0,0 +1 @@ +* [#13628](https://github.com/rubocop/rubocop/pull/13628): Add support for Ruby LSP as a built-in add-on. ([@koic][]) diff --git a/lib/ruby_lsp/rubocop/addon.rb b/lib/ruby_lsp/rubocop/addon.rb new file mode 100644 index 000000000000..9d919f091d4e --- /dev/null +++ b/lib/ruby_lsp/rubocop/addon.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require_relative '../../rubocop' +require_relative '../../rubocop/lsp/logger' +require_relative 'wraps_built_in_lsp_runtime' + +module RubyLsp + module RuboCop + # A Ruby LSP add-on for RuboCop. + class Addon < RubyLsp::Addon + def initializer + @wraps_built_in_lsp_runtime = nil + end + + def name + 'RuboCop' + end + + def activate(global_state, message_queue) + ::RuboCop::LSP::Logger.log("Activating RuboCop LSP addon #{::RuboCop::Version::STRING}.") + + @wraps_built_in_lsp_runtime = WrapsBuiltinLspRuntime.new + + global_state.register_formatter('rubocop', @wraps_built_in_lsp_runtime) + + register_additional_file_watchers(global_state, message_queue) + + ::RuboCop::LSP::Logger.log("Initialized RuboCop LSP addon #{::RuboCop::Version::STRING}.") + end + + def deactivate + @wraps_built_in_lsp_runtime = nil + end + + # rubocop:disable Layout/LineLength, Metrics/MethodLength + def register_additional_file_watchers(global_state, message_queue) + return unless global_state.supports_watching_files + + message_queue << Request.new( + id: 'rubocop-file-watcher', + method: 'client/registerCapability', + params: Interface::RegistrationParams.new( + registrations: [ + Interface::Registration.new( + id: 'workspace/didChangeWatchedFilesRuboCop', + method: 'workspace/didChangeWatchedFiles', + register_options: Interface::DidChangeWatchedFilesRegistrationOptions.new( + watchers: [ + Interface::FileSystemWatcher.new( + glob_pattern: '**/.rubocop.yml', + kind: Constant::WatchKind::CREATE | Constant::WatchKind::CHANGE | Constant::WatchKind::DELETE + ) + ] + ) + ) + ] + ) + ) + end + # rubocop:enable Layout/LineLength, Metrics/MethodLength + + def workspace_did_change_watched_files(changes) + return unless changes.any? { |change| change[:uri].end_with?('.rubocop.yml') } + + @wraps_built_in_lsp_runtime.init! + + ::RuboCop::LSP::Logger(<<~MESSAGE) + Re-initialized RuboCop LSP addon #{::RuboCop::Version::STRING} due to .rubocop.yml file change. + MESSAGE + end + end + end +end diff --git a/lib/ruby_lsp/rubocop/wraps_built_in_lsp_runtime.rb b/lib/ruby_lsp/rubocop/wraps_built_in_lsp_runtime.rb new file mode 100644 index 000000000000..0274b1f6ff25 --- /dev/null +++ b/lib/ruby_lsp/rubocop/wraps_built_in_lsp_runtime.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +module RubyLsp + module RuboCop + # Wrap RuboCop's built-in runtime for Ruby LSP's add-on. + class WrapsBuiltinLspRuntime + include RubyLsp::Requests::Support::Formatter + + def initialize + init! + end + + def init! + config = ::RuboCop::ConfigStore.new + @runtime = ::RuboCop::LSP::Runtime.new(config) + @rubocop_config = config.for_pwd + @cop_registry = ::RuboCop::Cop::Registry.global.to_h + end + + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def run_diagnostic(uri, document) + offenses = @runtime.offenses(uri_to_path(uri), document.source) + + # rubocop:disable Metrics/BlockLength + offenses.map do |o| + cop_name = o[:cop_name] + + msg = o[:message].delete_prefix(cop_name) + loc = o[:location] + + severity = case o[:severity] + when 'error', 'fatal' + RubyLsp::Constant::DiagnosticSeverity::ERROR + when 'warning' + RubyLsp::Constant::DiagnosticSeverity::WARNING + when 'convention' + RubyLsp::Constant::DiagnosticSeverity::INFORMATION + when 'refactor', 'info' + RubyLsp::Constant::DiagnosticSeverity::HINT + else # the above cases fully cover what RuboCop sends at this time + logger.puts "Unknown severity: #{severity.inspect}" + RubyLsp::Constant::DiagnosticSeverity::HINT + end + + RubyLsp::Interface::Diagnostic.new( + code: cop_name, + code_description: code_description(cop_name), + message: msg, + source: 'RuboCop', + severity: severity, + range: RubyLsp::Interface::Range.new( + start: RubyLsp::Interface::Position.new( + line: loc[:start_line] - 1, character: loc[:start_column] - 1 + ), + + end: RubyLsp::Interface::Position.new( + line: loc[:last_line] - 1, character: loc[:last_column] + ) + ) + # TODO: We need to do something like to support quickfixes thru code actions + # See: https://github.com/Shopify/ruby-lsp/blob/4c1906172add4d5c39c35d3396aa29c768bfb898/lib/ruby_lsp/requests/support/rubocop_diagnostic.rb#L62 + # data: { + # correctable: correctable?(offense), + # code_actions: to_lsp_code_actions + # } + # + # Right now, our offenses are all just JSON parsed from stdout shelling to RuboCop, so + # it seems we don't have the corrector available to us. + # + # Lifted from: + # https://github.com/Shopify/ruby-lsp/blob/8d4c17efce4e8ecc8e7c557ab2981db6b22c0b6d/lib/ruby_lsp/requests/support/rubocop_diagnostic.rb#L201 + # def correctable?(offense) + # !offense.corrector.nil? + # end + ) + end + # rubocop:enable Metrics/BlockLength + end + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + + def run_formatting(uri, document) + @runtime.format(uri_to_path(uri), document.source, command: 'rubocop.formatAutocorrects') + end + + private + + # duplicated from: lib/standard/lsp/routes.rb + # modified to incorporate Ruby LSP's to_standardized_path method + def uri_to_path(uri) + if uri.respond_to?(:to_standardized_path) && (standardized_path = uri.to_standardized_path) + standardized_path + else + uri.to_s.delete_prefix('file://') + end + end + + # lifted from: + # https://github.com/Shopify/ruby-lsp/blob/4c1906172add4d5c39c35d3396aa29c768bfb898/lib/ruby_lsp/requests/support/rubocop_diagnostic.rb#L84 + def code_description(cop_name) + return unless (cop_class = @cop_registry[cop_name]&.first) + return unless (doc_url = cop_class.documentation_url(@rubocop_config)) + + Interface::CodeDescription.new(href: doc_url) + end + end + end +end diff --git a/spec/fixtures/ruby_lsp/example.rb b/spec/fixtures/ruby_lsp/example.rb new file mode 100644 index 000000000000..1110d83a7217 --- /dev/null +++ b/spec/fixtures/ruby_lsp/example.rb @@ -0,0 +1,2 @@ +s = 'hi' +puts s diff --git a/spec/ruby_lsp/rubocop/addon_spec.rb b/spec/ruby_lsp/rubocop/addon_spec.rb new file mode 100644 index 000000000000..2324984f5df8 --- /dev/null +++ b/spec/ruby_lsp/rubocop/addon_spec.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +require 'ruby_lsp/internal' +require 'ruby_lsp/rubocop/addon' + +describe 'RubyLSP::RuboCop::Addon', :isolated_environment, :lsp do + let(:addon) do + RubyLsp::RuboCop::Addon.new + end + + let(:source) do + <<~RUBY + s = "hello" + puts s + RUBY + end + + before do + # Suppress Ruby LSP's add-on logging. + allow(RuboCop::LSP::Logger).to receive(:log) + end + + describe 'Add-on name' do + it 'is RuboCop' do + expect(addon.name).to eq 'RuboCop' + end + end + + describe 'textDocument/diagnostic' do + subject(:result) do + with_server(source, 'example.rb') do |server, uri| + server.process_message( + id: 2, + method: 'textDocument/diagnostic', + params: { + textDocument: { + uri: uri + } + } + ) + + server.pop_response + end + end + + let(:first_item) { result.response.items.first } + let(:second_item) { result.response.items[1] } + + it 'has basic result information' do + expect(result).to be_an_instance_of(RubyLsp::Result) + expect(result.response.kind).to eq 'full' + expect(result.response.items.size).to eq 2 + end + + it 'has first diagnostic information' do + expect(first_item.range.start.to_hash).to eq({ line: 0, character: 0 }) + expect(first_item.range.end.to_hash).to eq({ line: 0, character: 1 }) + expect(first_item.severity).to eq RubyLsp::Constant::DiagnosticSeverity::INFORMATION + expect(first_item.code).to eq 'Style/FrozenStringLiteralComment' + expect(first_item.code_description.href).to eq 'https://docs.rubocop.org/rubocop/cops_style.html#stylefrozenstringliteralcomment' + expect(first_item.source).to eq 'RuboCop' + expect(first_item.message).to eq 'Missing frozen string literal comment.' + end + + it 'has second diagnostic information' do + second_item = result.response.items[1] + expect(second_item.range.start.to_hash).to eq({ line: 0, character: 4 }) + expect(second_item.range.end.to_hash).to eq({ line: 0, character: 11 }) + expect(second_item.severity).to eq RubyLsp::Constant::DiagnosticSeverity::INFORMATION + expect(second_item.code).to eq 'Style/StringLiterals' + expect(second_item.code_description.href).to eq 'https://docs.rubocop.org/rubocop/cops_style.html#stylestringliterals' + expect(second_item.source).to eq 'RuboCop' + expect(second_item.message).to eq <<~MESSAGE.chop + Prefer single-quoted strings when you don't need string interpolation or special symbols. + MESSAGE + end + end + + describe 'textDocument/formatting' do + subject(:result) do + with_server(source, 'example.rb') do |server, uri| + server.process_message( + id: 2, + method: 'textDocument/formatting', + params: { + textDocument: { uri: uri }, + position: { line: 0, character: 0 } + } + ) + + server.pop_response + end + end + + it 'has basic result information' do + expect(result).to be_an_instance_of(RubyLsp::Result) + expect(result.response.size).to eq 1 + end + + it 'has autocorrected code' do + expect(result.response.first.new_text).to eq <<~RUBY + s = 'hello' + puts s + RUBY + end + end + + private + + # Lifted from here, because we need to override the formatter to RuboCop in the spec helper: + # https://github.com/Shopify/ruby-lsp/blob/4c1906172add4d5c39c35d3396aa29c768bfb898/lib/ruby_lsp/test_helper.rb#L20 + # + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def with_server( + source = nil, path = 'fake.rb', pwd: '..', stub_no_typechecker: false, load_addons: true + ) + Dir.chdir(pwd) do + server = RubyLsp::Server.new(test_mode: true) + uri = URI(File.join(server.global_state.workspace_path, path)) + server.global_state.formatter = 'rubocop' + server.global_state.instance_variable_set(:@linters, ['rubocop']) + server.global_state.stubs(:typechecker).returns(false) if stub_no_typechecker + + if source + server.process_message( + method: 'textDocument/didOpen', + params: { + textDocument: { + uri: uri, + text: source, + version: 1 + } + } + ) + end + + server.global_state.index.index_single( + RubyIndexer::IndexablePath.new(nil, uri.to_standardized_path), source + ) + server.load_addons if load_addons + + yield server, uri + end + ensure + if load_addons + RubyLsp::Addon.addons.each(&:deactivate) + RubyLsp::Addon.addons.clear + end + end + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength +end