Skip to content

Commit 7e46cb5

Browse files
fix tag completions for auto-paired brackets and block snippets (#209)
1 parent a335657 commit 7e46cb5

File tree

2 files changed

+117
-19
lines changed

2 files changed

+117
-19
lines changed

crates/djls-server/src/completions.rs

Lines changed: 115 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ use tower_lsp_server::lsp_types::CompletionItemKind;
1717
use tower_lsp_server::lsp_types::Documentation;
1818
use tower_lsp_server::lsp_types::InsertTextFormat;
1919
use tower_lsp_server::lsp_types::Position;
20+
use tower_lsp_server::lsp_types::Range;
21+
use tower_lsp_server::lsp_types::TextEdit;
2022

2123
/// Tracks what closing characters are needed to complete a template tag.
2224
///
@@ -119,7 +121,15 @@ pub fn handle_completion(
119121
};
120122

121123
// Generate completions based on available template tags
122-
generate_template_completions(&context, template_tags, tag_specs, supports_snippets)
124+
generate_template_completions(
125+
&context,
126+
template_tags,
127+
tag_specs,
128+
supports_snippets,
129+
position,
130+
&line_info.text,
131+
line_info.cursor_offset,
132+
)
123133
}
124134

125135
/// Extract line information from document at given position
@@ -280,6 +290,9 @@ fn generate_template_completions(
280290
template_tags: Option<&TemplateTags>,
281291
tag_specs: Option<&TagSpecs>,
282292
supports_snippets: bool,
293+
position: Position,
294+
line_text: &str,
295+
cursor_offset: usize,
283296
) -> Vec<CompletionItem> {
284297
match context {
285298
TemplateCompletionContext::TagName {
@@ -293,6 +306,9 @@ fn generate_template_completions(
293306
template_tags,
294307
tag_specs,
295308
supports_snippets,
309+
position,
310+
line_text,
311+
cursor_offset,
296312
),
297313
TemplateCompletionContext::TagArgument {
298314
tag,
@@ -322,21 +338,57 @@ fn generate_template_completions(
322338
}
323339
}
324340

341+
/// Calculate the range to replace for a completion
342+
fn calculate_replacement_range(
343+
position: Position,
344+
line_text: &str,
345+
cursor_offset: usize,
346+
partial_len: usize,
347+
closing: &ClosingBrace,
348+
) -> Range {
349+
// Start position: move back by the length of the partial text
350+
let start_col = position
351+
.character
352+
.saturating_sub(u32::try_from(partial_len).unwrap_or(0));
353+
let start = Position::new(position.line, start_col);
354+
355+
// End position: include auto-paired } if present
356+
let mut end_col = position.character;
357+
if matches!(closing, ClosingBrace::PartialClose) {
358+
// Include the auto-paired } in the replacement range
359+
// Check if there's a } immediately after cursor
360+
if line_text.len() > cursor_offset && &line_text[cursor_offset..=cursor_offset] == "}" {
361+
end_col += 1;
362+
}
363+
}
364+
let end = Position::new(position.line, end_col);
365+
366+
Range::new(start, end)
367+
}
368+
325369
/// Generate completions for tag names
370+
#[allow(clippy::too_many_arguments)]
326371
fn generate_tag_name_completions(
327372
partial: &str,
328373
needs_space: bool,
329374
closing: &ClosingBrace,
330375
template_tags: Option<&TemplateTags>,
331376
tag_specs: Option<&TagSpecs>,
332377
supports_snippets: bool,
378+
position: Position,
379+
line_text: &str,
380+
cursor_offset: usize,
333381
) -> Vec<CompletionItem> {
334382
let Some(tags) = template_tags else {
335383
return Vec::new();
336384
};
337385

338386
let mut completions = Vec::new();
339387

388+
// Calculate the replacement range for all completions
389+
let replacement_range =
390+
calculate_replacement_range(position, line_text, cursor_offset, partial.len(), closing);
391+
340392
// First, check if we should suggest end tags
341393
// If partial starts with "end", prioritize end tags
342394
if partial.starts_with("end") && tag_specs.is_some() {
@@ -364,7 +416,9 @@ fn generate_tag_name_completions(
364416
label: end_tag.name.clone(),
365417
kind: Some(CompletionItemKind::KEYWORD),
366418
detail: Some(format!("End tag for {opener_name}")),
367-
insert_text: Some(insert_text),
419+
text_edit: Some(tower_lsp_server::lsp_types::CompletionTextEdit::Edit(
420+
TextEdit::new(replacement_range, insert_text.clone()),
421+
)),
368422
insert_text_format: Some(InsertTextFormat::PLAIN_TEXT),
369423
filter_text: Some(end_tag.name.clone()),
370424
sort_text: Some(format!("0_{}", end_tag.name)), // Priority sort
@@ -393,14 +447,19 @@ fn generate_tag_name_completions(
393447
text.push(' ');
394448
}
395449

396-
// Add tag name and snippet arguments (including end tag if required)
397-
text.push_str(&generate_snippet_for_tag_with_end(tag.name(), spec));
398-
399-
// Add closing based on what's already present
400-
match closing {
401-
ClosingBrace::None => text.push_str(" %}"),
402-
ClosingBrace::PartialClose => text.push_str(" %"),
403-
ClosingBrace::FullClose => {} // No closing needed
450+
// Generate the snippet
451+
let snippet = generate_snippet_for_tag_with_end(tag.name(), spec);
452+
text.push_str(&snippet);
453+
454+
// Only add closing if the snippet doesn't already include it
455+
// (snippets for tags with end tags include their own %} closing)
456+
if !snippet.contains("%}") {
457+
// Add closing based on what's already present
458+
match closing {
459+
ClosingBrace::None => text.push_str(" %}"),
460+
ClosingBrace::PartialClose => text.push_str(" %"),
461+
ClosingBrace::FullClose => {} // No closing needed
462+
}
404463
}
405464

406465
(text, InsertTextFormat::SNIPPET)
@@ -419,19 +478,21 @@ fn generate_tag_name_completions(
419478
};
420479

421480
// Create completion item
422-
// Use SNIPPET kind when we're inserting a snippet, FUNCTION otherwise
481+
// Use SNIPPET kind when we're inserting a snippet, KEYWORD otherwise
423482
let kind = if matches!(insert_format, InsertTextFormat::SNIPPET) {
424483
CompletionItemKind::SNIPPET
425484
} else {
426-
CompletionItemKind::FUNCTION
485+
CompletionItemKind::KEYWORD
427486
};
428487

429488
let completion_item = CompletionItem {
430489
label: tag.name().clone(),
431490
kind: Some(kind),
432491
detail: Some(format!("from {}", tag.library())),
433492
documentation: tag.doc().map(|doc| Documentation::String(doc.clone())),
434-
insert_text: Some(insert_text),
493+
text_edit: Some(tower_lsp_server::lsp_types::CompletionTextEdit::Edit(
494+
TextEdit::new(replacement_range, insert_text.clone()),
495+
)),
435496
insert_text_format: Some(insert_format),
436497
filter_text: Some(tag.name().clone()),
437498
sort_text: Some(format!("1_{}", tag.name())), // Regular tags sort after end tags
@@ -505,7 +566,7 @@ fn generate_argument_completions(
505566
// Add closing if needed
506567
match closing {
507568
ClosingBrace::None => insert_text.push_str(" %}"),
508-
ClosingBrace::PartialClose => insert_text.push('%'),
569+
ClosingBrace::PartialClose => insert_text.push_str(" %"),
509570
ClosingBrace::FullClose => {} // No closing needed
510571
}
511572

@@ -563,7 +624,7 @@ fn generate_argument_completions(
563624
// Add closing if needed
564625
match closing {
565626
ClosingBrace::None => insert_text.push_str(" %}"),
566-
ClosingBrace::PartialClose => insert_text.push('%'),
627+
ClosingBrace::PartialClose => insert_text.push_str(" %"),
567628
ClosingBrace::FullClose => {} // No closing needed
568629
}
569630

@@ -614,7 +675,7 @@ fn generate_library_completions(
614675
// Add closing if needed
615676
match closing {
616677
ClosingBrace::None => insert_text.push_str(" %}"),
617-
ClosingBrace::PartialClose => insert_text.push('%'),
678+
ClosingBrace::PartialClose => insert_text.push_str(" %"),
618679
ClosingBrace::FullClose => {} // No closing needed
619680
}
620681

@@ -786,7 +847,8 @@ mod tests {
786847
closing: ClosingBrace::None,
787848
};
788849

789-
let completions = generate_template_completions(&context, None, None, false);
850+
let completions =
851+
generate_template_completions(&context, None, None, false, Position::new(0, 0), "", 0);
790852

791853
assert!(completions.is_empty());
792854
}
@@ -861,4 +923,40 @@ mod tests {
861923
}
862924
);
863925
}
926+
927+
#[test]
928+
fn test_analyze_template_context_with_auto_paired_brace() {
929+
// Simulates when editor auto-pairs { with } and user types {% if
930+
let line = "{% if}";
931+
let cursor_offset = 5; // After "if", before the auto-paired }
932+
933+
let context = analyze_template_context(line, cursor_offset).expect("Should get context");
934+
935+
assert_eq!(
936+
context,
937+
TemplateCompletionContext::TagName {
938+
partial: "if".to_string(),
939+
needs_space: false,
940+
closing: ClosingBrace::PartialClose, // Auto-paired } is detected as PartialClose
941+
}
942+
);
943+
}
944+
945+
#[test]
946+
fn test_analyze_template_context_with_proper_closing() {
947+
// Proper closing should still be detected
948+
let line = "{% if %}";
949+
let cursor_offset = 5; // After "if"
950+
951+
let context = analyze_template_context(line, cursor_offset).expect("Should get context");
952+
953+
assert_eq!(
954+
context,
955+
TemplateCompletionContext::TagName {
956+
partial: "if".to_string(),
957+
needs_space: false,
958+
closing: ClosingBrace::FullClose,
959+
}
960+
);
961+
}
864962
}

crates/djls-templates/src/templatetags/snippets.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ pub fn generate_snippet_for_tag_with_end(tag_name: &str, spec: &TagSpec) -> Stri
8888
if tag_name == "block" {
8989
// LSP snippets support placeholder mirroring using the same number
9090
// ${1:name} in opening tag will be mirrored to ${1} in closing tag
91-
let snippet = String::from("block ${1:name} %}\n$0\n{% endblock ${1}");
91+
let snippet = String::from("block ${1:name} %}\n$0\n{% endblock ${1} %}");
9292
return snippet;
9393
}
9494

@@ -225,7 +225,7 @@ mod tests {
225225
};
226226

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

231231
#[test]

0 commit comments

Comments
 (0)