From aebdb8e0f9a32a4ac0dd7d9a030158a9e9043532 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20=C5=9Awi=C4=85tkowski?= Date: Mon, 3 Nov 2025 14:48:36 +0100 Subject: [PATCH 1/2] Implement workspace/didChangeConfiguration and workspace/configuration This adds support for the pull model of dynamic configuration changes, as described here: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_configuration Upon receiving `workspace/didChangeConfiguration` message the Ruby LSP server sends `workspace/configuration` to the client. The client replies. This reply is different than other replies handled by Ruby LSP so far, as it does not include `method` and needs to be associated with the request via `id`. This required implementing a collection of server-sent requests, to be able to match. The `result` of the reply is not a hash, instead it's an array of hashes, so it needed to be handled separately as well. Technically, upon receiving `workspace/configuration` the server should check if it should register or unregister some capabilities. I intended to do that too, but it started to become messy and also I did not have a way to properly test it. --- lib/ruby_lsp/base_server.rb | 11 ++++++++++- lib/ruby_lsp/server.rb | 30 +++++++++++++++++++++++++++++- lib/ruby_lsp/utils.rb | 11 +++++++++++ test/server_test.rb | 25 +++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 2 deletions(-) diff --git a/lib/ruby_lsp/base_server.rb b/lib/ruby_lsp/base_server.rb index 98b26d0da7..3f1d1c9d39 100644 --- a/lib/ruby_lsp/base_server.rb +++ b/lib/ruby_lsp/base_server.rb @@ -13,6 +13,7 @@ def initialize(**options) @install_error = options[:install_error] #: StandardError? @incoming_queue = Thread::Queue.new #: Thread::Queue @outgoing_queue = Thread::Queue.new #: Thread::Queue + @sent_requests = {} @cancelled_requests = [] #: Array[Integer] @worker = new_worker #: Thread @current_request_id = 1 #: Integer @@ -21,7 +22,15 @@ def initialize(**options) @outgoing_dispatcher = Thread.new do unless @test_mode while (message = @outgoing_queue.pop) - @global_state.synchronize { @writer.write(message.to_hash) } + @global_state.synchronize do + # If the message is a request from server to client, we save it because we might + # need it later to understand the response. + if message.is_a?(Request) + id = message.to_hash[:id] + @sent_requests[id] = message + end + @writer.write(message.to_hash) + end end end end #: Thread diff --git a/lib/ruby_lsp/server.rb b/lib/ruby_lsp/server.rb index 75285543a6..74613ab025 100644 --- a/lib/ruby_lsp/server.rb +++ b/lib/ruby_lsp/server.rb @@ -80,6 +80,9 @@ def process_message(message) type_hierarchy_supertypes(message) when "typeHierarchy/subtypes" type_hierarchy_subtypes(message) + when "workspace/didChangeConfiguration" + send_log_message("Re-applying Ruby LSP configuration after workspace configuration change") + workspace_configuration_did_change(message) when "workspace/didChangeWatchedFiles" workspace_did_change_watched_files(message) when "workspace/symbol" @@ -157,9 +160,16 @@ def process_message(message) # Process responses to requests that were sent to the client #: (Hash[Symbol, untyped] message) -> void def process_response(message) - case message.dig(:result, :method) + # Some replies have method in their payload, but some do not and we need to match + # the request by id to find what the method is. + method = (message[:result].is_a?(Hash) && message.dig(:result, :method)) || @sent_requests[message[:id]]&.to_hash&.fetch(:method) + + case method when "window/showMessageRequest" window_show_message_request(message) + when "workspace/configuration" + send_log_message("Received workspace configuration from client: #{message}") + workspace_configuration_received(message) end end @@ -196,6 +206,21 @@ def load_addons(include_project_addons: true) private + #: (Hash[Symbol, untyped] message) -> void + def workspace_configuration_did_change(message) + # This assumes that the workspace configuration is under "rubyLsp" key, which seems + # to be the standard naming convention. + send_message(Request.workspace_configuration( + @current_request_id, section: "rubyLsp" + )) + end + + def workspace_configuration_received(message) + options = { initializationOptions: message[:result]&.first } + messages_to_send = @global_state.apply_options(options) + messages_to_send.each { |notification| send_message(notification) } + end + #: (Hash[Symbol, untyped] message) -> void def run_initialize(message) options = message[:params] @@ -273,6 +298,9 @@ def run_initialize(message) rename_provider: rename_provider, references_provider: !@global_state.has_type_checker, document_range_formatting_provider: true, + workspace: { + configuration: true, + }, experimental: { addon_detection: true, compose_bundle: true, diff --git a/lib/ruby_lsp/utils.rb b/lib/ruby_lsp/utils.rb index 9e78d0db4b..519350d322 100644 --- a/lib/ruby_lsp/utils.rb +++ b/lib/ruby_lsp/utils.rb @@ -175,6 +175,17 @@ def register_watched_files( ), ) end + + def workspace_configuration( + id, + section: + ) + new(id: id, method: "workspace/configuration", params: Interface::ConfigurationParams.new( + items: [ + Interface::ConfigurationItem.new(section: section), + ], + )) + end end #: (id: (Integer | String), method: String, params: Object) -> void diff --git a/test/server_test.rb b/test/server_test.rb index 9d9805b9d2..474f1f2cc4 100644 --- a/test/server_test.rb +++ b/test/server_test.rb @@ -412,6 +412,31 @@ def test_backtrace_is_printed_to_stderr_on_exceptions end end + def test_reply_to_workspace_configuration_modifies_global_state + @server.instance_variable_set(:@sent_requests, { + 1 => RubyLsp::Request.workspace_configuration( + 1, section: "rubyLsp" + ), + }) + + @server.process_message({ + id: 1, + result: [{ formatter: "standard" }], + }) + + assert_equal("standard", @server.global_state.formatter) + end + + def test_did_change_configuration_sends_workspace_configuration_request + @server.process_message({ + id: 1, + method: "workspace/didChangeConfiguration", + params: {}, + }) + + find_message(RubyLsp::Request, "workspace/configuration") + end + def test_changed_file_only_indexes_ruby path = File.join(Dir.pwd, "lib", "foo.rb") File.write(path, "class Foo\nend") From d0ddbf39fb8e9b075190aeed9717af7cb1ea6a8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20=C5=9Awi=C4=85tkowski?= Date: Wed, 12 Nov 2025 21:33:34 +0100 Subject: [PATCH 2/2] Store client's `workspace.configuration` capability --- lib/ruby_lsp/client_capabilities.rb | 7 ++++++- lib/ruby_lsp/server.rb | 3 --- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/ruby_lsp/client_capabilities.rb b/lib/ruby_lsp/client_capabilities.rb index 9e28684926..7c07cd3824 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_workspace_configuration #: -> 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 querying for its configuration via `workspace/configuration` + @supports_workspace_configuration = false #:bool end #: (Hash[Symbol, untyped] capabilities) -> void @@ -66,6 +70,7 @@ 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_workspace_configuration = workspace_capabilities[:configuration] end #: -> bool diff --git a/lib/ruby_lsp/server.rb b/lib/ruby_lsp/server.rb index 74613ab025..3c337ed5bc 100644 --- a/lib/ruby_lsp/server.rb +++ b/lib/ruby_lsp/server.rb @@ -298,9 +298,6 @@ def run_initialize(message) rename_provider: rename_provider, references_provider: !@global_state.has_type_checker, document_range_formatting_provider: true, - workspace: { - configuration: true, - }, experimental: { addon_detection: true, compose_bundle: true,