diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ec7467..44e2d9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/ - **Internal**: Moved task queueing functionality to `djls-server` crate, renamed from `Worker` to `Queue`, and simplified API. - **Internal**: Improved Python environment handling, including refactored activation logic. - **Internal**: Centralized Python linking build logic into a shared `djls-dev` crate to reduce duplication. +- **Internal (djls-project)**: Started Salsa integration for incremental computation with database structure and initial Python environment discovery functionality. ## [5.2.0a0] diff --git a/Cargo.toml b/Cargo.toml index fe5bb90..e438b7e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ async-trait = "0.1" pyo3 = "0.24" pyo3-async-runtimes = "0.24" pyo3-build-config = "0.24" +salsa = { git = "https://github.com/salsa-rs/salsa.git", rev = "7edce6e248f35c8114b4b021cdb474a3fb2813b3" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tempfile = "3.19" diff --git a/crates/djls-project/Cargo.toml b/crates/djls-project/Cargo.toml index e61d946..03613db 100644 --- a/crates/djls-project/Cargo.toml +++ b/crates/djls-project/Cargo.toml @@ -9,6 +9,7 @@ default = [] [dependencies] pyo3 = { workspace = true } +salsa = { workspace = true } tower-lsp-server = { workspace = true, features = ["proposed"] } which = "7.0.1" diff --git a/crates/djls-project/src/db.rs b/crates/djls-project/src/db.rs new file mode 100644 index 0000000..94fc21c --- /dev/null +++ b/crates/djls-project/src/db.rs @@ -0,0 +1,31 @@ +use crate::meta::ProjectMetadata; + +#[salsa::db] +pub trait Db: salsa::Database { + fn metadata(&self) -> &ProjectMetadata; +} + +#[salsa::db] +#[derive(Clone)] +pub struct ProjectDatabase { + storage: salsa::Storage, + metadata: ProjectMetadata, +} + +impl ProjectDatabase { + pub fn new(metadata: ProjectMetadata) -> Self { + let storage = salsa::Storage::new(None); + + Self { storage, metadata } + } +} + +#[salsa::db] +impl Db for ProjectDatabase { + fn metadata(&self) -> &ProjectMetadata { + &self.metadata + } +} + +#[salsa::db] +impl salsa::Database for ProjectDatabase {} diff --git a/crates/djls-project/src/lib.rs b/crates/djls-project/src/lib.rs index cad0a3b..18e2ef7 100644 --- a/crates/djls-project/src/lib.rs +++ b/crates/djls-project/src/lib.rs @@ -1,6 +1,12 @@ +mod db; +mod meta; +mod python; mod system; mod templatetags; +use db::ProjectDatabase; +use meta::ProjectMetadata; +use python::{find_python_environment, PythonEnvironment}; pub use templatetags::TemplateTags; use pyo3::prelude::*; @@ -24,13 +30,15 @@ impl DjangoProject { } pub fn initialize(&mut self, venv_path: Option<&str>) -> PyResult<()> { - self.env = Some( - PythonEnvironment::new(self.path.as_path(), venv_path).ok_or_else(|| { - PyErr::new::( - "Could not find Python environment", - ) - })?, - ); + let venv_pathbuf = venv_path.map(PathBuf::from); + let metadata = ProjectMetadata::new(self.path.clone(), venv_pathbuf); + let db = ProjectDatabase::new(metadata); + self.env = find_python_environment(&db); + if self.env.is_none() { + return Err(PyErr::new::( + "Could not find Python environment", + )); + } Python::with_gil(|py| { let sys = py.import("sys")?; @@ -80,686 +88,56 @@ impl fmt::Display for DjangoProject { } } -#[derive(Debug, PartialEq)] -struct PythonEnvironment { - python_path: PathBuf, - sys_path: Vec, - sys_prefix: PathBuf, -} - -impl PythonEnvironment { - fn new(project_path: &Path, venv_path: Option<&str>) -> Option { - if let Some(path) = venv_path { - let prefix = PathBuf::from(path); - if let Some(env) = Self::from_venv_prefix(&prefix) { - return Some(env); - } - // Invalid explicit path, continue searching... - } - - if let Ok(virtual_env) = system::env_var("VIRTUAL_ENV") { - let prefix = PathBuf::from(virtual_env); - if let Some(env) = Self::from_venv_prefix(&prefix) { - return Some(env); - } - } - - for venv_dir in &[".venv", "venv", "env", ".env"] { - let potential_venv = project_path.join(venv_dir); - if potential_venv.is_dir() { - if let Some(env) = Self::from_venv_prefix(&potential_venv) { - return Some(env); - } - } - } - - Self::from_system_python() - } - - fn from_venv_prefix(prefix: &Path) -> Option { - #[cfg(unix)] - let python_path = prefix.join("bin").join("python"); - #[cfg(windows)] - let python_path = prefix.join("Scripts").join("python.exe"); - - if !prefix.is_dir() || !python_path.exists() { - return None; - } - - #[cfg(unix)] - let bin_dir = prefix.join("bin"); - #[cfg(windows)] - let bin_dir = prefix.join("Scripts"); - - let mut sys_path = Vec::new(); - sys_path.push(bin_dir); - - if let Some(site_packages) = Self::find_site_packages(prefix) { - if site_packages.is_dir() { - sys_path.push(site_packages); - } - } - - Some(Self { - python_path: python_path.clone(), - sys_path, - sys_prefix: prefix.to_path_buf(), - }) - } - - pub fn activate(&self, py: Python) -> PyResult<()> { - let sys = py.import("sys")?; - let py_path = sys.getattr("path")?; - - for path in &self.sys_path { - if let Some(path_str) = path.to_str() { - py_path.call_method1("append", (path_str,))?; - } - } - - Ok(()) - } - - fn from_system_python() -> Option { - let python_path = match system::find_executable("python") { - Ok(p) => p, - Err(_) => return None, - }; - let bin_dir = python_path.parent()?; - let prefix = bin_dir.parent()?; - - let mut sys_path = Vec::new(); - sys_path.push(bin_dir.to_path_buf()); - - if let Some(site_packages) = Self::find_site_packages(prefix) { - if site_packages.is_dir() { - sys_path.push(site_packages); - } - } - - Some(Self { - python_path: python_path.clone(), - sys_path, - sys_prefix: prefix.to_path_buf(), - }) - } - - #[cfg(unix)] - fn find_site_packages(prefix: &Path) -> Option { - let lib_dir = prefix.join("lib"); - if !lib_dir.is_dir() { - return None; - } - std::fs::read_dir(lib_dir) - .ok()? - .filter_map(Result::ok) - .find(|e| { - e.file_type().is_ok_and(|ft| ft.is_dir()) - && e.file_name().to_string_lossy().starts_with("python") - }) - .map(|e| e.path().join("site-packages")) - } - - #[cfg(windows)] - fn find_site_packages(prefix: &Path) -> Option { - Some(prefix.join("Lib").join("site-packages")) - } -} - -impl fmt::Display for PythonEnvironment { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - writeln!(f, "Python path: {}", self.python_path.display())?; - writeln!(f, "Sys prefix: {}", self.sys_prefix.display())?; - writeln!(f, "Sys paths:")?; - for path in &self.sys_path { - writeln!(f, " {}", path.display())?; - } - Ok(()) - } -} - #[cfg(test)] mod tests { use super::*; use std::fs; - #[cfg(unix)] - use std::os::unix::fs::PermissionsExt; use tempfile::tempdir; - mod env_discovery { - use super::system::mock::{self as sys_mock, MockGuard}; - use super::*; - use which::Error as WhichError; + fn create_mock_django_project(dir: &Path) -> PathBuf { + let project_path = dir.to_path_buf(); + fs::create_dir_all(&project_path).unwrap(); - fn create_mock_venv(dir: &Path, version: Option<&str>) -> PathBuf { - let prefix = dir.to_path_buf(); + // Create a mock Django project structure + fs::create_dir_all(project_path.join("myapp")).unwrap(); + fs::create_dir_all(project_path.join("myapp/templates")).unwrap(); + fs::write(project_path.join("manage.py"), "#!/usr/bin/env python").unwrap(); - #[cfg(unix)] - { - let bin_dir = prefix.join("bin"); - fs::create_dir_all(&bin_dir).unwrap(); - fs::write(bin_dir.join("python"), "").unwrap(); - let lib_dir = prefix.join("lib"); - fs::create_dir_all(&lib_dir).unwrap(); - let py_version_dir = lib_dir.join(version.unwrap_or("python3.9")); - fs::create_dir_all(&py_version_dir).unwrap(); - fs::create_dir_all(py_version_dir.join("site-packages")).unwrap(); - } - #[cfg(windows)] - { - let bin_dir = prefix.join("Scripts"); - fs::create_dir_all(&bin_dir).unwrap(); - fs::write(bin_dir.join("python.exe"), "").unwrap(); - let lib_dir = prefix.join("Lib"); - fs::create_dir_all(&lib_dir).unwrap(); - fs::create_dir_all(lib_dir.join("site-packages")).unwrap(); - } - - prefix - } - - #[test] - fn test_explicit_venv_path_found() { - let project_dir = tempdir().unwrap(); - let venv_dir = tempdir().unwrap(); - let venv_prefix = create_mock_venv(venv_dir.path(), None); - - let env = - PythonEnvironment::new(project_dir.path(), Some(venv_prefix.to_str().unwrap())) - .expect("Should find environment with explicit path"); - - assert_eq!(env.sys_prefix, venv_prefix); - - #[cfg(unix)] - { - assert!(env.python_path.ends_with("bin/python")); - assert!(env.sys_path.contains(&venv_prefix.join("bin"))); - assert!(env - .sys_path - .contains(&venv_prefix.join("lib/python3.9/site-packages"))); - } - #[cfg(windows)] - { - assert!(env.python_path.ends_with("Scripts\\python.exe")); - assert!(env.sys_path.contains(&venv_prefix.join("Scripts"))); - assert!(env - .sys_path - .contains(&venv_prefix.join("Lib").join("site-packages"))); - } - } - - #[test] - fn test_explicit_venv_path_invalid_falls_through_to_project_venv() { - let project_dir = tempdir().unwrap(); - let project_venv_prefix = create_mock_venv(&project_dir.path().join(".venv"), None); - - let _guard = MockGuard; - // Ensure VIRTUAL_ENV is not set (returns VarError::NotPresent) - sys_mock::remove_env_var("VIRTUAL_ENV"); - - // Provide an invalid explicit path - let invalid_path = project_dir.path().join("non_existent_venv"); - let env = - PythonEnvironment::new(project_dir.path(), Some(invalid_path.to_str().unwrap())) - .expect("Should fall through to project .venv"); - - // Should have found the one in the project dir - assert_eq!(env.sys_prefix, project_venv_prefix); - } - - #[test] - fn test_virtual_env_variable_found() { - let project_dir = tempdir().unwrap(); - let venv_dir = tempdir().unwrap(); - let venv_prefix = create_mock_venv(venv_dir.path(), None); - - let _guard = MockGuard; - // Mock VIRTUAL_ENV to point to the mock venv - sys_mock::set_env_var("VIRTUAL_ENV", venv_prefix.to_str().unwrap().to_string()); - - let env = PythonEnvironment::new(project_dir.path(), None) - .expect("Should find environment via VIRTUAL_ENV"); - - assert_eq!(env.sys_prefix, venv_prefix); - - #[cfg(unix)] - assert!(env.python_path.ends_with("bin/python")); - #[cfg(windows)] - assert!(env.python_path.ends_with("Scripts\\python.exe")); - } - - #[test] - fn test_explicit_path_overrides_virtual_env() { - let project_dir = tempdir().unwrap(); - let venv1_dir = tempdir().unwrap(); - let venv1_prefix = create_mock_venv(venv1_dir.path(), None); // Mocked by VIRTUAL_ENV - let venv2_dir = tempdir().unwrap(); - let venv2_prefix = create_mock_venv(venv2_dir.path(), None); // Provided explicitly - - let _guard = MockGuard; - // Mock VIRTUAL_ENV to point to venv1 - sys_mock::set_env_var("VIRTUAL_ENV", venv1_prefix.to_str().unwrap().to_string()); - - // Call with explicit path to venv2 - let env = - PythonEnvironment::new(project_dir.path(), Some(venv2_prefix.to_str().unwrap())) - .expect("Should find environment via explicit path"); - - // Explicit path (venv2) should take precedence - assert_eq!( - env.sys_prefix, venv2_prefix, - "Explicit path should take precedence" - ); - } - - #[test] - fn test_project_venv_found() { - let project_dir = tempdir().unwrap(); - let venv_prefix = create_mock_venv(&project_dir.path().join(".venv"), None); - - let _guard = MockGuard; - // Ensure VIRTUAL_ENV is not set - sys_mock::remove_env_var("VIRTUAL_ENV"); - - let env = PythonEnvironment::new(project_dir.path(), None) - .expect("Should find environment in project .venv"); - - assert_eq!(env.sys_prefix, venv_prefix); - } - - #[test] - fn test_project_venv_priority() { - let project_dir = tempdir().unwrap(); - let dot_venv_prefix = create_mock_venv(&project_dir.path().join(".venv"), None); - let _venv_prefix = create_mock_venv(&project_dir.path().join("venv"), None); - - let _guard = MockGuard; - // Ensure VIRTUAL_ENV is not set - sys_mock::remove_env_var("VIRTUAL_ENV"); - - let env = - PythonEnvironment::new(project_dir.path(), None).expect("Should find environment"); - - // Should find .venv because it's checked first in the loop - assert_eq!(env.sys_prefix, dot_venv_prefix); - } - - #[test] - fn test_system_python_fallback() { - let project_dir = tempdir().unwrap(); - - let _guard = MockGuard; - // Ensure VIRTUAL_ENV is not set - sys_mock::remove_env_var("VIRTUAL_ENV"); - - let mock_sys_python_dir = tempdir().unwrap(); - let mock_sys_python_prefix = mock_sys_python_dir.path(); - - #[cfg(unix)] - let (bin_subdir, python_exe, site_packages_rel_path) = ( - "bin", - "python", - Path::new("lib").join("python3.9").join("site-packages"), - ); - #[cfg(windows)] - let (bin_subdir, python_exe, site_packages_rel_path) = ( - "Scripts", - "python.exe", - Path::new("Lib").join("site-packages"), - ); - - let bin_dir = mock_sys_python_prefix.join(bin_subdir); - fs::create_dir_all(&bin_dir).unwrap(); - let python_path = bin_dir.join(python_exe); - fs::write(&python_path, "").unwrap(); - - #[cfg(unix)] - { - let mut perms = fs::metadata(&python_path).unwrap().permissions(); - perms.set_mode(0o755); - fs::set_permissions(&python_path, perms).unwrap(); - } - - let site_packages_path = mock_sys_python_prefix.join(site_packages_rel_path); - fs::create_dir_all(&site_packages_path).unwrap(); - - sys_mock::set_exec_path("python", python_path.clone()); - - let system_env = PythonEnvironment::new(project_dir.path(), None); - - // Assert it found the mock system python via the mocked finder - assert!( - system_env.is_some(), - "Should fall back to the mock system python" - ); - - if let Some(env) = system_env { - assert_eq!( - env.python_path, python_path, - "Python path should match mock" - ); - assert_eq!( - env.sys_prefix, mock_sys_python_prefix, - "Sys prefix should match mock prefix" - ); - assert!( - env.sys_path.contains(&bin_dir), - "Sys path should contain mock bin dir" - ); - assert!( - env.sys_path.contains(&site_packages_path), - "Sys path should contain mock site-packages" - ); - } else { - panic!("Expected to find environment, but got None"); - } - } - - #[test] - fn test_no_python_found() { - let project_dir = tempdir().unwrap(); - - let _guard = MockGuard; // Setup guard to clear mocks - - // Ensure VIRTUAL_ENV is not set - sys_mock::remove_env_var("VIRTUAL_ENV"); - - // Ensure find_executable returns an error - sys_mock::set_exec_error("python", WhichError::CannotFindBinaryPath); - - let env = PythonEnvironment::new(project_dir.path(), None); - - assert!( - env.is_none(), - "Expected no environment to be found when all discovery methods fail" - ); - } - - #[test] - #[cfg(unix)] - fn test_unix_site_packages_discovery() { - let venv_dir = tempdir().unwrap(); - let prefix = venv_dir.path(); - let bin_dir = prefix.join("bin"); - fs::create_dir_all(&bin_dir).unwrap(); - fs::write(bin_dir.join("python"), "").unwrap(); - let lib_dir = prefix.join("lib"); - fs::create_dir_all(&lib_dir).unwrap(); - let py_version_dir1 = lib_dir.join("python3.8"); - fs::create_dir_all(&py_version_dir1).unwrap(); - fs::create_dir_all(py_version_dir1.join("site-packages")).unwrap(); - let py_version_dir2 = lib_dir.join("python3.10"); - fs::create_dir_all(&py_version_dir2).unwrap(); - fs::create_dir_all(py_version_dir2.join("site-packages")).unwrap(); - - let env = PythonEnvironment::from_venv_prefix(prefix).unwrap(); - - let found_site_packages = env.sys_path.iter().any(|p| p.ends_with("site-packages")); - assert!( - found_site_packages, - "Should have found a site-packages directory" - ); - assert!(env.sys_path.contains(&prefix.join("bin"))); - } - - #[test] - #[cfg(windows)] - fn test_windows_site_packages_discovery() { - let venv_dir = tempdir().unwrap(); - let prefix = venv_dir.path(); - let bin_dir = prefix.join("Scripts"); - fs::create_dir_all(&bin_dir).unwrap(); - fs::write(bin_dir.join("python.exe"), "").unwrap(); - let lib_dir = prefix.join("Lib"); - fs::create_dir_all(&lib_dir).unwrap(); - let site_packages = lib_dir.join("site-packages"); - fs::create_dir_all(&site_packages).unwrap(); - - let env = PythonEnvironment::from_venv_prefix(prefix).unwrap(); - - assert!(env.sys_path.contains(&prefix.join("Scripts"))); - assert!( - env.sys_path.contains(&site_packages), - "Should have found Lib/site-packages" - ); - } - - #[test] - fn test_from_venv_prefix_returns_none_if_dir_missing() { - let dir = tempdir().unwrap(); - let result = PythonEnvironment::from_venv_prefix(dir.path()); - assert!(result.is_none()); - } - - #[test] - fn test_from_venv_prefix_returns_none_if_binary_missing() { - let dir = tempdir().unwrap(); - let prefix = dir.path(); - fs::create_dir_all(prefix).unwrap(); - - #[cfg(unix)] - fs::create_dir_all(prefix.join("bin")).unwrap(); - #[cfg(windows)] - fs::create_dir_all(prefix.join("Scripts")).unwrap(); - - let result = PythonEnvironment::from_venv_prefix(prefix); - assert!(result.is_none()); - } + project_path } - mod env_activation { - use super::*; + #[test] + fn test_django_project_initialization() { + // This test needs to be run in an environment with Django installed + // For this test to pass, you would need a real Python environment with Django + // Here we're just testing the creation of the DjangoProject object + let project_dir = tempdir().unwrap(); + let project_path = create_mock_django_project(project_dir.path()); - fn get_sys_path(py: Python) -> PyResult> { - let sys = py.import("sys")?; - let py_path = sys.getattr("path")?; - py_path.extract::>() - } + let project = DjangoProject::new(project_path); - fn create_test_env(sys_paths: Vec) -> PythonEnvironment { - PythonEnvironment { - python_path: PathBuf::from("dummy/bin/python"), - sys_prefix: PathBuf::from("dummy"), - sys_path: sys_paths, - } - } + assert!(project.env.is_none()); // Environment not initialized yet + assert!(project.template_tags.is_none()); // Template tags not loaded yet + } - #[test] - fn test_activate_appends_paths() -> PyResult<()> { - let temp_dir = tempdir().unwrap(); - let path1 = temp_dir.path().join("scripts"); - let path2 = temp_dir.path().join("libs"); - fs::create_dir_all(&path1).unwrap(); - fs::create_dir_all(&path2).unwrap(); + #[test] + fn test_django_project_path() { + let project_dir = tempdir().unwrap(); + let project_path = create_mock_django_project(project_dir.path()); - let test_env = create_test_env(vec![path1.clone(), path2.clone()]); + let project = DjangoProject::new(project_path.clone()); - pyo3::prepare_freethreaded_python(); + assert_eq!(project.path(), project_path.as_path()); + } - Python::with_gil(|py| { - let initial_sys_path = get_sys_path(py)?; - let initial_len = initial_sys_path.len(); + #[test] + fn test_django_project_display() { + let project_dir = tempdir().unwrap(); + let project_path = create_mock_django_project(project_dir.path()); - test_env.activate(py)?; + let project = DjangoProject::new(project_path.clone()); - let final_sys_path = get_sys_path(py)?; - assert_eq!( - final_sys_path.len(), - initial_len + 2, - "Should have added 2 paths" - ); - assert_eq!( - final_sys_path.get(initial_len).unwrap(), - path1.to_str().expect("Path 1 should be valid UTF-8") - ); - assert_eq!( - final_sys_path.get(initial_len + 1).unwrap(), - path2.to_str().expect("Path 2 should be valid UTF-8") - ); - - Ok(()) - }) - } - - #[test] - fn test_activate_empty_sys_path() -> PyResult<()> { - let test_env = create_test_env(vec![]); - - pyo3::prepare_freethreaded_python(); - - Python::with_gil(|py| { - let initial_sys_path = get_sys_path(py)?; - - test_env.activate(py)?; - - let final_sys_path = get_sys_path(py)?; - assert_eq!( - final_sys_path, initial_sys_path, - "sys.path should remain unchanged for empty env.sys_path" - ); - - Ok(()) - }) - } - - #[test] - fn test_activate_with_non_existent_paths() -> PyResult<()> { - let temp_dir = tempdir().unwrap(); - let path1 = temp_dir.path().join("non_existent_dir"); - let path2 = temp_dir.path().join("another_missing/path"); - - let test_env = create_test_env(vec![path1.clone(), path2.clone()]); - - pyo3::prepare_freethreaded_python(); - - Python::with_gil(|py| { - let initial_sys_path = get_sys_path(py)?; - let initial_len = initial_sys_path.len(); - - test_env.activate(py)?; - - let final_sys_path = get_sys_path(py)?; - assert_eq!( - final_sys_path.len(), - initial_len + 2, - "Should still add 2 paths even if they don't exist" - ); - assert_eq!( - final_sys_path.get(initial_len).unwrap(), - path1.to_str().unwrap() - ); - assert_eq!( - final_sys_path.get(initial_len + 1).unwrap(), - path2.to_str().unwrap() - ); - - Ok(()) - }) - } - - #[test] - #[cfg(unix)] - fn test_activate_skips_non_utf8_paths_unix() -> PyResult<()> { - use std::ffi::OsStr; - use std::os::unix::ffi::OsStrExt; - - let temp_dir = tempdir().unwrap(); - let valid_path = temp_dir.path().join("valid_dir"); - fs::create_dir(&valid_path).unwrap(); - - let invalid_bytes = b"invalid_\xff_utf8"; - let os_str = OsStr::from_bytes(invalid_bytes); - let non_utf8_path = PathBuf::from(os_str); - assert!( - non_utf8_path.to_str().is_none(), - "Path should not be convertible to UTF-8 str" - ); - - let test_env = create_test_env(vec![valid_path.clone(), non_utf8_path.clone()]); - - pyo3::prepare_freethreaded_python(); - - Python::with_gil(|py| { - let initial_sys_path = get_sys_path(py)?; - let initial_len = initial_sys_path.len(); - - test_env.activate(py)?; - - let final_sys_path = get_sys_path(py)?; - assert_eq!( - final_sys_path.len(), - initial_len + 1, - "Should only add valid UTF-8 paths" - ); - assert_eq!( - final_sys_path.get(initial_len).unwrap(), - valid_path.to_str().unwrap() - ); - - let invalid_path_lossy = non_utf8_path.to_string_lossy(); - assert!( - !final_sys_path - .iter() - .any(|p| p.contains(&*invalid_path_lossy)), - "Non-UTF8 path should not be present in sys.path" - ); - - Ok(()) - }) - } - - #[test] - #[cfg(windows)] - fn test_activate_skips_non_utf8_paths_windows() -> PyResult<()> { - use std::ffi::OsString; - use std::os::windows::ffi::OsStringExt; - - let temp_dir = tempdir().unwrap(); - let valid_path = temp_dir.path().join("valid_dir"); - - let invalid_wide: Vec = vec![ - 'i' as u16, 'n' as u16, 'v' as u16, 'a' as u16, 'l' as u16, 'i' as u16, 'd' as u16, - '_' as u16, 0xD800, '_' as u16, 'w' as u16, 'i' as u16, 'd' as u16, 'e' as u16, - ]; - let os_string = OsString::from_wide(&invalid_wide); - let non_utf8_path = PathBuf::from(os_string); - - assert!( - non_utf8_path.to_str().is_none(), - "Path with lone surrogate should not be convertible to UTF-8 str" - ); - - let test_env = create_test_env(vec![valid_path.clone(), non_utf8_path.clone()]); - - pyo3::prepare_freethreaded_python(); - - Python::with_gil(|py| { - let initial_sys_path = get_sys_path(py)?; - let initial_len = initial_sys_path.len(); - - test_env.activate(py)?; - - let final_sys_path = get_sys_path(py)?; - assert_eq!( - final_sys_path.len(), - initial_len + 1, - "Should only add paths convertible to valid UTF-8" - ); - assert_eq!( - final_sys_path.get(initial_len).unwrap(), - valid_path.to_str().unwrap() - ); - - let invalid_path_lossy = non_utf8_path.to_string_lossy(); - assert!( - !final_sys_path - .iter() - .any(|p| p.contains(&*invalid_path_lossy)), - "Non-UTF8 path (from invalid wide chars) should not be present in sys.path" - ); - - Ok(()) - }) - } + let display_str = format!("{}", project); + assert!(display_str.contains(&format!("Project path: {}", project_path.display()))); } } diff --git a/crates/djls-project/src/meta.rs b/crates/djls-project/src/meta.rs new file mode 100644 index 0000000..14636d1 --- /dev/null +++ b/crates/djls-project/src/meta.rs @@ -0,0 +1,21 @@ +use std::path::PathBuf; + +#[derive(Clone, Debug)] +pub struct ProjectMetadata { + root: PathBuf, + venv: Option, +} + +impl ProjectMetadata { + pub fn new(root: PathBuf, venv: Option) -> Self { + ProjectMetadata { root, venv } + } + + pub fn root(&self) -> &PathBuf { + &self.root + } + + pub fn venv(&self) -> Option<&PathBuf> { + self.venv.as_ref() + } +} diff --git a/crates/djls-project/src/python.rs b/crates/djls-project/src/python.rs new file mode 100644 index 0000000..237502c --- /dev/null +++ b/crates/djls-project/src/python.rs @@ -0,0 +1,772 @@ +use crate::db::Db; +use crate::system; +use pyo3::prelude::*; +use std::fmt; +use std::path::{Path, PathBuf}; + +#[salsa::tracked] +pub fn find_python_environment(db: &dyn Db) -> Option { + let project_path = db.metadata().root(); + let venv_path = db.metadata().venv(); + + PythonEnvironment::new(project_path.as_path(), venv_path.and_then(|p| p.to_str())) +} + +#[derive(Clone, Debug, PartialEq)] +pub struct PythonEnvironment { + python_path: PathBuf, + sys_path: Vec, + sys_prefix: PathBuf, +} + +impl PythonEnvironment { + fn new(project_path: &Path, venv_path: Option<&str>) -> Option { + if let Some(path) = venv_path { + let prefix = PathBuf::from(path); + if let Some(env) = Self::from_venv_prefix(&prefix) { + return Some(env); + } + // Invalid explicit path, continue searching... + } + + if let Ok(virtual_env) = system::env_var("VIRTUAL_ENV") { + let prefix = PathBuf::from(virtual_env); + if let Some(env) = Self::from_venv_prefix(&prefix) { + return Some(env); + } + } + + for venv_dir in &[".venv", "venv", "env", ".env"] { + let potential_venv = project_path.join(venv_dir); + if potential_venv.is_dir() { + if let Some(env) = Self::from_venv_prefix(&potential_venv) { + return Some(env); + } + } + } + + Self::from_system_python() + } + + fn from_venv_prefix(prefix: &Path) -> Option { + #[cfg(unix)] + let python_path = prefix.join("bin").join("python"); + #[cfg(windows)] + let python_path = prefix.join("Scripts").join("python.exe"); + + if !prefix.is_dir() || !python_path.exists() { + return None; + } + + #[cfg(unix)] + let bin_dir = prefix.join("bin"); + #[cfg(windows)] + let bin_dir = prefix.join("Scripts"); + + let mut sys_path = Vec::new(); + sys_path.push(bin_dir); + + if let Some(site_packages) = Self::find_site_packages(prefix) { + if site_packages.is_dir() { + sys_path.push(site_packages); + } + } + + Some(Self { + python_path: python_path.clone(), + sys_path, + sys_prefix: prefix.to_path_buf(), + }) + } + + pub fn activate(&self, py: Python) -> PyResult<()> { + let sys = py.import("sys")?; + let py_path = sys.getattr("path")?; + + for path in &self.sys_path { + if let Some(path_str) = path.to_str() { + py_path.call_method1("append", (path_str,))?; + } + } + + Ok(()) + } + + fn from_system_python() -> Option { + let python_path = match system::find_executable("python") { + Ok(p) => p, + Err(_) => return None, + }; + let bin_dir = python_path.parent()?; + let prefix = bin_dir.parent()?; + + let mut sys_path = Vec::new(); + sys_path.push(bin_dir.to_path_buf()); + + if let Some(site_packages) = Self::find_site_packages(prefix) { + if site_packages.is_dir() { + sys_path.push(site_packages); + } + } + + Some(Self { + python_path: python_path.clone(), + sys_path, + sys_prefix: prefix.to_path_buf(), + }) + } + + #[cfg(unix)] + fn find_site_packages(prefix: &Path) -> Option { + let lib_dir = prefix.join("lib"); + if !lib_dir.is_dir() { + return None; + } + std::fs::read_dir(lib_dir) + .ok()? + .filter_map(Result::ok) + .find(|e| { + e.file_type().is_ok_and(|ft| ft.is_dir()) + && e.file_name().to_string_lossy().starts_with("python") + }) + .map(|e| e.path().join("site-packages")) + } + + #[cfg(windows)] + fn find_site_packages(prefix: &Path) -> Option { + Some(prefix.join("Lib").join("site-packages")) + } +} + +impl fmt::Display for PythonEnvironment { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "Python path: {}", self.python_path.display())?; + writeln!(f, "Sys prefix: {}", self.sys_prefix.display())?; + writeln!(f, "Sys paths:")?; + for path in &self.sys_path { + writeln!(f, " {}", path.display())?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + use tempfile::tempdir; + + fn create_mock_venv(dir: &Path, version: Option<&str>) -> PathBuf { + let prefix = dir.to_path_buf(); + + #[cfg(unix)] + { + let bin_dir = prefix.join("bin"); + fs::create_dir_all(&bin_dir).unwrap(); + fs::write(bin_dir.join("python"), "").unwrap(); + let lib_dir = prefix.join("lib"); + fs::create_dir_all(&lib_dir).unwrap(); + let py_version_dir = lib_dir.join(version.unwrap_or("python3.9")); + fs::create_dir_all(&py_version_dir).unwrap(); + fs::create_dir_all(py_version_dir.join("site-packages")).unwrap(); + } + #[cfg(windows)] + { + let bin_dir = prefix.join("Scripts"); + fs::create_dir_all(&bin_dir).unwrap(); + fs::write(bin_dir.join("python.exe"), "").unwrap(); + let lib_dir = prefix.join("Lib"); + fs::create_dir_all(&lib_dir).unwrap(); + fs::create_dir_all(lib_dir.join("site-packages")).unwrap(); + } + + prefix + } + + fn get_sys_path(py: Python) -> PyResult> { + let sys = py.import("sys")?; + let py_path = sys.getattr("path")?; + py_path.extract::>() + } + + fn create_test_env(sys_paths: Vec) -> PythonEnvironment { + PythonEnvironment { + python_path: PathBuf::from("dummy/bin/python"), + sys_prefix: PathBuf::from("dummy"), + sys_path: sys_paths, + } + } + + mod env_discovery { + use super::*; + use crate::system::mock::{self as sys_mock, MockGuard}; + use which::Error as WhichError; + + #[test] + fn test_explicit_venv_path_found() { + let project_dir = tempdir().unwrap(); + let venv_dir = tempdir().unwrap(); + let venv_prefix = create_mock_venv(venv_dir.path(), None); + + let env = + PythonEnvironment::new(project_dir.path(), Some(venv_prefix.to_str().unwrap())) + .expect("Should find environment with explicit path"); + + assert_eq!(env.sys_prefix, venv_prefix); + + #[cfg(unix)] + { + assert!(env.python_path.ends_with("bin/python")); + assert!(env.sys_path.contains(&venv_prefix.join("bin"))); + assert!(env + .sys_path + .contains(&venv_prefix.join("lib/python3.9/site-packages"))); + } + #[cfg(windows)] + { + assert!(env.python_path.ends_with("Scripts\\python.exe")); + assert!(env.sys_path.contains(&venv_prefix.join("Scripts"))); + assert!(env + .sys_path + .contains(&venv_prefix.join("Lib").join("site-packages"))); + } + } + + #[test] + fn test_explicit_venv_path_invalid_falls_through_to_project_venv() { + let project_dir = tempdir().unwrap(); + let project_venv_prefix = create_mock_venv(&project_dir.path().join(".venv"), None); + + let _guard = MockGuard; + // Ensure VIRTUAL_ENV is not set (returns VarError::NotPresent) + sys_mock::remove_env_var("VIRTUAL_ENV"); + + // Provide an invalid explicit path + let invalid_path = project_dir.path().join("non_existent_venv"); + let env = + PythonEnvironment::new(project_dir.path(), Some(invalid_path.to_str().unwrap())) + .expect("Should fall through to project .venv"); + + // Should have found the one in the project dir + assert_eq!(env.sys_prefix, project_venv_prefix); + } + + #[test] + fn test_virtual_env_variable_found() { + let project_dir = tempdir().unwrap(); + let venv_dir = tempdir().unwrap(); + let venv_prefix = create_mock_venv(venv_dir.path(), None); + + let _guard = MockGuard; + // Mock VIRTUAL_ENV to point to the mock venv + sys_mock::set_env_var("VIRTUAL_ENV", venv_prefix.to_str().unwrap().to_string()); + + let env = PythonEnvironment::new(project_dir.path(), None) + .expect("Should find environment via VIRTUAL_ENV"); + + assert_eq!(env.sys_prefix, venv_prefix); + + #[cfg(unix)] + assert!(env.python_path.ends_with("bin/python")); + #[cfg(windows)] + assert!(env.python_path.ends_with("Scripts\\python.exe")); + } + + #[test] + fn test_explicit_path_overrides_virtual_env() { + let project_dir = tempdir().unwrap(); + let venv1_dir = tempdir().unwrap(); + let venv1_prefix = create_mock_venv(venv1_dir.path(), None); // Mocked by VIRTUAL_ENV + let venv2_dir = tempdir().unwrap(); + let venv2_prefix = create_mock_venv(venv2_dir.path(), None); // Provided explicitly + + let _guard = MockGuard; + // Mock VIRTUAL_ENV to point to venv1 + sys_mock::set_env_var("VIRTUAL_ENV", venv1_prefix.to_str().unwrap().to_string()); + + // Call with explicit path to venv2 + let env = + PythonEnvironment::new(project_dir.path(), Some(venv2_prefix.to_str().unwrap())) + .expect("Should find environment via explicit path"); + + // Explicit path (venv2) should take precedence + assert_eq!( + env.sys_prefix, venv2_prefix, + "Explicit path should take precedence" + ); + } + + #[test] + fn test_project_venv_found() { + let project_dir = tempdir().unwrap(); + let venv_prefix = create_mock_venv(&project_dir.path().join(".venv"), None); + + let _guard = MockGuard; + // Ensure VIRTUAL_ENV is not set + sys_mock::remove_env_var("VIRTUAL_ENV"); + + let env = PythonEnvironment::new(project_dir.path(), None) + .expect("Should find environment in project .venv"); + + assert_eq!(env.sys_prefix, venv_prefix); + } + + #[test] + fn test_project_venv_priority() { + let project_dir = tempdir().unwrap(); + let dot_venv_prefix = create_mock_venv(&project_dir.path().join(".venv"), None); + let _venv_prefix = create_mock_venv(&project_dir.path().join("venv"), None); + + let _guard = MockGuard; + // Ensure VIRTUAL_ENV is not set + sys_mock::remove_env_var("VIRTUAL_ENV"); + + let env = + PythonEnvironment::new(project_dir.path(), None).expect("Should find environment"); + + // Should find .venv because it's checked first in the loop + assert_eq!(env.sys_prefix, dot_venv_prefix); + } + + #[test] + fn test_system_python_fallback() { + let project_dir = tempdir().unwrap(); + + let _guard = MockGuard; + // Ensure VIRTUAL_ENV is not set + sys_mock::remove_env_var("VIRTUAL_ENV"); + + let mock_sys_python_dir = tempdir().unwrap(); + let mock_sys_python_prefix = mock_sys_python_dir.path(); + + #[cfg(unix)] + let (bin_subdir, python_exe, site_packages_rel_path) = ( + "bin", + "python", + Path::new("lib").join("python3.9").join("site-packages"), + ); + #[cfg(windows)] + let (bin_subdir, python_exe, site_packages_rel_path) = ( + "Scripts", + "python.exe", + Path::new("Lib").join("site-packages"), + ); + + let bin_dir = mock_sys_python_prefix.join(bin_subdir); + fs::create_dir_all(&bin_dir).unwrap(); + let python_path = bin_dir.join(python_exe); + fs::write(&python_path, "").unwrap(); + + #[cfg(unix)] + { + let mut perms = fs::metadata(&python_path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&python_path, perms).unwrap(); + } + + let site_packages_path = mock_sys_python_prefix.join(site_packages_rel_path); + fs::create_dir_all(&site_packages_path).unwrap(); + + sys_mock::set_exec_path("python", python_path.clone()); + + let system_env = PythonEnvironment::new(project_dir.path(), None); + + // Assert it found the mock system python via the mocked finder + assert!( + system_env.is_some(), + "Should fall back to the mock system python" + ); + + if let Some(env) = system_env { + assert_eq!( + env.python_path, python_path, + "Python path should match mock" + ); + assert_eq!( + env.sys_prefix, mock_sys_python_prefix, + "Sys prefix should match mock prefix" + ); + assert!( + env.sys_path.contains(&bin_dir), + "Sys path should contain mock bin dir" + ); + assert!( + env.sys_path.contains(&site_packages_path), + "Sys path should contain mock site-packages" + ); + } else { + panic!("Expected to find environment, but got None"); + } + } + + #[test] + fn test_no_python_found() { + let project_dir = tempdir().unwrap(); + + let _guard = MockGuard; // Setup guard to clear mocks + + // Ensure VIRTUAL_ENV is not set + sys_mock::remove_env_var("VIRTUAL_ENV"); + + // Ensure find_executable returns an error + sys_mock::set_exec_error("python", WhichError::CannotFindBinaryPath); + + let env = PythonEnvironment::new(project_dir.path(), None); + + assert!( + env.is_none(), + "Expected no environment to be found when all discovery methods fail" + ); + } + + #[test] + #[cfg(unix)] + fn test_unix_site_packages_discovery() { + let venv_dir = tempdir().unwrap(); + let prefix = venv_dir.path(); + let bin_dir = prefix.join("bin"); + fs::create_dir_all(&bin_dir).unwrap(); + fs::write(bin_dir.join("python"), "").unwrap(); + let lib_dir = prefix.join("lib"); + fs::create_dir_all(&lib_dir).unwrap(); + let py_version_dir1 = lib_dir.join("python3.8"); + fs::create_dir_all(&py_version_dir1).unwrap(); + fs::create_dir_all(py_version_dir1.join("site-packages")).unwrap(); + let py_version_dir2 = lib_dir.join("python3.10"); + fs::create_dir_all(&py_version_dir2).unwrap(); + fs::create_dir_all(py_version_dir2.join("site-packages")).unwrap(); + + let env = PythonEnvironment::from_venv_prefix(prefix).unwrap(); + + let found_site_packages = env.sys_path.iter().any(|p| p.ends_with("site-packages")); + assert!( + found_site_packages, + "Should have found a site-packages directory" + ); + assert!(env.sys_path.contains(&prefix.join("bin"))); + } + + #[test] + #[cfg(windows)] + fn test_windows_site_packages_discovery() { + let venv_dir = tempdir().unwrap(); + let prefix = venv_dir.path(); + let bin_dir = prefix.join("Scripts"); + fs::create_dir_all(&bin_dir).unwrap(); + fs::write(bin_dir.join("python.exe"), "").unwrap(); + let lib_dir = prefix.join("Lib"); + fs::create_dir_all(&lib_dir).unwrap(); + let site_packages = lib_dir.join("site-packages"); + fs::create_dir_all(&site_packages).unwrap(); + + let env = PythonEnvironment::from_venv_prefix(prefix).unwrap(); + + assert!(env.sys_path.contains(&prefix.join("Scripts"))); + assert!( + env.sys_path.contains(&site_packages), + "Should have found Lib/site-packages" + ); + } + + #[test] + fn test_from_venv_prefix_returns_none_if_dir_missing() { + let dir = tempdir().unwrap(); + let result = PythonEnvironment::from_venv_prefix(dir.path()); + assert!(result.is_none()); + } + + #[test] + fn test_from_venv_prefix_returns_none_if_binary_missing() { + let dir = tempdir().unwrap(); + let prefix = dir.path(); + fs::create_dir_all(prefix).unwrap(); + + #[cfg(unix)] + fs::create_dir_all(prefix.join("bin")).unwrap(); + #[cfg(windows)] + fs::create_dir_all(prefix.join("Scripts")).unwrap(); + + let result = PythonEnvironment::from_venv_prefix(prefix); + assert!(result.is_none()); + } + } + + mod env_activation { + use super::*; + + #[test] + fn test_activate_appends_paths() -> PyResult<()> { + let temp_dir = tempdir().unwrap(); + let path1 = temp_dir.path().join("scripts"); + let path2 = temp_dir.path().join("libs"); + fs::create_dir_all(&path1).unwrap(); + fs::create_dir_all(&path2).unwrap(); + + let test_env = create_test_env(vec![path1.clone(), path2.clone()]); + + pyo3::prepare_freethreaded_python(); + + Python::with_gil(|py| { + let initial_sys_path = get_sys_path(py)?; + let initial_len = initial_sys_path.len(); + + test_env.activate(py)?; + + let final_sys_path = get_sys_path(py)?; + assert_eq!( + final_sys_path.len(), + initial_len + 2, + "Should have added 2 paths" + ); + assert_eq!( + final_sys_path.get(initial_len).unwrap(), + path1.to_str().expect("Path 1 should be valid UTF-8") + ); + assert_eq!( + final_sys_path.get(initial_len + 1).unwrap(), + path2.to_str().expect("Path 2 should be valid UTF-8") + ); + + Ok(()) + }) + } + + #[test] + fn test_activate_empty_sys_path() -> PyResult<()> { + let test_env = create_test_env(vec![]); + + pyo3::prepare_freethreaded_python(); + + Python::with_gil(|py| { + let initial_sys_path = get_sys_path(py)?; + + test_env.activate(py)?; + + let final_sys_path = get_sys_path(py)?; + assert_eq!( + final_sys_path, initial_sys_path, + "sys.path should remain unchanged for empty env.sys_path" + ); + + Ok(()) + }) + } + + #[test] + fn test_activate_with_non_existent_paths() -> PyResult<()> { + let temp_dir = tempdir().unwrap(); + let path1 = temp_dir.path().join("non_existent_dir"); + let path2 = temp_dir.path().join("another_missing/path"); + + let test_env = create_test_env(vec![path1.clone(), path2.clone()]); + + pyo3::prepare_freethreaded_python(); + + Python::with_gil(|py| { + let initial_sys_path = get_sys_path(py)?; + let initial_len = initial_sys_path.len(); + + test_env.activate(py)?; + + let final_sys_path = get_sys_path(py)?; + assert_eq!( + final_sys_path.len(), + initial_len + 2, + "Should still add 2 paths even if they don't exist" + ); + assert_eq!( + final_sys_path.get(initial_len).unwrap(), + path1.to_str().unwrap() + ); + assert_eq!( + final_sys_path.get(initial_len + 1).unwrap(), + path2.to_str().unwrap() + ); + + Ok(()) + }) + } + + #[test] + #[cfg(unix)] + fn test_activate_skips_non_utf8_paths_unix() -> PyResult<()> { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + + let temp_dir = tempdir().unwrap(); + let valid_path = temp_dir.path().join("valid_dir"); + fs::create_dir(&valid_path).unwrap(); + + let invalid_bytes = b"invalid_\xff_utf8"; + let os_str = OsStr::from_bytes(invalid_bytes); + let non_utf8_path = PathBuf::from(os_str); + assert!( + non_utf8_path.to_str().is_none(), + "Path should not be convertible to UTF-8 str" + ); + + let test_env = create_test_env(vec![valid_path.clone(), non_utf8_path.clone()]); + + pyo3::prepare_freethreaded_python(); + + Python::with_gil(|py| { + let initial_sys_path = get_sys_path(py)?; + let initial_len = initial_sys_path.len(); + + test_env.activate(py)?; + + let final_sys_path = get_sys_path(py)?; + assert_eq!( + final_sys_path.len(), + initial_len + 1, + "Should only add valid UTF-8 paths" + ); + assert_eq!( + final_sys_path.get(initial_len).unwrap(), + valid_path.to_str().unwrap() + ); + + let invalid_path_lossy = non_utf8_path.to_string_lossy(); + assert!( + !final_sys_path + .iter() + .any(|p| p.contains(&*invalid_path_lossy)), + "Non-UTF8 path should not be present in sys.path" + ); + + Ok(()) + }) + } + + #[test] + #[cfg(windows)] + fn test_activate_skips_non_utf8_paths_windows() -> PyResult<()> { + use std::ffi::OsString; + use std::os::windows::ffi::OsStringExt; + + let temp_dir = tempdir().unwrap(); + let valid_path = temp_dir.path().join("valid_dir"); + + let invalid_wide: Vec = vec![ + 'i' as u16, 'n' as u16, 'v' as u16, 'a' as u16, 'l' as u16, 'i' as u16, 'd' as u16, + '_' as u16, 0xD800, '_' as u16, 'w' as u16, 'i' as u16, 'd' as u16, 'e' as u16, + ]; + let os_string = OsString::from_wide(&invalid_wide); + let non_utf8_path = PathBuf::from(os_string); + + assert!( + non_utf8_path.to_str().is_none(), + "Path with lone surrogate should not be convertible to UTF-8 str" + ); + + let test_env = create_test_env(vec![valid_path.clone(), non_utf8_path.clone()]); + + pyo3::prepare_freethreaded_python(); + + Python::with_gil(|py| { + let initial_sys_path = get_sys_path(py)?; + let initial_len = initial_sys_path.len(); + + test_env.activate(py)?; + + let final_sys_path = get_sys_path(py)?; + assert_eq!( + final_sys_path.len(), + initial_len + 1, + "Should only add paths convertible to valid UTF-8" + ); + assert_eq!( + final_sys_path.get(initial_len).unwrap(), + valid_path.to_str().unwrap() + ); + + let invalid_path_lossy = non_utf8_path.to_string_lossy(); + assert!( + !final_sys_path + .iter() + .any(|p| p.contains(&*invalid_path_lossy)), + "Non-UTF8 path (from invalid wide chars) should not be present in sys.path" + ); + + Ok(()) + }) + } + } + + // Add tests for the salsa tracked function + mod salsa_integration { + use super::*; + use crate::db::ProjectDatabase; + use crate::meta::ProjectMetadata; + + #[test] + fn test_find_python_environment_with_salsa_db() { + let project_dir = tempdir().unwrap(); + let venv_dir = tempdir().unwrap(); + + // Create a mock venv + let venv_prefix = create_mock_venv(venv_dir.path(), None); + + // Create a metadata instance with project path and explicit venv path + let metadata = + ProjectMetadata::new(project_dir.path().to_path_buf(), Some(venv_prefix.clone())); + + // Create a ProjectDatabase with the metadata + let db = ProjectDatabase::new(metadata); + + // Call the tracked function + let env = find_python_environment(&db); + + // Verify we found the environment + assert!(env.is_some(), "Should find environment via salsa db"); + + if let Some(env) = env { + assert_eq!(env.sys_prefix, venv_prefix); + + #[cfg(unix)] + { + assert!(env.python_path.ends_with("bin/python")); + assert!(env.sys_path.contains(&venv_prefix.join("bin"))); + } + #[cfg(windows)] + { + assert!(env.python_path.ends_with("Scripts\\python.exe")); + assert!(env.sys_path.contains(&venv_prefix.join("Scripts"))); + } + } + } + + #[test] + fn test_find_python_environment_with_project_venv() { + let project_dir = tempdir().unwrap(); + + // Create a .venv in the project directory + let venv_prefix = create_mock_venv(&project_dir.path().join(".venv"), None); + + // Create a metadata instance with project path but no explicit venv path + let metadata = ProjectMetadata::new(project_dir.path().to_path_buf(), None); + + // Create a ProjectDatabase with the metadata + let db = ProjectDatabase::new(metadata); + + // Mock to ensure VIRTUAL_ENV is not set + let _guard = system::mock::MockGuard; + system::mock::remove_env_var("VIRTUAL_ENV"); + + // Call the tracked function + let env = find_python_environment(&db); + + // Verify we found the environment + assert!( + env.is_some(), + "Should find environment in project .venv via salsa db" + ); + + if let Some(env) = env { + assert_eq!(env.sys_prefix, venv_prefix); + } + } + } +} diff --git a/crates/djls-server/src/documents.rs b/crates/djls-server/src/documents.rs index 42ab979..21a8858 100644 --- a/crates/djls-server/src/documents.rs +++ b/crates/djls-server/src/documents.rs @@ -80,10 +80,12 @@ impl Store { self.documents.get_mut(uri) } + #[allow(dead_code)] pub fn get_all_documents(&self) -> impl Iterator { self.documents.values() } + #[allow(dead_code)] pub fn get_documents_by_language( &self, language_id: LanguageId, @@ -93,10 +95,12 @@ impl Store { .filter(move |doc| doc.language_id == language_id) } + #[allow(dead_code)] pub fn get_version(&self, uri: &str) -> Option { self.versions.get(uri).copied() } + #[allow(dead_code)] pub fn is_version_valid(&self, uri: &str, version: i32) -> bool { self.get_version(uri).map_or(false, |v| v == version) } @@ -203,10 +207,12 @@ impl TextDocument { self.index = LineIndex::new(&self.contents); } + #[allow(dead_code)] pub fn get_text(&self) -> &str { &self.contents } + #[allow(dead_code)] pub fn get_text_range(&self, range: Range) -> Option<&str> { let start = self.index.offset(range.start)? as usize; let end = self.index.offset(range.end)? as usize; @@ -226,6 +232,7 @@ impl TextDocument { Some(&self.contents[*start as usize..end as usize]) } + #[allow(dead_code)] pub fn line_count(&self) -> usize { self.index.line_starts.len() } @@ -287,6 +294,7 @@ impl LineIndex { Some(line_start + position.character) } + #[allow(dead_code)] pub fn position(&self, offset: u32) -> Position { let line = match self.line_starts.binary_search(&offset) { Ok(line) => line, diff --git a/crates/djls-templates/src/lexer.rs b/crates/djls-templates/src/lexer.rs index 4833183..d24b0cd 100644 --- a/crates/djls-templates/src/lexer.rs +++ b/crates/djls-templates/src/lexer.rs @@ -164,6 +164,7 @@ impl Lexer { self.peek_at(-1) } + #[allow(dead_code)] fn peek_until(&self, end: &str) -> Result { let mut index = self.current; let end_chars: Vec = end.chars().collect(); @@ -224,6 +225,7 @@ impl Lexer { Ok(self.source[start..self.current].trim().to_string()) } + #[allow(dead_code)] fn consume_chars(&mut self, s: &str) -> Result { for c in s.chars() { if c != self.peek()? { diff --git a/crates/djls-templates/src/parser.rs b/crates/djls-templates/src/parser.rs index d3b0de9..4133f24 100644 --- a/crates/djls-templates/src/parser.rs +++ b/crates/djls-templates/src/parser.rs @@ -150,6 +150,7 @@ impl Parser { self.peek_at(0) } + #[allow(dead_code)] fn peek_next(&self) -> Result { self.peek_at(1) } @@ -192,6 +193,7 @@ impl Parser { self.peek_previous() } + #[allow(dead_code)] fn backtrack(&mut self, steps: usize) -> Result { if self.current < steps { return Err(ParserError::stream_error(StreamError::AtBeginning)); diff --git a/crates/djls-templates/src/tagspecs.rs b/crates/djls-templates/src/tagspecs.rs index 22b4ec0..4c6ea58 100644 --- a/crates/djls-templates/src/tagspecs.rs +++ b/crates/djls-templates/src/tagspecs.rs @@ -13,20 +13,24 @@ pub enum TagSpecError { #[error("Failed to parse TOML: {0}")] Toml(#[from] toml::de::Error), #[error("Failed to extract specs: {0}")] + #[allow(dead_code)] Extract(String), #[error(transparent)] Other(#[from] anyhow::Error), } #[derive(Clone, Debug, Default)] +#[allow(dead_code)] pub struct TagSpecs(HashMap); impl TagSpecs { + #[allow(dead_code)] pub fn get(&self, key: &str) -> Option<&TagSpec> { self.0.get(key) } /// Load specs from a TOML file, looking under the specified table path + #[allow(dead_code)] fn load_from_toml(path: &Path, table_path: &[&str]) -> Result { let content = fs::read_to_string(path)?; let value: Value = toml::from_str(&content)?; @@ -51,6 +55,7 @@ impl TagSpecs { } /// Load specs from a user's project directory + #[allow(dead_code)] pub fn load_user_specs(project_root: &Path) -> Result { let config_files = ["djls.toml", ".djls.toml", "pyproject.toml"]; @@ -68,6 +73,7 @@ impl TagSpecs { } /// Load builtin specs from the crate's tagspecs directory + #[allow(dead_code)] pub fn load_builtin_specs() -> Result { let specs_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tagspecs"); let mut specs = HashMap::new(); @@ -85,12 +91,14 @@ impl TagSpecs { } /// Merge another TagSpecs into this one, with the other taking precedence + #[allow(dead_code)] pub fn merge(&mut self, other: TagSpecs) -> &mut Self { self.0.extend(other.0); self } /// Load both builtin and user specs, with user specs taking precedence + #[allow(dead_code)] pub fn load_all(project_root: &Path) -> Result { let mut specs = Self::load_builtin_specs()?; let user_specs = Self::load_user_specs(project_root)?; @@ -107,6 +115,7 @@ pub struct TagSpec { impl TagSpec { /// Recursive extraction: Check if node is spec, otherwise recurse if table. + #[allow(dead_code)] fn extract_specs( value: &Value, prefix: Option<&str>, // Path *to* this value node