diff --git a/Cargo.lock b/Cargo.lock index 10b95d9..fb0deb4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/crates/djls-conf/Cargo.toml b/crates/djls-conf/Cargo.toml index 67758aa..f87780b 100644 --- a/crates/djls-conf/Cargo.toml +++ b/crates/djls-conf/Cargo.toml @@ -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 } diff --git a/crates/djls-conf/src/lib.rs b/crates/djls-conf/src/lib.rs index 1eb41be..a31c261 100644 --- a/crates/djls-conf/src/lib.rs +++ b/crates/djls-conf/src/lib.rs @@ -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::from("", "", "djls") @@ -67,7 +71,7 @@ pub struct Settings { #[serde(default)] pythonpath: Vec, #[serde(default)] - tagspecs: Vec, + 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)); } } } diff --git a/crates/djls-conf/src/tagspecs.rs b/crates/djls-conf/src/tagspecs.rs index 4ccfba8..899d69c 100644 --- a/crates/djls-conf/src/tagspecs.rs +++ b/crates/djls-conf/src/tagspecs.rs @@ -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, + /// References to parent documents for overlay composition + #[serde(default)] + pub extends: Vec, + /// Tag libraries grouped by module + #[serde(default)] + pub libraries: Vec, + /// Extra metadata for extensibility + #[serde(default)] + pub extra: Option>, +} + +/// 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, - /// Optional intermediate tags (e.g., "elif", "else" for "if" tag) + pub requires_engine: Option, + /// Tags exposed by this library #[serde(default)] - pub intermediate_tags: Vec, - /// Tag arguments specification + pub tags: Vec, + /// Extra metadata + #[serde(default)] + pub extra: Option>, +} + +/// 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, + /// Intermediate tags (e.g., "elif", "else" for "if") + #[serde(default)] + pub intermediates: Vec, + /// Opening tag arguments #[serde(default)] pub args: Vec, + /// Extra metadata + #[serde(default)] + pub extra: Option>, +} + +/// 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, + /// Extra metadata + #[serde(default)] + pub extra: Option>, } /// 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, + /// Minimum occurrence count + #[serde(default)] + pub min: Option, + /// Maximum occurrence count + #[serde(default)] + pub max: Option, + /// Positioning constraint + #[serde(default = "default_position")] + pub position: PositionDef, + /// Extra metadata + #[serde(default)] + pub extra: Option>, +} + +/// 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, + /// Extra metadata + #[serde(default)] + pub extra: Option>, } -/// 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 }, -} - -/// 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 +} diff --git a/crates/djls-semantic/Cargo.toml b/crates/djls-semantic/Cargo.toml index 971d7d1..02bbdaf 100644 --- a/crates/djls-semantic/Cargo.toml +++ b/crates/djls-semantic/Cargo.toml @@ -19,6 +19,7 @@ walkdir = { workspace = true } [dev-dependencies] insta = { workspace = true } +serde_json = { workspace = true } tempfile = { workspace = true } [lints] diff --git a/crates/djls-semantic/src/templatetags/specs.rs b/crates/djls-semantic/src/templatetags/specs.rs index e96f82b..ac9d5b4 100644 --- a/crates/djls-semantic/src/templatetags/specs.rs +++ b/crates/djls-semantic/src/templatetags/specs.rs @@ -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, } -impl From 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::>() .into(), - args: value + args: tag_def .args .into_iter() .map(Into::into) @@ -314,42 +339,51 @@ impl TagArg { impl From 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::>() - .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 = 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 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()); } }