mirror of
https://github.com/joshuadavidthomas/django-language-server.git
synced 2025-09-13 22:06:19 +00:00
Add context-aware completions with snippets (#207)
This commit is contained in:
parent
792bdd8e13
commit
1cc233f204
5 changed files with 707 additions and 76 deletions
|
@ -4,7 +4,10 @@
|
||||||
//! and generating appropriate completion items for Django templates.
|
//! and generating appropriate completion items for Django templates.
|
||||||
|
|
||||||
use djls_project::TemplateTags;
|
use djls_project::TemplateTags;
|
||||||
use djls_templates::templatetags::generate_snippet_for_tag;
|
use djls_templates::templatetags::generate_partial_snippet;
|
||||||
|
use djls_templates::templatetags::generate_snippet_for_tag_with_end;
|
||||||
|
use djls_templates::templatetags::ArgType;
|
||||||
|
use djls_templates::templatetags::SimpleArgType;
|
||||||
use djls_templates::templatetags::TagSpecs;
|
use djls_templates::templatetags::TagSpecs;
|
||||||
use djls_workspace::FileKind;
|
use djls_workspace::FileKind;
|
||||||
use djls_workspace::PositionEncoding;
|
use djls_workspace::PositionEncoding;
|
||||||
|
@ -19,7 +22,7 @@ use tower_lsp_server::lsp_types::Position;
|
||||||
///
|
///
|
||||||
/// Used to determine whether the completion system needs to insert
|
/// Used to determine whether the completion system needs to insert
|
||||||
/// closing braces when completing a Django template tag.
|
/// closing braces when completing a Django template tag.
|
||||||
#[derive(Debug)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum ClosingBrace {
|
pub enum ClosingBrace {
|
||||||
/// No closing brace present - need to add full `%}` or `}}`
|
/// No closing brace present - need to add full `%}` or `}}`
|
||||||
None,
|
None,
|
||||||
|
@ -29,18 +32,56 @@ pub enum ClosingBrace {
|
||||||
FullClose,
|
FullClose,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cursor context within a Django template tag for completion support.
|
/// Rich context-aware completion information for Django templates.
|
||||||
///
|
///
|
||||||
/// Captures the state around the cursor position to provide intelligent
|
/// Distinguishes between different completion contexts to provide
|
||||||
/// completions and determine what text needs to be inserted.
|
/// appropriate suggestions based on cursor position.
|
||||||
#[derive(Debug)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct TemplateTagContext {
|
#[allow(dead_code)]
|
||||||
/// The partial tag text before the cursor (e.g., "loa" for "{% loa|")
|
pub enum TemplateCompletionContext {
|
||||||
pub partial_tag: String,
|
/// Completing a tag name after {%
|
||||||
/// What closing characters are already present after the cursor
|
TagName {
|
||||||
pub closing_brace: ClosingBrace,
|
/// Partial tag name typed so far
|
||||||
/// Whether a space is needed before the completion (true if cursor is right after `{%`)
|
partial: String,
|
||||||
pub needs_leading_space: bool,
|
/// Whether a space is needed before the tag name
|
||||||
|
needs_space: bool,
|
||||||
|
/// What closing characters are present
|
||||||
|
closing: ClosingBrace,
|
||||||
|
},
|
||||||
|
/// Completing arguments within a tag
|
||||||
|
TagArgument {
|
||||||
|
/// The tag name
|
||||||
|
tag: String,
|
||||||
|
/// Position in the argument list (0-based)
|
||||||
|
position: usize,
|
||||||
|
/// Partial text for current argument
|
||||||
|
partial: String,
|
||||||
|
/// Arguments already parsed before cursor
|
||||||
|
parsed_args: Vec<String>,
|
||||||
|
/// What closing characters are present
|
||||||
|
closing: ClosingBrace,
|
||||||
|
},
|
||||||
|
/// Completing a library name after {% load
|
||||||
|
LibraryName {
|
||||||
|
/// Partial library name typed so far
|
||||||
|
partial: String,
|
||||||
|
/// What closing characters are present
|
||||||
|
closing: ClosingBrace,
|
||||||
|
},
|
||||||
|
/// TODO: Future - completing filters after |
|
||||||
|
Filter {
|
||||||
|
/// Partial filter name typed so far
|
||||||
|
partial: String,
|
||||||
|
},
|
||||||
|
/// TODO: Future - completing variables after {{
|
||||||
|
Variable {
|
||||||
|
/// Partial variable name typed so far
|
||||||
|
partial: String,
|
||||||
|
/// What closing characters are present
|
||||||
|
closing: ClosingBrace,
|
||||||
|
},
|
||||||
|
/// No template context found
|
||||||
|
None,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Information about a line of text and cursor position within it
|
/// Information about a line of text and cursor position within it
|
||||||
|
@ -129,7 +170,7 @@ fn get_line_info(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Analyze a line of template text to determine completion context
|
/// Analyze a line of template text to determine completion context
|
||||||
fn analyze_template_context(line: &str, cursor_offset: usize) -> Option<TemplateTagContext> {
|
fn analyze_template_context(line: &str, cursor_offset: usize) -> Option<TemplateCompletionContext> {
|
||||||
// Find the last {% before cursor position
|
// Find the last {% before cursor position
|
||||||
let prefix = &line[..cursor_offset.min(line.len())];
|
let prefix = &line[..cursor_offset.min(line.len())];
|
||||||
let tag_start = prefix.rfind("{%")?;
|
let tag_start = prefix.rfind("{%")?;
|
||||||
|
@ -138,20 +179,86 @@ fn analyze_template_context(line: &str, cursor_offset: usize) -> Option<Template
|
||||||
let content_start = tag_start + 2;
|
let content_start = tag_start + 2;
|
||||||
let content = &prefix[content_start..];
|
let content = &prefix[content_start..];
|
||||||
|
|
||||||
// Check if we need a leading space (no space after {%)
|
|
||||||
let needs_leading_space = content.is_empty() || !content.starts_with(' ');
|
|
||||||
|
|
||||||
// Extract the partial tag name
|
|
||||||
let partial_tag = content.trim_start().to_string();
|
|
||||||
|
|
||||||
// Check what's after the cursor for closing detection
|
// Check what's after the cursor for closing detection
|
||||||
let suffix = &line[cursor_offset.min(line.len())..];
|
let suffix = &line[cursor_offset.min(line.len())..];
|
||||||
let closing_brace = detect_closing_brace(suffix);
|
let closing = detect_closing_brace(suffix);
|
||||||
|
|
||||||
Some(TemplateTagContext {
|
// Check if we need a leading space (no space after {%)
|
||||||
partial_tag,
|
let needs_space = content.is_empty() || !content.starts_with(' ');
|
||||||
closing_brace,
|
|
||||||
needs_leading_space,
|
// Parse the content to determine context
|
||||||
|
let trimmed = content.trim_start();
|
||||||
|
|
||||||
|
// Split into tokens by whitespace
|
||||||
|
let tokens: Vec<&str> = trimmed.split_whitespace().collect();
|
||||||
|
|
||||||
|
if tokens.is_empty() {
|
||||||
|
// Just opened tag, completing tag name
|
||||||
|
return Some(TemplateCompletionContext::TagName {
|
||||||
|
partial: String::new(),
|
||||||
|
needs_space,
|
||||||
|
closing,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're in the middle of typing the first token (tag name)
|
||||||
|
if tokens.len() == 1 && !trimmed.ends_with(char::is_whitespace) {
|
||||||
|
// Still typing the tag name
|
||||||
|
return Some(TemplateCompletionContext::TagName {
|
||||||
|
partial: tokens[0].to_string(),
|
||||||
|
needs_space,
|
||||||
|
closing,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// We have a complete tag name and are working on arguments
|
||||||
|
let tag_name = tokens[0];
|
||||||
|
|
||||||
|
// Special case for {% load %} - completing library names
|
||||||
|
if tag_name == "load" {
|
||||||
|
// Get the partial library name being typed
|
||||||
|
let partial = if trimmed.ends_with(char::is_whitespace) {
|
||||||
|
String::new()
|
||||||
|
} else if tokens.len() > 1 {
|
||||||
|
(*tokens.last().unwrap()).to_string()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
return Some(TemplateCompletionContext::LibraryName { partial, closing });
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other tags, we're completing arguments
|
||||||
|
// Calculate argument position and partial text
|
||||||
|
let parsed_args: Vec<String> = if tokens.len() > 1 {
|
||||||
|
tokens[1..].iter().map(|&s| s.to_string()).collect()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine position and partial
|
||||||
|
let (position, partial) = if trimmed.ends_with(char::is_whitespace) {
|
||||||
|
// After a space, starting a new argument
|
||||||
|
(parsed_args.len(), String::new())
|
||||||
|
} else if !parsed_args.is_empty() {
|
||||||
|
// In the middle of typing an argument
|
||||||
|
(parsed_args.len() - 1, parsed_args.last().unwrap().clone())
|
||||||
|
} else {
|
||||||
|
// Just after tag name with space
|
||||||
|
(0, String::new())
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(TemplateCompletionContext::TagArgument {
|
||||||
|
tag: tag_name.to_string(),
|
||||||
|
position,
|
||||||
|
partial: partial.clone(),
|
||||||
|
parsed_args: if partial.is_empty() {
|
||||||
|
parsed_args
|
||||||
|
} else {
|
||||||
|
// Don't include the partial argument in parsed_args
|
||||||
|
parsed_args[..parsed_args.len() - 1].to_vec()
|
||||||
|
},
|
||||||
|
closing,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -169,7 +276,57 @@ fn detect_closing_brace(suffix: &str) -> ClosingBrace {
|
||||||
|
|
||||||
/// Generate Django template tag completion items based on context
|
/// Generate Django template tag completion items based on context
|
||||||
fn generate_template_completions(
|
fn generate_template_completions(
|
||||||
context: &TemplateTagContext,
|
context: &TemplateCompletionContext,
|
||||||
|
template_tags: Option<&TemplateTags>,
|
||||||
|
tag_specs: Option<&TagSpecs>,
|
||||||
|
supports_snippets: bool,
|
||||||
|
) -> Vec<CompletionItem> {
|
||||||
|
match context {
|
||||||
|
TemplateCompletionContext::TagName {
|
||||||
|
partial,
|
||||||
|
needs_space,
|
||||||
|
closing,
|
||||||
|
} => generate_tag_name_completions(
|
||||||
|
partial,
|
||||||
|
*needs_space,
|
||||||
|
closing,
|
||||||
|
template_tags,
|
||||||
|
tag_specs,
|
||||||
|
supports_snippets,
|
||||||
|
),
|
||||||
|
TemplateCompletionContext::TagArgument {
|
||||||
|
tag,
|
||||||
|
position,
|
||||||
|
partial,
|
||||||
|
parsed_args,
|
||||||
|
closing,
|
||||||
|
} => generate_argument_completions(
|
||||||
|
tag,
|
||||||
|
*position,
|
||||||
|
partial,
|
||||||
|
parsed_args,
|
||||||
|
closing,
|
||||||
|
template_tags,
|
||||||
|
tag_specs,
|
||||||
|
supports_snippets,
|
||||||
|
),
|
||||||
|
TemplateCompletionContext::LibraryName { partial, closing } => {
|
||||||
|
generate_library_completions(partial, closing, template_tags)
|
||||||
|
}
|
||||||
|
TemplateCompletionContext::Filter { .. }
|
||||||
|
| TemplateCompletionContext::Variable { .. }
|
||||||
|
| TemplateCompletionContext::None => {
|
||||||
|
// Not implemented yet
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate completions for tag names
|
||||||
|
fn generate_tag_name_completions(
|
||||||
|
partial: &str,
|
||||||
|
needs_space: bool,
|
||||||
|
closing: &ClosingBrace,
|
||||||
template_tags: Option<&TemplateTags>,
|
template_tags: Option<&TemplateTags>,
|
||||||
tag_specs: Option<&TagSpecs>,
|
tag_specs: Option<&TagSpecs>,
|
||||||
supports_snippets: bool,
|
supports_snippets: bool,
|
||||||
|
@ -180,31 +337,69 @@ fn generate_template_completions(
|
||||||
|
|
||||||
let mut completions = Vec::new();
|
let mut completions = Vec::new();
|
||||||
|
|
||||||
|
// First, check if we should suggest end tags
|
||||||
|
// If partial starts with "end", prioritize end tags
|
||||||
|
if partial.starts_with("end") && tag_specs.is_some() {
|
||||||
|
let specs = tag_specs.unwrap();
|
||||||
|
|
||||||
|
// Add all end tags that match the partial
|
||||||
|
for (opener_name, spec) in specs.iter() {
|
||||||
|
if let Some(end_tag) = &spec.end_tag {
|
||||||
|
if end_tag.name.starts_with(partial) {
|
||||||
|
// Create a completion for the end tag
|
||||||
|
let mut insert_text = String::new();
|
||||||
|
if needs_space {
|
||||||
|
insert_text.push(' ');
|
||||||
|
}
|
||||||
|
insert_text.push_str(&end_tag.name);
|
||||||
|
|
||||||
|
// Add closing based on what's already present
|
||||||
|
match closing {
|
||||||
|
ClosingBrace::None => insert_text.push_str(" %}"),
|
||||||
|
ClosingBrace::PartialClose => insert_text.push_str(" %"),
|
||||||
|
ClosingBrace::FullClose => {} // No closing needed
|
||||||
|
}
|
||||||
|
|
||||||
|
completions.push(CompletionItem {
|
||||||
|
label: end_tag.name.clone(),
|
||||||
|
kind: Some(CompletionItemKind::KEYWORD),
|
||||||
|
detail: Some(format!("End tag for {opener_name}")),
|
||||||
|
insert_text: Some(insert_text),
|
||||||
|
insert_text_format: Some(InsertTextFormat::PLAIN_TEXT),
|
||||||
|
filter_text: Some(end_tag.name.clone()),
|
||||||
|
sort_text: Some(format!("0_{}", end_tag.name)), // Priority sort
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for tag in tags.iter() {
|
for tag in tags.iter() {
|
||||||
if tag.name().starts_with(&context.partial_tag) {
|
if tag.name().starts_with(partial) {
|
||||||
// Try to get snippet from TagSpecs if available and client supports snippets
|
// Try to get snippet from TagSpecs if available and client supports snippets
|
||||||
let (insert_text, insert_format) = if supports_snippets {
|
let (insert_text, insert_format) = if supports_snippets {
|
||||||
if let Some(specs) = tag_specs {
|
if let Some(specs) = tag_specs {
|
||||||
if let Some(spec) = specs.get(tag.name()) {
|
if let Some(spec) = specs.get(tag.name()) {
|
||||||
if spec.args.is_empty() {
|
if spec.args.is_empty() {
|
||||||
// No args, use plain text
|
// No args, use plain text
|
||||||
build_plain_insert(tag.name(), context)
|
build_plain_insert_for_tag(tag.name(), needs_space, closing)
|
||||||
} else {
|
} else {
|
||||||
// Generate snippet from tag spec
|
// Generate snippet from tag spec
|
||||||
let mut text = String::new();
|
let mut text = String::new();
|
||||||
|
|
||||||
// Add leading space if needed
|
// Add leading space if needed
|
||||||
if context.needs_leading_space {
|
if needs_space {
|
||||||
text.push(' ');
|
text.push(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add tag name and snippet arguments
|
// Add tag name and snippet arguments (including end tag if required)
|
||||||
text.push_str(&generate_snippet_for_tag(tag.name(), spec));
|
text.push_str(&generate_snippet_for_tag_with_end(tag.name(), spec));
|
||||||
|
|
||||||
// Add closing based on what's already present
|
// Add closing based on what's already present
|
||||||
match context.closing_brace {
|
match closing {
|
||||||
ClosingBrace::None => text.push_str(" %}"),
|
ClosingBrace::None => text.push_str(" %}"),
|
||||||
ClosingBrace::PartialClose => text.push('%'),
|
ClosingBrace::PartialClose => text.push_str(" %"),
|
||||||
ClosingBrace::FullClose => {} // No closing needed
|
ClosingBrace::FullClose => {} // No closing needed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -212,26 +407,34 @@ fn generate_template_completions(
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No spec found, use plain text
|
// No spec found, use plain text
|
||||||
build_plain_insert(tag.name(), context)
|
build_plain_insert_for_tag(tag.name(), needs_space, closing)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No specs available, use plain text
|
// No specs available, use plain text
|
||||||
build_plain_insert(tag.name(), context)
|
build_plain_insert_for_tag(tag.name(), needs_space, closing)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Client doesn't support snippets
|
// Client doesn't support snippets
|
||||||
build_plain_insert(tag.name(), context)
|
build_plain_insert_for_tag(tag.name(), needs_space, closing)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create completion item
|
// Create completion item
|
||||||
|
// Use SNIPPET kind when we're inserting a snippet, FUNCTION otherwise
|
||||||
|
let kind = if matches!(insert_format, InsertTextFormat::SNIPPET) {
|
||||||
|
CompletionItemKind::SNIPPET
|
||||||
|
} else {
|
||||||
|
CompletionItemKind::FUNCTION
|
||||||
|
};
|
||||||
|
|
||||||
let completion_item = CompletionItem {
|
let completion_item = CompletionItem {
|
||||||
label: tag.name().clone(),
|
label: tag.name().clone(),
|
||||||
kind: Some(CompletionItemKind::FUNCTION),
|
kind: Some(kind),
|
||||||
detail: Some(format!("from {}", tag.library())),
|
detail: Some(format!("from {}", tag.library())),
|
||||||
documentation: tag.doc().map(|doc| Documentation::String(doc.clone())),
|
documentation: tag.doc().map(|doc| Documentation::String(doc.clone())),
|
||||||
insert_text: Some(insert_text),
|
insert_text: Some(insert_text),
|
||||||
insert_text_format: Some(insert_format),
|
insert_text_format: Some(insert_format),
|
||||||
filter_text: Some(tag.name().clone()),
|
filter_text: Some(tag.name().clone()),
|
||||||
|
sort_text: Some(format!("1_{}", tag.name())), // Regular tags sort after end tags
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -242,12 +445,204 @@ fn generate_template_completions(
|
||||||
completions
|
completions
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build plain insert text without snippets
|
/// Generate completions for tag arguments
|
||||||
fn build_plain_insert(tag_name: &str, context: &TemplateTagContext) -> (String, InsertTextFormat) {
|
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
|
||||||
|
fn generate_argument_completions(
|
||||||
|
tag: &str,
|
||||||
|
position: usize,
|
||||||
|
partial: &str,
|
||||||
|
_parsed_args: &[String],
|
||||||
|
closing: &ClosingBrace,
|
||||||
|
_template_tags: Option<&TemplateTags>,
|
||||||
|
tag_specs: Option<&TagSpecs>,
|
||||||
|
supports_snippets: bool,
|
||||||
|
) -> Vec<CompletionItem> {
|
||||||
|
let Some(specs) = tag_specs else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(spec) = specs.get(tag) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get the argument at this position
|
||||||
|
if position >= spec.args.len() {
|
||||||
|
return Vec::new(); // Beyond expected args
|
||||||
|
}
|
||||||
|
|
||||||
|
let arg = &spec.args[position];
|
||||||
|
let mut completions = Vec::new();
|
||||||
|
|
||||||
|
match &arg.arg_type {
|
||||||
|
ArgType::Simple(SimpleArgType::Literal) => {
|
||||||
|
// For literals, complete the exact text
|
||||||
|
if arg.name.starts_with(partial) {
|
||||||
|
let mut insert_text = arg.name.clone();
|
||||||
|
|
||||||
|
// Add closing if needed
|
||||||
|
match closing {
|
||||||
|
ClosingBrace::None => insert_text.push_str(" %}"),
|
||||||
|
ClosingBrace::PartialClose => insert_text.push_str(" %"),
|
||||||
|
ClosingBrace::FullClose => {} // No closing needed
|
||||||
|
}
|
||||||
|
|
||||||
|
completions.push(CompletionItem {
|
||||||
|
label: arg.name.clone(),
|
||||||
|
kind: Some(CompletionItemKind::KEYWORD),
|
||||||
|
detail: Some("literal argument".to_string()),
|
||||||
|
insert_text: Some(insert_text),
|
||||||
|
insert_text_format: Some(InsertTextFormat::PLAIN_TEXT),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ArgType::Choice { choice } => {
|
||||||
|
// For choices, offer each option
|
||||||
|
for option in choice {
|
||||||
|
if option.starts_with(partial) {
|
||||||
|
let mut insert_text = option.clone();
|
||||||
|
|
||||||
|
// Add closing if needed
|
||||||
|
match closing {
|
||||||
|
ClosingBrace::None => insert_text.push_str(" %}"),
|
||||||
|
ClosingBrace::PartialClose => insert_text.push('%'),
|
||||||
|
ClosingBrace::FullClose => {} // No closing needed
|
||||||
|
}
|
||||||
|
|
||||||
|
completions.push(CompletionItem {
|
||||||
|
label: option.clone(),
|
||||||
|
kind: Some(CompletionItemKind::ENUM_MEMBER),
|
||||||
|
detail: Some(format!("choice for {}", arg.name)),
|
||||||
|
insert_text: Some(insert_text),
|
||||||
|
insert_text_format: Some(InsertTextFormat::PLAIN_TEXT),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ArgType::Simple(SimpleArgType::Variable) => {
|
||||||
|
// For variables, we could offer variable completions from context
|
||||||
|
// For now, just provide a hint
|
||||||
|
if partial.is_empty() {
|
||||||
|
completions.push(CompletionItem {
|
||||||
|
label: format!("<{}>", arg.name),
|
||||||
|
kind: Some(CompletionItemKind::VARIABLE),
|
||||||
|
detail: Some("variable argument".to_string()),
|
||||||
|
insert_text: None, // Don't insert placeholder
|
||||||
|
insert_text_format: Some(InsertTextFormat::PLAIN_TEXT),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ArgType::Simple(SimpleArgType::String) => {
|
||||||
|
// For strings, could offer template name completions
|
||||||
|
// For now, just provide a hint
|
||||||
|
if partial.is_empty() {
|
||||||
|
completions.push(CompletionItem {
|
||||||
|
label: format!("\"{}\"", arg.name),
|
||||||
|
kind: Some(CompletionItemKind::TEXT),
|
||||||
|
detail: Some("string argument".to_string()),
|
||||||
|
insert_text: None, // Don't insert placeholder
|
||||||
|
insert_text_format: Some(InsertTextFormat::PLAIN_TEXT),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ArgType::Simple(_) => {
|
||||||
|
// Other argument types not handled yet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're at the start of an argument position and client supports snippets,
|
||||||
|
// offer a snippet for all remaining arguments
|
||||||
|
if partial.is_empty() && supports_snippets && position < spec.args.len() {
|
||||||
|
let remaining_snippet = generate_partial_snippet(spec, position);
|
||||||
|
if !remaining_snippet.is_empty() {
|
||||||
|
let mut insert_text = remaining_snippet;
|
||||||
|
|
||||||
|
// Add closing if needed
|
||||||
|
match closing {
|
||||||
|
ClosingBrace::None => insert_text.push_str(" %}"),
|
||||||
|
ClosingBrace::PartialClose => insert_text.push('%'),
|
||||||
|
ClosingBrace::FullClose => {} // No closing needed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a completion item for the full remaining arguments
|
||||||
|
let label = if position == 0 {
|
||||||
|
format!("{tag} arguments")
|
||||||
|
} else {
|
||||||
|
"remaining arguments".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
completions.push(CompletionItem {
|
||||||
|
label,
|
||||||
|
kind: Some(CompletionItemKind::SNIPPET),
|
||||||
|
detail: Some("Complete remaining arguments".to_string()),
|
||||||
|
insert_text: Some(insert_text),
|
||||||
|
insert_text_format: Some(InsertTextFormat::SNIPPET),
|
||||||
|
sort_text: Some("zzz".to_string()), // Sort at the end
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
completions
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate completions for library names (for {% load %} tag)
|
||||||
|
fn generate_library_completions(
|
||||||
|
partial: &str,
|
||||||
|
closing: &ClosingBrace,
|
||||||
|
template_tags: Option<&TemplateTags>,
|
||||||
|
) -> Vec<CompletionItem> {
|
||||||
|
let Some(tags) = template_tags else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get unique library names
|
||||||
|
let mut libraries = std::collections::HashSet::new();
|
||||||
|
for tag in tags.iter() {
|
||||||
|
libraries.insert(tag.library());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut completions = Vec::new();
|
||||||
|
|
||||||
|
for library in libraries {
|
||||||
|
if library.starts_with(partial) {
|
||||||
|
let mut insert_text = library.clone();
|
||||||
|
|
||||||
|
// Add closing if needed
|
||||||
|
match closing {
|
||||||
|
ClosingBrace::None => insert_text.push_str(" %}"),
|
||||||
|
ClosingBrace::PartialClose => insert_text.push('%'),
|
||||||
|
ClosingBrace::FullClose => {} // No closing needed
|
||||||
|
}
|
||||||
|
|
||||||
|
completions.push(CompletionItem {
|
||||||
|
label: library.clone(),
|
||||||
|
kind: Some(CompletionItemKind::MODULE),
|
||||||
|
detail: Some("Django template library".to_string()),
|
||||||
|
insert_text: Some(insert_text),
|
||||||
|
insert_text_format: Some(InsertTextFormat::PLAIN_TEXT),
|
||||||
|
filter_text: Some(library.clone()),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
completions
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build plain insert text without snippets for tag names
|
||||||
|
fn build_plain_insert_for_tag(
|
||||||
|
tag_name: &str,
|
||||||
|
needs_space: bool,
|
||||||
|
closing: &ClosingBrace,
|
||||||
|
) -> (String, InsertTextFormat) {
|
||||||
let mut insert_text = String::new();
|
let mut insert_text = String::new();
|
||||||
|
|
||||||
// Add leading space if needed (cursor right after {%)
|
// Add leading space if needed (cursor right after {%)
|
||||||
if context.needs_leading_space {
|
if needs_space {
|
||||||
insert_text.push(' ');
|
insert_text.push(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -255,9 +650,9 @@ fn build_plain_insert(tag_name: &str, context: &TemplateTagContext) -> (String,
|
||||||
insert_text.push_str(tag_name);
|
insert_text.push_str(tag_name);
|
||||||
|
|
||||||
// Add closing based on what's already present
|
// Add closing based on what's already present
|
||||||
match context.closing_brace {
|
match closing {
|
||||||
ClosingBrace::None => insert_text.push_str(" %}"),
|
ClosingBrace::None => insert_text.push_str(" %}"),
|
||||||
ClosingBrace::PartialClose => insert_text.push('%'),
|
ClosingBrace::PartialClose => insert_text.push_str(" %"),
|
||||||
ClosingBrace::FullClose => {} // No closing needed
|
ClosingBrace::FullClose => {} // No closing needed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -269,27 +664,37 @@ mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_analyze_template_context_basic() {
|
fn test_analyze_template_context_tag_name() {
|
||||||
let line = "{% loa";
|
let line = "{% loa";
|
||||||
let cursor_offset = 6; // After "loa"
|
let cursor_offset = 6; // After "loa"
|
||||||
|
|
||||||
let context = analyze_template_context(line, cursor_offset).expect("Should get context");
|
let context = analyze_template_context(line, cursor_offset).expect("Should get context");
|
||||||
|
|
||||||
assert_eq!(context.partial_tag, "loa");
|
assert_eq!(
|
||||||
assert!(!context.needs_leading_space);
|
context,
|
||||||
assert!(matches!(context.closing_brace, ClosingBrace::None));
|
TemplateCompletionContext::TagName {
|
||||||
|
partial: "loa".to_string(),
|
||||||
|
needs_space: false,
|
||||||
|
closing: ClosingBrace::None,
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_analyze_template_context_needs_leading_space() {
|
fn test_analyze_template_context_needs_space() {
|
||||||
let line = "{%loa";
|
let line = "{%loa";
|
||||||
let cursor_offset = 5; // After "loa"
|
let cursor_offset = 5; // After "loa"
|
||||||
|
|
||||||
let context = analyze_template_context(line, cursor_offset).expect("Should get context");
|
let context = analyze_template_context(line, cursor_offset).expect("Should get context");
|
||||||
|
|
||||||
assert_eq!(context.partial_tag, "loa");
|
assert_eq!(
|
||||||
assert!(context.needs_leading_space);
|
context,
|
||||||
assert!(matches!(context.closing_brace, ClosingBrace::None));
|
TemplateCompletionContext::TagName {
|
||||||
|
partial: "loa".to_string(),
|
||||||
|
needs_space: true,
|
||||||
|
closing: ClosingBrace::None,
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
@ -299,21 +704,68 @@ mod tests {
|
||||||
|
|
||||||
let context = analyze_template_context(line, cursor_offset).expect("Should get context");
|
let context = analyze_template_context(line, cursor_offset).expect("Should get context");
|
||||||
|
|
||||||
assert_eq!(context.partial_tag, "load");
|
assert_eq!(
|
||||||
assert!(!context.needs_leading_space);
|
context,
|
||||||
assert!(matches!(context.closing_brace, ClosingBrace::FullClose));
|
TemplateCompletionContext::TagName {
|
||||||
|
partial: "load".to_string(),
|
||||||
|
needs_space: false,
|
||||||
|
closing: ClosingBrace::FullClose,
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_analyze_template_context_partial_closing() {
|
fn test_analyze_template_context_library_name() {
|
||||||
let line = "{% load }";
|
let line = "{% load stat";
|
||||||
let cursor_offset = 7; // After "load"
|
let cursor_offset = 12; // After "stat"
|
||||||
|
|
||||||
let context = analyze_template_context(line, cursor_offset).expect("Should get context");
|
let context = analyze_template_context(line, cursor_offset).expect("Should get context");
|
||||||
|
|
||||||
assert_eq!(context.partial_tag, "load");
|
assert_eq!(
|
||||||
assert!(!context.needs_leading_space);
|
context,
|
||||||
assert!(matches!(context.closing_brace, ClosingBrace::PartialClose));
|
TemplateCompletionContext::LibraryName {
|
||||||
|
partial: "stat".to_string(),
|
||||||
|
closing: ClosingBrace::None,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_analyze_template_context_tag_argument() {
|
||||||
|
let line = "{% for item i";
|
||||||
|
let cursor_offset = 13; // After "i"
|
||||||
|
|
||||||
|
let context = analyze_template_context(line, cursor_offset).expect("Should get context");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
context,
|
||||||
|
TemplateCompletionContext::TagArgument {
|
||||||
|
tag: "for".to_string(),
|
||||||
|
position: 1,
|
||||||
|
partial: "i".to_string(),
|
||||||
|
parsed_args: vec!["item".to_string()],
|
||||||
|
closing: ClosingBrace::None,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_analyze_template_context_tag_argument_with_space() {
|
||||||
|
let line = "{% for item ";
|
||||||
|
let cursor_offset = 12; // After space
|
||||||
|
|
||||||
|
let context = analyze_template_context(line, cursor_offset).expect("Should get context");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
context,
|
||||||
|
TemplateCompletionContext::TagArgument {
|
||||||
|
tag: "for".to_string(),
|
||||||
|
position: 1,
|
||||||
|
partial: String::new(),
|
||||||
|
parsed_args: vec!["item".to_string()],
|
||||||
|
closing: ClosingBrace::None,
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
@ -328,14 +780,85 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_generate_template_completions_empty_tags() {
|
fn test_generate_template_completions_empty_tags() {
|
||||||
let context = TemplateTagContext {
|
let context = TemplateCompletionContext::TagName {
|
||||||
partial_tag: "loa".to_string(),
|
partial: "loa".to_string(),
|
||||||
needs_leading_space: false,
|
needs_space: false,
|
||||||
closing_brace: ClosingBrace::None,
|
closing: ClosingBrace::None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let completions = generate_template_completions(&context, None, None, false);
|
let completions = generate_template_completions(&context, None, None, false);
|
||||||
|
|
||||||
assert!(completions.is_empty());
|
assert!(completions.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_analyze_context_for_tag_empty() {
|
||||||
|
let line = "{% ";
|
||||||
|
let cursor_offset = 3; // After space
|
||||||
|
|
||||||
|
let context = analyze_template_context(line, cursor_offset).expect("Should get context");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
context,
|
||||||
|
TemplateCompletionContext::TagName {
|
||||||
|
partial: String::new(),
|
||||||
|
needs_space: false,
|
||||||
|
closing: ClosingBrace::None,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_analyze_context_for_second_argument() {
|
||||||
|
let line = "{% for item in ";
|
||||||
|
let cursor_offset = 15; // After "in "
|
||||||
|
|
||||||
|
let context = analyze_template_context(line, cursor_offset).expect("Should get context");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
context,
|
||||||
|
TemplateCompletionContext::TagArgument {
|
||||||
|
tag: "for".to_string(),
|
||||||
|
position: 2,
|
||||||
|
partial: String::new(),
|
||||||
|
parsed_args: vec!["item".to_string(), "in".to_string()],
|
||||||
|
closing: ClosingBrace::None,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_analyze_context_autoescape_argument() {
|
||||||
|
let line = "{% autoescape o";
|
||||||
|
let cursor_offset = 15; // After "o"
|
||||||
|
|
||||||
|
let context = analyze_template_context(line, cursor_offset).expect("Should get context");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
context,
|
||||||
|
TemplateCompletionContext::TagArgument {
|
||||||
|
tag: "autoescape".to_string(),
|
||||||
|
position: 0,
|
||||||
|
partial: "o".to_string(),
|
||||||
|
parsed_args: vec![],
|
||||||
|
closing: ClosingBrace::None,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_library_context_multiple_libs() {
|
||||||
|
let line = "{% load staticfiles i18n ";
|
||||||
|
let cursor_offset = 25; // After "i18n "
|
||||||
|
|
||||||
|
let context = analyze_template_context(line, cursor_offset).expect("Should get context");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
context,
|
||||||
|
TemplateCompletionContext::LibraryName {
|
||||||
|
partial: String::new(),
|
||||||
|
closing: ClosingBrace::None,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -371,7 +371,7 @@ impl LanguageServer for DjangoLanguageServer {
|
||||||
let file_kind = FileKind::from_path(&path);
|
let file_kind = FileKind::from_path(&path);
|
||||||
let template_tags = session.project().and_then(|p| p.template_tags());
|
let template_tags = session.project().and_then(|p| p.template_tags());
|
||||||
let tag_specs = session.with_db(djls_templates::Db::tag_specs);
|
let tag_specs = session.with_db(djls_templates::Db::tag_specs);
|
||||||
let supports_snippets = true; // TODO: Get from client capabilities
|
let supports_snippets = session.supports_snippets();
|
||||||
|
|
||||||
let completions = crate::completions::handle_completion(
|
let completions = crate::completions::handle_completion(
|
||||||
&document,
|
&document,
|
||||||
|
|
|
@ -122,6 +122,18 @@ impl Session {
|
||||||
self.position_encoding
|
self.position_encoding
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if the client supports snippet completions
|
||||||
|
#[must_use]
|
||||||
|
pub fn supports_snippets(&self) -> bool {
|
||||||
|
self.client_capabilities
|
||||||
|
.text_document
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|td| td.completion.as_ref())
|
||||||
|
.and_then(|c| c.completion_item.as_ref())
|
||||||
|
.and_then(|ci| ci.snippet_support)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
/// Execute a read-only operation with access to the database.
|
/// Execute a read-only operation with access to the database.
|
||||||
pub fn with_db<F, R>(&self, f: F) -> R
|
pub fn with_db<F, R>(&self, f: F) -> R
|
||||||
where
|
where
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
mod snippets;
|
mod snippets;
|
||||||
mod specs;
|
mod specs;
|
||||||
|
|
||||||
|
pub use snippets::generate_partial_snippet;
|
||||||
pub use snippets::generate_snippet_for_tag;
|
pub use snippets::generate_snippet_for_tag;
|
||||||
|
pub use snippets::generate_snippet_for_tag_with_end;
|
||||||
pub use snippets::generate_snippet_from_args;
|
pub use snippets::generate_snippet_from_args;
|
||||||
pub use specs::Arg;
|
pub use specs::Arg;
|
||||||
pub use specs::ArgType;
|
pub use specs::ArgType;
|
||||||
|
|
|
@ -10,7 +10,13 @@ pub fn generate_snippet_from_args(args: &[Arg]) -> String {
|
||||||
let mut placeholder_index = 1;
|
let mut placeholder_index = 1;
|
||||||
|
|
||||||
for arg in args {
|
for arg in args {
|
||||||
// Skip optional args if we haven't seen any required args after them
|
// Skip optional literals entirely - they're usually flags like "reversed" or "only"
|
||||||
|
// that the user can add manually if needed
|
||||||
|
if !arg.required && matches!(&arg.arg_type, ArgType::Simple(SimpleArgType::Literal)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip other optional args if we haven't seen any required args yet
|
||||||
// This prevents generating snippets like: "{% for %}" when everything is optional
|
// This prevents generating snippets like: "{% for %}" when everything is optional
|
||||||
if !arg.required && parts.is_empty() {
|
if !arg.required && parts.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
|
@ -19,15 +25,8 @@ pub fn generate_snippet_from_args(args: &[Arg]) -> String {
|
||||||
let snippet_part = match &arg.arg_type {
|
let snippet_part = match &arg.arg_type {
|
||||||
ArgType::Simple(simple_type) => match simple_type {
|
ArgType::Simple(simple_type) => match simple_type {
|
||||||
SimpleArgType::Literal => {
|
SimpleArgType::Literal => {
|
||||||
if arg.required {
|
// At this point, we know it's required (optional literals were skipped above)
|
||||||
// Required literals are just plain text (e.g., "in", "as", "by")
|
arg.name.clone()
|
||||||
arg.name.clone()
|
|
||||||
} else {
|
|
||||||
// Optional literals become placeholders
|
|
||||||
let result = format!("${{{}:{}}}", placeholder_index, arg.name);
|
|
||||||
placeholder_index += 1;
|
|
||||||
result
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
SimpleArgType::Variable | SimpleArgType::Expression => {
|
SimpleArgType::Variable | SimpleArgType::Expression => {
|
||||||
// Variables and expressions become placeholders
|
// Variables and expressions become placeholders
|
||||||
|
@ -82,6 +81,44 @@ pub fn generate_snippet_for_tag(tag_name: &str, spec: &TagSpec) -> String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generate a complete LSP snippet for a tag including the tag name and closing tag if needed
|
||||||
|
#[must_use]
|
||||||
|
pub fn generate_snippet_for_tag_with_end(tag_name: &str, spec: &TagSpec) -> String {
|
||||||
|
// Special handling for block tag to mirror the name in endblock
|
||||||
|
if tag_name == "block" {
|
||||||
|
// LSP snippets support placeholder mirroring using the same number
|
||||||
|
// ${1:name} in opening tag will be mirrored to ${1} in closing tag
|
||||||
|
let snippet = String::from("block ${1:name} %}\n$0\n{% endblock ${1}");
|
||||||
|
return snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut snippet = generate_snippet_for_tag(tag_name, spec);
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
// 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);
|
||||||
|
snippet.push_str(" %}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
snippet
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a partial snippet starting from a specific argument position
|
||||||
|
/// This is useful when the user has already typed some arguments
|
||||||
|
#[must_use]
|
||||||
|
pub fn generate_partial_snippet(spec: &TagSpec, starting_from_position: usize) -> String {
|
||||||
|
if starting_from_position >= spec.args.len() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let remaining_args = &spec.args[starting_from_position..];
|
||||||
|
generate_snippet_from_args(remaining_args)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
@ -114,7 +151,7 @@ mod tests {
|
||||||
];
|
];
|
||||||
|
|
||||||
let snippet = generate_snippet_from_args(&args);
|
let snippet = generate_snippet_from_args(&args);
|
||||||
assert_eq!(snippet, "${1:item} in ${2:items} ${3:reversed}");
|
assert_eq!(snippet, "${1:item} in ${2:items}");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
@ -163,6 +200,63 @@ mod tests {
|
||||||
assert_eq!(snippet, "");
|
assert_eq!(snippet, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_snippet_for_block_tag() {
|
||||||
|
use crate::templatetags::specs::EndTag;
|
||||||
|
use crate::templatetags::specs::TagSpec;
|
||||||
|
|
||||||
|
let spec = TagSpec {
|
||||||
|
name: None,
|
||||||
|
end_tag: Some(EndTag {
|
||||||
|
name: "endblock".to_string(),
|
||||||
|
optional: false,
|
||||||
|
args: vec![Arg {
|
||||||
|
name: "name".to_string(),
|
||||||
|
required: false,
|
||||||
|
arg_type: ArgType::Simple(SimpleArgType::Variable),
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
intermediate_tags: None,
|
||||||
|
args: vec![Arg {
|
||||||
|
name: "name".to_string(),
|
||||||
|
required: true,
|
||||||
|
arg_type: ArgType::Simple(SimpleArgType::Variable),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
let snippet = generate_snippet_for_tag_with_end("block", &spec);
|
||||||
|
assert_eq!(snippet, "block ${1:name} %}\n$0\n{% endblock ${1}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_snippet_with_end_tag() {
|
||||||
|
use crate::templatetags::specs::EndTag;
|
||||||
|
use crate::templatetags::specs::TagSpec;
|
||||||
|
|
||||||
|
let spec = TagSpec {
|
||||||
|
name: None,
|
||||||
|
end_tag: Some(EndTag {
|
||||||
|
name: "endautoescape".to_string(),
|
||||||
|
optional: false,
|
||||||
|
args: vec![],
|
||||||
|
}),
|
||||||
|
intermediate_tags: None,
|
||||||
|
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_for_tag_with_end("autoescape", &spec);
|
||||||
|
assert_eq!(
|
||||||
|
snippet,
|
||||||
|
"autoescape ${1|on,off|} %}\n$0\n{% endautoescape %}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_snippet_for_url_tag() {
|
fn test_snippet_for_url_tag() {
|
||||||
let args = vec![
|
let args = vec![
|
||||||
|
@ -189,6 +283,6 @@ mod tests {
|
||||||
];
|
];
|
||||||
|
|
||||||
let snippet = generate_snippet_from_args(&args);
|
let snippet = generate_snippet_from_args(&args);
|
||||||
assert_eq!(snippet, "\"${1:view_name}\" ${2:args} ${3:as} ${4:varname}");
|
assert_eq!(snippet, "\"${1:view_name}\" ${2:args} ${3:varname}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue