Skip to content

Commit

Permalink
Merge pull request #636 from standardrb/add-code-actions
Browse files Browse the repository at this point in the history
Add support for Code Actions under Ruby LSP
  • Loading branch information
searls authored Jun 24, 2024
2 parents 83b5d19 + de955ab commit baa5a2e
Show file tree
Hide file tree
Showing 13 changed files with 482 additions and 224 deletions.
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?
!@offense.corrector.nil?
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

0 comments on commit baa5a2e

Please sign in to comment.