From bb50d738cc56690eae8e52e701e08afbb3433f09 Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 9 Sep 2025 01:19:03 -0500 Subject: [PATCH 1/4] fix tag completions for auto-paired brackets and block snippets --- crates/djls-server/src/completions.rs | 154 +++++++++++++++--- .../src/templatetags/snippets.rs | 2 +- 2 files changed, 132 insertions(+), 24 deletions(-) diff --git a/crates/djls-server/src/completions.rs b/crates/djls-server/src/completions.rs index 2a64bccc..8c36f694 100644 --- a/crates/djls-server/src/completions.rs +++ b/crates/djls-server/src/completions.rs @@ -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. /// @@ -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 @@ -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 { match context { TemplateCompletionContext::TagName { @@ -293,6 +306,9 @@ fn generate_template_completions( template_tags, tag_specs, supports_snippets, + position, + line_text, + cursor_offset, ), TemplateCompletionContext::TagArgument { tag, @@ -322,6 +338,32 @@ 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(partial_len as u32); + 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 + 1] == "}" { + end_col += 1; + } + } + let end = Position::new(position.line, end_col); + + Range::new(start, end) +} + /// Generate completions for tag names fn generate_tag_name_completions( partial: &str, @@ -330,12 +372,24 @@ fn generate_tag_name_completions( template_tags: Option<&TemplateTags>, tag_specs: Option<&TagSpecs>, supports_snippets: bool, + position: Position, + line_text: &str, + cursor_offset: usize, ) -> Vec { 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 @@ -364,7 +418,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 @@ -393,14 +449,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) @@ -419,11 +480,11 @@ 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 { @@ -431,7 +492,9 @@ fn generate_tag_name_completions( 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 @@ -479,12 +542,12 @@ fn generate_argument_completions( 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 - } + // 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(), @@ -505,7 +568,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 } @@ -563,7 +626,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 } @@ -614,7 +677,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 } @@ -786,7 +849,15 @@ 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()); } @@ -861,4 +932,41 @@ 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, + } + ); + } + } diff --git a/crates/djls-templates/src/templatetags/snippets.rs b/crates/djls-templates/src/templatetags/snippets.rs index 666b53c1..2c21b7ab 100644 --- a/crates/djls-templates/src/templatetags/snippets.rs +++ b/crates/djls-templates/src/templatetags/snippets.rs @@ -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; } From 49c1e237c0dcb07e27d3438c57765003de31fe80 Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 9 Sep 2025 01:20:14 -0500 Subject: [PATCH 2/4] clippy --- crates/djls-server/src/completions.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/djls-server/src/completions.rs b/crates/djls-server/src/completions.rs index 8c36f694..29b1cbcc 100644 --- a/crates/djls-server/src/completions.rs +++ b/crates/djls-server/src/completions.rs @@ -347,7 +347,7 @@ fn calculate_replacement_range( closing: &ClosingBrace, ) -> Range { // Start position: move back by the length of the partial text - let start_col = position.character.saturating_sub(partial_len as u32); + 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 @@ -355,7 +355,7 @@ fn calculate_replacement_range( 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 + 1] == "}" { + if line_text.len() > cursor_offset && &line_text[cursor_offset..=cursor_offset] == "}" { end_col += 1; } } @@ -365,6 +365,7 @@ fn calculate_replacement_range( } /// Generate completions for tag names +#[allow(clippy::too_many_arguments)] fn generate_tag_name_completions( partial: &str, needs_space: bool, From ea2ede4e392843ee1a972425fc768bd0c8dcae21 Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 9 Sep 2025 01:20:31 -0500 Subject: [PATCH 3/4] fmt --- crates/djls-server/src/completions.rs | 47 ++++++++++----------------- 1 file changed, 18 insertions(+), 29 deletions(-) diff --git a/crates/djls-server/src/completions.rs b/crates/djls-server/src/completions.rs index 29b1cbcc..eceb130d 100644 --- a/crates/djls-server/src/completions.rs +++ b/crates/djls-server/src/completions.rs @@ -347,9 +347,11 @@ fn calculate_replacement_range( 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_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) { @@ -360,7 +362,7 @@ fn calculate_replacement_range( } } let end = Position::new(position.line, end_col); - + Range::new(start, end) } @@ -382,15 +384,10 @@ fn generate_tag_name_completions( }; 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, - ); + 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 @@ -420,7 +417,7 @@ fn generate_tag_name_completions( kind: Some(CompletionItemKind::KEYWORD), detail: Some(format!("End tag for {opener_name}")), text_edit: Some(tower_lsp_server::lsp_types::CompletionTextEdit::Edit( - TextEdit::new(replacement_range, insert_text.clone()) + TextEdit::new(replacement_range, insert_text.clone()), )), insert_text_format: Some(InsertTextFormat::PLAIN_TEXT), filter_text: Some(end_tag.name.clone()), @@ -494,7 +491,7 @@ fn generate_tag_name_completions( detail: Some(format!("from {}", tag.library())), documentation: tag.doc().map(|doc| Documentation::String(doc.clone())), text_edit: Some(tower_lsp_server::lsp_types::CompletionTextEdit::Edit( - TextEdit::new(replacement_range, insert_text.clone()) + TextEdit::new(replacement_range, insert_text.clone()), )), insert_text_format: Some(insert_format), filter_text: Some(tag.name().clone()), @@ -543,12 +540,12 @@ fn generate_argument_completions( 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 - } + // 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(), @@ -850,15 +847,8 @@ mod tests { closing: ClosingBrace::None, }; - let completions = generate_template_completions( - &context, - None, - None, - false, - Position::new(0, 0), - "", - 0, - ); + let completions = + generate_template_completions(&context, None, None, false, Position::new(0, 0), "", 0); assert!(completions.is_empty()); } @@ -969,5 +959,4 @@ mod tests { } ); } - } From 02633543c1d236ea02a52ee357d60d77c0b214e0 Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 9 Sep 2025 01:27:20 -0500 Subject: [PATCH 4/4] fix assertion --- crates/djls-templates/src/templatetags/snippets.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/djls-templates/src/templatetags/snippets.rs b/crates/djls-templates/src/templatetags/snippets.rs index 2c21b7ab..ac8cd427 100644 --- a/crates/djls-templates/src/templatetags/snippets.rs +++ b/crates/djls-templates/src/templatetags/snippets.rs @@ -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]