Refactor tagspecs to use array of tables and consistent fields (#202)

This commit is contained in:
Josh Thomas 2025-09-08 20:20:28 -05:00 committed by GitHub
parent 6a4f1668e3
commit 095fb0a714
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 232 additions and 114 deletions

View file

@ -4,6 +4,7 @@ use std::path::Path;
use anyhow::Result; use anyhow::Result;
use serde::Deserialize; use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize; use serde::Serialize;
use thiserror::Error; use thiserror::Error;
use toml::Value; use toml::Value;
@ -41,8 +42,8 @@ impl TagSpecs {
#[must_use] #[must_use]
pub fn find_opener_for_closer(&self, closer: &str) -> Option<String> { pub fn find_opener_for_closer(&self, closer: &str) -> Option<String> {
for (tag_name, spec) in &self.0 { for (tag_name, spec) in &self.0 {
if let Some(end_spec) = &spec.end { if let Some(end_spec) = &spec.end_tag {
if end_spec.tag == closer { if end_spec.name == closer {
return Some(tag_name.clone()); return Some(tag_name.clone());
} }
} }
@ -54,8 +55,8 @@ impl TagSpecs {
#[must_use] #[must_use]
pub fn get_end_spec_for_closer(&self, closer: &str) -> Option<&EndTag> { pub fn get_end_spec_for_closer(&self, closer: &str) -> Option<&EndTag> {
for spec in self.0.values() { for spec in self.0.values() {
if let Some(end_spec) = &spec.end { if let Some(end_spec) = &spec.end_tag {
if end_spec.tag == closer { if end_spec.name == closer {
return Some(end_spec); return Some(end_spec);
} }
} }
@ -67,24 +68,28 @@ impl TagSpecs {
pub fn is_opener(&self, name: &str) -> bool { pub fn is_opener(&self, name: &str) -> bool {
self.0 self.0
.get(name) .get(name)
.and_then(|spec| spec.end.as_ref()) .and_then(|spec| spec.end_tag.as_ref())
.is_some() .is_some()
} }
#[must_use] #[must_use]
pub fn is_intermediate(&self, name: &str) -> bool { pub fn is_intermediate(&self, name: &str) -> bool {
self.0.values().any(|spec| { self.0.values().any(|spec| {
spec.intermediates spec.intermediate_tags
.as_ref() .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] #[must_use]
pub fn is_closer(&self, name: &str) -> bool { pub fn is_closer(&self, name: &str) -> bool {
self.0 self.0.values().any(|spec| {
.values() spec.end_tag
.any(|spec| spec.end.as_ref().is_some_and(|end_tag| end_tag.tag == name)) .as_ref()
.is_some_and(|end_tag| end_tag.name == name)
})
} }
/// Get the parent tags that can contain this intermediate tag /// 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<String> { pub fn get_parent_tags_for_intermediate(&self, intermediate: &str) -> Vec<String> {
let mut parents = Vec::new(); let mut parents = Vec::new();
for (opener_name, spec) in &self.0 { for (opener_name, spec) in &self.0 {
if let Some(intermediates) = &spec.intermediates { if let Some(intermediate_tags) = &spec.intermediate_tags {
if intermediates.contains(&intermediate.to_string()) { if intermediate_tags.iter().any(|tag| tag.name == intermediate) {
parents.push(opener_name.clone()); parents.push(opener_name.clone());
} }
} }
@ -195,22 +200,50 @@ impl TagSpecs {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TagSpec { pub struct TagSpec {
pub end: Option<EndTag>, #[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)] pub name: Option<String>,
pub intermediates: Option<Vec<String>>, #[serde(alias = "end")]
pub end_tag: Option<EndTag>,
#[serde(default, alias = "intermediates")]
pub intermediate_tags: Option<Vec<IntermediateTag>>,
#[serde(default)] #[serde(default)]
pub args: Option<ArgSpec>, pub args: Option<ArgSpec>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct EndTag { pub struct EndTag {
pub tag: String, #[serde(alias = "tag")]
pub name: String,
#[serde(default)] #[serde(default)]
pub optional: bool, pub optional: bool,
#[serde(default)] #[serde(default)]
pub args: Option<ArgSpec>, pub args: Option<ArgSpec>,
} }
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct IntermediateTag {
pub name: String,
}
impl<'de> Deserialize<'de> for IntermediateTag {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ArgSpec { pub struct ArgSpec {
#[serde(default)] #[serde(default)]
@ -227,12 +260,40 @@ impl TagSpec {
prefix: Option<&str>, // Path *to* this value node prefix: Option<&str>, // Path *to* this value node
specs: &mut HashMap<String, TagSpec>, specs: &mut HashMap<String, TagSpec>,
) -> Result<(), String> { ) -> Result<(), String> {
// Check if the current node *itself* represents a TagSpec definition // Check if this is an array of TagSpec entries (new format)
// We can be more specific: check if it's a table containing 'end', 'intermediates', or 'args' 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; let mut is_spec_node = false;
if let Some(table) = value.as_table() { if let Some(table) = value.as_table() {
if table.contains_key("end") if table.contains_key("end")
|| table.contains_key("end_tag")
|| table.contains_key("intermediates") || table.contains_key("intermediates")
|| table.contains_key("intermediate_tags")
|| table.contains_key("args") || table.contains_key("args")
{ {
// Looks like a spec, try to deserialize // 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"); let my_tag = specs.get("mytag").expect("mytag should be present");
assert_eq!( assert_eq!(
my_tag.end, my_tag.end_tag,
Some(EndTag { Some(EndTag {
tag: "endmytag".to_string(), name: "endmytag".to_string(),
optional: false, optional: false,
args: None, 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 let another_tag = specs
.get("anothertag") .get("anothertag")
.expect("anothertag should be present"); .expect("anothertag should be present");
assert_eq!( assert_eq!(
another_tag.end, another_tag.end_tag,
Some(EndTag { Some(EndTag {
tag: "endanothertag".to_string(), name: "endanothertag".to_string(),
optional: true, optional: true,
args: None, args: None,
}) })
); );
assert!( assert!(
another_tag.intermediates.is_none(), another_tag.intermediate_tags.is_none(),
"anothertag should have no intermediates" "anothertag should have no intermediate_tags"
); );
dir.close()?; dir.close()?;
@ -441,7 +507,7 @@ end = { tag = "endmytag2_from_pyproject" }
let specs = TagSpecs::load_user_specs(root)?; let specs = TagSpecs::load_user_specs(root)?;
let tag1 = specs.get("mytag1").expect("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"); assert_eq!(tag1.end_tag.as_ref().unwrap().name, "endmytag1_from_djls");
// Should not find mytag2 because djls.toml was found first // Should not find mytag2 because djls.toml was found first
assert!( assert!(
@ -454,7 +520,10 @@ end = { tag = "endmytag2_from_pyproject" }
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"); 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!( assert!(
specs.get("mytag2").is_some(), specs.get("mytag2").is_some(),

View file

@ -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. Tag Specifications (TagSpecs) define how tags are parsed and understood. They allow the parser to handle custom tags without hard-coding them.
```toml ```toml
[path.to.tag_name] # Path where tag is registered, e.g., django.template.defaulttags [[path.to.module]] # Array of tables for the module, e.g., tagspecs.django.template.defaulttags
end = { tag = "end_tag_name", optional = false } # Optional: Defines the closing tag name = "tag_name" # The tag name (e.g., "if", "for", "my_custom_tag")
intermediates = ["intermediate_tag_name", ...] # Optional: Defines intermediate tags (like else, elif) 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. The `name` field specifies the tag name (e.g., "if", "for", "my_custom_tag").
- `tag`: The name of the closing tag (e.g., "endif").
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`). - `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 ## Configuration
@ -30,30 +38,49 @@ The tag name itself (e.g., `if`, `for`, `my_custom_tag`) is derived from the las
### If Tag ### If Tag
```toml ```toml
[tagspecs.django.template.defaulttags.if] [[tagspecs.django.template.defaulttags]]
end = { tag = "endif" } name = "if"
intermediates = ["elif", "else"] end_tag = { name = "endif" }
intermediate_tags = [{ name = "elif" }, { name = "else" }]
args = { min = 1 } # condition
``` ```
### For Tag ### For Tag
```toml ```toml
[tagspecs.django.template.defaulttags.for] [[tagspecs.django.template.defaulttags]]
end = { tag = "endfor" } name = "for"
intermediates = ["empty"] end_tag = { name = "endfor" }
intermediate_tags = [{ name = "empty" }]
args = { min = 3 } # item in items (at minimum)
``` ```
### Autoescape Tag ### Autoescape Tag
```toml ```toml
[tagspecs.django.template.defaulttags.autoescape] [[tagspecs.django.template.defaulttags]]
end = { tag = "endautoescape" } name = "autoescape"
end_tag = { name = "endautoescape" }
args = { min = 1, max = 1 } # on or off
``` ```
### Custom Tag ### Custom Tag
```toml ```toml
[tagspecs.my_module.templatetags.my_tags.my_custom_tag] [[tagspecs.my_module.templatetags.my_tags]]
end = { tag = "endmycustomtag", optional = true } name = "my_custom_tag"
intermediates = ["myintermediate"] 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)
``` ```

View file

@ -1,79 +1,101 @@
[tagspecs.django.template.defaulttags.autoescape] [[tagspecs.django.template.defaulttags]]
end = { tag = "endautoescape" } name = "autoescape"
end_tag = { name = "endautoescape" }
args = { min = 1, max = 1 } # on or off args = { min = 1, max = 1 } # on or off
[tagspecs.django.template.defaulttags.block] [[tagspecs.django.template.defaulttags]]
end = { tag = "endblock", args = { min = 0, max = 1 } } name = "block"
end_tag = { name = "endblock", args = { min = 0, max = 1 } }
args = { min = 1, max = 1 } # block name args = { min = 1, max = 1 } # block name
[tagspecs.django.template.defaulttags.comment] [[tagspecs.django.template.defaulttags]]
end = { tag = "endcomment" } name = "comment"
end_tag = { name = "endcomment" }
[tagspecs.django.template.defaulttags.filter] [[tagspecs.django.template.defaulttags]]
end = { tag = "endfilter" } name = "csrf_token"
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]
args = { min = 0, max = 0 } # no arguments 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 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 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" }