Skip to content

Conversation

@katafrakt
Copy link

Motivation

Closes #3785

workspace/didChangeConfiguration and workspace/configuration are part of the spec. They are used to dynamically change the configuration of the server after initialization. With eglot, they are used to provide per-project configuration via .dir-locals.el files (see: https://www.gnu.org/software/emacs/manual/html_node/eglot/Project_002dspecific-configuration.html).

Implementation

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, as eglot does not support dynamic registration of capabilities.

Automated Tests

TBH it's a bit short on tests. I wasn't sure where/how to add more. I welcome all the suggestions in that area, because it feels a bit undertested.

Manual Tests

I was able to swap the formatter and linter from Rubocop to StandardRB in eglot using this .dir-locals.el:

 ((nil
   . ((eglot-workspace-configuration
       . (:rubyLsp
          (:formatter "standard" :linters ["standard"]
           :enabledFeatures (:codeActions t :diagnostics t :formatting t)))))))

@graphite-app
Copy link

graphite-app bot commented Nov 3, 2025

How to use the Graphite Merge Queue

Add the label graphite-merge to this PR to add it to the merge queue.

You must have a Graphite account in order to use the merge queue. Sign up using this link.

An organization admin has enabled the Graphite Merge Queue in this repository.

Please do not merge from GitHub as this will restart CI on PRs being processed by the merge queue.

@katafrakt katafrakt force-pushed the support-workspace-didchangeconfiguration branch 2 times, most recently from e3db658 to 21b3410 Compare November 3, 2025 18:09
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.
@katafrakt katafrakt force-pushed the support-workspace-didchangeconfiguration branch from 21b3410 to aebdb8e Compare November 3, 2025 18:09
Comment on lines +219 to +220
options = { initializationOptions: message[:result]&.first }
messages_to_send = @global_state.apply_options(options)
Copy link
Author

@katafrakt katafrakt Nov 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a hack to reuse apply_options, which expects initializationOptions, but perhaps should be properly extracted to a method just accepting the options to apply.

Also, this just takes first hash from the reply's result, which works here, because we only asked for one configuration item, but technically is not exactly in line with the spec.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this method is coupling information that we get during initialization (like the workspace folder) with other settings. It would indeed be nice to split into two methods:

  1. One that handles information required by the spec (workspace folders, capabilities and so on)
  2. Another that handles only the Ruby LSP's specific settings (what gets passed as initializationOptions)

Beyond just improving the design, invoking this twice may have weird consequences, like accidentally changing the negotiated encoding, so I don't think we can reuse it as is.

For example, if the editor and server negotiated UTF-8 initially, invoking this method without passing the capabilities -> general -> positionEncodings data will result in the server choosing UTF-16 (respecting the spec's default encoding), which would mean that editor and server would be trying to communicate using different encodings - leading to crashes, documents being in an invalid state and so on.

@katafrakt katafrakt marked this pull request as ready for review November 3, 2025 18:12
@katafrakt katafrakt requested a review from a team as a code owner November 3, 2025 18:12
private

#: (Hash[Symbol, untyped] message) -> void
def workspace_configuration_did_change(message)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My understanding based on the spec is that the parameters of workspace/didChangeConfiguration already include which settings are supposed to be changed.

Why do we need to make a request to the client? Could we use what we receive as parameters instead?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The way I understand the spec is that workspace/didChangeConfiguration can include the payload, but it's soft-deprecated behaviour, and I chose not to implement it.

The workspace/configuration request is sent from the server to the client to fetch configuration settings from the client. [...] This pull model replaces the old push model were the client signaled configuration change via an event.

https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_configuration

I think it's a strategic decision for the maintaining team to choose which approach to support and I will be happy to adjust this PR to whatever you decide (push, pull or both).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it. That makes sense, but then let's only implement the pull model rather than pulling inside of the old event.

We'll probably need to fire this request upon receiving the initialize notification, so that we can read any other configurations.

Unfortunately, it's a bit wasteful for editors that include workspace settings as part of the initial boot. I wonder if there's any information we can use to send this request selectively.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason why I implemented it in response to didChangeConfiguration is that Node's LS testbed seems to be doing that - however I don't see it in actually real server implementation, so I'm not sure what's going on with this.

You are right about this being unnecessary for majority of LSP connections that do not want to dynamically reconfigure anything. Maybe it's not worth implementing after all?

unless @test_mode
while (message = @outgoing_queue.pop)
@global_state.synchronize { @writer.write(message.to_hash) }
@global_state.synchronize do
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already process one response for server->client requests here.

Are we able to reuse that and avoid changing the base server?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This implementation relies on the response having method inside the payload. In this case it does not, which is why I needed to match it to the request by it's ID to know what method it is. Here are relevant pieces from my session:

[jsonrpc] e[17:49:30.206] <-- workspace/configuration[2] {"id":2,"method":"workspace/configuration","params":{"items":[{"section":"rubyLsp"}]},"jsonrpc":"2.0"}
[jsonrpc] e[17:49:30.207] --> workspace/configuration[2] {"jsonrpc":"2.0","id":2,"result":[{"formatter":"standard","linters":["standard"],"enabledFeatures":{"codeActions":true,"diagnostics":true,"formatting":true}}]}

Perhaps there's a better way to do the reply-to-request matching I did not find?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. In this case, I think we may need a new method for sending a request that eagerly registers it in the base server as something that's waiting for a response.

And maybe we can use a block to attach behaviour to the response ahead of time. Something like this:

send_request(...) do |response|
  # do something with the response
end

# in the base server

@response_handlers = {} #: Hash[Integer | String, { untyped } -> void]

def send_request(..., &block)
  send_message(Request.new(...))

  @response_handlers[id, block]
end

This way, it's easier to see what is expected to happen when we receive a response back. As an example, if we had to send multiple requests for workspace/configuration, it would be hard to know what needs to happen on each response without assigning the behaviour upfront.

Comment on lines +219 to +220
options = { initializationOptions: message[:result]&.first }
messages_to_send = @global_state.apply_options(options)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this method is coupling information that we get during initialization (like the workspace folder) with other settings. It would indeed be nice to split into two methods:

  1. One that handles information required by the spec (workspace folders, capabilities and so on)
  2. Another that handles only the Ruby LSP's specific settings (what gets passed as initializationOptions)

Beyond just improving the design, invoking this twice may have weird consequences, like accidentally changing the negotiated encoding, so I don't think we can reuse it as is.

For example, if the editor and server negotiated UTF-8 initially, invoking this method without passing the capabilities -> general -> positionEncodings data will result in the server choosing UTF-16 (respecting the spec's default encoding), which would mean that editor and server would be trying to communicate using different encodings - leading to crashes, documents being in an invalid state and so on.

references_provider: !@global_state.has_type_checker,
document_range_formatting_provider: true,
workspace: {
configuration: true,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see this in the server capabilities. Is this referring to the client capability instead?

If so, we need to remember whether the client supports the worskpace/configuration request in GlobalState. We can't send the request if the client doesn't inform us that it's supported.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is indeed client capability. I got confused. Thanks for catching that. I fixed it in d0ddbf3.

private

#: (Hash[Symbol, untyped] message) -> void
def workspace_configuration_did_change(message)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it. That makes sense, but then let's only implement the pull model rather than pulling inside of the old event.

We'll probably need to fire this request upon receiving the initialize notification, so that we can read any other configurations.

Unfortunately, it's a bit wasteful for editors that include workspace settings as part of the initial boot. I wonder if there's any information we can use to send this request selectively.

unless @test_mode
while (message = @outgoing_queue.pop)
@global_state.synchronize { @writer.write(message.to_hash) }
@global_state.synchronize do
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. In this case, I think we may need a new method for sending a request that eagerly registers it in the base server as something that's waiting for a response.

And maybe we can use a block to attach behaviour to the response ahead of time. Something like this:

send_request(...) do |response|
  # do something with the response
end

# in the base server

@response_handlers = {} #: Hash[Integer | String, { untyped } -> void]

def send_request(..., &block)
  send_message(Request.new(...))

  @response_handlers[id, block]
end

This way, it's easier to see what is expected to happen when we receive a response back. As an example, if we had to send multiple requests for workspace/configuration, it would be hard to know what needs to happen on each response without assigning the behaviour upfront.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support workspace/didChangeConfiguration

2 participants