diff --git a/crates/ty_server/src/capabilities.rs b/crates/ty_server/src/capabilities.rs index 8e73cdde83..d864ac78e2 100644 --- a/crates/ty_server/src/capabilities.rs +++ b/crates/ty_server/src/capabilities.rs @@ -9,6 +9,7 @@ use lsp_types::{ }; use crate::PositionEncoding; +use crate::session::GlobalSettings; bitflags::bitflags! { /// Represents the resolved client capabilities for the language server. @@ -31,6 +32,7 @@ bitflags::bitflags! { const FILE_WATCHER_SUPPORT = 1 << 12; const DIAGNOSTIC_DYNAMIC_REGISTRATION = 1 << 13; const WORKSPACE_CONFIGURATION = 1 << 14; + const RENAME_DYNAMIC_REGISTRATION = 1 << 15; } } @@ -110,6 +112,11 @@ impl ResolvedClientCapabilities { self.contains(Self::DIAGNOSTIC_DYNAMIC_REGISTRATION) } + /// Returns `true` if the client supports dynamic registration for rename capabilities. + pub(crate) const fn supports_rename_dynamic_registration(self) -> bool { + self.contains(Self::RENAME_DYNAMIC_REGISTRATION) + } + pub(super) fn new(client_capabilities: &ClientCapabilities) -> Self { let mut flags = Self::empty(); @@ -246,6 +253,13 @@ impl ResolvedClientCapabilities { flags |= Self::HIERARCHICAL_DOCUMENT_SYMBOL_SUPPORT; } + if text_document + .and_then(|text_document| text_document.rename.as_ref()?.dynamic_registration) + .unwrap_or_default() + { + flags |= Self::RENAME_DYNAMIC_REGISTRATION; + } + if client_capabilities .window .as_ref() @@ -259,9 +273,12 @@ impl ResolvedClientCapabilities { } } +/// Creates the server capabilities based on the resolved client capabilities and resolved global +/// settings from the initialization options. pub(crate) fn server_capabilities( position_encoding: PositionEncoding, resolved_client_capabilities: ResolvedClientCapabilities, + global_settings: &GlobalSettings, ) -> ServerCapabilities { let diagnostic_provider = if resolved_client_capabilities.supports_diagnostic_dynamic_registration() { @@ -275,6 +292,18 @@ pub(crate) fn server_capabilities( )) }; + let rename_provider = if resolved_client_capabilities.supports_rename_dynamic_registration() { + // If the client supports dynamic registration, we will register the rename capabilities + // dynamically based on the `ty.experimental.rename` setting. + None + } else { + // Otherwise, we check whether user has enabled rename support via the resolved settings + // from initialization options. + global_settings + .is_rename_enabled() + .then(|| OneOf::Right(server_rename_options())) + }; + ServerCapabilities { position_encoding: Some(position_encoding.into()), diagnostic_provider, @@ -289,10 +318,7 @@ pub(crate) fn server_capabilities( definition_provider: Some(OneOf::Left(true)), declaration_provider: Some(DeclarationCapability::Simple(true)), references_provider: Some(OneOf::Left(true)), - rename_provider: Some(OneOf::Right(RenameOptions { - prepare_provider: Some(true), - work_done_progress_options: WorkDoneProgressOptions::default(), - })), + rename_provider, document_highlight_provider: Some(OneOf::Left(true)), hover_provider: Some(HoverProviderCapability::Simple(true)), signature_help_provider: Some(SignatureHelpOptions { @@ -344,3 +370,10 @@ pub(crate) fn server_diagnostic_options(workspace_diagnostics: bool) -> Diagnost }, } } + +pub(crate) fn server_rename_options() -> RenameOptions { + RenameOptions { + prepare_provider: Some(true), + work_done_progress_options: WorkDoneProgressOptions::default(), + } +} diff --git a/crates/ty_server/src/server.rs b/crates/ty_server/src/server.rs index 0ab3f3326c..5819789fe5 100644 --- a/crates/ty_server/src/server.rs +++ b/crates/ty_server/src/server.rs @@ -69,8 +69,15 @@ impl Server { let resolved_client_capabilities = ResolvedClientCapabilities::new(&client_capabilities); let position_encoding = Self::find_best_position_encoding(&client_capabilities); - let server_capabilities = - server_capabilities(position_encoding, resolved_client_capabilities); + let server_capabilities = server_capabilities( + position_encoding, + resolved_client_capabilities, + &initialization_options + .options + .global + .clone() + .into_settings(), + ); let version = ruff_db::program_version().unwrap_or("Unknown"); tracing::debug!("Version: {version}"); @@ -102,7 +109,7 @@ impl Server { { tracing::warn!( "Received unknown options during initialization: {}", - serde_json::to_string_pretty(unknown_options) + serde_json::to_string_pretty(&unknown_options) .unwrap_or_else(|_| format!("{unknown_options:?}")) ); diff --git a/crates/ty_server/src/session.rs b/crates/ty_server/src/session.rs index 529897f71e..6e9ce40e62 100644 --- a/crates/ty_server/src/session.rs +++ b/crates/ty_server/src/session.rs @@ -5,7 +5,7 @@ use index::DocumentQueryError; use lsp_server::{Message, RequestId}; use lsp_types::notification::{Exit, Notification}; use lsp_types::request::{ - DocumentDiagnosticRequest, RegisterCapability, Request, Shutdown, UnregisterCapability, + DocumentDiagnosticRequest, RegisterCapability, Rename, Request, Shutdown, UnregisterCapability, WorkspaceDiagnosticRequest, }; use lsp_types::{ @@ -16,8 +16,7 @@ use options::GlobalOptions; use ruff_db::Db; use ruff_db::files::File; use ruff_db::system::{System, SystemPath, SystemPathBuf}; -use settings::GlobalSettings; -use std::collections::{BTreeMap, VecDeque}; +use std::collections::{BTreeMap, HashSet, VecDeque}; use std::ops::{Deref, DerefMut}; use std::panic::RefUnwindSafe; use std::sync::Arc; @@ -29,8 +28,10 @@ use ty_project::{ChangeResult, CheckMode, Db as _, ProjectDatabase, ProjectMetad pub(crate) use self::index::DocumentQuery; pub(crate) use self::options::InitializationOptions; pub use self::options::{ClientOptions, DiagnosticMode}; -pub(crate) use self::settings::WorkspaceSettings; -use crate::capabilities::{ResolvedClientCapabilities, server_diagnostic_options}; +pub(crate) use self::settings::{GlobalSettings, WorkspaceSettings}; +use crate::capabilities::{ + ResolvedClientCapabilities, server_diagnostic_options, server_rename_options, +}; use crate::document::{DocumentKey, DocumentVersion, NotebookDocument}; use crate::server::{Action, publish_settings_diagnostics}; use crate::session::client::Client; @@ -91,8 +92,6 @@ pub(crate) struct Session { shutdown_requested: bool, /// Whether the server has dynamically registered the diagnostic capability with the client. - diagnostic_capability_registered: bool, - /// Is the connected client a `TestServer` instance. in_test: bool, @@ -107,6 +106,10 @@ pub(crate) struct Session { /// We'll re-run the request after every change to `Session` (see `revision`) /// to see if there are now changes and, if so, respond to the client. suspended_workspace_diagnostics_request: Option, + + /// Registrations is a set of LSP methods that have been dynamically registered with the + /// client. + registrations: HashSet, } /// LSP State for a Project @@ -166,10 +169,10 @@ impl Session { resolved_client_capabilities, request_queue: RequestQueue::new(), shutdown_requested: false, - diagnostic_capability_registered: false, in_test, suspended_workspace_diagnostics_request: None, revision: 0, + registrations: HashSet::new(), }) } @@ -568,6 +571,7 @@ impl Session { } self.register_diagnostic_capability(client); + self.register_rename_capability(client); assert!( self.workspaces.all_initialized(), @@ -583,11 +587,13 @@ impl Session { } } + // TODO: Merge the following two methods as `register_capability` and `unregister_capability` + /// Sends a registration notification to the client to enable / disable workspace diagnostics - /// as per the `diagnostic_mode`. + /// as per the `ty.diagnosticMode` global setting. /// /// This method is a no-op if the client doesn't support dynamic registration of diagnostic - /// capabilities. + /// capability. fn register_diagnostic_capability(&mut self, client: &Client) { static DIAGNOSTIC_REGISTRATION_ID: &str = "ty/textDocument/diagnostic"; @@ -598,9 +604,11 @@ impl Session { return; } - let diagnostic_mode = self.global_settings.diagnostic_mode; + let registered = self + .registrations + .contains(DocumentDiagnosticRequest::METHOD); - if self.diagnostic_capability_registered { + if registered { client.send_request::( self, UnregistrationParams { @@ -615,6 +623,8 @@ impl Session { ); } + let diagnostic_mode = self.global_settings.diagnostic_mode; + let registration = Registration { id: DIAGNOSTIC_REGISTRATION_ID.into(), method: DocumentDiagnosticRequest::METHOD.into(), @@ -643,7 +653,68 @@ impl Session { }, ); - self.diagnostic_capability_registered = true; + if !registered { + self.registrations + .insert(DocumentDiagnosticRequest::METHOD.to_string()); + } + } + + /// Sends a registration notification to the client to enable / disable rename capability as + /// per the `ty.experimental.rename` global setting. + /// + /// This method is a no-op if the client doesn't support dynamic registration of rename + /// capability. + fn register_rename_capability(&mut self, client: &Client) { + static RENAME_REGISTRATION_ID: &str = "ty/textDocument/rename"; + + if !self + .resolved_client_capabilities + .supports_rename_dynamic_registration() + { + return; + } + + let registered = self.registrations.contains(Rename::METHOD); + + if registered { + client.send_request::( + self, + UnregistrationParams { + unregisterations: vec![Unregistration { + id: RENAME_REGISTRATION_ID.into(), + method: Rename::METHOD.into(), + }], + }, + move |_: &Client, ()| { + tracing::debug!("Unregistered rename capability"); + }, + ); + } + + if !self.global_settings.experimental.rename { + tracing::debug!("Rename capability is disabled in the client settings"); + return; + } + + let registration = Registration { + id: RENAME_REGISTRATION_ID.into(), + method: Rename::METHOD.into(), + register_options: Some(serde_json::to_value(server_rename_options()).unwrap()), + }; + + client.send_request::( + self, + RegistrationParams { + registrations: vec![registration], + }, + move |_: &Client, ()| { + tracing::debug!("Registered rename capability"); + }, + ); + + if !registered { + self.registrations.insert(Rename::METHOD.to_string()); + } } /// Creates a document snapshot with the URL referencing the document to snapshot. diff --git a/crates/ty_server/src/session/options.rs b/crates/ty_server/src/session/options.rs index 875118193d..57e52373c3 100644 --- a/crates/ty_server/src/session/options.rs +++ b/crates/ty_server/src/session/options.rs @@ -14,7 +14,7 @@ use ty_project::metadata::value::{RangedValue, RelativePathBuf}; use crate::logging::LogLevel; -use super::settings::{GlobalSettings, WorkspaceSettings}; +use super::settings::{ExperimentalSettings, GlobalSettings, WorkspaceSettings}; /// Initialization options that are set once at server startup that never change. /// @@ -106,6 +106,12 @@ impl ClientOptions { self } + #[must_use] + pub fn with_experimental_rename(mut self, enabled: bool) -> Self { + self.global.experimental.get_or_insert_default().rename = Some(enabled); + self + } + #[must_use] pub fn with_unknown(mut self, unknown: HashMap) -> Self { self.unknown = unknown; @@ -122,12 +128,23 @@ impl ClientOptions { pub(crate) struct GlobalOptions { /// Diagnostic mode for the language server. diagnostic_mode: Option, + + /// Experimental features that the server provides on an opt-in basis. + pub(crate) experimental: Option, } impl GlobalOptions { pub(crate) fn into_settings(self) -> GlobalSettings { + let experimental = self + .experimental + .map(|experimental| ExperimentalSettings { + rename: experimental.rename.unwrap_or_default(), + }) + .unwrap_or_default(); + GlobalSettings { diagnostic_mode: self.diagnostic_mode.unwrap_or_default(), + experimental, } } } @@ -238,6 +255,13 @@ impl Combine for DiagnosticMode { } } +#[derive(Clone, Combine, Debug, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub(crate) struct Experimental { + /// Whether to enable the experimental symbol rename feature. + pub(crate) rename: Option, +} + #[derive(Clone, Debug, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] struct PythonExtension { diff --git a/crates/ty_server/src/session/settings.rs b/crates/ty_server/src/session/settings.rs index d633d13ec7..ac9e3dd1c7 100644 --- a/crates/ty_server/src/session/settings.rs +++ b/crates/ty_server/src/session/settings.rs @@ -6,6 +6,13 @@ use ty_project::metadata::options::ProjectOptionsOverrides; #[derive(Clone, Default, Debug, PartialEq)] pub(crate) struct GlobalSettings { pub(super) diagnostic_mode: DiagnosticMode, + pub(super) experimental: ExperimentalSettings, +} + +impl GlobalSettings { + pub(crate) fn is_rename_enabled(&self) -> bool { + self.experimental.rename + } } impl GlobalSettings { @@ -14,6 +21,11 @@ impl GlobalSettings { } } +#[derive(Clone, Default, Debug, PartialEq)] +pub(crate) struct ExperimentalSettings { + pub(super) rename: bool, +} + /// Resolved client settings for a specific workspace. /// /// These settings are meant to be used directly by the server, and are *not* a 1:1 representation diff --git a/crates/ty_server/tests/e2e/initialize.rs b/crates/ty_server/tests/e2e/initialize.rs index 2af0bf2731..ce696d717c 100644 --- a/crates/ty_server/tests/e2e/initialize.rs +++ b/crates/ty_server/tests/e2e/initialize.rs @@ -433,3 +433,78 @@ fn unknown_options_in_workspace_configuration() -> Result<()> { Ok(()) } + +/// Tests that the server sends a registration request for the rename capability if the client +/// setting is set to true and dynamic registration is enabled. +#[test] +fn register_rename_capability_when_enabled() -> Result<()> { + let workspace_root = SystemPath::new("foo"); + let mut server = TestServerBuilder::new()? + .with_workspace(workspace_root, None)? + .with_initialization_options(ClientOptions::default().with_experimental_rename(true)) + .enable_rename_dynamic_registration(true) + .build()? + .wait_until_workspaces_are_initialized()?; + + let (_, params) = server.await_request::()?; + let [registration] = params.registrations.as_slice() else { + panic!( + "Expected a single registration, got: {:#?}", + params.registrations + ); + }; + + insta::assert_json_snapshot!(registration, @r#" + { + "id": "ty/textDocument/rename", + "method": "textDocument/rename", + "registerOptions": { + "prepareProvider": true + } + } + "#); + + Ok(()) +} + +/// Tests that rename capability is statically registered during initialization if the client +/// doesn't support dynamic registration, but the server is configured to support it. +#[test] +fn rename_available_without_dynamic_registration() -> Result<()> { + let workspace_root = SystemPath::new("foo"); + + let server = TestServerBuilder::new()? + .with_workspace(workspace_root, None)? + .with_initialization_options(ClientOptions::default().with_experimental_rename(true)) + .enable_rename_dynamic_registration(false) + .build()? + .wait_until_workspaces_are_initialized()?; + + let initialization_result = server.initialization_result().unwrap(); + insta::assert_json_snapshot!(initialization_result.capabilities.rename_provider, @r#" + { + "prepareProvider": true + } + "#); + + Ok(()) +} + +/// Tests that the server does not send a registration request for the rename capability if the +/// client setting is set to false and dynamic registration is enabled. +#[test] +fn not_register_rename_capability_when_disabled() -> Result<()> { + let workspace_root = SystemPath::new("foo"); + + TestServerBuilder::new()? + .with_workspace(workspace_root, None)? + .with_initialization_options(ClientOptions::default().with_experimental_rename(false)) + .enable_rename_dynamic_registration(true) + .build()? + .wait_until_workspaces_are_initialized()?; + + // The `Drop` implementation will make sure that the client did not receive any registration + // request. + + Ok(()) +} diff --git a/crates/ty_server/tests/e2e/main.rs b/crates/ty_server/tests/e2e/main.rs index dc90f8ae3a..97cd72b8a4 100644 --- a/crates/ty_server/tests/e2e/main.rs +++ b/crates/ty_server/tests/e2e/main.rs @@ -868,7 +868,7 @@ impl TestServerBuilder { pub(crate) fn enable_pull_diagnostics(mut self, enabled: bool) -> Self { self.client_capabilities .text_document - .get_or_insert_with(Default::default) + .get_or_insert_default() .diagnostic = if enabled { Some(DiagnosticClientCapabilities::default()) } else { @@ -881,9 +881,20 @@ impl TestServerBuilder { pub(crate) fn enable_diagnostic_dynamic_registration(mut self, enabled: bool) -> Self { self.client_capabilities .text_document - .get_or_insert_with(Default::default) + .get_or_insert_default() .diagnostic - .get_or_insert_with(Default::default) + .get_or_insert_default() + .dynamic_registration = Some(enabled); + self + } + + /// Enable or disable dynamic registration of rename capability + pub(crate) fn enable_rename_dynamic_registration(mut self, enabled: bool) -> Self { + self.client_capabilities + .text_document + .get_or_insert_default() + .rename + .get_or_insert_default() .dynamic_registration = Some(enabled); self } @@ -892,7 +903,7 @@ impl TestServerBuilder { pub(crate) fn enable_workspace_configuration(mut self, enabled: bool) -> Self { self.client_capabilities .workspace - .get_or_insert_with(Default::default) + .get_or_insert_default() .configuration = Some(enabled); self } @@ -902,7 +913,7 @@ impl TestServerBuilder { pub(crate) fn enable_did_change_watched_files(mut self, enabled: bool) -> Self { self.client_capabilities .workspace - .get_or_insert_with(Default::default) + .get_or_insert_default() .did_change_watched_files = if enabled { Some(DidChangeWatchedFilesClientCapabilities::default()) } else { diff --git a/crates/ty_server/tests/e2e/snapshots/e2e__initialize__initialization.snap b/crates/ty_server/tests/e2e/snapshots/e2e__initialize__initialization.snap index d8ebdd77a4..0c00d640c8 100644 --- a/crates/ty_server/tests/e2e/snapshots/e2e__initialize__initialization.snap +++ b/crates/ty_server/tests/e2e/snapshots/e2e__initialize__initialization.snap @@ -31,9 +31,6 @@ expression: initialization_result "documentHighlightProvider": true, "documentSymbolProvider": true, "workspaceSymbolProvider": true, - "renameProvider": { - "prepareProvider": true - }, "declarationProvider": true, "semanticTokensProvider": { "legend": { diff --git a/crates/ty_server/tests/e2e/snapshots/e2e__initialize__initialization_with_workspace.snap b/crates/ty_server/tests/e2e/snapshots/e2e__initialize__initialization_with_workspace.snap index d8ebdd77a4..0c00d640c8 100644 --- a/crates/ty_server/tests/e2e/snapshots/e2e__initialize__initialization_with_workspace.snap +++ b/crates/ty_server/tests/e2e/snapshots/e2e__initialize__initialization_with_workspace.snap @@ -31,9 +31,6 @@ expression: initialization_result "documentHighlightProvider": true, "documentSymbolProvider": true, "workspaceSymbolProvider": true, - "renameProvider": { - "prepareProvider": true - }, "declarationProvider": true, "semanticTokensProvider": { "legend": {