mirror of
https://github.com/joshuadavidthomas/django-language-server.git
synced 2025-08-04 01:58:18 +00:00
Add djls-conf
crate and add initial settings (#113)
This commit is contained in:
parent
b83ed621b5
commit
3008389f2b
6 changed files with 339 additions and 1 deletions
|
@ -18,6 +18,11 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/
|
||||||
|
|
||||||
## [Unreleased]
|
## [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
|
### Changed
|
||||||
|
|
||||||
- **Internal**: Moved task queueing functionality to `djls-server` crate, renamed from `Worker` to `Queue`, and simplified API.
|
- **Internal**: Moved task queueing functionality to `djls-server` crate, renamed from `Worker` to `Queue`, and simplified API.
|
||||||
|
|
|
@ -4,6 +4,7 @@ resolver = "2"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
djls = { path = "crates/djls" }
|
djls = { path = "crates/djls" }
|
||||||
|
djls-conf = { path = "crates/djls-conf" }
|
||||||
djls-project = { path = "crates/djls-project" }
|
djls-project = { path = "crates/djls-project" }
|
||||||
djls-server = { path = "crates/djls-server" }
|
djls-server = { path = "crates/djls-server" }
|
||||||
djls-templates = { path = "crates/djls-templates" }
|
djls-templates = { path = "crates/djls-templates" }
|
||||||
|
|
16
crates/djls-conf/Cargo.toml
Normal file
16
crates/djls-conf/Cargo.toml
Normal file
|
@ -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"
|
245
crates/djls-conf/src/lib.rs
Normal file
245
crates/djls-conf/src/lib.rs
Normal file
|
@ -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<Self, ConfigError> {
|
||||||
|
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<Self, ConfigError> {
|
||||||
|
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(_)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,6 +4,7 @@ version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
djls-conf = { workspace = true }
|
||||||
djls-project = { workspace = true }
|
djls-project = { workspace = true }
|
||||||
djls-templates = { workspace = true }
|
djls-templates = { workspace = true }
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
use crate::documents::Store;
|
use crate::documents::Store;
|
||||||
use crate::queue::Queue;
|
use crate::queue::Queue;
|
||||||
use crate::workspace::get_project_path;
|
use crate::workspace::get_project_path;
|
||||||
|
use djls_conf::Settings;
|
||||||
use djls_project::DjangoProject;
|
use djls_project::DjangoProject;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
@ -15,6 +16,7 @@ pub struct DjangoLanguageServer {
|
||||||
client: Client,
|
client: Client,
|
||||||
project: Arc<RwLock<Option<DjangoProject>>>,
|
project: Arc<RwLock<Option<DjangoProject>>>,
|
||||||
documents: Arc<RwLock<Store>>,
|
documents: Arc<RwLock<Store>>,
|
||||||
|
settings: Arc<RwLock<Settings>>,
|
||||||
queue: Queue,
|
queue: Queue,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -24,6 +26,7 @@ impl DjangoLanguageServer {
|
||||||
client,
|
client,
|
||||||
project: Arc::new(RwLock::new(None)),
|
project: Arc::new(RwLock::new(None)),
|
||||||
documents: Arc::new(RwLock::new(Store::new())),
|
documents: Arc::new(RwLock::new(Store::new())),
|
||||||
|
settings: Arc::new(RwLock::new(Settings::default())),
|
||||||
queue: Queue::new(),
|
queue: Queue::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -31,6 +34,48 @@ impl DjangoLanguageServer {
|
||||||
async fn log_message(&self, type_: MessageType, message: &str) {
|
async fn log_message(&self, type_: MessageType, message: &str) {
|
||||||
self.client.log_message(type_, message).await;
|
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 {
|
impl LanguageServer for DjangoLanguageServer {
|
||||||
|
@ -62,7 +107,9 @@ impl LanguageServer for DjangoLanguageServer {
|
||||||
// Ensure it's None if no path
|
// Ensure it's None if no path
|
||||||
*project_guard = None;
|
*project_guard = None;
|
||||||
}
|
}
|
||||||
} // Lock released
|
}
|
||||||
|
|
||||||
|
self.update_settings(project_path.as_deref()).await;
|
||||||
|
|
||||||
Ok(InitializeResult {
|
Ok(InitializeResult {
|
||||||
capabilities: ServerCapabilities {
|
capabilities: ServerCapabilities {
|
||||||
|
@ -75,6 +122,14 @@ impl LanguageServer for DjangoLanguageServer {
|
||||||
]),
|
]),
|
||||||
..Default::default()
|
..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(
|
text_document_sync: Some(TextDocumentSyncCapability::Options(
|
||||||
TextDocumentSyncOptions {
|
TextDocumentSyncOptions {
|
||||||
open_close: Some(true),
|
open_close: Some(true),
|
||||||
|
@ -234,4 +289,19 @@ impl LanguageServer for DjangoLanguageServer {
|
||||||
}
|
}
|
||||||
Ok(None)
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue