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

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.
This commit is contained in:
Arthur Fournier authored and GitHub committed 2026-04-20 10:10:03 +02:00
1 parent c6772f83ca
commit 03414d8058
2 files changed
+173 -8

No files matched your search

+153 -4
View File
@@ -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());
}
}
}