Skip to content

Commit c16635b

Browse files
add initial autocomplete for installed templatetags (#46)
1 parent 5eb8a77 commit c16635b

File tree

8 files changed

+359
-58
lines changed

8 files changed

+359
-58
lines changed

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ resolver = "2"
44

55
[workspace.dependencies]
66
djls = { path = "crates/djls" }
7+
djls-project = { path = "crates/djls-project" }
78
djls-server = { path = "crates/djls-server" }
89
djls-template-ast = { path = "crates/djls-template-ast" }
910
djls-worker = { path = "crates/djls-worker" }
@@ -16,6 +17,7 @@ serde = { version = "1.0", features = ["derive"] }
1617
serde_json = "1.0"
1718
thiserror = "2.0"
1819
tokio = { version = "1.42", features = ["full"] }
20+
tower-lsp = { version = "0.20", features = ["proposed"] }
1921

2022
[profile.dev.package]
2123
insta.opt-level = 3

crates/djls-project/Cargo.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
[package]
2+
name = "djls-project"
3+
version = "0.1.0"
4+
edition = "2021"
5+
6+
[dependencies]
7+
pyo3 = { workspace = true }
8+
tower-lsp = { workspace = true }

crates/djls-project/src/lib.rs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
mod templatetags;
2+
3+
pub use templatetags::TemplateTags;
4+
5+
use pyo3::prelude::*;
6+
use std::path::{Path, PathBuf};
7+
use tower_lsp::lsp_types::*;
8+
9+
#[derive(Debug)]
10+
pub struct DjangoProject {
11+
path: PathBuf,
12+
template_tags: Option<TemplateTags>,
13+
}
14+
15+
impl DjangoProject {
16+
pub fn new(path: PathBuf) -> Self {
17+
Self {
18+
path,
19+
template_tags: None,
20+
}
21+
}
22+
23+
pub fn from_initialize_params(params: &InitializeParams) -> Option<Self> {
24+
// Try current directory first
25+
let path = std::env::current_dir()
26+
.ok()
27+
// Fall back to workspace root if provided
28+
.or_else(|| {
29+
params
30+
.root_uri
31+
.as_ref()
32+
.and_then(|uri| uri.to_file_path().ok())
33+
});
34+
35+
path.map(Self::new)
36+
}
37+
38+
pub fn initialize(&mut self) -> PyResult<()> {
39+
Python::with_gil(|py| {
40+
// Add project to Python path
41+
let sys = py.import("sys")?;
42+
let py_path = sys.getattr("path")?;
43+
py_path.call_method1("append", (self.path.to_str().unwrap(),))?;
44+
45+
// Setup Django
46+
let django = py.import("django")?;
47+
django.call_method0("setup")?;
48+
49+
self.template_tags = Some(TemplateTags::from_python(py)?);
50+
51+
Ok(())
52+
})
53+
}
54+
55+
pub fn template_tags(&self) -> Option<&TemplateTags> {
56+
self.template_tags.as_ref()
57+
}
58+
59+
pub fn path(&self) -> &Path {
60+
&self.path
61+
}
62+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
use pyo3::prelude::*;
2+
use pyo3::types::{PyDict, PyList};
3+
use std::ops::Deref;
4+
5+
#[derive(Debug, Default, Clone)]
6+
pub struct TemplateTags(Vec<TemplateTag>);
7+
8+
impl Deref for TemplateTags {
9+
type Target = Vec<TemplateTag>;
10+
11+
fn deref(&self) -> &Self::Target {
12+
&self.0
13+
}
14+
}
15+
16+
impl TemplateTags {
17+
fn new() -> Self {
18+
Self(Vec::new())
19+
}
20+
21+
fn process_library(
22+
module_name: &str,
23+
library: &Bound<'_, PyAny>,
24+
tags: &mut Vec<TemplateTag>,
25+
) -> PyResult<()> {
26+
let tags_dict = library.getattr("tags")?;
27+
let dict = tags_dict.downcast::<PyDict>()?;
28+
29+
for (key, value) in dict.iter() {
30+
let tag_name = key.extract::<String>()?;
31+
let doc = value.getattr("__doc__")?.extract().ok();
32+
33+
let library_name = if module_name.is_empty() {
34+
"builtins".to_string()
35+
} else {
36+
module_name.split('.').last().unwrap_or("").to_string()
37+
};
38+
39+
tags.push(TemplateTag::new(tag_name, library_name, doc));
40+
}
41+
Ok(())
42+
}
43+
44+
pub fn from_python(py: Python) -> PyResult<TemplateTags> {
45+
let mut template_tags = TemplateTags::new();
46+
47+
let engine = py
48+
.import("django.template.engine")?
49+
.getattr("Engine")?
50+
.call_method0("get_default")?;
51+
52+
// Built-in template tags
53+
let builtins_attr = engine.getattr("template_builtins")?;
54+
let builtins = builtins_attr.downcast::<PyList>()?;
55+
for builtin in builtins {
56+
Self::process_library("", &builtin, &mut template_tags.0)?;
57+
}
58+
59+
// Custom template libraries
60+
let libraries_attr = engine.getattr("template_libraries")?;
61+
let libraries = libraries_attr.downcast::<PyDict>()?;
62+
for (module_name, library) in libraries.iter() {
63+
let module_name = module_name.extract::<String>()?;
64+
Self::process_library(&module_name, &library, &mut template_tags.0)?;
65+
}
66+
67+
Ok(template_tags)
68+
}
69+
}
70+
71+
#[derive(Debug, Clone, PartialEq, Eq)]
72+
pub struct TemplateTag {
73+
name: String,
74+
library: String,
75+
doc: Option<String>,
76+
}
77+
78+
impl TemplateTag {
79+
fn new(name: String, library: String, doc: Option<String>) -> Self {
80+
Self { name, library, doc }
81+
}
82+
83+
pub fn name(&self) -> &String {
84+
&self.name
85+
}
86+
87+
pub fn library(&self) -> &String {
88+
&self.library
89+
}
90+
91+
pub fn doc(&self) -> &Option<String> {
92+
&self.doc
93+
}
94+
}

crates/djls-server/Cargo.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@ version = "0.1.0"
44
edition = "2021"
55

66
[dependencies]
7+
djls-project = { workspace = true }
78
djls-template-ast = { workspace = true }
89
djls-worker = { workspace = true }
910

1011
anyhow = { workspace = true }
12+
pyo3 = { workspace = true }
1113
serde = { workspace = true }
1214
serde_json = { workspace = true }
1315
tokio = { workspace = true }
14-
15-
tower-lsp = { version = "0.20", features = ["proposed"] }
16-
lsp-types = "0.97"
16+
tower-lsp = { workspace = true }

crates/djls-server/src/documents.rs

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
use anyhow::{anyhow, Result};
2+
use djls_project::TemplateTags;
23
use std::collections::HashMap;
34
use tower_lsp::lsp_types::{
4-
DidChangeTextDocumentParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams, Position,
5-
Range,
5+
CompletionItem, CompletionItemKind, CompletionResponse, DidChangeTextDocumentParams,
6+
DidCloseTextDocumentParams, DidOpenTextDocumentParams, Documentation, InsertTextFormat,
7+
MarkupContent, MarkupKind, Position, Range,
68
};
79

810
#[derive(Debug)]
@@ -102,6 +104,56 @@ impl Store {
102104
pub fn is_version_valid(&self, uri: &str, version: i32) -> bool {
103105
self.get_version(uri).map_or(false, |v| v == version)
104106
}
107+
108+
pub fn get_completions(
109+
&self,
110+
uri: &str,
111+
position: Position,
112+
tags: &TemplateTags,
113+
) -> Option<CompletionResponse> {
114+
let document = self.get_document(uri)?;
115+
116+
if document.language_id != LanguageId::HtmlDjango {
117+
return None;
118+
}
119+
120+
let context = document.get_template_tag_context(position)?;
121+
122+
let mut completions: Vec<CompletionItem> = tags
123+
.iter()
124+
.filter(|tag| {
125+
context.partial_tag.is_empty() || tag.name().starts_with(&context.partial_tag)
126+
})
127+
.map(|tag| {
128+
let leading_space = if context.needs_leading_space { " " } else { "" };
129+
CompletionItem {
130+
label: tag.name().to_string(),
131+
kind: Some(CompletionItemKind::KEYWORD),
132+
detail: Some(format!("Template tag from {}", tag.library())),
133+
documentation: tag.doc().as_ref().map(|doc| {
134+
Documentation::MarkupContent(MarkupContent {
135+
kind: MarkupKind::Markdown,
136+
value: doc.to_string(),
137+
})
138+
}),
139+
insert_text: Some(match context.closing_brace {
140+
ClosingBrace::None => format!("{}{} %}}", leading_space, tag.name()),
141+
ClosingBrace::PartialClose => format!("{}{} %", leading_space, tag.name()),
142+
ClosingBrace::FullClose => format!("{}{} ", leading_space, tag.name()),
143+
}),
144+
insert_text_format: Some(InsertTextFormat::PLAIN_TEXT),
145+
..Default::default()
146+
}
147+
})
148+
.collect();
149+
150+
if completions.is_empty() {
151+
None
152+
} else {
153+
completions.sort_by(|a, b| a.label.cmp(&b.label));
154+
Some(CompletionResponse::Array(completions))
155+
}
156+
}
105157
}
106158

107159
#[derive(Clone, Debug)]
@@ -181,6 +233,32 @@ impl TextDocument {
181233
pub fn line_count(&self) -> usize {
182234
self.index.line_starts.len()
183235
}
236+
237+
pub fn get_template_tag_context(&self, position: Position) -> Option<TemplateTagContext> {
238+
let line = self.get_line(position.line.try_into().ok()?)?;
239+
let prefix = &line[..position.character.try_into().ok()?];
240+
let rest_of_line = &line[position.character.try_into().ok()?..];
241+
let rest_trimmed = rest_of_line.trim_start();
242+
243+
prefix.rfind("{%").map(|tag_start| {
244+
// Check if we're immediately after {% with no space
245+
let needs_leading_space = prefix.ends_with("{%");
246+
247+
let closing_brace = if rest_trimmed.starts_with("%}") {
248+
ClosingBrace::FullClose
249+
} else if rest_trimmed.starts_with("}") {
250+
ClosingBrace::PartialClose
251+
} else {
252+
ClosingBrace::None
253+
};
254+
255+
TemplateTagContext {
256+
partial_tag: prefix[tag_start + 2..].trim().to_string(),
257+
closing_brace,
258+
needs_leading_space,
259+
}
260+
})
261+
}
184262
}
185263

186264
#[derive(Clone, Debug)]
@@ -248,3 +326,17 @@ impl From<String> for LanguageId {
248326
Self::from(language_id.as_str())
249327
}
250328
}
329+
330+
#[derive(Debug)]
331+
pub enum ClosingBrace {
332+
None,
333+
PartialClose, // just }
334+
FullClose, // %}
335+
}
336+
337+
#[derive(Debug)]
338+
pub struct TemplateTagContext {
339+
pub partial_tag: String,
340+
pub closing_brace: ClosingBrace,
341+
pub needs_leading_space: bool,
342+
}

crates/djls-server/src/lib.rs

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ mod tasks;
66
use crate::notifier::TowerLspNotifier;
77
use crate::server::{DjangoLanguageServer, LspNotification, LspRequest};
88
use anyhow::Result;
9+
use server::LspResponse;
910
use std::sync::Arc;
1011
use tokio::sync::RwLock;
1112
use tower_lsp::jsonrpc::Result as LspResult;
@@ -19,11 +20,16 @@ struct TowerLspBackend {
1920
#[tower_lsp::async_trait]
2021
impl LanguageServer for TowerLspBackend {
2122
async fn initialize(&self, params: InitializeParams) -> LspResult<InitializeResult> {
22-
self.server
23-
.read()
23+
match self
24+
.server
25+
.write()
2426
.await
2527
.handle_request(LspRequest::Initialize(params))
26-
.map_err(|_| tower_lsp::jsonrpc::Error::internal_error())
28+
.map_err(|_| tower_lsp::jsonrpc::Error::internal_error())?
29+
{
30+
LspResponse::Initialize(result) => Ok(result),
31+
_ => Err(tower_lsp::jsonrpc::Error::internal_error()),
32+
}
2733
}
2834

2935
async fn initialized(&self, params: InitializedParams) {
@@ -77,6 +83,19 @@ impl LanguageServer for TowerLspBackend {
7783
eprintln!("Error handling document close: {}", e);
7884
}
7985
}
86+
87+
async fn completion(&self, params: CompletionParams) -> LspResult<Option<CompletionResponse>> {
88+
match self
89+
.server
90+
.write()
91+
.await
92+
.handle_request(LspRequest::Completion(params))
93+
.map_err(|_| tower_lsp::jsonrpc::Error::internal_error())?
94+
{
95+
LspResponse::Completion(result) => Ok(result),
96+
_ => Err(tower_lsp::jsonrpc::Error::internal_error()),
97+
}
98+
}
8099
}
81100

82101
pub async fn serve() -> Result<()> {

0 commit comments

Comments
 (0)