Refactor project state management to use Salsa (#216)
Some checks are pending
lint / cargo-check (push) Waiting to run
lint / pre-commit (push) Waiting to run
lint / rustfmt (push) Waiting to run
lint / clippy (push) Waiting to run
release / build (push) Waiting to run
release / test (push) Waiting to run
release / release (push) Blocked by required conditions
test / generate-matrix (push) Waiting to run
test / Python , Django () (push) Blocked by required conditions
test / tests (push) Blocked by required conditions
zizmor 🌈 / zizmor latest via PyPI (push) Waiting to run

This commit is contained in:
Josh Thomas 2025-09-10 22:50:36 -05:00 committed by GitHub
parent 007a009d33
commit d60b597478
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 516 additions and 343 deletions

View file

@ -21,7 +21,7 @@ pub enum ConfigError {
PyprojectSerialize(#[from] toml::ser::Error), PyprojectSerialize(#[from] toml::ser::Error),
} }
#[derive(Debug, Deserialize, Default, PartialEq)] #[derive(Debug, Deserialize, Default, PartialEq, Clone)]
pub struct Settings { pub struct Settings {
#[serde(default)] #[serde(default)]
debug: bool, debug: bool,

View file

@ -2,30 +2,33 @@
//! //!
//! This module extends the workspace database trait with project-specific //! This module extends the workspace database trait with project-specific
//! functionality including metadata access and Python environment discovery. //! 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 djls_workspace::Db as WorkspaceDb;
use crate::meta::ProjectMetadata; use crate::inspector::pool::InspectorPool;
use crate::python::PythonEnvironment; use crate::project::Project;
/// Project-specific database trait extending the workspace database /// Project-specific database trait extending the workspace database
#[salsa::db] #[salsa::db]
pub trait Db: WorkspaceDb { pub trait Db: WorkspaceDb {
/// Get the project metadata containing root path and venv configuration /// Get the current project (if set)
fn metadata(&self) -> &ProjectMetadata; fn project(&self) -> Option<Project>;
}
/// Find the Python environment for the project. /// Get the shared inspector pool for executing Python queries
/// fn inspector_pool(&self) -> Arc<InspectorPool>;
/// 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<PythonEnvironment> {
let project_path = db.metadata().root().as_path();
let venv_path = db.metadata().venv().and_then(|p| p.to_str());
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())
}
} }

View file

@ -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::<bool>(&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<String> {
// 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::<String>(&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
}

View file

@ -4,7 +4,30 @@ use anyhow::Context;
use anyhow::Result; use anyhow::Result;
use serde_json::Value; 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<TemplateTags> {
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<TemplateTag>); pub struct TemplateTags(Vec<TemplateTag>);
impl Deref for TemplateTags { impl Deref for TemplateTags {
@ -83,3 +106,29 @@ impl TemplateTag {
self.doc.as_ref() 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()));
}
}

View file

@ -7,6 +7,9 @@ pub use queries::Query;
use serde::Deserialize; use serde::Deserialize;
use serde::Serialize; use serde::Serialize;
use crate::db::Db as ProjectDb;
use crate::python::python_environment;
#[derive(Serialize)] #[derive(Serialize)]
pub struct DjlsRequest { pub struct DjlsRequest {
#[serde(flatten)] #[serde(flatten)]
@ -19,3 +22,32 @@ pub struct DjlsResponse {
pub data: Option<serde_json::Value>, pub data: Option<serde_json::Value>,
pub error: Option<String>, pub error: Option<String>,
} }
/// 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<String> {
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,
}
}

View file

@ -11,12 +11,6 @@ use super::DjlsRequest;
use super::DjlsResponse; use super::DjlsResponse;
use crate::python::PythonEnvironment; use crate::python::PythonEnvironment;
/// Global singleton pool for convenience
static GLOBAL_POOL: std::sync::OnceLock<InspectorPool> = 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); const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_secs(60);
/// Manages a pool of inspector processes with automatic cleanup /// Manages a pool of inspector processes with automatic cleanup

View file

@ -3,16 +3,17 @@ use std::path::PathBuf;
use serde::Deserialize; use serde::Deserialize;
use serde::Serialize; use serde::Serialize;
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash, Copy)]
#[serde(tag = "query", content = "args")] #[serde(tag = "query", content = "args")]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum Query { pub enum Query {
DjangoInit,
PythonEnv, PythonEnv,
Templatetags, Templatetags,
DjangoInit,
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
#[allow(clippy::struct_field_names)]
pub struct PythonEnvironmentQueryData { pub struct PythonEnvironmentQueryData {
pub sys_base_prefix: PathBuf, pub sys_base_prefix: PathBuf,
pub sys_executable: PathBuf, pub sys_executable: PathBuf,
@ -42,3 +43,21 @@ pub struct TemplateTag {
pub module: String, pub module: String,
pub doc: Option<String>, pub doc: Option<String>,
} }
#[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);
}
}

View file

@ -1,149 +1,15 @@
mod db; mod db;
pub mod inspector; mod django;
mod meta; mod inspector;
pub mod python; mod project;
mod python;
mod system; 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; pub use db::Db;
use inspector::pool::InspectorPool; pub use django::django_available;
use inspector::DjlsRequest; pub use django::django_settings_module;
use inspector::Query; pub use django::get_templatetags;
pub use meta::ProjectMetadata; pub use django::TemplateTags;
pub use python::PythonEnvironment; pub use inspector::pool::InspectorPool;
pub use templatetags::TemplateTags; pub use project::Project;
pub use python::Interpreter;
#[derive(Debug)]
pub struct DjangoProject {
path: PathBuf,
env: Option<PythonEnvironment>,
template_tags: Option<TemplateTags>,
inspector: Arc<InspectorPool>,
}
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())));
}
}

View file

@ -1,24 +0,0 @@
use std::path::PathBuf;
#[derive(Clone, Debug)]
pub struct ProjectMetadata {
root: PathBuf,
venv: Option<PathBuf>,
}
impl ProjectMetadata {
#[must_use]
pub fn new(root: PathBuf, venv: Option<PathBuf>) -> 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()
}
}

View file

@ -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<String>,
}
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);
}
}

View file

@ -1,8 +1,77 @@
use std::fmt; use std::fmt;
use std::path::Path; use std::path::Path;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc;
use crate::db::Db as ProjectDb;
use crate::system; 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<PathBuf> {
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)] #[derive(Clone, Debug, PartialEq)]
pub struct PythonEnvironment { pub struct PythonEnvironment {
@ -128,6 +197,38 @@ impl fmt::Display for PythonEnvironment {
Ok(()) 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<Arc<PythonEnvironment>> {
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)] #[cfg(test)]
mod tests { mod tests {
@ -167,13 +268,13 @@ mod tests {
} }
mod env_discovery { mod env_discovery {
use system::mock::MockGuard;
use system::mock::{
self as sys_mock,
};
use which::Error as WhichError; use which::Error as WhichError;
use super::*; use super::*;
use crate::system::mock::MockGuard;
use crate::system::mock::{
self as sys_mock,
};
#[test] #[test]
fn test_explicit_venv_path_found() { fn test_explicit_venv_path_found() {
@ -466,32 +567,37 @@ mod tests {
mod salsa_integration { mod salsa_integration {
use std::sync::Arc; use std::sync::Arc;
use std::sync::Mutex;
use djls_workspace::FileSystem; use djls_workspace::FileSystem;
use djls_workspace::InMemoryFileSystem; use djls_workspace::InMemoryFileSystem;
use super::*; use super::*;
use crate::db::find_python_environment; use crate::inspector::pool::InspectorPool;
use crate::db::Db as ProjectDb;
use crate::meta::ProjectMetadata;
/// Test implementation of ProjectDb for unit tests /// Test implementation of ProjectDb for unit tests
#[salsa::db] #[salsa::db]
#[derive(Clone)] #[derive(Clone)]
struct TestDatabase { struct TestDatabase {
storage: salsa::Storage<TestDatabase>, storage: salsa::Storage<TestDatabase>,
metadata: ProjectMetadata, project_root: PathBuf,
project: Arc<Mutex<Option<Project>>>,
fs: Arc<dyn FileSystem>, fs: Arc<dyn FileSystem>,
} }
impl TestDatabase { impl TestDatabase {
fn new(metadata: ProjectMetadata) -> Self { fn new(project_root: PathBuf) -> Self {
Self { Self {
storage: salsa::Storage::new(None), storage: salsa::Storage::new(None),
metadata, project_root,
project: Arc::new(Mutex::new(None)),
fs: Arc::new(InMemoryFileSystem::new()), fs: Arc::new(InMemoryFileSystem::new()),
} }
} }
fn set_project(&self, project: Project) {
*self.project.lock().unwrap() = Some(project);
}
} }
#[salsa::db] #[salsa::db]
@ -510,28 +616,51 @@ mod tests {
#[salsa::db] #[salsa::db]
impl ProjectDb for TestDatabase { impl ProjectDb for TestDatabase {
fn metadata(&self) -> &ProjectMetadata { fn project(&self) -> Option<Project> {
&self.metadata // 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<InspectorPool> {
Arc::new(InspectorPool::new())
} }
} }
#[test] #[test]
fn test_find_python_environment_with_salsa_db() { fn test_python_environment_with_salsa_db() {
let project_dir = tempdir().unwrap(); let project_dir = tempdir().unwrap();
let venv_dir = tempdir().unwrap(); let venv_dir = tempdir().unwrap();
// Create a mock venv // Create a mock venv
let venv_prefix = create_mock_venv(venv_dir.path(), None); let venv_prefix = create_mock_venv(venv_dir.path(), None);
// Create a metadata instance with project path and explicit venv path // Create a TestDatabase with the project root
let metadata = let db = TestDatabase::new(project_dir.path().to_path_buf());
ProjectMetadata::new(project_dir.path().to_path_buf(), Some(venv_prefix.clone()));
// Create a TestDatabase with the metadata // Create and configure the project with the venv path
let db = TestDatabase::new(metadata); 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 // Call the tracked function
let env = find_python_environment(&db); let env = python_environment(&db, project);
// Verify we found the environment // Verify we found the environment
assert!(env.is_some(), "Should find environment via salsa db"); assert!(env.is_some(), "Should find environment via salsa db");
@ -553,24 +682,22 @@ mod tests {
} }
#[test] #[test]
fn test_find_python_environment_with_project_venv() { fn test_python_environment_with_project_venv() {
let project_dir = tempdir().unwrap(); let project_dir = tempdir().unwrap();
// Create a .venv in the project directory // Create a .venv in the project directory
let venv_prefix = create_mock_venv(&project_dir.path().join(".venv"), None); let venv_prefix = create_mock_venv(&project_dir.path().join(".venv"), None);
// Create a metadata instance with project path but no explicit venv path // Create a TestDatabase with the project root
let metadata = ProjectMetadata::new(project_dir.path().to_path_buf(), None); let db = TestDatabase::new(project_dir.path().to_path_buf());
// Create a TestDatabase with the metadata
let db = TestDatabase::new(metadata);
// Mock to ensure VIRTUAL_ENV is not set // Mock to ensure VIRTUAL_ENV is not set
let _guard = system::mock::MockGuard; let _guard = system::mock::MockGuard;
system::mock::remove_env_var("VIRTUAL_ENV"); system::mock::remove_env_var("VIRTUAL_ENV");
// Call the tracked function // Call the tracked function (should find .venv)
let env = find_python_environment(&db); let project = db.project().unwrap();
let env = python_environment(&db, project);
// Verify we found the environment // Verify we found the environment
assert!( assert!(

View file

@ -7,12 +7,13 @@
use std::path::Path; use std::path::Path;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
#[cfg(test)]
use std::sync::Mutex; use std::sync::Mutex;
use dashmap::DashMap; use dashmap::DashMap;
use djls_project::Db as ProjectDb; 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::db::Db as TemplateDb;
use djls_templates::templatetags::TagSpecs; use djls_templates::templatetags::TagSpecs;
use djls_workspace::db::Db as WorkspaceDb; use djls_workspace::db::Db as WorkspaceDb;
@ -36,8 +37,11 @@ pub struct DjangoDatabase {
/// Maps paths to [`SourceFile`] entities for O(1) lookup. /// Maps paths to [`SourceFile`] entities for O(1) lookup.
files: Arc<DashMap<PathBuf, SourceFile>>, files: Arc<DashMap<PathBuf, SourceFile>>,
/// Project metadata containing root path and venv configuration. /// The single project for this database instance
metadata: ProjectMetadata, project: Arc<Mutex<Option<Project>>>,
/// Shared inspector pool for executing Python queries
inspector_pool: Arc<InspectorPool>,
storage: salsa::Storage<Self>, storage: salsa::Storage<Self>,
@ -56,7 +60,8 @@ impl Default for DjangoDatabase {
Self { Self {
fs: Arc::new(InMemoryFileSystem::new()), fs: Arc::new(InMemoryFileSystem::new()),
files: Arc::new(DashMap::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({ storage: salsa::Storage::new(Some(Box::new({
let logs = logs.clone(); let logs = logs.clone();
move |event| { move |event| {
@ -76,16 +81,26 @@ impl Default for DjangoDatabase {
} }
impl DjangoDatabase { impl DjangoDatabase {
/// Create a new [`DjangoDatabase`] with the given file system, file map, and project metadata. /// Set the project for this database instance
pub fn new( ///
file_system: Arc<dyn FileSystem>, /// # Panics
files: Arc<DashMap<PathBuf, SourceFile>>, ///
metadata: ProjectMetadata, /// Panics if the project mutex is poisoned.
) -> Self { 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<dyn FileSystem>, files: Arc<DashMap<PathBuf, SourceFile>>) -> Self {
Self { Self {
fs: file_system, fs: file_system,
files, files,
metadata, project: Arc::new(Mutex::new(None)),
inspector_pool: Arc::new(InspectorPool::new()),
storage: salsa::Storage::new(None), storage: salsa::Storage::new(None),
#[cfg(test)] #[cfg(test)]
logs: Arc::new(Mutex::new(None)), logs: Arc::new(Mutex::new(None)),
@ -163,7 +178,9 @@ impl WorkspaceDb for DjangoDatabase {
#[salsa::db] #[salsa::db]
impl TemplateDb for DjangoDatabase { impl TemplateDb for DjangoDatabase {
fn tag_specs(&self) -> Arc<TagSpecs> { fn tag_specs(&self) -> Arc<TagSpecs> {
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 let Ok(user_specs) = TagSpecs::load_user_specs(project_root) {
// If user specs exist and aren't empty, merge with built-in specs // If user specs exist and aren't empty, merge with built-in specs
@ -182,7 +199,11 @@ impl TemplateDb for DjangoDatabase {
#[salsa::db] #[salsa::db]
impl ProjectDb for DjangoDatabase { impl ProjectDb for DjangoDatabase {
fn metadata(&self) -> &ProjectMetadata { fn project(&self) -> Option<Project> {
&self.metadata *self.project.lock().unwrap()
}
fn inspector_pool(&self) -> Arc<InspectorPool> {
self.inspector_pool.clone()
} }
} }

View file

@ -1,6 +1,7 @@
use std::future::Future; use std::future::Future;
use std::sync::Arc; use std::sync::Arc;
use djls_project::Db as ProjectDb;
use djls_templates::analyze_template; use djls_templates::analyze_template;
use djls_templates::TemplateDiagnostic; use djls_templates::TemplateDiagnostic;
use djls_workspace::paths; 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) { async fn initialized(&self, _params: lsp_types::InitializedParams) {
tracing::info!("Server received initialized notification."); tracing::info!("Server received initialized notification.");
self.with_session_task(move |session_arc| async move { self.with_session_task(move |session_arc| async move {
let project_path_and_venv = { let session_lock = session_arc.lock().await;
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),
)
})
};
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!( tracing::info!(
"Task: Starting initialization for project at: {}", "Task: Starting initialization for project at: {}",
path_display path.display()
); );
if let Some(ref path) = venv_path { if let Some(project) = session_lock.project() {
tracing::info!("Using virtual environment from config: {}", path); project.initialize(session_lock.database());
} }
let init_result = { tracing::info!("Task: Successfully initialized project: {}", path.display());
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;
}
}
} else { } else {
tracing::info!("Task: No project instance found to initialize."); tracing::info!("Task: No project configured, skipping initialization.");
} }
Ok(()) Ok(())
}) })
.await; .await;
@ -369,7 +343,13 @@ impl LanguageServer for DjangoLanguageServer {
let position = params.text_document_position.position; let position = params.text_document_position.position;
let encoding = session.position_encoding(); let encoding = session.position_encoding();
let file_kind = FileKind::from_path(&path); 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 tag_specs = session.with_db(djls_templates::Db::tag_specs);
let supports_snippets = session.supports_snippets(); let supports_snippets = session.supports_snippets();
@ -378,7 +358,7 @@ impl LanguageServer for DjangoLanguageServer {
position, position,
encoding, encoding,
file_kind, file_kind,
template_tags, template_tags.as_ref(),
Some(&tag_specs), Some(&tag_specs),
supports_snippets, supports_snippets,
); );
@ -474,20 +454,20 @@ impl LanguageServer for DjangoLanguageServer {
async fn did_change_configuration(&self, _params: lsp_types::DidChangeConfigurationParams) { async fn did_change_configuration(&self, _params: lsp_types::DidChangeConfigurationParams) {
tracing::info!("Configuration change detected. Reloading settings..."); tracing::info!("Configuration change detected. Reloading settings...");
let project_path = self self.with_session_mut(|session| {
.with_session(|session| session.project().map(|p| p.path().to_path_buf())) if let Some(project) = session.project() {
.await; let project_root = project.root(session.database());
if let Some(path) = project_path { match djls_conf::Settings::new(project_root.as_path()) {
self.with_session_mut(|session| match djls_conf::Settings::new(path.as_path()) { Ok(new_settings) => {
Ok(new_settings) => { session.set_settings(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;
}
} }
} }

View file

@ -6,16 +6,16 @@
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use anyhow::Result;
use dashmap::DashMap; use dashmap::DashMap;
use djls_conf::Settings; use djls_conf::Settings;
use djls_project::DjangoProject; use djls_project::Db as ProjectDb;
use djls_project::ProjectMetadata; use djls_project::Interpreter;
use djls_workspace::db::SourceFile; use djls_workspace::db::SourceFile;
use djls_workspace::paths; use djls_workspace::paths;
use djls_workspace::PositionEncoding; use djls_workspace::PositionEncoding;
use djls_workspace::TextDocument; use djls_workspace::TextDocument;
use djls_workspace::Workspace; use djls_workspace::Workspace;
use salsa::Setter;
use tower_lsp_server::lsp_types; use tower_lsp_server::lsp_types;
use url::Url; use url::Url;
@ -28,14 +28,13 @@ use crate::db::DjangoDatabase;
/// - Project configuration and settings /// - Project configuration and settings
/// - Client capabilities and position encoding /// - Client capabilities and position encoding
/// - Workspace operations (buffers and file system) /// - Workspace operations (buffers and file system)
/// - All Salsa inputs (`SessionState`, Project)
/// ///
/// Following Ruff's architecture, the concrete database lives at this level /// Following Ruff's architecture, the concrete database lives at this level
/// and is passed down to operations that need it. /// and is passed down to operations that need it.
pub struct Session { pub struct Session {
/// The Django project configuration
project: Option<DjangoProject>,
/// LSP server settings /// LSP server settings
// TODO: this should really be in the database
settings: Settings, settings: Settings,
/// Workspace for buffer and file system management /// Workspace for buffer and file system management
@ -44,12 +43,12 @@ pub struct Session {
/// but not the database (which is owned directly by Session). /// but not the database (which is owned directly by Session).
workspace: Workspace, workspace: Workspace,
#[allow(dead_code)]
client_capabilities: lsp_types::ClientCapabilities, client_capabilities: lsp_types::ClientCapabilities,
/// Position encoding negotiated with client /// Position encoding negotiated with client
position_encoding: PositionEncoding, position_encoding: PositionEncoding,
/// The Salsa database for incremental computation
db: DjangoDatabase, db: DjangoDatabase,
} }
@ -65,47 +64,51 @@ impl Session {
std::env::current_dir().ok() std::env::current_dir().ok()
}); });
let (project, settings, metadata) = if let Some(path) = &project_path { let settings = if let Some(path) = &project_path {
let settings = djls_conf::Settings::new(path).unwrap_or_else(|_| djls_conf::Settings::default())
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)
} else { } else {
// Default metadata for when there's no project path Settings::default()
let metadata = ProjectMetadata::new(PathBuf::from("."), None);
(None, Settings::default(), metadata)
}; };
// Create workspace for buffer management
let workspace = Workspace::new(); let workspace = Workspace::new();
// Create the concrete database with the workspace's file system and metadata
let files = Arc::new(DashMap::new()); 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 { Self {
db,
project,
settings, settings,
workspace, workspace,
client_capabilities: params.capabilities.clone(), client_capabilities: params.capabilities.clone(),
position_encoding: PositionEncoding::negotiate(params), position_encoding: PositionEncoding::negotiate(params),
db,
} }
} }
#[must_use] #[must_use]
pub fn project(&self) -> Option<&DjangoProject> { pub fn db(&self) -> &DjangoDatabase {
self.project.as_ref() &self.db
}
pub fn project_mut(&mut self) -> &mut Option<DjangoProject> {
&mut self.project
} }
#[must_use] #[must_use]
@ -122,18 +125,6 @@ impl Session {
self.position_encoding 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. /// Execute a read-only operation with access to the database.
pub fn with_db<F, R>(&self, f: F) -> R pub fn with_db<F, R>(&self, f: F) -> R
where where
@ -155,13 +146,9 @@ impl Session {
&self.db &self.db
} }
/// Initialize the project with the database. /// Get the current project for this session
pub fn initialize_project(&mut self) -> Result<()> { pub fn project(&self) -> Option<djls_project::Project> {
if let Some(project) = self.project.as_mut() { self.db.project()
project.initialize(&self.db)
} else {
Ok(())
}
} }
/// Open a document in the session. /// Open a document in the session.
@ -256,6 +243,18 @@ impl Session {
.and_then(|td| td.diagnostic.as_ref()) .and_then(|td| td.diagnostic.as_ref())
.is_some() .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 { impl Default for Session {

View file

@ -48,6 +48,7 @@ pub trait Db: salsa::Database {
pub struct SourceFile { pub struct SourceFile {
/// The file's classification for analysis routing /// The file's classification for analysis routing
pub kind: FileKind, pub kind: FileKind,
// TODO: Change from Arc<str> to PathBuf for consistency with Project.root
/// The file path /// The file path
#[returns(ref)] #[returns(ref)]
pub path: Arc<str>, pub path: Arc<str>,
@ -78,6 +79,7 @@ pub fn source_text(db: &dyn Db, file: SourceFile) -> Arc<str> {
/// on files identified by path rather than by SourceFile input. /// on files identified by path rather than by SourceFile input.
#[salsa::input] #[salsa::input]
pub struct FilePath { pub struct FilePath {
// TODO: Change from Arc<str> to PathBuf for consistency with Project.root
/// The file path as a string /// The file path as a string
#[returns(ref)] #[returns(ref)]
pub path: Arc<str>, pub path: Arc<str>,