Add context-aware completions with snippets (#207)

This commit is contained in:
Josh Thomas 2025-09-09 00:36:14 -05:00 committed by GitHub
parent 792bdd8e13
commit 1cc233f204
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 707 additions and 76 deletions

View file

@ -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,
}
);
}
} }

View file

@ -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,

View file

@ -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

View file

@ -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;

View file

@ -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}");
} }
} }