languages: Fix Python LSP workspace folder detection in uv workspaces (#53781)

Arthur Fournier created

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.

Change summary

crates/languages/src/python.rs                    | 157 ++++++++++++++++
crates/project/tests/integration/project_tests.rs |  24 ++
2 files changed, 173 insertions(+), 8 deletions(-)

Detailed changes

crates/languages/src/python.rs 🔗

@@ -82,14 +82,30 @@ impl ManifestProvider for PyprojectTomlManifestProvider {
             delegate,
         }: ManifestQuery,
     ) -> Option<Arc<RelPath>> {
+        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>) -> bool {
+                self.existing_files.contains(path.as_unix_str())
+            }
+        }
+
+        fn search(files: &[&'static str], query_path: &str) -> Option<Arc<RelPath>> {
+            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());
+        }
+    }
 }

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<Arc<RelPath>> {
+            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)
         }
     }