mirror of
https://github.com/joshuadavidthomas/django-language-server.git
synced 2025-09-11 12:56:46 +00:00
add typed argspecs for LSP snippets to tagspecs config (#206)
This commit is contained in:
parent
03f18d2211
commit
792bdd8e13
8 changed files with 602 additions and 122 deletions
|
@ -4,6 +4,8 @@
|
|||
//! and generating appropriate completion items for Django templates.
|
||||
|
||||
use djls_project::TemplateTags;
|
||||
use djls_templates::templatetags::generate_snippet_for_tag;
|
||||
use djls_templates::templatetags::TagSpecs;
|
||||
use djls_workspace::FileKind;
|
||||
use djls_workspace::PositionEncoding;
|
||||
use djls_workspace::TextDocument;
|
||||
|
@ -57,6 +59,8 @@ pub fn handle_completion(
|
|||
encoding: PositionEncoding,
|
||||
file_kind: FileKind,
|
||||
template_tags: Option<&TemplateTags>,
|
||||
tag_specs: Option<&TagSpecs>,
|
||||
supports_snippets: bool,
|
||||
) -> Vec<CompletionItem> {
|
||||
// Only handle template files
|
||||
if file_kind != FileKind::Template {
|
||||
|
@ -74,7 +78,7 @@ pub fn handle_completion(
|
|||
};
|
||||
|
||||
// Generate completions based on available template tags
|
||||
generate_template_completions(&context, template_tags)
|
||||
generate_template_completions(&context, template_tags, tag_specs, supports_snippets)
|
||||
}
|
||||
|
||||
/// Extract line information from document at given position
|
||||
|
@ -126,49 +130,49 @@ fn get_line_info(
|
|||
|
||||
/// Analyze a line of template text to determine completion context
|
||||
fn analyze_template_context(line: &str, cursor_offset: usize) -> Option<TemplateTagContext> {
|
||||
if cursor_offset > line.chars().count() {
|
||||
return None;
|
||||
}
|
||||
// Find the last {% before cursor position
|
||||
let prefix = &line[..cursor_offset.min(line.len())];
|
||||
let tag_start = prefix.rfind("{%")?;
|
||||
|
||||
let chars: Vec<char> = line.chars().collect();
|
||||
let prefix = chars[..cursor_offset].iter().collect::<String>();
|
||||
let rest_of_line = chars[cursor_offset..].iter().collect::<String>();
|
||||
let rest_trimmed = rest_of_line.trim_start();
|
||||
// Get the content between {% and cursor
|
||||
let content_start = tag_start + 2;
|
||||
let content = &prefix[content_start..];
|
||||
|
||||
prefix.rfind("{%").map(|tag_start| {
|
||||
let closing_brace = if rest_trimmed.starts_with("%}") {
|
||||
ClosingBrace::FullClose
|
||||
} else if rest_trimmed.starts_with('}') {
|
||||
ClosingBrace::PartialClose
|
||||
} else {
|
||||
ClosingBrace::None
|
||||
};
|
||||
// Check if we need a leading space (no space after {%)
|
||||
let needs_leading_space = content.is_empty() || !content.starts_with(' ');
|
||||
|
||||
let partial_tag_start = tag_start + 2; // Skip "{%"
|
||||
let content_after_tag = if partial_tag_start < prefix.len() {
|
||||
&prefix[partial_tag_start..]
|
||||
} else {
|
||||
""
|
||||
};
|
||||
// Extract the partial tag name
|
||||
let partial_tag = content.trim_start().to_string();
|
||||
|
||||
// Check if we need a leading space - true if there's no space after {%
|
||||
let needs_leading_space =
|
||||
!content_after_tag.starts_with(' ') && !content_after_tag.is_empty();
|
||||
// Check what's after the cursor for closing detection
|
||||
let suffix = &line[cursor_offset.min(line.len())..];
|
||||
let closing_brace = detect_closing_brace(suffix);
|
||||
|
||||
let partial_tag = content_after_tag.trim().to_string();
|
||||
|
||||
TemplateTagContext {
|
||||
Some(TemplateTagContext {
|
||||
partial_tag,
|
||||
closing_brace,
|
||||
needs_leading_space,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Detect what closing brace is present after the cursor
|
||||
fn detect_closing_brace(suffix: &str) -> ClosingBrace {
|
||||
let trimmed = suffix.trim_start();
|
||||
if trimmed.starts_with("%}") {
|
||||
ClosingBrace::FullClose
|
||||
} else if trimmed.starts_with('}') {
|
||||
ClosingBrace::PartialClose
|
||||
} else {
|
||||
ClosingBrace::None
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate Django template tag completion items based on context
|
||||
fn generate_template_completions(
|
||||
context: &TemplateTagContext,
|
||||
template_tags: Option<&TemplateTags>,
|
||||
tag_specs: Option<&TagSpecs>,
|
||||
supports_snippets: bool,
|
||||
) -> Vec<CompletionItem> {
|
||||
let Some(tags) = template_tags else {
|
||||
return Vec::new();
|
||||
|
@ -177,26 +181,48 @@ fn generate_template_completions(
|
|||
let mut completions = Vec::new();
|
||||
|
||||
for tag in tags.iter() {
|
||||
// Filter tags based on partial match
|
||||
if tag.name().starts_with(&context.partial_tag) {
|
||||
// Determine insertion text based on context
|
||||
let mut insert_text = String::new();
|
||||
// Try to get snippet from TagSpecs if available and client supports snippets
|
||||
let (insert_text, insert_format) = if supports_snippets {
|
||||
if let Some(specs) = tag_specs {
|
||||
if let Some(spec) = specs.get(tag.name()) {
|
||||
if spec.args.is_empty() {
|
||||
// No args, use plain text
|
||||
build_plain_insert(tag.name(), context)
|
||||
} else {
|
||||
// Generate snippet from tag spec
|
||||
let mut text = String::new();
|
||||
|
||||
// Add leading space if needed (cursor right after {%)
|
||||
// Add leading space if needed
|
||||
if context.needs_leading_space {
|
||||
insert_text.push(' ');
|
||||
text.push(' ');
|
||||
}
|
||||
|
||||
// Add the tag name
|
||||
insert_text.push_str(tag.name());
|
||||
// Add tag name and snippet arguments
|
||||
text.push_str(&generate_snippet_for_tag(tag.name(), spec));
|
||||
|
||||
// Add closing based on what's already present
|
||||
match context.closing_brace {
|
||||
ClosingBrace::None => insert_text.push_str(" %}"),
|
||||
ClosingBrace::PartialClose => insert_text.push('%'),
|
||||
ClosingBrace::None => text.push_str(" %}"),
|
||||
ClosingBrace::PartialClose => text.push('%'),
|
||||
ClosingBrace::FullClose => {} // No closing needed
|
||||
}
|
||||
|
||||
(text, InsertTextFormat::SNIPPET)
|
||||
}
|
||||
} else {
|
||||
// No spec found, use plain text
|
||||
build_plain_insert(tag.name(), context)
|
||||
}
|
||||
} else {
|
||||
// No specs available, use plain text
|
||||
build_plain_insert(tag.name(), context)
|
||||
}
|
||||
} else {
|
||||
// Client doesn't support snippets
|
||||
build_plain_insert(tag.name(), context)
|
||||
};
|
||||
|
||||
// Create completion item
|
||||
let completion_item = CompletionItem {
|
||||
label: tag.name().clone(),
|
||||
|
@ -204,7 +230,7 @@ fn generate_template_completions(
|
|||
detail: Some(format!("from {}", tag.library())),
|
||||
documentation: tag.doc().map(|doc| Documentation::String(doc.clone())),
|
||||
insert_text: Some(insert_text),
|
||||
insert_text_format: Some(InsertTextFormat::PLAIN_TEXT),
|
||||
insert_text_format: Some(insert_format),
|
||||
filter_text: Some(tag.name().clone()),
|
||||
..Default::default()
|
||||
};
|
||||
|
@ -216,6 +242,28 @@ fn generate_template_completions(
|
|||
completions
|
||||
}
|
||||
|
||||
/// Build plain insert text without snippets
|
||||
fn build_plain_insert(tag_name: &str, context: &TemplateTagContext) -> (String, InsertTextFormat) {
|
||||
let mut insert_text = String::new();
|
||||
|
||||
// Add leading space if needed (cursor right after {%)
|
||||
if context.needs_leading_space {
|
||||
insert_text.push(' ');
|
||||
}
|
||||
|
||||
// Add the tag name
|
||||
insert_text.push_str(tag_name);
|
||||
|
||||
// Add closing based on what's already present
|
||||
match context.closing_brace {
|
||||
ClosingBrace::None => insert_text.push_str(" %}"),
|
||||
ClosingBrace::PartialClose => insert_text.push('%'),
|
||||
ClosingBrace::FullClose => {} // No closing needed
|
||||
}
|
||||
|
||||
(insert_text, InsertTextFormat::PLAIN_TEXT)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
@ -286,7 +334,7 @@ mod tests {
|
|||
closing_brace: ClosingBrace::None,
|
||||
};
|
||||
|
||||
let completions = generate_template_completions(&context, None);
|
||||
let completions = generate_template_completions(&context, None, None, false);
|
||||
|
||||
assert!(completions.is_empty());
|
||||
}
|
||||
|
|
|
@ -370,6 +370,8 @@ impl LanguageServer for DjangoLanguageServer {
|
|||
let encoding = session.position_encoding();
|
||||
let file_kind = FileKind::from_path(&path);
|
||||
let template_tags = session.project().and_then(|p| p.template_tags());
|
||||
let tag_specs = session.with_db(djls_templates::Db::tag_specs);
|
||||
let supports_snippets = true; // TODO: Get from client capabilities
|
||||
|
||||
let completions = crate::completions::handle_completion(
|
||||
&document,
|
||||
|
@ -377,6 +379,8 @@ impl LanguageServer for DjangoLanguageServer {
|
|||
encoding,
|
||||
file_kind,
|
||||
template_tags,
|
||||
Some(&tag_specs),
|
||||
supports_snippets,
|
||||
);
|
||||
|
||||
if completions.is_empty() {
|
||||
|
|
|
@ -1,6 +1,11 @@
|
|||
mod snippets;
|
||||
mod specs;
|
||||
|
||||
pub use specs::ArgSpec;
|
||||
pub use snippets::generate_snippet_for_tag;
|
||||
pub use snippets::generate_snippet_from_args;
|
||||
pub use specs::Arg;
|
||||
pub use specs::ArgType;
|
||||
pub use specs::SimpleArgType;
|
||||
pub use specs::TagSpecs;
|
||||
|
||||
pub enum TagType {
|
||||
|
|
194
crates/djls-templates/src/templatetags/snippets.rs
Normal file
194
crates/djls-templates/src/templatetags/snippets.rs
Normal file
|
@ -0,0 +1,194 @@
|
|||
use super::specs::Arg;
|
||||
use super::specs::ArgType;
|
||||
use super::specs::SimpleArgType;
|
||||
use super::specs::TagSpec;
|
||||
|
||||
/// Generate an LSP snippet pattern from an array of arguments
|
||||
#[must_use]
|
||||
pub fn generate_snippet_from_args(args: &[Arg]) -> String {
|
||||
let mut parts = Vec::new();
|
||||
let mut placeholder_index = 1;
|
||||
|
||||
for arg in args {
|
||||
// Skip optional args if we haven't seen any required args after them
|
||||
// This prevents generating snippets like: "{% for %}" when everything is optional
|
||||
if !arg.required && parts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let snippet_part = match &arg.arg_type {
|
||||
ArgType::Simple(simple_type) => match simple_type {
|
||||
SimpleArgType::Literal => {
|
||||
if arg.required {
|
||||
// Required literals are just plain text (e.g., "in", "as", "by")
|
||||
arg.name.clone()
|
||||
} else {
|
||||
// Optional literals become placeholders
|
||||
let result = format!("${{{}:{}}}", placeholder_index, arg.name);
|
||||
placeholder_index += 1;
|
||||
result
|
||||
}
|
||||
}
|
||||
SimpleArgType::Variable | SimpleArgType::Expression => {
|
||||
// Variables and expressions become placeholders
|
||||
let result = format!("${{{}:{}}}", placeholder_index, arg.name);
|
||||
placeholder_index += 1;
|
||||
result
|
||||
}
|
||||
SimpleArgType::String => {
|
||||
// Strings get quotes around them
|
||||
let result = format!("\"${{{}:{}}}\"", placeholder_index, arg.name);
|
||||
placeholder_index += 1;
|
||||
result
|
||||
}
|
||||
SimpleArgType::Assignment => {
|
||||
// Assignments use the name as-is (e.g., "var=value")
|
||||
let result = format!("${{{}:{}}}", placeholder_index, arg.name);
|
||||
placeholder_index += 1;
|
||||
result
|
||||
}
|
||||
SimpleArgType::VarArgs => {
|
||||
// Variable arguments, just use the name
|
||||
let result = format!("${{{}:{}}}", placeholder_index, arg.name);
|
||||
placeholder_index += 1;
|
||||
result
|
||||
}
|
||||
},
|
||||
ArgType::Choice { choice } => {
|
||||
// Choice placeholders with options
|
||||
let result = format!("${{{}|{}|}}", placeholder_index, choice.join(","));
|
||||
placeholder_index += 1;
|
||||
result
|
||||
}
|
||||
};
|
||||
|
||||
parts.push(snippet_part);
|
||||
}
|
||||
|
||||
parts.join(" ")
|
||||
}
|
||||
|
||||
/// Generate a complete LSP snippet for a tag including the tag name
|
||||
#[must_use]
|
||||
pub fn generate_snippet_for_tag(tag_name: &str, spec: &TagSpec) -> String {
|
||||
let args_snippet = generate_snippet_from_args(&spec.args);
|
||||
|
||||
if args_snippet.is_empty() {
|
||||
// Tag with no arguments
|
||||
tag_name.to_string()
|
||||
} else {
|
||||
// Tag with arguments
|
||||
format!("{tag_name} {args_snippet}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::templatetags::specs::ArgType;
|
||||
use crate::templatetags::specs::SimpleArgType;
|
||||
|
||||
#[test]
|
||||
fn test_snippet_for_for_tag() {
|
||||
let args = vec![
|
||||
Arg {
|
||||
name: "item".to_string(),
|
||||
required: true,
|
||||
arg_type: ArgType::Simple(SimpleArgType::Variable),
|
||||
},
|
||||
Arg {
|
||||
name: "in".to_string(),
|
||||
required: true,
|
||||
arg_type: ArgType::Simple(SimpleArgType::Literal),
|
||||
},
|
||||
Arg {
|
||||
name: "items".to_string(),
|
||||
required: true,
|
||||
arg_type: ArgType::Simple(SimpleArgType::Variable),
|
||||
},
|
||||
Arg {
|
||||
name: "reversed".to_string(),
|
||||
required: false,
|
||||
arg_type: ArgType::Simple(SimpleArgType::Literal),
|
||||
},
|
||||
];
|
||||
|
||||
let snippet = generate_snippet_from_args(&args);
|
||||
assert_eq!(snippet, "${1:item} in ${2:items} ${3:reversed}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_snippet_for_if_tag() {
|
||||
let args = vec![Arg {
|
||||
name: "condition".to_string(),
|
||||
required: true,
|
||||
arg_type: ArgType::Simple(SimpleArgType::Expression),
|
||||
}];
|
||||
|
||||
let snippet = generate_snippet_from_args(&args);
|
||||
assert_eq!(snippet, "${1:condition}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_snippet_for_autoescape_tag() {
|
||||
let args = vec![Arg {
|
||||
name: "mode".to_string(),
|
||||
required: true,
|
||||
arg_type: ArgType::Choice {
|
||||
choice: vec!["on".to_string(), "off".to_string()],
|
||||
},
|
||||
}];
|
||||
|
||||
let snippet = generate_snippet_from_args(&args);
|
||||
assert_eq!(snippet, "${1|on,off|}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_snippet_for_extends_tag() {
|
||||
let args = vec![Arg {
|
||||
name: "template".to_string(),
|
||||
required: true,
|
||||
arg_type: ArgType::Simple(SimpleArgType::String),
|
||||
}];
|
||||
|
||||
let snippet = generate_snippet_from_args(&args);
|
||||
assert_eq!(snippet, "\"${1:template}\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_snippet_for_csrf_token_tag() {
|
||||
let args = vec![];
|
||||
|
||||
let snippet = generate_snippet_from_args(&args);
|
||||
assert_eq!(snippet, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_snippet_for_url_tag() {
|
||||
let args = vec![
|
||||
Arg {
|
||||
name: "view_name".to_string(),
|
||||
required: true,
|
||||
arg_type: ArgType::Simple(SimpleArgType::String),
|
||||
},
|
||||
Arg {
|
||||
name: "args".to_string(),
|
||||
required: false,
|
||||
arg_type: ArgType::Simple(SimpleArgType::VarArgs),
|
||||
},
|
||||
Arg {
|
||||
name: "as".to_string(),
|
||||
required: false,
|
||||
arg_type: ArgType::Simple(SimpleArgType::Literal),
|
||||
},
|
||||
Arg {
|
||||
name: "varname".to_string(),
|
||||
required: false,
|
||||
arg_type: ArgType::Simple(SimpleArgType::Variable),
|
||||
},
|
||||
];
|
||||
|
||||
let snippet = generate_snippet_from_args(&args);
|
||||
assert_eq!(snippet, "\"${1:view_name}\" ${2:args} ${3:as} ${4:varname}");
|
||||
}
|
||||
}
|
|
@ -207,9 +207,41 @@ pub struct TagSpec {
|
|||
#[serde(default, alias = "intermediates")]
|
||||
pub intermediate_tags: Option<Vec<IntermediateTag>>,
|
||||
#[serde(default)]
|
||||
pub args: Option<ArgSpec>,
|
||||
pub args: Vec<Arg>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Arg {
|
||||
pub name: String,
|
||||
#[serde(default = "default_true")]
|
||||
pub required: bool,
|
||||
#[serde(rename = "type")]
|
||||
pub arg_type: ArgType,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(untagged)]
|
||||
pub enum ArgType {
|
||||
Simple(SimpleArgType),
|
||||
Choice { choice: Vec<String> },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SimpleArgType {
|
||||
Literal,
|
||||
Variable,
|
||||
String,
|
||||
Expression,
|
||||
Assignment,
|
||||
VarArgs,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
// Keep ArgSpec for backward compatibility in EndTag
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct EndTag {
|
||||
#[serde(alias = "tag")]
|
||||
|
@ -217,7 +249,7 @@ pub struct EndTag {
|
|||
#[serde(default)]
|
||||
pub optional: bool,
|
||||
#[serde(default)]
|
||||
pub args: Option<ArgSpec>,
|
||||
pub args: Vec<Arg>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
|
@ -244,14 +276,6 @@ impl<'de> Deserialize<'de> for IntermediateTag {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ArgSpec {
|
||||
#[serde(default)]
|
||||
pub min: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub max: Option<usize>,
|
||||
}
|
||||
|
||||
impl TagSpec {
|
||||
/// Recursive extraction: Check if node is spec, otherwise recurse if table.
|
||||
#[allow(dead_code)]
|
||||
|
@ -405,15 +429,17 @@ mod tests {
|
|||
assert!(specs.get(tag).is_some(), "{tag} tag should be present");
|
||||
}
|
||||
|
||||
// Check that newly added tags are present
|
||||
let additional_tags = ["debug", "firstof", "lorem", "regroup", "widthratio"];
|
||||
|
||||
for tag in additional_tags {
|
||||
assert!(specs.get(tag).is_some(), "{tag} tag should be present");
|
||||
}
|
||||
|
||||
// Check that some tags are still missing
|
||||
let missing_tags = [
|
||||
"debug",
|
||||
"firstof",
|
||||
"lorem",
|
||||
"querystring", // 5.1
|
||||
"regroup",
|
||||
"querystring", // Django 5.1+
|
||||
"resetcycle",
|
||||
"widthratio",
|
||||
];
|
||||
|
||||
for tag in missing_tags {
|
||||
|
@ -452,7 +478,7 @@ end = { tag = "endanothertag", optional = true }
|
|||
Some(EndTag {
|
||||
name: "endmytag".to_string(),
|
||||
optional: false,
|
||||
args: None,
|
||||
args: vec![],
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
|
@ -470,7 +496,25 @@ end = { tag = "endanothertag", optional = true }
|
|||
Some(EndTag {
|
||||
name: "endanothertag".to_string(),
|
||||
optional: true,
|
||||
args: None,
|
||||
args: vec![],
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
my_tag.intermediate_tags,
|
||||
Some(vec![IntermediateTag {
|
||||
name: "mybranch".to_string()
|
||||
}])
|
||||
);
|
||||
|
||||
let another_tag = specs
|
||||
.get("anothertag")
|
||||
.expect("anothertag should be present");
|
||||
assert_eq!(
|
||||
another_tag.end_tag,
|
||||
Some(EndTag {
|
||||
name: "endanothertag".to_string(),
|
||||
optional: true,
|
||||
args: vec![],
|
||||
})
|
||||
);
|
||||
assert!(
|
||||
|
|
|
@ -23,7 +23,9 @@ use crate::ast::Span;
|
|||
use crate::ast::TagName;
|
||||
use crate::ast::TagNode;
|
||||
use crate::db::Db as TemplateDb;
|
||||
use crate::templatetags::ArgSpec;
|
||||
use crate::templatetags::Arg;
|
||||
use crate::templatetags::ArgType;
|
||||
use crate::templatetags::SimpleArgType;
|
||||
use crate::templatetags::TagType;
|
||||
use crate::Ast;
|
||||
|
||||
|
@ -57,14 +59,14 @@ impl<'db> TagValidator<'db> {
|
|||
let tag_specs = self.db.tag_specs();
|
||||
let tag_type = TagType::for_name(&name_str, &tag_specs);
|
||||
|
||||
let arg_spec = match tag_type {
|
||||
let args = match tag_type {
|
||||
TagType::Closer => tag_specs
|
||||
.get_end_spec_for_closer(&name_str)
|
||||
.and_then(|s| s.args.as_ref()),
|
||||
_ => tag_specs.get(&name_str).and_then(|s| s.args.as_ref()),
|
||||
.map(|s| &s.args),
|
||||
_ => tag_specs.get(&name_str).map(|s| &s.args),
|
||||
};
|
||||
|
||||
self.check_arguments(&name_str, &bits, span, arg_spec);
|
||||
self.check_arguments(&name_str, &bits, span, args);
|
||||
|
||||
match tag_type {
|
||||
TagType::Opener => {
|
||||
|
@ -104,32 +106,36 @@ impl<'db> TagValidator<'db> {
|
|||
name: &str,
|
||||
bits: &[String],
|
||||
span: Span,
|
||||
arg_spec: Option<&ArgSpec>,
|
||||
args: Option<&Vec<Arg>>,
|
||||
) {
|
||||
let Some(arg_spec) = arg_spec else {
|
||||
let Some(args) = args else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(min) = arg_spec.min {
|
||||
if bits.len() < min {
|
||||
// Count required arguments
|
||||
let required_count = args.iter().filter(|arg| arg.required).count();
|
||||
|
||||
if bits.len() < required_count {
|
||||
self.errors.push(AstError::MissingRequiredArguments {
|
||||
tag: name.to_string(),
|
||||
min,
|
||||
min: required_count,
|
||||
span,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(max) = arg_spec.max {
|
||||
if bits.len() > max {
|
||||
// If there are more bits than defined args, that might be okay for varargs
|
||||
let has_varargs = args
|
||||
.iter()
|
||||
.any(|arg| matches!(arg.arg_type, ArgType::Simple(SimpleArgType::VarArgs)));
|
||||
|
||||
if !has_varargs && bits.len() > args.len() {
|
||||
self.errors.push(AstError::TooManyArguments {
|
||||
tag: name.to_string(),
|
||||
max,
|
||||
max: args.len(),
|
||||
span,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_intermediate(&mut self, name: &str, span: Span) {
|
||||
// Check if this intermediate tag has the required parent
|
||||
|
|
|
@ -4,29 +4,46 @@ Tag Specifications (TagSpecs) define how template tags are structured, helping t
|
|||
|
||||
## Schema
|
||||
|
||||
Tag Specifications (TagSpecs) define how tags are parsed and understood. They allow the parser to handle custom tags without hard-coding them.
|
||||
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 = { min = 1, max = 3 } # Optional: Argument constraints
|
||||
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 argument constraints for the end 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").
|
||||
- `name`: The name of the intermediate tag (e.g., "else", "elif")
|
||||
|
||||
The `args` table defines argument constraints:
|
||||
- `min`: Minimum number of arguments required.
|
||||
- `max`: Maximum number of arguments allowed.
|
||||
### 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
|
||||
|
||||
|
@ -42,7 +59,10 @@ The `args` table defines argument constraints:
|
|||
name = "if"
|
||||
end_tag = { name = "endif" }
|
||||
intermediate_tags = [{ name = "elif" }, { name = "else" }]
|
||||
args = { min = 1 } # condition
|
||||
args = [
|
||||
{ name = "condition", type = "expression" }
|
||||
]
|
||||
# Generates snippet: if ${1:condition}
|
||||
```
|
||||
|
||||
### For Tag
|
||||
|
@ -52,7 +72,13 @@ args = { min = 1 } # condition
|
|||
name = "for"
|
||||
end_tag = { name = "endfor" }
|
||||
intermediate_tags = [{ name = "empty" }]
|
||||
args = { min = 3 } # item in items (at minimum)
|
||||
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
|
||||
|
@ -61,7 +87,24 @@ args = { min = 3 } # item in items (at minimum)
|
|||
[[tagspecs.django.template.defaulttags]]
|
||||
name = "autoescape"
|
||||
end_tag = { name = "endautoescape" }
|
||||
args = { min = 1, max = 1 } # on or off
|
||||
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
|
||||
|
@ -71,16 +114,26 @@ args = { min = 1, max = 1 } # on or off
|
|||
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 (no end tag)
|
||||
### Standalone Tags
|
||||
|
||||
```toml
|
||||
[[tagspecs.django.template.defaulttags]]
|
||||
name = "csrf_token"
|
||||
args = { min = 0, max = 0 } # no arguments
|
||||
args = [] # No arguments
|
||||
# Generates snippet: csrf_token
|
||||
|
||||
[[tagspecs.django.template.defaulttags]]
|
||||
name = "load"
|
||||
args = { min = 1 } # library name(s)
|
||||
args = [
|
||||
{ name = "libraries", type = "varargs" }
|
||||
]
|
||||
# Generates snippet: load ${1:libraries}
|
||||
```
|
||||
|
|
|
@ -1,101 +1,227 @@
|
|||
[[tagspecs.django.template.defaulttags]]
|
||||
name = "autoescape"
|
||||
end_tag = { name = "endautoescape" }
|
||||
args = { min = 1, max = 1 } # on or off
|
||||
args = [
|
||||
{ name = "mode", type = { choice = ["on", "off"] } }
|
||||
]
|
||||
|
||||
[[tagspecs.django.template.defaulttags]]
|
||||
name = "block"
|
||||
end_tag = { name = "endblock", args = { min = 0, max = 1 } }
|
||||
args = { min = 1, max = 1 } # block name
|
||||
end_tag = { name = "endblock", args = [{ name = "name", required = false, type = "variable" }] }
|
||||
args = [
|
||||
{ name = "name", type = "variable" }
|
||||
]
|
||||
|
||||
[[tagspecs.django.template.defaulttags]]
|
||||
name = "comment"
|
||||
end_tag = { name = "endcomment" }
|
||||
args = []
|
||||
|
||||
[[tagspecs.django.template.defaulttags]]
|
||||
name = "csrf_token"
|
||||
args = { min = 0, max = 0 } # no arguments
|
||||
args = []
|
||||
|
||||
[[tagspecs.django.template.defaulttags]]
|
||||
name = "cycle"
|
||||
args = { min = 1 } # values to cycle through
|
||||
args = [
|
||||
{ name = "values", type = "varargs" },
|
||||
{ name = "as", required = false, type = "literal" },
|
||||
{ name = "varname", required = false, type = "variable" }
|
||||
]
|
||||
|
||||
[[tagspecs.django.template.defaulttags]]
|
||||
name = "extends"
|
||||
args = { min = 1, max = 1 } # template name
|
||||
args = [
|
||||
{ name = "template", type = "string" }
|
||||
]
|
||||
|
||||
[[tagspecs.django.template.defaulttags]]
|
||||
name = "filter"
|
||||
end_tag = { name = "endfilter" }
|
||||
args = { min = 1 } # filter expression
|
||||
args = [
|
||||
{ name = "filter_expr", type = "expression" }
|
||||
]
|
||||
|
||||
[[tagspecs.django.template.defaulttags]]
|
||||
name = "for"
|
||||
end_tag = { name = "endfor" }
|
||||
intermediate_tags = [ { name = "empty" } ]
|
||||
args = { min = 3 } # item in items (at minimum)
|
||||
args = [
|
||||
{ name = "item", type = "variable" },
|
||||
{ name = "in", type = "literal" },
|
||||
{ name = "items", type = "variable" },
|
||||
{ name = "reversed", required = false, type = "literal" }
|
||||
]
|
||||
|
||||
[[tagspecs.django.template.defaulttags]]
|
||||
name = "include"
|
||||
args = { min = 1 } # template name [with context]
|
||||
args = [
|
||||
{ name = "template", type = "string" },
|
||||
{ name = "with", required = false, type = "literal" },
|
||||
{ name = "context", required = false, type = "varargs" },
|
||||
{ name = "only", required = false, type = "literal" }
|
||||
]
|
||||
|
||||
[[tagspecs.django.template.defaulttags]]
|
||||
name = "if"
|
||||
end_tag = { name = "endif" }
|
||||
intermediate_tags = [ { name = "elif" }, { name = "else" } ]
|
||||
args = { min = 1 } # condition
|
||||
args = [
|
||||
{ name = "condition", type = "expression" }
|
||||
]
|
||||
|
||||
[[tagspecs.django.template.defaulttags]]
|
||||
name = "ifchanged"
|
||||
end_tag = { name = "endifchanged" }
|
||||
intermediate_tags = [ { name = "else" } ]
|
||||
args = [
|
||||
{ name = "variables", required = false, type = "varargs" }
|
||||
]
|
||||
|
||||
[[tagspecs.django.template.defaulttags]]
|
||||
name = "load"
|
||||
args = { min = 1 } # library name(s)
|
||||
args = [
|
||||
{ name = "libraries", type = "varargs" }
|
||||
]
|
||||
|
||||
[[tagspecs.django.template.defaulttags]]
|
||||
name = "now"
|
||||
args = { min = 1, max = 1 } # format string
|
||||
args = [
|
||||
{ name = "format_string", type = "string" },
|
||||
{ name = "as", required = false, type = "literal" },
|
||||
{ name = "varname", required = false, type = "variable" }
|
||||
]
|
||||
|
||||
[[tagspecs.django.template.defaulttags]]
|
||||
name = "regroup"
|
||||
args = [
|
||||
{ name = "list", type = "variable" },
|
||||
{ name = "by", type = "literal" },
|
||||
{ name = "attribute", type = "variable" },
|
||||
{ name = "as", type = "literal" },
|
||||
{ name = "grouped", type = "variable" }
|
||||
]
|
||||
|
||||
[[tagspecs.django.template.defaulttags]]
|
||||
name = "spaceless"
|
||||
end_tag = { name = "endspaceless" }
|
||||
args = []
|
||||
|
||||
[[tagspecs.django.template.defaulttags]]
|
||||
name = "static"
|
||||
args = [
|
||||
{ name = "path", type = "string" }
|
||||
]
|
||||
|
||||
[[tagspecs.django.template.defaulttags]]
|
||||
name = "templatetag"
|
||||
args = { min = 1, max = 1 } # special character name
|
||||
args = [
|
||||
{ name = "tagbit", type = { choice = ["openblock", "closeblock", "openvariable", "closevariable", "openbrace", "closebrace", "opencomment", "closecomment"] } }
|
||||
]
|
||||
|
||||
[[tagspecs.django.template.defaulttags]]
|
||||
name = "url"
|
||||
args = { min = 1 } # view name [args...]
|
||||
args = [
|
||||
{ name = "view_name", type = "string" },
|
||||
{ name = "args", required = false, type = "varargs" },
|
||||
{ name = "as", required = false, type = "literal" },
|
||||
{ name = "varname", required = false, type = "variable" }
|
||||
]
|
||||
|
||||
[[tagspecs.django.template.defaulttags]]
|
||||
name = "verbatim"
|
||||
end_tag = { name = "endverbatim" }
|
||||
args = [
|
||||
{ name = "name", required = false, type = "string" }
|
||||
]
|
||||
|
||||
[[tagspecs.django.template.defaulttags]]
|
||||
name = "widthratio"
|
||||
args = [
|
||||
{ name = "this_value", type = "variable" },
|
||||
{ name = "max_value", type = "variable" },
|
||||
{ name = "max_width", type = "variable" },
|
||||
{ name = "as", required = false, type = "literal" },
|
||||
{ name = "varname", required = false, type = "variable" }
|
||||
]
|
||||
|
||||
[[tagspecs.django.template.defaulttags]]
|
||||
name = "with"
|
||||
end_tag = { name = "endwith" }
|
||||
args = { min = 1 } # variable assignment(s)
|
||||
args = [
|
||||
{ name = "assignments", type = "varargs" }
|
||||
]
|
||||
|
||||
# Cache tags
|
||||
[[tagspecs.django.templatetags.cache]]
|
||||
name = "cache"
|
||||
end_tag = { name = "endcache" }
|
||||
args = [
|
||||
{ name = "timeout", type = "variable" },
|
||||
{ name = "cache_key", type = "variable" },
|
||||
{ name = "variables", required = false, type = "varargs" }
|
||||
]
|
||||
|
||||
# Internationalization tags
|
||||
[[tagspecs.django.templatetags.i10n]]
|
||||
name = "localize"
|
||||
end_tag = { name = "endlocalize" }
|
||||
args = [
|
||||
{ name = "mode", required = false, type = { choice = ["on", "off"] } }
|
||||
]
|
||||
|
||||
[[tagspecs.django.templatetags.i18n]]
|
||||
name = "blocktranslate"
|
||||
end_tag = { name = "endblocktranslate" }
|
||||
intermediate_tags = [ { name = "plural" } ]
|
||||
args = [
|
||||
{ name = "context", required = false, type = "string" },
|
||||
{ name = "with", required = false, type = "literal" },
|
||||
{ name = "assignments", required = false, type = "varargs" },
|
||||
{ name = "asvar", required = false, type = "literal" },
|
||||
{ name = "varname", required = false, type = "variable" }
|
||||
]
|
||||
|
||||
[[tagspecs.django.templatetags.i18n]]
|
||||
name = "trans"
|
||||
args = [
|
||||
{ name = "message", type = "string" },
|
||||
{ name = "context", required = false, type = "string" },
|
||||
{ name = "as", required = false, type = "literal" },
|
||||
{ name = "varname", required = false, type = "variable" },
|
||||
{ name = "noop", required = false, type = "literal" }
|
||||
]
|
||||
|
||||
# Timezone tags
|
||||
[[tagspecs.django.templatetags.tz]]
|
||||
name = "localtime"
|
||||
end_tag = { name = "endlocaltime" }
|
||||
args = [
|
||||
{ name = "mode", required = false, type = { choice = ["on", "off"] } }
|
||||
]
|
||||
|
||||
[[tagspecs.django.templatetags.tz]]
|
||||
name = "timezone"
|
||||
end_tag = { name = "endtimezone" }
|
||||
args = [
|
||||
{ name = "timezone", type = "variable" }
|
||||
]
|
||||
|
||||
# Additional commonly used tags
|
||||
[[tagspecs.django.template.defaulttags]]
|
||||
name = "firstof"
|
||||
args = [
|
||||
{ name = "variables", type = "varargs" }
|
||||
]
|
||||
|
||||
[[tagspecs.django.template.defaulttags]]
|
||||
name = "lorem"
|
||||
args = [
|
||||
{ name = "count", required = false, type = "variable" },
|
||||
{ name = "method", required = false, type = { choice = ["w", "p", "b"] } },
|
||||
{ name = "random", required = false, type = "literal" }
|
||||
]
|
||||
|
||||
[[tagspecs.django.template.defaulttags]]
|
||||
name = "debug"
|
||||
args = []
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue