Skip to content

Commit

Permalink
Add support for Ruby LSP as a built-in add-on
Browse files Browse the repository at this point in the history
## 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
koic committed Dec 26, 2024
1 parent e6c7ee2 commit 6c24d10
Show file tree
Hide file tree
Showing 6 changed files with 339 additions and 0 deletions.
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ gem 'rspec', '~> 3.7'
gem 'rubocop-performance', '~> 1.23.0'
gem 'rubocop-rake', '~> 0.6.0'
gem 'rubocop-rspec', '~> 3.3.0'
# NOTE: These don't work in Ruby LSP.
gem 'ruby-lsp', '~> 0.17' if RUBY_VERSION < '3.0' || RUBY_ENGINE == 'jruby'
gem 'simplecov', '~> 0.20'
gem 'stackprof', platform: :mri
gem 'test-queue'
Expand Down
1 change: 1 addition & 0 deletions changelog/new_support_ruby_lsp_addon.md
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][])
73 changes: 73 additions & 0 deletions lib/ruby_lsp/rubocop/addon.rb
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
107 changes: 107 additions & 0 deletions lib/ruby_lsp/rubocop/wraps_built_in_lsp_runtime.rb
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
2 changes: 2 additions & 0 deletions spec/fixtures/ruby_lsp/example.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
s = 'hi'
puts s
154 changes: 154 additions & 0 deletions spec/ruby_lsp/rubocop/addon_spec.rb
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

0 comments on commit 6c24d10

Please sign in to comment.