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 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<String> {
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<String> {
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<EndTag>,
#[serde(default)]
pub intermediates: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(alias = "end")]
pub end_tag: Option<EndTag>,
#[serde(default, alias = "intermediates")]
pub intermediate_tags: Option<Vec<IntermediateTag>>,
#[serde(default)]
pub args: Option<ArgSpec>,
}
#[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<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)]
pub struct ArgSpec {
#[serde(default)]
@ -227,12 +260,40 @@ impl TagSpec {
prefix: Option<&str>, // Path *to* this value node
specs: &mut HashMap<String, TagSpec>,
) -> 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(),

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.
```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)
```

View file

@ -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" }