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:
1 parent
c6772f83ca
commit
03414d8058
2 files changed
+173
-8
No files matched your search
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user