Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for Code Actions under Ruby LSP #636

Merged
merged 4 commits into from
Jun 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 5 additions & 7 deletions lib/ruby_lsp/standard/addon.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,18 @@
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}"
@logger = ::Standard::Lsp::Logger.new(prefix: "[Standard Ruby]")
@logger.puts "Activating Standard Ruby LSP addon v#{::Standard::VERSION}"
RuboCop::LSP.enable
@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}"
@logger.puts "Initialized Standard Ruby LSP addon #{::Standard::VERSION}"
end

def deactivate
Expand Down Expand Up @@ -52,7 +50,7 @@ def register_additional_file_watchers(global_state, message_queue)
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"
@logger.puts "Re-initialized Standard Ruby LSP addon #{::Standard::VERSION} due to .standard.yml file change"
end
end
end
Expand Down
66 changes: 2 additions & 64 deletions lib/ruby_lsp/standard/wraps_built_in_lsp_standardizer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,69 +7,17 @@ def initialize
end

def init!
@config = ::Standard::BuildsConfig.new.call([])
@standardizer = ::Standard::Lsp::Standardizer.new(
@config,
::Standard::Lsp::Logger.new
::Standard::BuildsConfig.new.call([])
)
@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
)
}
@standardizer.offenses(uri_to_path(uri), document.source, document.encoding)
end

private
Expand All @@ -83,16 +31,6 @@ def uri_to_path(uri)
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
168 changes: 168 additions & 0 deletions lib/standard/lsp/diagnostic.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
module Standard
module Lsp
class Diagnostic
Constant = LanguageServer::Protocol::Constant
Interface = LanguageServer::Protocol::Interface

RUBOCOP_TO_LSP_SEVERITY = {
info: Constant::DiagnosticSeverity::HINT,
refactor: Constant::DiagnosticSeverity::INFORMATION,
convention: Constant::DiagnosticSeverity::INFORMATION,
warning: Constant::DiagnosticSeverity::WARNING,
error: Constant::DiagnosticSeverity::ERROR,
fatal: Constant::DiagnosticSeverity::ERROR
}.freeze

def initialize(document_encoding, offense, uri, cop_class)
@document_encoding = document_encoding
@offense = offense
@uri = uri
@cop_class = cop_class
end

def to_lsp_code_actions
code_actions = []

code_actions << autocorrect_action if correctable?
code_actions << disable_line_action

code_actions
end

def to_lsp_diagnostic(config)
highlighted = @offense.highlighted_area
Interface::Diagnostic.new(
message: message,
source: "Standard Ruby",
code: @offense.cop_name,
code_description: code_description(config),
severity: severity,
range: Interface::Range.new(
start: Interface::Position.new(
line: @offense.line - 1,
character: highlighted.begin_pos
),
end: Interface::Position.new(
line: @offense.line - 1,
character: highlighted.end_pos
)
),
data: {
correctable: correctable?,
code_actions: to_lsp_code_actions
}
)
end

private

def message
message = @offense.message
message += "\n\nThis offense is not auto-correctable.\n" unless correctable?
message
end

def severity
RUBOCOP_TO_LSP_SEVERITY[@offense.severity.name]
end

def code_description(config)
return unless @cop_class

if (doc_url = @cop_class.documentation_url(config))
Interface::CodeDescription.new(href: doc_url)
end
end

def autocorrect_action
Interface::CodeAction.new(
title: "Autocorrect #{@offense.cop_name}",
kind: Constant::CodeActionKind::QUICK_FIX,
edit: Interface::WorkspaceEdit.new(
document_changes: [
Interface::TextDocumentEdit.new(
text_document: Interface::OptionalVersionedTextDocumentIdentifier.new(
uri: @uri.to_s,
version: nil
),
edits: correctable? ? offense_replacements : []
)
]
),
is_preferred: true
)
end

def offense_replacements
@offense.corrector.as_replacements.map do |range, replacement|
Interface::TextEdit.new(
range: Interface::Range.new(
start: Interface::Position.new(line: range.line - 1, character: range.column),
end: Interface::Position.new(line: range.last_line - 1, character: range.last_column)
),
new_text: replacement
)
end
end

def disable_line_action
Interface::CodeAction.new(
title: "Disable #{@offense.cop_name} for this line",
kind: Constant::CodeActionKind::QUICK_FIX,
edit: Interface::WorkspaceEdit.new(
document_changes: [
Interface::TextDocumentEdit.new(
text_document: Interface::OptionalVersionedTextDocumentIdentifier.new(
uri: @uri.to_s,
version: nil
),
edits: line_disable_comment
)
]
)
)
end

def line_disable_comment
new_text = if @offense.source_line.include?(" # standard:disable ")
",#{@offense.cop_name}"
else
" # standard:disable #{@offense.cop_name}"
end

eol = Interface::Position.new(
line: @offense.line - 1,
character: length_of_line(@offense.source_line)
)

# TODO: fails for multiline strings - may be preferable to use block
# comments to disable some offenses
inline_comment = Interface::TextEdit.new(
range: Interface::Range.new(start: eol, end: eol),
new_text: new_text
)

[inline_comment]
end

def length_of_line(line)
if @document_encoding == Encoding::UTF_16LE
line_length = 0
line.codepoints.each do |codepoint|
line_length += 1
if codepoint > RubyLsp::Document::Scanner::SURROGATE_PAIR_START
line_length += 1
end
end
line_length
else
line.length
end
end

def correctable?
[email protected]?
end
end
end
end
5 changes: 3 additions & 2 deletions lib/standard/lsp/logger.rb
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
module Standard
module Lsp
class Logger
def initialize
def initialize(prefix: "[server]")
@prefix = prefix
@puts_onces = []
end

def puts(message)
warn("[server] #{message}")
warn [@prefix, message].compact.join(" ")
end

def puts_once(message)
Expand Down
35 changes: 1 addition & 34 deletions lib/standard/lsp/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -154,45 +154,12 @@ def format_file(file_uri)

def diagnostic(file_uri, text)
@text_cache[file_uri] = text
offenses = @standardizer.offenses(uri_to_path(file_uri), text)

lsp_diagnostics = offenses.map { |o|
code = o[:cop_name]

msg = o[:message].delete_prefix(code)
loc = o[:location]

severity = case o[:severity]
when "error", "fatal"
SEV::ERROR
when "warning"
SEV::WARNING
when "convention"
SEV::INFORMATION
when "refactor", "info"
SEV::HINT
else # the above cases fully cover what RuboCop sends at this time
logger.puts "Unknown severity: #{severity.inspect}"
SEV::HINT
end

{
code: code,
message: msg,
range: {
start: {character: loc[:start_column] - 1, line: loc[:start_line] - 1},
end: {character: loc[:last_column], line: loc[:last_line] - 1}
},
severity: severity,
source: "standard"
}
}

{
method: "textDocument/publishDiagnostics",
params: {
uri: file_uri,
diagnostics: lsp_diagnostics
diagnostics: @standardizer.offenses(uri_to_path(file_uri), text)
}
}
end
Expand Down
3 changes: 2 additions & 1 deletion lib/standard/lsp/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@ def initialize(config)
@writer = Proto::Transport::Io::Writer.new($stdout)
@reader = Proto::Transport::Io::Reader.new($stdin)
@logger = Logger.new
@standardizer = Standard::Lsp::Standardizer.new(config, @logger)
@standardizer = Standard::Lsp::Standardizer.new(config)
@routes = Routes.new(@writer, @logger, @standardizer)
end

def start
RuboCop::LSP.enable
@reader.read do |request|
if !request.key?(:method)
@routes.handle_method_missing(request)
Expand Down
Loading
Loading