mirror of
https://github.com/joshuadavidthomas/django-language-server.git
synced 2025-12-23 08:47:53 +00:00
migrate to TagSpecs v0.5.0 spec
This commit is contained in:
parent
179aea71d9
commit
3a876602b6
6 changed files with 543 additions and 259 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
||||
|
|
|
|||
|
|
@ -17,12 +17,16 @@ use thiserror::Error;
|
|||
|
||||
pub use crate::diagnostics::DiagnosticSeverity;
|
||||
pub use crate::diagnostics::DiagnosticsConfig;
|
||||
pub use crate::tagspecs::ArgKindDef;
|
||||
pub use crate::tagspecs::ArgTypeDef;
|
||||
pub use crate::tagspecs::EndTagDef;
|
||||
pub use crate::tagspecs::IntermediateTagDef;
|
||||
pub use crate::tagspecs::SimpleArgTypeDef;
|
||||
pub use crate::tagspecs::PositionDef;
|
||||
pub use crate::tagspecs::TagArgDef;
|
||||
pub use crate::tagspecs::TagDef;
|
||||
pub use crate::tagspecs::TagLibraryDef;
|
||||
pub use crate::tagspecs::TagSpecDef;
|
||||
pub use crate::tagspecs::TagTypeDef;
|
||||
|
||||
pub(crate) fn project_dirs() -> Option<ProjectDirs> {
|
||||
ProjectDirs::from("", "", "djls")
|
||||
|
|
@ -67,7 +71,7 @@ pub struct Settings {
|
|||
#[serde(default)]
|
||||
pythonpath: Vec<String>,
|
||||
#[serde(default)]
|
||||
tagspecs: Vec<TagSpecDef>,
|
||||
tagspecs: TagSpecDef,
|
||||
#[serde(default)]
|
||||
diagnostics: DiagnosticsConfig,
|
||||
}
|
||||
|
|
@ -88,7 +92,7 @@ impl Settings {
|
|||
if !overrides.pythonpath.is_empty() {
|
||||
settings.pythonpath = overrides.pythonpath;
|
||||
}
|
||||
if !overrides.tagspecs.is_empty() {
|
||||
if !overrides.tagspecs.libraries.is_empty() {
|
||||
settings.tagspecs = overrides.tagspecs;
|
||||
}
|
||||
// For diagnostics, override if the config is non-default
|
||||
|
|
@ -164,7 +168,7 @@ impl Settings {
|
|||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn tagspecs(&self) -> &[TagSpecDef] {
|
||||
pub fn tagspecs(&self) -> &TagSpecDef {
|
||||
&self.tagspecs
|
||||
}
|
||||
|
||||
|
|
@ -197,7 +201,7 @@ mod tests {
|
|||
venv_path: None,
|
||||
django_settings_module: None,
|
||||
pythonpath: vec![],
|
||||
tagspecs: vec![],
|
||||
tagspecs: TagSpecDef::default(),
|
||||
diagnostics: DiagnosticsConfig::default(),
|
||||
}
|
||||
);
|
||||
|
|
@ -505,43 +509,68 @@ T100 = "hint"
|
|||
|
||||
mod tagspecs {
|
||||
use super::*;
|
||||
use crate::tagspecs::ArgTypeDef;
|
||||
use crate::tagspecs::SimpleArgTypeDef;
|
||||
use crate::tagspecs::ArgKindDef;
|
||||
|
||||
#[test]
|
||||
fn test_load_tagspecs_from_djls_toml() {
|
||||
let dir = tempdir().unwrap();
|
||||
let content = r#"
|
||||
[[tagspecs]]
|
||||
name = "mytag"
|
||||
module = "myapp.templatetags.custom"
|
||||
end_tag = { name = "endmytag" }
|
||||
[tagspecs]
|
||||
version = "0.5.0"
|
||||
|
||||
[[tagspecs]]
|
||||
name = "for"
|
||||
[[tagspecs.libraries]]
|
||||
module = "myapp.templatetags.custom"
|
||||
|
||||
[[tagspecs.libraries.tags]]
|
||||
name = "mytag"
|
||||
type = "block"
|
||||
|
||||
[tagspecs.libraries.tags.end]
|
||||
name = "endmytag"
|
||||
|
||||
[[tagspecs.libraries]]
|
||||
module = "django.template.defaulttags"
|
||||
end_tag = { name = "endfor" }
|
||||
intermediate_tags = [{ name = "empty" }]
|
||||
args = [
|
||||
{ name = "item", type = "variable" },
|
||||
{ name = "in", type = "literal" },
|
||||
{ name = "items", type = "variable" }
|
||||
]
|
||||
|
||||
[[tagspecs.libraries.tags]]
|
||||
name = "for"
|
||||
type = "block"
|
||||
|
||||
[tagspecs.libraries.tags.end]
|
||||
name = "endfor"
|
||||
|
||||
[[tagspecs.libraries.tags.intermediates]]
|
||||
name = "empty"
|
||||
|
||||
[[tagspecs.libraries.tags.args]]
|
||||
name = "item"
|
||||
kind = "variable"
|
||||
|
||||
[[tagspecs.libraries.tags.args]]
|
||||
name = "in"
|
||||
kind = "literal"
|
||||
|
||||
[[tagspecs.libraries.tags.args]]
|
||||
name = "items"
|
||||
kind = "variable"
|
||||
"#;
|
||||
fs::write(dir.path().join("djls.toml"), content).unwrap();
|
||||
let settings = Settings::new(Utf8Path::from_path(dir.path()).unwrap(), None).unwrap();
|
||||
|
||||
assert_eq!(settings.tagspecs().len(), 2);
|
||||
assert_eq!(settings.tagspecs().libraries.len(), 2);
|
||||
|
||||
let mytag = &settings.tagspecs()[0];
|
||||
let lib0 = &settings.tagspecs().libraries[0];
|
||||
assert_eq!(lib0.module, "myapp.templatetags.custom");
|
||||
assert_eq!(lib0.tags.len(), 1);
|
||||
let mytag = &lib0.tags[0];
|
||||
assert_eq!(mytag.name, "mytag");
|
||||
assert_eq!(mytag.module, "myapp.templatetags.custom");
|
||||
assert_eq!(mytag.end_tag.as_ref().unwrap().name, "endmytag");
|
||||
assert_eq!(mytag.end.as_ref().unwrap().name, "endmytag");
|
||||
|
||||
let for_tag = &settings.tagspecs()[1];
|
||||
let lib1 = &settings.tagspecs().libraries[1];
|
||||
assert_eq!(lib1.module, "django.template.defaulttags");
|
||||
assert_eq!(lib1.tags.len(), 1);
|
||||
let for_tag = &lib1.tags[0];
|
||||
assert_eq!(for_tag.name, "for");
|
||||
assert_eq!(for_tag.module, "django.template.defaulttags");
|
||||
assert_eq!(for_tag.intermediate_tags.len(), 1);
|
||||
assert_eq!(for_tag.intermediates.len(), 1);
|
||||
assert_eq!(for_tag.args.len(), 3);
|
||||
}
|
||||
|
||||
|
|
@ -552,22 +581,37 @@ args = [
|
|||
[tool.djls]
|
||||
debug = true
|
||||
|
||||
[[tool.djls.tagspecs]]
|
||||
name = "cache"
|
||||
[tool.djls.tagspecs]
|
||||
version = "0.5.0"
|
||||
|
||||
[[tool.djls.tagspecs.libraries]]
|
||||
module = "django.templatetags.cache"
|
||||
end_tag = { name = "endcache", optional = false }
|
||||
args = [
|
||||
{ name = "expire_time", type = "variable" },
|
||||
{ name = "fragment_name", type = "string" }
|
||||
]
|
||||
|
||||
[[tool.djls.tagspecs.libraries.tags]]
|
||||
name = "cache"
|
||||
type = "block"
|
||||
|
||||
[tool.djls.tagspecs.libraries.tags.end]
|
||||
name = "endcache"
|
||||
required = true
|
||||
|
||||
[[tool.djls.tagspecs.libraries.tags.args]]
|
||||
name = "expire_time"
|
||||
kind = "variable"
|
||||
|
||||
[[tool.djls.tagspecs.libraries.tags.args]]
|
||||
name = "fragment_name"
|
||||
kind = "variable"
|
||||
"#;
|
||||
fs::write(dir.path().join("pyproject.toml"), content).unwrap();
|
||||
let settings = Settings::new(Utf8Path::from_path(dir.path()).unwrap(), None).unwrap();
|
||||
|
||||
assert_eq!(settings.tagspecs().len(), 1);
|
||||
let cache = &settings.tagspecs()[0];
|
||||
assert_eq!(settings.tagspecs().libraries.len(), 1);
|
||||
let lib = &settings.tagspecs().libraries[0];
|
||||
assert_eq!(lib.module, "django.templatetags.cache");
|
||||
assert_eq!(lib.tags.len(), 1);
|
||||
let cache = &lib.tags[0];
|
||||
assert_eq!(cache.name, "cache");
|
||||
assert_eq!(cache.module, "django.templatetags.cache");
|
||||
assert_eq!(cache.args.len(), 2);
|
||||
}
|
||||
|
||||
|
|
@ -575,34 +619,42 @@ args = [
|
|||
fn test_arg_types() {
|
||||
let dir = tempdir().unwrap();
|
||||
let content = r#"
|
||||
[[tagspecs]]
|
||||
name = "test"
|
||||
[[tagspecs.libraries]]
|
||||
module = "test.module"
|
||||
args = [
|
||||
{ name = "simple", type = "variable" },
|
||||
{ name = "choice", type = { choice = ["on", "off"] } },
|
||||
{ name = "optional", required = false, type = "string" }
|
||||
]
|
||||
|
||||
[[tagspecs.libraries.tags]]
|
||||
name = "test"
|
||||
type = "standalone"
|
||||
|
||||
[[tagspecs.libraries.tags.args]]
|
||||
name = "simple"
|
||||
kind = "variable"
|
||||
|
||||
[[tagspecs.libraries.tags.args]]
|
||||
name = "choice"
|
||||
kind = "choice"
|
||||
|
||||
[tagspecs.libraries.tags.args.extra]
|
||||
choices = ["on", "off"]
|
||||
|
||||
[[tagspecs.libraries.tags.args]]
|
||||
name = "optional"
|
||||
required = false
|
||||
kind = "variable"
|
||||
"#;
|
||||
fs::write(dir.path().join("djls.toml"), content).unwrap();
|
||||
let settings = Settings::new(Utf8Path::from_path(dir.path()).unwrap(), None).unwrap();
|
||||
|
||||
let test = &settings.tagspecs()[0];
|
||||
let lib = &settings.tagspecs().libraries[0];
|
||||
let test = &lib.tags[0];
|
||||
assert_eq!(test.args.len(), 3);
|
||||
|
||||
// Check simple type
|
||||
assert!(matches!(
|
||||
test.args[0].arg_type,
|
||||
ArgTypeDef::Simple(SimpleArgTypeDef::Variable)
|
||||
));
|
||||
|
||||
// Check choice type
|
||||
if let ArgTypeDef::Choice { ref choice } = test.args[1].arg_type {
|
||||
assert_eq!(choice, &vec!["on".to_string(), "off".to_string()]);
|
||||
} else {
|
||||
panic!("Expected choice type");
|
||||
}
|
||||
// Check simple kind
|
||||
assert!(matches!(test.args[0].kind, ArgKindDef::Variable));
|
||||
|
||||
// Check choice kind
|
||||
assert!(matches!(test.args[1].kind, ArgKindDef::Choice));
|
||||
|
||||
// Check optional arg
|
||||
assert!(!test.args[2].required);
|
||||
}
|
||||
|
|
@ -611,48 +663,69 @@ args = [
|
|||
fn test_intermediate_tags() {
|
||||
let dir = tempdir().unwrap();
|
||||
let content = r#"
|
||||
[[tagspecs]]
|
||||
name = "if"
|
||||
[[tagspecs.libraries]]
|
||||
module = "django.template.defaulttags"
|
||||
end_tag = { name = "endif" }
|
||||
intermediate_tags = [
|
||||
{ name = "elif" },
|
||||
{ name = "else" }
|
||||
]
|
||||
args = [
|
||||
{ name = "condition", type = "expression" }
|
||||
]
|
||||
|
||||
[[tagspecs.libraries.tags]]
|
||||
name = "if"
|
||||
type = "block"
|
||||
|
||||
[tagspecs.libraries.tags.end]
|
||||
name = "endif"
|
||||
|
||||
[[tagspecs.libraries.tags.intermediates]]
|
||||
name = "elif"
|
||||
|
||||
[[tagspecs.libraries.tags.intermediates]]
|
||||
name = "else"
|
||||
|
||||
[[tagspecs.libraries.tags.args]]
|
||||
name = "condition"
|
||||
kind = "any"
|
||||
"#;
|
||||
fs::write(dir.path().join("djls.toml"), content).unwrap();
|
||||
let settings = Settings::new(Utf8Path::from_path(dir.path()).unwrap(), None).unwrap();
|
||||
|
||||
let if_tag = &settings.tagspecs()[0];
|
||||
let lib = &settings.tagspecs().libraries[0];
|
||||
let if_tag = &lib.tags[0];
|
||||
assert_eq!(if_tag.name, "if");
|
||||
|
||||
assert_eq!(if_tag.intermediate_tags.len(), 2);
|
||||
assert_eq!(if_tag.intermediate_tags[0].name, "elif");
|
||||
assert_eq!(if_tag.intermediate_tags[1].name, "else");
|
||||
assert_eq!(if_tag.intermediates.len(), 2);
|
||||
assert_eq!(if_tag.intermediates[0].name, "elif");
|
||||
assert_eq!(if_tag.intermediates[1].name, "else");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_end_tag_with_args() {
|
||||
let dir = tempdir().unwrap();
|
||||
let content = r#"
|
||||
[[tagspecs]]
|
||||
name = "block"
|
||||
[[tagspecs.libraries]]
|
||||
module = "django.template.defaulttags"
|
||||
end_tag = { name = "endblock", args = [{ name = "name", required = false, type = "variable" }] }
|
||||
args = [
|
||||
{ name = "name", type = "variable" }
|
||||
]
|
||||
|
||||
[[tagspecs.libraries.tags]]
|
||||
name = "block"
|
||||
type = "block"
|
||||
|
||||
[tagspecs.libraries.tags.end]
|
||||
name = "endblock"
|
||||
|
||||
[[tagspecs.libraries.tags.end.args]]
|
||||
name = "name"
|
||||
required = false
|
||||
kind = "variable"
|
||||
|
||||
[[tagspecs.libraries.tags.args]]
|
||||
name = "name"
|
||||
kind = "variable"
|
||||
"#;
|
||||
fs::write(dir.path().join("djls.toml"), content).unwrap();
|
||||
let settings = Settings::new(Utf8Path::from_path(dir.path()).unwrap(), None).unwrap();
|
||||
|
||||
let block_tag = &settings.tagspecs()[0];
|
||||
let lib = &settings.tagspecs().libraries[0];
|
||||
let block_tag = &lib.tags[0];
|
||||
assert_eq!(block_tag.name, "block");
|
||||
|
||||
let end_tag = block_tag.end_tag.as_ref().unwrap();
|
||||
let end_tag = block_tag.end.as_ref().unwrap();
|
||||
assert_eq!(end_tag.name, "endblock");
|
||||
assert_eq!(end_tag.args.len(), 1);
|
||||
assert!(!end_tag.args[0].required);
|
||||
|
|
@ -665,66 +738,79 @@ args = [
|
|||
debug = true
|
||||
venv_path = "/path/to/venv"
|
||||
|
||||
[[tagspecs]]
|
||||
name = "custom"
|
||||
[tagspecs]
|
||||
|
||||
[[tagspecs.libraries]]
|
||||
module = "myapp.tags"
|
||||
args = []
|
||||
|
||||
[[tagspecs.libraries.tags]]
|
||||
name = "custom"
|
||||
type = "standalone"
|
||||
"#;
|
||||
fs::write(dir.path().join("djls.toml"), content).unwrap();
|
||||
let settings = Settings::new(Utf8Path::from_path(dir.path()).unwrap(), None).unwrap();
|
||||
|
||||
assert!(settings.debug());
|
||||
assert_eq!(settings.tagspecs().libraries.len(), 1);
|
||||
assert_eq!(settings.tagspecs().libraries[0].tags[0].name, "custom");
|
||||
assert_eq!(settings.venv_path(), Some("/path/to/venv"));
|
||||
assert_eq!(settings.tagspecs().len(), 1);
|
||||
assert_eq!(settings.tagspecs()[0].name, "custom");
|
||||
assert!(settings.debug());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_arg_types() {
|
||||
fn test_all_arg_kinds() {
|
||||
let dir = tempdir().unwrap();
|
||||
let content = r#"
|
||||
[[tagspecs]]
|
||||
name = "test_all_types"
|
||||
[tagspecs]
|
||||
|
||||
[[tagspecs.libraries]]
|
||||
module = "test.module"
|
||||
args = [
|
||||
{ name = "literal", type = "literal" },
|
||||
{ name = "variable", type = "variable" },
|
||||
{ name = "string", type = "string" },
|
||||
{ name = "expression", type = "expression" },
|
||||
{ name = "assignment", type = "assignment" },
|
||||
{ name = "varargs", type = "varargs" }
|
||||
]
|
||||
|
||||
[[tagspecs.libraries.tags]]
|
||||
name = "test_all_kinds"
|
||||
type = "standalone"
|
||||
|
||||
[[tagspecs.libraries.tags.args]]
|
||||
name = "literal"
|
||||
kind = "literal"
|
||||
|
||||
[[tagspecs.libraries.tags.args]]
|
||||
name = "variable"
|
||||
kind = "variable"
|
||||
|
||||
[[tagspecs.libraries.tags.args]]
|
||||
name = "any"
|
||||
kind = "any"
|
||||
|
||||
[[tagspecs.libraries.tags.args]]
|
||||
name = "syntax"
|
||||
kind = "syntax"
|
||||
|
||||
[[tagspecs.libraries.tags.args]]
|
||||
name = "assignment"
|
||||
kind = "assignment"
|
||||
|
||||
[[tagspecs.libraries.tags.args]]
|
||||
name = "modifier"
|
||||
kind = "modifier"
|
||||
|
||||
[[tagspecs.libraries.tags.args]]
|
||||
name = "choice"
|
||||
kind = "choice"
|
||||
"#;
|
||||
fs::write(dir.path().join("djls.toml"), content).unwrap();
|
||||
let settings = Settings::new(Utf8Path::from_path(dir.path()).unwrap(), None).unwrap();
|
||||
|
||||
let test = &settings.tagspecs()[0];
|
||||
assert_eq!(test.args.len(), 6);
|
||||
let lib = &settings.tagspecs().libraries[0];
|
||||
let test = &lib.tags[0];
|
||||
assert_eq!(test.args.len(), 7);
|
||||
|
||||
assert!(matches!(
|
||||
test.args[0].arg_type,
|
||||
ArgTypeDef::Simple(SimpleArgTypeDef::Literal)
|
||||
));
|
||||
assert!(matches!(
|
||||
test.args[1].arg_type,
|
||||
ArgTypeDef::Simple(SimpleArgTypeDef::Variable)
|
||||
));
|
||||
assert!(matches!(
|
||||
test.args[2].arg_type,
|
||||
ArgTypeDef::Simple(SimpleArgTypeDef::String)
|
||||
));
|
||||
assert!(matches!(
|
||||
test.args[3].arg_type,
|
||||
ArgTypeDef::Simple(SimpleArgTypeDef::Expression)
|
||||
));
|
||||
assert!(matches!(
|
||||
test.args[4].arg_type,
|
||||
ArgTypeDef::Simple(SimpleArgTypeDef::Assignment)
|
||||
));
|
||||
assert!(matches!(
|
||||
test.args[5].arg_type,
|
||||
ArgTypeDef::Simple(SimpleArgTypeDef::VarArgs)
|
||||
));
|
||||
assert!(matches!(test.args[0].kind, ArgKindDef::Literal));
|
||||
assert!(matches!(test.args[1].kind, ArgKindDef::Variable));
|
||||
assert!(matches!(test.args[2].kind, ArgKindDef::Any));
|
||||
assert!(matches!(test.args[3].kind, ArgKindDef::Syntax));
|
||||
assert!(matches!(test.args[4].kind, ArgKindDef::Assignment));
|
||||
assert!(matches!(test.args[5].kind, ArgKindDef::Modifier));
|
||||
assert!(matches!(test.args[6].kind, ArgKindDef::Choice));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,79 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
/// A single tag specification
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
/// Root TagSpec document (v0.5.0)
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Default)]
|
||||
#[serde(default)]
|
||||
pub struct TagSpecDef {
|
||||
/// Tag name (e.g., "for", "if", "cache")
|
||||
pub name: String,
|
||||
/// Module where this tag is defined (e.g., "django.template.defaulttags")
|
||||
/// Specification version (defaults to "0.5.0")
|
||||
#[serde(default = "default_version")]
|
||||
pub version: String,
|
||||
/// Template engine (defaults to "django")
|
||||
#[serde(default = "default_engine")]
|
||||
pub engine: String,
|
||||
/// Engine version constraint (PEP 440 for Django)
|
||||
#[serde(default)]
|
||||
pub requires_engine: Option<String>,
|
||||
/// References to parent documents for overlay composition
|
||||
#[serde(default)]
|
||||
pub extends: Vec<String>,
|
||||
/// Tag libraries grouped by module
|
||||
#[serde(default)]
|
||||
pub libraries: Vec<TagLibraryDef>,
|
||||
/// Extra metadata for extensibility
|
||||
#[serde(default)]
|
||||
pub extra: Option<HashMap<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
/// Tag library grouping tags by module
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
pub struct TagLibraryDef {
|
||||
/// Dotted Python import path (e.g., "django.template.defaulttags")
|
||||
pub module: String,
|
||||
/// Optional end tag specification
|
||||
/// Engine version constraint for this library
|
||||
#[serde(default)]
|
||||
pub end_tag: Option<EndTagDef>,
|
||||
/// Optional intermediate tags (e.g., "elif", "else" for "if" tag)
|
||||
pub requires_engine: Option<String>,
|
||||
/// Tags exposed by this library
|
||||
#[serde(default)]
|
||||
pub intermediate_tags: Vec<IntermediateTagDef>,
|
||||
/// Tag arguments specification
|
||||
pub tags: Vec<TagDef>,
|
||||
/// Extra metadata
|
||||
#[serde(default)]
|
||||
pub extra: Option<HashMap<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
/// Individual tag specification
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
pub struct TagDef {
|
||||
/// Tag name (e.g., "for", "if", "url")
|
||||
pub name: String,
|
||||
/// Tag type: block, loader, or standalone
|
||||
#[serde(rename = "type")]
|
||||
pub tag_type: TagTypeDef,
|
||||
/// End tag specification (auto-synthesized for block tags if omitted)
|
||||
#[serde(default)]
|
||||
pub end: Option<EndTagDef>,
|
||||
/// Intermediate tags (e.g., "elif", "else" for "if")
|
||||
#[serde(default)]
|
||||
pub intermediates: Vec<IntermediateTagDef>,
|
||||
/// Opening tag arguments
|
||||
#[serde(default)]
|
||||
pub args: Vec<TagArgDef>,
|
||||
/// Extra metadata
|
||||
#[serde(default)]
|
||||
pub extra: Option<HashMap<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
/// Tag type classification
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TagTypeDef {
|
||||
/// Block tag with opening/closing tags
|
||||
Block,
|
||||
/// Loader tag (may optionally behave as block)
|
||||
Loader,
|
||||
/// Standalone tag (no closing tag)
|
||||
Standalone,
|
||||
}
|
||||
|
||||
/// End tag specification
|
||||
|
|
@ -23,22 +81,47 @@ pub struct TagSpecDef {
|
|||
pub struct EndTagDef {
|
||||
/// End tag name (e.g., "endfor", "endif")
|
||||
pub name: String,
|
||||
/// Whether the end tag is optional
|
||||
#[serde(default)]
|
||||
pub optional: bool,
|
||||
/// Optional arguments for the end tag
|
||||
/// Whether the end tag must appear explicitly
|
||||
#[serde(default = "default_true")]
|
||||
pub required: bool,
|
||||
/// End tag arguments
|
||||
#[serde(default)]
|
||||
pub args: Vec<TagArgDef>,
|
||||
/// Extra metadata
|
||||
#[serde(default)]
|
||||
pub extra: Option<HashMap<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
/// Intermediate tag specification
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
pub struct IntermediateTagDef {
|
||||
/// Intermediate tag name (e.g., "elif", "else")
|
||||
/// Intermediate tag name (e.g., "elif", "else", "empty")
|
||||
pub name: String,
|
||||
/// Optional arguments for the end tag
|
||||
/// Intermediate tag arguments
|
||||
#[serde(default)]
|
||||
pub args: Vec<TagArgDef>,
|
||||
/// Minimum occurrence count
|
||||
#[serde(default)]
|
||||
pub min: Option<usize>,
|
||||
/// Maximum occurrence count
|
||||
#[serde(default)]
|
||||
pub max: Option<usize>,
|
||||
/// Positioning constraint
|
||||
#[serde(default = "default_position")]
|
||||
pub position: PositionDef,
|
||||
/// Extra metadata
|
||||
#[serde(default)]
|
||||
pub extra: Option<HashMap<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
/// Intermediate tag positioning
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PositionDef {
|
||||
/// Can appear anywhere
|
||||
Any,
|
||||
/// Must be last before end tag
|
||||
Last,
|
||||
}
|
||||
|
||||
/// Tag argument specification
|
||||
|
|
@ -49,33 +132,67 @@ pub struct TagArgDef {
|
|||
/// Whether the argument is required
|
||||
#[serde(default = "default_true")]
|
||||
pub required: bool,
|
||||
/// Argument type
|
||||
#[serde(rename = "type")]
|
||||
/// Argument type: positional, keyword, or both
|
||||
#[serde(rename = "type", default = "default_arg_type")]
|
||||
pub arg_type: ArgTypeDef,
|
||||
/// Argument kind (semantic role)
|
||||
pub kind: ArgKindDef,
|
||||
/// Exact token count (null means variable)
|
||||
#[serde(default)]
|
||||
pub count: Option<usize>,
|
||||
/// Extra metadata
|
||||
#[serde(default)]
|
||||
pub extra: Option<HashMap<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
/// Argument type specification
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
#[serde(untagged)]
|
||||
pub enum ArgTypeDef {
|
||||
/// Simple type like "variable", "string", etc.
|
||||
Simple(SimpleArgTypeDef),
|
||||
/// Choice from a list of values
|
||||
Choice { choice: Vec<String> },
|
||||
}
|
||||
|
||||
/// Simple argument types
|
||||
/// Argument type (positional vs keyword)
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SimpleArgTypeDef {
|
||||
Literal,
|
||||
Variable,
|
||||
String,
|
||||
Expression,
|
||||
pub enum ArgTypeDef {
|
||||
/// Can be positional or keyword
|
||||
Both,
|
||||
/// Must be positional
|
||||
Positional,
|
||||
/// Must be keyword
|
||||
Keyword,
|
||||
}
|
||||
|
||||
/// Argument kind (semantic classification)
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ArgKindDef {
|
||||
/// Any template expression or literal
|
||||
Any,
|
||||
/// Variable assignment (e.g., "as varname")
|
||||
Assignment,
|
||||
VarArgs,
|
||||
/// Choice from specific literals
|
||||
Choice,
|
||||
/// Literal token
|
||||
Literal,
|
||||
/// Boolean modifier (e.g., "reversed")
|
||||
Modifier,
|
||||
/// Mandatory syntactic token (e.g., "in")
|
||||
Syntax,
|
||||
/// Template variable or filter expression
|
||||
Variable,
|
||||
}
|
||||
|
||||
fn default_version() -> String {
|
||||
"0.5.0".to_string()
|
||||
}
|
||||
|
||||
fn default_engine() -> String {
|
||||
"django".to_string()
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_position() -> PositionDef {
|
||||
PositionDef::Any
|
||||
}
|
||||
|
||||
fn default_arg_type() -> ArgTypeDef {
|
||||
ArgTypeDef::Both
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ walkdir = { workspace = true }
|
|||
|
||||
[dev-dependencies]
|
||||
insta = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
[lints]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use std::borrow::Cow;
|
||||
use std::borrow::Cow::Borrowed as B;
|
||||
use std::collections::hash_map::IntoIter;
|
||||
use std::collections::hash_map::Iter;
|
||||
use std::ops::Deref;
|
||||
|
|
@ -154,13 +155,16 @@ impl From<&djls_conf::Settings> for TagSpecs {
|
|||
// Start with built-in specs
|
||||
let mut specs = crate::templatetags::django_builtin_specs();
|
||||
|
||||
// Convert and merge user-defined tagspecs
|
||||
// Convert and merge user-defined tagspecs from all libraries
|
||||
let mut user_specs = FxHashMap::default();
|
||||
for tagspec_def in settings.tagspecs() {
|
||||
// Clone because we're consuming the tagspec_def in the conversion
|
||||
let name = tagspec_def.name.clone();
|
||||
let tagspec: TagSpec = tagspec_def.clone().into();
|
||||
user_specs.insert(name, tagspec);
|
||||
let tagspec_doc = settings.tagspecs();
|
||||
|
||||
for library in &tagspec_doc.libraries {
|
||||
for tag_def in &library.tags {
|
||||
let name = tag_def.name.clone();
|
||||
let tagspec: TagSpec = (tag_def.clone(), library.module.clone()).into();
|
||||
user_specs.insert(name, tagspec);
|
||||
}
|
||||
}
|
||||
|
||||
// Merge user specs into built-in specs (user specs override built-ins)
|
||||
|
|
@ -180,18 +184,39 @@ pub struct TagSpec {
|
|||
pub args: L<TagArg>,
|
||||
}
|
||||
|
||||
impl From<djls_conf::TagSpecDef> for TagSpec {
|
||||
fn from(value: djls_conf::TagSpecDef) -> Self {
|
||||
impl From<(djls_conf::TagDef, String)> for TagSpec {
|
||||
fn from((tag_def, module): (djls_conf::TagDef, String)) -> Self {
|
||||
let end_tag = match tag_def.tag_type {
|
||||
djls_conf::TagTypeDef::Block => {
|
||||
// Block tags: synthesize end tag if not provided
|
||||
tag_def.end.map(Into::into).or_else(|| {
|
||||
Some(EndTag {
|
||||
name: format!("end{}", tag_def.name).into(),
|
||||
optional: false,
|
||||
args: B(&[]),
|
||||
})
|
||||
})
|
||||
}
|
||||
djls_conf::TagTypeDef::Loader => {
|
||||
// Loader tags: use end tag if provided, otherwise None
|
||||
tag_def.end.map(Into::into)
|
||||
}
|
||||
djls_conf::TagTypeDef::Standalone => {
|
||||
// Standalone tags: must not have end tag
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
TagSpec {
|
||||
module: value.module.into(),
|
||||
end_tag: value.end_tag.map(Into::into),
|
||||
intermediate_tags: value
|
||||
.intermediate_tags
|
||||
module: module.into(),
|
||||
end_tag,
|
||||
intermediate_tags: tag_def
|
||||
.intermediates
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect::<Vec<_>>()
|
||||
.into(),
|
||||
args: value
|
||||
args: tag_def
|
||||
.args
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
|
|
@ -314,42 +339,51 @@ impl TagArg {
|
|||
|
||||
impl From<djls_conf::TagArgDef> for TagArg {
|
||||
fn from(value: djls_conf::TagArgDef) -> Self {
|
||||
match value.arg_type {
|
||||
djls_conf::ArgTypeDef::Simple(simple) => match simple {
|
||||
djls_conf::SimpleArgTypeDef::Literal => TagArg::Literal {
|
||||
lit: value.name.into(),
|
||||
required: value.required,
|
||||
},
|
||||
djls_conf::SimpleArgTypeDef::Variable => TagArg::Var {
|
||||
name: value.name.into(),
|
||||
required: value.required,
|
||||
},
|
||||
djls_conf::SimpleArgTypeDef::String => TagArg::String {
|
||||
name: value.name.into(),
|
||||
required: value.required,
|
||||
},
|
||||
djls_conf::SimpleArgTypeDef::Expression => TagArg::Expr {
|
||||
name: value.name.into(),
|
||||
required: value.required,
|
||||
},
|
||||
djls_conf::SimpleArgTypeDef::Assignment => TagArg::Assignment {
|
||||
name: value.name.into(),
|
||||
required: value.required,
|
||||
},
|
||||
djls_conf::SimpleArgTypeDef::VarArgs => TagArg::VarArgs {
|
||||
name: value.name.into(),
|
||||
required: value.required,
|
||||
},
|
||||
match value.kind {
|
||||
djls_conf::ArgKindDef::Literal => TagArg::Literal {
|
||||
lit: value.name.into(),
|
||||
required: value.required,
|
||||
},
|
||||
djls_conf::ArgTypeDef::Choice { choice } => TagArg::Choice {
|
||||
djls_conf::ArgKindDef::Variable => TagArg::Var {
|
||||
name: value.name.into(),
|
||||
required: value.required,
|
||||
choices: choice
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect::<Vec<_>>()
|
||||
.into(),
|
||||
},
|
||||
djls_conf::ArgKindDef::Syntax => TagArg::Literal {
|
||||
lit: value.name.into(),
|
||||
required: value.required,
|
||||
},
|
||||
djls_conf::ArgKindDef::Any => TagArg::Expr {
|
||||
name: value.name.into(),
|
||||
required: value.required,
|
||||
},
|
||||
djls_conf::ArgKindDef::Assignment => TagArg::Assignment {
|
||||
name: value.name.into(),
|
||||
required: value.required,
|
||||
},
|
||||
djls_conf::ArgKindDef::Modifier => TagArg::Literal {
|
||||
lit: value.name.into(),
|
||||
required: value.required,
|
||||
},
|
||||
djls_conf::ArgKindDef::Choice => {
|
||||
// Extract choices from extra metadata
|
||||
let choices: Vec<S> = value
|
||||
.extra
|
||||
.as_ref()
|
||||
.and_then(|e| e.get("choices"))
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| Cow::Owned(s.to_string())))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
TagArg::Choice {
|
||||
name: value.name.into(),
|
||||
required: value.required,
|
||||
choices: Cow::Owned(choices),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -365,7 +399,7 @@ impl From<djls_conf::EndTagDef> for EndTag {
|
|||
fn from(value: djls_conf::EndTagDef) -> Self {
|
||||
EndTag {
|
||||
name: value.name.into(),
|
||||
optional: value.optional,
|
||||
optional: !value.required, // Invert: new spec uses 'required', runtime uses 'optional'
|
||||
args: value
|
||||
.args
|
||||
.into_iter()
|
||||
|
|
@ -727,25 +761,40 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_conversion_from_conf_types() {
|
||||
// Test TagArgDef -> TagArg conversion for different arg types
|
||||
let string_arg_def = djls_conf::TagArgDef {
|
||||
name: "test".to_string(),
|
||||
required: true,
|
||||
arg_type: djls_conf::ArgTypeDef::Simple(djls_conf::SimpleArgTypeDef::String),
|
||||
};
|
||||
assert!(matches!(
|
||||
TagArg::from(string_arg_def),
|
||||
TagArg::String { .. }
|
||||
));
|
||||
use std::collections::HashMap;
|
||||
|
||||
// Test TagArgDef -> TagArg conversion for Variable kind
|
||||
let var_arg_def = djls_conf::TagArgDef {
|
||||
name: "test_arg".to_string(),
|
||||
required: true,
|
||||
arg_type: djls_conf::ArgTypeDef::Both,
|
||||
kind: djls_conf::ArgKindDef::Variable,
|
||||
count: None,
|
||||
extra: None,
|
||||
};
|
||||
let arg = TagArg::from(var_arg_def);
|
||||
assert!(matches!(arg, TagArg::Var { .. }));
|
||||
if let TagArg::Var { name, required } = arg {
|
||||
assert_eq!(name.as_ref(), "test_arg");
|
||||
assert!(required);
|
||||
}
|
||||
|
||||
// Test choice argument with extra metadata
|
||||
let mut choice_extra = HashMap::new();
|
||||
choice_extra.insert(
|
||||
"choices".to_string(),
|
||||
serde_json::json!(["on", "off"]),
|
||||
);
|
||||
let choice_arg_def = djls_conf::TagArgDef {
|
||||
name: "mode".to_string(),
|
||||
required: false,
|
||||
arg_type: djls_conf::ArgTypeDef::Choice {
|
||||
choice: vec!["on".to_string(), "off".to_string()],
|
||||
},
|
||||
arg_type: djls_conf::ArgTypeDef::Both,
|
||||
kind: djls_conf::ArgKindDef::Choice,
|
||||
count: Some(1),
|
||||
extra: Some(choice_extra),
|
||||
};
|
||||
if let TagArg::Choice { choices, .. } = TagArg::from(choice_arg_def) {
|
||||
if let TagArg::Choice { choices, required, .. } = TagArg::from(choice_arg_def) {
|
||||
assert!(!required);
|
||||
assert_eq!(choices.len(), 2);
|
||||
assert_eq!(choices[0].as_ref(), "on");
|
||||
assert_eq!(choices[1].as_ref(), "off");
|
||||
|
|
@ -753,28 +802,16 @@ mod tests {
|
|||
panic!("Expected Choice variant");
|
||||
}
|
||||
|
||||
// Test TagArgDef -> TagArg conversion for Variable type
|
||||
let tag_arg_def = djls_conf::TagArgDef {
|
||||
name: "test_arg".to_string(),
|
||||
required: true,
|
||||
arg_type: djls_conf::ArgTypeDef::Simple(djls_conf::SimpleArgTypeDef::Variable),
|
||||
};
|
||||
let arg = TagArg::from(tag_arg_def);
|
||||
assert!(matches!(arg, TagArg::Var { .. }));
|
||||
if let TagArg::Var { name, required } = arg {
|
||||
assert_eq!(name.as_ref(), "test_arg");
|
||||
assert!(required);
|
||||
}
|
||||
|
||||
// Test EndTagDef -> EndTag conversion
|
||||
// Test EndTagDef -> EndTag conversion (note: required inverted to optional)
|
||||
let end_tag_def = djls_conf::EndTagDef {
|
||||
name: "endtest".to_string(),
|
||||
optional: true,
|
||||
required: false, // Becomes optional=true in runtime
|
||||
args: vec![],
|
||||
extra: None,
|
||||
};
|
||||
let end_tag = EndTag::from(end_tag_def);
|
||||
assert_eq!(end_tag.name.as_ref(), "endtest");
|
||||
assert!(end_tag.optional);
|
||||
assert!(end_tag.optional); // required=false becomes optional=true
|
||||
assert_eq!(end_tag.args.len(), 0);
|
||||
|
||||
// Test IntermediateTagDef -> IntermediateTag conversion
|
||||
|
|
@ -783,31 +820,45 @@ mod tests {
|
|||
args: vec![djls_conf::TagArgDef {
|
||||
name: "condition".to_string(),
|
||||
required: true,
|
||||
arg_type: djls_conf::ArgTypeDef::Simple(djls_conf::SimpleArgTypeDef::Expression),
|
||||
arg_type: djls_conf::ArgTypeDef::Both,
|
||||
kind: djls_conf::ArgKindDef::Any,
|
||||
count: None,
|
||||
extra: None,
|
||||
}],
|
||||
min: None,
|
||||
max: None,
|
||||
position: djls_conf::PositionDef::Any,
|
||||
extra: None,
|
||||
};
|
||||
let intermediate = IntermediateTag::from(intermediate_def);
|
||||
assert_eq!(intermediate.name.as_ref(), "elif");
|
||||
assert_eq!(intermediate.args.len(), 1);
|
||||
assert_eq!(intermediate.args[0].name().as_ref(), "condition");
|
||||
|
||||
// Test full TagSpecDef -> TagSpec conversion
|
||||
let tagspec_def = djls_conf::TagSpecDef {
|
||||
// Test full TagDef -> TagSpec conversion with module
|
||||
let tag_def = djls_conf::TagDef {
|
||||
name: "custom".to_string(),
|
||||
module: "myapp.templatetags".to_string(), // Note: module is ignored in conversion
|
||||
end_tag: Some(djls_conf::EndTagDef {
|
||||
tag_type: djls_conf::TagTypeDef::Block,
|
||||
end: Some(djls_conf::EndTagDef {
|
||||
name: "endcustom".to_string(),
|
||||
optional: false,
|
||||
required: true,
|
||||
args: vec![],
|
||||
extra: None,
|
||||
}),
|
||||
intermediate_tags: vec![djls_conf::IntermediateTagDef {
|
||||
intermediates: vec![djls_conf::IntermediateTagDef {
|
||||
name: "branch".to_string(),
|
||||
args: vec![],
|
||||
min: None,
|
||||
max: None,
|
||||
position: djls_conf::PositionDef::Any,
|
||||
extra: None,
|
||||
}],
|
||||
args: vec![],
|
||||
extra: None,
|
||||
};
|
||||
let tagspec = TagSpec::from(tagspec_def);
|
||||
// Name field was removed from TagSpec
|
||||
let module = "myapp.templatetags".to_string();
|
||||
let tagspec = TagSpec::from((tag_def, module));
|
||||
assert_eq!(tagspec.module.as_ref(), "myapp.templatetags");
|
||||
assert!(tagspec.end_tag.is_some());
|
||||
assert_eq!(tagspec.end_tag.as_ref().unwrap().name.as_ref(), "endcustom");
|
||||
assert_eq!(tagspec.intermediate_tags.len(), 1);
|
||||
|
|
@ -831,20 +882,46 @@ mod tests {
|
|||
// Test case 2: Settings with user-defined tagspecs
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let config_content = r#"
|
||||
[[tagspecs]]
|
||||
name = "mytag"
|
||||
module = "myapp.templatetags.custom"
|
||||
end_tag = { name = "endmytag", optional = false }
|
||||
intermediate_tags = [{ name = "mybranch" }]
|
||||
args = [
|
||||
{ name = "arg1", type = "variable", required = true },
|
||||
{ name = "arg2", type = { choice = ["on", "off"] }, required = false }
|
||||
]
|
||||
[tagspecs]
|
||||
version = "0.5.0"
|
||||
|
||||
[[tagspecs]]
|
||||
name = "if"
|
||||
[[tagspecs.libraries]]
|
||||
module = "myapp.templatetags.custom"
|
||||
|
||||
[[tagspecs.libraries.tags]]
|
||||
name = "mytag"
|
||||
type = "block"
|
||||
|
||||
[tagspecs.libraries.tags.end]
|
||||
name = "endmytag"
|
||||
required = true
|
||||
|
||||
[[tagspecs.libraries.tags.intermediates]]
|
||||
name = "mybranch"
|
||||
|
||||
[[tagspecs.libraries.tags.args]]
|
||||
name = "arg1"
|
||||
kind = "variable"
|
||||
required = true
|
||||
|
||||
[[tagspecs.libraries.tags.args]]
|
||||
name = "arg2"
|
||||
kind = "choice"
|
||||
required = false
|
||||
|
||||
[tagspecs.libraries.tags.args.extra]
|
||||
choices = ["on", "off"]
|
||||
|
||||
[[tagspecs.libraries]]
|
||||
module = "myapp.overrides"
|
||||
end_tag = { name = "endif", optional = true }
|
||||
|
||||
[[tagspecs.libraries.tags]]
|
||||
name = "if"
|
||||
type = "block"
|
||||
|
||||
[tagspecs.libraries.tags.end]
|
||||
name = "endif"
|
||||
required = false
|
||||
"#;
|
||||
fs::write(dir.path().join("djls.toml"), config_content).unwrap();
|
||||
|
||||
|
|
@ -858,9 +935,9 @@ end_tag = { name = "endif", optional = true }
|
|||
|
||||
// Should have user-defined custom tag
|
||||
let mytag = specs.get("mytag").expect("mytag should be present");
|
||||
// Name field was removed from TagSpec
|
||||
assert_eq!(mytag.module.as_ref(), "myapp.templatetags.custom");
|
||||
assert_eq!(mytag.end_tag.as_ref().unwrap().name.as_ref(), "endmytag");
|
||||
assert!(!mytag.end_tag.as_ref().unwrap().optional);
|
||||
assert!(!mytag.end_tag.as_ref().unwrap().optional); // required=true becomes optional=false
|
||||
assert_eq!(mytag.intermediate_tags.len(), 1);
|
||||
assert_eq!(mytag.intermediate_tags[0].name.as_ref(), "mybranch");
|
||||
assert_eq!(mytag.args.len(), 2);
|
||||
|
|
@ -871,9 +948,9 @@ end_tag = { name = "endif", optional = true }
|
|||
|
||||
// Should have overridden built-in "if" tag
|
||||
let if_tag = specs.get("if").expect("if tag should be present");
|
||||
assert!(if_tag.end_tag.as_ref().unwrap().optional); // Changed to optional
|
||||
// Note: The built-in if tag has intermediate tags, but the override doesn't specify them
|
||||
// The override completely replaces the built-in
|
||||
assert!(if_tag.end_tag.as_ref().unwrap().optional); // required=false becomes optional=true
|
||||
// Note: The built-in if tag has intermediate tags, but the override doesn't specify them
|
||||
// The override completely replaces the built-in
|
||||
assert!(if_tag.intermediate_tags.is_empty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue