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) } }