From cfd5b8ff10cd88a97988292c964689f67301520b Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Mon, 28 Jul 2025 23:19:31 -0400 Subject: [PATCH] python: Uplift basedpyright support into core (#35250) This PR adds a built-in adapter for the basedpyright language server. For now, it's behind the `basedpyright` feature flag, and needs to be requested explicitly like this for staff: ``` "languages": { "Python": { "language_servers": ["basedpyright", "!pylsp", "!pyright"] } } ``` (After uninstalling the basedpyright extension.) Release Notes: - N/A --- Cargo.lock | 1 + crates/languages/Cargo.toml | 1 + crates/languages/src/lib.rs | 24 ++- crates/languages/src/python.rs | 337 +++++++++++++++++++++++++++++++++ 4 files changed, 362 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 25196fc349..7ab4a85c7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9226,6 +9226,7 @@ dependencies = [ "chrono", "collections", "dap", + "feature_flags", "futures 0.3.31", "gpui", "http_client", diff --git a/crates/languages/Cargo.toml b/crates/languages/Cargo.toml index 2e8f007cff..260126da63 100644 --- a/crates/languages/Cargo.toml +++ b/crates/languages/Cargo.toml @@ -41,6 +41,7 @@ async-trait.workspace = true chrono.workspace = true collections.workspace = true dap.workspace = true +feature_flags.workspace = true futures.workspace = true gpui.workspace = true http_client.workspace = true diff --git a/crates/languages/src/lib.rs b/crates/languages/src/lib.rs index a224111002..001fd15200 100644 --- a/crates/languages/src/lib.rs +++ b/crates/languages/src/lib.rs @@ -1,4 +1,5 @@ use anyhow::Context as _; +use feature_flags::{FeatureFlag, FeatureFlagAppExt as _}; use gpui::{App, UpdateGlobal}; use node_runtime::NodeRuntime; use python::PyprojectTomlManifestProvider; @@ -11,7 +12,7 @@ use util::{ResultExt, asset_str}; pub use language::*; -use crate::json::JsonTaskProvider; +use crate::{json::JsonTaskProvider, python::BasedPyrightLspAdapter}; mod bash; mod c; @@ -52,6 +53,12 @@ pub static LANGUAGE_GIT_COMMIT: std::sync::LazyLock> = )) }); +struct BasedPyrightFeatureFlag; + +impl FeatureFlag for BasedPyrightFeatureFlag { + const NAME: &'static str = "basedpyright"; +} + pub fn init(languages: Arc, node: NodeRuntime, cx: &mut App) { #[cfg(feature = "load-grammars")] languages.register_native_grammars([ @@ -88,6 +95,7 @@ pub fn init(languages: Arc, node: NodeRuntime, cx: &mut App) { let py_lsp_adapter = Arc::new(python::PyLspAdapter::new()); let python_context_provider = Arc::new(python::PythonContextProvider); let python_lsp_adapter = Arc::new(python::PythonLspAdapter::new(node.clone())); + let basedpyright_lsp_adapter = Arc::new(BasedPyrightLspAdapter::new()); let python_toolchain_provider = Arc::new(python::PythonToolchainProvider::default()); let rust_context_provider = Arc::new(rust::RustContextProvider); let rust_lsp_adapter = Arc::new(rust::RustLspAdapter); @@ -228,6 +236,20 @@ pub fn init(languages: Arc, node: NodeRuntime, cx: &mut App) { ); } + let mut basedpyright_lsp_adapter = Some(basedpyright_lsp_adapter); + cx.observe_flag::({ + let languages = languages.clone(); + move |enabled, _| { + if enabled { + if let Some(adapter) = basedpyright_lsp_adapter.take() { + languages + .register_available_lsp_adapter(adapter.name(), move || adapter.clone()); + } + } + } + }) + .detach(); + // Register globally available language servers. // // This will allow users to add support for a built-in language server (e.g., Tailwind) diff --git a/crates/languages/src/python.rs b/crates/languages/src/python.rs index ebdbd93248..4a0cc7078b 100644 --- a/crates/languages/src/python.rs +++ b/crates/languages/src/python.rs @@ -1290,6 +1290,343 @@ impl LspAdapter for PyLspAdapter { } } +pub(crate) struct BasedPyrightLspAdapter { + python_venv_base: OnceCell, String>>, +} + +impl BasedPyrightLspAdapter { + const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("basedpyright"); + const BINARY_NAME: &'static str = "basedpyright-langserver"; + + pub(crate) fn new() -> Self { + Self { + python_venv_base: OnceCell::new(), + } + } + + async fn ensure_venv(delegate: &dyn LspAdapterDelegate) -> Result> { + let python_path = Self::find_base_python(delegate) + .await + .context("Could not find Python installation for basedpyright")?; + let work_dir = delegate + .language_server_download_dir(&Self::SERVER_NAME) + .await + .context("Could not get working directory for basedpyright")?; + let mut path = PathBuf::from(work_dir.as_ref()); + path.push("basedpyright-venv"); + if !path.exists() { + util::command::new_smol_command(python_path) + .arg("-m") + .arg("venv") + .arg("basedpyright-venv") + .current_dir(work_dir) + .spawn()? + .output() + .await?; + } + + Ok(path.into()) + } + + // Find "baseline", user python version from which we'll create our own venv. + async fn find_base_python(delegate: &dyn LspAdapterDelegate) -> Option { + for path in ["python3", "python"] { + if let Some(path) = delegate.which(path.as_ref()).await { + return Some(path); + } + } + None + } + + async fn base_venv(&self, delegate: &dyn LspAdapterDelegate) -> Result, String> { + self.python_venv_base + .get_or_init(move || async move { + Self::ensure_venv(delegate) + .await + .map_err(|e| format!("{e}")) + }) + .await + .clone() + } +} + +#[async_trait(?Send)] +impl LspAdapter for BasedPyrightLspAdapter { + fn name(&self) -> LanguageServerName { + Self::SERVER_NAME.clone() + } + + async fn initialization_options( + self: Arc, + _: &dyn Fs, + _: &Arc, + ) -> Result> { + // Provide minimal initialization options + // Virtual environment configuration will be handled through workspace configuration + Ok(Some(json!({ + "python": { + "analysis": { + "autoSearchPaths": true, + "useLibraryCodeForTypes": true, + "autoImportCompletions": true + } + } + }))) + } + + async fn check_if_user_installed( + &self, + delegate: &dyn LspAdapterDelegate, + toolchains: Arc, + cx: &AsyncApp, + ) -> Option { + if let Some(bin) = delegate.which(Self::BINARY_NAME.as_ref()).await { + let env = delegate.shell_env().await; + Some(LanguageServerBinary { + path: bin, + env: Some(env), + arguments: vec!["--stdio".into()], + }) + } else { + let venv = toolchains + .active_toolchain( + delegate.worktree_id(), + Arc::from("".as_ref()), + LanguageName::new("Python"), + &mut cx.clone(), + ) + .await?; + let path = Path::new(venv.path.as_ref()) + .parent()? + .join(Self::BINARY_NAME); + path.exists().then(|| LanguageServerBinary { + path, + arguments: vec!["--stdio".into()], + env: None, + }) + } + } + + async fn fetch_latest_server_version( + &self, + _: &dyn LspAdapterDelegate, + ) -> Result> { + Ok(Box::new(()) as Box<_>) + } + + async fn fetch_server_binary( + &self, + _latest_version: Box, + _container_dir: PathBuf, + delegate: &dyn LspAdapterDelegate, + ) -> Result { + let venv = self.base_venv(delegate).await.map_err(|e| anyhow!(e))?; + let pip_path = venv.join(BINARY_DIR).join("pip3"); + ensure!( + util::command::new_smol_command(pip_path.as_path()) + .arg("install") + .arg("basedpyright") + .arg("-U") + .output() + .await? + .status + .success(), + "basedpyright installation failed" + ); + let pylsp = venv.join(BINARY_DIR).join(Self::BINARY_NAME); + Ok(LanguageServerBinary { + path: pylsp, + env: None, + arguments: vec!["--stdio".into()], + }) + } + + async fn cached_server_binary( + &self, + _container_dir: PathBuf, + delegate: &dyn LspAdapterDelegate, + ) -> Option { + let venv = self.base_venv(delegate).await.ok()?; + let pylsp = venv.join(BINARY_DIR).join(Self::BINARY_NAME); + Some(LanguageServerBinary { + path: pylsp, + env: None, + arguments: vec!["--stdio".into()], + }) + } + + async fn process_completions(&self, items: &mut [lsp::CompletionItem]) { + // Pyright assigns each completion item a `sortText` of the form `XX.YYYY.name`. + // Where `XX` is the sorting category, `YYYY` is based on most recent usage, + // and `name` is the symbol name itself. + // + // Because the symbol name is included, there generally are not ties when + // sorting by the `sortText`, so the symbol's fuzzy match score is not taken + // into account. Here, we remove the symbol name from the sortText in order + // to allow our own fuzzy score to be used to break ties. + // + // see https://github.com/microsoft/pyright/blob/95ef4e103b9b2f129c9320427e51b73ea7cf78bd/packages/pyright-internal/src/languageService/completionProvider.ts#LL2873 + for item in items { + let Some(sort_text) = &mut item.sort_text else { + continue; + }; + let mut parts = sort_text.split('.'); + let Some(first) = parts.next() else { continue }; + let Some(second) = parts.next() else { continue }; + let Some(_) = parts.next() else { continue }; + sort_text.replace_range(first.len() + second.len() + 1.., ""); + } + } + + async fn label_for_completion( + &self, + item: &lsp::CompletionItem, + language: &Arc, + ) -> Option { + let label = &item.label; + let grammar = language.grammar()?; + let highlight_id = match item.kind? { + lsp::CompletionItemKind::METHOD => grammar.highlight_id_for_name("function.method")?, + lsp::CompletionItemKind::FUNCTION => grammar.highlight_id_for_name("function")?, + lsp::CompletionItemKind::CLASS => grammar.highlight_id_for_name("type")?, + lsp::CompletionItemKind::CONSTANT => grammar.highlight_id_for_name("constant")?, + _ => return None, + }; + let filter_range = item + .filter_text + .as_deref() + .and_then(|filter| label.find(filter).map(|ix| ix..ix + filter.len())) + .unwrap_or(0..label.len()); + Some(language::CodeLabel { + text: label.clone(), + runs: vec![(0..label.len(), highlight_id)], + filter_range, + }) + } + + async fn label_for_symbol( + &self, + name: &str, + kind: lsp::SymbolKind, + language: &Arc, + ) -> Option { + let (text, filter_range, display_range) = match kind { + lsp::SymbolKind::METHOD | lsp::SymbolKind::FUNCTION => { + let text = format!("def {}():\n", name); + let filter_range = 4..4 + name.len(); + let display_range = 0..filter_range.end; + (text, filter_range, display_range) + } + lsp::SymbolKind::CLASS => { + let text = format!("class {}:", name); + let filter_range = 6..6 + name.len(); + let display_range = 0..filter_range.end; + (text, filter_range, display_range) + } + lsp::SymbolKind::CONSTANT => { + let text = format!("{} = 0", name); + let filter_range = 0..name.len(); + let display_range = 0..filter_range.end; + (text, filter_range, display_range) + } + _ => return None, + }; + + Some(language::CodeLabel { + runs: language.highlight_text(&text.as_str().into(), display_range.clone()), + text: text[display_range].to_string(), + filter_range, + }) + } + + async fn workspace_configuration( + self: Arc, + _: &dyn Fs, + adapter: &Arc, + toolchains: Arc, + cx: &mut AsyncApp, + ) -> Result { + let toolchain = toolchains + .active_toolchain( + adapter.worktree_id(), + Arc::from("".as_ref()), + LanguageName::new("Python"), + cx, + ) + .await; + cx.update(move |cx| { + let mut user_settings = + language_server_settings(adapter.as_ref(), &Self::SERVER_NAME, cx) + .and_then(|s| s.settings.clone()) + .unwrap_or_default(); + + // If we have a detected toolchain, configure Pyright to use it + if let Some(toolchain) = toolchain { + if user_settings.is_null() { + user_settings = Value::Object(serde_json::Map::default()); + } + let object = user_settings.as_object_mut().unwrap(); + + let interpreter_path = toolchain.path.to_string(); + + // Detect if this is a virtual environment + if let Some(interpreter_dir) = Path::new(&interpreter_path).parent() { + if let Some(venv_dir) = interpreter_dir.parent() { + // Check if this looks like a virtual environment + if venv_dir.join("pyvenv.cfg").exists() + || venv_dir.join("bin/activate").exists() + || venv_dir.join("Scripts/activate.bat").exists() + { + // Set venvPath and venv at the root level + // This matches the format of a pyrightconfig.json file + if let Some(parent) = venv_dir.parent() { + // Use relative path if the venv is inside the workspace + let venv_path = if parent == adapter.worktree_root_path() { + ".".to_string() + } else { + parent.to_string_lossy().into_owned() + }; + object.insert("venvPath".to_string(), Value::String(venv_path)); + } + + if let Some(venv_name) = venv_dir.file_name() { + object.insert( + "venv".to_owned(), + Value::String(venv_name.to_string_lossy().into_owned()), + ); + } + } + } + } + + // Always set the python interpreter path + // Get or create the python section + let python = object + .entry("python") + .or_insert(Value::Object(serde_json::Map::default())) + .as_object_mut() + .unwrap(); + + // Set both pythonPath and defaultInterpreterPath for compatibility + python.insert( + "pythonPath".to_owned(), + Value::String(interpreter_path.clone()), + ); + python.insert( + "defaultInterpreterPath".to_owned(), + Value::String(interpreter_path), + ); + } + + user_settings + }) + } + + fn manifest_name(&self) -> Option { + Some(SharedString::new_static("pyproject.toml").into()) + } +} + #[cfg(test)] mod tests { use gpui::{AppContext as _, BorrowAppContext, Context, TestAppContext};