diff --git a/Cargo.lock b/Cargo.lock index e110adff..7196ef4b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -587,6 +587,7 @@ dependencies = [ "djls-workspace", "salsa", "tower-lsp-server", + "tracing", ] [[package]] @@ -622,6 +623,8 @@ dependencies = [ "serde", "tempfile", "thiserror 2.0.17", + "tracing", + "walkdir", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 6aab6c31..8bc3255e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ tracing = "0.1" tracing-appender = "0.2" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "time"] } url = "2.5" +walkdir = "2.5" which = "8.0" # testing diff --git a/crates/djls-bench/src/db.rs b/crates/djls-bench/src/db.rs index 8e358325..0e70caf0 100644 --- a/crates/djls-bench/src/db.rs +++ b/crates/djls-bench/src/db.rs @@ -73,4 +73,8 @@ impl SemanticDb for Db { fn tag_index(&self) -> TagIndex<'_> { TagIndex::from_specs(self) } + + fn template_dirs(&self) -> Option> { + None + } } diff --git a/crates/djls-ide/Cargo.toml b/crates/djls-ide/Cargo.toml index b233537b..a062c829 100644 --- a/crates/djls-ide/Cargo.toml +++ b/crates/djls-ide/Cargo.toml @@ -12,6 +12,7 @@ djls-workspace = { workspace = true } salsa = { workspace = true } tower-lsp-server = { workspace = true } +tracing = { workspace = true } [lints] workspace = true diff --git a/crates/djls-ide/src/lib.rs b/crates/djls-ide/src/lib.rs index 55060ec3..e0b126b7 100644 --- a/crates/djls-ide/src/lib.rs +++ b/crates/djls-ide/src/lib.rs @@ -1,9 +1,11 @@ mod completions; mod diagnostics; +mod navigation; mod snippets; pub use completions::handle_completion; pub use diagnostics::collect_diagnostics; +pub use navigation::goto_template_definition; pub use snippets::generate_partial_snippet; pub use snippets::generate_snippet_for_tag; pub use snippets::generate_snippet_for_tag_with_end; diff --git a/crates/djls-ide/src/navigation.rs b/crates/djls-ide/src/navigation.rs new file mode 100644 index 00000000..6e450c68 --- /dev/null +++ b/crates/djls-ide/src/navigation.rs @@ -0,0 +1,69 @@ +use djls_semantic::resolve_template; +use djls_semantic::ResolveResult; +use djls_source::File; +use djls_source::LineCol; +use djls_source::Offset; +use djls_source::PositionEncoding; +use djls_templates::parse_template; +use djls_templates::Node; +use tower_lsp_server::lsp_types; +use tower_lsp_server::UriExt; + +pub fn goto_template_definition( + db: &dyn djls_semantic::Db, + file: File, + position: lsp_types::Position, + encoding: PositionEncoding, +) -> Option { + let nodelist = parse_template(db, file)?; + + let line_index = file.line_index(db); + let source = file.source(db); + let line_col = LineCol::new(position.line, position.character); + + let offset = encoding.line_col_to_offset(line_index, line_col, source.as_str())?; + + let template_name = find_template_name_at_offset(nodelist.nodelist(db), offset)?; + tracing::debug!("Found template reference: '{}'", template_name); + + match resolve_template(db, &template_name) { + ResolveResult::Found(template) => { + let path = template.path_buf(db); + tracing::debug!("Resolved template to: {}", path); + let uri = lsp_types::Uri::from_file_path(path.as_std_path())?; + + Some(lsp_types::GotoDefinitionResponse::Scalar( + lsp_types::Location { + uri, + range: lsp_types::Range::default(), + }, + )) + } + ResolveResult::NotFound { tried, .. } => { + tracing::warn!("Template '{}' not found. Tried: {:?}", template_name, tried); + None + } + } +} + +fn find_template_name_at_offset(nodes: &[Node], offset: Offset) -> Option { + for node in nodes { + if let Node::Tag { + name, bits, span, .. + } = node + { + if (name == "extends" || name == "include") && span.contains(offset) { + let template_str = bits.first()?; + let template_name = template_str + .trim() + .trim_start_matches('"') + .trim_end_matches('"') + .trim_start_matches('\'') + .trim_end_matches('\'') + .to_string(); + return Some(template_name); + } + } + } + None +} diff --git a/crates/djls-project/inspector/inspector.py b/crates/djls-project/inspector/inspector.py index 91c1ba46..531cb323 100644 --- a/crates/djls-project/inspector/inspector.py +++ b/crates/djls-project/inspector/inspector.py @@ -10,6 +10,7 @@ from queries import QueryData from queries import get_installed_templatetags from queries import get_python_environment_info + from queries import get_template_dirs from queries import initialize_django except ImportError: # Fall back to relative import (when running with python -m) @@ -17,6 +18,7 @@ from .queries import QueryData from .queries import get_installed_templatetags from .queries import get_python_environment_info + from .queries import get_template_dirs from .queries import initialize_django @@ -40,11 +42,19 @@ def to_dict(self) -> dict[str, Any]: data_dict = asdict(self.data) # Convert Path objects to strings for key, value in data_dict.items(): - if key in ["sys_base_prefix", "sys_executable", "sys_prefix"]: - if value: - data_dict[key] = str(value) - elif key == "sys_path": + # Handle single Path objects + if hasattr(value, "__fspath__"): # Path-like object + data_dict[key] = str(value) + # Handle lists of Path objects + elif ( + isinstance(value, list) + and value + and hasattr(value[0], "__fspath__") + ): data_dict[key] = [str(p) for p in value] + # Handle optional Path objects (could be None) + elif value is None: + pass # Keep None as is d["data"] = data_dict return d @@ -62,16 +72,19 @@ def handle_request(request: dict[str, Any]) -> DjlsResponse: args = request.get("args") - if query == Query.PYTHON_ENV: + if query == Query.DJANGO_INIT: + success, error = initialize_django() + return DjlsResponse(ok=success, data=None, error=error) + + elif query == Query.PYTHON_ENV: return DjlsResponse(ok=True, data=get_python_environment_info()) + elif query == Query.TEMPLATE_DIRS: + return DjlsResponse(ok=True, data=get_template_dirs()) + elif query == Query.TEMPLATETAGS: return DjlsResponse(ok=True, data=get_installed_templatetags()) - elif query == Query.DJANGO_INIT: - success, error = initialize_django() - return DjlsResponse(ok=success, data=None, error=error) - return DjlsResponse(ok=False, error=f"Unhandled query type: {query}") except Exception as e: diff --git a/crates/djls-project/inspector/queries.py b/crates/djls-project/inspector/queries.py index e583bbe6..60cd5e8c 100644 --- a/crates/djls-project/inspector/queries.py +++ b/crates/djls-project/inspector/queries.py @@ -9,9 +9,27 @@ class Query(str, Enum): + DJANGO_INIT = "django_init" PYTHON_ENV = "python_env" + TEMPLATE_DIRS = "template_dirs" TEMPLATETAGS = "templatetags" - DJANGO_INIT = "django_init" + + +def initialize_django() -> tuple[bool, str | None]: + import django + from django.apps import apps + + try: + if not os.environ.get("DJANGO_SETTINGS_MODULE"): + return False, None + + if not apps.ready: + django.setup() + + return True, None + + except Exception as e: + return False, str(e) @dataclass @@ -43,21 +61,30 @@ def get_python_environment_info(): ) -def initialize_django() -> tuple[bool, str | None]: - import django +@dataclass +class TemplateDirsQueryData: + dirs: list[Path] + + +def get_template_dirs() -> TemplateDirsQueryData: from django.apps import apps + from django.conf import settings - try: - if not os.environ.get("DJANGO_SETTINGS_MODULE"): - return False, None + dirs = [] - if not apps.ready: - django.setup() + for engine in settings.TEMPLATES: + if "django" not in engine["BACKEND"].lower(): + continue - return True, None + dirs.extend(engine.get("DIRS", [])) - except Exception as e: - return False, str(e) + if engine.get("APP_DIRS", False): + for app_config in apps.get_app_configs(): + template_dir = Path(app_config.path) / "templates" + if template_dir.exists(): + dirs.append(template_dir) + + return TemplateDirsQueryData(dirs) @dataclass @@ -108,4 +135,4 @@ def get_installed_templatetags() -> TemplateTagQueryData: return TemplateTagQueryData(templatetags=templatetags) -QueryData = PythonEnvironmentQueryData | TemplateTagQueryData +QueryData = PythonEnvironmentQueryData | TemplateDirsQueryData | TemplateTagQueryData diff --git a/crates/djls-project/src/django.rs b/crates/djls-project/src/django.rs index 1cab880b..5e62651a 100644 --- a/crates/djls-project/src/django.rs +++ b/crates/djls-project/src/django.rs @@ -1,5 +1,6 @@ use std::ops::Deref; +use camino::Utf8PathBuf; use serde::Deserialize; use serde::Serialize; @@ -28,6 +29,50 @@ pub fn django_available(db: &dyn ProjectDb, _project: Project) -> bool { inspector::query(db, &DjangoInitRequest).is_some() } +#[derive(Serialize)] +struct TemplateDirsRequest; + +#[derive(Deserialize)] +struct TemplateDirsResponse { + dirs: Vec, +} + +impl InspectorRequest for TemplateDirsRequest { + const NAME: &'static str = "template_dirs"; + type Response = TemplateDirsResponse; +} + +#[salsa::tracked] +pub fn template_dirs(db: &dyn ProjectDb, _project: Project) -> Option { + tracing::debug!("Requesting template directories from inspector"); + + let response = inspector::query(db, &TemplateDirsRequest)?; + + let dir_count = response.dirs.len(); + tracing::info!( + "Retrieved {} template directories from inspector", + dir_count + ); + + for (i, dir) in response.dirs.iter().enumerate() { + tracing::debug!(" Template dir [{}]: {}", i, dir); + } + + let missing_dirs: Vec<_> = response.dirs.iter().filter(|dir| !dir.exists()).collect(); + + if !missing_dirs.is_empty() { + tracing::warn!( + "Found {} non-existent template directories: {:?}", + missing_dirs.len(), + missing_dirs + ); + } + + Some(response.dirs) +} + +type TemplateDirs = Vec; + #[derive(Serialize)] struct TemplatetagsRequest; diff --git a/crates/djls-project/src/lib.rs b/crates/djls-project/src/lib.rs index 595bdb5c..3de51b84 100644 --- a/crates/djls-project/src/lib.rs +++ b/crates/djls-project/src/lib.rs @@ -6,6 +6,7 @@ mod python; pub use db::Db; pub use django::django_available; +pub use django::template_dirs; pub use django::templatetags; pub use django::TemplateTags; pub use inspector::Inspector; diff --git a/crates/djls-project/src/project.rs b/crates/djls-project/src/project.rs index 3210b0f7..a0024435 100644 --- a/crates/djls-project/src/project.rs +++ b/crates/djls-project/src/project.rs @@ -3,6 +3,7 @@ use camino::Utf8PathBuf; use crate::db::Db as ProjectDb; use crate::django_available; +use crate::template_dirs; use crate::templatetags; use crate::Interpreter; @@ -80,5 +81,6 @@ impl Project { pub fn initialize(self, db: &dyn ProjectDb) { let _ = django_available(db, self); let _ = templatetags(db, self); + let _ = template_dirs(db, self); } } diff --git a/crates/djls-semantic/Cargo.toml b/crates/djls-semantic/Cargo.toml index 17d13fd3..971d7d17 100644 --- a/crates/djls-semantic/Cargo.toml +++ b/crates/djls-semantic/Cargo.toml @@ -14,6 +14,8 @@ rustc-hash = { workspace = true } salsa = { workspace = true } serde = { workspace = true } thiserror = { workspace = true } +tracing = { workspace = true } +walkdir = { workspace = true } [dev-dependencies] insta = { workspace = true } diff --git a/crates/djls-semantic/src/blocks/tree.rs b/crates/djls-semantic/src/blocks/tree.rs index 776b59bf..6c4942af 100644 --- a/crates/djls-semantic/src/blocks/tree.rs +++ b/crates/djls-semantic/src/blocks/tree.rs @@ -168,6 +168,7 @@ mod tests { use std::sync::Mutex; use camino::Utf8Path; + use camino::Utf8PathBuf; use djls_source::File; use djls_source::Span; use djls_templates::parse_template; @@ -226,6 +227,10 @@ mod tests { fn tag_index(&self) -> TagIndex<'_> { TagIndex::from_specs(self) } + + fn template_dirs(&self) -> Option> { + None + } } #[test] diff --git a/crates/djls-semantic/src/db.rs b/crates/djls-semantic/src/db.rs index bad641c4..10807452 100644 --- a/crates/djls-semantic/src/db.rs +++ b/crates/djls-semantic/src/db.rs @@ -1,3 +1,4 @@ +use camino::Utf8PathBuf; use djls_templates::Db as TemplateDb; use crate::blocks::TagIndex; @@ -10,6 +11,8 @@ pub trait Db: TemplateDb { fn tag_specs(&self) -> TagSpecs; fn tag_index(&self) -> TagIndex<'_>; + + fn template_dirs(&self) -> Option>; } #[salsa::accumulator] diff --git a/crates/djls-semantic/src/lib.rs b/crates/djls-semantic/src/lib.rs index 7423accb..e5ab4e92 100644 --- a/crates/djls-semantic/src/lib.rs +++ b/crates/djls-semantic/src/lib.rs @@ -1,6 +1,7 @@ mod blocks; mod db; mod errors; +mod resolution; mod semantic; mod templatetags; mod traits; @@ -10,6 +11,8 @@ pub use blocks::TagIndex; pub use db::Db; pub use db::ValidationErrorAccumulator; pub use errors::ValidationError; +pub use resolution::resolve_template; +pub use resolution::ResolveResult; pub use semantic::build_semantic_forest; use semantic::validate_block_tags; use semantic::validate_non_block_tags; diff --git a/crates/djls-semantic/src/resolution.rs b/crates/djls-semantic/src/resolution.rs new file mode 100644 index 00000000..71708228 --- /dev/null +++ b/crates/djls-semantic/src/resolution.rs @@ -0,0 +1,4 @@ +mod templates; + +pub use templates::resolve_template; +pub use templates::ResolveResult; diff --git a/crates/djls-semantic/src/resolution/templates.rs b/crates/djls-semantic/src/resolution/templates.rs new file mode 100644 index 00000000..e33494c6 --- /dev/null +++ b/crates/djls-semantic/src/resolution/templates.rs @@ -0,0 +1,125 @@ +use camino::Utf8PathBuf; +use djls_source::safe_join; +use djls_source::Utf8PathClean; +use walkdir::WalkDir; + +pub use crate::db::Db as SemanticDb; + +#[salsa::tracked] +pub struct Template<'db> { + name: TemplateName<'db>, + #[returns(ref)] + path: Utf8PathBuf, +} + +impl<'db> Template<'db> { + pub fn name_str(&'db self, db: &'db dyn SemanticDb) -> &'db str { + self.name(db).name(db) + } + + pub fn path_buf(&'db self, db: &'db dyn SemanticDb) -> &'db Utf8PathBuf { + self.path(db) + } +} + +#[salsa::interned] +pub struct TemplateName { + #[returns(ref)] + name: String, +} + +#[salsa::tracked] +pub fn discover_templates(db: &dyn SemanticDb) -> Vec> { + let mut templates = Vec::new(); + + if let Some(search_dirs) = db.template_dirs() { + tracing::debug!("Discovering templates in {} directories", search_dirs.len()); + + for dir in &search_dirs { + if !dir.exists() { + tracing::warn!("Template directory does not exist: {}", dir); + continue; + } + + for entry in WalkDir::new(dir) + .into_iter() + .filter_map(std::result::Result::ok) + .filter(|e| e.file_type().is_file()) + { + let Ok(path) = Utf8PathBuf::from_path_buf(entry.path().to_path_buf()) else { + continue; + }; + + let name = match path.strip_prefix(dir) { + Ok(rel) => rel.clean().to_string(), + Err(_) => continue, + }; + + templates.push(Template::new(db, TemplateName::new(db, name), path)); + } + } + } else { + tracing::warn!("No template directories configured"); + } + + tracing::debug!("Discovered {} total templates", templates.len()); + templates +} + +#[salsa::tracked] +pub fn find_template<'db>( + db: &'db dyn SemanticDb, + template_name: TemplateName<'db>, +) -> Option> { + let templates = discover_templates(db); + + templates + .iter() + .find(|t| t.name(db) == template_name) + .copied() +} + +#[derive(Clone, PartialEq, salsa::Update)] +pub enum ResolveResult<'db> { + Found(Template<'db>), + NotFound { + name: String, + tried: Vec, + }, +} + +impl<'db> ResolveResult<'db> { + #[must_use] + pub fn ok(self) -> Option> { + match self { + Self::Found(t) => Some(t), + Self::NotFound { .. } => None, + } + } + + #[must_use] + pub fn is_found(&self) -> bool { + matches!(self, Self::Found(_)) + } +} + +pub fn resolve_template<'db>(db: &'db dyn SemanticDb, name: &str) -> ResolveResult<'db> { + let template_name = TemplateName::new(db, name.to_string()); + if let Some(template) = find_template(db, template_name) { + return ResolveResult::Found(template); + } + + let tried = db + .template_dirs() + .map(|dirs| { + dirs.iter() + .filter_map(|d| safe_join(d, name).ok()) + .collect() + }) + .unwrap_or_default(); + + ResolveResult::NotFound { + name: name.to_string(), + tried, + } +} diff --git a/crates/djls-semantic/src/semantic/forest.rs b/crates/djls-semantic/src/semantic/forest.rs index 30683b73..eca71843 100644 --- a/crates/djls-semantic/src/semantic/forest.rs +++ b/crates/djls-semantic/src/semantic/forest.rs @@ -217,6 +217,7 @@ mod tests { use std::sync::Mutex; use camino::Utf8Path; + use camino::Utf8PathBuf; use djls_source::File; use djls_templates::parse_template; use djls_workspace::FileSystem; @@ -274,6 +275,10 @@ mod tests { fn tag_index(&self) -> TagIndex<'_> { TagIndex::from_specs(self) } + + fn template_dirs(&self) -> Option> { + None + } } #[test] diff --git a/crates/djls-server/src/db.rs b/crates/djls-server/src/db.rs index 50b052fa..c6c6726a 100644 --- a/crates/djls-server/src/db.rs +++ b/crates/djls-server/src/db.rs @@ -10,6 +10,7 @@ use std::sync::Mutex; use camino::Utf8Path; use camino::Utf8PathBuf; use djls_conf::Settings; +use djls_project::template_dirs; use djls_project::Db as ProjectDb; use djls_project::Inspector; use djls_project::Project; @@ -206,6 +207,14 @@ impl SemanticDb for DjangoDatabase { fn tag_index(&self) -> TagIndex<'_> { TagIndex::from_specs(self) } + + fn template_dirs(&self) -> Option> { + if let Some(project) = self.project() { + template_dirs(self, project) + } else { + None + } + } } #[salsa::db] diff --git a/crates/djls-server/src/server.rs b/crates/djls-server/src/server.rs index 79225db0..8b2b134f 100644 --- a/crates/djls-server/src/server.rs +++ b/crates/djls-server/src/server.rs @@ -167,6 +167,7 @@ impl LanguageServer for DjangoLanguageServer { work_done_progress_options: lsp_types::WorkDoneProgressOptions::default(), }, )), + definition_provider: Some(lsp_types::OneOf::Left(true)), ..Default::default() }, server_info: Some(lsp_types::ServerInfo { @@ -417,6 +418,33 @@ impl LanguageServer for DjangoLanguageServer { )) } + async fn goto_definition( + &self, + params: lsp_types::GotoDefinitionParams, + ) -> LspResult> { + let response = self + .with_session_mut(|session| { + let url = paths::parse_lsp_uri( + ¶ms.text_document_position_params.text_document.uri, + paths::LspContext::GotoDefinition, + )?; + let path = paths::url_to_path(&url)?; + let file = session.get_or_create_file(&path); + + session.with_db(|db| { + djls_ide::goto_template_definition( + db, + file, + params.text_document_position_params.position, + session.position_encoding(), + ) + }) + }) + .await; + + Ok(response) + } + async fn did_change_configuration(&self, _params: lsp_types::DidChangeConfigurationParams) { tracing::info!("Configuration change detected. Reloading settings..."); diff --git a/crates/djls-source/src/lib.rs b/crates/djls-source/src/lib.rs index ce6d8295..4aaa249e 100644 --- a/crates/djls-source/src/lib.rs +++ b/crates/djls-source/src/lib.rs @@ -2,6 +2,7 @@ mod collections; mod db; mod file; mod line; +mod path; mod position; mod protocol; @@ -11,6 +12,9 @@ pub use db::Db; pub use file::File; pub use file::FileKind; pub use line::LineIndex; +pub use path::safe_join; +pub use path::SafeJoinError; +pub use path::Utf8PathClean; pub use position::LineCol; pub use position::Offset; pub use position::Span; diff --git a/crates/djls-source/src/path.rs b/crates/djls-source/src/path.rs new file mode 100644 index 00000000..c8e80035 --- /dev/null +++ b/crates/djls-source/src/path.rs @@ -0,0 +1,52 @@ +mod clean; + +use camino::Utf8Path; +use camino::Utf8PathBuf; +use clean::clean_utf8_path; +pub use clean::Utf8PathClean; + +/// Django's `safe_join` equivalent - join paths and ensure result is within base +pub fn safe_join(base: &Utf8Path, name: &str) -> Result { + let candidate = base.join(name); + let cleaned = clean_utf8_path(&candidate); + + if cleaned.starts_with(base) { + Ok(cleaned) + } else { + Err(SafeJoinError::OutsideBase { + base: base.to_path_buf(), + attempted: name.to_string(), + resolved: cleaned, + }) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum SafeJoinError { + #[error("Path '{attempted}' would resolve to '{resolved}' which is outside base '{base}'")] + OutsideBase { + base: Utf8PathBuf, + attempted: String, + resolved: Utf8PathBuf, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_safe_join_allows_normal_path() { + let base = Utf8Path::new("/templates"); + assert_eq!( + safe_join(base, "myapp/base.html").unwrap(), + Utf8PathBuf::from("/templates/myapp/base.html") + ); + } + + #[test] + fn test_safe_join_blocks_parent_escape() { + let base = Utf8Path::new("/templates"); + assert!(safe_join(base, "../../etc/passwd").is_err()); + } +} diff --git a/crates/djls-source/src/path/clean.rs b/crates/djls-source/src/path/clean.rs new file mode 100644 index 00000000..9a67f397 --- /dev/null +++ b/crates/djls-source/src/path/clean.rs @@ -0,0 +1,82 @@ +//! Vendored and adapted from `path-clean` crate, +//! +//! path-clean LICENSE-MIT: +//! Copyright (c) 2018 Dan Reeves +//! +//! Permission is hereby granted, free of charge, to any person obtaining a copy +//! of this software and associated documentation files (the "Software"), to deal +//! in the Software without restriction, including without limitation the rights +//! to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +//! copies of the Software, and to permit persons to whom the Software is +//! furnished to do so, subject to the following conditions: +//! +//! The above copyright notice and this permission notice shall be included in all +//! copies or substantial portions of the Software. +//! +//! THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +//! IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +//! FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +//! AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +//! LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +//! OUT OF OR IN + +use std::path::Component; + +use camino::Utf8Path; +use camino::Utf8PathBuf; + +pub trait Utf8PathClean { + fn clean(&self) -> Utf8PathBuf; +} + +impl Utf8PathClean for Utf8Path { + fn clean(&self) -> Utf8PathBuf { + clean_utf8_path(self) + } +} + +impl Utf8PathClean for Utf8PathBuf { + fn clean(&self) -> Utf8PathBuf { + clean_utf8_path(self) + } +} + +pub fn clean_utf8_path(path: &Utf8Path) -> Utf8PathBuf { + let mut out = Vec::new(); + + for comp in path.as_std_path().components() { + match comp { + Component::CurDir => (), + Component::ParentDir => match out.last() { + Some(Component::RootDir) => (), + Some(Component::Normal(_)) => { + out.pop(); + } + None | Some(Component::CurDir | Component::ParentDir | Component::Prefix(_)) => { + out.push(comp); + } + }, + comp => out.push(comp), + } + } + + if out.is_empty() { + Utf8PathBuf::from(".") + } else { + let cleaned: std::path::PathBuf = out.iter().collect(); + Utf8PathBuf::from_path_buf(cleaned).expect("Path should still be UTF-8") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_clean_removes_dots() { + assert_eq!( + clean_utf8_path(Utf8Path::new("hello/world/..")), + Utf8PathBuf::from("hello") + ); + } +} diff --git a/crates/djls-source/src/position.rs b/crates/djls-source/src/position.rs index c1c27c5c..87786634 100644 --- a/crates/djls-source/src/position.rs +++ b/crates/djls-source/src/position.rs @@ -206,6 +206,12 @@ impl Span { length: length_expand, } } + + #[must_use] + pub fn contains(self, offset: Offset) -> bool { + let offset_u32 = offset.get(); + offset_u32 >= self.start && offset_u32 < self.end() + } } impl From<(u32, u32)> for Span { diff --git a/crates/djls-workspace/src/paths.rs b/crates/djls-workspace/src/paths.rs index abde7ea5..e6699caf 100644 --- a/crates/djls-workspace/src/paths.rs +++ b/crates/djls-workspace/src/paths.rs @@ -49,29 +49,25 @@ pub fn url_to_path(url: &Url) -> Option { /// Context for LSP operations, used for error reporting #[derive(Debug, Clone, Copy)] pub enum LspContext { - /// textDocument/didOpen notification - DidOpen, - /// textDocument/didChange notification + Completion, + Diagnostic, DidChange, - /// textDocument/didClose notification DidClose, - /// textDocument/didSave notification + DidOpen, DidSave, - /// textDocument/completion request - Completion, - /// textDocument/diagnostic request - Diagnostic, + GotoDefinition, } impl std::fmt::Display for LspContext { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::DidOpen => write!(f, "didOpen"), + Self::Completion => write!(f, "completion"), + Self::Diagnostic => write!(f, "diagnostic"), Self::DidChange => write!(f, "didChange"), Self::DidClose => write!(f, "didClose"), + Self::DidOpen => write!(f, "didOpen"), Self::DidSave => write!(f, "didSave"), - Self::Completion => write!(f, "completion"), - Self::Diagnostic => write!(f, "diagnostic"), + Self::GotoDefinition => write!(f, "gotoDefinition"), } } } diff --git a/tests/project/djls_app/templates/djls_app/admin.html b/tests/project/djls_app/templates/djls_app/admin.html new file mode 100644 index 00000000..b6e4ffdc --- /dev/null +++ b/tests/project/djls_app/templates/djls_app/admin.html @@ -0,0 +1,8 @@ +{% extends "admin/base.html" %} + +{% block title %}Admin Test{% endblock %} + +{% block content %} +

Testing External Templates

+ {% include "registration/logged_out.html" %} +{% endblock %} diff --git a/tests/project/djls_app/templates/djls_app/base.html b/tests/project/djls_app/templates/djls_app/base.html index 6856111b..b98c7816 100644 --- a/tests/project/djls_app/templates/djls_app/base.html +++ b/tests/project/djls_app/templates/djls_app/base.html @@ -1,25 +1,33 @@ {% load static %} - + - Test Page + + + + {% block title %}Django Test App{% endblock %} + + + {% include "djls_app/header.html" %} +
- {% block content %} -

Hello, {{ user.username }}!

-

This is a test template.

- {% if items %} -
    - {% for item in items %}
  • {{ item.name }}
  • {% endfor %} -
- {% else %} -

No items found.

- {% endif %} - Logo - {# This is a comment #} - {% endblock content %} + {% block content %} +

Hello, {{ user.username }}!

+

This is a test template.

+ {% if items %} +
    + {% for item in items %}
  • {{ item.name }}
  • {% endfor %} +
+ {% else %} +

No items found.

+ {% endif %} + Logo + {# This is a comment #} + {% endblock content %} +
diff --git a/tests/project/djls_app/templates/djls_app/header.html b/tests/project/djls_app/templates/djls_app/header.html new file mode 100644 index 00000000..1574bbbe --- /dev/null +++ b/tests/project/djls_app/templates/djls_app/header.html @@ -0,0 +1,9 @@ +
+ +
diff --git a/tests/project/djls_app/templates/djls_app/home.html b/tests/project/djls_app/templates/djls_app/home.html new file mode 100644 index 00000000..be082bdb --- /dev/null +++ b/tests/project/djls_app/templates/djls_app/home.html @@ -0,0 +1,7 @@ +{% extends "djls_app/base.html" %} + +{% block title %}Home{% endblock %} + +{% block content %} +

Welcome

+{% endblock content %} diff --git a/tests/project/djls_test/settings.py b/tests/project/djls_test/settings.py index fccf2c86..8eb09566 100644 --- a/tests/project/djls_test/settings.py +++ b/tests/project/djls_test/settings.py @@ -10,6 +10,8 @@ https://docs.djangoproject.com/en/5.2/ref/settings/ """ +from __future__ import annotations + from pathlib import Path # Build paths inside the project like this: BASE_DIR / 'subdir'. @@ -20,7 +22,7 @@ # See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'django-insecure-msaa(q-!zojg2ffv&1+y1cal!#e-%@$d%@2sehn4^m1yh%*%-3' +SECRET_KEY = "django-insecure-msaa(q-!zojg2ffv&1+y1cal!#e-%@$d%@2sehn4^m1yh%*%-3" # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True @@ -31,51 +33,52 @@ # Application definition INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "djls_app", ] MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", ] -ROOT_URLCONF = 'djls_test.urls' +ROOT_URLCONF = "djls_test.urls" TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], }, }, ] -WSGI_APPLICATION = 'djls_test.wsgi.application' +WSGI_APPLICATION = "djls_test.wsgi.application" # Database # https://docs.djangoproject.com/en/5.2/ref/settings/#databases DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", } } @@ -85,16 +88,16 @@ AUTH_PASSWORD_VALIDATORS = [ { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", }, ] @@ -102,9 +105,9 @@ # Internationalization # https://docs.djangoproject.com/en/5.2/topics/i18n/ -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = "en-us" -TIME_ZONE = 'UTC' +TIME_ZONE = "UTC" USE_I18N = True @@ -114,9 +117,9 @@ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/5.2/howto/static-files/ -STATIC_URL = 'static/' +STATIC_URL = "static/" # Default primary key field type # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"