Skip to content

Commit

Permalink
Merge pull request #630 from standardrb/ruby-lsp-spike
Browse files Browse the repository at this point in the history
Add support for Ruby LSP as a built-in add-on
  • Loading branch information
searls committed Jun 23, 2024
2 parents de139d7 + 5ce7a2b commit 710cb6f
Show file tree
Hide file tree
Showing 9 changed files with 298 additions and 2 deletions.
2 changes: 1 addition & 1 deletion .standard.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
ruby_version: 2.6
ruby_version: 3.0
ignore:
- tmp/**/*
- test/fixture/**/*
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ gem "bundler"
gem "minitest", "~> 5.0"
gem "rake", "~> 13.0"
gem "m"
gem "mutex_m"
gem "ruby-lsp"

# You may want to run these off path locally:
# gem "lint_roller", path: "../lint_roller"
Expand Down
9 changes: 9 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@ GEM
rake (>= 0.9.2.2)
method_source (1.0.0)
minitest (5.20.0)
mutex_m (0.2.0)
parallel (1.23.0)
parser (3.3.0.5)
ast (~> 2.4.1)
racc
prism (0.27.0)
racc (1.7.1)
rainbow (3.1.1)
rake (13.0.6)
Expand All @@ -46,13 +48,18 @@ GEM
rubocop-performance (1.21.0)
rubocop (>= 1.48.1, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
ruby-lsp (0.16.6)
language_server-protocol (~> 3.17.0)
prism (>= 0.23.0, < 0.28)
sorbet-runtime (>= 0.5.10782)
ruby-progressbar (1.13.0)
simplecov (0.22.0)
docile (~> 1.1)
simplecov-html (~> 0.11)
simplecov_json_formatter (~> 0.1)
simplecov-html (0.12.3)
simplecov_json_formatter (0.1.4)
sorbet-runtime (0.5.11385)
standard-custom (1.0.2)
lint_roller (~> 1.0)
rubocop (~> 1.50)
Expand All @@ -69,7 +76,9 @@ DEPENDENCIES
bundler
m
minitest (~> 5.0)
mutex_m
rake (~> 13.0)
ruby-lsp
simplecov
standard!

Expand Down
60 changes: 60 additions & 0 deletions lib/ruby_lsp/standard/addon.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
require "standard"
require_relative "wraps_built_in_lsp_standardizer"

module RubyLsp
module Standard
class Addon < ::RubyLsp::Addon
def initializer
@wraps_built_in_lsp_standardizer = nil
end

def name
"Standard Ruby"
end

def activate(global_state, message_queue)
warn "Activating Standard Ruby LSP addon v#{::Standard::VERSION}"
@wraps_built_in_lsp_standardizer = WrapsBuiltinLspStandardizer.new
global_state.register_formatter("standard", @wraps_built_in_lsp_standardizer)
register_additional_file_watchers(global_state, message_queue)
warn "Initialized Standard Ruby LSP addon #{::Standard::VERSION}"
end

def deactivate
@wraps_built_in_lsp_standardizer = nil
end

def register_additional_file_watchers(global_state, message_queue)
return unless global_state.supports_watching_files

message_queue << Request.new(
id: "standard-file-watcher",
method: "client/registerCapability",
params: Interface::RegistrationParams.new(
registrations: [
Interface::Registration.new(
id: "workspace/didChangeWatchedFilesStandard",
method: "workspace/didChangeWatchedFiles",
register_options: Interface::DidChangeWatchedFilesRegistrationOptions.new(
watchers: [
Interface::FileSystemWatcher.new(
glob_pattern: "**/.standard.yml",
kind: Constant::WatchKind::CREATE | Constant::WatchKind::CHANGE | Constant::WatchKind::DELETE
)
]
)
)
]
)
)
end

def workspace_did_change_watched_files(changes)
if changes.any? { |change| change[:uri].end_with?(".standard.yml") }
@wraps_built_in_lsp_standardizer.init!
warn "Re-initialized Standard Ruby LSP addon #{::Standard::VERSION} due to .standard.yml file change"
end
end
end
end
end
98 changes: 98 additions & 0 deletions lib/ruby_lsp/standard/wraps_built_in_lsp_standardizer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
module RubyLsp
module Standard
class WrapsBuiltinLspStandardizer
include RubyLsp::Requests::Support::Formatter
def initialize
init!
end

def init!
@config = ::Standard::BuildsConfig.new.call([])
@standardizer = ::Standard::Lsp::Standardizer.new(
@config,
::Standard::Lsp::Logger.new
)
@rubocop_config = @config.rubocop_config_store.for_pwd
@cop_registry = RuboCop::Cop::Registry.global.to_h
end

def run_formatting(uri, document)
@standardizer.format(uri_to_path(uri), document.source)
end

def run_diagnostic(uri, document)
offenses = @standardizer.offenses(uri_to_path(uri), document.source)

offenses.map { |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: "Standard Ruby",
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

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).nil?
standardized_path
else
uri.to_s.sub(%r{^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)
if (cop_class = @cop_registry[cop_name]&.first)
if (doc_url = cop_class.documentation_url(@rubocop_config))
Interface::CodeDescription.new(href: doc_url)
end
end
end
end
end
end
2 changes: 1 addition & 1 deletion lib/standard/plugin/merges_plugins_into_rubocop_config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ def blank_rubocop_config(example_config)
end

def except(hash_or_config, keys)
hash_or_config.to_h.reject { |key, _| keys.include?(key) }.to_h
hash_or_config.to_h.except(*keys).to_h
end

# Always deletes nil entries, always overwrites arrays
Expand Down
Empty file.
2 changes: 2 additions & 0 deletions test/fixture/ruby_lsp/simple.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
s = 'hi'
puts s
125 changes: 125 additions & 0 deletions test/ruby_lsp_addon_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# All of these requires were needed because `ruby_lsp/internal` mutates rubocop
# in a way that breaks test/standard/cli_test.rb
require "sorbet-runtime"
require "language_server-protocol"
require "ruby_lsp/base_server"
require "ruby_lsp/server"
require "ruby_lsp/requests"
require "ruby_lsp/addon"
require "ruby_lsp/utils"
require "ruby_lsp/store"
require "ruby_lsp/document"
require "ruby_lsp/global_state"
require "core_ext/uri"
require "ruby_indexer/ruby_indexer"
require "ruby_lsp/ruby_document"
require "prism"
require "ruby_lsp/standard/addon"

require_relative "test_helper"

class RubyLspAddonTest < UnitTest
def setup
@addon = RubyLsp::Standard::Addon.new
super
end

def test_name
assert_equal "Standard Ruby", @addon.name
end

def test_diagnostic
source = <<~RUBY
s = 'hello'
puts s
RUBY
with_server(source, "simple.rb") do |server, uri|
server.process_message(
id: 2,
method: "textDocument/diagnostic",
params: {
textDocument: {
uri: uri
}
}
)

result = server.pop_response

assert_instance_of(RubyLsp::Result, result)
assert_equal "full", result.response.kind
assert_equal 1, result.response.items.size
item = result.response.items.first
assert_equal({line: 0, character: 4}, item.range.start.to_hash)
assert_equal({line: 0, character: 11}, item.range.end.to_hash)
assert_equal RubyLsp::Constant::DiagnosticSeverity::INFORMATION, item.severity
assert_equal "Style/StringLiterals", item.code
assert_equal "https://docs.rubocop.org/rubocop/cops_style.html#stylestringliterals", item.code_description.href
assert_equal "Standard Ruby", item.source
assert_equal "Prefer double-quoted strings unless you need single quotes to avoid extra backslashes for escaping.", item.message
end
end

def test_format
source = <<~RUBY
s = 'hello'
puts s
RUBY
with_server(source, "simple.rb") do |server, uri|
server.process_message(
id: 2,
method: "textDocument/formatting",
params: {textDocument: {uri: uri}, position: {line: 0, character: 0}}
)

result = server.pop_response

assert_instance_of(RubyLsp::Result, result)
assert 1, result.response.size
assert_equal <<~RUBY, result.response.first.new_text
s = "hello"
puts s
RUBY
end
end

private

# Lifted from here, because we need to override the formatter to "standard" in the test helper:
# https://github.com/Shopify/ruby-lsp/blob/4c1906172add4d5c39c35d3396aa29c768bfb898/lib/ruby_lsp/test_helper.rb#L20
def with_server(source = nil, path = "fake.rb", pwd: "test/fixture/ruby_lsp", stub_no_typechecker: false, load_addons: true,
&block)
Dir.chdir pwd do
server = RubyLsp::Server.new(test_mode: true)
uri = Kernel.URI(File.join(server.global_state.workspace_path, path))
server.global_state.formatter = "standard"
server.global_state.instance_variable_set(:@linters, ["standard"])
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
block.call(server, uri)
end
ensure
if load_addons
RubyLsp::Addon.addons.each(&:deactivate)
RubyLsp::Addon.addons.clear
end
end
end

0 comments on commit 710cb6f

Please sign in to comment.