From 3131470cce738d8e2220a4ef66e6d25cf8256e2c Mon Sep 17 00:00:00 2001 From: Josh Date: Mon, 25 Aug 2025 12:22:54 -0500 Subject: [PATCH] wip --- Cargo.lock | 2 + crates/djls-server/src/lib.rs | 1 - crates/djls-server/src/server.rs | 103 ++++++- crates/djls-server/src/session.rs | 55 +++- crates/djls-server/src/workspace/document.rs | 231 -------------- crates/djls-server/src/workspace/mod.rs | 6 - crates/djls-server/src/workspace/utils.rs | 43 --- crates/djls-templates/src/lib.rs | 2 +- crates/djls-workspace/Cargo.toml | 2 + crates/djls-workspace/src/bridge.rs | 33 +- crates/djls-workspace/src/db.rs | 5 +- .../djls-workspace/src/document/language.rs | 34 +++ .../djls-workspace/src/document/line_index.rs | 87 ++++++ crates/djls-workspace/src/document/mod.rs | 132 ++++++++ .../src/document}/store.rs | 289 ++++++++++++++---- .../djls-workspace/src/document/template.rs | 13 + crates/djls-workspace/src/lib.rs | 16 +- .../djls-workspace/src/{vfs.rs => vfs/mod.rs} | 115 ++++--- .../djls-workspace/src/{ => vfs}/watcher.rs | 22 +- 19 files changed, 761 insertions(+), 430 deletions(-) delete mode 100644 crates/djls-server/src/workspace/document.rs delete mode 100644 crates/djls-server/src/workspace/mod.rs delete mode 100644 crates/djls-server/src/workspace/utils.rs create mode 100644 crates/djls-workspace/src/document/language.rs create mode 100644 crates/djls-workspace/src/document/line_index.rs create mode 100644 crates/djls-workspace/src/document/mod.rs rename crates/{djls-server/src/workspace => djls-workspace/src/document}/store.rs (67%) create mode 100644 crates/djls-workspace/src/document/template.rs rename crates/djls-workspace/src/{vfs.rs => vfs/mod.rs} (84%) rename crates/djls-workspace/src/{ => vfs}/watcher.rs (97%) diff --git a/Cargo.lock b/Cargo.lock index 73c6aae..d739a68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -509,11 +509,13 @@ dependencies = [ "anyhow", "camino", "dashmap", + "djls-project", "djls-templates", "notify", "salsa", "tempfile", "tokio", + "tower-lsp-server", "url", ] diff --git a/crates/djls-server/src/lib.rs b/crates/djls-server/src/lib.rs index 1ebe618..b601c7a 100644 --- a/crates/djls-server/src/lib.rs +++ b/crates/djls-server/src/lib.rs @@ -3,7 +3,6 @@ mod logging; mod queue; mod server; mod session; -mod workspace; use std::io::IsTerminal; diff --git a/crates/djls-server/src/server.rs b/crates/djls-server/src/server.rs index 9df7c63..e61bb08 100644 --- a/crates/djls-server/src/server.rs +++ b/crates/djls-server/src/server.rs @@ -218,8 +218,23 @@ impl LanguageServer for DjangoLanguageServer { tracing::info!("Opened document: {:?}", params.text_document.uri); self.with_session_mut(|session| { - if let Err(e) = session.documents_mut().handle_did_open(¶ms) { - tracing::error!("Failed to handle did_open: {}", e); + let uri = params.text_document.uri.clone(); + let version = params.text_document.version; + let language_id = + djls_workspace::LanguageId::from(params.text_document.language_id.as_str()); + let text = params.text_document.text.clone(); + + // Convert LSP Uri to url::Url + if let Ok(url) = url::Url::parse(&uri.to_string()) { + if let Err(e) = + session + .documents_mut() + .open_document(url, version, language_id, text) + { + tracing::error!("Failed to handle did_open: {}", e); + } + } else { + tracing::error!("Invalid URI: {:?}", uri); } }) .await; @@ -229,7 +244,21 @@ impl LanguageServer for DjangoLanguageServer { tracing::info!("Changed document: {:?}", params.text_document.uri); self.with_session_mut(|session| { - let _ = session.documents_mut().handle_did_change(¶ms); + let uri = ¶ms.text_document.uri; + let version = params.text_document.version; + let changes = params.content_changes.clone(); + + // Convert LSP Uri to url::Url + if let Ok(url) = url::Url::parse(&uri.to_string()) { + if let Err(e) = session + .documents_mut() + .update_document(&url, version, changes) + { + tracing::error!("Failed to handle did_change: {}", e); + } + } else { + tracing::error!("Invalid URI: {:?}", uri); + } }) .await; } @@ -238,7 +267,14 @@ impl LanguageServer for DjangoLanguageServer { tracing::info!("Closed document: {:?}", params.text_document.uri); self.with_session_mut(|session| { - session.documents_mut().handle_did_close(¶ms); + let uri = ¶ms.text_document.uri; + + // Convert LSP Uri to url::Url + if let Ok(url) = url::Url::parse(&uri.to_string()) { + session.documents_mut().close_document(&url); + } else { + tracing::error!("Invalid URI: {:?}", uri); + } }) .await; } @@ -248,14 +284,61 @@ impl LanguageServer for DjangoLanguageServer { .with_session(|session| { if let Some(project) = session.project() { if let Some(tags) = project.template_tags() { - return session.documents().get_completions( - params.text_document_position.text_document.uri.as_str(), - params.text_document_position.position, - tags, - ); + let uri = ¶ms.text_document_position.text_document.uri; + let position = params.text_document_position.position; + + // Convert LSP Uri to url::Url + if let Ok(url) = url::Url::parse(&uri.to_string()) { + if let Some(context) = session.documents().get_template_context(&url, position) { + // Use the context to generate completions + let mut completions: Vec = 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 { "" }; + tower_lsp_server::lsp_types::CompletionItem { + label: tag.name().to_string(), + kind: Some(tower_lsp_server::lsp_types::CompletionItemKind::KEYWORD), + detail: Some(format!("Template tag from {}", tag.library())), + documentation: tag.doc().as_ref().map(|doc| { + tower_lsp_server::lsp_types::Documentation::MarkupContent( + tower_lsp_server::lsp_types::MarkupContent { + kind: tower_lsp_server::lsp_types::MarkupKind::Markdown, + value: (*doc).to_string(), + } + ) + }), + insert_text: Some(match context.closing_brace { + djls_workspace::ClosingBrace::None => format!("{}{} %}}", leading_space, tag.name()), + djls_workspace::ClosingBrace::PartialClose => format!("{}{} %", leading_space, tag.name()), + djls_workspace::ClosingBrace::FullClose => format!("{}{} ", leading_space, tag.name()), + }), + insert_text_format: Some(tower_lsp_server::lsp_types::InsertTextFormat::PLAIN_TEXT), + ..Default::default() + } + }) + .collect(); + + if completions.is_empty() { + None + } else { + completions.sort_by(|a, b| a.label.cmp(&b.label)); + Some(tower_lsp_server::lsp_types::CompletionResponse::Array(completions)) + } + } else { + None + } + } else { + None + } + } else { + None } + } else { + None } - None }) .await) } diff --git a/crates/djls-server/src/session.rs b/crates/djls-server/src/session.rs index a5832cb..8a584d3 100644 --- a/crates/djls-server/src/session.rs +++ b/crates/djls-server/src/session.rs @@ -1,14 +1,17 @@ +use std::path::PathBuf; + use djls_conf::Settings; use djls_project::DjangoProject; +use djls_workspace::DocumentStore; +use percent_encoding::percent_decode_str; use tower_lsp_server::lsp_types::ClientCapabilities; use tower_lsp_server::lsp_types::InitializeParams; - -use crate::workspace::Store; +use tower_lsp_server::lsp_types::Uri; #[derive(Default)] pub struct Session { project: Option, - documents: Store, + documents: DocumentStore, settings: Settings, #[allow(dead_code)] @@ -16,8 +19,46 @@ pub struct Session { } impl Session { + /// Determines the project root path from initialization parameters. + /// + /// Tries the current directory first, then falls back to the first workspace folder. + fn get_project_path(params: &InitializeParams) -> Option { + // Try current directory first + std::env::current_dir().ok().or_else(|| { + // Fall back to the first workspace folder URI + params + .workspace_folders + .as_ref() + .and_then(|folders| folders.first()) + .and_then(|folder| Self::uri_to_pathbuf(&folder.uri)) + }) + } + + /// Converts a `file:` URI into an absolute `PathBuf`. + fn uri_to_pathbuf(uri: &Uri) -> Option { + // Check if the scheme is "file" + if uri.scheme().is_none_or(|s| s.as_str() != "file") { + return None; + } + + // Get the path part as a string + let encoded_path_str = uri.path().as_str(); + + // Decode the percent-encoded path string + let decoded_path_cow = percent_decode_str(encoded_path_str).decode_utf8_lossy(); + let path_str = decoded_path_cow.as_ref(); + + #[cfg(windows)] + let path_str = { + // Remove leading '/' for paths like /C:/... + path_str.strip_prefix('/').unwrap_or(path_str) + }; + + Some(PathBuf::from(path_str)) + } + pub fn new(params: &InitializeParams) -> Self { - let project_path = crate::workspace::get_project_path(params); + let project_path = Self::get_project_path(params); let (project, settings) = if let Some(path) = &project_path { let settings = @@ -33,7 +74,7 @@ impl Session { Self { client_capabilities: params.capabilities.clone(), project, - documents: Store::default(), + documents: DocumentStore::new(), settings, } } @@ -46,11 +87,11 @@ impl Session { &mut self.project } - pub fn documents(&self) -> &Store { + pub fn documents(&self) -> &DocumentStore { &self.documents } - pub fn documents_mut(&mut self) -> &mut Store { + pub fn documents_mut(&mut self) -> &mut DocumentStore { &mut self.documents } diff --git a/crates/djls-server/src/workspace/document.rs b/crates/djls-server/src/workspace/document.rs deleted file mode 100644 index 75812b8..0000000 --- a/crates/djls-server/src/workspace/document.rs +++ /dev/null @@ -1,231 +0,0 @@ -use djls_workspace::{FileId, VfsSnapshot}; -use std::sync::Arc; -use tower_lsp_server::lsp_types::{Position, Range}; - -#[derive(Clone, Debug)] -pub struct TextDocument { - pub uri: String, - pub version: i32, - pub language_id: LanguageId, - file_id: FileId, -} - -impl TextDocument { - pub fn new(uri: String, version: i32, language_id: LanguageId, file_id: FileId) -> Self { - Self { - uri, - version, - language_id, - file_id, - } - } - - pub fn file_id(&self) -> FileId { - self.file_id - } - - pub fn get_content(&self, vfs: &VfsSnapshot) -> Option> { - vfs.get_text(self.file_id) - } - - pub fn get_line(&self, vfs: &VfsSnapshot, line_index: &LineIndex, line: u32) -> Option { - let content = self.get_content(vfs)?; - - let line_start = *line_index.line_starts.get(line as usize)?; - let line_end = line_index - .line_starts - .get(line as usize + 1) - .copied() - .unwrap_or(line_index.length); - - Some(content[line_start as usize..line_end as usize].to_string()) - } - - pub fn get_text_range( - &self, - vfs: &VfsSnapshot, - line_index: &LineIndex, - range: Range, - ) -> Option { - let content = self.get_content(vfs)?; - - let start_offset = line_index.offset(range.start)? as usize; - let end_offset = line_index.offset(range.end)? as usize; - - Some(content[start_offset..end_offset].to_string()) - } - - pub fn get_template_tag_context( - &self, - vfs: &VfsSnapshot, - line_index: &LineIndex, - position: Position, - ) -> Option { - let content = self.get_content(vfs)?; - - let start = line_index.line_starts.get(position.line as usize)?; - let end = line_index - .line_starts - .get(position.line as usize + 1) - .copied() - .unwrap_or(line_index.length); - - let line = &content[*start as usize..end as usize]; - let char_pos: usize = position.character.try_into().ok()?; - let prefix = &line[..char_pos]; - let rest_of_line = &line[char_pos..]; - 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)] -pub struct LineIndex { - pub line_starts: Vec, - pub line_starts_utf16: Vec, - pub length: u32, - pub length_utf16: u32, -} - -impl LineIndex { - pub fn new(text: &str) -> Self { - let mut line_starts = vec![0]; - let mut line_starts_utf16 = vec![0]; - let mut pos_utf8 = 0; - let mut pos_utf16 = 0; - - for c in text.chars() { - pos_utf8 += u32::try_from(c.len_utf8()).unwrap_or(0); - pos_utf16 += u32::try_from(c.len_utf16()).unwrap_or(0); - if c == '\n' { - line_starts.push(pos_utf8); - line_starts_utf16.push(pos_utf16); - } - } - - Self { - line_starts, - line_starts_utf16, - length: pos_utf8, - length_utf16: pos_utf16, - } - } - - pub fn offset(&self, position: Position) -> Option { - let line_start = self.line_starts.get(position.line as usize)?; - - Some(line_start + position.character) - } - - /// Convert UTF-16 LSP position to UTF-8 byte offset - pub fn offset_utf16(&self, position: Position, text: &str) -> Option { - let line_start_utf8 = self.line_starts.get(position.line as usize)?; - let _line_start_utf16 = self.line_starts_utf16.get(position.line as usize)?; - - // If position is at start of line, return UTF-8 line start - if position.character == 0 { - return Some(*line_start_utf8); - } - - // Find the line text - let next_line_start = self - .line_starts - .get(position.line as usize + 1) - .copied() - .unwrap_or(self.length); - - let line_text = text.get(*line_start_utf8 as usize..next_line_start as usize)?; - - // Convert UTF-16 character offset to UTF-8 byte offset within the line - let mut utf16_pos = 0; - let mut utf8_pos = 0; - - for c in line_text.chars() { - if utf16_pos >= position.character { - break; - } - utf16_pos += u32::try_from(c.len_utf16()).unwrap_or(0); - utf8_pos += u32::try_from(c.len_utf8()).unwrap_or(0); - } - - Some(line_start_utf8 + utf8_pos) - } - - #[allow(dead_code)] - pub fn position(&self, offset: u32) -> Position { - let line = match self.line_starts.binary_search(&offset) { - Ok(line) => line, - Err(line) => line - 1, - }; - - let line_start = self.line_starts[line]; - let character = offset - line_start; - - Position::new(u32::try_from(line).unwrap_or(0), character) - } -} - -#[derive(Clone, Debug, PartialEq)] -pub enum LanguageId { - HtmlDjango, - Other, - Python, -} - -impl From<&str> for LanguageId { - fn from(language_id: &str) -> Self { - match language_id { - "django-html" | "htmldjango" => Self::HtmlDjango, - "python" => Self::Python, - _ => Self::Other, - } - } -} - -impl From for LanguageId { - fn from(language_id: String) -> Self { - Self::from(language_id.as_str()) - } -} - -impl From for djls_workspace::FileKind { - fn from(language_id: LanguageId) -> Self { - match language_id { - LanguageId::Python => Self::Python, - LanguageId::HtmlDjango => Self::Template, - LanguageId::Other => Self::Other, - } - } -} - -#[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, -} diff --git a/crates/djls-server/src/workspace/mod.rs b/crates/djls-server/src/workspace/mod.rs deleted file mode 100644 index fb15df9..0000000 --- a/crates/djls-server/src/workspace/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -mod document; -mod store; -mod utils; - -pub use store::Store; -pub use utils::get_project_path; diff --git a/crates/djls-server/src/workspace/utils.rs b/crates/djls-server/src/workspace/utils.rs deleted file mode 100644 index 08a40ba..0000000 --- a/crates/djls-server/src/workspace/utils.rs +++ /dev/null @@ -1,43 +0,0 @@ -use std::path::PathBuf; - -use percent_encoding::percent_decode_str; -use tower_lsp_server::lsp_types::InitializeParams; -use tower_lsp_server::lsp_types::Uri; - -/// Determines the project root path from initialization parameters. -/// -/// Tries the current directory first, then falls back to the first workspace folder. -pub fn get_project_path(params: &InitializeParams) -> Option { - // Try current directory first - std::env::current_dir().ok().or_else(|| { - // Fall back to the first workspace folder URI - params - .workspace_folders - .as_ref() - .and_then(|folders| folders.first()) - .and_then(|folder| uri_to_pathbuf(&folder.uri)) - }) -} - -/// Converts a `file:` URI into an absolute `PathBuf`. -fn uri_to_pathbuf(uri: &Uri) -> Option { - // Check if the scheme is "file" - if uri.scheme().is_none_or(|s| s.as_str() != "file") { - return None; - } - - // Get the path part as a string - let encoded_path_str = uri.path().as_str(); - - // Decode the percent-encoded path string - let decoded_path_cow = percent_decode_str(encoded_path_str).decode_utf8_lossy(); - let path_str = decoded_path_cow.as_ref(); - - #[cfg(windows)] - let path_str = { - // Remove leading '/' for paths like /C:/... - path_str.strip_prefix('/').unwrap_or(path_str) - }; - - Some(PathBuf::from(path_str)) -} diff --git a/crates/djls-templates/src/lib.rs b/crates/djls-templates/src/lib.rs index 7eab1f6..7c2369c 100644 --- a/crates/djls-templates/src/lib.rs +++ b/crates/djls-templates/src/lib.rs @@ -1,4 +1,4 @@ -mod ast; +pub mod ast; mod error; mod lexer; mod parser; diff --git a/crates/djls-workspace/Cargo.toml b/crates/djls-workspace/Cargo.toml index 34fa01e..0a46bd8 100644 --- a/crates/djls-workspace/Cargo.toml +++ b/crates/djls-workspace/Cargo.toml @@ -5,6 +5,7 @@ edition = "2021" [dependencies] djls-templates = { workspace = true } +djls-project = { workspace = true } anyhow = { workspace = true } camino = { workspace = true } @@ -12,6 +13,7 @@ dashmap = { workspace = true } notify = { workspace = true } salsa = { workspace = true } tokio = { workspace = true } +tower-lsp-server = { workspace = true } url = { workspace = true } [dev-dependencies] diff --git a/crates/djls-workspace/src/bridge.rs b/crates/djls-workspace/src/bridge.rs index 767afbe..0338262 100644 --- a/crates/djls-workspace/src/bridge.rs +++ b/crates/djls-workspace/src/bridge.rs @@ -4,15 +4,20 @@ //! It ensures we only touch Salsa when content or classification changes, maximizing //! incremental performance. -use std::{collections::HashMap, sync::Arc}; +use std::collections::HashMap; +use std::sync::Arc; use salsa::Setter; -use super::{ - db::{parse_template, template_errors, Database, SourceFile, TemplateAst, TemplateLoaderOrder}, - vfs::{FileKind, VfsSnapshot}, - FileId, -}; +use super::db::parse_template; +use super::db::template_errors; +use super::db::Database; +use super::db::SourceFile; +use super::db::TemplateAst; +use super::db::TemplateLoaderOrder; +use super::vfs::FileKind; +use super::vfs::VfsSnapshot; +use super::FileId; /// Owner of the Salsa [`Database`] plus the handles for updating inputs. /// @@ -63,7 +68,7 @@ impl FileStore { /// /// The method is idempotent and minimizes Salsa invalidations by checking for /// actual changes before updating inputs. - pub fn apply_vfs_snapshot(&mut self, snap: &VfsSnapshot) { + pub(crate) fn apply_vfs_snapshot(&mut self, snap: &VfsSnapshot) { for (id, rec) in &snap.files { let new_text = snap.get_text(*id).unwrap_or_else(|| Arc::::from("")); let new_kind = rec.meta.kind; @@ -86,14 +91,14 @@ impl FileStore { /// Get the text content of a file by its [`FileId`]. /// /// Returns `None` if the file is not tracked in the [`FileStore`]. - pub fn file_text(&self, id: FileId) -> Option> { + pub(crate) fn file_text(&self, id: FileId) -> Option> { self.files.get(&id).map(|sf| sf.text(&self.db).clone()) } /// Get the file kind classification by its [`FileId`]. /// /// Returns `None` if the file is not tracked in the [`FileStore`]. - pub fn file_kind(&self, id: FileId) -> Option { + pub(crate) fn file_kind(&self, id: FileId) -> Option { self.files.get(&id).map(|sf| sf.kind(&self.db)) } @@ -102,7 +107,7 @@ impl FileStore { /// This method leverages Salsa's incremental computation to cache parsed ASTs. /// The AST is only re-parsed when the file's content changes in the VFS. /// Returns `None` if the file is not tracked or is not a template file. - pub fn get_template_ast(&self, id: FileId) -> Option> { + pub(crate) fn get_template_ast(&self, id: FileId) -> Option> { let source_file = self.files.get(&id)?; parse_template(&self.db, *source_file) } @@ -112,7 +117,7 @@ impl FileStore { /// This method provides quick access to template errors without needing the full AST. /// Useful for diagnostics and error reporting. Returns an empty slice for /// non-template files or files not tracked in the store. - pub fn get_template_errors(&self, id: FileId) -> Arc<[String]> { + pub(crate) fn get_template_errors(&self, id: FileId) -> Arc<[String]> { self.files .get(&id) .map_or_else(|| Arc::from(vec![]), |sf| template_errors(&self.db, *sf)) @@ -127,10 +132,12 @@ impl Default for FileStore { #[cfg(test)] mod tests { - use super::*; - use crate::vfs::{TextSource, Vfs}; use camino::Utf8PathBuf; + use super::*; + use crate::vfs::TextSource; + use crate::vfs::Vfs; + #[test] fn test_filestore_template_ast_caching() { let mut store = FileStore::new(); diff --git a/crates/djls-workspace/src/db.rs b/crates/djls-workspace/src/db.rs index 1b4ece5..2783eae 100644 --- a/crates/djls-workspace/src/db.rs +++ b/crates/djls-workspace/src/db.rs @@ -7,6 +7,8 @@ use std::sync::Arc; #[cfg(test)] use std::sync::Mutex; +use djls_templates::Ast; + use crate::vfs::FileKind; /// Salsa database root for workspace @@ -139,9 +141,10 @@ pub fn template_errors(db: &dyn salsa::Database, file: SourceFile) -> Arc<[Strin #[cfg(test)] mod tests { - use super::*; use salsa::Setter; + use super::*; + #[test] fn test_template_parsing_caches_result() { let db = Database::default(); diff --git a/crates/djls-workspace/src/document/language.rs b/crates/djls-workspace/src/document/language.rs new file mode 100644 index 0000000..09f0bb5 --- /dev/null +++ b/crates/djls-workspace/src/document/language.rs @@ -0,0 +1,34 @@ +use crate::vfs::FileKind; + +#[derive(Clone, Debug, PartialEq)] +pub enum LanguageId { + HtmlDjango, + Other, + Python, +} + +impl From<&str> for LanguageId { + fn from(language_id: &str) -> Self { + match language_id { + "django-html" | "htmldjango" => Self::HtmlDjango, + "python" => Self::Python, + _ => Self::Other, + } + } +} + +impl From for LanguageId { + fn from(language_id: String) -> Self { + Self::from(language_id.as_str()) + } +} + +impl From for FileKind { + fn from(language_id: LanguageId) -> Self { + match language_id { + LanguageId::Python => Self::Python, + LanguageId::HtmlDjango => Self::Template, + LanguageId::Other => Self::Other, + } + } +} diff --git a/crates/djls-workspace/src/document/line_index.rs b/crates/djls-workspace/src/document/line_index.rs new file mode 100644 index 0000000..39f1fde --- /dev/null +++ b/crates/djls-workspace/src/document/line_index.rs @@ -0,0 +1,87 @@ +use tower_lsp_server::lsp_types::Position; + +#[derive(Clone, Debug)] +pub struct LineIndex { + pub line_starts: Vec, + pub line_starts_utf16: Vec, + pub length: u32, + pub length_utf16: u32, +} + +impl LineIndex { + pub fn new(text: &str) -> Self { + let mut line_starts = vec![0]; + let mut line_starts_utf16 = vec![0]; + let mut pos_utf8 = 0; + let mut pos_utf16 = 0; + + for c in text.chars() { + pos_utf8 += u32::try_from(c.len_utf8()).unwrap_or(0); + pos_utf16 += u32::try_from(c.len_utf16()).unwrap_or(0); + if c == '\n' { + line_starts.push(pos_utf8); + line_starts_utf16.push(pos_utf16); + } + } + + Self { + line_starts, + line_starts_utf16, + length: pos_utf8, + length_utf16: pos_utf16, + } + } + + pub fn offset(&self, position: Position) -> Option { + let line_start = self.line_starts.get(position.line as usize)?; + + Some(line_start + position.character) + } + + /// Convert UTF-16 LSP position to UTF-8 byte offset + pub fn offset_utf16(&self, position: Position, text: &str) -> Option { + let line_start_utf8 = self.line_starts.get(position.line as usize)?; + let _line_start_utf16 = self.line_starts_utf16.get(position.line as usize)?; + + // If position is at start of line, return UTF-8 line start + if position.character == 0 { + return Some(*line_start_utf8); + } + + // Find the line text + let next_line_start = self + .line_starts + .get(position.line as usize + 1) + .copied() + .unwrap_or(self.length); + + let line_text = text.get(*line_start_utf8 as usize..next_line_start as usize)?; + + // Convert UTF-16 character offset to UTF-8 byte offset within the line + let mut utf16_pos = 0; + let mut utf8_pos = 0; + + for c in line_text.chars() { + if utf16_pos >= position.character { + break; + } + utf16_pos += u32::try_from(c.len_utf16()).unwrap_or(0); + utf8_pos += u32::try_from(c.len_utf8()).unwrap_or(0); + } + + Some(line_start_utf8 + utf8_pos) + } + + #[allow(dead_code)] + pub fn position(&self, offset: u32) -> Position { + let line = match self.line_starts.binary_search(&offset) { + Ok(line) => line, + Err(line) => line - 1, + }; + + let line_start = self.line_starts[line]; + let character = offset - line_start; + + Position::new(u32::try_from(line).unwrap_or(0), character) + } +} diff --git a/crates/djls-workspace/src/document/mod.rs b/crates/djls-workspace/src/document/mod.rs new file mode 100644 index 0000000..840cd17 --- /dev/null +++ b/crates/djls-workspace/src/document/mod.rs @@ -0,0 +1,132 @@ +mod language; +mod line_index; +mod store; +mod template; + +pub use language::LanguageId; +pub use line_index::LineIndex; +pub use store::DocumentStore; +pub use template::ClosingBrace; +pub use template::TemplateTagContext; +use tower_lsp_server::lsp_types::Position; +use tower_lsp_server::lsp_types::Range; + +use crate::FileId; + +#[derive(Clone, Debug)] +pub struct TextDocument { + pub uri: String, + pub version: i32, + pub language_id: LanguageId, + pub(crate) file_id: FileId, + line_index: LineIndex, +} + +impl TextDocument { + pub(crate) fn new( + uri: String, + version: i32, + language_id: LanguageId, + file_id: FileId, + content: &str, + ) -> Self { + let line_index = LineIndex::new(content); + Self { + uri, + version, + language_id, + file_id, + line_index, + } + } + + pub(crate) fn file_id(&self) -> FileId { + self.file_id + } + + pub fn line_index(&self) -> &LineIndex { + &self.line_index + } + + pub fn get_content<'a>(&self, content: &'a str) -> &'a str { + content + } + + pub fn get_line(&self, content: &str, line: u32) -> Option { + let line_start = *self.line_index.line_starts.get(line as usize)?; + let line_end = self + .line_index + .line_starts + .get(line as usize + 1) + .copied() + .unwrap_or(self.line_index.length); + + Some(content[line_start as usize..line_end as usize].to_string()) + } + + pub fn get_text_range(&self, content: &str, range: Range) -> Option { + let start_offset = self.line_index.offset(range.start)? as usize; + let end_offset = self.line_index.offset(range.end)? as usize; + + Some(content[start_offset..end_offset].to_string()) + } + + pub fn get_template_tag_context( + &self, + content: &str, + position: Position, + ) -> Option { + let start = self.line_index.line_starts.get(position.line as usize)?; + let end = self + .line_index + .line_starts + .get(position.line as usize + 1) + .copied() + .unwrap_or(self.line_index.length); + + let line = &content[*start as usize..end as usize]; + let char_pos: usize = position.character.try_into().ok()?; + let prefix = &line[..char_pos]; + let rest_of_line = &line[char_pos..]; + 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, + } + }) + } + + pub fn position_to_offset(&self, position: Position) -> Option { + self.line_index.offset(position) + } + + pub fn offset_to_position(&self, offset: u32) -> Position { + self.line_index.position(offset) + } + + pub fn update_content(&mut self, content: &str) { + self.line_index = LineIndex::new(content); + } + + pub fn version(&self) -> i32 { + self.version + } + + pub fn language_id(&self) -> LanguageId { + self.language_id.clone() + } +} diff --git a/crates/djls-server/src/workspace/store.rs b/crates/djls-workspace/src/document/store.rs similarity index 67% rename from crates/djls-server/src/workspace/store.rs rename to crates/djls-workspace/src/document/store.rs index 9096201..8284107 100644 --- a/crates/djls-server/src/workspace/store.rs +++ b/crates/djls-workspace/src/document/store.rs @@ -1,11 +1,11 @@ use std::collections::HashMap; -use std::sync::{Arc, Mutex}; +use std::sync::Arc; +use std::sync::Mutex; use anyhow::anyhow; use anyhow::Result; use camino::Utf8PathBuf; use djls_project::TemplateTags; -use djls_workspace::{FileId, FileKind, FileStore, TextSource, Vfs}; use tower_lsp_server::lsp_types::CompletionItem; use tower_lsp_server::lsp_types::CompletionItemKind; use tower_lsp_server::lsp_types::CompletionResponse; @@ -22,31 +22,118 @@ use tower_lsp_server::lsp_types::Position; use tower_lsp_server::lsp_types::Range; use tower_lsp_server::lsp_types::TextDocumentContentChangeEvent; -use super::document::{ClosingBrace, LanguageId, LineIndex, TextDocument}; +use crate::bridge::FileStore; +use crate::db::TemplateAst; +use crate::vfs::FileKind; +use crate::vfs::TextSource; +use crate::vfs::Vfs; +use crate::ClosingBrace; +use crate::LanguageId; +use crate::LineIndex; +use crate::TextDocument; -pub struct Store { +pub struct DocumentStore { vfs: Arc, file_store: Arc>, - file_ids: HashMap, - line_indices: HashMap, - versions: HashMap, documents: HashMap, } -impl Default for Store { +impl Default for DocumentStore { fn default() -> Self { Self { vfs: Arc::new(Vfs::default()), file_store: Arc::new(Mutex::new(FileStore::new())), - file_ids: HashMap::new(), - line_indices: HashMap::new(), - versions: HashMap::new(), documents: HashMap::new(), } } } -impl Store { +impl DocumentStore { + pub fn new() -> Self { + Self::default() + } + + /// Open a document with the given URI, version, language, and text content. + /// This creates a new TextDocument and stores it internally, hiding VFS details. + pub fn open_document( + &mut self, + uri: url::Url, + version: i32, + language_id: LanguageId, + text: String, + ) -> Result<()> { + let uri_str = uri.to_string(); + let kind = FileKind::from(language_id.clone()); + + // Convert URI to path - simplified for now, just use URI string + let path = Utf8PathBuf::from(uri.as_str()); + + // Store content in VFS + let text_source = TextSource::Overlay(Arc::from(text.as_str())); + let file_id = self.vfs.intern_file(uri, path, kind, text_source); + + // Set overlay content in VFS + self.vfs.set_overlay(file_id, Arc::from(text.as_str()))?; + + // Sync VFS snapshot to FileStore for Salsa tracking + let snapshot = self.vfs.snapshot(); + let mut file_store = self.file_store.lock().unwrap(); + file_store.apply_vfs_snapshot(&snapshot); + + // Create TextDocument with LineIndex + let document = TextDocument::new(uri_str.clone(), version, language_id, file_id, &text); + self.documents.insert(uri_str, document); + + Ok(()) + } + + /// Update a document with the given URI, version, and text changes. + /// This applies changes to the document and updates the VFS accordingly. + pub fn update_document( + &mut self, + uri: &url::Url, + version: i32, + changes: Vec, + ) -> Result<()> { + let uri_str = uri.to_string(); + + // Get document and file_id from the documents HashMap + let document = self + .documents + .get(&uri_str) + .ok_or_else(|| anyhow!("Document not found: {}", uri_str))?; + let file_id = document.file_id(); + + // Get current content from VFS + let snapshot = self.vfs.snapshot(); + let current_content = snapshot + .get_text(file_id) + .ok_or_else(|| anyhow!("File content not found: {}", uri_str))?; + + // Get line index from the document + let line_index = document.line_index(); + + // Apply text changes using the existing function + let new_content = apply_text_changes(¤t_content, &changes, line_index)?; + + // Update TextDocument version and content + if let Some(document) = self.documents.get_mut(&uri_str) { + document.version = version; + document.update_content(&new_content); + } + + // Update VFS with new content + self.vfs + .set_overlay(file_id, Arc::from(new_content.as_str()))?; + + // Sync VFS snapshot to FileStore for Salsa tracking + let snapshot = self.vfs.snapshot(); + let mut file_store = self.file_store.lock().unwrap(); + file_store.apply_vfs_snapshot(&snapshot); + + Ok(()) + } + pub fn handle_did_open(&mut self, params: &DidOpenTextDocumentParams) -> Result<()> { let uri_str = params.text_document.uri.to_string(); let uri = params.text_document.uri.clone(); @@ -75,13 +162,14 @@ impl Store { file_store.apply_vfs_snapshot(&snapshot); // Create TextDocument metadata - let document = TextDocument::new(uri_str.clone(), version, language_id.clone(), file_id); - self.documents.insert(uri_str.clone(), document); - - // Cache mappings and indices - self.file_ids.insert(uri_str.clone(), file_id); - self.line_indices.insert(file_id, LineIndex::new(&content)); - self.versions.insert(uri_str, version); + let document = TextDocument::new( + uri_str.clone(), + version, + language_id.clone(), + file_id, + &content, + ); + self.documents.insert(uri_str, document); Ok(()) } @@ -90,12 +178,12 @@ impl Store { let uri_str = params.text_document.uri.as_str().to_string(); let version = params.text_document.version; - // Look up FileId - let file_id = self - .file_ids + // Get document and file_id from the documents HashMap + let document = self + .documents .get(&uri_str) - .copied() .ok_or_else(|| anyhow!("Document not found: {}", uri_str))?; + let file_id = document.file_id(); // Get current content from VFS let snapshot = self.vfs.snapshot(); @@ -103,19 +191,17 @@ impl Store { .get_text(file_id) .ok_or_else(|| anyhow!("File content not found: {}", uri_str))?; - // Get current line index for position calculations - let line_index = self - .line_indices - .get(&file_id) - .ok_or_else(|| anyhow!("Line index not found for: {}", uri_str))?; + // Get line index from the document (TextDocument now stores its own LineIndex) + let line_index = document.line_index(); // Apply text changes using the new function let new_content = apply_text_changes(¤t_content, ¶ms.content_changes, line_index)?; - // Update TextDocument version + // Update TextDocument version and content if let Some(document) = self.documents.get_mut(&uri_str) { document.version = version; + document.update_content(&new_content); } // Update VFS with new content @@ -127,41 +213,38 @@ impl Store { let mut file_store = self.file_store.lock().unwrap(); file_store.apply_vfs_snapshot(&snapshot); - // Update cached line index and version - self.line_indices - .insert(file_id, LineIndex::new(&new_content)); - self.versions.insert(uri_str, version); - Ok(()) } + /// Close a document with the given URI. + /// This removes the document from internal storage and cleans up resources. + pub fn close_document(&mut self, uri: &url::Url) { + let uri_str = uri.as_str(); + + // Remove TextDocument metadata + self.documents.remove(uri_str); + + // Note: We don't remove from VFS as it might be useful for caching + // The VFS will handle cleanup internally + } + pub fn handle_did_close(&mut self, params: &DidCloseTextDocumentParams) { let uri_str = params.text_document.uri.as_str(); // Remove TextDocument metadata self.documents.remove(uri_str); - // Look up FileId and remove mappings - if let Some(file_id) = self.file_ids.remove(uri_str) { - self.line_indices.remove(&file_id); - } - self.versions.remove(uri_str); - // Note: We don't remove from VFS as it might be useful for caching // The VFS will handle cleanup internally } - pub fn get_file_id(&self, uri: &str) -> Option { - self.file_ids.get(uri).copied() - } - - pub fn get_line_index(&self, file_id: FileId) -> Option<&LineIndex> { - self.line_indices.get(&file_id) + pub fn get_line_index(&self, uri: &str) -> Option<&LineIndex> { + self.documents.get(uri).map(|doc| doc.line_index()) } #[allow(dead_code)] pub fn get_version(&self, uri: &str) -> Option { - self.versions.get(uri).copied() + self.documents.get(uri).map(|doc| doc.version()) } #[allow(dead_code)] @@ -178,6 +261,99 @@ impl Store { self.documents.get_mut(uri) } + // URI-based query methods (new API) + pub fn get_document_by_url(&self, uri: &url::Url) -> Option<&TextDocument> { + self.get_document(uri.as_str()) + } + + pub fn get_document_text(&self, uri: &url::Url) -> Option> { + let document = self.get_document_by_url(uri)?; + let file_id = document.file_id(); + let snapshot = self.vfs.snapshot(); + snapshot.get_text(file_id) + } + + pub fn get_line_text(&self, uri: &url::Url, line: u32) -> Option { + let document = self.get_document_by_url(uri)?; + let snapshot = self.vfs.snapshot(); + let content = snapshot.get_text(document.file_id())?; + document.get_line(content.as_ref(), line) + } + + pub fn get_word_at_position(&self, uri: &url::Url, position: Position) -> Option { + // This is a simplified implementation - get the line and extract word at position + let line_text = self.get_line_text(uri, position.line)?; + let char_pos: usize = position.character.try_into().ok()?; + + if char_pos >= line_text.len() { + return None; + } + + // Find word boundaries (simplified - considers alphanumeric and underscore as word chars) + let line_bytes = line_text.as_bytes(); + let mut start = char_pos; + let mut end = char_pos; + + // Find start of word + while start > 0 && is_word_char(line_bytes[start - 1]) { + start -= 1; + } + + // Find end of word + while end < line_text.len() && is_word_char(line_bytes[end]) { + end += 1; + } + + if start < end { + Some(line_text[start..end].to_string()) + } else { + None + } + } + + // Position mapping methods + pub fn offset_to_position(&self, uri: &url::Url, offset: usize) -> Option { + let document = self.get_document_by_url(uri)?; + Some(document.offset_to_position(offset as u32)) + } + + pub fn position_to_offset(&self, uri: &url::Url, position: Position) -> Option { + let document = self.get_document_by_url(uri)?; + document + .position_to_offset(position) + .map(|offset| offset as usize) + } + + // Template-specific methods + pub fn get_template_ast(&self, uri: &url::Url) -> Option> { + let document = self.get_document_by_url(uri)?; + let file_id = document.file_id(); + let file_store = self.file_store.lock().unwrap(); + file_store.get_template_ast(file_id) + } + + pub fn get_template_errors(&self, uri: &url::Url) -> Vec { + let document = match self.get_document_by_url(uri) { + Some(doc) => doc, + None => return vec![], + }; + let file_id = document.file_id(); + let file_store = self.file_store.lock().unwrap(); + let errors = file_store.get_template_errors(file_id); + errors.to_vec() + } + + pub fn get_template_context( + &self, + uri: &url::Url, + position: Position, + ) -> Option { + let document = self.get_document_by_url(uri)?; + let snapshot = self.vfs.snapshot(); + let content = snapshot.get_text(document.file_id())?; + document.get_template_tag_context(content.as_ref(), position) + } + pub fn get_completions( &self, uri: &str, @@ -186,7 +362,7 @@ impl Store { ) -> Option { // Check if this is a Django template using TextDocument metadata let document = self.get_document(uri)?; - if document.language_id != LanguageId::HtmlDjango { + if document.language_id() != LanguageId::HtmlDjango { return None; } @@ -202,8 +378,9 @@ impl Store { // Get template tag context from document let vfs_snapshot = self.vfs.snapshot(); - let line_index = self.get_line_index(file_id)?; - let context = document.get_template_tag_context(&vfs_snapshot, line_index, position)?; + let text_content = vfs_snapshot.get_text(file_id)?; + let content = text_content.as_ref(); + let context = document.get_template_tag_context(content, position)?; let mut completions: Vec = tags .iter() @@ -252,12 +429,12 @@ impl Store { }; // Only process template files - if document.language_id != LanguageId::HtmlDjango { + if document.language_id() != LanguageId::HtmlDjango { return vec![]; } let file_id = document.file_id(); - let Some(_line_index) = self.get_line_index(file_id) else { + let Some(_line_index) = self.get_line_index(uri) else { return vec![]; }; @@ -294,6 +471,11 @@ impl Store { } } +/// Check if a byte represents a word character (alphanumeric or underscore) +fn is_word_char(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || byte == b'_' +} + /// Apply text changes to content, handling multiple changes correctly fn apply_text_changes( content: &str, @@ -360,9 +542,10 @@ fn apply_text_changes( #[cfg(test)] mod tests { - use super::*; use tower_lsp_server::lsp_types::Range; + use super::*; + #[test] fn test_apply_single_character_insertion() { let content = "Hello world"; diff --git a/crates/djls-workspace/src/document/template.rs b/crates/djls-workspace/src/document/template.rs new file mode 100644 index 0000000..2a0547c --- /dev/null +++ b/crates/djls-workspace/src/document/template.rs @@ -0,0 +1,13 @@ +#[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, +} diff --git a/crates/djls-workspace/src/lib.rs b/crates/djls-workspace/src/lib.rs index a3db4c3..fb45cc1 100644 --- a/crates/djls-workspace/src/lib.rs +++ b/crates/djls-workspace/src/lib.rs @@ -1,14 +1,14 @@ mod bridge; mod db; +mod document; mod vfs; -mod watcher; -pub use bridge::FileStore; -pub use db::{ - parse_template, template_errors, Database, SourceFile, TemplateAst, TemplateLoaderOrder, -}; -pub use vfs::{FileKind, FileMeta, FileRecord, Revision, TextSource, Vfs, VfsSnapshot}; -pub use watcher::{VfsWatcher, WatchConfig, WatchEvent}; +pub use document::ClosingBrace; +pub use document::DocumentStore; +pub use document::LanguageId; +pub use document::LineIndex; +pub use document::TemplateTagContext; +pub use document::TextDocument; /// Stable, compact identifier for files across the subsystem. /// @@ -16,7 +16,7 @@ pub use watcher::{VfsWatcher, WatchConfig, WatchEvent}; /// Salsa inputs. Once assigned to a file (via its URI), a [`FileId`] remains stable for the /// lifetime of the VFS, even if the file's content or metadata changes. #[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)] -pub struct FileId(u32); +pub(crate) struct FileId(u32); impl FileId { /// Create a [`FileId`] from a raw u32 value. diff --git a/crates/djls-workspace/src/vfs.rs b/crates/djls-workspace/src/vfs/mod.rs similarity index 84% rename from crates/djls-workspace/src/vfs.rs rename to crates/djls-workspace/src/vfs/mod.rs index 08eb182..2b4b22f 100644 --- a/crates/djls-workspace/src/vfs.rs +++ b/crates/djls-workspace/src/vfs/mod.rs @@ -4,25 +4,28 @@ //! and snapshotting. Downstream systems consume snapshots to avoid locking and to //! batch updates. -use anyhow::{anyhow, Result}; +mod watcher; + +use std::collections::hash_map::DefaultHasher; +use std::collections::HashMap; +use std::fs; +use std::hash::Hash; +use std::hash::Hasher; +use std::sync::atomic::AtomicU32; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; +use std::sync::Arc; + +use anyhow::anyhow; +use anyhow::Result; use camino::Utf8PathBuf; use dashmap::DashMap; -use std::collections::hash_map::DefaultHasher; -use std::fs; -use std::hash::{Hash, Hasher}; -use std::{ - collections::HashMap, - sync::{ - atomic::{AtomicU32, AtomicU64, Ordering}, - Arc, - }, -}; use url::Url; +use watcher::VfsWatcher; +use watcher::WatchConfig; +use watcher::WatchEvent; -use super::{ - watcher::{VfsWatcher, WatchConfig, WatchEvent}, - FileId, -}; +use super::FileId; /// Monotonic counter representing global VFS state. /// @@ -30,18 +33,18 @@ use super::{ /// This provides a cheap way to detect if any changes have occurred since /// a previous snapshot was taken. #[derive(Copy, Clone, Eq, PartialEq, Hash, Debug, Default, PartialOrd, Ord)] -pub struct Revision(u64); +pub(crate) struct Revision(u64); impl Revision { /// Create a [`Revision`] from a raw u64 value. #[must_use] - pub fn from_raw(raw: u64) -> Self { + fn from_raw(raw: u64) -> Self { Revision(raw) } /// Get the underlying u64 value. #[must_use] - pub fn value(self) -> u64 { + fn value(self) -> u64 { self.0 } } @@ -65,11 +68,11 @@ pub enum FileKind { /// [`FileMeta`] contains all non-content information about a file, including its /// identity (URI), filesystem path, and classification. #[derive(Clone, Debug)] -pub struct FileMeta { +pub(crate) struct FileMeta { /// The file's URI (typically file:// scheme) - pub uri: Url, + uri: Url, /// The file's path in the filesystem - pub path: Utf8PathBuf, + path: Utf8PathBuf, /// Classification for routing to analyzers pub kind: FileKind, } @@ -80,7 +83,7 @@ pub struct FileMeta { /// debugging and understanding the current state of the VFS. All variants hold /// `Arc` for efficient sharing. #[derive(Clone)] -pub enum TextSource { +pub(crate) enum TextSource { /// Content loaded from disk Disk(Arc), /// Content from LSP client overlay (in-memory edits) @@ -89,18 +92,47 @@ pub enum TextSource { Generated(Arc), } +/// Content hash for efficient change detection. +/// +/// [`FileHash`] encapsulates the hashing logic used to detect when file content +/// has changed, avoiding unnecessary recomputation in downstream systems like Salsa. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +struct FileHash(u64); + +impl FileHash { + /// Compute hash from text source content. + fn from_text_source(src: &TextSource) -> Self { + let s: &str = match src { + TextSource::Disk(s) | TextSource::Overlay(s) | TextSource::Generated(s) => s, + }; + let mut h = DefaultHasher::new(); + s.hash(&mut h); + Self(h.finish()) + } + + /// Check if this hash differs from another, indicating content changed. + fn differs_from(self, other: Self) -> bool { + self.0 != other.0 + } + + /// Get raw hash value (for debugging/logging). + fn raw(self) -> u64 { + self.0 + } +} + /// Complete record of a file in the VFS. /// /// [`FileRecord`] combines metadata, current text content, and a content hash /// for efficient change detection. #[derive(Clone)] -pub struct FileRecord { +pub(crate) struct FileRecord { /// File metadata (URI, path, kind, version) pub meta: FileMeta, /// Current text content and its source - pub text: TextSource, + text: TextSource, /// Hash of current content for change detection - pub hash: u64, + hash: FileHash, } /// Thread-safe virtual file system with change tracking. @@ -129,7 +161,7 @@ impl Vfs { /// Returns the existing [`FileId`] if the URI is already known, or creates a new /// [`FileRecord`] with the provided metadata and text. This method computes and /// stores a content hash for change detection. - pub fn intern_file( + pub(crate) fn intern_file( &self, uri: Url, path: Utf8PathBuf, @@ -145,7 +177,7 @@ impl Vfs { path: path.clone(), kind, }; - let hash = content_hash(&text); + let hash = FileHash::from_text_source(&text); self.by_uri.insert(uri, id); self.by_path.insert(path, id); self.files.insert(id, FileRecord { meta, text, hash }); @@ -159,14 +191,14 @@ impl Vfs { /// (detected via hash comparison). /// /// Returns a tuple of (new global revision, whether content changed). - pub fn set_overlay(&self, id: FileId, new_text: Arc) -> Result<(Revision, bool)> { + pub(crate) fn set_overlay(&self, id: FileId, new_text: Arc) -> Result<(Revision, bool)> { let mut rec = self .files .get_mut(&id) .ok_or_else(|| anyhow!("unknown file: {:?}", id))?; let next = TextSource::Overlay(new_text); - let new_hash = content_hash(&next); - let changed = new_hash != rec.hash; + let new_hash = FileHash::from_text_source(&next); + let changed = new_hash.differs_from(rec.hash); if changed { rec.text = next; rec.hash = new_hash; @@ -183,7 +215,7 @@ impl Vfs { /// Materializes a consistent view of all files for downstream consumers. /// The snapshot includes the current revision and a clone of all file records. /// This operation is relatively cheap due to `Arc` sharing of text content. - pub fn snapshot(&self) -> VfsSnapshot { + pub(crate) fn snapshot(&self) -> VfsSnapshot { VfsSnapshot { revision: Revision::from_raw(self.head.load(Ordering::SeqCst)), files: self @@ -268,11 +300,11 @@ impl Vfs { .map_err(|e| anyhow!("Failed to read file {}: {}", path, e))?; let new_text = TextSource::Disk(Arc::from(content.as_str())); - let new_hash = content_hash(&new_text); + let new_hash = FileHash::from_text_source(&new_text); // Update the file if content changed if let Some(mut record) = self.files.get_mut(&file_id) { - if record.hash != new_hash { + if new_hash.differs_from(record.hash) { record.text = new_text; record.hash = new_hash; self.head.fetch_add(1, Ordering::SeqCst); @@ -301,28 +333,15 @@ impl Default for Vfs { } } -/// Compute a stable hash over file content. -/// -/// Used for efficient change detection - if the hash hasn't changed, -/// the content hasn't changed, avoiding unnecessary Salsa invalidations. -fn content_hash(src: &TextSource) -> u64 { - let s: &str = match src { - TextSource::Disk(s) | TextSource::Overlay(s) | TextSource::Generated(s) => s, - }; - let mut h = DefaultHasher::new(); - s.hash(&mut h); - h.finish() -} - /// Immutable snapshot view of the VFS at a specific revision. /// /// [`VfsSnapshot`] provides a consistent view of all files for downstream consumers, /// avoiding the need for locking during processing. Snapshots are created atomically /// and can be safely shared across threads. #[derive(Clone)] -pub struct VfsSnapshot { +pub(crate) struct VfsSnapshot { /// The global revision at the time of snapshot - pub revision: Revision, + revision: Revision, /// All files in the VFS at snapshot time pub files: HashMap, } diff --git a/crates/djls-workspace/src/watcher.rs b/crates/djls-workspace/src/vfs/watcher.rs similarity index 97% rename from crates/djls-workspace/src/watcher.rs rename to crates/djls-workspace/src/vfs/watcher.rs index 55fa9a7..aee0467 100644 --- a/crates/djls-workspace/src/watcher.rs +++ b/crates/djls-workspace/src/vfs/watcher.rs @@ -4,15 +4,21 @@ //! and synchronize them with the VFS. It uses cross-platform file watching with //! debouncing to handle rapid changes efficiently. -use anyhow::{anyhow, Result}; +use std::collections::HashMap; +use std::sync::mpsc; +use std::thread; +use std::time::Duration; +use std::time::Instant; + +use anyhow::anyhow; +use anyhow::Result; use camino::Utf8PathBuf; -use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; -use std::{ - collections::HashMap, - sync::mpsc, - thread, - time::{Duration, Instant}, -}; +use notify::Config; +use notify::Event; +use notify::EventKind; +use notify::RecommendedWatcher; +use notify::RecursiveMode; +use notify::Watcher; /// Event types that can occur in the file system. ///