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
53 changes: 53 additions & 0 deletions jekyll/add-ons.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions lib/ruby_lsp/addon.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
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
56 changes: 56 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,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]
Expand All @@ -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
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
Loading
Loading