mirror of
https://github.com/joshuadavidthomas/django-language-server.git
synced 2025-09-13 13:56:25 +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.
|
||||
|
||||
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_workspace::FileKind;
|
||||
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
|
||||
/// closing braces when completing a Django template tag.
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ClosingBrace {
|
||||
/// No closing brace present - need to add full `%}` or `}}`
|
||||
None,
|
||||
|
@ -29,18 +32,56 @@ pub enum ClosingBrace {
|
|||
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
|
||||
/// completions and determine what text needs to be inserted.
|
||||
#[derive(Debug)]
|
||||
pub struct TemplateTagContext {
|
||||
/// The partial tag text before the cursor (e.g., "loa" for "{% loa|")
|
||||
pub partial_tag: String,
|
||||
/// What closing characters are already present after the cursor
|
||||
pub closing_brace: ClosingBrace,
|
||||
/// Whether a space is needed before the completion (true if cursor is right after `{%`)
|
||||
pub needs_leading_space: bool,
|
||||
/// Distinguishes between different completion contexts to provide
|
||||
/// appropriate suggestions based on cursor position.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[allow(dead_code)]
|
||||
pub enum TemplateCompletionContext {
|
||||
/// Completing a tag name after {%
|
||||
TagName {
|
||||
/// Partial tag name typed so far
|
||||
partial: String,
|
||||
/// 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
|
||||
|
@ -129,7 +170,7 @@ 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> {
|
||||
fn analyze_template_context(line: &str, cursor_offset: usize) -> Option<TemplateCompletionContext> {
|
||||
// Find the last {% before cursor position
|
||||
let prefix = &line[..cursor_offset.min(line.len())];
|
||||
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 = &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
|
||||
let suffix = &line[cursor_offset.min(line.len())..];
|
||||
let closing_brace = detect_closing_brace(suffix);
|
||||
let closing = detect_closing_brace(suffix);
|
||||
|
||||
Some(TemplateTagContext {
|
||||
partial_tag,
|
||||
closing_brace,
|
||||
needs_leading_space,
|
||||
// Check if we need a leading space (no space after {%)
|
||||
let needs_space = content.is_empty() || !content.starts_with(' ');
|
||||
|
||||
// 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
|
||||
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>,
|
||||
tag_specs: Option<&TagSpecs>,
|
||||
supports_snippets: bool,
|
||||
|
@ -180,31 +337,69 @@ fn generate_template_completions(
|
|||
|
||||
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() {
|
||||
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
|
||||
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)
|
||||
build_plain_insert_for_tag(tag.name(), needs_space, closing)
|
||||
} else {
|
||||
// Generate snippet from tag spec
|
||||
let mut text = String::new();
|
||||
|
||||
// Add leading space if needed
|
||||
if context.needs_leading_space {
|
||||
if needs_space {
|
||||
text.push(' ');
|
||||
}
|
||||
|
||||
// Add tag name and snippet arguments
|
||||
text.push_str(&generate_snippet_for_tag(tag.name(), spec));
|
||||
// Add tag name and snippet arguments (including end tag if required)
|
||||
text.push_str(&generate_snippet_for_tag_with_end(tag.name(), spec));
|
||||
|
||||
// Add closing based on what's already present
|
||||
match context.closing_brace {
|
||||
match closing {
|
||||
ClosingBrace::None => text.push_str(" %}"),
|
||||
ClosingBrace::PartialClose => text.push('%'),
|
||||
ClosingBrace::PartialClose => text.push_str(" %"),
|
||||
ClosingBrace::FullClose => {} // No closing needed
|
||||
}
|
||||
|
||||
|
@ -212,26 +407,34 @@ fn generate_template_completions(
|
|||
}
|
||||
} else {
|
||||
// No spec found, use plain text
|
||||
build_plain_insert(tag.name(), context)
|
||||
build_plain_insert_for_tag(tag.name(), needs_space, closing)
|
||||
}
|
||||
} else {
|
||||
// No specs available, use plain text
|
||||
build_plain_insert(tag.name(), context)
|
||||
build_plain_insert_for_tag(tag.name(), needs_space, closing)
|
||||
}
|
||||
} else {
|
||||
// Client doesn't support snippets
|
||||
build_plain_insert(tag.name(), context)
|
||||
build_plain_insert_for_tag(tag.name(), needs_space, closing)
|
||||
};
|
||||
|
||||
// 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 {
|
||||
label: tag.name().clone(),
|
||||
kind: Some(CompletionItemKind::FUNCTION),
|
||||
kind: Some(kind),
|
||||
detail: Some(format!("from {}", tag.library())),
|
||||
documentation: tag.doc().map(|doc| Documentation::String(doc.clone())),
|
||||
insert_text: Some(insert_text),
|
||||
insert_text_format: Some(insert_format),
|
||||
filter_text: Some(tag.name().clone()),
|
||||
sort_text: Some(format!("1_{}", tag.name())), // Regular tags sort after end tags
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
|
@ -242,12 +445,204 @@ fn generate_template_completions(
|
|||
completions
|
||||
}
|
||||
|
||||
/// Build plain insert text without snippets
|
||||
fn build_plain_insert(tag_name: &str, context: &TemplateTagContext) -> (String, InsertTextFormat) {
|
||||
/// Generate completions for tag arguments
|
||||
#[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();
|
||||
|
||||
// Add leading space if needed (cursor right after {%)
|
||||
if context.needs_leading_space {
|
||||
if needs_space {
|
||||
insert_text.push(' ');
|
||||
}
|
||||
|
||||
|
@ -255,9 +650,9 @@ fn build_plain_insert(tag_name: &str, context: &TemplateTagContext) -> (String,
|
|||
insert_text.push_str(tag_name);
|
||||
|
||||
// Add closing based on what's already present
|
||||
match context.closing_brace {
|
||||
match closing {
|
||||
ClosingBrace::None => insert_text.push_str(" %}"),
|
||||
ClosingBrace::PartialClose => insert_text.push('%'),
|
||||
ClosingBrace::PartialClose => insert_text.push_str(" %"),
|
||||
ClosingBrace::FullClose => {} // No closing needed
|
||||
}
|
||||
|
||||
|
@ -269,27 +664,37 @@ mod tests {
|
|||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_analyze_template_context_basic() {
|
||||
fn test_analyze_template_context_tag_name() {
|
||||
let line = "{% loa";
|
||||
let cursor_offset = 6; // After "loa"
|
||||
|
||||
let context = analyze_template_context(line, cursor_offset).expect("Should get context");
|
||||
|
||||
assert_eq!(context.partial_tag, "loa");
|
||||
assert!(!context.needs_leading_space);
|
||||
assert!(matches!(context.closing_brace, ClosingBrace::None));
|
||||
assert_eq!(
|
||||
context,
|
||||
TemplateCompletionContext::TagName {
|
||||
partial: "loa".to_string(),
|
||||
needs_space: false,
|
||||
closing: ClosingBrace::None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_analyze_template_context_needs_leading_space() {
|
||||
fn test_analyze_template_context_needs_space() {
|
||||
let line = "{%loa";
|
||||
let cursor_offset = 5; // After "loa"
|
||||
|
||||
let context = analyze_template_context(line, cursor_offset).expect("Should get context");
|
||||
|
||||
assert_eq!(context.partial_tag, "loa");
|
||||
assert!(context.needs_leading_space);
|
||||
assert!(matches!(context.closing_brace, ClosingBrace::None));
|
||||
assert_eq!(
|
||||
context,
|
||||
TemplateCompletionContext::TagName {
|
||||
partial: "loa".to_string(),
|
||||
needs_space: true,
|
||||
closing: ClosingBrace::None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
@ -299,21 +704,68 @@ mod tests {
|
|||
|
||||
let context = analyze_template_context(line, cursor_offset).expect("Should get context");
|
||||
|
||||
assert_eq!(context.partial_tag, "load");
|
||||
assert!(!context.needs_leading_space);
|
||||
assert!(matches!(context.closing_brace, ClosingBrace::FullClose));
|
||||
assert_eq!(
|
||||
context,
|
||||
TemplateCompletionContext::TagName {
|
||||
partial: "load".to_string(),
|
||||
needs_space: false,
|
||||
closing: ClosingBrace::FullClose,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_analyze_template_context_partial_closing() {
|
||||
let line = "{% load }";
|
||||
let cursor_offset = 7; // After "load"
|
||||
fn test_analyze_template_context_library_name() {
|
||||
let line = "{% load stat";
|
||||
let cursor_offset = 12; // After "stat"
|
||||
|
||||
let context = analyze_template_context(line, cursor_offset).expect("Should get context");
|
||||
|
||||
assert_eq!(context.partial_tag, "load");
|
||||
assert!(!context.needs_leading_space);
|
||||
assert!(matches!(context.closing_brace, ClosingBrace::PartialClose));
|
||||
assert_eq!(
|
||||
context,
|
||||
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]
|
||||
|
@ -328,14 +780,85 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_generate_template_completions_empty_tags() {
|
||||
let context = TemplateTagContext {
|
||||
partial_tag: "loa".to_string(),
|
||||
needs_leading_space: false,
|
||||
closing_brace: ClosingBrace::None,
|
||||
let context = TemplateCompletionContext::TagName {
|
||||
partial: "loa".to_string(),
|
||||
needs_space: false,
|
||||
closing: ClosingBrace::None,
|
||||
};
|
||||
|
||||
let completions = generate_template_completions(&context, None, None, false);
|
||||
|
||||
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 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 supports_snippets = session.supports_snippets();
|
||||
|
||||
let completions = crate::completions::handle_completion(
|
||||
&document,
|
||||
|
|
|
@ -122,6 +122,18 @@ impl Session {
|
|||
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.
|
||||
pub fn with_db<F, R>(&self, f: F) -> R
|
||||
where
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
mod snippets;
|
||||
mod specs;
|
||||
|
||||
pub use snippets::generate_partial_snippet;
|
||||
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 specs::Arg;
|
||||
pub use specs::ArgType;
|
||||
|
|
|
@ -10,7 +10,13 @@ pub fn generate_snippet_from_args(args: &[Arg]) -> String {
|
|||
let mut placeholder_index = 1;
|
||||
|
||||
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
|
||||
if !arg.required && parts.is_empty() {
|
||||
continue;
|
||||
|
@ -19,15 +25,8 @@ pub fn generate_snippet_from_args(args: &[Arg]) -> String {
|
|||
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
|
||||
}
|
||||
// At this point, we know it's required (optional literals were skipped above)
|
||||
arg.name.clone()
|
||||
}
|
||||
SimpleArgType::Variable | SimpleArgType::Expression => {
|
||||
// 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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
@ -114,7 +151,7 @@ mod tests {
|
|||
];
|
||||
|
||||
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]
|
||||
|
@ -163,6 +200,63 @@ mod tests {
|
|||
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]
|
||||
fn test_snippet_for_url_tag() {
|
||||
let args = vec![
|
||||
|
@ -189,6 +283,6 @@ mod tests {
|
|||
];
|
||||
|
||||
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