migrate to TagSpecs v0.5.0 spec

This commit is contained in:
Josh Thomas 2025-11-03 23:08:53 -06:00
parent 179aea71d9
commit 3a876602b6
6 changed files with 543 additions and 259 deletions

2
Cargo.lock generated
View file

@ -534,6 +534,7 @@ dependencies = [
"config",
"directories",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.17",
"toml",
@ -586,6 +587,7 @@ dependencies = [
"rustc-hash",
"salsa",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.17",
"tracing",

View file

@ -9,6 +9,7 @@ camino = { workspace = true }
config = { workspace = true }
directories = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
toml = { workspace = true }

View file

@ -17,12 +17,16 @@ use thiserror::Error;
pub use crate::diagnostics::DiagnosticSeverity;
pub use crate::diagnostics::DiagnosticsConfig;
pub use crate::tagspecs::ArgKindDef;
pub use crate::tagspecs::ArgTypeDef;
pub use crate::tagspecs::EndTagDef;
pub use crate::tagspecs::IntermediateTagDef;
pub use crate::tagspecs::SimpleArgTypeDef;
pub use crate::tagspecs::PositionDef;
pub use crate::tagspecs::TagArgDef;
pub use crate::tagspecs::TagDef;
pub use crate::tagspecs::TagLibraryDef;
pub use crate::tagspecs::TagSpecDef;
pub use crate::tagspecs::TagTypeDef;
pub(crate) fn project_dirs() -> Option<ProjectDirs> {
ProjectDirs::from("", "", "djls")
@ -67,7 +71,7 @@ pub struct Settings {
#[serde(default)]
pythonpath: Vec<String>,
#[serde(default)]
tagspecs: Vec<TagSpecDef>,
tagspecs: TagSpecDef,
#[serde(default)]
diagnostics: DiagnosticsConfig,
}
@ -88,7 +92,7 @@ impl Settings {
if !overrides.pythonpath.is_empty() {
settings.pythonpath = overrides.pythonpath;
}
if !overrides.tagspecs.is_empty() {
if !overrides.tagspecs.libraries.is_empty() {
settings.tagspecs = overrides.tagspecs;
}
// For diagnostics, override if the config is non-default
@ -164,7 +168,7 @@ impl Settings {
}
#[must_use]
pub fn tagspecs(&self) -> &[TagSpecDef] {
pub fn tagspecs(&self) -> &TagSpecDef {
&self.tagspecs
}
@ -197,7 +201,7 @@ mod tests {
venv_path: None,
django_settings_module: None,
pythonpath: vec![],
tagspecs: vec![],
tagspecs: TagSpecDef::default(),
diagnostics: DiagnosticsConfig::default(),
}
);
@ -505,43 +509,68 @@ T100 = "hint"
mod tagspecs {
use super::*;
use crate::tagspecs::ArgTypeDef;
use crate::tagspecs::SimpleArgTypeDef;
use crate::tagspecs::ArgKindDef;
#[test]
fn test_load_tagspecs_from_djls_toml() {
let dir = tempdir().unwrap();
let content = r#"
[[tagspecs]]
name = "mytag"
module = "myapp.templatetags.custom"
end_tag = { name = "endmytag" }
[tagspecs]
version = "0.5.0"
[[tagspecs]]
name = "for"
[[tagspecs.libraries]]
module = "myapp.templatetags.custom"
[[tagspecs.libraries.tags]]
name = "mytag"
type = "block"
[tagspecs.libraries.tags.end]
name = "endmytag"
[[tagspecs.libraries]]
module = "django.template.defaulttags"
end_tag = { name = "endfor" }
intermediate_tags = [{ name = "empty" }]
args = [
{ name = "item", type = "variable" },
{ name = "in", type = "literal" },
{ name = "items", type = "variable" }
]
[[tagspecs.libraries.tags]]
name = "for"
type = "block"
[tagspecs.libraries.tags.end]
name = "endfor"
[[tagspecs.libraries.tags.intermediates]]
name = "empty"
[[tagspecs.libraries.tags.args]]
name = "item"
kind = "variable"
[[tagspecs.libraries.tags.args]]
name = "in"
kind = "literal"
[[tagspecs.libraries.tags.args]]
name = "items"
kind = "variable"
"#;
fs::write(dir.path().join("djls.toml"), content).unwrap();
let settings = Settings::new(Utf8Path::from_path(dir.path()).unwrap(), None).unwrap();
assert_eq!(settings.tagspecs().len(), 2);
assert_eq!(settings.tagspecs().libraries.len(), 2);
let mytag = &settings.tagspecs()[0];
let lib0 = &settings.tagspecs().libraries[0];
assert_eq!(lib0.module, "myapp.templatetags.custom");
assert_eq!(lib0.tags.len(), 1);
let mytag = &lib0.tags[0];
assert_eq!(mytag.name, "mytag");
assert_eq!(mytag.module, "myapp.templatetags.custom");
assert_eq!(mytag.end_tag.as_ref().unwrap().name, "endmytag");
assert_eq!(mytag.end.as_ref().unwrap().name, "endmytag");
let for_tag = &settings.tagspecs()[1];
let lib1 = &settings.tagspecs().libraries[1];
assert_eq!(lib1.module, "django.template.defaulttags");
assert_eq!(lib1.tags.len(), 1);
let for_tag = &lib1.tags[0];
assert_eq!(for_tag.name, "for");
assert_eq!(for_tag.module, "django.template.defaulttags");
assert_eq!(for_tag.intermediate_tags.len(), 1);
assert_eq!(for_tag.intermediates.len(), 1);
assert_eq!(for_tag.args.len(), 3);
}
@ -552,22 +581,37 @@ args = [
[tool.djls]
debug = true
[[tool.djls.tagspecs]]
name = "cache"
[tool.djls.tagspecs]
version = "0.5.0"
[[tool.djls.tagspecs.libraries]]
module = "django.templatetags.cache"
end_tag = { name = "endcache", optional = false }
args = [
{ name = "expire_time", type = "variable" },
{ name = "fragment_name", type = "string" }
]
[[tool.djls.tagspecs.libraries.tags]]
name = "cache"
type = "block"
[tool.djls.tagspecs.libraries.tags.end]
name = "endcache"
required = true
[[tool.djls.tagspecs.libraries.tags.args]]
name = "expire_time"
kind = "variable"
[[tool.djls.tagspecs.libraries.tags.args]]
name = "fragment_name"
kind = "variable"
"#;
fs::write(dir.path().join("pyproject.toml"), content).unwrap();
let settings = Settings::new(Utf8Path::from_path(dir.path()).unwrap(), None).unwrap();
assert_eq!(settings.tagspecs().len(), 1);
let cache = &settings.tagspecs()[0];
assert_eq!(settings.tagspecs().libraries.len(), 1);
let lib = &settings.tagspecs().libraries[0];
assert_eq!(lib.module, "django.templatetags.cache");
assert_eq!(lib.tags.len(), 1);
let cache = &lib.tags[0];
assert_eq!(cache.name, "cache");
assert_eq!(cache.module, "django.templatetags.cache");
assert_eq!(cache.args.len(), 2);
}
@ -575,34 +619,42 @@ args = [
fn test_arg_types() {
let dir = tempdir().unwrap();
let content = r#"
[[tagspecs]]
name = "test"
[[tagspecs.libraries]]
module = "test.module"
args = [
{ name = "simple", type = "variable" },
{ name = "choice", type = { choice = ["on", "off"] } },
{ name = "optional", required = false, type = "string" }
]
[[tagspecs.libraries.tags]]
name = "test"
type = "standalone"
[[tagspecs.libraries.tags.args]]
name = "simple"
kind = "variable"
[[tagspecs.libraries.tags.args]]
name = "choice"
kind = "choice"
[tagspecs.libraries.tags.args.extra]
choices = ["on", "off"]
[[tagspecs.libraries.tags.args]]
name = "optional"
required = false
kind = "variable"
"#;
fs::write(dir.path().join("djls.toml"), content).unwrap();
let settings = Settings::new(Utf8Path::from_path(dir.path()).unwrap(), None).unwrap();
let test = &settings.tagspecs()[0];
let lib = &settings.tagspecs().libraries[0];
let test = &lib.tags[0];
assert_eq!(test.args.len(), 3);
// Check simple type
assert!(matches!(
test.args[0].arg_type,
ArgTypeDef::Simple(SimpleArgTypeDef::Variable)
));
// Check choice type
if let ArgTypeDef::Choice { ref choice } = test.args[1].arg_type {
assert_eq!(choice, &vec!["on".to_string(), "off".to_string()]);
} else {
panic!("Expected choice type");
}
// Check simple kind
assert!(matches!(test.args[0].kind, ArgKindDef::Variable));
// Check choice kind
assert!(matches!(test.args[1].kind, ArgKindDef::Choice));
// Check optional arg
assert!(!test.args[2].required);
}
@ -611,48 +663,69 @@ args = [
fn test_intermediate_tags() {
let dir = tempdir().unwrap();
let content = r#"
[[tagspecs]]
name = "if"
[[tagspecs.libraries]]
module = "django.template.defaulttags"
end_tag = { name = "endif" }
intermediate_tags = [
{ name = "elif" },
{ name = "else" }
]
args = [
{ name = "condition", type = "expression" }
]
[[tagspecs.libraries.tags]]
name = "if"
type = "block"
[tagspecs.libraries.tags.end]
name = "endif"
[[tagspecs.libraries.tags.intermediates]]
name = "elif"
[[tagspecs.libraries.tags.intermediates]]
name = "else"
[[tagspecs.libraries.tags.args]]
name = "condition"
kind = "any"
"#;
fs::write(dir.path().join("djls.toml"), content).unwrap();
let settings = Settings::new(Utf8Path::from_path(dir.path()).unwrap(), None).unwrap();
let if_tag = &settings.tagspecs()[0];
let lib = &settings.tagspecs().libraries[0];
let if_tag = &lib.tags[0];
assert_eq!(if_tag.name, "if");
assert_eq!(if_tag.intermediate_tags.len(), 2);
assert_eq!(if_tag.intermediate_tags[0].name, "elif");
assert_eq!(if_tag.intermediate_tags[1].name, "else");
assert_eq!(if_tag.intermediates.len(), 2);
assert_eq!(if_tag.intermediates[0].name, "elif");
assert_eq!(if_tag.intermediates[1].name, "else");
}
#[test]
fn test_end_tag_with_args() {
let dir = tempdir().unwrap();
let content = r#"
[[tagspecs]]
name = "block"
[[tagspecs.libraries]]
module = "django.template.defaulttags"
end_tag = { name = "endblock", args = [{ name = "name", required = false, type = "variable" }] }
args = [
{ name = "name", type = "variable" }
]
[[tagspecs.libraries.tags]]
name = "block"
type = "block"
[tagspecs.libraries.tags.end]
name = "endblock"
[[tagspecs.libraries.tags.end.args]]
name = "name"
required = false
kind = "variable"
[[tagspecs.libraries.tags.args]]
name = "name"
kind = "variable"
"#;
fs::write(dir.path().join("djls.toml"), content).unwrap();
let settings = Settings::new(Utf8Path::from_path(dir.path()).unwrap(), None).unwrap();
let block_tag = &settings.tagspecs()[0];
let lib = &settings.tagspecs().libraries[0];
let block_tag = &lib.tags[0];
assert_eq!(block_tag.name, "block");
let end_tag = block_tag.end_tag.as_ref().unwrap();
let end_tag = block_tag.end.as_ref().unwrap();
assert_eq!(end_tag.name, "endblock");
assert_eq!(end_tag.args.len(), 1);
assert!(!end_tag.args[0].required);
@ -665,66 +738,79 @@ args = [
debug = true
venv_path = "/path/to/venv"
[[tagspecs]]
name = "custom"
[tagspecs]
[[tagspecs.libraries]]
module = "myapp.tags"
args = []
[[tagspecs.libraries.tags]]
name = "custom"
type = "standalone"
"#;
fs::write(dir.path().join("djls.toml"), content).unwrap();
let settings = Settings::new(Utf8Path::from_path(dir.path()).unwrap(), None).unwrap();
assert!(settings.debug());
assert_eq!(settings.tagspecs().libraries.len(), 1);
assert_eq!(settings.tagspecs().libraries[0].tags[0].name, "custom");
assert_eq!(settings.venv_path(), Some("/path/to/venv"));
assert_eq!(settings.tagspecs().len(), 1);
assert_eq!(settings.tagspecs()[0].name, "custom");
assert!(settings.debug());
}
#[test]
fn test_all_arg_types() {
fn test_all_arg_kinds() {
let dir = tempdir().unwrap();
let content = r#"
[[tagspecs]]
name = "test_all_types"
[tagspecs]
[[tagspecs.libraries]]
module = "test.module"
args = [
{ name = "literal", type = "literal" },
{ name = "variable", type = "variable" },
{ name = "string", type = "string" },
{ name = "expression", type = "expression" },
{ name = "assignment", type = "assignment" },
{ name = "varargs", type = "varargs" }
]
[[tagspecs.libraries.tags]]
name = "test_all_kinds"
type = "standalone"
[[tagspecs.libraries.tags.args]]
name = "literal"
kind = "literal"
[[tagspecs.libraries.tags.args]]
name = "variable"
kind = "variable"
[[tagspecs.libraries.tags.args]]
name = "any"
kind = "any"
[[tagspecs.libraries.tags.args]]
name = "syntax"
kind = "syntax"
[[tagspecs.libraries.tags.args]]
name = "assignment"
kind = "assignment"
[[tagspecs.libraries.tags.args]]
name = "modifier"
kind = "modifier"
[[tagspecs.libraries.tags.args]]
name = "choice"
kind = "choice"
"#;
fs::write(dir.path().join("djls.toml"), content).unwrap();
let settings = Settings::new(Utf8Path::from_path(dir.path()).unwrap(), None).unwrap();
let test = &settings.tagspecs()[0];
assert_eq!(test.args.len(), 6);
let lib = &settings.tagspecs().libraries[0];
let test = &lib.tags[0];
assert_eq!(test.args.len(), 7);
assert!(matches!(
test.args[0].arg_type,
ArgTypeDef::Simple(SimpleArgTypeDef::Literal)
));
assert!(matches!(
test.args[1].arg_type,
ArgTypeDef::Simple(SimpleArgTypeDef::Variable)
));
assert!(matches!(
test.args[2].arg_type,
ArgTypeDef::Simple(SimpleArgTypeDef::String)
));
assert!(matches!(
test.args[3].arg_type,
ArgTypeDef::Simple(SimpleArgTypeDef::Expression)
));
assert!(matches!(
test.args[4].arg_type,
ArgTypeDef::Simple(SimpleArgTypeDef::Assignment)
));
assert!(matches!(
test.args[5].arg_type,
ArgTypeDef::Simple(SimpleArgTypeDef::VarArgs)
));
assert!(matches!(test.args[0].kind, ArgKindDef::Literal));
assert!(matches!(test.args[1].kind, ArgKindDef::Variable));
assert!(matches!(test.args[2].kind, ArgKindDef::Any));
assert!(matches!(test.args[3].kind, ArgKindDef::Syntax));
assert!(matches!(test.args[4].kind, ArgKindDef::Assignment));
assert!(matches!(test.args[5].kind, ArgKindDef::Modifier));
assert!(matches!(test.args[6].kind, ArgKindDef::Choice));
}
}
}

View file

@ -1,21 +1,79 @@
use std::collections::HashMap;
use serde::Deserialize;
/// A single tag specification
#[derive(Debug, Clone, Deserialize, PartialEq)]
/// Root TagSpec document (v0.5.0)
#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
#[serde(default)]
pub struct TagSpecDef {
/// Tag name (e.g., "for", "if", "cache")
pub name: String,
/// Module where this tag is defined (e.g., "django.template.defaulttags")
/// Specification version (defaults to "0.5.0")
#[serde(default = "default_version")]
pub version: String,
/// Template engine (defaults to "django")
#[serde(default = "default_engine")]
pub engine: String,
/// Engine version constraint (PEP 440 for Django)
#[serde(default)]
pub requires_engine: Option<String>,
/// References to parent documents for overlay composition
#[serde(default)]
pub extends: Vec<String>,
/// Tag libraries grouped by module
#[serde(default)]
pub libraries: Vec<TagLibraryDef>,
/// Extra metadata for extensibility
#[serde(default)]
pub extra: Option<HashMap<String, serde_json::Value>>,
}
/// Tag library grouping tags by module
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct TagLibraryDef {
/// Dotted Python import path (e.g., "django.template.defaulttags")
pub module: String,
/// Optional end tag specification
/// Engine version constraint for this library
#[serde(default)]
pub end_tag: Option<EndTagDef>,
/// Optional intermediate tags (e.g., "elif", "else" for "if" tag)
pub requires_engine: Option<String>,
/// Tags exposed by this library
#[serde(default)]
pub intermediate_tags: Vec<IntermediateTagDef>,
/// Tag arguments specification
pub tags: Vec<TagDef>,
/// Extra metadata
#[serde(default)]
pub extra: Option<HashMap<String, serde_json::Value>>,
}
/// Individual tag specification
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct TagDef {
/// Tag name (e.g., "for", "if", "url")
pub name: String,
/// Tag type: block, loader, or standalone
#[serde(rename = "type")]
pub tag_type: TagTypeDef,
/// End tag specification (auto-synthesized for block tags if omitted)
#[serde(default)]
pub end: Option<EndTagDef>,
/// Intermediate tags (e.g., "elif", "else" for "if")
#[serde(default)]
pub intermediates: Vec<IntermediateTagDef>,
/// Opening tag arguments
#[serde(default)]
pub args: Vec<TagArgDef>,
/// Extra metadata
#[serde(default)]
pub extra: Option<HashMap<String, serde_json::Value>>,
}
/// Tag type classification
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum TagTypeDef {
/// Block tag with opening/closing tags
Block,
/// Loader tag (may optionally behave as block)
Loader,
/// Standalone tag (no closing tag)
Standalone,
}
/// End tag specification
@ -23,22 +81,47 @@ pub struct TagSpecDef {
pub struct EndTagDef {
/// End tag name (e.g., "endfor", "endif")
pub name: String,
/// Whether the end tag is optional
#[serde(default)]
pub optional: bool,
/// Optional arguments for the end tag
/// Whether the end tag must appear explicitly
#[serde(default = "default_true")]
pub required: bool,
/// End tag arguments
#[serde(default)]
pub args: Vec<TagArgDef>,
/// Extra metadata
#[serde(default)]
pub extra: Option<HashMap<String, serde_json::Value>>,
}
/// Intermediate tag specification
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct IntermediateTagDef {
/// Intermediate tag name (e.g., "elif", "else")
/// Intermediate tag name (e.g., "elif", "else", "empty")
pub name: String,
/// Optional arguments for the end tag
/// Intermediate tag arguments
#[serde(default)]
pub args: Vec<TagArgDef>,
/// Minimum occurrence count
#[serde(default)]
pub min: Option<usize>,
/// Maximum occurrence count
#[serde(default)]
pub max: Option<usize>,
/// Positioning constraint
#[serde(default = "default_position")]
pub position: PositionDef,
/// Extra metadata
#[serde(default)]
pub extra: Option<HashMap<String, serde_json::Value>>,
}
/// Intermediate tag positioning
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum PositionDef {
/// Can appear anywhere
Any,
/// Must be last before end tag
Last,
}
/// Tag argument specification
@ -49,33 +132,67 @@ pub struct TagArgDef {
/// Whether the argument is required
#[serde(default = "default_true")]
pub required: bool,
/// Argument type
#[serde(rename = "type")]
/// Argument type: positional, keyword, or both
#[serde(rename = "type", default = "default_arg_type")]
pub arg_type: ArgTypeDef,
/// Argument kind (semantic role)
pub kind: ArgKindDef,
/// Exact token count (null means variable)
#[serde(default)]
pub count: Option<usize>,
/// Extra metadata
#[serde(default)]
pub extra: Option<HashMap<String, serde_json::Value>>,
}
/// Argument type specification
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum ArgTypeDef {
/// Simple type like "variable", "string", etc.
Simple(SimpleArgTypeDef),
/// Choice from a list of values
Choice { choice: Vec<String> },
}
/// Simple argument types
/// Argument type (positional vs keyword)
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum SimpleArgTypeDef {
Literal,
Variable,
String,
Expression,
pub enum ArgTypeDef {
/// Can be positional or keyword
Both,
/// Must be positional
Positional,
/// Must be keyword
Keyword,
}
/// Argument kind (semantic classification)
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum ArgKindDef {
/// Any template expression or literal
Any,
/// Variable assignment (e.g., "as varname")
Assignment,
VarArgs,
/// Choice from specific literals
Choice,
/// Literal token
Literal,
/// Boolean modifier (e.g., "reversed")
Modifier,
/// Mandatory syntactic token (e.g., "in")
Syntax,
/// Template variable or filter expression
Variable,
}
fn default_version() -> String {
"0.5.0".to_string()
}
fn default_engine() -> String {
"django".to_string()
}
fn default_true() -> bool {
true
}
fn default_position() -> PositionDef {
PositionDef::Any
}
fn default_arg_type() -> ArgTypeDef {
ArgTypeDef::Both
}

View file

@ -19,6 +19,7 @@ walkdir = { workspace = true }
[dev-dependencies]
insta = { workspace = true }
serde_json = { workspace = true }
tempfile = { workspace = true }
[lints]

View file

@ -1,4 +1,5 @@
use std::borrow::Cow;
use std::borrow::Cow::Borrowed as B;
use std::collections::hash_map::IntoIter;
use std::collections::hash_map::Iter;
use std::ops::Deref;
@ -154,13 +155,16 @@ impl From<&djls_conf::Settings> for TagSpecs {
// Start with built-in specs
let mut specs = crate::templatetags::django_builtin_specs();
// Convert and merge user-defined tagspecs
// Convert and merge user-defined tagspecs from all libraries
let mut user_specs = FxHashMap::default();
for tagspec_def in settings.tagspecs() {
// Clone because we're consuming the tagspec_def in the conversion
let name = tagspec_def.name.clone();
let tagspec: TagSpec = tagspec_def.clone().into();
user_specs.insert(name, tagspec);
let tagspec_doc = settings.tagspecs();
for library in &tagspec_doc.libraries {
for tag_def in &library.tags {
let name = tag_def.name.clone();
let tagspec: TagSpec = (tag_def.clone(), library.module.clone()).into();
user_specs.insert(name, tagspec);
}
}
// Merge user specs into built-in specs (user specs override built-ins)
@ -180,18 +184,39 @@ pub struct TagSpec {
pub args: L<TagArg>,
}
impl From<djls_conf::TagSpecDef> for TagSpec {
fn from(value: djls_conf::TagSpecDef) -> Self {
impl From<(djls_conf::TagDef, String)> for TagSpec {
fn from((tag_def, module): (djls_conf::TagDef, String)) -> Self {
let end_tag = match tag_def.tag_type {
djls_conf::TagTypeDef::Block => {
// Block tags: synthesize end tag if not provided
tag_def.end.map(Into::into).or_else(|| {
Some(EndTag {
name: format!("end{}", tag_def.name).into(),
optional: false,
args: B(&[]),
})
})
}
djls_conf::TagTypeDef::Loader => {
// Loader tags: use end tag if provided, otherwise None
tag_def.end.map(Into::into)
}
djls_conf::TagTypeDef::Standalone => {
// Standalone tags: must not have end tag
None
}
};
TagSpec {
module: value.module.into(),
end_tag: value.end_tag.map(Into::into),
intermediate_tags: value
.intermediate_tags
module: module.into(),
end_tag,
intermediate_tags: tag_def
.intermediates
.into_iter()
.map(Into::into)
.collect::<Vec<_>>()
.into(),
args: value
args: tag_def
.args
.into_iter()
.map(Into::into)
@ -314,42 +339,51 @@ impl TagArg {
impl From<djls_conf::TagArgDef> for TagArg {
fn from(value: djls_conf::TagArgDef) -> Self {
match value.arg_type {
djls_conf::ArgTypeDef::Simple(simple) => match simple {
djls_conf::SimpleArgTypeDef::Literal => TagArg::Literal {
lit: value.name.into(),
required: value.required,
},
djls_conf::SimpleArgTypeDef::Variable => TagArg::Var {
name: value.name.into(),
required: value.required,
},
djls_conf::SimpleArgTypeDef::String => TagArg::String {
name: value.name.into(),
required: value.required,
},
djls_conf::SimpleArgTypeDef::Expression => TagArg::Expr {
name: value.name.into(),
required: value.required,
},
djls_conf::SimpleArgTypeDef::Assignment => TagArg::Assignment {
name: value.name.into(),
required: value.required,
},
djls_conf::SimpleArgTypeDef::VarArgs => TagArg::VarArgs {
name: value.name.into(),
required: value.required,
},
match value.kind {
djls_conf::ArgKindDef::Literal => TagArg::Literal {
lit: value.name.into(),
required: value.required,
},
djls_conf::ArgTypeDef::Choice { choice } => TagArg::Choice {
djls_conf::ArgKindDef::Variable => TagArg::Var {
name: value.name.into(),
required: value.required,
choices: choice
.into_iter()
.map(Into::into)
.collect::<Vec<_>>()
.into(),
},
djls_conf::ArgKindDef::Syntax => TagArg::Literal {
lit: value.name.into(),
required: value.required,
},
djls_conf::ArgKindDef::Any => TagArg::Expr {
name: value.name.into(),
required: value.required,
},
djls_conf::ArgKindDef::Assignment => TagArg::Assignment {
name: value.name.into(),
required: value.required,
},
djls_conf::ArgKindDef::Modifier => TagArg::Literal {
lit: value.name.into(),
required: value.required,
},
djls_conf::ArgKindDef::Choice => {
// Extract choices from extra metadata
let choices: Vec<S> = value
.extra
.as_ref()
.and_then(|e| e.get("choices"))
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| Cow::Owned(s.to_string())))
.collect()
})
.unwrap_or_default();
TagArg::Choice {
name: value.name.into(),
required: value.required,
choices: Cow::Owned(choices),
}
}
}
}
}
@ -365,7 +399,7 @@ impl From<djls_conf::EndTagDef> for EndTag {
fn from(value: djls_conf::EndTagDef) -> Self {
EndTag {
name: value.name.into(),
optional: value.optional,
optional: !value.required, // Invert: new spec uses 'required', runtime uses 'optional'
args: value
.args
.into_iter()
@ -727,25 +761,40 @@ mod tests {
#[test]
fn test_conversion_from_conf_types() {
// Test TagArgDef -> TagArg conversion for different arg types
let string_arg_def = djls_conf::TagArgDef {
name: "test".to_string(),
required: true,
arg_type: djls_conf::ArgTypeDef::Simple(djls_conf::SimpleArgTypeDef::String),
};
assert!(matches!(
TagArg::from(string_arg_def),
TagArg::String { .. }
));
use std::collections::HashMap;
// Test TagArgDef -> TagArg conversion for Variable kind
let var_arg_def = djls_conf::TagArgDef {
name: "test_arg".to_string(),
required: true,
arg_type: djls_conf::ArgTypeDef::Both,
kind: djls_conf::ArgKindDef::Variable,
count: None,
extra: None,
};
let arg = TagArg::from(var_arg_def);
assert!(matches!(arg, TagArg::Var { .. }));
if let TagArg::Var { name, required } = arg {
assert_eq!(name.as_ref(), "test_arg");
assert!(required);
}
// Test choice argument with extra metadata
let mut choice_extra = HashMap::new();
choice_extra.insert(
"choices".to_string(),
serde_json::json!(["on", "off"]),
);
let choice_arg_def = djls_conf::TagArgDef {
name: "mode".to_string(),
required: false,
arg_type: djls_conf::ArgTypeDef::Choice {
choice: vec!["on".to_string(), "off".to_string()],
},
arg_type: djls_conf::ArgTypeDef::Both,
kind: djls_conf::ArgKindDef::Choice,
count: Some(1),
extra: Some(choice_extra),
};
if let TagArg::Choice { choices, .. } = TagArg::from(choice_arg_def) {
if let TagArg::Choice { choices, required, .. } = TagArg::from(choice_arg_def) {
assert!(!required);
assert_eq!(choices.len(), 2);
assert_eq!(choices[0].as_ref(), "on");
assert_eq!(choices[1].as_ref(), "off");
@ -753,28 +802,16 @@ mod tests {
panic!("Expected Choice variant");
}
// Test TagArgDef -> TagArg conversion for Variable type
let tag_arg_def = djls_conf::TagArgDef {
name: "test_arg".to_string(),
required: true,
arg_type: djls_conf::ArgTypeDef::Simple(djls_conf::SimpleArgTypeDef::Variable),
};
let arg = TagArg::from(tag_arg_def);
assert!(matches!(arg, TagArg::Var { .. }));
if let TagArg::Var { name, required } = arg {
assert_eq!(name.as_ref(), "test_arg");
assert!(required);
}
// Test EndTagDef -> EndTag conversion
// Test EndTagDef -> EndTag conversion (note: required inverted to optional)
let end_tag_def = djls_conf::EndTagDef {
name: "endtest".to_string(),
optional: true,
required: false, // Becomes optional=true in runtime
args: vec![],
extra: None,
};
let end_tag = EndTag::from(end_tag_def);
assert_eq!(end_tag.name.as_ref(), "endtest");
assert!(end_tag.optional);
assert!(end_tag.optional); // required=false becomes optional=true
assert_eq!(end_tag.args.len(), 0);
// Test IntermediateTagDef -> IntermediateTag conversion
@ -783,31 +820,45 @@ mod tests {
args: vec![djls_conf::TagArgDef {
name: "condition".to_string(),
required: true,
arg_type: djls_conf::ArgTypeDef::Simple(djls_conf::SimpleArgTypeDef::Expression),
arg_type: djls_conf::ArgTypeDef::Both,
kind: djls_conf::ArgKindDef::Any,
count: None,
extra: None,
}],
min: None,
max: None,
position: djls_conf::PositionDef::Any,
extra: None,
};
let intermediate = IntermediateTag::from(intermediate_def);
assert_eq!(intermediate.name.as_ref(), "elif");
assert_eq!(intermediate.args.len(), 1);
assert_eq!(intermediate.args[0].name().as_ref(), "condition");
// Test full TagSpecDef -> TagSpec conversion
let tagspec_def = djls_conf::TagSpecDef {
// Test full TagDef -> TagSpec conversion with module
let tag_def = djls_conf::TagDef {
name: "custom".to_string(),
module: "myapp.templatetags".to_string(), // Note: module is ignored in conversion
end_tag: Some(djls_conf::EndTagDef {
tag_type: djls_conf::TagTypeDef::Block,
end: Some(djls_conf::EndTagDef {
name: "endcustom".to_string(),
optional: false,
required: true,
args: vec![],
extra: None,
}),
intermediate_tags: vec![djls_conf::IntermediateTagDef {
intermediates: vec![djls_conf::IntermediateTagDef {
name: "branch".to_string(),
args: vec![],
min: None,
max: None,
position: djls_conf::PositionDef::Any,
extra: None,
}],
args: vec![],
extra: None,
};
let tagspec = TagSpec::from(tagspec_def);
// Name field was removed from TagSpec
let module = "myapp.templatetags".to_string();
let tagspec = TagSpec::from((tag_def, module));
assert_eq!(tagspec.module.as_ref(), "myapp.templatetags");
assert!(tagspec.end_tag.is_some());
assert_eq!(tagspec.end_tag.as_ref().unwrap().name.as_ref(), "endcustom");
assert_eq!(tagspec.intermediate_tags.len(), 1);
@ -831,20 +882,46 @@ mod tests {
// Test case 2: Settings with user-defined tagspecs
let dir = tempfile::TempDir::new().unwrap();
let config_content = r#"
[[tagspecs]]
name = "mytag"
module = "myapp.templatetags.custom"
end_tag = { name = "endmytag", optional = false }
intermediate_tags = [{ name = "mybranch" }]
args = [
{ name = "arg1", type = "variable", required = true },
{ name = "arg2", type = { choice = ["on", "off"] }, required = false }
]
[tagspecs]
version = "0.5.0"
[[tagspecs]]
name = "if"
[[tagspecs.libraries]]
module = "myapp.templatetags.custom"
[[tagspecs.libraries.tags]]
name = "mytag"
type = "block"
[tagspecs.libraries.tags.end]
name = "endmytag"
required = true
[[tagspecs.libraries.tags.intermediates]]
name = "mybranch"
[[tagspecs.libraries.tags.args]]
name = "arg1"
kind = "variable"
required = true
[[tagspecs.libraries.tags.args]]
name = "arg2"
kind = "choice"
required = false
[tagspecs.libraries.tags.args.extra]
choices = ["on", "off"]
[[tagspecs.libraries]]
module = "myapp.overrides"
end_tag = { name = "endif", optional = true }
[[tagspecs.libraries.tags]]
name = "if"
type = "block"
[tagspecs.libraries.tags.end]
name = "endif"
required = false
"#;
fs::write(dir.path().join("djls.toml"), config_content).unwrap();
@ -858,9 +935,9 @@ end_tag = { name = "endif", optional = true }
// Should have user-defined custom tag
let mytag = specs.get("mytag").expect("mytag should be present");
// Name field was removed from TagSpec
assert_eq!(mytag.module.as_ref(), "myapp.templatetags.custom");
assert_eq!(mytag.end_tag.as_ref().unwrap().name.as_ref(), "endmytag");
assert!(!mytag.end_tag.as_ref().unwrap().optional);
assert!(!mytag.end_tag.as_ref().unwrap().optional); // required=true becomes optional=false
assert_eq!(mytag.intermediate_tags.len(), 1);
assert_eq!(mytag.intermediate_tags[0].name.as_ref(), "mybranch");
assert_eq!(mytag.args.len(), 2);
@ -871,9 +948,9 @@ end_tag = { name = "endif", optional = true }
// Should have overridden built-in "if" tag
let if_tag = specs.get("if").expect("if tag should be present");
assert!(if_tag.end_tag.as_ref().unwrap().optional); // Changed to optional
// Note: The built-in if tag has intermediate tags, but the override doesn't specify them
// The override completely replaces the built-in
assert!(if_tag.end_tag.as_ref().unwrap().optional); // required=false becomes optional=true
// Note: The built-in if tag has intermediate tags, but the override doesn't specify them
// The override completely replaces the built-in
assert!(if_tag.intermediate_tags.is_empty());
}
}