diff --git a/.mkdocs.yml b/.mkdocs.yml index f0cc7b15..855fa457 100644 --- a/.mkdocs.yml +++ b/.mkdocs.yml @@ -20,6 +20,9 @@ markdown_extensions: - pymdownx.superfences - pymdownx.tasklist: custom_checkbox: true + - pymdownx.tabbed: + alternate_style: true + combine_header_slug: true - pymdownx.tilde plugins: - search diff --git a/CHANGELOG.md b/CHANGELOG.md index ab435791..b3511e78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,14 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/ ## [Unreleased] +### Changed + +- Updated TagSpecs to v0.6.0 format with hierarchical `[[tagspecs.libraries]]` structure + +### Deprecated + +- TagSpecs v0.4.0 flat format (will be removed in v5.2.7), [migration guide here](docs/tagspecs.md#migration-from-v040) + ## [5.2.4] ### Added diff --git a/Cargo.lock b/Cargo.lock index 10b95d96..5a1413c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -534,9 +534,11 @@ dependencies = [ "config", "directories", "serde", + "serde_json", "tempfile", "thiserror 2.0.17", "toml", + "tracing", ] [[package]] @@ -586,6 +588,7 @@ dependencies = [ "rustc-hash", "salsa", "serde", + "serde_json", "tempfile", "thiserror 2.0.17", "tracing", diff --git a/Cargo.toml b/Cargo.toml index 09b68e35..468482b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,12 @@ tempfile = "3.23" pedantic = { level = "warn", priority = -1 } missing_errors_doc = "allow" +[workspace.lints.rust] +# Allow deprecated warnings for legacy tagspecs (remove in v5.2.7) +# NOTE: To see all deprecation warnings when removing legacy code in v5.2.7, +# temporarily delete this `deprecated = "allow"` line +deprecated = "allow" + [profile.dev.package] insta.opt-level = 3 similar.opt-level = 3 diff --git a/crates/djls-conf/Cargo.toml b/crates/djls-conf/Cargo.toml index 67758aa9..010a0589 100644 --- a/crates/djls-conf/Cargo.toml +++ b/crates/djls-conf/Cargo.toml @@ -9,8 +9,10 @@ camino = { workspace = true } config = { workspace = true } directories = { workspace = true } serde = { workspace = true } +serde_json = { workspace = true } thiserror = { workspace = true } toml = { workspace = true } +tracing = { workspace = true } [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/djls-conf/TAGSPECS.md b/crates/djls-conf/TAGSPECS.md deleted file mode 100644 index 2b7153b1..00000000 --- a/crates/djls-conf/TAGSPECS.md +++ /dev/null @@ -1,139 +0,0 @@ -# TagSpecs - -Tag Specifications (TagSpecs) define how template tags are structured, helping the language server understand template syntax for features like block completion and diagnostics. - -## Schema - -Tag Specifications (TagSpecs) define how tags are parsed and understood. They allow the parser to handle custom tags without hard-coding them and provide rich autocompletion with LSP snippets. - -```toml -[[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 = [ # Defines tag arguments for validation and snippets - { name = "arg_name", type = "arg_type", required = true } -] -``` - -### Core Fields - -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 array of arguments for the end tag (e.g., endblock can take a name) - -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") - -### Argument Specification - -The `args` array defines the expected arguments for a tag. Each argument has: -- `name`: The argument name (used as placeholder text in LSP snippets) -- `type`: The argument type (see below) -- `required`: Whether the argument is required (defaults to `true`) - -#### Argument Types - -- `"literal"`: A literal keyword that must appear exactly (e.g., "in", "as", "by") -- `"variable"`: A template variable name -- `"string"`: A string literal (will be wrapped in quotes in snippets) -- `"expression"`: A template expression or condition -- `"assignment"`: A variable assignment (e.g., "var=value") -- `"varargs"`: Variable number of arguments -- `{ choice = ["option1", "option2"] }`: A choice from specific options (generates choice snippets) - -## Configuration - -- **Built-in TagSpecs**: The parser includes TagSpecs for Django's built-in tags and popular third-party tags. These are provided by `djls-templates` automatically; users do not need to define them. The examples below show the format, but you only need to create files for your *own* custom tags or to override built-in behavior. -- **User-defined TagSpecs**: Users can expand or override TagSpecs via `pyproject.toml` or `djls.toml` files in their project, allowing custom tags and configurations to be seamlessly integrated. - -## Examples - -### If Tag - -```toml -[[tagspecs.django.template.defaulttags]] -name = "if" -end_tag = { name = "endif" } -intermediate_tags = [{ name = "elif" }, { name = "else" }] -args = [ - { name = "condition", type = "expression" } -] -# Generates snippet: if ${1:condition} -``` - -### For Tag - -```toml -[[tagspecs.django.template.defaulttags]] -name = "for" -end_tag = { name = "endfor" } -intermediate_tags = [{ name = "empty" }] -args = [ - { name = "item", type = "variable" }, - { name = "in", type = "literal" }, - { name = "items", type = "variable" }, - { name = "reversed", required = false, type = "literal" } -] -# Generates snippet: for ${1:item} in ${2:items} ${3:reversed} -``` - -### Autoescape Tag - -```toml -[[tagspecs.django.template.defaulttags]] -name = "autoescape" -end_tag = { name = "endautoescape" } -args = [ - { name = "mode", type = { choice = ["on", "off"] } } -] -# Generates snippet: autoescape ${1|on,off|} -``` - -### URL Tag with Optional Arguments - -```toml -[[tagspecs.django.template.defaulttags]] -name = "url" -args = [ - { name = "view_name", type = "string" }, - { name = "args", required = false, type = "varargs" }, - { name = "as", required = false, type = "literal" }, - { name = "varname", required = false, type = "variable" } -] -# Generates snippet: url "${1:view_name}" ${2:args} ${3:as} ${4:varname} -``` - -### Custom Tag - -```toml -[[tagspecs.my_module.templatetags.my_tags]] -name = "my_custom_tag" -end_tag = { name = "endmycustomtag", optional = true } -intermediate_tags = [{ name = "myintermediate" }] -args = [ - { name = "param1", type = "variable" }, - { name = "with", type = "literal" }, - { name = "param2", type = "string" } -] -# Generates snippet: my_custom_tag ${1:param1} with "${2:param2}" -``` - -### Standalone Tags - -```toml -[[tagspecs.django.template.defaulttags]] -name = "csrf_token" -args = [] # No arguments -# Generates snippet: csrf_token - -[[tagspecs.django.template.defaulttags]] -name = "load" -args = [ - { name = "libraries", type = "varargs" } -] -# Generates snippet: load ${1:libraries} -``` diff --git a/crates/djls-conf/src/lib.rs b/crates/djls-conf/src/lib.rs index 1eb41be3..2013f901 100644 --- a/crates/djls-conf/src/lib.rs +++ b/crates/djls-conf/src/lib.rs @@ -13,16 +13,21 @@ use config::File; use config::FileFormat; use directories::ProjectDirs; use serde::Deserialize; +use serde::Deserializer; 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") @@ -66,12 +71,40 @@ pub struct Settings { django_settings_module: Option, #[serde(default)] pythonpath: Vec, - #[serde(default)] - tagspecs: Vec, + #[serde(default, deserialize_with = "deserialize_tagspecs")] + tagspecs: TagSpecDef, #[serde(default)] diagnostics: DiagnosticsConfig, } +// DEPRECATION: Remove in v5.2.7 +fn deserialize_tagspecs<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + use serde::de::Error; + use serde_json::Value; + + let value = Value::deserialize(deserializer)?; + + if let Ok(new_format) = TagSpecDef::deserialize(&value) { + return Ok(new_format); + } + + if let Ok(legacy) = Vec::::deserialize(&value) { + tracing::warn!( + "DEPRECATED: TagSpecs v0.4.0 format detected. Please migrate to v0.6.0 format. \ + The old format will be removed in v5.2.7. \ + See migration guide: https://djls.joshthomas.dev/tagspecs/#migration-from-v040" + ); + return Ok(tagspecs::legacy::convert_legacy_tagspecs(legacy)); + } + + Err(D::Error::custom( + "Invalid tagspecs format. Expected v0.6.0 hierarchical format or legacy v0.4.0 array format", + )) +} + impl Settings { pub fn new(project_root: &Utf8Path, overrides: Option) -> Result { let user_config_file = @@ -88,7 +121,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 +197,7 @@ impl Settings { } #[must_use] - pub fn tagspecs(&self) -> &[TagSpecDef] { + pub fn tagspecs(&self) -> &TagSpecDef { &self.tagspecs } @@ -197,7 +230,7 @@ mod tests { venv_path: None, django_settings_module: None, pythonpath: vec![], - tagspecs: vec![], + tagspecs: TagSpecDef::default(), diagnostics: DiagnosticsConfig::default(), } ); @@ -505,43 +538,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" +[tagspecs] +version = "0.6.0" + +[[tagspecs.libraries]] module = "myapp.templatetags.custom" -end_tag = { name = "endmytag" } -[[tagspecs]] -name = "for" +[[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 +610,37 @@ args = [ [tool.djls] debug = true -[[tool.djls.tagspecs]] -name = "cache" +[tool.djls.tagspecs] +version = "0.6.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,33 +648,41 @@ 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 +692,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 +767,233 @@ 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] + +[[tagspecs.libraries]] +module = "test.module" + +[[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 lib = &settings.tagspecs().libraries[0]; + let test = &lib.tags[0]; + assert_eq!(test.args.len(), 7); + + 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)); + } + + // DEPRECATION TESTS: Remove in v5.2.7 + mod legacy_format { + use super::*; + + #[test] + fn test_load_legacy_flat_format() { + let dir = tempdir().unwrap(); + let content = r#" +[[tagspecs]] +name = "mytag" +module = "myapp.templatetags.custom" +end_tag = { name = "endmytag" } + [[tagspecs]] -name = "test_all_types" +name = "for" +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" } +] +"#; + fs::write(dir.path().join("djls.toml"), content).unwrap(); + let settings = + Settings::new(Utf8Path::from_path(dir.path()).unwrap(), None).unwrap(); + + // Should be converted to new hierarchical format + assert_eq!(settings.tagspecs().version, "0.6.0"); + assert_eq!(settings.tagspecs().libraries.len(), 2); + + // Find libraries (order not guaranteed) + let myapp_lib = settings + .tagspecs() + .libraries + .iter() + .find(|lib| lib.module == "myapp.templatetags.custom") + .unwrap(); + let django_lib = settings + .tagspecs() + .libraries + .iter() + .find(|lib| lib.module == "django.template.defaulttags") + .unwrap(); + + // Verify mytag conversion + assert_eq!(myapp_lib.tags.len(), 1); + let mytag = &myapp_lib.tags[0]; + assert_eq!(mytag.name, "mytag"); + assert!(matches!(mytag.tag_type, TagTypeDef::Block)); + assert_eq!(mytag.end.as_ref().unwrap().name, "endmytag"); + + // Verify for tag conversion + assert_eq!(django_lib.tags.len(), 1); + let for_tag = &django_lib.tags[0]; + assert_eq!(for_tag.name, "for"); + assert_eq!(for_tag.intermediates.len(), 1); + assert_eq!(for_tag.intermediates[0].name, "empty"); + assert_eq!(for_tag.args.len(), 3); + } + + #[test] + fn test_legacy_optional_end_tag_conversion() { + let dir = tempdir().unwrap(); + let content = r#" +[[tagspecs]] +name = "cache" +module = "django.templatetags.cache" +end_tag = { name = "endcache", optional = false } +args = [ + { name = "expire_time", type = "variable" }, + { name = "fragment_name", type = "string" } +] +"#; + fs::write(dir.path().join("djls.toml"), content).unwrap(); + let settings = + Settings::new(Utf8Path::from_path(dir.path()).unwrap(), None).unwrap(); + + let lib = &settings.tagspecs().libraries[0]; + let cache = &lib.tags[0]; + + // optional: false should convert to required: true + assert!(cache.end.as_ref().unwrap().required); + } + + #[test] + fn test_legacy_choice_arg_conversion() { + let dir = tempdir().unwrap(); + let content = r#" +[[tagspecs]] +name = "test" 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" } + { name = "choice", type = { choice = ["on", "off"] } } ] "#; - fs::write(dir.path().join("djls.toml"), content).unwrap(); - let settings = Settings::new(Utf8Path::from_path(dir.path()).unwrap(), None).unwrap(); + fs::write(dir.path().join("djls.toml"), content).unwrap(); + let settings = + Settings::new(Utf8Path::from_path(dir.path()).unwrap(), None).unwrap(); + + let lib = &settings.tagspecs().libraries[0]; + let test = &lib.tags[0]; + + assert_eq!(test.args.len(), 1); + assert!(matches!(test.args[0].kind, ArgKindDef::Choice)); + + // Verify choices are in extra metadata + assert!(test.args[0].extra.is_some()); + let choices = test.args[0].extra.as_ref().unwrap().get("choices"); + assert!(choices.is_some()); + } + + #[test] + fn test_legacy_multiple_tags_same_module() { + let dir = tempdir().unwrap(); + let content = r#" +[[tagspecs]] +name = "tag1" +module = "myapp.tags" - let test = &settings.tagspecs()[0]; - assert_eq!(test.args.len(), 6); - - 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) - )); +[[tagspecs]] +name = "tag2" +module = "myapp.tags" + +[[tagspecs]] +name = "tag3" +module = "other.tags" +"#; + fs::write(dir.path().join("djls.toml"), content).unwrap(); + let settings = + Settings::new(Utf8Path::from_path(dir.path()).unwrap(), None).unwrap(); + + // Should group tags by module + assert_eq!(settings.tagspecs().libraries.len(), 2); + + let myapp_lib = settings + .tagspecs() + .libraries + .iter() + .find(|lib| lib.module == "myapp.tags") + .unwrap(); + assert_eq!(myapp_lib.tags.len(), 2); + + let other_lib = settings + .tagspecs() + .libraries + .iter() + .find(|lib| lib.module == "other.tags") + .unwrap(); + assert_eq!(other_lib.tags.len(), 1); + } } } } diff --git a/crates/djls-conf/src/tagspecs.rs b/crates/djls-conf/src/tagspecs.rs index 4ccfba87..00d64c10 100644 --- a/crates/djls-conf/src/tagspecs.rs +++ b/crates/djls-conf/src/tagspecs.rs @@ -1,21 +1,82 @@ +use std::collections::HashMap; + use serde::Deserialize; -/// A single tag specification -#[derive(Debug, Clone, Deserialize, PartialEq)] +// DEPRECATION: Remove in v5.2.7 (after v5.2.5 and v5.2.6) +pub mod legacy; + +/// Root `TagSpec` document (v0.6.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.6.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 +84,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 +135,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 +/// Argument type (positional vs keyword) #[derive(Debug, Clone, Deserialize, PartialEq)] -#[serde(untagged)] +#[serde(rename_all = "lowercase")] pub enum ArgTypeDef { - /// Simple type like "variable", "string", etc. - Simple(SimpleArgTypeDef), - /// Choice from a list of values - Choice { choice: Vec }, + /// Can be positional or keyword + Both, + /// Must be positional + Positional, + /// Must be keyword + Keyword, } -/// Simple argument types +/// Argument kind (semantic classification) #[derive(Debug, Clone, Deserialize, PartialEq)] #[serde(rename_all = "lowercase")] -pub enum SimpleArgTypeDef { +pub enum ArgKindDef { + /// Any template expression or literal + Any, + /// Variable assignment (e.g., "as varname") + Assignment, + /// 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, - String, - Expression, - Assignment, - VarArgs, +} + +fn default_version() -> String { + "0.6.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-conf/src/tagspecs/legacy.rs b/crates/djls-conf/src/tagspecs/legacy.rs new file mode 100644 index 00000000..09462b9e --- /dev/null +++ b/crates/djls-conf/src/tagspecs/legacy.rs @@ -0,0 +1,431 @@ +// DEPRECATION: This entire module will be removed in v5.2.7 +// Legacy v0.4.0 TagSpec format support with deprecation warnings +// +// This module provides backward compatibility for the old flat tagspec format. +// It should be removed after two releases (v5.2.5 and v5.2.6) following the +// project's deprecation policy. + +use std::collections::HashMap; + +use serde::Deserialize; + +use super::ArgKindDef; +use super::EndTagDef; +use super::IntermediateTagDef; +use super::TagArgDef; +use super::TagDef; +use super::TagLibraryDef; +use super::TagSpecDef; +use super::TagTypeDef; + +/// Legacy v0.4.0 tag specification (DEPRECATED) +#[deprecated(since = "5.2.5", note = "Remove in v5.2.7")] +#[allow(deprecated)] +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub struct LegacyTagSpecDef { + /// Tag name (e.g., "for", "if", "cache") + pub name: String, + /// Module where this tag is defined (e.g., "django.template.defaulttags") + pub module: String, + /// Optional end tag specification + #[serde(default)] + pub end_tag: Option, + /// Optional intermediate tags (e.g., "elif", "else" for "if" tag) + #[serde(default)] + pub intermediate_tags: Vec, + /// Tag arguments specification + #[serde(default)] + pub args: Vec, +} + +/// Legacy end tag specification +#[deprecated(since = "5.2.5", note = "Remove in v5.2.7")] +#[allow(deprecated)] +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub struct LegacyEndTagDef { + /// End tag name (e.g., "endfor", "endif") + pub name: String, + /// Whether the end tag is optional (default: false) + #[serde(default)] + pub optional: bool, + /// Optional arguments for the end tag + #[serde(default)] + pub args: Vec, +} + +/// Legacy intermediate tag specification +#[deprecated(since = "5.2.5", note = "Remove in v5.2.7")] +#[allow(deprecated)] +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub struct LegacyIntermediateTagDef { + /// Intermediate tag name (e.g., "elif", "else") + pub name: String, + /// Optional arguments for the intermediate tag + #[serde(default)] + pub args: Vec, +} + +/// Legacy tag argument specification +#[deprecated(since = "5.2.5", note = "Remove in v5.2.7")] +#[allow(deprecated)] +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub struct LegacyTagArgDef { + /// Argument name + pub name: String, + /// Whether the argument is required (default: true) + #[serde(default = "default_true")] + pub required: bool, + /// Argument type (called "kind" in v0.6.0) + #[serde(rename = "type")] + pub arg_type: LegacyArgTypeDef, +} + +/// Legacy argument type specification +#[deprecated(since = "5.2.5", note = "Remove in v5.2.7")] +#[allow(deprecated)] +#[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum LegacyArgTypeDef { + /// Simple type like "variable", "string", etc. + Simple(LegacySimpleArgTypeDef), + /// Choice from a list of values + Choice { choice: Vec }, +} + +/// Legacy simple argument types +#[deprecated(since = "5.2.5", note = "Remove in v5.2.7")] +#[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum LegacySimpleArgTypeDef { + Literal, + Variable, + String, + Expression, + Assignment, + VarArgs, +} + +fn default_true() -> bool { + true +} + +/// Convert a vector of legacy tagspecs to the new v0.6.0 hierarchical format +/// +/// Groups tags by module and creates the appropriate library structure. +#[deprecated(since = "5.2.5", note = "Remove in v5.2.7")] +#[must_use] +pub fn convert_legacy_tagspecs(legacy: Vec) -> TagSpecDef { + let mut modules: HashMap> = HashMap::new(); + + for legacy_tag in legacy { + let module = legacy_tag.module.clone(); + let tag = convert_legacy_tag(legacy_tag); + modules.entry(module).or_default().push(tag); + } + + let libraries = modules + .into_iter() + .map(|(module, tags)| TagLibraryDef { + module, + requires_engine: None, + tags, + extra: None, + }) + .collect(); + + TagSpecDef { + version: "0.6.0".to_string(), + engine: "django".to_string(), + requires_engine: None, + extends: vec![], + libraries, + extra: None, + } +} + +fn convert_legacy_tag(legacy: LegacyTagSpecDef) -> TagDef { + let tag_type = if legacy.end_tag.is_some() { + TagTypeDef::Block + } else { + TagTypeDef::Standalone + }; + + TagDef { + name: legacy.name, + tag_type, + end: legacy.end_tag.map(convert_legacy_end_tag), + intermediates: legacy + .intermediate_tags + .into_iter() + .map(convert_legacy_intermediate_tag) + .collect(), + args: legacy.args.into_iter().map(convert_legacy_arg).collect(), + extra: None, + } +} + +fn convert_legacy_end_tag(legacy: LegacyEndTagDef) -> EndTagDef { + EndTagDef { + name: legacy.name, + required: !legacy.optional, // Invert: optional -> required + args: legacy.args.into_iter().map(convert_legacy_arg).collect(), + extra: None, + } +} + +fn convert_legacy_intermediate_tag(legacy: LegacyIntermediateTagDef) -> IntermediateTagDef { + IntermediateTagDef { + name: legacy.name, + args: legacy.args.into_iter().map(convert_legacy_arg).collect(), + min: None, + max: None, + position: super::PositionDef::Any, + extra: None, + } +} + +fn convert_legacy_arg(legacy: LegacyTagArgDef) -> TagArgDef { + let (kind, extra) = match legacy.arg_type { + LegacyArgTypeDef::Simple(simple) => { + let kind = match simple { + LegacySimpleArgTypeDef::Literal => ArgKindDef::Literal, + LegacySimpleArgTypeDef::Variable | LegacySimpleArgTypeDef::String => { + ArgKindDef::Variable + } + LegacySimpleArgTypeDef::Expression | LegacySimpleArgTypeDef::VarArgs => { + ArgKindDef::Any + } + LegacySimpleArgTypeDef::Assignment => ArgKindDef::Assignment, + }; + (kind, None) + } + LegacyArgTypeDef::Choice { choice } => { + // Store choices in extra metadata as required by v0.6.0 spec + let mut extra = std::collections::HashMap::new(); + extra.insert( + "choices".to_string(), + serde_json::Value::Array( + choice.into_iter().map(serde_json::Value::String).collect(), + ), + ); + (ArgKindDef::Choice, Some(extra)) + } + }; + + TagArgDef { + name: legacy.name, + required: legacy.required, + arg_type: super::ArgTypeDef::Both, // Default for legacy + kind, + count: None, + extra, + } +} + +#[cfg(test)] +mod tests { + #![allow(deprecated)] + + use super::*; + + #[test] + fn test_convert_simple_tag() { + let legacy = vec![LegacyTagSpecDef { + name: "mytag".to_string(), + module: "myapp.tags".to_string(), + end_tag: None, + intermediate_tags: vec![], + args: vec![], + }]; + + let converted = convert_legacy_tagspecs(legacy); + + assert_eq!(converted.version, "0.6.0"); + assert_eq!(converted.libraries.len(), 1); + assert_eq!(converted.libraries[0].module, "myapp.tags"); + assert_eq!(converted.libraries[0].tags.len(), 1); + assert_eq!(converted.libraries[0].tags[0].name, "mytag"); + assert!(matches!( + converted.libraries[0].tags[0].tag_type, + TagTypeDef::Standalone + )); + } + + #[test] + fn test_convert_block_tag() { + let legacy = vec![LegacyTagSpecDef { + name: "block".to_string(), + module: "django.template.defaulttags".to_string(), + end_tag: Some(LegacyEndTagDef { + name: "endblock".to_string(), + optional: false, + args: vec![], + }), + intermediate_tags: vec![], + args: vec![], + }]; + + let converted = convert_legacy_tagspecs(legacy); + + assert_eq!(converted.libraries[0].tags[0].name, "block"); + assert!(matches!( + converted.libraries[0].tags[0].tag_type, + TagTypeDef::Block + )); + assert!(converted.libraries[0].tags[0].end.is_some()); + assert_eq!( + converted.libraries[0].tags[0].end.as_ref().unwrap().name, + "endblock" + ); + assert!( + converted.libraries[0].tags[0] + .end + .as_ref() + .unwrap() + .required + ); + } + + #[test] + fn test_convert_optional_end_tag() { + let legacy = vec![LegacyTagSpecDef { + name: "autoescape".to_string(), + module: "django.template.defaulttags".to_string(), + end_tag: Some(LegacyEndTagDef { + name: "endautoescape".to_string(), + optional: true, + args: vec![], + }), + intermediate_tags: vec![], + args: vec![], + }]; + + let converted = convert_legacy_tagspecs(legacy); + + assert!( + !converted.libraries[0].tags[0] + .end + .as_ref() + .unwrap() + .required + ); + } + + #[test] + fn test_convert_arg_types() { + let legacy = vec![LegacyTagSpecDef { + name: "test".to_string(), + module: "test.module".to_string(), + end_tag: None, + intermediate_tags: vec![], + args: vec![ + LegacyTagArgDef { + name: "lit".to_string(), + required: true, + arg_type: LegacyArgTypeDef::Simple(LegacySimpleArgTypeDef::Literal), + }, + LegacyTagArgDef { + name: "var".to_string(), + required: true, + arg_type: LegacyArgTypeDef::Simple(LegacySimpleArgTypeDef::Variable), + }, + LegacyTagArgDef { + name: "choice".to_string(), + required: true, + arg_type: LegacyArgTypeDef::Choice { + choice: vec!["on".to_string(), "off".to_string()], + }, + }, + ], + }]; + + let converted = convert_legacy_tagspecs(legacy); + let args = &converted.libraries[0].tags[0].args; + + assert!(matches!(args[0].kind, ArgKindDef::Literal)); + assert!(matches!(args[1].kind, ArgKindDef::Variable)); + assert!(matches!(args[2].kind, ArgKindDef::Choice)); + + // Check that choices are stored in extra + assert!(args[2].extra.is_some()); + let choices = args[2].extra.as_ref().unwrap().get("choices").unwrap(); + assert_eq!(choices, &serde_json::json!(["on", "off"])); + } + + #[test] + fn test_convert_groups_by_module() { + let legacy = vec![ + LegacyTagSpecDef { + name: "tag1".to_string(), + module: "module.a".to_string(), + end_tag: None, + intermediate_tags: vec![], + args: vec![], + }, + LegacyTagSpecDef { + name: "tag2".to_string(), + module: "module.b".to_string(), + end_tag: None, + intermediate_tags: vec![], + args: vec![], + }, + LegacyTagSpecDef { + name: "tag3".to_string(), + module: "module.a".to_string(), + end_tag: None, + intermediate_tags: vec![], + args: vec![], + }, + ]; + + let converted = convert_legacy_tagspecs(legacy); + + assert_eq!(converted.libraries.len(), 2); + + // Find the libraries by module (order is not guaranteed due to HashMap) + let module_a = converted + .libraries + .iter() + .find(|lib| lib.module == "module.a") + .unwrap(); + let module_b = converted + .libraries + .iter() + .find(|lib| lib.module == "module.b") + .unwrap(); + + assert_eq!(module_a.tags.len(), 2); + assert_eq!(module_b.tags.len(), 1); + } + + #[test] + fn test_convert_intermediate_tags() { + let legacy = vec![LegacyTagSpecDef { + name: "if".to_string(), + module: "django.template.defaulttags".to_string(), + end_tag: Some(LegacyEndTagDef { + name: "endif".to_string(), + optional: false, + args: vec![], + }), + intermediate_tags: vec![ + LegacyIntermediateTagDef { + name: "elif".to_string(), + args: vec![], + }, + LegacyIntermediateTagDef { + name: "else".to_string(), + args: vec![], + }, + ], + args: vec![], + }]; + + let converted = convert_legacy_tagspecs(legacy); + let intermediates = &converted.libraries[0].tags[0].intermediates; + + assert_eq!(intermediates.len(), 2); + assert_eq!(intermediates[0].name, "elif"); + assert_eq!(intermediates[1].name, "else"); + } +} diff --git a/crates/djls-ide/src/snippets.rs b/crates/djls-ide/src/snippets.rs index 103a0c0a..5662bde6 100644 --- a/crates/djls-ide/src/snippets.rs +++ b/crates/djls-ide/src/snippets.rs @@ -93,7 +93,7 @@ pub fn generate_snippet_for_tag_with_end(tag_name: &str, spec: &TagSpec) -> Stri // If this tag has a required end tag, include it in the snippet if let Some(end_tag) = &spec.end_tag { - if !end_tag.optional { + if end_tag.required { // Add closing %} for the opening tag, newline, cursor position, newline, then end tag snippet.push_str(" %}\n$0\n{% "); snippet.push_str(&end_tag.name); @@ -197,7 +197,7 @@ mod tests { module: "django.template.loader_tags".into(), end_tag: Some(EndTag { name: "endblock".into(), - optional: false, + required: true, args: vec![TagArg::Var { name: "name".into(), required: false, @@ -224,7 +224,7 @@ mod tests { module: "django.template.defaulttags".into(), end_tag: Some(EndTag { name: "endautoescape".into(), - optional: false, + required: true, args: Cow::Borrowed(&[]), }), intermediate_tags: Cow::Borrowed(&[]), diff --git a/crates/djls-semantic/Cargo.toml b/crates/djls-semantic/Cargo.toml index 971d7d17..02bbdafe 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/blocks/builder.rs b/crates/djls-semantic/src/blocks/builder.rs index 85456009..776943ba 100644 --- a/crates/djls-semantic/src/blocks/builder.rs +++ b/crates/djls-semantic/src/blocks/builder.rs @@ -352,19 +352,19 @@ impl<'db> BlockTreeBuilder<'db> { fn finish(&mut self) { while let Some(frame) = self.stack.pop() { - if self.index.is_end_optional(self.db, &frame.opener_name) { - // No explicit closer: finalize last segment to end of input (best-effort) - // We do not know the real end; leave as-is and extend container by opener span only. - self.ops.push(TreeOp::ExtendBlockSpan { - id: frame.container_body, - span: frame.opener_span, - }); - } else { + if self.index.is_end_required(self.db, &frame.opener_name) { self.ops .push(TreeOp::AccumulateDiagnostic(ValidationError::UnclosedTag { tag: frame.opener_name, span: frame.opener_span, })); + } else { + // No explicit closer required: finalize last segment to end of input (best-effort) + // We do not know the real end; leave as-is and extend container by opener span only. + self.ops.push(TreeOp::ExtendBlockSpan { + id: frame.container_body, + span: frame.opener_span, + }); } } } diff --git a/crates/djls-semantic/src/blocks/grammar.rs b/crates/djls-semantic/src/blocks/grammar.rs index 63aa4d13..25a0deb9 100644 --- a/crates/djls-semantic/src/blocks/grammar.rs +++ b/crates/djls-semantic/src/blocks/grammar.rs @@ -19,7 +19,7 @@ pub struct TagIndex<'db> { #[derive(Clone, Debug, PartialEq, Eq)] pub struct EndMeta { - optional: bool, + required: bool, match_args: Vec, } @@ -49,10 +49,10 @@ impl<'db> TagIndex<'db> { TagClass::Unknown } - pub fn is_end_optional(self, db: &'db dyn crate::Db, opener_name: &str) -> bool { + pub fn is_end_required(self, db: &'db dyn crate::Db, opener_name: &str) -> bool { self.openers(db) .get(opener_name) - .is_some_and(|meta| meta.optional) + .is_some_and(|meta| meta.required) } pub fn validate_close( @@ -133,7 +133,7 @@ impl<'db> TagIndex<'db> { .collect(); let meta = EndMeta { - optional: end_tag.optional, + required: end_tag.required, match_args, }; diff --git a/crates/djls-semantic/src/templatetags/builtins.rs b/crates/djls-semantic/src/templatetags/builtins.rs index 7b4355db..3774b2bb 100644 --- a/crates/djls-semantic/src/templatetags/builtins.rs +++ b/crates/djls-semantic/src/templatetags/builtins.rs @@ -22,7 +22,7 @@ static DEFAULTTAGS_PAIRS: &[(&str, &TagSpec)] = &[ module: B(DEFAULTTAGS_MOD), end_tag: Some(EndTag { name: B("endautoescape"), - optional: false, + required: true, args: B(&[]), }), intermediate_tags: B(&[]), @@ -39,7 +39,7 @@ static DEFAULTTAGS_PAIRS: &[(&str, &TagSpec)] = &[ module: B(DEFAULTTAGS_MOD), end_tag: Some(EndTag { name: B("endcomment"), - optional: false, + required: true, args: B(&[]), }), intermediate_tags: B(&[]), @@ -99,7 +99,7 @@ static DEFAULTTAGS_PAIRS: &[(&str, &TagSpec)] = &[ module: B(DEFAULTTAGS_MOD), end_tag: Some(EndTag { name: B("endfilter"), - optional: false, + required: true, args: B(&[]), }), intermediate_tags: B(&[]), @@ -141,7 +141,7 @@ static DEFAULTTAGS_PAIRS: &[(&str, &TagSpec)] = &[ module: B(DEFAULTTAGS_MOD), end_tag: Some(EndTag { name: B("endfor"), - optional: false, + required: true, args: B(&[]), }), intermediate_tags: B(&[IntermediateTag { @@ -174,7 +174,7 @@ static DEFAULTTAGS_PAIRS: &[(&str, &TagSpec)] = &[ module: B(DEFAULTTAGS_MOD), end_tag: Some(EndTag { name: B("endif"), - optional: false, + required: true, args: B(&[]), }), intermediate_tags: B(&[ @@ -202,7 +202,7 @@ static DEFAULTTAGS_PAIRS: &[(&str, &TagSpec)] = &[ module: B(DEFAULTTAGS_MOD), end_tag: Some(EndTag { name: B("endifchanged"), - optional: false, + required: true, args: B(&[]), }), intermediate_tags: B(&[IntermediateTag { @@ -312,7 +312,7 @@ static DEFAULTTAGS_PAIRS: &[(&str, &TagSpec)] = &[ module: B(DEFAULTTAGS_MOD), end_tag: Some(EndTag { name: B("endspaceless"), - optional: false, + required: true, args: B(&[]), }), intermediate_tags: B(&[]), @@ -373,7 +373,7 @@ static DEFAULTTAGS_PAIRS: &[(&str, &TagSpec)] = &[ module: B(DEFAULTTAGS_MOD), end_tag: Some(EndTag { name: B("endverbatim"), - optional: false, + required: true, args: B(&[]), }), intermediate_tags: B(&[]), @@ -419,7 +419,7 @@ static DEFAULTTAGS_PAIRS: &[(&str, &TagSpec)] = &[ module: B(DEFAULTTAGS_MOD), end_tag: Some(EndTag { name: B("endwith"), - optional: false, + required: true, args: B(&[]), }), intermediate_tags: B(&[]), @@ -439,7 +439,7 @@ static LOADER_TAGS_PAIRS: &[(&str, &TagSpec)] = &[ module: B(MOD_LOADER_TAGS), end_tag: Some(EndTag { name: B("endblock"), - optional: false, + required: true, args: B(&[TagArg::Var { name: B("name"), required: false, @@ -499,7 +499,7 @@ static CACHE_PAIRS: &[(&str, &TagSpec)] = &[( module: B(CACHE_MOD), end_tag: Some(EndTag { name: B("endcache"), - optional: false, + required: true, args: B(&[]), }), intermediate_tags: B(&[]), @@ -528,7 +528,7 @@ static I18N_PAIRS: &[(&str, &TagSpec)] = &[ module: B(I18N_MOD), end_tag: Some(EndTag { name: B("endblocktrans"), - optional: false, + required: true, args: B(&[]), }), intermediate_tags: B(BLOCKTRANS_INTERMEDIATE_TAGS), @@ -541,7 +541,7 @@ static I18N_PAIRS: &[(&str, &TagSpec)] = &[ module: B(I18N_MOD), end_tag: Some(EndTag { name: B("endblocktranslate"), - optional: false, + required: true, args: B(&[]), }), intermediate_tags: B(BLOCKTRANS_INTERMEDIATE_TAGS), @@ -621,7 +621,7 @@ static L10N_PAIRS: &[(&str, &TagSpec)] = &[( module: B(L10N_MOD), end_tag: Some(EndTag { name: B("endlocalize"), - optional: false, + required: true, args: B(&[]), }), intermediate_tags: B(&[]), @@ -660,7 +660,7 @@ static TZ_PAIRS: &[(&str, &TagSpec)] = &[ module: B(TZ_MOD), end_tag: Some(EndTag { name: B("endlocaltime"), - optional: false, + required: true, args: B(&[]), }), intermediate_tags: B(&[]), @@ -677,7 +677,7 @@ static TZ_PAIRS: &[(&str, &TagSpec)] = &[ module: B(TZ_MOD), end_tag: Some(EndTag { name: B("endtimezone"), - optional: false, + required: true, args: B(&[]), }), intermediate_tags: B(&[]), diff --git a/crates/djls-semantic/src/templatetags/specs.rs b/crates/djls-semantic/src/templatetags/specs.rs index e96f82b8..d1227b16 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(), + required: true, + 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,45 @@ 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 + | djls_conf::ArgKindDef::Syntax + | djls_conf::ArgKindDef::Modifier => TagArg::Literal { + lit: value.name.into(), + required: value.required, + }, + djls_conf::ArgKindDef::Variable => TagArg::Var { + name: value.name.into(), + required: value.required, }, - djls_conf::ArgTypeDef::Choice { choice } => TagArg::Choice { + djls_conf::ArgKindDef::Any => TagArg::Expr { name: value.name.into(), required: value.required, - choices: choice - .into_iter() - .map(Into::into) - .collect::>() - .into(), }, + djls_conf::ArgKindDef::Assignment => TagArg::Assignment { + name: 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), + } + } } } } @@ -357,7 +385,7 @@ impl From for TagArg { #[derive(Debug, Clone, PartialEq)] pub struct EndTag { pub name: S, - pub optional: bool, + pub required: bool, pub args: L, } @@ -365,7 +393,7 @@ impl From for EndTag { fn from(value: djls_conf::EndTagDef) -> Self { EndTag { name: value.name.into(), - optional: value.optional, + required: value.required, args: value .args .into_iter() @@ -426,7 +454,7 @@ mod tests { module: "django.template.defaulttags".into(), end_tag: Some(EndTag { name: "endif".into(), - optional: false, + required: true, args: Cow::Borrowed(&[]), }), intermediate_tags: Cow::Owned(vec![ @@ -450,7 +478,7 @@ mod tests { module: "django.template.defaulttags".into(), end_tag: Some(EndTag { name: "endfor".into(), - optional: false, + required: true, args: Cow::Borrowed(&[]), }), intermediate_tags: Cow::Owned(vec![ @@ -474,7 +502,7 @@ mod tests { module: "django.template.loader_tags".into(), end_tag: Some(EndTag { name: "endblock".into(), - optional: false, + required: true, args: Cow::Owned(vec![TagArg::Var { name: "name".into(), required: false, @@ -556,7 +584,7 @@ mod tests { let endif_spec = specs.get_end_spec_for_closer("endif").unwrap(); assert_eq!(endif_spec.name.as_ref(), "endif"); - assert!(!endif_spec.optional); + assert!(endif_spec.required); assert_eq!(endif_spec.args.len(), 0); let endblock_spec = specs.get_end_spec_for_closer("endblock").unwrap(); @@ -680,7 +708,7 @@ mod tests { module: "django.template.defaulttags".into(), end_tag: Some(EndTag { name: "endif".into(), - optional: true, // Changed to optional + required: false, // Changed to not required args: Cow::Borrowed(&[]), }), intermediate_tags: Cow::Borrowed(&[]), // Removed intermediates @@ -701,7 +729,7 @@ mod tests { // Check that existing tag was overwritten let if_spec = specs1.get("if").unwrap(); - assert!(if_spec.end_tag.as_ref().unwrap().optional); // Should be optional now + assert!(!if_spec.end_tag.as_ref().unwrap().required); // Should not be required now assert!(if_spec.intermediate_tags.is_empty()); // Should have no intermediates // Check that unaffected tags remain @@ -727,25 +755,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(), + 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::Simple(djls_conf::SimpleArgTypeDef::String), + arg_type: djls_conf::ArgTypeDef::Both, + kind: djls_conf::ArgKindDef::Variable, + count: None, + extra: None, }; - assert!(matches!( - TagArg::from(string_arg_def), - TagArg::String { .. } - )); + 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 +796,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 let end_tag_def = djls_conf::EndTagDef { name: "endtest".to_string(), - optional: true, + required: false, 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.required); assert_eq!(end_tag.args.len(), 0); // Test IntermediateTagDef -> IntermediateTag conversion @@ -783,31 +814,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 +876,46 @@ mod tests { // Test case 2: Settings with user-defined tagspecs let dir = tempfile::TempDir::new().unwrap(); let config_content = r#" -[[tagspecs]] -name = "mytag" +[tagspecs] +version = "0.6.0" + +[[tagspecs.libraries]] 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]] -name = "if" + +[[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 +929,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().required); 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 +942,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().required); + // 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()); } } diff --git a/docs/configuration.md b/docs/configuration.md index c54ce2f2..33714401 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -161,10 +161,13 @@ S100 = "off" # Override: S100 is off ### `tagspecs` -**Default:** `[]` +**Default:** Empty (no custom tagspecs) Define custom template tag specifications for tags not included in Django's built-in or popular third-party libraries. +> **⚠️ DEPRECATED FORMAT**: The v0.4.0 flat `[[tagspecs]]` format is deprecated and will be removed in v5.2.7. +> Please migrate to the [v0.6.0 hierarchical format](../crates/djls-conf/TAGSPECS.md#migration-from-v040). + See the [TagSpecs documentation](../crates/djls-conf/TAGSPECS.md) for detailed schema and examples. ## Configuration Methods diff --git a/docs/tagspecs.md b/docs/tagspecs.md new file mode 100644 index 00000000..fd947ba6 --- /dev/null +++ b/docs/tagspecs.md @@ -0,0 +1,334 @@ +--- +title: TagSpecs +--- + +Configure custom template tag specifications to extend Django Language Server's understanding of your custom template tags. + +## Overview + +[TagSpecs](https://github.com/joshuadavidthomas/djtagspecs) (Tag Specifications) define the structure and behavior of Django template tags, enabling the language server to provide: + +- Autocompletion with context-aware snippets +- Validation and diagnostics for tag arguments +- Block tag matching and nesting validation +- Custom tag documentation on hover + +Django Language Server includes built-in TagSpecs for Django's standard template tags and popular third-party libraries. You only need to define TagSpecs for your custom template tags. + +!!! note "Specification Reference" + + Django Language Server implements the [TagSpecs v0.6.0 specification](https://github.com/joshuadavidthomas/djtagspecs/tree/v0.6.0). See the specification repository for complete schema documentation. + +## Configuration + +TagSpecs can be configured in your project's `djls.toml`, `.djls.toml`, or `pyproject.toml` file: + +=== "`djls.toml`" + + ```toml + [tagspecs] + version = "0.6.0" + + [[tagspecs.libraries]] + module = "myapp.templatetags.custom" + + [[tagspecs.libraries.tags]] + name = "highlight" + type = "block" + + [tagspecs.libraries.tags.end] + name = "endhighlight" + + [[tagspecs.libraries.tags.args]] + name = "language" + kind = "variable" + ``` + +=== "`pyproject.toml`" + + ```toml + [tool.djls.tagspecs] + version = "0.6.0" + + [[tool.djls.tagspecs.libraries]] + module = "myapp.templatetags.custom" + + [[tool.djls.tagspecs.libraries.tags]] + name = "highlight" + type = "block" + + [tool.djls.tagspecs.libraries.tags.end] + name = "endhighlight" + + [[tool.djls.tagspecs.libraries.tags.args]] + name = "language" + kind = "variable" + ``` + + In `pyproject.toml`, prefix all tables with `tool.djls.` - otherwise the structure is identical. + +### Tag Types + +- `"block"` - Block tag with opening and closing tags (e.g., `{% mytag %}...{% endmytag %}`) +- `"standalone"` - Single tag with no closing tag (e.g., `{% mytag %}`) +- `"loader"` - Loader tag that may optionally behave as block (e.g., `{% extends %}`) + +### Argument Kinds + +The `kind` field defines the semantic role of an argument: + +- `"literal"` - Exact literal token (e.g., `"reversed"`) +- `"syntax"` - Mandatory syntactic keyword (e.g., `"in"`, `"as"`) +- `"variable"` - Template variable or filter expression +- `"any"` - Any template expression or literal +- `"assignment"` - Variable assignment pattern +- `"modifier"` - Boolean modifier flag +- `"choice"` - Choice from specific literals (requires `extra.choices`) + +## Common Patterns + +!!! note + Examples below use `djls.toml` format. For `pyproject.toml`, prefix all tables with `tool.djls.` + +### Block Tag with Intermediates + +```toml +[[tagspecs.libraries]] +module = "myapp.templatetags.custom" + +[[tagspecs.libraries.tags]] +name = "switch" +type = "block" + +[tagspecs.libraries.tags.end] +name = "endswitch" + +[[tagspecs.libraries.tags.intermediates]] +name = "case" + +[[tagspecs.libraries.tags.intermediates]] +name = "default" + +[[tagspecs.libraries.tags.args]] +name = "value" +kind = "variable" +``` + +### Tag with Syntax Keywords + +```toml +[[tagspecs.libraries.tags]] +name = "assign" +type = "standalone" + +[[tagspecs.libraries.tags.args]] +name = "value" +kind = "any" + +[[tagspecs.libraries.tags.args]] +name = "as" +kind = "syntax" + +[[tagspecs.libraries.tags.args]] +name = "varname" +kind = "variable" +``` + +### Tag with Choice Arguments + +```toml +[[tagspecs.libraries.tags]] +name = "cache" +type = "block" + +[tagspecs.libraries.tags.end] +name = "endcache" + +[[tagspecs.libraries.tags.args]] +name = "timeout" +kind = "variable" + +[[tagspecs.libraries.tags.args]] +name = "mode" +kind = "choice" + +[tagspecs.libraries.tags.args.extra] +choices = ["public", "private"] +``` + +### Standalone Tag + +```toml +[[tagspecs.libraries.tags]] +name = "render_widget" +type = "standalone" + +[[tagspecs.libraries.tags.args]] +name = "widget_name" +kind = "variable" + +[[tagspecs.libraries.tags.args]] +name = "options" +kind = "any" +required = false +``` + +## Migration from v0.4.0 + +The v0.6.0 format introduces a hierarchical structure that better represents how Django organizes template tags into libraries. + +The migration to the new version will follow the [breaking changes policy](./index.md#breaking-changes), with this deprecation timeline: + +- **v5.2.5** (current): Old format supported with deprecation warnings +- **v5.2.6**: Old format still supported with deprecation warnings +- **v5.2.7**: Old format **removed** - you must migrate to v0.6.0 + +Here are the key changes: + +1. **Hierarchical Structure**: Tags are now grouped by library module + - Old: `[[tagspecs]]` (flat array of tags, each with `module` field) + - New: `[[tagspecs.libraries]]` containing `[[tagspecs.libraries.tags]]` +2. **Tag Type Classification**: Tags now have an explicit `type` field + - Old: Implicitly determined by presence of `end_tag` + - New: Explicit `type = "block"`, `"standalone"`, or `"loader"` +3. **Argument Kind vs Type**: Semantic role separated from positional/keyword designation + - Old: `args = [{ name = "foo", type = "variable" }]` + - New: `args = [{ name = "foo", kind = "variable" }]` + - The `type` field now means positional vs keyword (`"both"`, `"positional"`, `"keyword"`) +4. **End Tag Optional → Required**: Inverted boolean for clarity + - Old: `end_tag = { name = "endif", optional = false }` + - New: `end = { name = "endif", required = true }` +5. **Renamed Fields**: + - `end_tag` → `end` + - `intermediate_tags` → `intermediates` +6. **Choice Arguments**: Moved to extra metadata + - Old: `type = { choice = ["on", "off"] }` + - New: `kind = "choice"` with `extra.choices = ["on", "off"]` + +If you encounter issues during migration, please [open an issue](https://github.com/joshuadavidthomas/django-language-server/issues) with your tagspec configuration. + +### Migration Examples + +#### Example 1: Simple Block Tag + +**Old format (v0.4.0) - DEPRECATED:** +```toml +[[tagspecs]] +name = "block" +module = "django.template.defaulttags" +end_tag = { name = "endblock", optional = false } +args = [ + { name = "name", type = "variable" } +] +``` + +**New format (v0.6.0):** +```toml +[tagspecs] +version = "0.6.0" + +[[tagspecs.libraries]] +module = "django.template.defaulttags" + +[[tagspecs.libraries.tags]] +name = "block" +type = "block" + +[tagspecs.libraries.tags.end] +name = "endblock" +required = true + +[[tagspecs.libraries.tags.args]] +name = "name" +kind = "variable" +``` + +#### Example 2: Multiple Tags from Same Module + +**Old format (v0.4.0) - DEPRECATED:** +```toml +[[tagspecs]] +name = "tag1" +module = "myapp.tags" +args = [{ name = "arg1", type = "variable" }] + +[[tagspecs]] +name = "tag2" +module = "myapp.tags" +args = [{ name = "arg2", type = "literal" }] +``` + +**New format (v0.6.0):** +```toml +[tagspecs] +version = "0.6.0" + +[[tagspecs.libraries]] +module = "myapp.tags" + +[[tagspecs.libraries.tags]] +name = "tag1" +type = "standalone" + +[[tagspecs.libraries.tags.args]] +name = "arg1" +kind = "variable" + +[[tagspecs.libraries.tags]] +name = "tag2" +type = "standalone" + +[[tagspecs.libraries.tags.args]] +name = "arg2" +kind = "literal" +``` + +#### Example 3: Choice Arguments + +**Old format (v0.4.0) - DEPRECATED:** +```toml +[[tagspecs]] +name = "autoescape" +module = "django.template.defaulttags" +end_tag = { name = "endautoescape" } +args = [ + { name = "mode", type = { choice = ["on", "off"] } } +] +``` + +**New format (v0.6.0):** +```toml +[tagspecs] +version = "0.6.0" + +[[tagspecs.libraries]] +module = "django.template.defaulttags" + +[[tagspecs.libraries.tags]] +name = "autoescape" +type = "block" + +[tagspecs.libraries.tags.end] +name = "endautoescape" + +[[tagspecs.libraries.tags.args]] +name = "mode" +kind = "choice" + +[tagspecs.libraries.tags.args.extra] +choices = ["on", "off"] +``` + +#### Example 4: Argument Type Mapping + +**Old argument types → New argument kinds:** + +| Old `type` | New `kind` | Notes | +|------------|------------|-------| +| `"literal"` | `"literal"` or `"syntax"` | Use `"syntax"` for mandatory tokens like `"in"`, `"as"` | +| `"variable"` | `"variable"` | No change | +| `"string"` | `"variable"` | Strings are just variables in v0.6.0 | +| `"expression"` | `"any"` | Renamed for clarity | +| `"assignment"` | `"assignment"` | No change | +| `"varargs"` | `"any"` | Use count or omit for variable-length | +| `{ choice = [...] }` | `"choice"` | Choices moved to `extra.choices` |