diff --git a/lsp/README.md b/lsp/README.md index 8f0ef09434..e94c39d5b6 100644 --- a/lsp/README.md +++ b/lsp/README.md @@ -32,5 +32,12 @@ settings: will provide both type errors and other language features like go-to definition, intellisense, hover, etc. Enable this option to keep type errors from Pyrefly unchanged but use VSCode's Python extension for everything else. + - `python.pyrefly.analysis.disabledLanguageServices` [boolean: false]: an + analysis-level option that disables language-service features produced by + Pyrefly's analysis (for example: go-to definition, hover, and other + navigation/intelligence features) while leaving Pyrefly diagnostics + (type errors) intact. Use this when you want Pyrefly's type checking but + prefer the editor's language features or another provider for those + services. - `pyrefly.lspPath` [string: '']: if your platform is not supported, you can build pyrefly from source and specify the binary here. diff --git a/lsp/package.json b/lsp/package.json index 7b2f9ffb1a..a9b770af12 100644 --- a/lsp/package.json +++ b/lsp/package.json @@ -95,6 +95,69 @@ "off", "verbose" ] + }, + "python.pyrefly.analysis.disabledLanguageServices": { + "type": "object", + "default": {}, + "description": "Disable specific language services. Set individual services to true to disable them.", + "properties": { + "hover": { + "type": "boolean", + "default": false + }, + "documentSymbol": { + "type": "boolean", + "default": false + }, + "workspaceSymbol": { + "type": "boolean", + "default": false + }, + "inlayHint": { + "type": "boolean", + "default": false + }, + "completion": { + "type": "boolean", + "default": false + }, + "codeAction": { + "type": "boolean", + "default": false + }, + "definition": { + "type": "boolean", + "default": false + }, + "typeDefinition": { + "type": "boolean", + "default": false + }, + "references": { + "type": "boolean", + "default": false + }, + "documentHighlight": { + "type": "boolean", + "default": false + }, + "rename": { + "type": "boolean", + "default": false + }, + "codeLens": { + "type": "boolean", + "default": false + }, + "semanticTokens": { + "type": "boolean", + "default": false + }, + "signatureHelp": { + "type": "boolean", + "default": false + } + } } } } diff --git a/pyrefly/lib/lsp/non_wasm/server.rs b/pyrefly/lib/lsp/non_wasm/server.rs index 244d3fa38f..c88d2d8d69 100644 --- a/pyrefly/lib/lsp/non_wasm/server.rs +++ b/pyrefly/lib/lsp/non_wasm/server.rs @@ -1097,7 +1097,8 @@ impl Server { params: crate::lsp::wasm::provide_type::ProvideTypeParams, ) -> Option { let uri = ¶ms.text_document.uri; - let handle = self.make_handle_if_enabled(uri)?; + // provide_type is not a standard LSP service so we will always keep it enabled + let handle = self.make_handle_if_enabled(uri, "textDocument/provideType")?; provide_type(transaction, &handle, params.positions) } @@ -1608,31 +1609,46 @@ impl Server { /// Create a handle with analysis config that decides language service behavior. /// Return None if the workspace has language services disabled (and thus you shouldn't do anything). + /// + /// `method` should be the LSP request METHOD string from lsp_types::request::* types + /// (e.g., GotoDefinition::METHOD, HoverRequest::METHOD, etc.) fn make_handle_with_lsp_analysis_config_if_enabled( &self, uri: &Url, + method: &str, ) -> Option<(Handle, Option)> { let path = uri.to_file_path().unwrap(); self.workspaces.get_with(path.clone(), |(_, workspace)| { + // Check if all language services are disabled if workspace.disable_language_services { eprintln!("Skipping request - language services disabled"); - None - } else { - let module_path = if self.open_files.read().contains_key(&path) { - ModulePath::memory(path) - } else { - ModulePath::filesystem(path) - }; - Some(( - handle_from_module_path(&self.state, module_path), - workspace.lsp_analysis_config, - )) + return None; + } + + // Check if the specific service is disabled + if let Some(lsp_config) = workspace.lsp_analysis_config { + if let Some(disabled_services) = lsp_config.disabled_language_services { + if disabled_services.is_disabled(method) { + eprintln!("Skipping request - {} service disabled", method); + return None; + } + } } + + let module_path = if self.open_files.read().contains_key(&path) { + ModulePath::memory(path) + } else { + ModulePath::filesystem(path) + }; + Some(( + handle_from_module_path(&self.state, module_path), + workspace.lsp_analysis_config, + )) }) } - fn make_handle_if_enabled(&self, uri: &Url) -> Option { - self.make_handle_with_lsp_analysis_config_if_enabled(uri) + fn make_handle_if_enabled(&self, uri: &Url, method: &str) -> Option { + self.make_handle_with_lsp_analysis_config_if_enabled(uri, method) .map(|(handle, _)| handle) } @@ -1642,7 +1658,7 @@ impl Server { params: GotoDefinitionParams, ) -> Option { let uri = ¶ms.text_document_position_params.text_document.uri; - let handle = self.make_handle_if_enabled(uri)?; + let handle = self.make_handle_if_enabled(uri, GotoDefinition::METHOD)?; let info = transaction.get_module_info(&handle)?; let range = info .lined_buffer() @@ -1667,7 +1683,7 @@ impl Server { params: GotoTypeDefinitionParams, ) -> Option { let uri = ¶ms.text_document_position_params.text_document.uri; - let handle = self.make_handle_if_enabled(uri)?; + let handle = self.make_handle_if_enabled(uri, GotoTypeDefinition::METHOD)?; let info = transaction.get_module_info(&handle)?; let range = info .lined_buffer() @@ -1694,7 +1710,7 @@ impl Server { ) -> anyhow::Result { let uri = ¶ms.text_document_position.text_document.uri; let (handle, import_format) = - match self.make_handle_with_lsp_analysis_config_if_enabled(uri) { + match self.make_handle_with_lsp_analysis_config_if_enabled(uri, Completion::METHOD) { None => { return Ok(CompletionResponse::List(CompletionList { is_incomplete: false, @@ -1726,7 +1742,8 @@ impl Server { params: CodeActionParams, ) -> Option { let uri = ¶ms.text_document.uri; - let (handle, lsp_config) = self.make_handle_with_lsp_analysis_config_if_enabled(uri)?; + let (handle, lsp_config) = + self.make_handle_with_lsp_analysis_config_if_enabled(uri, CodeActionRequest::METHOD)?; let import_format = lsp_config.and_then(|c| c.import_format).unwrap_or_default(); let module_info = transaction.get_module_info(&handle)?; let range = module_info.lined_buffer().from_lsp_range(params.range); @@ -1758,7 +1775,7 @@ impl Server { params: DocumentHighlightParams, ) -> Option> { let uri = ¶ms.text_document_position_params.text_document.uri; - let handle = self.make_handle_if_enabled(uri)?; + let handle = self.make_handle_if_enabled(uri, DocumentHighlightRequest::METHOD)?; let info = transaction.get_module_info(&handle)?; let position = info .lined_buffer() @@ -1784,7 +1801,7 @@ impl Server { position: Position, map_result: impl FnOnce(Vec<(Url, Vec)>) -> V + Send + Sync + 'static, ) { - let Some(handle) = self.make_handle_if_enabled(uri) else { + let Some(handle) = self.make_handle_if_enabled(uri, References::METHOD) else { return self.send_response(new_response::>(request_id, Ok(None))); }; let transaction = ide_transaction_manager.non_committable_transaction(&self.state); @@ -1922,7 +1939,7 @@ impl Server { params: TextDocumentPositionParams, ) -> Option { let uri = ¶ms.text_document.uri; - let handle = self.make_handle_if_enabled(uri)?; + let handle = self.make_handle_if_enabled(uri, Rename::METHOD)?; let info = transaction.get_module_info(&handle)?; let position = info.lined_buffer().from_lsp_position(params.position); transaction @@ -1936,7 +1953,7 @@ impl Server { params: SignatureHelpParams, ) -> Option { let uri = ¶ms.text_document_position_params.text_document.uri; - let handle = self.make_handle_if_enabled(uri)?; + let handle = self.make_handle_if_enabled(uri, SignatureHelpRequest::METHOD)?; let info = transaction.get_module_info(&handle)?; let position = info .lined_buffer() @@ -1946,7 +1963,7 @@ impl Server { fn hover(&self, transaction: &Transaction<'_>, params: HoverParams) -> Option { let uri = ¶ms.text_document_position_params.text_document.uri; - let handle = self.make_handle_if_enabled(uri)?; + let handle = self.make_handle_if_enabled(uri, HoverRequest::METHOD)?; let info = transaction.get_module_info(&handle)?; let position = info .lined_buffer() @@ -1963,7 +1980,7 @@ impl Server { let uri = ¶ms.text_document.uri; let range = ¶ms.range; let (handle, lsp_analysis_config) = - self.make_handle_with_lsp_analysis_config_if_enabled(uri)?; + self.make_handle_with_lsp_analysis_config_if_enabled(uri, InlayHintRequest::METHOD)?; let info = transaction.get_module_info(&handle)?; let t = transaction.inlay_hints( &handle, @@ -2004,7 +2021,7 @@ impl Server { params: SemanticTokensParams, ) -> Option { let uri = ¶ms.text_document.uri; - let handle = self.make_handle_if_enabled(uri)?; + let handle = self.make_handle_if_enabled(uri, SemanticTokensFullRequest::METHOD)?; Some(SemanticTokensResult::Tokens(SemanticTokens { result_id: None, data: transaction @@ -2019,7 +2036,7 @@ impl Server { params: SemanticTokensRangeParams, ) -> Option { let uri = ¶ms.text_document.uri; - let handle = self.make_handle_if_enabled(uri)?; + let handle = self.make_handle_if_enabled(uri, SemanticTokensRangeRequest::METHOD)?; let module_info = transaction.get_module_info(&handle)?; let range = module_info.lined_buffer().from_lsp_range(params.range); Some(SemanticTokensRangeResult::Tokens(SemanticTokens { @@ -2052,7 +2069,7 @@ impl Server { { return None; } - let handle = self.make_handle_if_enabled(uri)?; + let handle = self.make_handle_if_enabled(uri, DocumentSymbolRequest::METHOD)?; transaction.symbols(&handle) } diff --git a/pyrefly/lib/lsp/non_wasm/workspace.rs b/pyrefly/lib/lsp/non_wasm/workspace.rs index 1b871c0578..db1f234e2c 100644 --- a/pyrefly/lib/lsp/non_wasm/workspace.rs +++ b/pyrefly/lib/lsp/non_wasm/workspace.rs @@ -158,6 +158,8 @@ struct PyreflyClientConfig { display_type_errors: Option, disable_language_services: Option, extra_paths: Option>, + #[serde(default, deserialize_with = "deserialize_analysis")] + analysis: Option, } #[derive(Clone, Copy, Debug, Deserialize)] @@ -169,6 +171,61 @@ pub enum DiagnosticMode { OpenFilesOnly, } +/// Configuration for which language services should be disabled +#[derive(Clone, Copy, Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DisabledLanguageServices { + #[serde(default)] + pub definition: bool, + #[serde(default)] + pub type_definition: bool, + #[serde(default)] + pub code_action: bool, + #[serde(default)] + pub completion: bool, + #[serde(default)] + pub document_highlight: bool, + #[serde(default)] + pub references: bool, + #[serde(default)] + pub rename: bool, + #[serde(default)] + pub signature_help: bool, + #[serde(default)] + pub hover: bool, + #[serde(default)] + pub inlay_hint: bool, + #[serde(default)] + pub document_symbol: bool, + #[serde(default)] + pub semantic_tokens: bool, +} + +impl DisabledLanguageServices { + /// Check if a language service is disabled based on the LSP request METHOD string + /// Uses the METHOD constants from lsp_types::request::* types + pub fn is_disabled(&self, method: &str) -> bool { + match method { + "textDocument/provideType" => false, // Always enabled + "textDocument/definition" => self.definition, + "textDocument/typeDefinition" => self.type_definition, + "textDocument/codeAction" => self.code_action, + "textDocument/completion" => self.completion, + "textDocument/documentHighlight" => self.document_highlight, + "textDocument/references" => self.references, + "textDocument/rename" => self.rename, + "textDocument/signatureHelp" => self.signature_help, + "textDocument/hover" => self.hover, + "textDocument/inlayHint" => self.inlay_hint, + "textDocument/documentSymbol" => self.document_symbol, + "textDocument/semanticTokens/full" | "textDocument/semanticTokens/range" => { + self.semantic_tokens + } + _ => false, // Unknown methods are not disabled + } + } +} + /// https://code.visualstudio.com/docs/python/settings-reference#_pylance-language-server #[derive(Clone, Copy, Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] @@ -177,6 +234,8 @@ pub struct LspAnalysisConfig { pub diagnostic_mode: Option, pub import_format: Option, pub inlay_hints: Option, + #[serde(default)] + pub disabled_language_services: Option, } fn deserialize_analysis<'de, D>(deserializer: D) -> Result, D::Error> @@ -282,6 +341,7 @@ impl Workspaces { self.update_pythonpath(modified, scope_uri, &python_path); } + let mut analysis_handled = false; if let Some(pyrefly) = config.pyrefly { if let Some(extra_paths) = pyrefly.extra_paths { self.update_search_paths(modified, scope_uri, extra_paths); @@ -290,9 +350,17 @@ impl Workspaces { self.update_disable_language_services(scope_uri, disable_language_services); } self.update_display_type_errors(modified, scope_uri, pyrefly.display_type_errors); + // Handle analysis config nested under pyrefly (e.g., pyrefly.analysis.disabledLanguageServices) + if let Some(analysis) = pyrefly.analysis { + self.update_ide_settings(modified, scope_uri, analysis); + analysis_handled = true; + } } - if let Some(analysis) = config.analysis { - self.update_ide_settings(modified, scope_uri, analysis); + // Also handle analysis at top level for backward compatibility (only if not already handled) + if !analysis_handled { + if let Some(analysis) = config.analysis { + self.update_ide_settings(modified, scope_uri, analysis); + } } } diff --git a/pyrefly/lib/test/lsp/lsp_interaction/configuration.rs b/pyrefly/lib/test/lsp/lsp_interaction/configuration.rs index 5258b5d1d0..9e5e701fae 100644 --- a/pyrefly/lib/test/lsp/lsp_interaction/configuration.rs +++ b/pyrefly/lib/test/lsp/lsp_interaction/configuration.rs @@ -374,6 +374,115 @@ fn test_disable_language_services_default_workspace() { interaction.shutdown(); } +#[test] +fn test_disable_specific_language_services_via_analysis_config() { + let test_files_root = get_test_files_root(); + let scope_uri = Url::from_file_path(test_files_root.path()).unwrap(); + let mut interaction = LspInteraction::new(); + interaction.set_root(test_files_root.path().to_path_buf()); + interaction.initialize(InitializeSettings { + workspace_folders: Some(vec![("test".to_owned(), scope_uri.clone())]), + configuration: Some(None), + ..Default::default() + }); + + interaction.server.did_open("foo.py"); + + // Test hover works initially + interaction.server.hover("foo.py", 6, 17); + interaction.client.expect_response(Response { + id: RequestId::from(2), + result: Some(serde_json::json!({ + "contents": { + "kind":"markdown", + "value":"```python\n(class) Bar: type[Bar]\n```\n\nGo to [Bar](".to_owned() + + Url::from_file_path(test_files_root.path().join("bar.py")).unwrap().as_str() + + "#L7,7)" + } + })), + error: None, + }); + + // Test definition works initially + interaction.server.definition("foo.py", 6, 16); + interaction.client.expect_response(Response { + id: RequestId::from(3), + result: Some(serde_json::json!({ + "uri": Url::from_file_path(test_files_root.path().join("bar.py")).unwrap().to_string(), + "range": { + "start": { + "line": 6, + "character": 6 + }, + "end": { + "line": 6, + "character": 9 + } + } + })), + error: None, + }); + + // Change configuration to disable only hover (mimicking pyrefly.analysis.disabledLanguageServices) + interaction.server.did_change_configuration(); + interaction + .client + .expect_configuration_request(2, Some(vec![&scope_uri])); + interaction.server.send_configuration_response( + 2, + serde_json::json!([ + { + "pyrefly": { + "analysis": { + "disabledLanguageServices": { + "hover": true, + } + } + } + }, + { + "pyrefly": { + "analysis": { + "disabledLanguageServices": { + "hover": true, + } + } + } + } + ]), + ); + + // Hover should now be disabled + interaction.server.hover("foo.py", 6, 17); + interaction.client.expect_response(Response { + id: RequestId::from(4), + result: Some(serde_json::json!({"contents": []})), + error: None, + }); + + // But definition should still work + interaction.server.definition("foo.py", 6, 16); + interaction.client.expect_response(Response { + id: RequestId::from(5), + result: Some(serde_json::json!({ + "uri": Url::from_file_path(test_files_root.path().join("bar.py")).unwrap().to_string(), + "range": { + "start": { + "line": 6, + "character": 6 + }, + "end": { + "line": 6, + "character": 9 + } + } + })), + error: None, + }); + + interaction.shutdown(); +} + #[test] fn test_did_change_workspace_folder() { let root = get_test_files_root(); diff --git a/website/docs/IDE.mdx b/website/docs/IDE.mdx index f87aaafc64..2cd1befe70 100644 --- a/website/docs/IDE.mdx +++ b/website/docs/IDE.mdx @@ -28,6 +28,13 @@ By default, Pyrefly should work in the IDE with no configuration necessary. But The following configuration options are IDE-specific and exposed as VSCode settings: - Disable language services - `python.pyrefly.disableLanguageServices` [boolean: false]: By default, Pyrefly will provide both type errors and other language features like go-to definition, intellisense, hover, etc. Set `disableLanguageServices` to `true` to keep type errors from Pyrefly unchanged but use VSCode's Python extension for everything else. + - `python.pyrefly.analysis.disabledLanguageServices` [boolean: false]: An + analysis-level option that disables language-service features produced by + Pyrefly's analysis (for example: go-to definition, hover, and other + navigation/intelligence features) while leaving Pyrefly diagnostics + (type errors) intact. Use this when you want Pyrefly's type checking but + prefer the editor's language features or another provider for those + services. - Disable type errors - `python.pyrefly.displayTypeErrors` [string: 'default']: If `'default'`, Pyrefly will only provide type check squiggles in the IDE if your file is covered by a [Pyrefly configuration](../configuration). If `'force-off'`, Pyrefly will never provide type check squiggles in the IDE. If `'force-on'`, Pyrefly will always provide type check squiggles in the IDE. - Specify a custom Pyrefly Binary (lspPath)