Skip to content

Commit

Permalink
Add support for Code Actions under Ruby LSP
Browse files Browse the repository at this point in the history
Follow up standardrb/standard#636.

Because standardrb/standard#636 alone does not behave as expected in VS Code,
this includes the content of the following commits related to the changes in
standardrb/standard#636.

- standardrb/standard@ab18144
- standardrb/standard@edd199b
- standardrb/standard@4240fc5
- standardrb/standard@2fcca8d
- standardrb/standard@84ee9f4

This ensures that the features related to standardrb/standard#636 will work,
so it is being submitted as an independent commit.
  • Loading branch information
koic committed Dec 27, 2024
1 parent 4378f75 commit 7810c74
Show file tree
Hide file tree
Showing 10 changed files with 435 additions and 184 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* [#13628](https://github.com/rubocop/rubocop/pull/13628): Make LSP server support quick fix code action. ([@koic][])
189 changes: 189 additions & 0 deletions lib/rubocop/lsp/diagnostic.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
# frozen_string_literal: true

require_relative 'severity'

#
# This code is based on https://github.com/standardrb/standard.
#
# Copyright (c) 2023 Test Double, Inc.
#
# The MIT License (MIT)
#
# https://github.com/standardrb/standard/blob/main/LICENSE.txt
#
module RuboCop
module LSP
# Diagnostic for Language Server Protocol of RuboCop.
# @api private
class Diagnostic
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

# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
def to_lsp_diagnostic(config)
highlighted = @offense.highlighted_area

LanguageServer::Protocol::Interface::Diagnostic.new(
message: message,
source: 'RuboCop',
code: @offense.cop_name,
code_description: code_description(config),
severity: severity,
range: LanguageServer::Protocol::Interface::Range.new(
start: LanguageServer::Protocol::Interface::Position.new(
line: @offense.line - 1,
character: highlighted.begin_pos
),
end: LanguageServer::Protocol::Interface::Position.new(
line: @offense.line - 1,
character: highlighted.end_pos
)
),
data: {
correctable: correctable?,
code_actions: to_lsp_code_actions
}
)
end
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength

private

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

def severity
Severity.find_by(@offense.severity.name)
end

def code_description(config)
return unless @cop_class
return unless (doc_url = @cop_class.documentation_url(config))

LanguageServer::Protocol::Interface::CodeDescription.new(href: doc_url)
end

# rubocop:disable Layout/LineLength, Metrics/MethodLength
def autocorrect_action
LanguageServer::Protocol::Interface::CodeAction.new(
title: "Autocorrect #{@offense.cop_name}",
kind: LanguageServer::Protocol::Constant::CodeActionKind::QUICK_FIX,
edit: LanguageServer::Protocol::Interface::WorkspaceEdit.new(
document_changes: [
LanguageServer::Protocol::Interface::TextDocumentEdit.new(
text_document: LanguageServer::Protocol::Interface::OptionalVersionedTextDocumentIdentifier.new(
uri: ensure_uri_scheme(@uri.to_s).to_s,
version: nil
),
edits: correctable? ? offense_replacements : []
)
]
),
is_preferred: true
)
end
# rubocop:enable Layout/LineLength, Metrics/MethodLength

# rubocop:disable Metrics/MethodLength
def offense_replacements
@offense.corrector.as_replacements.map do |range, replacement|
LanguageServer::Protocol::Interface::TextEdit.new(
range: LanguageServer::Protocol::Interface::Range.new(
start: LanguageServer::Protocol::Interface::Position.new(
line: range.line - 1,
character: range.column
),
end: LanguageServer::Protocol::Interface::Position.new(
line: range.last_line - 1,
character: range.last_column
)
),
new_text: replacement
)
end
end
# rubocop:enable Metrics/MethodLength

# rubocop:disable Layout/LineLength, Metrics/MethodLength
def disable_line_action
LanguageServer::Protocol::Interface::CodeAction.new(
title: "Disable #{@offense.cop_name} for this line",
kind: LanguageServer::Protocol::Constant::CodeActionKind::QUICK_FIX,
edit: LanguageServer::Protocol::Interface::WorkspaceEdit.new(
document_changes: [
LanguageServer::Protocol::Interface::TextDocumentEdit.new(
text_document: LanguageServer::Protocol::Interface::OptionalVersionedTextDocumentIdentifier.new(
uri: ensure_uri_scheme(@uri.to_s).to_s,
version: nil
),
edits: line_disable_comment
)
]
)
)
end
# rubocop:enable Layout/LineLength, Metrics/MethodLength

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

eol = LanguageServer::Protocol::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 = LanguageServer::Protocol::Interface::TextEdit.new(
range: LanguageServer::Protocol::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
line_length += 1 if codepoint > RubyLsp::Document::Scanner::SURROGATE_PAIR_START
end
line_length
else
line.length
end
end

def correctable?
!@offense.corrector.nil?
end

def ensure_uri_scheme(uri)
uri = URI.parse(uri)
uri.scheme = 'file' if uri.scheme.nil?
uri
end
end
end
end
4 changes: 2 additions & 2 deletions lib/rubocop/lsp/logger.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ module LSP
# Log for Language Server Protocol of RuboCop.
# @api private
class Logger
def self.log(message)
warn("[server] #{message}")
def self.log(message, prefix: '[server]')
warn("#{prefix} #{message}")
end
end
end
Expand Down
30 changes: 7 additions & 23 deletions lib/rubocop/lsp/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ module LSP
# Routes for Language Server Protocol of RuboCop.
# @api private
class Routes
CONFIGURATION_FILE_PATTERNS = [
RuboCop::ConfigFinder::DOTFILE,
RuboCop::CLI::Command::AutoGenerateConfig::AUTO_GENERATED_FILE
].freeze

def self.handle(name, &block)
define_method(:"handle_#{name}", &block)
end
Expand Down Expand Up @@ -96,7 +101,7 @@ def for(name)

handle 'workspace/didChangeWatchedFiles' do |request|
changed = request[:params][:changes].any? do |change|
change[:uri].end_with?(RuboCop::ConfigFinder::DOTFILE)
CONFIGURATION_FILE_PATTERNS.any? { |path| change[:uri].end_with?(path) }
end

if changed
Expand Down Expand Up @@ -204,40 +209,19 @@ def format_file(file_uri, command: nil)

def diagnostic(file_uri, text)
@text_cache[file_uri] = text
offenses = @server.offenses(remove_file_protocol_from(file_uri), text)
diagnostics = offenses.map { |offense| to_diagnostic(offense) }

{
method: 'textDocument/publishDiagnostics',
params: {
uri: file_uri,
diagnostics: diagnostics
diagnostics: @server.offenses(remove_file_protocol_from(file_uri), text)
}
}
end

def remove_file_protocol_from(uri)
uri.delete_prefix('file://')
end

def to_diagnostic(offense)
code = offense[:cop_name]
message = offense[:message]
loc = offense[:location]
rubocop_severity = offense[:severity]
severity = Severity.find_by(rubocop_severity)

{
code: code, message: message, range: to_range(loc), severity: severity, source: 'rubocop'
}
end

def to_range(location)
{
start: { character: location[:start_column] - 1, line: location[:start_line] - 1 },
end: { character: location[:last_column], line: location[:last_line] - 1 }
}
end
end
end
end
64 changes: 15 additions & 49 deletions lib/rubocop/lsp/runtime.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require 'stringio'
require_relative 'diagnostic'
require_relative 'stdin_runner'

#
# This code is based on https://github.com/standardrb/standard.
Expand All @@ -19,59 +20,38 @@ class Runtime
attr_writer :safe_autocorrect, :lint_mode, :layout_mode

def initialize(config_store)
@config_store = config_store
@logged_paths = []
@runner = RuboCop::Lsp::StdinRunner.new(config_store)
@cop_registry = RuboCop::Cop::Registry.global.to_h

@safe_autocorrect = true
@lint_mode = false
@layout_mode = false
end

# This abuses the `--stdin` option of rubocop and reads the formatted text
# from the `options[:stdin]` that rubocop mutates. This depends on
# `parallel: false` as well as the fact that RuboCop doesn't otherwise dup
# or reassign that options object. Risky business!
#
# Reassigning `options[:stdin]` is done here:
# https://github.com/rubocop/rubocop/blob/v1.52.0/lib/rubocop/cop/team.rb#L131
# Printing `options[:stdin]`
# https://github.com/rubocop/rubocop/blob/v1.52.0/lib/rubocop/cli/command/execute_runner.rb#L95
# Setting `parallel: true` would break this here:
# https://github.com/rubocop/rubocop/blob/v1.52.0/lib/rubocop/runner.rb#L72
def format(path, text, command:)
safe_autocorrect = if command
command == 'rubocop.formatAutocorrects'
else
@safe_autocorrect
end

formatting_options = {
stdin: text, force_exclusion: true, autocorrect: true, safe_autocorrect: safe_autocorrect
}
formatting_options = { autocorrect: true, safe_autocorrect: safe_autocorrect }
formatting_options[:only] = config_only_options if @lint_mode || @layout_mode

redirect_stdout { run_rubocop(formatting_options, path) }

formatting_options[:stdin]
@runner.run(path, text, formatting_options)
@runner.formatted_source
end

def offenses(path, text)
diagnostic_options = {
stdin: text, force_exclusion: true, formatters: ['json'], format: 'json'
}
def offenses(path, text, document_encoding = nil)
diagnostic_options = {}
diagnostic_options[:only] = config_only_options if @lint_mode || @layout_mode

json = redirect_stdout { run_rubocop(diagnostic_options, path) }
results = JSON.parse(json, symbolize_names: true)

if results[:files].empty?
unless @logged_paths.include?(path)
Logger.log "Ignoring file, per configuration: #{path}"
@logged_paths << path
end
return []
@runner.run(path, text, diagnostic_options)
@runner.offenses.map do |offense|
Diagnostic.new(
document_encoding, offense, path, @cop_registry[offense.cop_name]&.first
).to_lsp_diagnostic(@runner.config_for_working_directory)
end

results.dig(:files, 0, :offenses)
end

private
Expand All @@ -82,20 +62,6 @@ def config_only_options
only_options << 'Layout' if @layout_mode
only_options
end

def redirect_stdout(&block)
stdout = StringIO.new

RuboCop::Server::Helper.redirect(stdout: stdout, &block)

stdout.string
end

def run_rubocop(options, path)
runner = RuboCop::Runner.new(options, @config_store)

runner.run([path])
end
end
end
end
Loading

0 comments on commit 7810c74

Please sign in to comment.