From d60b59747821af55c78a374152bff2d0176d034d Mon Sep 17 00:00:00 2001 From: Josh Thomas Date: Wed, 10 Sep 2025 22:50:36 -0500 Subject: [PATCH] Refactor project state management to use Salsa (#216) --- crates/djls-conf/src/lib.rs | 2 +- crates/djls-project/src/db.rs | 37 ++-- crates/djls-project/src/django.rs | 71 +++++++ .../src/{ => django}/templatetags.rs | 51 ++++- crates/djls-project/src/inspector.rs | 32 ++++ crates/djls-project/src/inspector/pool.rs | 6 - crates/djls-project/src/inspector/queries.rs | 23 ++- crates/djls-project/src/lib.rs | 156 ++------------- crates/djls-project/src/meta.rs | 24 --- crates/djls-project/src/project.rs | 34 ++++ crates/djls-project/src/python.rs | 181 +++++++++++++++--- crates/djls-server/src/db.rs | 51 +++-- crates/djls-server/src/server.rs | 88 ++++----- crates/djls-server/src/session.rs | 101 +++++----- crates/djls-workspace/src/db.rs | 2 + 15 files changed, 516 insertions(+), 343 deletions(-) create mode 100644 crates/djls-project/src/django.rs rename crates/djls-project/src/{ => django}/templatetags.rs (57%) delete mode 100644 crates/djls-project/src/meta.rs create mode 100644 crates/djls-project/src/project.rs diff --git a/crates/djls-conf/src/lib.rs b/crates/djls-conf/src/lib.rs index 2b909ea..65e2b69 100644 --- a/crates/djls-conf/src/lib.rs +++ b/crates/djls-conf/src/lib.rs @@ -21,7 +21,7 @@ pub enum ConfigError { PyprojectSerialize(#[from] toml::ser::Error), } -#[derive(Debug, Deserialize, Default, PartialEq)] +#[derive(Debug, Deserialize, Default, PartialEq, Clone)] pub struct Settings { #[serde(default)] debug: bool, diff --git a/crates/djls-project/src/db.rs b/crates/djls-project/src/db.rs index fbfc417..c1bce1f 100644 --- a/crates/djls-project/src/db.rs +++ b/crates/djls-project/src/db.rs @@ -2,30 +2,33 @@ //! //! This module extends the workspace database trait with project-specific //! functionality including metadata access and Python environment discovery. +//! +//! ## Architecture +//! +//! Following the Salsa pattern established in workspace and templates crates: +//! - `DjangoProject` is a Salsa input representing external project state +//! - Tracked functions compute derived values (Python env, Django config) +//! - Database trait provides stable configuration (metadata, template tags) + +use std::path::Path; +use std::sync::Arc; use djls_workspace::Db as WorkspaceDb; -use crate::meta::ProjectMetadata; -use crate::python::PythonEnvironment; +use crate::inspector::pool::InspectorPool; +use crate::project::Project; /// Project-specific database trait extending the workspace database #[salsa::db] pub trait Db: WorkspaceDb { - /// Get the project metadata containing root path and venv configuration - fn metadata(&self) -> &ProjectMetadata; -} + /// Get the current project (if set) + fn project(&self) -> Option; -/// Find the Python environment for the project. -/// -/// This Salsa tracked function discovers the Python environment based on: -/// 1. Explicit venv path from metadata -/// 2. VIRTUAL_ENV environment variable -/// 3. Common venv directories in project root (.venv, venv, env, .env) -/// 4. System Python as fallback -#[salsa::tracked] -pub fn find_python_environment(db: &dyn Db) -> Option { - let project_path = db.metadata().root().as_path(); - let venv_path = db.metadata().venv().and_then(|p| p.to_str()); + /// Get the shared inspector pool for executing Python queries + fn inspector_pool(&self) -> Arc; - PythonEnvironment::new(project_path, venv_path) + /// Get the project root path if a project is set + fn project_path(&self) -> Option<&Path> { + self.project().map(|p| p.root(self).as_path()) + } } diff --git a/crates/djls-project/src/django.rs b/crates/djls-project/src/django.rs new file mode 100644 index 0000000..87a6acc --- /dev/null +++ b/crates/djls-project/src/django.rs @@ -0,0 +1,71 @@ +mod templatetags; + +pub use templatetags::get_templatetags; +pub use templatetags::TemplateTags; + +use crate::db::Db as ProjectDb; +use crate::inspector::inspector_run; +use crate::inspector::queries::Query; +use crate::python::python_environment; +use crate::Project; + +/// Check if Django is available for the current project. +/// +/// This determines if Django is installed and configured in the Python environment. +/// First consults the inspector, then falls back to environment detection. +#[salsa::tracked] +pub fn django_available(db: &dyn ProjectDb, project: Project) -> bool { + // First try to get Django availability from inspector + if let Some(json_data) = inspector_run(db, Query::DjangoInit) { + // Parse the JSON response - expect a boolean + if let Ok(available) = serde_json::from_str::(&json_data) { + return available; + } + } + + // Fallback to environment detection + python_environment(db, project).is_some() +} + +/// Get the Django settings module name for the current project. +/// +/// Returns the settings_module_override from project, or inspector result, +/// or DJANGO_SETTINGS_MODULE env var, or attempts to detect it. +#[salsa::tracked] +pub fn django_settings_module(db: &dyn ProjectDb, project: Project) -> Option { + // Check project override first + if let Some(settings) = project.settings_module(db) { + return Some(settings.clone()); + } + + // Try to get settings module from inspector + if let Some(json_data) = inspector_run(db, Query::DjangoInit) { + // Parse the JSON response - expect a string + if let Ok(settings) = serde_json::from_str::(&json_data) { + return Some(settings); + } + } + + let project_path = project.root(db); + + // Try to detect settings module + if project_path.join("manage.py").exists() { + // Look for common settings modules + for candidate in &["settings", "config.settings", "project.settings"] { + let parts: Vec<&str> = candidate.split('.').collect(); + let mut path = project_path.clone(); + for part in &parts[..parts.len() - 1] { + path = path.join(part); + } + if let Some(last) = parts.last() { + path = path.join(format!("{last}.py")); + } + + if path.exists() { + return Some((*candidate).to_string()); + } + } + } + + None +} diff --git a/crates/djls-project/src/templatetags.rs b/crates/djls-project/src/django/templatetags.rs similarity index 57% rename from crates/djls-project/src/templatetags.rs rename to crates/djls-project/src/django/templatetags.rs index 237a454..9f731ba 100644 --- a/crates/djls-project/src/templatetags.rs +++ b/crates/djls-project/src/django/templatetags.rs @@ -4,7 +4,30 @@ use anyhow::Context; use anyhow::Result; use serde_json::Value; -#[derive(Debug, Default, Clone)] +use crate::db::Db as ProjectDb; +use crate::inspector::inspector_run; +use crate::inspector::queries::Query; +use crate::Project; + +/// Get template tags for the current project by querying the inspector. +/// +/// This tracked function calls the inspector to retrieve Django template tags +/// and parses the JSON response into a TemplateTags struct. +#[salsa::tracked] +pub fn get_templatetags(db: &dyn ProjectDb, _project: Project) -> Option { + let json_str = inspector_run(db, Query::Templatetags)?; + + // Parse the JSON string into a Value first + let json_value: serde_json::Value = match serde_json::from_str(&json_str) { + Ok(value) => value, + Err(_) => return None, + }; + + // Parse the JSON data into TemplateTags + TemplateTags::from_json(&json_value).ok() +} + +#[derive(Debug, Default, Clone, PartialEq)] pub struct TemplateTags(Vec); impl Deref for TemplateTags { @@ -83,3 +106,29 @@ impl TemplateTag { self.doc.as_ref() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_template_tags_parsing() { + // Test that TemplateTags can parse valid JSON + let json_data = r#"{ + "templatetags": [ + { + "name": "test_tag", + "module": "test_module", + "doc": "Test documentation" + } + ] + }"#; + + let value: serde_json::Value = serde_json::from_str(json_data).unwrap(); + let tags = TemplateTags::from_json(&value).unwrap(); + assert_eq!(tags.len(), 1); + assert_eq!(tags[0].name(), "test_tag"); + assert_eq!(tags[0].library(), "test_module"); + assert_eq!(tags[0].doc(), Some(&"Test documentation".to_string())); + } +} diff --git a/crates/djls-project/src/inspector.rs b/crates/djls-project/src/inspector.rs index 2497453..e6cd2dd 100644 --- a/crates/djls-project/src/inspector.rs +++ b/crates/djls-project/src/inspector.rs @@ -7,6 +7,9 @@ pub use queries::Query; use serde::Deserialize; use serde::Serialize; +use crate::db::Db as ProjectDb; +use crate::python::python_environment; + #[derive(Serialize)] pub struct DjlsRequest { #[serde(flatten)] @@ -19,3 +22,32 @@ pub struct DjlsResponse { pub data: Option, pub error: Option, } + +/// Run an inspector query and return the JSON result as a string. +/// +/// This tracked function executes inspector queries through the shared pool +/// and caches the results based on project state and query kind. +pub fn inspector_run(db: &dyn ProjectDb, query: Query) -> Option { + let project = db.project()?; + let python_env = python_environment(db, project)?; + let project_path = project.root(db); + + match db + .inspector_pool() + .query(&python_env, project_path, &DjlsRequest { query }) + { + Ok(response) => { + if response.ok { + if let Some(data) = response.data { + // Convert to JSON string + serde_json::to_string(&data).ok() + } else { + None + } + } else { + None + } + } + Err(_) => None, + } +} diff --git a/crates/djls-project/src/inspector/pool.rs b/crates/djls-project/src/inspector/pool.rs index 1db929a..b5a153d 100644 --- a/crates/djls-project/src/inspector/pool.rs +++ b/crates/djls-project/src/inspector/pool.rs @@ -11,12 +11,6 @@ use super::DjlsRequest; use super::DjlsResponse; use crate::python::PythonEnvironment; -/// Global singleton pool for convenience -static GLOBAL_POOL: std::sync::OnceLock = std::sync::OnceLock::new(); - -pub fn global_pool() -> &'static InspectorPool { - GLOBAL_POOL.get_or_init(InspectorPool::new) -} const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_secs(60); /// Manages a pool of inspector processes with automatic cleanup diff --git a/crates/djls-project/src/inspector/queries.rs b/crates/djls-project/src/inspector/queries.rs index 12b68ae..5da2159 100644 --- a/crates/djls-project/src/inspector/queries.rs +++ b/crates/djls-project/src/inspector/queries.rs @@ -3,16 +3,17 @@ use std::path::PathBuf; use serde::Deserialize; use serde::Serialize; -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash, Copy)] #[serde(tag = "query", content = "args")] #[serde(rename_all = "snake_case")] pub enum Query { + DjangoInit, PythonEnv, Templatetags, - DjangoInit, } #[derive(Serialize, Deserialize)] +#[allow(clippy::struct_field_names)] pub struct PythonEnvironmentQueryData { pub sys_base_prefix: PathBuf, pub sys_executable: PathBuf, @@ -42,3 +43,21 @@ pub struct TemplateTag { pub module: String, pub doc: Option, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_query_enum() { + // Test that Query variants exist and are copyable + let python_env = Query::PythonEnv; + let templatetags = Query::Templatetags; + let django_init = Query::DjangoInit; + + // Test that they can be copied + assert_eq!(python_env, Query::PythonEnv); + assert_eq!(templatetags, Query::Templatetags); + assert_eq!(django_init, Query::DjangoInit); + } +} diff --git a/crates/djls-project/src/lib.rs b/crates/djls-project/src/lib.rs index 6474d17..64a7e1b 100644 --- a/crates/djls-project/src/lib.rs +++ b/crates/djls-project/src/lib.rs @@ -1,149 +1,15 @@ mod db; -pub mod inspector; -mod meta; -pub mod python; +mod django; +mod inspector; +mod project; +mod python; mod system; -mod templatetags; -use std::fmt; -use std::path::Path; -use std::path::PathBuf; -use std::sync::Arc; - -use anyhow::Context; -use anyhow::Result; -pub use db::find_python_environment; pub use db::Db; -use inspector::pool::InspectorPool; -use inspector::DjlsRequest; -use inspector::Query; -pub use meta::ProjectMetadata; -pub use python::PythonEnvironment; -pub use templatetags::TemplateTags; - -#[derive(Debug)] -pub struct DjangoProject { - path: PathBuf, - env: Option, - template_tags: Option, - inspector: Arc, -} - -impl DjangoProject { - #[must_use] - pub fn new(path: PathBuf) -> Self { - Self { - path, - env: None, - template_tags: None, - inspector: Arc::new(InspectorPool::new()), - } - } - - pub fn initialize(&mut self, db: &dyn Db) -> Result<()> { - // Use the database to find the Python environment - self.env = find_python_environment(db); - let env = self - .env - .as_ref() - .context("Could not find Python environment")?; - - // Initialize Django - let request = DjlsRequest { - query: Query::DjangoInit, - }; - let response = self.inspector.query(env, &self.path, &request)?; - - if !response.ok { - anyhow::bail!("Failed to initialize Django: {:?}", response.error); - } - - // Get template tags - let request = DjlsRequest { - query: Query::Templatetags, - }; - let response = self.inspector.query(env, &self.path, &request)?; - - if let Some(data) = response.data { - self.template_tags = Some(TemplateTags::from_json(&data)?); - } - - Ok(()) - } - - #[must_use] - pub fn template_tags(&self) -> Option<&TemplateTags> { - self.template_tags.as_ref() - } - - #[must_use] - pub fn path(&self) -> &Path { - &self.path - } -} - -impl fmt::Display for DjangoProject { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - writeln!(f, "Project path: {}", self.path.display())?; - if let Some(py_env) = &self.env { - write!(f, "{py_env}")?; - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use std::fs; - - use tempfile::tempdir; - - use super::*; - - fn create_mock_django_project(dir: &Path) -> PathBuf { - let project_path = dir.to_path_buf(); - fs::create_dir_all(&project_path).unwrap(); - - // 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(); - - project_path - } - - #[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()); - - let project = DjangoProject::new(project_path); - - assert!(project.env.is_none()); // Environment not initialized yet - assert!(project.template_tags.is_none()); // Template tags not loaded yet - } - - #[test] - fn test_django_project_path() { - let project_dir = tempdir().unwrap(); - let project_path = create_mock_django_project(project_dir.path()); - - let project = DjangoProject::new(project_path.clone()); - - assert_eq!(project.path(), project_path.as_path()); - } - - #[test] - fn test_django_project_display() { - let project_dir = tempdir().unwrap(); - let project_path = create_mock_django_project(project_dir.path()); - - let project = DjangoProject::new(project_path.clone()); - - let display_str = format!("{project}"); - assert!(display_str.contains(&format!("Project path: {}", project_path.display()))); - } -} +pub use django::django_available; +pub use django::django_settings_module; +pub use django::get_templatetags; +pub use django::TemplateTags; +pub use inspector::pool::InspectorPool; +pub use project::Project; +pub use python::Interpreter; diff --git a/crates/djls-project/src/meta.rs b/crates/djls-project/src/meta.rs deleted file mode 100644 index c041809..0000000 --- a/crates/djls-project/src/meta.rs +++ /dev/null @@ -1,24 +0,0 @@ -use std::path::PathBuf; - -#[derive(Clone, Debug)] -pub struct ProjectMetadata { - root: PathBuf, - venv: Option, -} - -impl ProjectMetadata { - #[must_use] - pub fn new(root: PathBuf, venv: Option) -> Self { - ProjectMetadata { root, venv } - } - - #[must_use] - pub fn root(&self) -> &PathBuf { - &self.root - } - - #[must_use] - pub fn venv(&self) -> Option<&PathBuf> { - self.venv.as_ref() - } -} diff --git a/crates/djls-project/src/project.rs b/crates/djls-project/src/project.rs new file mode 100644 index 0000000..735b794 --- /dev/null +++ b/crates/djls-project/src/project.rs @@ -0,0 +1,34 @@ +use std::path::PathBuf; + +use crate::db::Db as ProjectDb; +use crate::django_available; +use crate::django_settings_module; +use crate::get_templatetags; +use crate::python::Interpreter; + +/// Complete project configuration as a Salsa input. +/// +/// Following Ruff's pattern, this contains all external project configuration +/// rather than minimal keys that everything derives from. This replaces both +/// Project input and ProjectMetadata. +// TODO: Add templatetags as a field on this input +#[salsa::input] +#[derive(Debug)] +pub struct Project { + /// The project root path + #[returns(ref)] + pub root: PathBuf, + /// Interpreter specification for Python environment discovery + pub interpreter: Interpreter, + /// Optional Django settings module override from configuration + #[returns(ref)] + pub settings_module: Option, +} + +impl Project { + pub fn initialize(self, db: &dyn ProjectDb) { + let _ = django_available(db, self); + let _ = django_settings_module(db, self); + let _ = get_templatetags(db, self); + } +} diff --git a/crates/djls-project/src/python.rs b/crates/djls-project/src/python.rs index 2e2810b..9de8849 100644 --- a/crates/djls-project/src/python.rs +++ b/crates/djls-project/src/python.rs @@ -1,8 +1,77 @@ use std::fmt; use std::path::Path; use std::path::PathBuf; +use std::sync::Arc; +use crate::db::Db as ProjectDb; use crate::system; +use crate::Project; + +/// Interpreter specification for Python environment discovery. +/// +/// This enum represents the different ways to specify which Python interpreter +/// to use for a project. +#[derive(Clone, Debug, PartialEq)] +pub enum Interpreter { + /// Automatically discover interpreter (`VIRTUAL_ENV`, project venv dirs, system) + Auto, + /// Use specific virtual environment path + VenvPath(String), + /// Use specific interpreter executable path + InterpreterPath(String), +} + +/// Resolve the Python interpreter path for the current project. +/// +/// This tracked function determines the interpreter path based on the project's +/// interpreter specification. +#[salsa::tracked] +pub fn resolve_interpreter(db: &dyn ProjectDb, project: Project) -> Option { + match &project.interpreter(db) { + Interpreter::InterpreterPath(path) => { + let path_buf = PathBuf::from(path.as_str()); + if path_buf.exists() { + Some(path_buf) + } else { + None + } + } + Interpreter::VenvPath(venv_path) => { + // Derive interpreter path from venv + #[cfg(unix)] + let interpreter_path = PathBuf::from(venv_path.as_str()).join("bin").join("python"); + #[cfg(windows)] + let interpreter_path = PathBuf::from(venv_path.as_str()) + .join("Scripts") + .join("python.exe"); + + if interpreter_path.exists() { + Some(interpreter_path) + } else { + None + } + } + Interpreter::Auto => { + // Try common venv directories + for venv_dir in &[".venv", "venv", "env", ".env"] { + let potential_venv = project.root(db).join(venv_dir); + if potential_venv.is_dir() { + #[cfg(unix)] + let interpreter_path = potential_venv.join("bin").join("python"); + #[cfg(windows)] + let interpreter_path = potential_venv.join("Scripts").join("python.exe"); + + if interpreter_path.exists() { + return Some(interpreter_path); + } + } + } + + // Fall back to system python + system::find_executable("python").ok() + } + } +} #[derive(Clone, Debug, PartialEq)] pub struct PythonEnvironment { @@ -128,6 +197,38 @@ impl fmt::Display for PythonEnvironment { Ok(()) } } +/// +/// Find the Python environment for the current Django project. +/// +/// This Salsa tracked function discovers the Python environment based on: +/// 1. Explicit venv path from project config +/// 2. VIRTUAL_ENV environment variable +/// 3. Common venv directories in project root (.venv, venv, env, .env) +/// 4. System Python as fallback +#[salsa::tracked] +pub fn python_environment(db: &dyn ProjectDb, project: Project) -> Option> { + let interpreter_path = resolve_interpreter(db, project)?; + let project_path = project.root(db); + + // For venv paths, we need to determine the venv root + let interpreter_spec = project.interpreter(db); + let venv_path = match &interpreter_spec { + Interpreter::InterpreterPath(_) => { + // Try to determine venv from interpreter path + interpreter_path + .parent() + .and_then(|bin_dir| bin_dir.parent()) + .and_then(|venv_root| venv_root.to_str()) + } + Interpreter::VenvPath(path) => Some(path.as_str()), + Interpreter::Auto => { + // For auto-discovery, let PythonEnvironment::new handle it + None + } + }; + + PythonEnvironment::new(project_path, venv_path).map(Arc::new) +} #[cfg(test)] mod tests { @@ -167,13 +268,13 @@ mod tests { } mod env_discovery { + use system::mock::MockGuard; + use system::mock::{ + self as sys_mock, + }; use which::Error as WhichError; use super::*; - use crate::system::mock::MockGuard; - use crate::system::mock::{ - self as sys_mock, - }; #[test] fn test_explicit_venv_path_found() { @@ -466,32 +567,37 @@ mod tests { mod salsa_integration { use std::sync::Arc; + use std::sync::Mutex; use djls_workspace::FileSystem; use djls_workspace::InMemoryFileSystem; use super::*; - use crate::db::find_python_environment; - use crate::db::Db as ProjectDb; - use crate::meta::ProjectMetadata; + use crate::inspector::pool::InspectorPool; /// Test implementation of ProjectDb for unit tests #[salsa::db] #[derive(Clone)] struct TestDatabase { storage: salsa::Storage, - metadata: ProjectMetadata, + project_root: PathBuf, + project: Arc>>, fs: Arc, } impl TestDatabase { - fn new(metadata: ProjectMetadata) -> Self { + fn new(project_root: PathBuf) -> Self { Self { storage: salsa::Storage::new(None), - metadata, + project_root, + project: Arc::new(Mutex::new(None)), fs: Arc::new(InMemoryFileSystem::new()), } } + + fn set_project(&self, project: Project) { + *self.project.lock().unwrap() = Some(project); + } } #[salsa::db] @@ -510,28 +616,51 @@ mod tests { #[salsa::db] impl ProjectDb for TestDatabase { - fn metadata(&self) -> &ProjectMetadata { - &self.metadata + fn project(&self) -> Option { + // Return existing project or create a new one + let mut project_lock = self.project.lock().unwrap(); + if project_lock.is_none() { + let root = &self.project_root; + let interpreter_spec = Interpreter::Auto; + let django_settings = std::env::var("DJANGO_SETTINGS_MODULE").ok(); + + *project_lock = Some(Project::new( + self, + root.clone(), + interpreter_spec, + django_settings, + )); + } + *project_lock + } + + fn inspector_pool(&self) -> Arc { + Arc::new(InspectorPool::new()) } } #[test] - fn test_find_python_environment_with_salsa_db() { + fn test_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 TestDatabase with the project root + let db = TestDatabase::new(project_dir.path().to_path_buf()); - // Create a TestDatabase with the metadata - let db = TestDatabase::new(metadata); + // Create and configure the project with the venv path + let project = Project::new( + &db, + project_dir.path().to_path_buf(), + Interpreter::VenvPath(venv_prefix.to_string_lossy().to_string()), + None, + ); + db.set_project(project); // Call the tracked function - let env = find_python_environment(&db); + let env = python_environment(&db, project); // Verify we found the environment assert!(env.is_some(), "Should find environment via salsa db"); @@ -553,24 +682,22 @@ mod tests { } #[test] - fn test_find_python_environment_with_project_venv() { + fn test_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 TestDatabase with the metadata - let db = TestDatabase::new(metadata); + // Create a TestDatabase with the project root + let db = TestDatabase::new(project_dir.path().to_path_buf()); // 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); + // Call the tracked function (should find .venv) + let project = db.project().unwrap(); + let env = python_environment(&db, project); // Verify we found the environment assert!( diff --git a/crates/djls-server/src/db.rs b/crates/djls-server/src/db.rs index 94eb47f..752c97c 100644 --- a/crates/djls-server/src/db.rs +++ b/crates/djls-server/src/db.rs @@ -7,12 +7,13 @@ use std::path::Path; use std::path::PathBuf; use std::sync::Arc; -#[cfg(test)] use std::sync::Mutex; use dashmap::DashMap; use djls_project::Db as ProjectDb; -use djls_project::ProjectMetadata; +use djls_project::InspectorPool; +use djls_project::Interpreter; +use djls_project::Project; use djls_templates::db::Db as TemplateDb; use djls_templates::templatetags::TagSpecs; use djls_workspace::db::Db as WorkspaceDb; @@ -36,8 +37,11 @@ pub struct DjangoDatabase { /// Maps paths to [`SourceFile`] entities for O(1) lookup. files: Arc>, - /// Project metadata containing root path and venv configuration. - metadata: ProjectMetadata, + /// The single project for this database instance + project: Arc>>, + + /// Shared inspector pool for executing Python queries + inspector_pool: Arc, storage: salsa::Storage, @@ -56,7 +60,8 @@ impl Default for DjangoDatabase { Self { fs: Arc::new(InMemoryFileSystem::new()), files: Arc::new(DashMap::new()), - metadata: ProjectMetadata::new(PathBuf::from("/test"), None), + project: Arc::new(Mutex::new(None)), + inspector_pool: Arc::new(InspectorPool::new()), storage: salsa::Storage::new(Some(Box::new({ let logs = logs.clone(); move |event| { @@ -76,16 +81,26 @@ impl Default for DjangoDatabase { } impl DjangoDatabase { - /// Create a new [`DjangoDatabase`] with the given file system, file map, and project metadata. - pub fn new( - file_system: Arc, - files: Arc>, - metadata: ProjectMetadata, - ) -> Self { + /// Set the project for this database instance + /// + /// # Panics + /// + /// Panics if the project mutex is poisoned. + pub fn set_project(&self, root: &Path) { + let interpreter = Interpreter::Auto; + let django_settings = std::env::var("DJANGO_SETTINGS_MODULE").ok(); + + let project = Project::new(self, root.to_path_buf(), interpreter, django_settings); + + *self.project.lock().unwrap() = Some(project); + } + /// Create a new [`DjangoDatabase`] with the given file system and file map. + pub fn new(file_system: Arc, files: Arc>) -> Self { Self { fs: file_system, files, - metadata, + project: Arc::new(Mutex::new(None)), + inspector_pool: Arc::new(InspectorPool::new()), storage: salsa::Storage::new(None), #[cfg(test)] logs: Arc::new(Mutex::new(None)), @@ -163,7 +178,9 @@ impl WorkspaceDb for DjangoDatabase { #[salsa::db] impl TemplateDb for DjangoDatabase { fn tag_specs(&self) -> Arc { - let project_root = self.metadata.root(); + let project_root_buf = + std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + let project_root = project_root_buf.as_path(); if let Ok(user_specs) = TagSpecs::load_user_specs(project_root) { // If user specs exist and aren't empty, merge with built-in specs @@ -182,7 +199,11 @@ impl TemplateDb for DjangoDatabase { #[salsa::db] impl ProjectDb for DjangoDatabase { - fn metadata(&self) -> &ProjectMetadata { - &self.metadata + fn project(&self) -> Option { + *self.project.lock().unwrap() + } + + fn inspector_pool(&self) -> Arc { + self.inspector_pool.clone() } } diff --git a/crates/djls-server/src/server.rs b/crates/djls-server/src/server.rs index 204a4ad..a5007d7 100644 --- a/crates/djls-server/src/server.rs +++ b/crates/djls-server/src/server.rs @@ -1,6 +1,7 @@ use std::future::Future; use std::sync::Arc; +use djls_project::Db as ProjectDb; use djls_templates::analyze_template; use djls_templates::TemplateDiagnostic; use djls_workspace::paths; @@ -181,58 +182,31 @@ impl LanguageServer for DjangoLanguageServer { }) } - #[allow(clippy::too_many_lines)] async fn initialized(&self, _params: lsp_types::InitializedParams) { tracing::info!("Server received initialized notification."); self.with_session_task(move |session_arc| async move { - let project_path_and_venv = { - let session_lock = session_arc.lock().await; - session_lock.project().map(|p| { - ( - p.path().display().to_string(), - session_lock - .settings() - .venv_path() - .map(std::string::ToString::to_string), - ) - }) - }; + let session_lock = session_arc.lock().await; - if let Some((path_display, venv_path)) = project_path_and_venv { + let project_path = session_lock + .project() + .map(|p| p.root(session_lock.database()).clone()); + + if let Some(path) = project_path { tracing::info!( "Task: Starting initialization for project at: {}", - path_display + path.display() ); - if let Some(ref path) = venv_path { - tracing::info!("Using virtual environment from config: {}", path); + if let Some(project) = session_lock.project() { + project.initialize(session_lock.database()); } - let init_result = { - let mut session_lock = session_arc.lock().await; - session_lock.initialize_project() - }; - - match init_result { - Ok(()) => { - tracing::info!("Task: Successfully initialized project: {}", path_display); - } - Err(e) => { - tracing::error!( - "Task: Failed to initialize Django project at {}: {}", - path_display, - e - ); - - // Clear project on error - let mut session_lock = session_arc.lock().await; - *session_lock.project_mut() = None; - } - } + tracing::info!("Task: Successfully initialized project: {}", path.display()); } else { - tracing::info!("Task: No project instance found to initialize."); + tracing::info!("Task: No project configured, skipping initialization."); } + Ok(()) }) .await; @@ -369,7 +343,13 @@ impl LanguageServer for DjangoLanguageServer { let position = params.text_document_position.position; let encoding = session.position_encoding(); let file_kind = FileKind::from_path(&path); - let template_tags = session.project().and_then(|p| p.template_tags()); + let template_tags = session.with_db(|db| { + if let Some(project) = db.project() { + djls_project::get_templatetags(db, project) + } else { + None + } + }); let tag_specs = session.with_db(djls_templates::Db::tag_specs); let supports_snippets = session.supports_snippets(); @@ -378,7 +358,7 @@ impl LanguageServer for DjangoLanguageServer { position, encoding, file_kind, - template_tags, + template_tags.as_ref(), Some(&tag_specs), supports_snippets, ); @@ -474,20 +454,20 @@ impl LanguageServer for DjangoLanguageServer { async fn did_change_configuration(&self, _params: lsp_types::DidChangeConfigurationParams) { tracing::info!("Configuration change detected. Reloading settings..."); - let project_path = self - .with_session(|session| session.project().map(|p| p.path().to_path_buf())) - .await; + self.with_session_mut(|session| { + if let Some(project) = session.project() { + let project_root = project.root(session.database()); - if let Some(path) = project_path { - self.with_session_mut(|session| match djls_conf::Settings::new(path.as_path()) { - Ok(new_settings) => { - session.set_settings(new_settings); + match djls_conf::Settings::new(project_root.as_path()) { + Ok(new_settings) => { + session.set_settings(new_settings); + } + Err(e) => { + tracing::error!("Error loading settings: {}", e); + } } - Err(e) => { - tracing::error!("Error loading settings: {}", e); - } - }) - .await; - } + } + }) + .await; } } diff --git a/crates/djls-server/src/session.rs b/crates/djls-server/src/session.rs index 55a1ba0..0fee0e1 100644 --- a/crates/djls-server/src/session.rs +++ b/crates/djls-server/src/session.rs @@ -6,16 +6,16 @@ use std::path::PathBuf; use std::sync::Arc; -use anyhow::Result; use dashmap::DashMap; use djls_conf::Settings; -use djls_project::DjangoProject; -use djls_project::ProjectMetadata; +use djls_project::Db as ProjectDb; +use djls_project::Interpreter; use djls_workspace::db::SourceFile; use djls_workspace::paths; use djls_workspace::PositionEncoding; use djls_workspace::TextDocument; use djls_workspace::Workspace; +use salsa::Setter; use tower_lsp_server::lsp_types; use url::Url; @@ -28,14 +28,13 @@ use crate::db::DjangoDatabase; /// - Project configuration and settings /// - Client capabilities and position encoding /// - Workspace operations (buffers and file system) +/// - All Salsa inputs (`SessionState`, Project) /// /// Following Ruff's architecture, the concrete database lives at this level /// and is passed down to operations that need it. pub struct Session { - /// The Django project configuration - project: Option, - /// LSP server settings + // TODO: this should really be in the database settings: Settings, /// Workspace for buffer and file system management @@ -44,12 +43,12 @@ pub struct Session { /// but not the database (which is owned directly by Session). workspace: Workspace, - #[allow(dead_code)] client_capabilities: lsp_types::ClientCapabilities, /// Position encoding negotiated with client position_encoding: PositionEncoding, + /// The Salsa database for incremental computation db: DjangoDatabase, } @@ -65,47 +64,51 @@ impl Session { std::env::current_dir().ok() }); - let (project, settings, metadata) = if let Some(path) = &project_path { - let settings = - djls_conf::Settings::new(path).unwrap_or_else(|_| djls_conf::Settings::default()); - - let project = Some(djls_project::DjangoProject::new(path.clone())); - - // Create metadata for the project with venv path from settings - let venv_path = settings.venv_path().map(PathBuf::from); - let metadata = ProjectMetadata::new(path.clone(), venv_path); - - (project, settings, metadata) + let settings = if let Some(path) = &project_path { + djls_conf::Settings::new(path).unwrap_or_else(|_| djls_conf::Settings::default()) } else { - // Default metadata for when there's no project path - let metadata = ProjectMetadata::new(PathBuf::from("."), None); - (None, Settings::default(), metadata) + Settings::default() }; - // Create workspace for buffer management let workspace = Workspace::new(); - // Create the concrete database with the workspace's file system and metadata let files = Arc::new(DashMap::new()); - let db = DjangoDatabase::new(workspace.file_system(), files, metadata); + let mut db = DjangoDatabase::new(workspace.file_system(), files); + + if let Some(root_path) = &project_path { + db.set_project(root_path); + + if let Some(project) = db.project() { + // TODO: should this logic live in the project? + if let Some(venv_path) = settings.venv_path() { + let interpreter = Interpreter::VenvPath(venv_path.to_string()); + project.set_interpreter(&mut db).to(interpreter); + } else if let Ok(virtual_env) = std::env::var("VIRTUAL_ENV") { + let interpreter = Interpreter::VenvPath(virtual_env); + project.set_interpreter(&mut db).to(interpreter); + } + + // TODO: allow for configuring via settings + if let Ok(settings_module) = std::env::var("DJANGO_SETTINGS_MODULE") { + project + .set_settings_module(&mut db) + .to(Some(settings_module)); + } + } + } Self { - db, - project, settings, workspace, client_capabilities: params.capabilities.clone(), position_encoding: PositionEncoding::negotiate(params), + db, } } #[must_use] - pub fn project(&self) -> Option<&DjangoProject> { - self.project.as_ref() - } - - pub fn project_mut(&mut self) -> &mut Option { - &mut self.project + pub fn db(&self) -> &DjangoDatabase { + &self.db } #[must_use] @@ -122,18 +125,6 @@ impl Session { self.position_encoding } - /// Check if the client supports snippet completions - #[must_use] - pub fn supports_snippets(&self) -> bool { - self.client_capabilities - .text_document - .as_ref() - .and_then(|td| td.completion.as_ref()) - .and_then(|c| c.completion_item.as_ref()) - .and_then(|ci| ci.snippet_support) - .unwrap_or(false) - } - /// Execute a read-only operation with access to the database. pub fn with_db(&self, f: F) -> R where @@ -155,13 +146,9 @@ impl Session { &self.db } - /// Initialize the project with the database. - pub fn initialize_project(&mut self) -> Result<()> { - if let Some(project) = self.project.as_mut() { - project.initialize(&self.db) - } else { - Ok(()) - } + /// Get the current project for this session + pub fn project(&self) -> Option { + self.db.project() } /// Open a document in the session. @@ -256,6 +243,18 @@ impl Session { .and_then(|td| td.diagnostic.as_ref()) .is_some() } + + /// Check if the client supports snippet completions + #[must_use] + pub fn supports_snippets(&self) -> bool { + self.client_capabilities + .text_document + .as_ref() + .and_then(|td| td.completion.as_ref()) + .and_then(|c| c.completion_item.as_ref()) + .and_then(|ci| ci.snippet_support) + .unwrap_or(false) + } } impl Default for Session { diff --git a/crates/djls-workspace/src/db.rs b/crates/djls-workspace/src/db.rs index 846ab4e..451e9d0 100644 --- a/crates/djls-workspace/src/db.rs +++ b/crates/djls-workspace/src/db.rs @@ -48,6 +48,7 @@ pub trait Db: salsa::Database { pub struct SourceFile { /// The file's classification for analysis routing pub kind: FileKind, + // TODO: Change from Arc to PathBuf for consistency with Project.root /// The file path #[returns(ref)] pub path: Arc, @@ -78,6 +79,7 @@ pub fn source_text(db: &dyn Db, file: SourceFile) -> Arc { /// on files identified by path rather than by SourceFile input. #[salsa::input] pub struct FilePath { + // TODO: Change from Arc to PathBuf for consistency with Project.root /// The file path as a string #[returns(ref)] pub path: Arc,