Add configuration option to disable individual diagnostics (#347)

This commit is contained in:
Josh Thomas 2025-11-03 17:08:44 -05:00 committed by GitHub
parent f36fb51b55
commit cf60dd63dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 532 additions and 4 deletions

View file

@ -20,6 +20,7 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/
### Added
- Added `diagnostics.severity` configuration option for configuring diagnostic severity levels
- Added `pythonpath` configuration option for specifying additional Python import paths
- Added documentation for VS Code extension
- Added documentation for Zed extension

View file

@ -4,6 +4,7 @@ version = "0.0.0"
edition = "2021"
[dependencies]
djls-conf = { workspace = true }
djls-source = { workspace = true }
djls-templates = { workspace = true }
djls-semantic = { workspace = true }

View file

@ -85,4 +85,8 @@ impl SemanticDb for Db {
fn template_dirs(&self) -> Option<Vec<Utf8PathBuf>> {
None
}
fn diagnostics_config(&self) -> djls_conf::DiagnosticsConfig {
djls_conf::DiagnosticsConfig::default()
}
}

View file

@ -0,0 +1,261 @@
use std::collections::HashMap;
use serde::Deserialize;
/// Diagnostic severity level for LSP diagnostics.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DiagnosticSeverity {
Off,
Error,
Warning,
Info,
Hint,
}
/// Configuration for diagnostic severity levels.
///
/// All diagnostics are enabled by default at "error" severity.
/// Configure severity per diagnostic code or prefix pattern.
/// Specific codes override prefix patterns.
///
/// Example configuration:
/// ```toml
/// [tool.djls.diagnostics.severity]
/// # Individual codes
/// S101 = "warning"
/// S102 = "off"
///
/// # Prefixes for bulk configuration
/// "T" = "off" # Disable all template errors
/// T100 = "hint" # But show parser errors as hints (specific overrides prefix)
/// ```
#[derive(Debug, Clone, PartialEq, Deserialize, Default)]
pub struct DiagnosticsConfig {
/// Map of diagnostic codes/prefixes to severity levels.
/// Supports:
/// - Specific codes: "S100", "T100"
/// - Prefixes: "S" (all S-series), "T" (all T-series), "S1" (S100-S199)
/// - More specific patterns override less specific ones
#[serde(default)]
severity: HashMap<String, DiagnosticSeverity>,
}
impl DiagnosticsConfig {
/// Get the severity level for a diagnostic code.
///
/// Resolution order (most specific wins):
/// 1. Exact match (e.g., "S100")
/// 2. Longest prefix match (e.g., "S1" over "S")
/// 3. Default: Error
#[must_use]
pub fn get_severity(&self, code: &str) -> DiagnosticSeverity {
// First, check for exact match
if let Some(&severity) = self.severity.get(code) {
return severity;
}
// Then, find the longest matching prefix
let mut best_match: Option<(&str, DiagnosticSeverity)> = None;
for (pattern, &severity) in &self.severity {
if code.starts_with(pattern) {
match best_match {
None => best_match = Some((pattern, severity)),
Some((existing_pattern, _)) => {
// Longer patterns are more specific
if pattern.len() > existing_pattern.len() {
best_match = Some((pattern, severity));
}
}
}
}
}
best_match.map_or(DiagnosticSeverity::Error, |(_, severity)| severity)
}
/// Check if a diagnostic should be shown (severity is not Off).
#[must_use]
pub fn is_enabled(&self, code: &str) -> bool {
self.get_severity(code) != DiagnosticSeverity::Off
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_severity_default() {
let config = DiagnosticsConfig::default();
assert_eq!(config.get_severity("S100"), DiagnosticSeverity::Error);
assert_eq!(config.get_severity("T100"), DiagnosticSeverity::Error);
}
#[test]
fn test_get_severity_exact_match() {
let mut severity = HashMap::new();
severity.insert("S100".to_string(), DiagnosticSeverity::Warning);
severity.insert("S101".to_string(), DiagnosticSeverity::Off);
let config = DiagnosticsConfig { severity };
assert_eq!(config.get_severity("S100"), DiagnosticSeverity::Warning);
assert_eq!(config.get_severity("S101"), DiagnosticSeverity::Off);
assert_eq!(config.get_severity("S102"), DiagnosticSeverity::Error);
}
#[test]
fn test_get_severity_prefix_match() {
let mut severity = HashMap::new();
severity.insert("S".to_string(), DiagnosticSeverity::Warning);
severity.insert("T".to_string(), DiagnosticSeverity::Off);
let config = DiagnosticsConfig { severity };
assert_eq!(config.get_severity("S100"), DiagnosticSeverity::Warning);
assert_eq!(config.get_severity("S101"), DiagnosticSeverity::Warning);
assert_eq!(config.get_severity("T100"), DiagnosticSeverity::Off);
assert_eq!(config.get_severity("T900"), DiagnosticSeverity::Off);
}
#[test]
fn test_get_severity_longest_prefix_wins() {
let mut severity = HashMap::new();
severity.insert("S".to_string(), DiagnosticSeverity::Warning);
severity.insert("S1".to_string(), DiagnosticSeverity::Off);
severity.insert("S10".to_string(), DiagnosticSeverity::Hint);
let config = DiagnosticsConfig { severity };
// S10 is most specific for S100
assert_eq!(config.get_severity("S100"), DiagnosticSeverity::Hint);
assert_eq!(config.get_severity("S101"), DiagnosticSeverity::Hint);
// S1 is most specific for S110
assert_eq!(config.get_severity("S110"), DiagnosticSeverity::Off);
assert_eq!(config.get_severity("S199"), DiagnosticSeverity::Off);
// S is most specific for S200
assert_eq!(config.get_severity("S200"), DiagnosticSeverity::Warning);
}
#[test]
fn test_get_severity_exact_overrides_prefix() {
let mut severity = HashMap::new();
severity.insert("S".to_string(), DiagnosticSeverity::Warning);
severity.insert("S1".to_string(), DiagnosticSeverity::Off);
severity.insert("S100".to_string(), DiagnosticSeverity::Error);
let config = DiagnosticsConfig { severity };
// Exact match wins
assert_eq!(config.get_severity("S100"), DiagnosticSeverity::Error);
// S1 prefix for other S1xx codes
assert_eq!(config.get_severity("S101"), DiagnosticSeverity::Off);
// S prefix for S2xx codes
assert_eq!(config.get_severity("S200"), DiagnosticSeverity::Warning);
}
#[test]
fn test_is_enabled_default() {
let config = DiagnosticsConfig::default();
assert!(config.is_enabled("S100"));
assert!(config.is_enabled("T100"));
}
#[test]
fn test_is_enabled_with_off() {
let mut severity = HashMap::new();
severity.insert("S100".to_string(), DiagnosticSeverity::Off);
let config = DiagnosticsConfig { severity };
assert!(!config.is_enabled("S100"));
assert!(config.is_enabled("S101"));
}
#[test]
fn test_is_enabled_with_prefix_off() {
let mut severity = HashMap::new();
severity.insert("T".to_string(), DiagnosticSeverity::Off);
let config = DiagnosticsConfig { severity };
assert!(!config.is_enabled("T100"));
assert!(!config.is_enabled("T900"));
assert!(config.is_enabled("S100"));
}
#[test]
fn test_is_enabled_prefix_off_with_specific_override() {
let mut severity = HashMap::new();
severity.insert("T".to_string(), DiagnosticSeverity::Off);
severity.insert("T100".to_string(), DiagnosticSeverity::Hint);
let config = DiagnosticsConfig { severity };
// T100 has specific override, so it's enabled
assert!(config.is_enabled("T100"));
// Other T codes are off
assert!(!config.is_enabled("T900"));
assert!(!config.is_enabled("T901"));
}
#[test]
fn test_deserialize_diagnostics_config() {
let toml = r#"
[severity]
S100 = "off"
S101 = "warning"
S102 = "hint"
"T" = "off"
T100 = "info"
"#;
let config: DiagnosticsConfig = toml::from_str(toml).unwrap();
assert_eq!(config.get_severity("S100"), DiagnosticSeverity::Off);
assert_eq!(config.get_severity("S101"), DiagnosticSeverity::Warning);
assert_eq!(config.get_severity("S102"), DiagnosticSeverity::Hint);
// T prefix applies to T900
assert_eq!(config.get_severity("T900"), DiagnosticSeverity::Off);
// T100 has specific override
assert_eq!(config.get_severity("T100"), DiagnosticSeverity::Info);
}
#[test]
fn test_complex_scenario() {
let mut severity = HashMap::new();
// Disable all template errors
severity.insert("T".to_string(), DiagnosticSeverity::Off);
// But show parser errors as hints
severity.insert("T100".to_string(), DiagnosticSeverity::Hint);
// Make all semantic errors warnings
severity.insert("S".to_string(), DiagnosticSeverity::Warning);
// Except S100 which is completely off
severity.insert("S100".to_string(), DiagnosticSeverity::Off);
// And S10x (S100-S109) should be info
severity.insert("S10".to_string(), DiagnosticSeverity::Info);
let config = DiagnosticsConfig { severity };
// S100 is exact match - off
assert_eq!(config.get_severity("S100"), DiagnosticSeverity::Off);
assert!(!config.is_enabled("S100"));
// S101 matches S10 prefix - info
assert_eq!(config.get_severity("S101"), DiagnosticSeverity::Info);
assert!(config.is_enabled("S101"));
// S200 matches S prefix - warning
assert_eq!(config.get_severity("S200"), DiagnosticSeverity::Warning);
assert!(config.is_enabled("S200"));
// T100 has exact match - hint
assert_eq!(config.get_severity("T100"), DiagnosticSeverity::Hint);
assert!(config.is_enabled("T100"));
// T900 matches T prefix - off
assert_eq!(config.get_severity("T900"), DiagnosticSeverity::Off);
assert!(!config.is_enabled("T900"));
}
}

View file

@ -1,3 +1,4 @@
pub mod diagnostics;
pub mod tagspecs;
use std::fs;
@ -14,6 +15,8 @@ use directories::ProjectDirs;
use serde::Deserialize;
use thiserror::Error;
pub use crate::diagnostics::DiagnosticSeverity;
pub use crate::diagnostics::DiagnosticsConfig;
pub use crate::tagspecs::ArgTypeDef;
pub use crate::tagspecs::EndTagDef;
pub use crate::tagspecs::IntermediateTagDef;
@ -65,6 +68,8 @@ pub struct Settings {
pythonpath: Vec<String>,
#[serde(default)]
tagspecs: Vec<TagSpecDef>,
#[serde(default)]
diagnostics: DiagnosticsConfig,
}
impl Settings {
@ -86,6 +91,10 @@ impl Settings {
if !overrides.tagspecs.is_empty() {
settings.tagspecs = overrides.tagspecs;
}
// For diagnostics, override if the config is non-default
if overrides.diagnostics != DiagnosticsConfig::default() {
settings.diagnostics = overrides.diagnostics;
}
}
Ok(settings)
@ -158,6 +167,11 @@ impl Settings {
pub fn tagspecs(&self) -> &[TagSpecDef] {
&self.tagspecs
}
#[must_use]
pub fn diagnostics(&self) -> &DiagnosticsConfig {
&self.diagnostics
}
}
#[cfg(test)]
@ -184,6 +198,7 @@ mod tests {
django_settings_module: None,
pythonpath: vec![],
tagspecs: vec![],
diagnostics: DiagnosticsConfig::default(),
}
);
}
@ -267,6 +282,42 @@ mod tests {
}
);
}
#[test]
fn test_load_diagnostics_config() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("djls.toml"),
r#"
[diagnostics.severity]
S100 = "off"
S101 = "warning"
"T" = "off"
T100 = "hint"
"#,
)
.unwrap();
let settings = Settings::new(Utf8Path::from_path(dir.path()).unwrap(), None).unwrap();
// Test via public API
assert_eq!(
settings.diagnostics.get_severity("S100"),
DiagnosticSeverity::Off
);
assert_eq!(
settings.diagnostics.get_severity("S101"),
DiagnosticSeverity::Warning
);
// T prefix applies to T900
assert_eq!(
settings.diagnostics.get_severity("T900"),
DiagnosticSeverity::Off
);
// T100 has specific override
assert_eq!(
settings.diagnostics.get_severity("T100"),
DiagnosticSeverity::Hint
);
}
}
mod priority {

View file

@ -4,6 +4,7 @@ version = "0.0.0"
edition = "2021"
[dependencies]
djls-conf = { workspace = true }
djls-project = { workspace = true }
djls-semantic = { workspace = true }
djls-source = { workspace = true }

View file

@ -6,6 +6,7 @@ use djls_templates::TemplateError;
use djls_templates::TemplateErrorAccumulator;
use tower_lsp_server::lsp_types;
use crate::ext::DiagnosticSeverityExt;
use crate::ext::SpanExt;
trait DiagnosticError: std::fmt::Display {
@ -88,6 +89,9 @@ impl DiagnosticError for ValidationError {
/// parsing and validation. The caller must provide the parsed `NodeList` (or `None`
/// if parsing failed), making it explicit that parsing should have already occurred.
///
/// Diagnostics are filtered based on the configuration settings (`select` and `ignore`),
/// and severity levels can be overridden per diagnostic code.
///
/// # Parameters
/// - `db`: The Salsa database
/// - `file`: The source file (needed to retrieve accumulated template errors)
@ -95,7 +99,7 @@ impl DiagnosticError for ValidationError {
///
/// # Returns
/// A vector of LSP diagnostics combining both template syntax errors and
/// semantic validation errors.
/// semantic validation errors, filtered by the diagnostics configuration.
///
/// # Design
/// This API design makes it clear that:
@ -110,13 +114,27 @@ pub fn collect_diagnostics(
) -> Vec<lsp_types::Diagnostic> {
let mut diagnostics = Vec::new();
let config = db.diagnostics_config();
let template_errors =
djls_templates::parse_template::accumulated::<TemplateErrorAccumulator>(db, file);
let line_index = file.line_index(db);
for error_acc in template_errors {
diagnostics.push(error_acc.0.as_diagnostic(line_index));
let mut diagnostic = error_acc.0.as_diagnostic(line_index);
if let Some(lsp_types::NumberOrString::String(code)) = &diagnostic.code {
let severity = config.get_severity(code);
// Skip if diagnostic is disabled (severity = off)
if let Some(lsp_severity) = severity.to_lsp_severity() {
diagnostic.severity = Some(lsp_severity);
diagnostics.push(diagnostic);
}
} else {
// No code, use default
diagnostics.push(diagnostic);
}
}
if let Some(nodelist) = nodelist {
@ -125,9 +143,49 @@ pub fn collect_diagnostics(
>(db, nodelist);
for error_acc in validation_errors {
diagnostics.push(error_acc.0.as_diagnostic(line_index));
let mut diagnostic = error_acc.0.as_diagnostic(line_index);
if let Some(lsp_types::NumberOrString::String(code)) = &diagnostic.code {
let severity = config.get_severity(code);
// Skip if diagnostic is disabled (severity = off)
if let Some(lsp_severity) = severity.to_lsp_severity() {
diagnostic.severity = Some(lsp_severity);
diagnostics.push(diagnostic);
}
} else {
// No code, use default
diagnostics.push(diagnostic);
}
}
}
diagnostics
}
#[cfg(test)]
mod tests {
use djls_conf::DiagnosticSeverity;
use super::*;
#[test]
fn test_to_lsp_severity() {
assert_eq!(DiagnosticSeverity::Off.to_lsp_severity(), None);
assert_eq!(
DiagnosticSeverity::Error.to_lsp_severity(),
Some(lsp_types::DiagnosticSeverity::ERROR)
);
assert_eq!(
DiagnosticSeverity::Warning.to_lsp_severity(),
Some(lsp_types::DiagnosticSeverity::WARNING)
);
assert_eq!(
DiagnosticSeverity::Info.to_lsp_severity(),
Some(lsp_types::DiagnosticSeverity::INFORMATION)
);
assert_eq!(
DiagnosticSeverity::Hint.to_lsp_severity(),
Some(lsp_types::DiagnosticSeverity::HINT)
);
}
}

View file

@ -1,5 +1,6 @@
use camino::Utf8Path;
use camino::Utf8PathBuf;
use djls_conf::DiagnosticSeverity;
use djls_source::LineIndex;
use djls_source::Offset;
use djls_source::Span;
@ -44,3 +45,19 @@ impl Utf8PathExt for Utf8PathBuf {
lsp_types::Uri::from_file_path(self.as_std_path())
}
}
pub(crate) trait DiagnosticSeverityExt {
fn to_lsp_severity(self) -> Option<lsp_types::DiagnosticSeverity>;
}
impl DiagnosticSeverityExt for DiagnosticSeverity {
fn to_lsp_severity(self) -> Option<lsp_types::DiagnosticSeverity> {
match self {
DiagnosticSeverity::Off => None,
DiagnosticSeverity::Error => Some(lsp_types::DiagnosticSeverity::ERROR),
DiagnosticSeverity::Warning => Some(lsp_types::DiagnosticSeverity::WARNING),
DiagnosticSeverity::Info => Some(lsp_types::DiagnosticSeverity::INFORMATION),
DiagnosticSeverity::Hint => Some(lsp_types::DiagnosticSeverity::HINT),
}
}
}

View file

@ -247,6 +247,10 @@ mod tests {
fn template_dirs(&self) -> Option<Vec<Utf8PathBuf>> {
None
}
fn diagnostics_config(&self) -> djls_conf::DiagnosticsConfig {
djls_conf::DiagnosticsConfig::default()
}
}
#[test]

View file

@ -1,4 +1,5 @@
use camino::Utf8PathBuf;
use djls_conf::DiagnosticsConfig;
use djls_templates::Db as TemplateDb;
use crate::blocks::TagIndex;
@ -13,6 +14,9 @@ pub trait Db: TemplateDb {
fn tag_index(&self) -> TagIndex<'_>;
fn template_dirs(&self) -> Option<Vec<Utf8PathBuf>>;
/// Get the diagnostics configuration
fn diagnostics_config(&self) -> DiagnosticsConfig;
}
#[salsa::accumulator]

View file

@ -287,6 +287,10 @@ mod tests {
fn template_dirs(&self) -> Option<Vec<Utf8PathBuf>> {
None
}
fn diagnostics_config(&self) -> djls_conf::DiagnosticsConfig {
djls_conf::DiagnosticsConfig::default()
}
}
#[test]

View file

@ -199,6 +199,10 @@ impl SemanticDb for DjangoDatabase {
None
}
}
fn diagnostics_config(&self) -> djls_conf::DiagnosticsConfig {
self.settings().diagnostics().clone()
}
}
#[salsa::db]

View file

@ -55,6 +55,110 @@ Additional directories to add to Python's import search path when the inspector
Enable debug logging for troubleshooting language server issues.
### `diagnostics`
Configure diagnostic severity levels. All diagnostics are enabled by default at "error" severity level.
**Default:** All diagnostics shown as errors
#### `diagnostics.severity`
Map diagnostic codes or prefixes to severity levels. Supports:
- **Exact codes:** `"S100"`, `"T100"`
- **Prefixes:** `"S"` (all S-series), `"T"` (all T-series), `"S1"` (S100-S199), `"T9"` (T900-T999)
- **Resolution:** More specific patterns override less specific (exact > longer prefix > shorter prefix)
**Available severity levels:**
- `"off"` - Disable diagnostic completely
- `"hint"` - Show as subtle hint
- `"info"` - Show as information
- `"warning"` - Show as warning
- `"error"` - Show as error (default)
#### Available Diagnostic Codes
**Template Errors (T-series):**
- `T100` - Parser errors (syntax issues in templates)
- `T900` - IO errors (file read/write issues)
- `T901` - Configuration errors (invalid tagspecs)
**Semantic Validation Errors (S-series):**
- `S100` - Unclosed tag (missing end tag)
- `S101` - Unbalanced structure (mismatched block tags)
- `S102` - Orphaned tag (intermediate tag without parent)
- `S103` - Unmatched block name (e.g., `{% endblock foo %}` doesn't match `{% block bar %}`)
- `S104` - Missing required arguments
- `S105` - Too many arguments
- `S106` - Invalid literal argument
- `S107` - Invalid argument choice
#### Examples
**Disable specific diagnostics:**
```toml
[diagnostics.severity]
S100 = "off" # Don't show unclosed tag errors
T100 = "off" # Don't show parser errors
```
**Disable all template errors:**
```toml
[diagnostics.severity]
"T" = "off" # Prefix matches all T-series
```
**Disable with specific override:**
```toml
[diagnostics.severity]
"T" = "off" # Disable all template errors
T100 = "hint" # But show parser errors as hints (specific overrides prefix)
```
**Make all semantic errors warnings:**
```toml
[diagnostics.severity]
"S" = "warning" # All semantic errors as warnings
```
**Complex configuration:**
```toml
[diagnostics.severity]
# Disable all template errors
"T" = "off"
# But show parser errors as hints
T100 = "hint"
# Make all semantic errors warnings
"S" = "warning"
# Except completely disable unclosed tags
S100 = "off"
# And make S10x (S100-S109) info level
"S10" = "info"
```
**Resolution order example:**
```toml
[diagnostics.severity]
"S" = "warning" # Base: all S-series are warnings
"S1" = "info" # Override: S100-S199 are info
S100 = "off" # Override: S100 is off
# Results:
# S100 → off (exact match)
# S101 → info ("S1" prefix)
# S200 → warning ("S" prefix)
```
**When to configure:**
- Disable false positives: Set problematic diagnostics to `"off"`
- Gradual adoption: Downgrade to `"warning"` or `"hint"` during migration
- Focus attention: Disable entire categories with prefix patterns
- Fine-tune experience: Mix prefix patterns with specific overrides
### `tagspecs`
**Default:** `[]`
@ -80,7 +184,15 @@ Pass configuration through your editor's LSP client using `initializationOptions
{
"django_settings_module": "myproject.settings",
"venv_path": "/path/to/venv",
"pythonpath": ["/path/to/shared/libs"]
"pythonpath": ["/path/to/shared/libs"],
"diagnostics": {
"severity": {
"S100": "off",
"S101": "warning",
"T": "off",
"T100": "hint"
}
}
}
```
@ -97,6 +209,12 @@ If you use `pyproject.toml`, add a `[tool.djls]` section:
django_settings_module = "myproject.settings"
venv_path = "/path/to/venv" # Optional: only if auto-detection fails
pythonpath = ["/path/to/shared/libs"] # Optional: additional import paths
[tool.djls.diagnostics.severity]
S100 = "off"
S101 = "warning"
"T" = "off"
T100 = "hint"
```
If you prefer a dedicated config file or don't use `pyproject.toml`, you can use `djls.toml` (same settings, no `[tool.djls]` table).