Skip to content

Commit bb50d73

Browse files
fix tag completions for auto-paired brackets and block snippets
1 parent a335657 commit bb50d73

File tree

2 files changed

+132
-24
lines changed

2 files changed

+132
-24
lines changed

crates/djls-server/src/completions.rs

Lines changed: 131 additions & 23 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,6 +338,32 @@ 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.character.saturating_sub(partial_len as u32);
351+
let start = Position::new(position.line, start_col);
352+
353+
// End position: include auto-paired } if present
354+
let mut end_col = position.character;
355+
if matches!(closing, ClosingBrace::PartialClose) {
356+
// Include the auto-paired } in the replacement range
357+
// Check if there's a } immediately after cursor
358+
if line_text.len() > cursor_offset && &line_text[cursor_offset..cursor_offset + 1] == "}" {
359+
end_col += 1;
360+
}
361+
}
362+
let end = Position::new(position.line, end_col);
363+
364+
Range::new(start, end)
365+
}
366+
325367
/// Generate completions for tag names
326368
fn generate_tag_name_completions(
327369
partial: &str,
@@ -330,12 +372,24 @@ fn generate_tag_name_completions(
330372
template_tags: Option<&TemplateTags>,
331373
tag_specs: Option<&TagSpecs>,
332374
supports_snippets: bool,
375+
position: Position,
376+
line_text: &str,
377+
cursor_offset: usize,
333378
) -> Vec<CompletionItem> {
334379
let Some(tags) = template_tags else {
335380
return Vec::new();
336381
};
337382

338383
let mut completions = Vec::new();
384+
385+
// Calculate the replacement range for all completions
386+
let replacement_range = calculate_replacement_range(
387+
position,
388+
line_text,
389+
cursor_offset,
390+
partial.len(),
391+
closing,
392+
);
339393

340394
// First, check if we should suggest end tags
341395
// If partial starts with "end", prioritize end tags
@@ -364,7 +418,9 @@ fn generate_tag_name_completions(
364418
label: end_tag.name.clone(),
365419
kind: Some(CompletionItemKind::KEYWORD),
366420
detail: Some(format!("End tag for {opener_name}")),
367-
insert_text: Some(insert_text),
421+
text_edit: Some(tower_lsp_server::lsp_types::CompletionTextEdit::Edit(
422+
TextEdit::new(replacement_range, insert_text.clone())
423+
)),
368424
insert_text_format: Some(InsertTextFormat::PLAIN_TEXT),
369425
filter_text: Some(end_tag.name.clone()),
370426
sort_text: Some(format!("0_{}", end_tag.name)), // Priority sort
@@ -393,14 +449,19 @@ fn generate_tag_name_completions(
393449
text.push(' ');
394450
}
395451

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
452+
// Generate the snippet
453+
let snippet = generate_snippet_for_tag_with_end(tag.name(), spec);
454+
text.push_str(&snippet);
455+
456+
// Only add closing if the snippet doesn't already include it
457+
// (snippets for tags with end tags include their own %} closing)
458+
if !snippet.contains("%}") {
459+
// Add closing based on what's already present
460+
match closing {
461+
ClosingBrace::None => text.push_str(" %}"),
462+
ClosingBrace::PartialClose => text.push_str(" %"),
463+
ClosingBrace::FullClose => {} // No closing needed
464+
}
404465
}
405466

406467
(text, InsertTextFormat::SNIPPET)
@@ -419,19 +480,21 @@ fn generate_tag_name_completions(
419480
};
420481

421482
// Create completion item
422-
// Use SNIPPET kind when we're inserting a snippet, FUNCTION otherwise
483+
// Use SNIPPET kind when we're inserting a snippet, KEYWORD otherwise
423484
let kind = if matches!(insert_format, InsertTextFormat::SNIPPET) {
424485
CompletionItemKind::SNIPPET
425486
} else {
426-
CompletionItemKind::FUNCTION
487+
CompletionItemKind::KEYWORD
427488
};
428489

429490
let completion_item = CompletionItem {
430491
label: tag.name().clone(),
431492
kind: Some(kind),
432493
detail: Some(format!("from {}", tag.library())),
433494
documentation: tag.doc().map(|doc| Documentation::String(doc.clone())),
434-
insert_text: Some(insert_text),
495+
text_edit: Some(tower_lsp_server::lsp_types::CompletionTextEdit::Edit(
496+
TextEdit::new(replacement_range, insert_text.clone())
497+
)),
435498
insert_text_format: Some(insert_format),
436499
filter_text: Some(tag.name().clone()),
437500
sort_text: Some(format!("1_{}", tag.name())), // Regular tags sort after end tags
@@ -479,12 +542,12 @@ fn generate_argument_completions(
479542
if arg.name.starts_with(partial) {
480543
let mut insert_text = arg.name.clone();
481544

482-
// Add closing if needed
483-
match closing {
484-
ClosingBrace::None => insert_text.push_str(" %}"),
485-
ClosingBrace::PartialClose => insert_text.push_str(" %"),
486-
ClosingBrace::FullClose => {} // No closing needed
487-
}
545+
// Add closing if needed
546+
match closing {
547+
ClosingBrace::None => insert_text.push_str(" %}"),
548+
ClosingBrace::PartialClose => insert_text.push_str(" %"),
549+
ClosingBrace::FullClose => {} // No closing needed
550+
}
488551

489552
completions.push(CompletionItem {
490553
label: arg.name.clone(),
@@ -505,7 +568,7 @@ fn generate_argument_completions(
505568
// Add closing if needed
506569
match closing {
507570
ClosingBrace::None => insert_text.push_str(" %}"),
508-
ClosingBrace::PartialClose => insert_text.push('%'),
571+
ClosingBrace::PartialClose => insert_text.push_str(" %"),
509572
ClosingBrace::FullClose => {} // No closing needed
510573
}
511574

@@ -563,7 +626,7 @@ fn generate_argument_completions(
563626
// Add closing if needed
564627
match closing {
565628
ClosingBrace::None => insert_text.push_str(" %}"),
566-
ClosingBrace::PartialClose => insert_text.push('%'),
629+
ClosingBrace::PartialClose => insert_text.push_str(" %"),
567630
ClosingBrace::FullClose => {} // No closing needed
568631
}
569632

@@ -614,7 +677,7 @@ fn generate_library_completions(
614677
// Add closing if needed
615678
match closing {
616679
ClosingBrace::None => insert_text.push_str(" %}"),
617-
ClosingBrace::PartialClose => insert_text.push('%'),
680+
ClosingBrace::PartialClose => insert_text.push_str(" %"),
618681
ClosingBrace::FullClose => {} // No closing needed
619682
}
620683

@@ -786,7 +849,15 @@ mod tests {
786849
closing: ClosingBrace::None,
787850
};
788851

789-
let completions = generate_template_completions(&context, None, None, false);
852+
let completions = generate_template_completions(
853+
&context,
854+
None,
855+
None,
856+
false,
857+
Position::new(0, 0),
858+
"",
859+
0,
860+
);
790861

791862
assert!(completions.is_empty());
792863
}
@@ -861,4 +932,41 @@ mod tests {
861932
}
862933
);
863934
}
935+
936+
#[test]
937+
fn test_analyze_template_context_with_auto_paired_brace() {
938+
// Simulates when editor auto-pairs { with } and user types {% if
939+
let line = "{% if}";
940+
let cursor_offset = 5; // After "if", before the auto-paired }
941+
942+
let context = analyze_template_context(line, cursor_offset).expect("Should get context");
943+
944+
assert_eq!(
945+
context,
946+
TemplateCompletionContext::TagName {
947+
partial: "if".to_string(),
948+
needs_space: false,
949+
closing: ClosingBrace::PartialClose, // Auto-paired } is detected as PartialClose
950+
}
951+
);
952+
}
953+
954+
#[test]
955+
fn test_analyze_template_context_with_proper_closing() {
956+
// Proper closing should still be detected
957+
let line = "{% if %}";
958+
let cursor_offset = 5; // After "if"
959+
960+
let context = analyze_template_context(line, cursor_offset).expect("Should get context");
961+
962+
assert_eq!(
963+
context,
964+
TemplateCompletionContext::TagName {
965+
partial: "if".to_string(),
966+
needs_space: false,
967+
closing: ClosingBrace::FullClose,
968+
}
969+
);
970+
}
971+
864972
}

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

Lines changed: 1 addition & 1 deletion
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

0 commit comments

Comments
 (0)