add initial autocomplete for installed templatetags (#46)

This commit is contained in:
Josh Thomas 2024-12-23 19:36:54 -06:00 committed by GitHub
parent 5eb8a775e4
commit c16635b1c0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 359 additions and 58 deletions

View file

@ -4,6 +4,7 @@ resolver = "2"
[workspace.dependencies]
djls = { path = "crates/djls" }
djls-project = { path = "crates/djls-project" }
djls-server = { path = "crates/djls-server" }
djls-template-ast = { path = "crates/djls-template-ast" }
djls-worker = { path = "crates/djls-worker" }
@ -16,6 +17,7 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "2.0"
tokio = { version = "1.42", features = ["full"] }
tower-lsp = { version = "0.20", features = ["proposed"] }
[profile.dev.package]
insta.opt-level = 3

View file

@ -0,0 +1,8 @@
[package]
name = "djls-project"
version = "0.1.0"
edition = "2021"
[dependencies]
pyo3 = { workspace = true }
tower-lsp = { workspace = true }

View file

@ -0,0 +1,62 @@
mod templatetags;
pub use templatetags::TemplateTags;
use pyo3::prelude::*;
use std::path::{Path, PathBuf};
use tower_lsp::lsp_types::*;
#[derive(Debug)]
pub struct DjangoProject {
path: PathBuf,
template_tags: Option<TemplateTags>,
}
impl DjangoProject {
pub fn new(path: PathBuf) -> Self {
Self {
path,
template_tags: None,
}
}
pub fn from_initialize_params(params: &InitializeParams) -> Option<Self> {
// Try current directory first
let path = std::env::current_dir()
.ok()
// Fall back to workspace root if provided
.or_else(|| {
params
.root_uri
.as_ref()
.and_then(|uri| uri.to_file_path().ok())
});
path.map(Self::new)
}
pub fn initialize(&mut self) -> PyResult<()> {
Python::with_gil(|py| {
// Add project to Python path
let sys = py.import("sys")?;
let py_path = sys.getattr("path")?;
py_path.call_method1("append", (self.path.to_str().unwrap(),))?;
// Setup Django
let django = py.import("django")?;
django.call_method0("setup")?;
self.template_tags = Some(TemplateTags::from_python(py)?);
Ok(())
})
}
pub fn template_tags(&self) -> Option<&TemplateTags> {
self.template_tags.as_ref()
}
pub fn path(&self) -> &Path {
&self.path
}
}

View file

@ -0,0 +1,94 @@
use pyo3::prelude::*;
use pyo3::types::{PyDict, PyList};
use std::ops::Deref;
#[derive(Debug, Default, Clone)]
pub struct TemplateTags(Vec<TemplateTag>);
impl Deref for TemplateTags {
type Target = Vec<TemplateTag>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl TemplateTags {
fn new() -> Self {
Self(Vec::new())
}
fn process_library(
module_name: &str,
library: &Bound<'_, PyAny>,
tags: &mut Vec<TemplateTag>,
) -> PyResult<()> {
let tags_dict = library.getattr("tags")?;
let dict = tags_dict.downcast::<PyDict>()?;
for (key, value) in dict.iter() {
let tag_name = key.extract::<String>()?;
let doc = value.getattr("__doc__")?.extract().ok();
let library_name = if module_name.is_empty() {
"builtins".to_string()
} else {
module_name.split('.').last().unwrap_or("").to_string()
};
tags.push(TemplateTag::new(tag_name, library_name, doc));
}
Ok(())
}
pub fn from_python(py: Python) -> PyResult<TemplateTags> {
let mut template_tags = TemplateTags::new();
let engine = py
.import("django.template.engine")?
.getattr("Engine")?
.call_method0("get_default")?;
// Built-in template tags
let builtins_attr = engine.getattr("template_builtins")?;
let builtins = builtins_attr.downcast::<PyList>()?;
for builtin in builtins {
Self::process_library("", &builtin, &mut template_tags.0)?;
}
// Custom template libraries
let libraries_attr = engine.getattr("template_libraries")?;
let libraries = libraries_attr.downcast::<PyDict>()?;
for (module_name, library) in libraries.iter() {
let module_name = module_name.extract::<String>()?;
Self::process_library(&module_name, &library, &mut template_tags.0)?;
}
Ok(template_tags)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TemplateTag {
name: String,
library: String,
doc: Option<String>,
}
impl TemplateTag {
fn new(name: String, library: String, doc: Option<String>) -> Self {
Self { name, library, doc }
}
pub fn name(&self) -> &String {
&self.name
}
pub fn library(&self) -> &String {
&self.library
}
pub fn doc(&self) -> &Option<String> {
&self.doc
}
}

View file

@ -4,13 +4,13 @@ version = "0.1.0"
edition = "2021"
[dependencies]
djls-project = { workspace = true }
djls-template-ast = { workspace = true }
djls-worker = { workspace = true }
anyhow = { workspace = true }
pyo3 = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
tower-lsp = { version = "0.20", features = ["proposed"] }
lsp-types = "0.97"
tower-lsp = { workspace = true }

View file

@ -1,8 +1,10 @@
use anyhow::{anyhow, Result};
use djls_project::TemplateTags;
use std::collections::HashMap;
use tower_lsp::lsp_types::{
DidChangeTextDocumentParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams, Position,
Range,
CompletionItem, CompletionItemKind, CompletionResponse, DidChangeTextDocumentParams,
DidCloseTextDocumentParams, DidOpenTextDocumentParams, Documentation, InsertTextFormat,
MarkupContent, MarkupKind, Position, Range,
};
#[derive(Debug)]
@ -102,6 +104,56 @@ impl Store {
pub fn is_version_valid(&self, uri: &str, version: i32) -> bool {
self.get_version(uri).map_or(false, |v| v == version)
}
pub fn get_completions(
&self,
uri: &str,
position: Position,
tags: &TemplateTags,
) -> Option<CompletionResponse> {
let document = self.get_document(uri)?;
if document.language_id != LanguageId::HtmlDjango {
return None;
}
let context = document.get_template_tag_context(position)?;
let mut completions: Vec<CompletionItem> = tags
.iter()
.filter(|tag| {
context.partial_tag.is_empty() || tag.name().starts_with(&context.partial_tag)
})
.map(|tag| {
let leading_space = if context.needs_leading_space { " " } else { "" };
CompletionItem {
label: tag.name().to_string(),
kind: Some(CompletionItemKind::KEYWORD),
detail: Some(format!("Template tag from {}", tag.library())),
documentation: tag.doc().as_ref().map(|doc| {
Documentation::MarkupContent(MarkupContent {
kind: MarkupKind::Markdown,
value: doc.to_string(),
})
}),
insert_text: Some(match context.closing_brace {
ClosingBrace::None => format!("{}{} %}}", leading_space, tag.name()),
ClosingBrace::PartialClose => format!("{}{} %", leading_space, tag.name()),
ClosingBrace::FullClose => format!("{}{} ", leading_space, tag.name()),
}),
insert_text_format: Some(InsertTextFormat::PLAIN_TEXT),
..Default::default()
}
})
.collect();
if completions.is_empty() {
None
} else {
completions.sort_by(|a, b| a.label.cmp(&b.label));
Some(CompletionResponse::Array(completions))
}
}
}
#[derive(Clone, Debug)]
@ -181,6 +233,32 @@ impl TextDocument {
pub fn line_count(&self) -> usize {
self.index.line_starts.len()
}
pub fn get_template_tag_context(&self, position: Position) -> Option<TemplateTagContext> {
let line = self.get_line(position.line.try_into().ok()?)?;
let prefix = &line[..position.character.try_into().ok()?];
let rest_of_line = &line[position.character.try_into().ok()?..];
let rest_trimmed = rest_of_line.trim_start();
prefix.rfind("{%").map(|tag_start| {
// Check if we're immediately after {% with no space
let needs_leading_space = prefix.ends_with("{%");
let closing_brace = if rest_trimmed.starts_with("%}") {
ClosingBrace::FullClose
} else if rest_trimmed.starts_with("}") {
ClosingBrace::PartialClose
} else {
ClosingBrace::None
};
TemplateTagContext {
partial_tag: prefix[tag_start + 2..].trim().to_string(),
closing_brace,
needs_leading_space,
}
})
}
}
#[derive(Clone, Debug)]
@ -248,3 +326,17 @@ impl From<String> for LanguageId {
Self::from(language_id.as_str())
}
}
#[derive(Debug)]
pub enum ClosingBrace {
None,
PartialClose, // just }
FullClose, // %}
}
#[derive(Debug)]
pub struct TemplateTagContext {
pub partial_tag: String,
pub closing_brace: ClosingBrace,
pub needs_leading_space: bool,
}

View file

@ -6,6 +6,7 @@ mod tasks;
use crate::notifier::TowerLspNotifier;
use crate::server::{DjangoLanguageServer, LspNotification, LspRequest};
use anyhow::Result;
use server::LspResponse;
use std::sync::Arc;
use tokio::sync::RwLock;
use tower_lsp::jsonrpc::Result as LspResult;
@ -19,11 +20,16 @@ struct TowerLspBackend {
#[tower_lsp::async_trait]
impl LanguageServer for TowerLspBackend {
async fn initialize(&self, params: InitializeParams) -> LspResult<InitializeResult> {
self.server
.read()
match self
.server
.write()
.await
.handle_request(LspRequest::Initialize(params))
.map_err(|_| tower_lsp::jsonrpc::Error::internal_error())
.map_err(|_| tower_lsp::jsonrpc::Error::internal_error())?
{
LspResponse::Initialize(result) => Ok(result),
_ => Err(tower_lsp::jsonrpc::Error::internal_error()),
}
}
async fn initialized(&self, params: InitializedParams) {
@ -77,6 +83,19 @@ impl LanguageServer for TowerLspBackend {
eprintln!("Error handling document close: {}", e);
}
}
async fn completion(&self, params: CompletionParams) -> LspResult<Option<CompletionResponse>> {
match self
.server
.write()
.await
.handle_request(LspRequest::Completion(params))
.map_err(|_| tower_lsp::jsonrpc::Error::internal_error())?
{
LspResponse::Completion(result) => Ok(result),
_ => Err(tower_lsp::jsonrpc::Error::internal_error()),
}
}
}
pub async fn serve() -> Result<()> {

View file

@ -2,7 +2,10 @@ use crate::documents::Store;
use crate::notifier::Notifier;
use crate::tasks::DebugTask;
use anyhow::Result;
use djls_project::DjangoProject;
use djls_worker::Worker;
use pyo3::prelude::*;
use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::Duration;
use tower_lsp::lsp_types::*;
@ -12,6 +15,12 @@ const SERVER_VERSION: &str = "0.1.0";
pub enum LspRequest {
Initialize(InitializeParams),
Completion(CompletionParams),
}
pub enum LspResponse {
Initialize(InitializeResult),
Completion(Option<CompletionResponse>),
}
pub enum LspNotification {
@ -23,6 +32,7 @@ pub enum LspNotification {
}
pub struct DjangoLanguageServer {
project: Option<DjangoProject>,
notifier: Arc<Box<dyn Notifier>>,
documents: Store,
worker: Worker,
@ -33,33 +43,77 @@ impl DjangoLanguageServer {
let notifier = Arc::new(notifier);
Self {
project: None,
notifier,
documents: Store::new(),
worker: Worker::new(),
}
}
pub fn handle_request(&self, request: LspRequest) -> Result<InitializeResult> {
pub fn handle_request(&mut self, request: LspRequest) -> Result<LspResponse> {
match request {
LspRequest::Initialize(_params) => Ok(InitializeResult {
capabilities: ServerCapabilities {
text_document_sync: Some(TextDocumentSyncCapability::Options(
TextDocumentSyncOptions {
open_close: Some(true),
change: Some(TextDocumentSyncKind::INCREMENTAL),
will_save: Some(false),
will_save_wait_until: Some(false),
save: Some(SaveOptions::default().into()),
},
)),
..Default::default()
},
offset_encoding: None,
server_info: Some(ServerInfo {
name: SERVER_NAME.to_string(),
version: Some(SERVER_VERSION.to_string()),
}),
}),
LspRequest::Initialize(params) => {
if let Some(mut project) = DjangoProject::from_initialize_params(&params) {
if let Err(e) = project.initialize() {
self.notifier.log_message(
MessageType::ERROR,
&format!("Failed to initialize Django project: {}", e),
)?;
} else {
self.notifier.log_message(
MessageType::INFO,
&format!("Using project path: {}", project.path().display()),
)?;
self.project = Some(project);
}
}
Ok(LspResponse::Initialize(InitializeResult {
capabilities: ServerCapabilities {
completion_provider: Some(CompletionOptions {
resolve_provider: Some(false),
trigger_characters: Some(vec![
"{".to_string(),
"%".to_string(),
" ".to_string(),
]),
..Default::default()
}),
text_document_sync: Some(TextDocumentSyncCapability::Options(
TextDocumentSyncOptions {
open_close: Some(true),
change: Some(TextDocumentSyncKind::INCREMENTAL),
will_save: Some(false),
will_save_wait_until: Some(false),
save: Some(SaveOptions::default().into()),
},
)),
..Default::default()
},
offset_encoding: None,
server_info: Some(ServerInfo {
name: SERVER_NAME.to_string(),
version: Some(SERVER_VERSION.to_string()),
}),
}))
}
LspRequest::Completion(params) => {
let completions = if let Some(project) = &self.project {
if let Some(tags) = project.template_tags() {
self.documents.get_completions(
params.text_document_position.text_document.uri.as_str(),
params.text_document_position.position,
tags,
)
} else {
None
}
} else {
None
};
Ok(LspResponse::Completion(completions))
}
}
}
@ -71,36 +125,6 @@ impl DjangoLanguageServer {
MessageType::INFO,
&format!("Opened document: {}", params.text_document.uri),
)?;
// Execute - still sync
self.worker.execute(DebugTask::new(
"Quick task".to_string(),
Duration::from_millis(100),
self.notifier.clone(),
))?;
// Submit - spawn async task
let worker = self.worker.clone();
let task = DebugTask::new(
"Important task".to_string(),
Duration::from_secs(1),
self.notifier.clone(),
);
tokio::spawn(async move {
let _ = worker.submit(task).await;
});
// Wait for result - spawn async task
let worker = self.worker.clone();
let task = DebugTask::new(
"Task with result".to_string(),
Duration::from_secs(2),
self.notifier.clone(),
);
tokio::spawn(async move {
let _ = worker.wait_for(task).await;
});
Ok(())
}
LspNotification::DidChangeTextDocument(params) => {