From 2d9822fd03b62dc95eaf8c287db4f249174618b9 Mon Sep 17 00:00:00 2001 From: pvcresin Date: Sun, 6 Sep 2026 21:16:06 +0900 Subject: [PATCH 1/4] Allow add-ons to register and execute commands --- jekyll/add-ons.markdown | 44 +++++++++++ lib/ruby_lsp/addon.rb | 12 +++ lib/ruby_lsp/client_capabilities.rb | 10 ++- lib/ruby_lsp/server.rb | 42 ++++++++++ lib/ruby_lsp/utils.rb | 17 ++++ test/global_state_test.rb | 24 ++++++ test/requests/execute_command_test.rb | 109 ++++++++++++++++++++++++++ 7 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 test/requests/execute_command_test.rb diff --git a/jekyll/add-ons.markdown b/jekyll/add-ons.markdown index 4acf4352ff..65ee17f714 100644 --- a/jekyll/add-ons.markdown +++ b/jekyll/add-ons.markdown @@ -134,6 +134,50 @@ 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. + +Command identifiers should use an add-on-specific prefix to avoid collisions with commands from other add-ons. The +return value from `execute_command` 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 + + 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..185dd2fca4 100644 --- a/lib/ruby_lsp/addon.rb +++ b/lib/ruby_lsp/addon.rb @@ -280,5 +280,17 @@ 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 + + # 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..e523975f31 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,30 @@ 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 = Addon.addons.find do |candidate| + !candidate.error? && candidate.commands.include?(command) + end + + unless addon + send_message(Error.new( + id: message[:id], + code: Constant::ErrorCodes::INVALID_PARAMS, + message: "Unknown command: #{command}", + )) + return + end + + send_message(Result.new( + id: message[:id], + response: addon.execute_command(command, arguments), + )) + end + #: (Hash[Symbol, untyped] message) -> void def code_lens_resolve(message) code_lens = message[:params] @@ -1538,5 +1565,20 @@ 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(&:commands).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..7ad6cbc037 --- /dev/null +++ b/test/requests/execute_command_test.rb @@ -0,0 +1,109 @@ +# typed: true +# frozen_string_literal: true + +require "test_helper" + +module RubyLsp + class ExecuteCommandTest < Minitest::Test + def setup + @addon_class = Class.new(Addon) do + 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) + { 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.addons << @addon_class.new + + with_server(load_addons: false) do |server, _uri| + server.process_message( + id: 1, + method: "workspace/executeCommand", + params: { + command: "commandAddon.echo", + arguments: ["hello"], + }, + ) + + result = server.pop_response + assert_instance_of(Result, result) + assert_equal( + { command: "commandAddon.echo", arguments: ["hello"] }, + result.response, + ) + 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.addons << @addon_class.new + + server = Server.new(test_mode: true) + 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(["commandAddon.echo"], registered_capability.register_options.commands) + ensure + server&.run_shutdown + end + end +end From 41dda4077f5ae679417ef94420bb2fac71e11b4e Mon Sep 17 00:00:00 2001 From: pvcresin Date: Thu, 17 Sep 2026 18:33:44 +0900 Subject: [PATCH 2/4] Scope add-on command IDs to server instances --- jekyll/add-ons.markdown | 13 ++++++- lib/ruby_lsp/addon.rb | 10 +++++ lib/ruby_lsp/server.rb | 26 ++++++++++--- test/requests/execute_command_test.rb | 53 ++++++++++++++++++++++++--- 4 files changed, 88 insertions(+), 14 deletions(-) diff --git a/jekyll/add-ons.markdown b/jekyll/add-ons.markdown index 65ee17f714..5a77050daa 100644 --- a/jekyll/add-ons.markdown +++ b/jekyll/add-ons.markdown @@ -139,8 +139,10 @@ This approach enables all add-on responses to be captured in a single round of A 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. -Command identifiers should use an add-on-specific prefix to avoid collisions with commands from other add-ons. The -return value from `execute_command` is returned to the client as the result of the `workspace/executeCommand` request. +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 @@ -164,6 +166,13 @@ module RubyLsp ["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" diff --git a/lib/ruby_lsp/addon.rb b/lib/ruby_lsp/addon.rb index 185dd2fca4..830ffdf969 100644 --- a/lib/ruby_lsp/addon.rb +++ b/lib/ruby_lsp/addon.rb @@ -1,6 +1,8 @@ # typed: strict # frozen_string_literal: true +require "securerandom" + module RubyLsp # To register an add-on, inherit from this class and implement both `name` and `activate` # @@ -174,6 +176,7 @@ def gem_installation_path?(path) #: -> void def initialize @errors = [] #: Array[StandardError] + @ruby_lsp_command_id = SecureRandom.uuid #: String end #: (StandardError error) -> self @@ -288,6 +291,13 @@ 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 diff --git a/lib/ruby_lsp/server.rb b/lib/ruby_lsp/server.rb index e523975f31..51a8bb0e53 100644 --- a/lib/ruby_lsp/server.rb +++ b/lib/ruby_lsp/server.rb @@ -1526,11 +1526,9 @@ def resolve_test_commands(message) def execute_command(message) command = message.dig(:params, :command) arguments = message.dig(:params, :arguments) || [] - addon = Addon.addons.find do |candidate| - !candidate.error? && candidate.commands.include?(command) - end + addon_command = find_addon_command(command) - unless addon + unless addon_command send_message(Error.new( id: message[:id], code: Constant::ErrorCodes::INVALID_PARAMS, @@ -1539,12 +1537,26 @@ def execute_command(message) return end + addon, original_command = addon_command send_message(Result.new( id: message[:id], - response: addon.execute_command(command, arguments), + 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] @@ -1571,7 +1583,9 @@ def code_lens_resolve(message) def register_addon_commands return unless @global_state.client_capabilities.supports_execute_command_registration - commands = Addon.addons.reject(&:error?).flat_map(&:commands).uniq + 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( diff --git a/test/requests/execute_command_test.rb b/test/requests/execute_command_test.rb index 7ad6cbc037..35d6013401 100644 --- a/test/requests/execute_command_test.rb +++ b/test/requests/execute_command_test.rb @@ -7,6 +7,13 @@ 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 @@ -23,7 +30,7 @@ def commands end def execute_command(command, arguments) - { command: command, arguments: arguments } + { identifier: @identifier, command: command, arguments: arguments } end end @@ -36,14 +43,15 @@ def teardown end def test_executes_an_addon_command - Addon.addons << @addon_class.new + 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: "commandAddon.echo", + command: addon.command_id("commandAddon.echo"), arguments: ["hello"], }, ) @@ -51,12 +59,44 @@ def test_executes_an_addon_command result = server.pop_response assert_instance_of(Result, result) assert_equal( - { command: "commandAddon.echo", arguments: ["hello"] }, + { 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 @@ -78,7 +118,8 @@ def test_returns_an_error_for_an_unknown_command end def test_registers_addon_commands_after_initialization - Addon.addons << @addon_class.new + addon = @addon_class.new + Addon.addons << addon server = Server.new(test_mode: true) server.global_state.apply_options({ @@ -101,7 +142,7 @@ def test_registers_addon_commands_after_initialization registered_capability = registration.params.registrations.first assert_equal("addon-commands", registered_capability.id) assert_equal("workspace/executeCommand", registered_capability.method) - assert_equal(["commandAddon.echo"], registered_capability.register_options.commands) + assert_equal([addon.command_id("commandAddon.echo")], registered_capability.register_options.commands) ensure server&.run_shutdown end From c0bf20a8427c6f59a3a8db925645afca3349b6cd Mon Sep 17 00:00:00 2001 From: pvcresin Date: Thu, 24 Sep 2026 12:30:13 +0900 Subject: [PATCH 3/4] Remove redundant SecureRandom require --- lib/ruby_lsp/addon.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/ruby_lsp/addon.rb b/lib/ruby_lsp/addon.rb index 830ffdf969..a612746d55 100644 --- a/lib/ruby_lsp/addon.rb +++ b/lib/ruby_lsp/addon.rb @@ -1,8 +1,6 @@ # typed: strict # frozen_string_literal: true -require "securerandom" - module RubyLsp # To register an add-on, inherit from this class and implement both `name` and `activate` # From bc946d49870d30c4b1d1d20a890b69f2a961ef67 Mon Sep 17 00:00:00 2001 From: pvcresin Date: Thu, 24 Sep 2026 12:30:56 +0900 Subject: [PATCH 4/4] Use the add-on test helper for command registration --- test/requests/execute_command_test.rb | 43 +++++++++++++-------------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/test/requests/execute_command_test.rb b/test/requests/execute_command_test.rb index 35d6013401..6f8fe9bfa4 100644 --- a/test/requests/execute_command_test.rb +++ b/test/requests/execute_command_test.rb @@ -121,30 +121,29 @@ def test_registers_addon_commands_after_initialization addon = @addon_class.new Addon.addons << addon - server = Server.new(test_mode: true) - server.global_state.apply_options({ - capabilities: { - workspace: { - executeCommand: { - dynamicRegistration: true, + 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) - ensure - server&.run_shutdown + }) + 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