diff --git a/jekyll/add-ons.markdown b/jekyll/add-ons.markdown index 4acf4352ff..5a77050daa 100644 --- a/jekyll/add-ons.markdown +++ b/jekyll/add-ons.markdown @@ -134,6 +134,59 @@ In this example, the listener is registered to the dispatcher to listen for the This approach enables all add-on responses to be captured in a single round of AST visits, greatly improving performance. +### Providing commands + +Add-ons can provide commands that are invoked by Code Lenses, Code Actions, or other editor features. The Ruby LSP +registers these commands dynamically with clients that support `workspace/executeCommand` dynamic registration. + +Add-ons should use an add-on-specific prefix for their logical command identifiers. When creating a Code Lens, Code +Action, or another editor feature that invokes a command, use `command_id` to obtain an identifier that is unique to the +Ruby LSP server instance. The original command identifier is passed to `execute_command`, and its return value is +returned to the client as the result of the `workspace/executeCommand` request. + +```ruby +module RubyLsp + module MyGem + class Addon < ::RubyLsp::Addon + def activate(global_state, message_queue) + @message_queue = message_queue + end + + def deactivate; end + + def name + "Ruby LSP My Gem" + end + + def version + "0.1.0" + end + + def commands + ["myGem.insertType"] + end + + # For example, a Code Lens can refer to this command with: + # + # Interface::Command.new( + # title: "Insert inferred type", + # command: command_id("myGem.insertType"), + # ) + + def execute_command(command, arguments) + case command + when "myGem.insertType" + # Use @message_queue to send a workspace/applyEdit request to the client. + end + end + end + end +end +``` + +The client may invoke a command without arguments, so add-ons should handle an empty arguments array. Clients that do +not support dynamic command registration will not display add-on commands. + ### Enhancing features There are two ways to enhance Ruby LSP features. One is handling DSLs that occur at a call site and that do not change diff --git a/lib/ruby_lsp/addon.rb b/lib/ruby_lsp/addon.rb index 91102e3c61..a612746d55 100644 --- a/lib/ruby_lsp/addon.rb +++ b/lib/ruby_lsp/addon.rb @@ -174,6 +174,7 @@ def gem_installation_path?(path) #: -> void def initialize @errors = [] #: Array[StandardError] + @ruby_lsp_command_id = SecureRandom.uuid #: String end #: (StandardError error) -> self @@ -280,5 +281,24 @@ def create_discover_tests_listener(response_builder, dispatcher, uri); end def resolve_test_commands(items) [] end + + # Returns the commands provided by the add-on + # @overridable + #: -> Array[String] + def commands + [] + end + + # Returns a command identifier scoped to this add-on instance. Add-ons should use this identifier when creating + # Code Lenses, Code Actions, or other editor features that invoke one of their commands. + #: (String command) -> String + def command_id(command) + "#{command}-#{@ruby_lsp_command_id}" + end + + # Executes a command provided by the add-on + # @overridable + #: (String command, Array[untyped] arguments) -> untyped + def execute_command(command, arguments); end end end diff --git a/lib/ruby_lsp/client_capabilities.rb b/lib/ruby_lsp/client_capabilities.rb index 9e28684926..eef317c251 100644 --- a/lib/ruby_lsp/client_capabilities.rb +++ b/lib/ruby_lsp/client_capabilities.rb @@ -11,7 +11,8 @@ class ClientCapabilities :window_show_message_supports_extra_properties, :supports_progress, :supports_diagnostic_refresh, - :supports_code_lens_refresh + :supports_code_lens_refresh, + :supports_execute_command_registration #: -> void def initialize @@ -38,6 +39,9 @@ def initialize # The editor supports server initiated refresh for code lenses @supports_code_lens_refresh = false #: bool + + # The editor supports dynamically registering commands + @supports_execute_command_registration = false #: bool end #: (Hash[Symbol, untyped] capabilities) -> void @@ -66,6 +70,10 @@ def apply_client_capabilities(capabilities) @supports_diagnostic_refresh = workspace_capabilities.dig(:diagnostics, :refreshSupport) || false @supports_code_lens_refresh = workspace_capabilities.dig(:codeLens, :refreshSupport) || false + @supports_execute_command_registration = workspace_capabilities.dig( + :executeCommand, + :dynamicRegistration, + ) || false end #: -> bool diff --git a/lib/ruby_lsp/server.rb b/lib/ruby_lsp/server.rb index c700fc6c55..51a8bb0e53 100644 --- a/lib/ruby_lsp/server.rb +++ b/lib/ruby_lsp/server.rb @@ -90,6 +90,8 @@ def process_message(message) workspace_did_change_watched_files(message) when "workspace/symbol" workspace_symbol(message) + when "workspace/executeCommand" + execute_command(message) when "rubyLsp/textDocument/showSyntaxTree" text_document_show_syntax_tree(message) when "rubyLsp/workspace/dependencies" @@ -353,6 +355,7 @@ def run_initialize(message) #: -> void def run_initialized load_addons + register_addon_commands RubyVM::YJIT.enable if defined?(RubyVM::YJIT.enable) unless @setup_error @@ -1518,6 +1521,42 @@ def resolve_test_commands(message) )) end + # Executes a command provided by one of the loaded add-ons + #: (Hash[Symbol, untyped] message) -> void + def execute_command(message) + command = message.dig(:params, :command) + arguments = message.dig(:params, :arguments) || [] + addon_command = find_addon_command(command) + + unless addon_command + send_message(Error.new( + id: message[:id], + code: Constant::ErrorCodes::INVALID_PARAMS, + message: "Unknown command: #{command}", + )) + return + end + + addon, original_command = addon_command + send_message(Result.new( + id: message[:id], + response: addon.execute_command(original_command, arguments), + )) + end + + #: (String command) -> [Addon, String]? + def find_addon_command(command) + Addon.addons.each do |addon| + next if addon.error? + + addon.commands.each do |original_command| + return [addon, original_command] if addon.command_id(original_command) == command + end + end + + nil + end + #: (Hash[Symbol, untyped] message) -> void def code_lens_resolve(message) code_lens = message[:params] @@ -1538,5 +1577,22 @@ def code_lens_resolve(message) response: code_lens, )) end + + # Add-ons are loaded after the initialize response is sent, so their commands need to be registered dynamically. + #: -> void + def register_addon_commands + return unless @global_state.client_capabilities.supports_execute_command_registration + + commands = Addon.addons.reject(&:error?).flat_map do |addon| + addon.commands.map { |command| addon.command_id(command) } + end.uniq + return if commands.empty? + + send_message(Request.register_execute_commands( + @current_request_id, + commands, + registration_id: "addon-commands", + )) + end end end diff --git a/lib/ruby_lsp/utils.rb b/lib/ruby_lsp/utils.rb index 9e78d0db4b..309869ae03 100644 --- a/lib/ruby_lsp/utils.rb +++ b/lib/ruby_lsp/utils.rb @@ -175,6 +175,23 @@ def register_watched_files( ), ) end + + #: (Integer id, Array[String] commands, ?registration_id: String?) -> Request + def register_execute_commands(id, commands, registration_id: nil) + new( + id: id, + method: "client/registerCapability", + params: Interface::RegistrationParams.new( + registrations: [ + Interface::Registration.new( + id: registration_id || SecureRandom.uuid, + method: "workspace/executeCommand", + register_options: Interface::ExecuteCommandRegistrationOptions.new(commands: commands), + ), + ], + ), + ) + end end #: (id: (Integer | String), method: String, params: Object) -> void diff --git a/test/global_state_test.rb b/test/global_state_test.rb index 35f96e7540..ad1029c0dd 100644 --- a/test/global_state_test.rb +++ b/test/global_state_test.rb @@ -149,6 +149,30 @@ def test_watching_files_if_not_reported refute(state.client_capabilities.supports_watching_files) end + def test_execute_command_registration_if_supported + state = GlobalState.new + state.apply_options({ + capabilities: { + workspace: { + executeCommand: { + dynamicRegistration: true, + }, + }, + }, + }) + assert(state.client_capabilities.supports_execute_command_registration) + end + + def test_execute_command_registration_if_not_supported + state = GlobalState.new + state.apply_options({ + capabilities: { + workspace: {}, + }, + }) + refute(state.client_capabilities.supports_execute_command_registration) + end + def test_linter_specification ::RuboCop::Version.const_set(:STRING, "1.68.0") state = GlobalState.new diff --git a/test/requests/execute_command_test.rb b/test/requests/execute_command_test.rb new file mode 100644 index 0000000000..6f8fe9bfa4 --- /dev/null +++ b/test/requests/execute_command_test.rb @@ -0,0 +1,149 @@ +# typed: true +# frozen_string_literal: true + +require "test_helper" + +module RubyLsp + class ExecuteCommandTest < Minitest::Test + def setup + @addon_class = Class.new(Addon) do + attr_reader :identifier + + def initialize(identifier = nil) + @identifier = identifier + super() + end + + def activate(global_state, outgoing_queue); end + def deactivate; end + + def name + "Command Add-on" + end + + def version + "0.1.0" + end + + def commands + ["commandAddon.echo"] + end + + def execute_command(command, arguments) + { identifier: @identifier, command: command, arguments: arguments } + end + end + + Addon.addon_classes.delete(@addon_class) + end + + def teardown + Addon.addons.select { |addon| addon.is_a?(@addon_class) }.each(&:deactivate) + Addon.addons.delete_if { |addon| addon.is_a?(@addon_class) } + end + + def test_executes_an_addon_command + addon = @addon_class.new("first") + Addon.addons << addon + + with_server(load_addons: false) do |server, _uri| + server.process_message( + id: 1, + method: "workspace/executeCommand", + params: { + command: addon.command_id("commandAddon.echo"), + arguments: ["hello"], + }, + ) + + result = server.pop_response + assert_instance_of(Result, result) + assert_equal( + { identifier: "first", command: "commandAddon.echo", arguments: ["hello"] }, + result.response, + ) + end + end + + def test_command_ids_are_unique_per_addon_instance + first_addon = @addon_class.new + second_addon = @addon_class.new + + first_command = first_addon.command_id("commandAddon.echo") + second_command = second_addon.command_id("commandAddon.echo") + + refute_equal(first_command, second_command) + assert_equal(first_command, first_addon.command_id("commandAddon.echo")) + end + + def test_executes_the_command_on_the_addon_that_owns_the_command_id + first_addon = @addon_class.new("first") + second_addon = @addon_class.new("second") + Addon.addons.push(first_addon, second_addon) + + with_server(load_addons: false) do |server, _uri| + server.process_message( + id: 1, + method: "workspace/executeCommand", + params: { + command: second_addon.command_id("commandAddon.echo"), + arguments: [], + }, + ) + + result = server.pop_response + assert_instance_of(Result, result) + assert_equal("second", result.response[:identifier]) + end + end + + def test_returns_an_error_for_an_unknown_command + Addon.addons << @addon_class.new + + with_server(load_addons: false) do |server, _uri| + server.process_message( + id: 1, + method: "workspace/executeCommand", + params: { + command: "commandAddon.missing", + arguments: [], + }, + ) + + error = server.pop_response + assert_instance_of(Error, error) + assert_equal(Constant::ErrorCodes::INVALID_PARAMS, error.code) + assert_equal("Unknown command: commandAddon.missing", error.message) + end + end + + def test_registers_addon_commands_after_initialization + addon = @addon_class.new + Addon.addons << addon + + with_server(load_addons: false) do |server, _uri| + server.global_state.apply_options({ + capabilities: { + workspace: { + executeCommand: { + dynamicRegistration: true, + }, + }, + }, + }) + server.stubs(:load_addons) + server.stubs(:perform_initial_indexing) + server.process_message(method: "initialized") + + registration = server.pop_response + assert_instance_of(Request, registration) + assert_equal("client/registerCapability", registration.method) + + registered_capability = registration.params.registrations.first + assert_equal("addon-commands", registered_capability.id) + assert_equal("workspace/executeCommand", registered_capability.method) + assert_equal([addon.command_id("commandAddon.echo")], registered_capability.register_options.commands) + end + end + end +end