diff --git a/CHANGELOG.md b/CHANGELOG.md index 84114d75..f3f1cb53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,11 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/ ## [Unreleased] +### Added + +- Added support for loading server settings from user files (`~/.config/djls/djls.toml`) and project files (`djls.toml`, `.djls.toml`, and `pyproject.toml` via `[tool.djls]` table). +- Implemented dynamic settings reloading via `workspace/didChangeConfiguration`. + ### Changed - **Internal**: Moved task queueing functionality to `djls-server` crate, renamed from `Worker` to `Queue`, and simplified API. diff --git a/Cargo.toml b/Cargo.toml index 423f9475..28be6230 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ resolver = "2" [workspace.dependencies] djls = { path = "crates/djls" } +djls-conf = { path = "crates/djls-conf" } djls-project = { path = "crates/djls-project" } djls-server = { path = "crates/djls-server" } djls-templates = { path = "crates/djls-templates" } diff --git a/crates/djls-conf/Cargo.toml b/crates/djls-conf/Cargo.toml new file mode 100644 index 00000000..98fa4589 --- /dev/null +++ b/crates/djls-conf/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "djls-conf" +version = "0.0.0" +edition = "2021" + +[dependencies] +anyhow = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } + +config = { version ="0.15", features = ["toml"] } +directories = "6.0" +toml = "0.8" + +[dev-dependencies] +tempfile = "3.19" diff --git a/crates/djls-conf/src/lib.rs b/crates/djls-conf/src/lib.rs new file mode 100644 index 00000000..0189d1b4 --- /dev/null +++ b/crates/djls-conf/src/lib.rs @@ -0,0 +1,245 @@ +use config::{Config, ConfigError as ExternalConfigError, File, FileFormat}; +use directories::ProjectDirs; +use serde::Deserialize; +use std::{fs, path::Path}; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ConfigError { + #[error("Configuration build/deserialize error")] + Config(#[from] ExternalConfigError), + #[error("Failed to read pyproject.toml")] + PyprojectIo(#[from] std::io::Error), + #[error("Failed to parse pyproject.toml TOML")] + PyprojectParse(#[from] toml::de::Error), + #[error("Failed to serialize extracted pyproject.toml data")] + PyprojectSerialize(#[from] toml::ser::Error), +} + +#[derive(Debug, Deserialize, Default, PartialEq)] +#[serde(default)] +pub struct Settings { + debug: bool, +} + +impl Settings { + pub fn new(project_root: &Path) -> Result { + let user_config_file = ProjectDirs::from("com.github", "joshuadavidthomas", "djls") + .map(|proj_dirs| proj_dirs.config_dir().join("djls.toml")); + + Self::load_from_paths(project_root, user_config_file.as_deref()) + } + + fn load_from_paths( + project_root: &Path, + user_config_path: Option<&Path>, + ) -> Result { + let mut builder = Config::builder(); + + if let Some(path) = user_config_path { + builder = builder.add_source(File::from(path).format(FileFormat::Toml).required(false)); + } + + let pyproject_path = project_root.join("pyproject.toml"); + if pyproject_path.exists() { + let content = fs::read_to_string(&pyproject_path)?; + let toml_str: toml::Value = toml::from_str(&content)?; + let tool_djls_value: Option<&toml::Value> = + ["tool", "djls"].iter().try_fold(&toml_str, |val, &key| { + // Attempt to get the next key. If it exists, return Some(value) to continue. + // If get returns None, try_fold automatically stops and returns None overall. + val.get(key) + }); + if let Some(tool_djls_table) = tool_djls_value.and_then(|v| v.as_table()) { + let tool_djls_string = toml::to_string(tool_djls_table)?; + builder = builder.add_source(File::from_str(&tool_djls_string, FileFormat::Toml)); + } + } + + builder = builder.add_source( + File::from(project_root.join(".djls.toml")) + .format(FileFormat::Toml) + .required(false), + ); + + builder = builder.add_source( + File::from(project_root.join("djls.toml")) + .format(FileFormat::Toml) + .required(false), + ); + + let config = builder.build()?; + let settings = config.try_deserialize()?; + Ok(settings) + } + + pub fn debug(&self) -> bool { + self.debug + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + mod defaults { + use super::*; + + #[test] + fn test_load_no_files() { + let dir = tempdir().unwrap(); + let settings = Settings::new(dir.path()).unwrap(); + // Should load defaults + assert_eq!(settings, Settings { debug: false }); + // Add assertions for future default fields here + } + } + + mod project_files { + use super::*; + + #[test] + fn test_load_djls_toml_only() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("djls.toml"), "debug = true").unwrap(); + let settings = Settings::new(dir.path()).unwrap(); + assert_eq!(settings, Settings { debug: true }); + } + + #[test] + fn test_load_dot_djls_toml_only() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join(".djls.toml"), "debug = true").unwrap(); + let settings = Settings::new(dir.path()).unwrap(); + assert_eq!(settings, Settings { debug: true }); + } + + #[test] + fn test_load_pyproject_toml_only() { + let dir = tempdir().unwrap(); + // Write the setting under [tool.djls] + let content = "[tool.djls]\ndebug = true\n"; + fs::write(dir.path().join("pyproject.toml"), content).unwrap(); + let settings = Settings::new(dir.path()).unwrap(); + assert_eq!(settings, Settings { debug: true }); + } + } + + mod priority { + use super::*; + + #[test] + fn test_project_priority_djls_overrides_dot_djls() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join(".djls.toml"), "debug = false").unwrap(); + fs::write(dir.path().join("djls.toml"), "debug = true").unwrap(); + let settings = Settings::new(dir.path()).unwrap(); + assert_eq!(settings, Settings { debug: true }); // djls.toml wins + } + + #[test] + fn test_project_priority_dot_djls_overrides_pyproject() { + let dir = tempdir().unwrap(); + let pyproject_content = "[tool.djls]\ndebug = false\n"; + fs::write(dir.path().join("pyproject.toml"), pyproject_content).unwrap(); + fs::write(dir.path().join(".djls.toml"), "debug = true").unwrap(); + let settings = Settings::new(dir.path()).unwrap(); + assert_eq!(settings, Settings { debug: true }); // .djls.toml wins + } + + #[test] + fn test_project_priority_all_files_djls_wins() { + let dir = tempdir().unwrap(); + let pyproject_content = "[tool.djls]\ndebug = false\n"; + fs::write(dir.path().join("pyproject.toml"), pyproject_content).unwrap(); + fs::write(dir.path().join(".djls.toml"), "debug = false").unwrap(); + fs::write(dir.path().join("djls.toml"), "debug = true").unwrap(); + let settings = Settings::new(dir.path()).unwrap(); + assert_eq!(settings, Settings { debug: true }); // djls.toml wins + } + + #[test] + fn test_user_priority_project_overrides_user() { + let user_dir = tempdir().unwrap(); + let project_dir = tempdir().unwrap(); + let user_conf_path = user_dir.path().join("config.toml"); + fs::write(&user_conf_path, "debug = true").unwrap(); // User: true + let pyproject_content = "[tool.djls]\ndebug = false\n"; // Project: false + fs::write(project_dir.path().join("pyproject.toml"), pyproject_content).unwrap(); + + let settings = + Settings::load_from_paths(project_dir.path(), Some(&user_conf_path)).unwrap(); + assert_eq!(settings, Settings { debug: false }); // pyproject.toml overrides user + } + + #[test] + fn test_user_priority_djls_overrides_user() { + let user_dir = tempdir().unwrap(); + let project_dir = tempdir().unwrap(); + let user_conf_path = user_dir.path().join("config.toml"); + fs::write(&user_conf_path, "debug = true").unwrap(); // User: true + fs::write(project_dir.path().join("djls.toml"), "debug = false").unwrap(); // Project: false + + let settings = + Settings::load_from_paths(project_dir.path(), Some(&user_conf_path)).unwrap(); + assert_eq!(settings, Settings { debug: false }); // djls.toml overrides user + } + } + + mod user_config { + use super::*; + + #[test] + fn test_load_user_config_only() { + let user_dir = tempdir().unwrap(); + let project_dir = tempdir().unwrap(); // Empty project dir + let user_conf_path = user_dir.path().join("config.toml"); + fs::write(&user_conf_path, "debug = true").unwrap(); + + let settings = + Settings::load_from_paths(project_dir.path(), Some(&user_conf_path)).unwrap(); + assert_eq!(settings, Settings { debug: true }); + } + + #[test] + fn test_no_user_config_file_present() { + let user_dir = tempdir().unwrap(); // Exists, but no config.toml inside + let project_dir = tempdir().unwrap(); + let user_conf_path = user_dir.path().join("config.toml"); // Path exists, file doesn't + let pyproject_content = "[tool.djls]\ndebug = true\n"; + fs::write(project_dir.path().join("pyproject.toml"), pyproject_content).unwrap(); + + // Should load project settings fine, ignoring non-existent user config + let settings = + Settings::load_from_paths(project_dir.path(), Some(&user_conf_path)).unwrap(); + assert_eq!(settings, Settings { debug: true }); + } + + #[test] + fn test_user_config_path_not_provided() { + // Simulates ProjectDirs::from returning None + let project_dir = tempdir().unwrap(); + fs::write(project_dir.path().join("djls.toml"), "debug = true").unwrap(); + + // Call helper with None for user path + let settings = Settings::load_from_paths(project_dir.path(), None).unwrap(); + assert_eq!(settings, Settings { debug: true }); + } + } + + mod errors { + use super::*; + + #[test] + fn test_invalid_toml_content() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("djls.toml"), "debug = not_a_boolean").unwrap(); + // Need to call Settings::new here as load_from_paths doesn't involve ProjectDirs + let result = Settings::new(dir.path()); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), ConfigError::Config(_))); + } + } +} diff --git a/crates/djls-server/Cargo.toml b/crates/djls-server/Cargo.toml index 9458d237..7fb21c4f 100644 --- a/crates/djls-server/Cargo.toml +++ b/crates/djls-server/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] +djls-conf = { workspace = true } djls-project = { workspace = true } djls-templates = { workspace = true } diff --git a/crates/djls-server/src/server.rs b/crates/djls-server/src/server.rs index f80b5377..e405e83e 100644 --- a/crates/djls-server/src/server.rs +++ b/crates/djls-server/src/server.rs @@ -1,6 +1,7 @@ use crate::documents::Store; use crate::queue::Queue; use crate::workspace::get_project_path; +use djls_conf::Settings; use djls_project::DjangoProject; use std::sync::Arc; use tokio::sync::RwLock; @@ -15,6 +16,7 @@ pub struct DjangoLanguageServer { client: Client, project: Arc>>, documents: Arc>, + settings: Arc>, queue: Queue, } @@ -24,6 +26,7 @@ impl DjangoLanguageServer { client, project: Arc::new(RwLock::new(None)), documents: Arc::new(RwLock::new(Store::new())), + settings: Arc::new(RwLock::new(Settings::default())), queue: Queue::new(), } } @@ -31,6 +34,48 @@ impl DjangoLanguageServer { async fn log_message(&self, type_: MessageType, message: &str) { self.client.log_message(type_, message).await; } + + async fn update_settings(&self, project_path: Option<&std::path::Path>) { + if let Some(path) = project_path { + match Settings::new(path) { + Ok(loaded_settings) => { + let mut settings_guard = self.settings.write().await; + *settings_guard = loaded_settings; + // Could potentially check if settings actually changed before logging + self.log_message( + MessageType::INFO, + &format!( + "Successfully loaded/reloaded settings for {}", + path.display() + ), + ) + .await; + } + Err(e) => { + // Keep existing settings if loading/reloading fails + self.log_message( + MessageType::ERROR, + &format!( + "Failed to load/reload settings for {}: {}", + path.display(), + e + ), + ) + .await; + } + } + } else { + // If no project path, ensure we're using defaults (might already be the case) + // Or log that project-specific settings can't be loaded. + let mut settings_guard = self.settings.write().await; + *settings_guard = Settings::default(); // Reset to default if no project path + self.log_message( + MessageType::INFO, + "No project root identified. Using default settings.", + ) + .await; + } + } } impl LanguageServer for DjangoLanguageServer { @@ -62,7 +107,9 @@ impl LanguageServer for DjangoLanguageServer { // Ensure it's None if no path *project_guard = None; } - } // Lock released + } + + self.update_settings(project_path.as_deref()).await; Ok(InitializeResult { capabilities: ServerCapabilities { @@ -75,6 +122,14 @@ impl LanguageServer for DjangoLanguageServer { ]), ..Default::default() }), + workspace: Some(WorkspaceServerCapabilities { + workspace_folders: Some(WorkspaceFoldersServerCapabilities { + supported: Some(true), + change_notifications: Some(OneOf::Left(true)), + }), + // Add file operations if needed later + file_operations: None, + }), text_document_sync: Some(TextDocumentSyncCapability::Options( TextDocumentSyncOptions { open_close: Some(true), @@ -234,4 +289,19 @@ impl LanguageServer for DjangoLanguageServer { } Ok(None) } + + async fn did_change_configuration(&self, _params: DidChangeConfigurationParams) { + self.log_message( + MessageType::INFO, + "Configuration change detected. Reloading settings...", + ) + .await; + + let project_path = { + let project_guard = self.project.read().await; + project_guard.as_ref().map(|p| p.path().to_path_buf()) + }; + + self.update_settings(project_path.as_deref()).await; + } }