Skip to content

Commit 792bdd8

Browse files
add typed argspecs for LSP snippets to tagspecs config (#206)
1 parent 03f18d2 commit 792bdd8

File tree

8 files changed

+605
-125
lines changed

8 files changed

+605
-125
lines changed

crates/djls-server/src/completions.rs

Lines changed: 100 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
//! and generating appropriate completion items for Django templates.
55
66
use djls_project::TemplateTags;
7+
use djls_templates::templatetags::generate_snippet_for_tag;
8+
use djls_templates::templatetags::TagSpecs;
79
use djls_workspace::FileKind;
810
use djls_workspace::PositionEncoding;
911
use djls_workspace::TextDocument;
@@ -57,6 +59,8 @@ pub fn handle_completion(
5759
encoding: PositionEncoding,
5860
file_kind: FileKind,
5961
template_tags: Option<&TemplateTags>,
62+
tag_specs: Option<&TagSpecs>,
63+
supports_snippets: bool,
6064
) -> Vec<CompletionItem> {
6165
// Only handle template files
6266
if file_kind != FileKind::Template {
@@ -74,7 +78,7 @@ pub fn handle_completion(
7478
};
7579

7680
// Generate completions based on available template tags
77-
generate_template_completions(&context, template_tags)
81+
generate_template_completions(&context, template_tags, tag_specs, supports_snippets)
7882
}
7983

8084
/// Extract line information from document at given position
@@ -126,49 +130,49 @@ fn get_line_info(
126130

127131
/// Analyze a line of template text to determine completion context
128132
fn analyze_template_context(line: &str, cursor_offset: usize) -> Option<TemplateTagContext> {
129-
if cursor_offset > line.chars().count() {
130-
return None;
131-
}
133+
// Find the last {% before cursor position
134+
let prefix = &line[..cursor_offset.min(line.len())];
135+
let tag_start = prefix.rfind("{%")?;
132136

133-
let chars: Vec<char> = line.chars().collect();
134-
let prefix = chars[..cursor_offset].iter().collect::<String>();
135-
let rest_of_line = chars[cursor_offset..].iter().collect::<String>();
136-
let rest_trimmed = rest_of_line.trim_start();
137-
138-
prefix.rfind("{%").map(|tag_start| {
139-
let closing_brace = if rest_trimmed.starts_with("%}") {
140-
ClosingBrace::FullClose
141-
} else if rest_trimmed.starts_with('}') {
142-
ClosingBrace::PartialClose
143-
} else {
144-
ClosingBrace::None
145-
};
137+
// Get the content between {% and cursor
138+
let content_start = tag_start + 2;
139+
let content = &prefix[content_start..];
146140

147-
let partial_tag_start = tag_start + 2; // Skip "{%"
148-
let content_after_tag = if partial_tag_start < prefix.len() {
149-
&prefix[partial_tag_start..]
150-
} else {
151-
""
152-
};
141+
// Check if we need a leading space (no space after {%)
142+
let needs_leading_space = content.is_empty() || !content.starts_with(' ');
153143

154-
// Check if we need a leading space - true if there's no space after {%
155-
let needs_leading_space =
156-
!content_after_tag.starts_with(' ') && !content_after_tag.is_empty();
144+
// Extract the partial tag name
145+
let partial_tag = content.trim_start().to_string();
157146

158-
let partial_tag = content_after_tag.trim().to_string();
147+
// Check what's after the cursor for closing detection
148+
let suffix = &line[cursor_offset.min(line.len())..];
149+
let closing_brace = detect_closing_brace(suffix);
159150

160-
TemplateTagContext {
161-
partial_tag,
162-
closing_brace,
163-
needs_leading_space,
164-
}
151+
Some(TemplateTagContext {
152+
partial_tag,
153+
closing_brace,
154+
needs_leading_space,
165155
})
166156
}
167157

158+
/// Detect what closing brace is present after the cursor
159+
fn detect_closing_brace(suffix: &str) -> ClosingBrace {
160+
let trimmed = suffix.trim_start();
161+
if trimmed.starts_with("%}") {
162+
ClosingBrace::FullClose
163+
} else if trimmed.starts_with('}') {
164+
ClosingBrace::PartialClose
165+
} else {
166+
ClosingBrace::None
167+
}
168+
}
169+
168170
/// Generate Django template tag completion items based on context
169171
fn generate_template_completions(
170172
context: &TemplateTagContext,
171173
template_tags: Option<&TemplateTags>,
174+
tag_specs: Option<&TagSpecs>,
175+
supports_snippets: bool,
172176
) -> Vec<CompletionItem> {
173177
let Some(tags) = template_tags else {
174178
return Vec::new();
@@ -177,25 +181,47 @@ fn generate_template_completions(
177181
let mut completions = Vec::new();
178182

179183
for tag in tags.iter() {
180-
// Filter tags based on partial match
181184
if tag.name().starts_with(&context.partial_tag) {
182-
// Determine insertion text based on context
183-
let mut insert_text = String::new();
184-
185-
// Add leading space if needed (cursor right after {%)
186-
if context.needs_leading_space {
187-
insert_text.push(' ');
188-
}
189-
190-
// Add the tag name
191-
insert_text.push_str(tag.name());
192-
193-
// Add closing based on what's already present
194-
match context.closing_brace {
195-
ClosingBrace::None => insert_text.push_str(" %}"),
196-
ClosingBrace::PartialClose => insert_text.push('%'),
197-
ClosingBrace::FullClose => {} // No closing needed
198-
}
185+
// Try to get snippet from TagSpecs if available and client supports snippets
186+
let (insert_text, insert_format) = if supports_snippets {
187+
if let Some(specs) = tag_specs {
188+
if let Some(spec) = specs.get(tag.name()) {
189+
if spec.args.is_empty() {
190+
// No args, use plain text
191+
build_plain_insert(tag.name(), context)
192+
} else {
193+
// Generate snippet from tag spec
194+
let mut text = String::new();
195+
196+
// Add leading space if needed
197+
if context.needs_leading_space {
198+
text.push(' ');
199+
}
200+
201+
// Add tag name and snippet arguments
202+
text.push_str(&generate_snippet_for_tag(tag.name(), spec));
203+
204+
// Add closing based on what's already present
205+
match context.closing_brace {
206+
ClosingBrace::None => text.push_str(" %}"),
207+
ClosingBrace::PartialClose => text.push('%'),
208+
ClosingBrace::FullClose => {} // No closing needed
209+
}
210+
211+
(text, InsertTextFormat::SNIPPET)
212+
}
213+
} else {
214+
// No spec found, use plain text
215+
build_plain_insert(tag.name(), context)
216+
}
217+
} else {
218+
// No specs available, use plain text
219+
build_plain_insert(tag.name(), context)
220+
}
221+
} else {
222+
// Client doesn't support snippets
223+
build_plain_insert(tag.name(), context)
224+
};
199225

200226
// Create completion item
201227
let completion_item = CompletionItem {
@@ -204,7 +230,7 @@ fn generate_template_completions(
204230
detail: Some(format!("from {}", tag.library())),
205231
documentation: tag.doc().map(|doc| Documentation::String(doc.clone())),
206232
insert_text: Some(insert_text),
207-
insert_text_format: Some(InsertTextFormat::PLAIN_TEXT),
233+
insert_text_format: Some(insert_format),
208234
filter_text: Some(tag.name().clone()),
209235
..Default::default()
210236
};
@@ -216,6 +242,28 @@ fn generate_template_completions(
216242
completions
217243
}
218244

245+
/// Build plain insert text without snippets
246+
fn build_plain_insert(tag_name: &str, context: &TemplateTagContext) -> (String, InsertTextFormat) {
247+
let mut insert_text = String::new();
248+
249+
// Add leading space if needed (cursor right after {%)
250+
if context.needs_leading_space {
251+
insert_text.push(' ');
252+
}
253+
254+
// Add the tag name
255+
insert_text.push_str(tag_name);
256+
257+
// Add closing based on what's already present
258+
match context.closing_brace {
259+
ClosingBrace::None => insert_text.push_str(" %}"),
260+
ClosingBrace::PartialClose => insert_text.push('%'),
261+
ClosingBrace::FullClose => {} // No closing needed
262+
}
263+
264+
(insert_text, InsertTextFormat::PLAIN_TEXT)
265+
}
266+
219267
#[cfg(test)]
220268
mod tests {
221269
use super::*;
@@ -286,7 +334,7 @@ mod tests {
286334
closing_brace: ClosingBrace::None,
287335
};
288336

289-
let completions = generate_template_completions(&context, None);
337+
let completions = generate_template_completions(&context, None, None, false);
290338

291339
assert!(completions.is_empty());
292340
}

crates/djls-server/src/server.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,13 +370,17 @@ impl LanguageServer for DjangoLanguageServer {
370370
let encoding = session.position_encoding();
371371
let file_kind = FileKind::from_path(&path);
372372
let template_tags = session.project().and_then(|p| p.template_tags());
373+
let tag_specs = session.with_db(djls_templates::Db::tag_specs);
374+
let supports_snippets = true; // TODO: Get from client capabilities
373375

374376
let completions = crate::completions::handle_completion(
375377
&document,
376378
position,
377379
encoding,
378380
file_kind,
379381
template_tags,
382+
Some(&tag_specs),
383+
supports_snippets,
380384
);
381385

382386
if completions.is_empty() {

crates/djls-templates/src/templatetags.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1+
mod snippets;
12
mod specs;
23

3-
pub use specs::ArgSpec;
4+
pub use snippets::generate_snippet_for_tag;
5+
pub use snippets::generate_snippet_from_args;
6+
pub use specs::Arg;
7+
pub use specs::ArgType;
8+
pub use specs::SimpleArgType;
49
pub use specs::TagSpecs;
510

611
pub enum TagType {

0 commit comments

Comments
 (0)