From 03414d805867269300fcd2f83f3863531ab69804 Mon Sep 17 00:00:00 2001 From: Arthur Fournier Date: Mon, 20 Apr 2026 10:10:03 +0200 Subject: [PATCH] languages: Fix Python LSP workspace folder detection in uv workspaces (#53781) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #47926 ## Summary When using a uv (or Poetry/PDM) workspace with multiple subprojects, Python LSP servers (Pyright, Ruff, ty, etc.) are initialized with the subproject directory as their workspace folder instead of the workspace root. This happens because `PyprojectTomlManifestProvider::search()` returns the first (innermost) `pyproject.toml` found walking up the directory tree. For example, in a uv workspace like: main-project/ ├── pyproject.toml # workspace root with [tool.uv.workspace] ├── uv.lock ├── packages/ │ └── project-api/ │ └── pyproject.toml # subpackage └── projects/ └── project-a/ └── pyproject.toml # subpackage Opening a file in `packages/project-api/` would register `packages/project-api/` as the LSP workspace folder instead of `main-project/`. ## Approach The fix uses lockfile existence as a heuristic to detect workspace roots. The updated `search()` method walks all ancestors (similar to `CargoManifestProvider`) and: - Tracks the **innermost** `pyproject.toml` as a fallback - Tracks the **outermost** `pyproject.toml` that has a sibling lockfile (`uv.lock`, `poetry.lock`, `pdm.lock`, or `Pipfile.lock`) - Returns the outermost workspace root if found, otherwise falls back to the innermost This works within the existing `ManifestDelegate` interface (existence checks only, no file content reading). | Scenario | Result | |---|---| | uv workspace (root `pyproject.toml` + `uv.lock`) | Returns workspace root | | Poetry workspace (root `pyproject.toml` + `poetry.lock`) | Returns workspace root | | Simple project (single `pyproject.toml`, no lockfile) | Returns project dir (unchanged) | | Independent subprojects (no lockfile at any level) | Returns each project's own dir (unchanged) | Since the manifest provider is set at the Python **language** level, this fix applies to all Python LSP servers (Pyright, Ruff, ty, etc.). ## Test plan - [x] Added unit tests for `PyprojectTomlManifestProvider` covering all scenarios above - [x] Existing integration test `test_running_multiple_instances_of_a_single_server_in_one_worktree` passes (independent subprojects without lockfiles) - [x] `cargo check -p languages` compiles cleanly - [x] Manual testing with a real uv workspace (Pyright and Ruff both receive correct workspace root) Release Notes: - Fixed Python LSP servers (Pyright, Ruff, etc.) using the wrong workspace folder in uv/Poetry/PDM workspaces with multiple subprojects. --- crates/languages/src/python.rs | 157 +++++++++++++++++- .../tests/integration/project_tests.rs | 24 ++- 2 files changed, 173 insertions(+), 8 deletions(-) diff --git a/crates/languages/src/python.rs b/crates/languages/src/python.rs index d27db372bf3d5f84ba282b30afd060f3ae4b183e..17a1d24be765598b83cbba65ecf54e2ad1f3b558 100644 --- a/crates/languages/src/python.rs +++ b/crates/languages/src/python.rs @@ -82,14 +82,30 @@ impl ManifestProvider for PyprojectTomlManifestProvider { delegate, }: ManifestQuery, ) -> Option> { + const WORKSPACE_LOCKFILES: &[&str] = + &["uv.lock", "poetry.lock", "pdm.lock", "Pipfile.lock"]; + + let mut innermost_pyproject = None; + let mut outermost_workspace_root = None; + for path in path.ancestors().take(depth) { - let p = path.join(RelPath::unix("pyproject.toml").unwrap()); - if delegate.exists(&p, Some(false)) { - return Some(path.into()); + let pyproject_path = path.join(RelPath::unix("pyproject.toml").unwrap()); + if delegate.exists(&pyproject_path, Some(false)) { + if innermost_pyproject.is_none() { + innermost_pyproject = Some(Arc::from(path)); + } + + let has_lockfile = WORKSPACE_LOCKFILES.iter().any(|lockfile| { + let lockfile_path = path.join(RelPath::unix(lockfile).unwrap()); + delegate.exists(&lockfile_path, Some(false)) + }); + if has_lockfile { + outermost_workspace_root = Some(Arc::from(path)); + } } } - None + outermost_workspace_root.or(innermost_pyproject) } } @@ -3018,4 +3034,137 @@ mod tests { assert!(enum_values.contains(&serde_json::json!("single"))); assert!(enum_values.contains(&serde_json::json!("preserve"))); } + + mod pyproject_manifest_tests { + use std::collections::HashSet; + use std::sync::Arc; + + use language::{ManifestDelegate, ManifestProvider, ManifestQuery}; + use settings::WorktreeId; + use util::rel_path::RelPath; + + use crate::python::PyprojectTomlManifestProvider; + + struct FakeManifestDelegate { + existing_files: HashSet<&'static str>, + } + + impl ManifestDelegate for FakeManifestDelegate { + fn worktree_id(&self) -> WorktreeId { + WorktreeId::from_usize(0) + } + + fn exists(&self, path: &RelPath, _is_dir: Option) -> bool { + self.existing_files.contains(path.as_unix_str()) + } + } + + fn search(files: &[&'static str], query_path: &str) -> Option> { + let delegate = Arc::new(FakeManifestDelegate { + existing_files: files.iter().copied().collect(), + }); + let provider = PyprojectTomlManifestProvider; + provider.search(ManifestQuery { + path: RelPath::unix(query_path).unwrap().into(), + depth: 10, + delegate, + }) + } + + #[test] + fn test_simple_project_no_lockfile() { + let result = search(&["project/pyproject.toml"], "project/src/main.py"); + assert_eq!(result.as_deref(), RelPath::unix("project").ok()); + } + + #[test] + fn test_uv_workspace_returns_root() { + let result = search( + &[ + "pyproject.toml", + "uv.lock", + "packages/subproject/pyproject.toml", + ], + "packages/subproject/src/main.py", + ); + assert_eq!(result.as_deref(), RelPath::unix("").ok()); + } + + #[test] + fn test_poetry_workspace_returns_root() { + let result = search( + &["pyproject.toml", "poetry.lock", "libs/mylib/pyproject.toml"], + "libs/mylib/src/main.py", + ); + assert_eq!(result.as_deref(), RelPath::unix("").ok()); + } + + #[test] + fn test_pdm_workspace_returns_root() { + let result = search( + &[ + "pyproject.toml", + "pdm.lock", + "packages/mypackage/pyproject.toml", + ], + "packages/mypackage/src/main.py", + ); + assert_eq!(result.as_deref(), RelPath::unix("").ok()); + } + + #[test] + fn test_independent_subprojects_no_lockfile_at_root() { + let result_a = search( + &["project-a/pyproject.toml", "project-b/pyproject.toml"], + "project-a/src/main.py", + ); + assert_eq!(result_a.as_deref(), RelPath::unix("project-a").ok()); + + let result_b = search( + &["project-a/pyproject.toml", "project-b/pyproject.toml"], + "project-b/src/main.py", + ); + assert_eq!(result_b.as_deref(), RelPath::unix("project-b").ok()); + } + + #[test] + fn test_no_pyproject_returns_none() { + let result = search(&[], "src/main.py"); + assert_eq!(result, None); + } + + #[test] + fn test_subproject_with_own_lockfile_and_workspace_root() { + // Both root and subproject have lockfiles; should return root (outermost) + let result = search( + &[ + "pyproject.toml", + "uv.lock", + "packages/sub/pyproject.toml", + "packages/sub/uv.lock", + ], + "packages/sub/src/main.py", + ); + assert_eq!(result.as_deref(), RelPath::unix("").ok()); + } + + #[test] + fn test_depth_limits_search() { + let delegate = Arc::new(FakeManifestDelegate { + existing_files: ["pyproject.toml", "uv.lock", "deep/nested/pyproject.toml"] + .into_iter() + .collect(), + }); + let provider = PyprojectTomlManifestProvider; + // depth=3 from "deep/nested/src/main.py" searches: + // "deep/nested/src/main.py", "deep/nested/src", and "deep/nested" + // It won't reach "deep" or root "" + let result = provider.search(ManifestQuery { + path: RelPath::unix("deep/nested/src/main.py").unwrap().into(), + depth: 3, + delegate, + }); + assert_eq!(result.as_deref(), RelPath::unix("deep/nested").ok()); + } + } } diff --git a/crates/project/tests/integration/project_tests.rs b/crates/project/tests/integration/project_tests.rs index fa7454e5e16f17e4bbdebc87d90fa780c996724e..bad9fcf58dc9392fd92f0c0930aa50fbe3b728d0 100644 --- a/crates/project/tests/integration/project_tests.rs +++ b/crates/project/tests/integration/project_tests.rs @@ -1330,14 +1330,30 @@ async fn test_running_multiple_instances_of_a_single_server_in_one_worktree( delegate, }: ManifestQuery, ) -> Option> { + const WORKSPACE_LOCKFILES: &[&str] = + &["uv.lock", "poetry.lock", "pdm.lock", "Pipfile.lock"]; + + let mut innermost_pyproject = None; + let mut outermost_workspace_root = None; + for path in path.ancestors().take(depth) { - let p = path.join(rel_path("pyproject.toml")); - if delegate.exists(&p, Some(false)) { - return Some(path.into()); + let pyproject_path = path.join(rel_path("pyproject.toml")); + if delegate.exists(&pyproject_path, Some(false)) { + if innermost_pyproject.is_none() { + innermost_pyproject = Some(Arc::from(path)); + } + + let has_lockfile = WORKSPACE_LOCKFILES.iter().any(|lockfile| { + let lockfile_path = path.join(rel_path(lockfile)); + delegate.exists(&lockfile_path, Some(false)) + }); + if has_lockfile { + outermost_workspace_root = Some(Arc::from(path)); + } } } - None + outermost_workspace_root.or(innermost_pyproject) } }