diff --git a/crates/djls-templates/src/templatetags/specs.rs b/crates/djls-templates/src/templatetags/specs.rs index 3f70bbc..ce8344f 100644 --- a/crates/djls-templates/src/templatetags/specs.rs +++ b/crates/djls-templates/src/templatetags/specs.rs @@ -4,6 +4,7 @@ use std::path::Path; use anyhow::Result; use serde::Deserialize; +use serde::Deserializer; use serde::Serialize; use thiserror::Error; use toml::Value; @@ -41,8 +42,8 @@ impl TagSpecs { #[must_use] pub fn find_opener_for_closer(&self, closer: &str) -> Option { for (tag_name, spec) in &self.0 { - if let Some(end_spec) = &spec.end { - if end_spec.tag == closer { + if let Some(end_spec) = &spec.end_tag { + if end_spec.name == closer { return Some(tag_name.clone()); } } @@ -54,8 +55,8 @@ impl TagSpecs { #[must_use] pub fn get_end_spec_for_closer(&self, closer: &str) -> Option<&EndTag> { for spec in self.0.values() { - if let Some(end_spec) = &spec.end { - if end_spec.tag == closer { + if let Some(end_spec) = &spec.end_tag { + if end_spec.name == closer { return Some(end_spec); } } @@ -67,24 +68,28 @@ impl TagSpecs { pub fn is_opener(&self, name: &str) -> bool { self.0 .get(name) - .and_then(|spec| spec.end.as_ref()) + .and_then(|spec| spec.end_tag.as_ref()) .is_some() } #[must_use] pub fn is_intermediate(&self, name: &str) -> bool { self.0.values().any(|spec| { - spec.intermediates + spec.intermediate_tags .as_ref() - .is_some_and(|intermediates| intermediates.contains(&name.to_string())) + .is_some_and(|intermediate_tags| { + intermediate_tags.iter().any(|tag| tag.name == name) + }) }) } #[must_use] pub fn is_closer(&self, name: &str) -> bool { - self.0 - .values() - .any(|spec| spec.end.as_ref().is_some_and(|end_tag| end_tag.tag == name)) + self.0.values().any(|spec| { + spec.end_tag + .as_ref() + .is_some_and(|end_tag| end_tag.name == name) + }) } /// Get the parent tags that can contain this intermediate tag @@ -92,8 +97,8 @@ impl TagSpecs { pub fn get_parent_tags_for_intermediate(&self, intermediate: &str) -> Vec { let mut parents = Vec::new(); for (opener_name, spec) in &self.0 { - if let Some(intermediates) = &spec.intermediates { - if intermediates.contains(&intermediate.to_string()) { + if let Some(intermediate_tags) = &spec.intermediate_tags { + if intermediate_tags.iter().any(|tag| tag.name == intermediate) { parents.push(opener_name.clone()); } } @@ -195,22 +200,50 @@ impl TagSpecs { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct TagSpec { - pub end: Option, - #[serde(default)] - pub intermediates: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(alias = "end")] + pub end_tag: Option, + #[serde(default, alias = "intermediates")] + pub intermediate_tags: Option>, #[serde(default)] pub args: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct EndTag { - pub tag: String, + #[serde(alias = "tag")] + pub name: String, #[serde(default)] pub optional: bool, #[serde(default)] pub args: Option, } +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct IntermediateTag { + pub name: String, +} + +impl<'de> Deserialize<'de> for IntermediateTag { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum IntermediateTagHelper { + String(String), + Object { name: String }, + } + + match IntermediateTagHelper::deserialize(deserializer)? { + IntermediateTagHelper::String(s) => Ok(IntermediateTag { name: s }), + IntermediateTagHelper::Object { name } => Ok(IntermediateTag { name }), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ArgSpec { #[serde(default)] @@ -227,12 +260,40 @@ impl TagSpec { prefix: Option<&str>, // Path *to* this value node specs: &mut HashMap, ) -> Result<(), String> { - // Check if the current node *itself* represents a TagSpec definition - // We can be more specific: check if it's a table containing 'end', 'intermediates', or 'args' + // Check if this is an array of TagSpec entries (new format) + if let Some(array) = value.as_array() { + for item in array { + if let Some(table) = item.as_table() { + // Check if it has a 'name' field (new format) + if table.contains_key("name") { + match TagSpec::deserialize(item.clone()) { + Ok(mut tag_spec) => { + if let Some(name) = tag_spec.name.take() { + specs.insert(name, tag_spec); + } else { + return Err( + "TagSpec has 'name' field but it's None".to_string() + ); + } + } + Err(e) => { + return Err(format!("Failed to deserialize TagSpec in array: {e}")); + } + } + } + } + } + return Ok(()); + } + + // Check if the current node *itself* represents a TagSpec definition (old format) + // We can be more specific: check if it's a table containing 'end'/'end_tag', 'intermediates'/'intermediate_tags', or 'args' let mut is_spec_node = false; if let Some(table) = value.as_table() { if table.contains_key("end") + || table.contains_key("end_tag") || table.contains_key("intermediates") + || table.contains_key("intermediate_tags") || table.contains_key("args") { // Looks like a spec, try to deserialize @@ -387,29 +448,34 @@ end = { tag = "endanothertag", optional = true } let my_tag = specs.get("mytag").expect("mytag should be present"); assert_eq!( - my_tag.end, + my_tag.end_tag, Some(EndTag { - tag: "endmytag".to_string(), + name: "endmytag".to_string(), optional: false, args: None, }) ); - assert_eq!(my_tag.intermediates, Some(vec!["mybranch".to_string()])); + assert_eq!( + my_tag.intermediate_tags, + Some(vec![IntermediateTag { + name: "mybranch".to_string() + }]) + ); let another_tag = specs .get("anothertag") .expect("anothertag should be present"); assert_eq!( - another_tag.end, + another_tag.end_tag, Some(EndTag { - tag: "endanothertag".to_string(), + name: "endanothertag".to_string(), optional: true, args: None, }) ); assert!( - another_tag.intermediates.is_none(), - "anothertag should have no intermediates" + another_tag.intermediate_tags.is_none(), + "anothertag should have no intermediate_tags" ); dir.close()?; @@ -441,7 +507,7 @@ end = { tag = "endmytag2_from_pyproject" } let specs = TagSpecs::load_user_specs(root)?; let tag1 = specs.get("mytag1").expect("mytag1 should be present"); - assert_eq!(tag1.end.as_ref().unwrap().tag, "endmytag1_from_djls"); + assert_eq!(tag1.end_tag.as_ref().unwrap().name, "endmytag1_from_djls"); // Should not find mytag2 because djls.toml was found first assert!( @@ -454,7 +520,10 @@ end = { tag = "endmytag2_from_pyproject" } 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_eq!( + tag1.end_tag.as_ref().unwrap().name, + "endmytag1_from_pyproject" + ); assert!( specs.get("mytag2").is_some(), diff --git a/crates/djls-templates/tagspecs/README.md b/crates/djls-templates/tagspecs/README.md index 0baad17..6b496bd 100644 --- a/crates/djls-templates/tagspecs/README.md +++ b/crates/djls-templates/tagspecs/README.md @@ -7,18 +7,26 @@ Tag Specifications (TagSpecs) define how template tags are structured, helping t Tag Specifications (TagSpecs) define how tags are parsed and understood. They allow the parser to handle custom tags without hard-coding them. ```toml -[path.to.tag_name] # Path where tag is registered, e.g., django.template.defaulttags -end = { tag = "end_tag_name", optional = false } # Optional: Defines the closing tag -intermediates = ["intermediate_tag_name", ...] # Optional: Defines intermediate tags (like else, elif) +[[path.to.module]] # Array of tables for the module, e.g., tagspecs.django.template.defaulttags +name = "tag_name" # The tag name (e.g., "if", "for", "my_custom_tag") +end_tag = { name = "end_tag_name", optional = false } # Optional: Defines the closing tag +intermediate_tags = [{ name = "tag_name" }, ...] # Optional: Defines intermediate tags +args = { min = 1, max = 3 } # Optional: Argument constraints ``` -The `end` table defines the closing tag for a block tag. -- `tag`: The name of the closing tag (e.g., "endif"). +The `name` field specifies the tag name (e.g., "if", "for", "my_custom_tag"). + +The `end_tag` table defines the closing tag for a block tag. +- `name`: The name of the closing tag (e.g., "endif"). - `optional`: Whether the closing tag is optional (defaults to `false`). +- `args`: Optional argument constraints for the end tag. -The `intermediates` array lists tags that can appear between the opening and closing tags (e.g., "else", "elif" for an "if" tag). +The `intermediate_tags` array lists tags that can appear between the opening and closing tags. Each intermediate tag is an object with: +- `name`: The name of the intermediate tag (e.g., "else", "elif"). -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. +The `args` table defines argument constraints: +- `min`: Minimum number of arguments required. +- `max`: Maximum number of arguments allowed. ## Configuration @@ -30,30 +38,49 @@ The tag name itself (e.g., `if`, `for`, `my_custom_tag`) is derived from the las ### If Tag ```toml -[tagspecs.django.template.defaulttags.if] -end = { tag = "endif" } -intermediates = ["elif", "else"] +[[tagspecs.django.template.defaulttags]] +name = "if" +end_tag = { name = "endif" } +intermediate_tags = [{ name = "elif" }, { name = "else" }] +args = { min = 1 } # condition ``` ### For Tag ```toml -[tagspecs.django.template.defaulttags.for] -end = { tag = "endfor" } -intermediates = ["empty"] +[[tagspecs.django.template.defaulttags]] +name = "for" +end_tag = { name = "endfor" } +intermediate_tags = [{ name = "empty" }] +args = { min = 3 } # item in items (at minimum) ``` ### Autoescape Tag ```toml -[tagspecs.django.template.defaulttags.autoescape] -end = { tag = "endautoescape" } +[[tagspecs.django.template.defaulttags]] +name = "autoescape" +end_tag = { name = "endautoescape" } +args = { min = 1, max = 1 } # on or off ``` ### Custom Tag ```toml -[tagspecs.my_module.templatetags.my_tags.my_custom_tag] -end = { tag = "endmycustomtag", optional = true } -intermediates = ["myintermediate"] +[[tagspecs.my_module.templatetags.my_tags]] +name = "my_custom_tag" +end_tag = { name = "endmycustomtag", optional = true } +intermediate_tags = [{ name = "myintermediate" }] +``` + +### Standalone Tags (no end tag) + +```toml +[[tagspecs.django.template.defaulttags]] +name = "csrf_token" +args = { min = 0, max = 0 } # no arguments + +[[tagspecs.django.template.defaulttags]] +name = "load" +args = { min = 1 } # library name(s) ``` diff --git a/crates/djls-templates/tagspecs/django.toml b/crates/djls-templates/tagspecs/django.toml index dcf6bcc..0a8adeb 100644 --- a/crates/djls-templates/tagspecs/django.toml +++ b/crates/djls-templates/tagspecs/django.toml @@ -1,79 +1,101 @@ -[tagspecs.django.template.defaulttags.autoescape] -end = { tag = "endautoescape" } +[[tagspecs.django.template.defaulttags]] +name = "autoescape" +end_tag = { name = "endautoescape" } args = { min = 1, max = 1 } # on or off -[tagspecs.django.template.defaulttags.block] -end = { tag = "endblock", args = { min = 0, max = 1 } } +[[tagspecs.django.template.defaulttags]] +name = "block" +end_tag = { name = "endblock", args = { min = 0, max = 1 } } args = { min = 1, max = 1 } # block name -[tagspecs.django.template.defaulttags.comment] -end = { tag = "endcomment" } +[[tagspecs.django.template.defaulttags]] +name = "comment" +end_tag = { name = "endcomment" } -[tagspecs.django.template.defaulttags.filter] -end = { tag = "endfilter" } -args = { min = 1 } # filter expression - -[tagspecs.django.template.defaulttags.for] -end = { tag = "endfor" } -intermediates = [ "empty" ] -args = { min = 3 } # item in items (at minimum) - -[tagspecs.django.template.defaulttags.if] -end = { tag = "endif" } -intermediates = [ "elif", "else" ] -args = { min = 1 } # condition - -[tagspecs.django.template.defaulttags.ifchanged] -end = { tag = "endifchanged" } -intermediates = [ "else" ] - -[tagspecs.django.template.defaulttags.spaceless] -end = { tag = "endspaceless" } - -[tagspecs.django.template.defaulttags.verbatim] -end = { tag = "endverbatim" } - -[tagspecs.django.template.defaulttags.with] -end = { tag = "endwith" } -args = { min = 1 } # variable assignment(s) - -[tagspecs.django.templatetags.cache.cache] -end = { tag = "endcache" } - -[tagspecs.django.templatetags.i10n.localize] -end = { tag = "endlocalize" } - -[tagspecs.django.templatetags.i18n.blocktranslate] -end = { tag = "endblocktranslate" } -intermediates = [ "plural" ] - -[tagspecs.django.templatetags.tz.localtime] -end = { tag = "endlocaltime" } - -[tagspecs.django.templatetags.tz.timezone] -end = { tag = "endtimezone" } - -# Standalone tags (no end tag) -[tagspecs.django.template.defaulttags.extends] -args = { min = 1, max = 1 } # template name - -[tagspecs.django.template.defaulttags.include] -args = { min = 1 } # template name [with context] - -[tagspecs.django.template.defaulttags.load] -args = { min = 1 } # library name(s) - -[tagspecs.django.template.defaulttags.url] -args = { min = 1 } # view name [args...] - -[tagspecs.django.template.defaulttags.cycle] -args = { min = 1 } # values to cycle through - -[tagspecs.django.template.defaulttags.csrf_token] +[[tagspecs.django.template.defaulttags]] +name = "csrf_token" args = { min = 0, max = 0 } # no arguments -[tagspecs.django.template.defaulttags.now] +[[tagspecs.django.template.defaulttags]] +name = "cycle" +args = { min = 1 } # values to cycle through + +[[tagspecs.django.template.defaulttags]] +name = "extends" +args = { min = 1, max = 1 } # template name + +[[tagspecs.django.template.defaulttags]] +name = "filter" +end_tag = { name = "endfilter" } +args = { min = 1 } # filter expression + +[[tagspecs.django.template.defaulttags]] +name = "for" +end_tag = { name = "endfor" } +intermediate_tags = [ { name = "empty" } ] +args = { min = 3 } # item in items (at minimum) + +[[tagspecs.django.template.defaulttags]] +name = "include" +args = { min = 1 } # template name [with context] + +[[tagspecs.django.template.defaulttags]] +name = "if" +end_tag = { name = "endif" } +intermediate_tags = [ { name = "elif" }, { name = "else" } ] +args = { min = 1 } # condition + +[[tagspecs.django.template.defaulttags]] +name = "ifchanged" +end_tag = { name = "endifchanged" } +intermediate_tags = [ { name = "else" } ] + +[[tagspecs.django.template.defaulttags]] +name = "load" +args = { min = 1 } # library name(s) + +[[tagspecs.django.template.defaulttags]] +name = "now" args = { min = 1, max = 1 } # format string -[tagspecs.django.template.defaulttags.templatetag] +[[tagspecs.django.template.defaulttags]] +name = "spaceless" +end_tag = { name = "endspaceless" } + +[[tagspecs.django.template.defaulttags]] +name = "templatetag" args = { min = 1, max = 1 } # special character name + +[[tagspecs.django.template.defaulttags]] +name = "url" +args = { min = 1 } # view name [args...] + +[[tagspecs.django.template.defaulttags]] +name = "verbatim" +end_tag = { name = "endverbatim" } + +[[tagspecs.django.template.defaulttags]] +name = "with" +end_tag = { name = "endwith" } +args = { min = 1 } # variable assignment(s) + +[[tagspecs.django.templatetags.cache]] +name = "cache" +end_tag = { name = "endcache" } + +[[tagspecs.django.templatetags.i10n]] +name = "localize" +end_tag = { name = "endlocalize" } + +[[tagspecs.django.templatetags.i18n]] +name = "blocktranslate" +end_tag = { name = "endblocktranslate" } +intermediate_tags = [ { name = "plural" } ] + +[[tagspecs.django.templatetags.tz]] +name = "localtime" +end_tag = { name = "endlocaltime" } + +[[tagspecs.django.templatetags.tz]] +name = "timezone" +end_tag = { name = "endtimezone" }