Skip to content

Conversation

dhruvmanila
Copy link
Member

@dhruvmanila dhruvmanila commented Jul 29, 2025

Summary

This PR implements support for providing LSP client settings.

The complementary PR in the ty VS Code extension: astral-sh/ty-vscode#106.

Notes for the previous iteration of this PR is in #19614 (comment) (click on "Details").

Specifically, this PR splits the client settings into 3 distinct groups. Keep in mind that these groups are not visible to the user, they're merely an implementation detail. The groups are:

  1. GlobalOptions - these are the options that are global to the language server and will be the same for all the workspaces that are handled by the server
  2. WorkspaceOptions - these are the options that are specific to a workspace and will be applied only when running any logic for that workspace
  3. InitializationOptions - these are the options that can be specified during initialization

The initialization options are a superset that contains both the global and workspace options flattened into a 1-dimensional structure. This means that the user can specify any and all fields present in GlobalOptions and WorkspaceOptions in the initialization options in addition to the fields that are specific to initialization options.

From the current set of available settings, following are only available during initialization because they are required at that time, are static during the runtime of the server and changing their values require a restart to take effect:

  • logLevel
  • logFile

And, following are available under GlobalOptions:

  • diagnosticMode

And, following under WorkspaceOptions:

  • disableLanguageServices
  • pythonExtension (Python environment information that is populated by the ty VS Code extension)

workspace/configuration

This request allows server to ask the client for configuration to a specific workspace. But, this is only supported by the client that has the workspace.configuration client capability set to true. What to do for clients that don't support pulling configurations?

In that case, the settings needs to be provided in the initialization options and updating the values of those settings can only be done by restarting the server. With the way this is implemented, this means that if the client does not support pulling workspace configuration then there's no way to specify settings specific to a workspace. Earlier, this would've been possible by providing an array of client options with an additional field which specifies which workspace the options belong to but that adds complexity and clients that actually do not support workspace/configuration would usually not support multiple workspaces either.

Now, for the clients that do support this, the server will initiate the request to get the configuration for all the workspaces at the start of the server. Once the server receives these options, it will resolve them for each workspace as follows:

  1. Combine the client options sent during initialization with the options specific to the workspace creating the final client options that's specific to this workspace
  2. Create a global options by combining the global options from (1) for all workspaces which in turn will also combine the global options sent during initialization

The global options are resolved into the global settings and are available on the Session which is initialized with the default global settings. The workspace options are resolved into the workspace settings and are available on the respective Workspace.

The SessionSnapshot contains the global settings while the document snapshot contains the workspace settings. We could add the global settings to the document snapshot but that's currently not needed.

Document diagnostic dynamic registration

Currently, the document diagnostic server capability is created based on the diagnosticMode sent during initialization. But, that wouldn't provide us with the complete picture. This means the server needs to defer registering the document diagnostic capability at a later point once the settings have been resolved.

This is done using dynamic registration for clients that support it. For clients that do not support dynamic registration for document diagnostic capability, the server advertises itself as always supporting workspace diagnostics and work done progress token.

This dynamic registration now allows us to change the server capability for workspace diagnostics based on the resolved diagnosticMode value. In the future, once workspace/didChangeConfiguration is supported, we can avoid the server restart when users have changed any client settings.

Test Plan

Add integration tests and recorded videos on the user experience in various editors:

VS Code

For VS Code users, the settings experience is unchanged because the extension defines it's own interface on how the user can specify the server setting. This means everything is under the ty.* namespace as usual.

Screen.Recording.2025-08-04.at.17.12.30.mov

Zed

For Zed, the settings experience has changed. Users can specify settings during initialization:

{
  "lsp": {
    "ty": {
      "initialization_options": {
        "logLevel": "debug",
        "logFile": "~/.cache/ty.log",
        "diagnosticMode": "workspace",
        "disableLanguageServices": true
      }
    },
  }
}

Or, can specify the options under the settings key:

{
  "lsp": {
    "ty": {
      "settings": {
        "ty": {
          "diagnosticMode": "openFilesOnly",
          "disableLanguageServices": true
        }
      },
      "initialization_options": {
        "logLevel": "debug",
        "logFile": "~/.cache/ty.log"
      }
    },
  }
}

The logLevel and logFile setting still needs to go under the initialization options because they're required by the server during initialization.

We can remove the nesting of the settings under the "ty" namespace by updating the return type of https://github.com/zed-extensions/ty/blob/db9ea0cdfd7352529748ef5bf729a152c0219805/src/tychecker.rs#L45-L49 to be wrapped inside ty directly so that users can avoid doing the double nesting.

There's one issue here which is that if the diagnosticMode is specified in both the initialization option and settings key, then the resolution is a bit different - if either of them is set to be workspace, then it wins which means that in the following configuration, the diagnostic mode is workspace:

{
  "lsp": {
    "ty": {
      "settings": {
        "ty": {
          "diagnosticMode": "openFilesOnly"
        }
      },
      "initialization_options": {
        "diagnosticMode": "workspace"
      }
    },
  }
}

This behavior is mainly a result of combining global options from various workspace configuration results. Users should not be able to provide global options in multiple workspaces but that restriction cannot be done on the server side. The ty VS Code extension restricts these global settings to only be set in the user settings and not in workspace settings but we do not control extensions in other editors.

Screen.Recording.2025-08-04.at.17.40.07.mov

Neovim

Same as in Zed.

Other

Other editors that do not support workspace/configuration, the users would need to provide the server settings during initialization.

@dhruvmanila dhruvmanila added server Related to the LSP server ty Multi-file analysis & type inference labels Jul 29, 2025
@dhruvmanila dhruvmanila force-pushed the dhruv/client-settings branch from 59963e8 to 43e3f39 Compare July 29, 2025 09:37
Copy link
Contributor

github-actions bot commented Jul 29, 2025

Diagnostic diff on typing conformance tests

No changes detected when running ty on typing conformance tests ✅

Copy link
Contributor

github-actions bot commented Jul 29, 2025

mypy_primer results

No ecosystem changes detected ✅
No memory usage changes detected ✅

@dhruvmanila dhruvmanila force-pushed the dhruv/client-settings branch from f4f95fc to 0cd42eb Compare July 29, 2025 11:18
) -> crate::server::Result<Option<GotoDefinitionResponse>> {
if snapshot.client_settings().is_language_services_disabled() {
if snapshot
.workspace_settings()
Copy link
Member

Choose a reason for hiding this comment

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

I like the new terminology used here

@MichaReiser
Copy link
Member

MichaReiser commented Jul 30, 2025

I like where this is going. It clarifies a lot of the differences!

One way to improve would be to accept all the settings in initialization options and consider them global settings. Then, if the client supports workspace/configuration request, we can ask the client for workspace specific configuration that would take precedence over the global settings.

This makes a lot of sense to me and seems more intuitive. Especially given that most editors don't support workspaces.

Here, there are two kinds of settings that options.into_settings will return - GlobalSettings are the ones that are applied globally e.g., ty.diagnosticMode and WorkspaceSettings are the ones that are applied on a per-workspace level e.g., ty.disableLanguageServices.

I don't fully understand this part. I also left an inline comment related to this but I think the structure we want to deserialize into in the workspace configuration handler is one that has two fields: global_options and workspace_options (we can use serde(flatten) to hide this detail from clients). I think this will help clarify which fields are workspace specific vs which fields are global. It also allows you to implement combine on GlobalOptions and not on settings (following the pattern we use in the CLI)

}
}

/// Creates the default [`DiagnosticOptions`] for the server.
Copy link
Member

Choose a reason for hiding this comment

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

This feels a bit out of place. The function doesn't contain any code related to capabilities

Copy link
Member Author

Choose a reason for hiding this comment

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

It does, the DiagnosticOptions are the server capability for document diagnostic.

@dhruvmanila
Copy link
Member Author

Notes for the previous iteration:

Details

This PR adds support for client settings via the workspace/configuration request.

Currently, the settings can only be passed in via the initialization options which are limited because any change to the settings require a server restart to take effect. This is why Micha added the infrastructure to request configuration for a specific workspace via the workspace/configuration request.

This PR implements the point 1, 2, 3 from astral-sh/ty#82 (comment) i.e.,

  • Create a InitializationOptions that only contains logLevel and logFiles. These are the static options that actually require a server restart to take effect
  • Start the server initialization process where the server initiates a request to get all workspace configurations via workspace/configuration request endpoint and defers all notifications and requests until the initialization is complete
  • Once the server receives the response from workspace/configuration request, it initializes the workspace options with the received data
  • Here, there are two kinds of settings that options.into_settings will return - GlobalSettings are the ones that are applied globally e.g., ty.diagnosticMode and WorkspaceSettings are the ones that are applied on a per-workspace level e.g., ty.disableLanguageServices. Now, the protocol only allows to request configuration specific to a workspace, there's no concept of global settings that are dynamic in the spec, so we model that internally using GlobalSettings but this requires taking all the GlobalSettings from all the configured workspaces and combining them. This could result in a mis-match where one workspace has "workspace" diagnostic mode while other has "openFilesOnly" diagnostic mode. In this case, the server should show this mismatch somehow but currently it doesn't.

This split is recommended in microsoft/language-server-protocol#567 and is also followed by Dart where initialization options are separate from workspace options.

But, there seems to be one issue which is that the way workspace/configuration works is that the client can store these settings in any structure it wants which means that the way the user would need to store the settings might differ from editor to editor. I've provided examples on what it would look like with this PR.

For VS Code:

{
	"ty.logLevel": "debug",
	"ty.diagnosticMode": "workspace"
}

For Neovim:

{
	settings = {
		diagnosticMode = "workspace",
	},
	init_options = {
		logLevel = "debug",
	}
}

For Zed:

{
  "lsp": {
    "ty": {
      "settings": {
        "ty": {
          "diagnosticMode": "openFilesOnly"
        }
      },
	  "initialization_options": {
		"logLevel": "debug"
	  }
    }
  }
}

For Helix:

[language-server.ty.config]
logLevel = "debug"
ty = { diagnosticMode = "workspace" }

Question

Based on all of the changes that I've made so far and what I'm observing when using this PR to define the settings in an editor is that this model still seems a bit confusing from a user perspective. One way to improve would be to accept all the settings in initialization options and consider them global settings. Then, if the client supports workspace/configuration request, we can ask the client for workspace specific configuration that would take precedence over the global settings. But, note that some settings like diagnosticMode are still global so a workspace setting cannot override the value of diagnosticMode. We could show a warning if the user has done this.

@dhruvmanila dhruvmanila marked this pull request as ready for review August 4, 2025 12:18
Comment on lines -26 to -33
// Check if language services are disabled
if snapshot
.index()
.global_settings()
.is_language_services_disabled()
{
return Ok(None);
}
Copy link
Member Author

Choose a reason for hiding this comment

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

We can't query this setting because it's scope is limited to a specific workspace and these endpoints are for all the workspaces managed by the server i.e., this setting is present under WorkspaceSettings but the SessionSnapshot does not know which workspace is this endpoint for.

I think this is fine because all the workspace related endpoints except for workspace diagnostics are triggered by user explicitly. But, if we do want to enforce this, we could use the settings value from each workspace and act on it accordingly. For example, in workspace symbols, we would skip the workspace if language services are disabled for that workspace:

        // Iterate through all projects in the session
        for db in snapshot.projects() {
			// Find the workspace which this project belongs to
			// Check if language services are enabled or not
			if language_services_disabled {
				continue;
			}

@sharkdp sharkdp removed their request for review August 5, 2025 06:55
@MichaReiser MichaReiser added the great writeup A wonderful example of a quality contribution label Aug 5, 2025
Copy link
Member

@MichaReiser MichaReiser left a comment

Choose a reason for hiding this comment

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

This is awesome. Thank you.

The only change I'd like to see before landing this PR is to deduplicate Combine. We already have two implementations of it (one in ty and one in ruff). I'd rather avoid a third.

Comment on lines 262 to 266
serde_json::from_value(value).unwrap_or_else(|err| {
tracing::warn!(
"Failed to deserialize workspace options for {url}: {err}. \
Using default options"
);
Copy link
Member

Choose a reason for hiding this comment

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

I know it's unrelated to your changes, so I'm okay deferring this. But should we maybe show a pop up if this happens? E.g. could this happen if a user configures zed or neovim incorrectly?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah, I think that could happen and it might go unnoticed, I think it'd be useful to add a popup.

Copy link
Member Author

Choose a reason for hiding this comment

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

I think it'd be better as a follow-up instead

let workspace_configurations = workspaces
.into_iter()
.map(|(folder, options)| (folder.uri, options))
.filter_map(|(folder, options)| Some((folder.uri, options?)))
Copy link
Member

Choose a reason for hiding this comment

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

Isn't it required that we have one option for each url? What's the reason for allowing None for the configuration?

Copy link
Member Author

Choose a reason for hiding this comment

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

It's not a requirement for the storage part on the client side but it's a requirement when responding to the server which is handled by the handle_workspace_configuration_request method.

@dhruvmanila
Copy link
Member Author

(I've addressed most of the feedback, I need to address a couple more but I need to go out for sometime, will do it once I'm back.)

Copy link
Contributor

github-actions bot commented Aug 6, 2025

ruff-ecosystem results

Linter (stable)

✅ ecosystem check detected no linter changes.

Linter (preview)

✅ ecosystem check detected no linter changes.

Formatter (stable)

✅ ecosystem check detected no format changes.

Formatter (preview)

✅ ecosystem check detected no format changes.

@dhruvmanila dhruvmanila merged commit 1f29a04 into main Aug 6, 2025
38 checks passed
@dhruvmanila dhruvmanila deleted the dhruv/client-settings branch August 6, 2025 13:07
dhruvmanila added a commit to astral-sh/ty-vscode that referenced this pull request Aug 6, 2025
# Summary

Related PR for ty server: astral-sh/ruff#19614

This PR simplifies the settings handling in the extension by:
- Replace `IInitializationOptions` with `InitializationOptions` that
only contains `logLevel` and `logFile`
- Let server ask for workspace specific options via the
`workspace/configuration` request
- Rename `ISettings` to `ExtensionSettings` as now that only contains
settings specific to VS Code
- Rename `python.ty.disableLanguageServices` to
`ty.disableLanguageServices`

## Test Plan

Refer to the test plan in astral-sh/ruff#19614
specific to VS Code.
dhruvmanila added a commit to astral-sh/ty that referenced this pull request Aug 7, 2025
## Summary

This PR updates the documentation to reflect the changes made in
astral-sh/ruff#19614 and
astral-sh/ty-vscode#106.

## Test Plan

<details><summary>Screenshot of the new editor settings reference
page:</summary>
<p>

<img width="4992" height="3702" alt="image"
src="https://github.com/user-attachments/assets/e3afd5a3-8b5b-43c3-addd-e1d6636ab1b7"
/>

</p>
</details>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

great writeup A wonderful example of a quality contribution server Related to the LSP server ty Multi-file analysis & type inference

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants