forked from rubocop/rubocop
-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add support for Ruby LSP as a built-in add-on
## Summary This PR adds support for the Ruby LSP Add-on: https://shopify.github.io/ruby-lsp/add-ons.html Ruby LSP is widely used among Ruby developers. This Add-on resolves the current dependency of Ruby LSP on RuboCop's internal APIs by allowing RuboCop itself to provide the features related to Ruby LSP, marking the start of potentially removing these dependencies in the future. It may also be possible to integrate the Standard Ruby's LSP implementation into this RuboCop's LSP implementation at a later time. A future is envisioned in which Ruby LSP, Standard Ruby, and RuboCop don't each need to implement their own RuboCop runner for LSP but instead unify that functionality. An option is available so it can work with both RuboCop's built-in LSP and Ruby LSP. RuboCop's built-in LSP will continue to serve as a lightweight LSP for users who only need RuboCop. ## References - standardrb/standard#630 - https://github.com/standardrb/standard/wiki/IDE:-vscode#using-ruby-lsp ## NOTE This PR is split into multiple commits around three core PRs related to Standard Ruby. Please don't squash these commits, as the separation is intentional. Documentation will likely be covered later in conjunction with Ruby LSP.
- Loading branch information
Showing
6 changed files
with
341 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
* [#13628](https://github.com/rubocop/rubocop/pull/13628): Add support for Ruby LSP as a built-in add-on. ([@koic][]) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
s = 'hi' | ||
puts s |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,154 @@ | ||
# frozen_string_literal: true | ||
|
||
# NOTE: These don't work in Ruby LSP. | ||
return if RUBY_VERSION < '3.0' || RUBY_ENGINE == 'jruby' || RuboCop::Platform.windows? | ||
|
||
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 |