Refactor and simplify initial tagspecs (#110)
Some checks failed
lint / pre-commit (push) Waiting to run
release / linux (map[runner:ubuntu-22.04 target:aarch64]) (push) Failing after 6s
release / linux (map[runner:ubuntu-22.04 target:armv7]) (push) Failing after 3s
release / linux (map[runner:ubuntu-22.04 target:ppc64le]) (push) Failing after 3s
release / linux (map[runner:ubuntu-22.04 target:s390x]) (push) Failing after 3s
release / linux (map[runner:ubuntu-22.04 target:x86]) (push) Failing after 3s
release / linux (map[runner:ubuntu-22.04 target:x86_64]) (push) Failing after 3s
release / musllinux (map[runner:ubuntu-22.04 target:aarch64]) (push) Failing after 3s
release / musllinux (map[runner:ubuntu-22.04 target:armv7]) (push) Failing after 3s
release / musllinux (map[runner:ubuntu-22.04 target:x86]) (push) Failing after 3s
release / musllinux (map[runner:ubuntu-22.04 target:x86_64]) (push) Failing after 3s
release / test (push) Has been skipped
release / windows (map[runner:windows-latest target:x64]) (push) Has been cancelled
release / windows (map[runner:windows-latest target:x86]) (push) Has been cancelled
release / macos (map[runner:macos-13 target:x86_64]) (push) Has been cancelled
release / macos (map[runner:macos-14 target:aarch64]) (push) Has been cancelled
release / sdist (push) Has been cancelled
release / release (push) Has been cancelled

This commit is contained in:
Josh Thomas 2025-04-29 14:07:35 -05:00 committed by GitHub
parent 646f12b8d0
commit b83ed621b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 197 additions and 248 deletions

View file

@ -1,5 +1,5 @@
use anyhow::Result; use anyhow::Result;
use serde::Deserialize; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
@ -27,38 +27,41 @@ impl TagSpecs {
} }
/// Load specs from a TOML file, looking under the specified table path /// Load specs from a TOML file, looking under the specified table path
fn load_from_toml(path: &Path, table_path: &[&str]) -> Result<Self, anyhow::Error> { fn load_from_toml(path: &Path, table_path: &[&str]) -> Result<Self, TagSpecError> {
let content = fs::read_to_string(path)?; let content = fs::read_to_string(path)?;
let value: Value = toml::from_str(&content)?; let value: Value = toml::from_str(&content)?;
// Navigate to the specified table let start_node = table_path
let table = table_path
.iter() .iter()
.try_fold(&value, |current, &key| { .try_fold(&value, |current, &key| current.get(key));
current
.get(key)
.ok_or_else(|| anyhow::anyhow!("Missing table: {}", key))
})
.unwrap_or(&value);
let mut specs = HashMap::new(); let mut specs = HashMap::new();
TagSpec::extract_specs(table, None, &mut specs)
.map_err(|e| TagSpecError::Extract(e.to_string()))?; if let Some(node) = start_node {
let initial_prefix = if table_path.is_empty() {
None
} else {
Some(table_path.join("."))
};
TagSpec::extract_specs(node, initial_prefix.as_deref(), &mut specs)
.map_err(TagSpecError::Extract)?;
}
Ok(TagSpecs(specs)) Ok(TagSpecs(specs))
} }
/// Load specs from a user's project directory /// Load specs from a user's project directory
pub fn load_user_specs(project_root: &Path) -> Result<Self, anyhow::Error> { pub fn load_user_specs(project_root: &Path) -> Result<Self, anyhow::Error> {
// List of config files to try, in priority order
let config_files = ["djls.toml", ".djls.toml", "pyproject.toml"]; let config_files = ["djls.toml", ".djls.toml", "pyproject.toml"];
for &file in &config_files { for &file in &config_files {
let path = project_root.join(file); let path = project_root.join(file);
if path.exists() { if path.exists() {
return match file { let result = match file {
"pyproject.toml" => Self::load_from_toml(&path, &["tool", "djls", "tagspecs"]), "pyproject.toml" => Self::load_from_toml(&path, &["tool", "djls", "tagspecs"]),
_ => Self::load_from_toml(&path, &["tagspecs"]), // Root level for other files _ => Self::load_from_toml(&path, &["tagspecs"]),
}; };
return result.map_err(anyhow::Error::from);
} }
} }
Ok(Self::default()) Ok(Self::default())
@ -72,8 +75,8 @@ impl TagSpecs {
for entry in fs::read_dir(&specs_dir)? { for entry in fs::read_dir(&specs_dir)? {
let entry = entry?; let entry = entry?;
let path = entry.path(); let path = entry.path();
if path.extension().and_then(|ext| ext.to_str()) == Some("toml") { if path.is_file() && path.extension().and_then(|ext| ext.to_str()) == Some("toml") {
let file_specs = Self::load_from_toml(&path, &[])?; let file_specs = Self::load_from_toml(&path, &["tagspecs"])?;
specs.extend(file_specs.0); specs.extend(file_specs.0);
} }
} }
@ -95,80 +98,85 @@ impl TagSpecs {
} }
} }
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TagSpec { pub struct TagSpec {
#[serde(rename = "type")] pub end: Option<EndTag>,
pub tag_type: TagType,
pub closing: Option<String>,
#[serde(default)] #[serde(default)]
pub branches: Option<Vec<String>>, pub intermediates: Option<Vec<String>>,
pub args: Option<Vec<ArgSpec>>,
} }
impl TagSpec { impl TagSpec {
/// Recursive extraction: Check if node is spec, otherwise recurse if table.
fn extract_specs( fn extract_specs(
value: &Value, value: &Value,
prefix: Option<&str>, prefix: Option<&str>, // Path *to* this value node
specs: &mut HashMap<String, TagSpec>, specs: &mut HashMap<String, TagSpec>,
) -> Result<(), String> { ) -> Result<(), String> {
// Try to deserialize as a tag spec first // Check if the current node *itself* represents a TagSpec definition
match TagSpec::deserialize(value.clone()) { // We can be more specific: check if it's a table containing 'end' or 'intermediates'
Ok(tag_spec) => { let mut is_spec_node = false;
let name = prefix.map_or_else(String::new, |p| { if let Some(table) = value.as_table() {
p.split('.').last().unwrap_or(p).to_string() if table.contains_key("end") || table.contains_key("intermediates") {
}); // Looks like a spec, try to deserialize
specs.insert(name, tag_spec); match TagSpec::deserialize(value.clone()) {
Ok(tag_spec) => {
// It is a TagSpec. Get name from prefix.
if let Some(p) = prefix {
if let Some(name) = p.split('.').next_back().filter(|s| !s.is_empty()) {
specs.insert(name.to_string(), tag_spec);
is_spec_node = true;
} else {
return Err(format!(
"Invalid prefix '{}' resulted in empty tag name component.",
p
));
}
} else {
return Err("Cannot determine tag name for TagSpec: prefix is None."
.to_string());
}
}
Err(e) => {
// Looked like a spec but failed to deserialize. This is an error.
return Err(format!(
"Failed to deserialize potential TagSpec at prefix '{}': {}",
prefix.unwrap_or("<root>"),
e
));
}
}
} }
Err(_) => { }
// Not a tag spec, try recursing into any table values
for (key, value) in value.as_table().iter().flat_map(|t| t.iter()) { // If the node was successfully processed as a spec, DO NOT recurse into its fields.
// Otherwise, if it's a table, recurse into its children.
if !is_spec_node {
if let Some(table) = value.as_table() {
for (key, inner_value) in table.iter() {
let new_prefix = match prefix { let new_prefix = match prefix {
None => key.clone(), None => key.clone(),
Some(p) => format!("{}.{}", p, key), Some(p) => format!("{}.{}", p, key),
}; };
Self::extract_specs(value, Some(&new_prefix), specs)?; Self::extract_specs(inner_value, Some(&new_prefix), specs)?;
} }
} }
} }
Ok(()) Ok(())
} }
} }
#[derive(Clone, Debug, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")] pub struct EndTag {
pub enum TagType { pub tag: String,
Container,
Inclusion,
Single,
}
#[derive(Clone, Debug, Deserialize)]
pub struct ArgSpec {
pub name: String,
pub required: bool,
#[serde(default)] #[serde(default)]
pub allowed_values: Option<Vec<String>>, pub optional: bool,
#[serde(default)]
pub is_kwarg: bool,
}
impl ArgSpec {
pub fn is_placeholder(arg: &str) -> bool {
arg.starts_with('{') && arg.ends_with('}')
}
pub fn get_placeholder_name(arg: &str) -> Option<&str> {
if Self::is_placeholder(arg) {
Some(&arg[1..arg.len() - 1])
} else {
None
}
}
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::fs;
#[test] #[test]
fn test_can_load_builtins() -> Result<(), anyhow::Error> { fn test_can_load_builtins() -> Result<(), anyhow::Error> {
@ -176,6 +184,8 @@ mod tests {
assert!(!specs.0.is_empty(), "Should have loaded at least one spec"); assert!(!specs.0.is_empty(), "Should have loaded at least one spec");
assert!(specs.get("if").is_some(), "'if' tag should be present");
for name in specs.0.keys() { for name in specs.0.keys() {
assert!(!name.is_empty(), "Tag name should not be empty"); assert!(!name.is_empty(), "Tag name should not be empty");
} }
@ -190,29 +200,34 @@ mod tests {
"autoescape", "autoescape",
"block", "block",
"comment", "comment",
"cycle",
"debug",
"extends",
"filter", "filter",
"for", "for",
"firstof",
"if", "if",
"include", "ifchanged",
"load",
"now",
"spaceless", "spaceless",
"templatetag",
"url",
"verbatim", "verbatim",
"with", "with",
"cache",
"localize",
"blocktranslate",
"localtime",
"timezone",
]; ];
let missing_tags = [ let missing_tags = [
"csrf_token", "csrf_token",
"ifchanged", "cycle",
"debug",
"extends",
"firstof",
"include",
"load",
"lorem", "lorem",
"now",
"querystring", // 5.1 "querystring", // 5.1
"regroup", "regroup",
"resetcycle", "resetcycle",
"templatetag",
"url",
"widthratio", "widthratio",
]; ];
@ -237,33 +252,44 @@ mod tests {
let root = dir.path(); let root = dir.path();
let pyproject_content = r#" let pyproject_content = r#"
[tool.djls.template.tags.mytag] [tool.djls.tagspecs.mytag]
type = "container" end = { tag = "endmytag" }
closing = "endmytag" intermediates = ["mybranch"]
branches = ["mybranch"]
args = [{ name = "myarg", required = true }] [tool.djls.tagspecs.anothertag]
end = { tag = "endanothertag", optional = true }
"#; "#;
fs::write(root.join("pyproject.toml"), pyproject_content)?; fs::write(root.join("pyproject.toml"), pyproject_content)?;
// Load all (built-in + user)
let specs = TagSpecs::load_all(root)?; let specs = TagSpecs::load_all(root)?;
let if_tag = specs.get("if").expect("if tag should be present"); assert!(specs.get("if").is_some(), "'if' tag should be present");
assert_eq!(if_tag.tag_type, TagType::Container);
let my_tag = specs.get("mytag").expect("mytag should be present"); let my_tag = specs.get("mytag").expect("mytag should be present");
assert_eq!(my_tag.tag_type, TagType::Container); assert_eq!(
assert_eq!(my_tag.closing, Some("endmytag".to_string())); my_tag.end,
Some(EndTag {
tag: "endmytag".to_string(),
optional: false
})
);
assert_eq!(my_tag.intermediates, Some(vec!["mybranch".to_string()]));
let branches = my_tag let another_tag = specs
.branches .get("anothertag")
.as_ref() .expect("anothertag should be present");
.expect("mytag should have branches"); assert_eq!(
assert!(branches.iter().any(|b| b == "mybranch")); another_tag.end,
Some(EndTag {
let args = my_tag.args.as_ref().expect("mytag should have args"); tag: "endanothertag".to_string(),
let arg = &args[0]; optional: true
assert_eq!(arg.name, "myarg"); })
assert!(arg.required); );
assert!(
another_tag.intermediates.is_none(),
"anothertag should have no intermediates"
);
dir.close()?; dir.close()?;
Ok(()) Ok(())
@ -274,36 +300,45 @@ args = [{ name = "myarg", required = true }]
let dir = tempfile::tempdir()?; let dir = tempfile::tempdir()?;
let root = dir.path(); let root = dir.path();
// djls.toml has higher priority
let djls_content = r#" let djls_content = r#"
[mytag1] [tagspecs.mytag1]
type = "container" end = { tag = "endmytag1_from_djls" }
closing = "endmytag1"
"#; "#;
fs::write(root.join("djls.toml"), djls_content)?; fs::write(root.join("djls.toml"), djls_content)?;
// pyproject.toml has lower priority
let pyproject_content = r#" let pyproject_content = r#"
[tool.djls.template.tags] [tool.djls.tagspecs.mytag1]
mytag2.type = "container" end = { tag = "endmytag1_from_pyproject" }
mytag2.closing = "endmytag2"
[tool.djls.tagspecs.mytag2]
end = { tag = "endmytag2_from_pyproject" }
"#; "#;
fs::write(root.join("pyproject.toml"), pyproject_content)?; fs::write(root.join("pyproject.toml"), pyproject_content)?;
let specs = TagSpecs::load_user_specs(root)?; let specs = TagSpecs::load_user_specs(root)?;
assert!(specs.get("mytag1").is_some(), "mytag1 should be present"); let tag1 = specs.get("mytag1").expect("mytag1 should be present");
assert_eq!(tag1.end.as_ref().unwrap().tag, "endmytag1_from_djls");
// Should not find mytag2 because djls.toml was found first
assert!( assert!(
specs.get("mytag2").is_none(), specs.get("mytag2").is_none(),
"mytag2 should not be present" "mytag2 should not be present when djls.toml exists"
); );
// Remove djls.toml, now pyproject.toml should be used
fs::remove_file(root.join("djls.toml"))?; fs::remove_file(root.join("djls.toml"))?;
let specs = TagSpecs::load_user_specs(root)?; let specs = TagSpecs::load_user_specs(root)?;
let tag1 = specs.get("mytag1").expect("mytag1 should be present now");
assert_eq!(tag1.end.as_ref().unwrap().tag, "endmytag1_from_pyproject");
assert!( assert!(
specs.get("mytag1").is_none(), specs.get("mytag2").is_some(),
"mytag1 should not be present" "mytag2 should be present when only pyproject.toml exists"
); );
assert!(specs.get("mytag2").is_some(), "mytag2 should be present");
dir.close()?; dir.close()?;
Ok(()) Ok(())

View file

@ -1,51 +1,28 @@
# TagSpecs # TagSpecs
Tag Specifications (TagSpecs) define how template tags are structured, helping the language server understand template syntax for features like block completion and diagnostics.
## Schema ## Schema
Tag Specifications (TagSpecs) define how tags are parsed and understood. They allow the parser to handle custom tags without hard-coding them. Tag Specifications (TagSpecs) define how tags are parsed and understood. They allow the parser to handle custom tags without hard-coding them.
```toml ```toml
[package.module.path.tag_name] # Path where tag is registered, e.g., django.template.defaulttags [path.to.tag_name] # Path where tag is registered, e.g., django.template.defaulttags
type = "container" | "inclusion" | "single" end = { tag = "end_tag_name", optional = false } # Optional: Defines the closing tag
closing = "closing_tag_name" # For block tags that require a closing tag intermediates = ["intermediate_tag_name", ...] # Optional: Defines intermediate tags (like else, elif)
branches = ["branch_tag_name", ...] # For block tags that support branches
# Arguments can be positional (matched by order) or keyword (matched by name)
args = [
# Positional argument (position inferred from array index)
{ name = "setting", required = true, allowed_values = ["on", "off"] },
# Keyword argument
{ name = "key", required = false, is_kwarg = true }
]
``` ```
The `name` field in args should match the internal name used in Django's node implementation. For example, the `autoescape` tag's argument is stored as `setting` in Django's `AutoEscapeControlNode`. The `end` table defines the closing tag for a block tag.
- `tag`: The name of the closing tag (e.g., "endif").
- `optional`: Whether the closing tag is optional (defaults to `false`).
## Tag Types The `intermediates` array lists tags that can appear between the opening and closing tags (e.g., "else", "elif" for an "if" tag).
- `container`: Tags that wrap content and require a closing tag The tag name itself (e.g., `if`, `for`, `my_custom_tag`) is derived from the last segment of the TOML table path defining the spec.
```django
{% if condition %}content{% endif %}
{% for item in items %}content{% endfor %}
```
- `inclusion`: Tags that include or extend templates.
```django
{% extends "base.html" %}
{% include "partial.html" %}
```
- `single`: Single tags that don't wrap content
```django
{% csrf_token %}
```
## Configuration ## Configuration
- **Built-in TagSpecs**: The parser includes TagSpecs for Django's built-in tags and popular third-party tags. - **Built-in TagSpecs**: The parser includes TagSpecs for Django's built-in tags and popular third-party tags. These are provided by `djls-templates` automatically; users do not need to define them. The examples below show the format, but you only need to create files for your *own* custom tags or to override built-in behavior.
- **User-defined TagSpecs**: Users can expand or override TagSpecs via `pyproject.toml` or `djls.toml` files in their project, allowing custom tags and configurations to be seamlessly integrated. - **User-defined TagSpecs**: Users can expand or override TagSpecs via `pyproject.toml` or `djls.toml` files in their project, allowing custom tags and configurations to be seamlessly integrated.
## Examples ## Examples
@ -53,37 +30,30 @@ The `name` field in args should match the internal name used in Django's node im
### If Tag ### If Tag
```toml ```toml
[django.template.defaulttags.if] [tagspecs.django.template.defaulttags.if]
type = "container" end = { tag = "endif" }
closing = "endif" intermediates = ["elif", "else"]
branches = ["elif", "else"]
args = [{ name = "condition", required = true }]
``` ```
### Include Tag ### For Tag
```toml ```toml
[django.template.defaulttags.includes] [tagspecs.django.template.defaulttags.for]
type = "inclusion" end = { tag = "endfor" }
args = [{ name = "template_name", required = true }] intermediates = ["empty"]
``` ```
### Autoescape Tag ### Autoescape Tag
```toml ```toml
[django.template.defaulttags.autoescape] [tagspecs.django.template.defaulttags.autoescape]
type = "container" end = { tag = "endautoescape" }
closing = "endautoescape"
args = [{ name = "setting", required = true, allowed_values = ["on", "off"] }]
``` ```
### Custom Tag with Kwargs ### Custom Tag
```toml ```toml
[my_module.templatetags.my_tags.my_custom_tag] [tagspecs.my_module.templatetags.my_tags.my_custom_tag]
type = "single" end = { tag = "endmycustomtag", optional = true }
args = [ intermediates = ["myintermediate"]
{ name = "arg1", required = true },
{ name = "kwarg1", required = false, is_kwarg = true }
]
``` ```

View file

@ -1,104 +1,48 @@
[django.template.defaulttags.autoescape] [tagspecs.django.template.defaulttags.autoescape]
args = [{ name = "setting", required = true, allowed_values = ["on", "off"] }] end = { tag = "endautoescape" }
closing = "endautoescape"
type = "container"
[django.template.defaulttags.block] [tagspecs.django.template.defaulttags.block]
closing = "endblock" end = { tag = "endblock" }
type = "container"
[django.template.defaulttags.comment] [tagspecs.django.template.defaulttags.comment]
closing = "endcomment" end = { tag = "endcomment" }
type = "container"
[django.template.defaulttags.cycle] [tagspecs.django.template.defaulttags.filter]
args = [ end = { tag = "endfilter" }
{ name = "cyclevars", required = true },
{ name = "variable_name", required = false, is_kwarg = true },
]
type = "single"
[django.template.defaulttags.debug] [tagspecs.django.template.defaulttags.for]
type = "single" end = { tag = "endfor" }
intermediates = [ "empty" ]
[django.template.defaulttags.extends] [tagspecs.django.template.defaulttags.if]
args = [{ name = "parent_name", required = true }] end = { tag = "endif" }
type = "inclusion" intermediates = [ "elif", "else" ]
[django.template.defaulttags.for] [tagspecs.django.template.defaulttags.ifchanged]
args = [ end = { tag = "endifchanged" }
{ name = "{item}", required = true }, intermediates = [ "else" ]
{ name = "in", required = true },
{ name = "{iterable}", required = true },
]
branches = ["empty"]
closing = "endfor"
type = "container"
[django.template.defaulttags.filter] [tagspecs.django.template.defaulttags.spaceless]
args = [{ name = "filter_expr", required = true }] end = { tag = "endspaceless" }
closing = "endfilter"
type = "container"
[django.template.defaulttags.firstof] [tagspecs.django.template.defaulttags.verbatim]
args = [{ name = "variables", required = true }] end = { tag = "endverbatim" }
type = "single"
[django.template.defaulttags.if] [tagspecs.django.template.defaulttags.with]
args = [{ name = "condition", required = true }] end = { tag = "endwith" }
branches = ["elif", "else"]
closing = "endif"
type = "container"
[django.template.defaulttags.include] [tagspecs.django.templatetags.cache.cache]
args = [ end = { tag = "endcache" }
{ name = "template", required = true },
{ name = "with", required = false, is_kwarg = true },
{ name = "only", required = false, is_kwarg = true },
]
type = "inclusion"
[django.template.defaulttags.load] [tagspecs.django.templatetags.i10n.localize]
args = [{ name = "library", required = true }] end = { tag = "endlocalize" }
type = "single"
[django.template.defaulttags.now] [tagspecs.django.templatetags.i18n.blocktranslate]
args = [{ name = "format_string", required = true }] end = { tag = "endblocktranslate" }
type = "single" intermediates = [ "plural" ]
[django.template.defaulttags.spaceless] [tagspecs.django.templatetags.tz.localtime]
closing = "endspaceless" end = { tag = "endlocaltime" }
type = "container"
[django.template.defaulttags.templatetag] [tagspecs.django.templatetags.tz.timezone]
type = "single" end = { tag = "endtimezone" }
[[django.template.defaulttags.templatetag.args]]
allowed_values = [
"openblock",
"closeblock",
"openvariable",
"closevariable",
"openbrace",
"closebrace",
"opencomment",
"closecomment",
]
name = "tagtype"
required = true
[django.template.defaulttags.url]
args = [
{ name = "view_name", required = true },
{ name = "asvar", required = false, is_kwarg = true },
]
type = "single"
[django.template.defaulttags.verbatim]
closing = "endverbatim"
type = "container"
[django.template.defaulttags.with]
args = [{ name = "extra_context", required = true }]
closing = "endwith"
type = "container"