Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 115 additions & 17 deletions crates/djls-server/src/completions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ use tower_lsp_server::lsp_types::CompletionItemKind;
use tower_lsp_server::lsp_types::Documentation;
use tower_lsp_server::lsp_types::InsertTextFormat;
use tower_lsp_server::lsp_types::Position;
use tower_lsp_server::lsp_types::Range;
use tower_lsp_server::lsp_types::TextEdit;

/// Tracks what closing characters are needed to complete a template tag.
///
Expand Down Expand Up @@ -119,7 +121,15 @@ pub fn handle_completion(
};

// Generate completions based on available template tags
generate_template_completions(&context, template_tags, tag_specs, supports_snippets)
generate_template_completions(
&context,
template_tags,
tag_specs,
supports_snippets,
position,
&line_info.text,
line_info.cursor_offset,
)
}

/// Extract line information from document at given position
Expand Down Expand Up @@ -280,6 +290,9 @@ fn generate_template_completions(
template_tags: Option<&TemplateTags>,
tag_specs: Option<&TagSpecs>,
supports_snippets: bool,
position: Position,
line_text: &str,
cursor_offset: usize,
) -> Vec<CompletionItem> {
match context {
TemplateCompletionContext::TagName {
Expand All @@ -293,6 +306,9 @@ fn generate_template_completions(
template_tags,
tag_specs,
supports_snippets,
position,
line_text,
cursor_offset,
),
TemplateCompletionContext::TagArgument {
tag,
Expand Down Expand Up @@ -322,21 +338,57 @@ fn generate_template_completions(
}
}

/// Calculate the range to replace for a completion
fn calculate_replacement_range(
position: Position,
line_text: &str,
cursor_offset: usize,
partial_len: usize,
closing: &ClosingBrace,
) -> Range {
// Start position: move back by the length of the partial text
let start_col = position
.character
.saturating_sub(u32::try_from(partial_len).unwrap_or(0));
let start = Position::new(position.line, start_col);

// End position: include auto-paired } if present
let mut end_col = position.character;
if matches!(closing, ClosingBrace::PartialClose) {
// Include the auto-paired } in the replacement range
// Check if there's a } immediately after cursor
if line_text.len() > cursor_offset && &line_text[cursor_offset..=cursor_offset] == "}" {
end_col += 1;
}
}
let end = Position::new(position.line, end_col);

Range::new(start, end)
}

/// Generate completions for tag names
#[allow(clippy::too_many_arguments)]
fn generate_tag_name_completions(
partial: &str,
needs_space: bool,
closing: &ClosingBrace,
template_tags: Option<&TemplateTags>,
tag_specs: Option<&TagSpecs>,
supports_snippets: bool,
position: Position,
line_text: &str,
cursor_offset: usize,
) -> Vec<CompletionItem> {
let Some(tags) = template_tags else {
return Vec::new();
};

let mut completions = Vec::new();

// Calculate the replacement range for all completions
let replacement_range =
calculate_replacement_range(position, line_text, cursor_offset, partial.len(), closing);

// 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() {
Expand Down Expand Up @@ -364,7 +416,9 @@ fn generate_tag_name_completions(
label: end_tag.name.clone(),
kind: Some(CompletionItemKind::KEYWORD),
detail: Some(format!("End tag for {opener_name}")),
insert_text: Some(insert_text),
text_edit: Some(tower_lsp_server::lsp_types::CompletionTextEdit::Edit(
TextEdit::new(replacement_range, insert_text.clone()),
)),
insert_text_format: Some(InsertTextFormat::PLAIN_TEXT),
filter_text: Some(end_tag.name.clone()),
sort_text: Some(format!("0_{}", end_tag.name)), // Priority sort
Expand Down Expand Up @@ -393,14 +447,19 @@ fn generate_tag_name_completions(
text.push(' ');
}

// 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 closing {
ClosingBrace::None => text.push_str(" %}"),
ClosingBrace::PartialClose => text.push_str(" %"),
ClosingBrace::FullClose => {} // No closing needed
// Generate the snippet
let snippet = generate_snippet_for_tag_with_end(tag.name(), spec);
text.push_str(&snippet);

// Only add closing if the snippet doesn't already include it
// (snippets for tags with end tags include their own %} closing)
if !snippet.contains("%}") {
// Add closing based on what's already present
match closing {
ClosingBrace::None => text.push_str(" %}"),
ClosingBrace::PartialClose => text.push_str(" %"),
ClosingBrace::FullClose => {} // No closing needed
}
}

(text, InsertTextFormat::SNIPPET)
Expand All @@ -419,19 +478,21 @@ fn generate_tag_name_completions(
};

// Create completion item
// Use SNIPPET kind when we're inserting a snippet, FUNCTION otherwise
// Use SNIPPET kind when we're inserting a snippet, KEYWORD otherwise
let kind = if matches!(insert_format, InsertTextFormat::SNIPPET) {
CompletionItemKind::SNIPPET
} else {
CompletionItemKind::FUNCTION
CompletionItemKind::KEYWORD
};

let completion_item = CompletionItem {
label: tag.name().clone(),
kind: Some(kind),
detail: Some(format!("from {}", tag.library())),
documentation: tag.doc().map(|doc| Documentation::String(doc.clone())),
insert_text: Some(insert_text),
text_edit: Some(tower_lsp_server::lsp_types::CompletionTextEdit::Edit(
TextEdit::new(replacement_range, insert_text.clone()),
)),
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
Expand Down Expand Up @@ -505,7 +566,7 @@ fn generate_argument_completions(
// Add closing if needed
match closing {
ClosingBrace::None => insert_text.push_str(" %}"),
ClosingBrace::PartialClose => insert_text.push('%'),
ClosingBrace::PartialClose => insert_text.push_str(" %"),
ClosingBrace::FullClose => {} // No closing needed
}

Expand Down Expand Up @@ -563,7 +624,7 @@ fn generate_argument_completions(
// Add closing if needed
match closing {
ClosingBrace::None => insert_text.push_str(" %}"),
ClosingBrace::PartialClose => insert_text.push('%'),
ClosingBrace::PartialClose => insert_text.push_str(" %"),
ClosingBrace::FullClose => {} // No closing needed
}

Expand Down Expand Up @@ -614,7 +675,7 @@ fn generate_library_completions(
// Add closing if needed
match closing {
ClosingBrace::None => insert_text.push_str(" %}"),
ClosingBrace::PartialClose => insert_text.push('%'),
ClosingBrace::PartialClose => insert_text.push_str(" %"),
ClosingBrace::FullClose => {} // No closing needed
}

Expand Down Expand Up @@ -786,7 +847,8 @@ mod tests {
closing: ClosingBrace::None,
};

let completions = generate_template_completions(&context, None, None, false);
let completions =
generate_template_completions(&context, None, None, false, Position::new(0, 0), "", 0);

assert!(completions.is_empty());
}
Expand Down Expand Up @@ -861,4 +923,40 @@ mod tests {
}
);
}

#[test]
fn test_analyze_template_context_with_auto_paired_brace() {
// Simulates when editor auto-pairs { with } and user types {% if
let line = "{% if}";
let cursor_offset = 5; // After "if", before the auto-paired }

let context = analyze_template_context(line, cursor_offset).expect("Should get context");

assert_eq!(
context,
TemplateCompletionContext::TagName {
partial: "if".to_string(),
needs_space: false,
closing: ClosingBrace::PartialClose, // Auto-paired } is detected as PartialClose
}
);
}

#[test]
fn test_analyze_template_context_with_proper_closing() {
// Proper closing should still be detected
let line = "{% if %}";
let cursor_offset = 5; // After "if"

let context = analyze_template_context(line, cursor_offset).expect("Should get context");

assert_eq!(
context,
TemplateCompletionContext::TagName {
partial: "if".to_string(),
needs_space: false,
closing: ClosingBrace::FullClose,
}
);
}
}
4 changes: 2 additions & 2 deletions crates/djls-templates/src/templatetags/snippets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ pub fn generate_snippet_for_tag_with_end(tag_name: &str, spec: &TagSpec) -> Stri
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}");
let snippet = String::from("block ${1:name} %}\n$0\n{% endblock ${1} %}");
return snippet;
}

Expand Down Expand Up @@ -225,7 +225,7 @@ mod tests {
};

let snippet = generate_snippet_for_tag_with_end("block", &spec);
assert_eq!(snippet, "block ${1:name} %}\n$0\n{% endblock ${1}");
assert_eq!(snippet, "block ${1:name} %}\n$0\n{% endblock ${1} %}");
}

#[test]
Expand Down