Skip to content
Open
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
44 changes: 44 additions & 0 deletions jekyll/add-ons.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions lib/ruby_lsp/addon.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 9 additions & 1 deletion lib/ruby_lsp/client_capabilities.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions lib/ruby_lsp/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand All @@ -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
17 changes: 17 additions & 0 deletions lib/ruby_lsp/utils.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions test/global_state_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
109 changes: 109 additions & 0 deletions test/requests/execute_command_test.rb
Original file line number Diff line number Diff line change
@@ -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
Loading