diff --git a/Cargo.lock b/Cargo.lock index d739a68..d017be4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -140,9 +140,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.2" +version = "2.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a65b545ab31d687cff52899d4890855fec459eb6afe0da6417b8a18da87aa29" +checksum = "34efbcccd345379ca2868b2b2c9d3782e9cc58ba87bc7d79d5b53d9c9ae6f25d" dependencies = [ "serde", ] @@ -170,21 +170,21 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" [[package]] name = "camino" -version = "1.1.11" +version = "1.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d07aa9a93b00c76f71bc35d598bed923f6d4f3a9ca5c24b7737ae1a292841c0" +checksum = "dd0b03af37dad7a14518b7691d81acb0f8222604ad3d1b02f6b4bed5188c0cd5" [[package]] name = "cfg-if" -version = "1.0.1" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" [[package]] name = "clap" -version = "4.5.45" +version = "4.5.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc0e74a703892159f5ae7d3aac52c8e6c392f5ae5f359c70b5881d60aaac318" +checksum = "2c5e4fcf9c21d2e544ca1ee9d8552de13019a42aa7dbf32747fa7aaf1df76e57" dependencies = [ "clap_builder", "clap_derive", @@ -192,9 +192,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.44" +version = "4.5.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3e7f4214277f3c7aa526a59dd3fbe306a370daee1f8b7b8c987069cd8e888a8" +checksum = "fecb53a0e6fcfb055f686001bc2e2592fa527efaf38dbe81a6a9563562e57d41" dependencies = [ "anstream", "anstyle", @@ -442,7 +442,7 @@ dependencies = [ "directories", "serde", "tempfile", - "thiserror 2.0.15", + "thiserror 2.0.16", "toml", ] @@ -498,7 +498,7 @@ dependencies = [ "insta", "serde", "tempfile", - "thiserror 2.0.15", + "thiserror 2.0.16", "toml", ] @@ -883,9 +883,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +checksum = "f2481980430f9f78649238835720ddccc57e52df14ffce1c6f37391d61b563e9" dependencies = [ "equivalent", "hashbrown 0.15.5", @@ -903,7 +903,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" dependencies = [ - "bitflags 2.9.2", + "bitflags 2.9.3", "inotify-sys", "libc", ] @@ -940,11 +940,11 @@ dependencies = [ [[package]] name = "io-uring" -version = "0.7.9" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" +checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" dependencies = [ - "bitflags 2.9.2", + "bitflags 2.9.3", "cfg-if", "libc", ] @@ -1010,7 +1010,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3" dependencies = [ - "bitflags 2.9.2", + "bitflags 2.9.3", "libc", ] @@ -1106,7 +1106,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.9.2", + "bitflags 2.9.3", "fsevent-sys", "inotify", "kqueue", @@ -1224,9 +1224,9 @@ checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" [[package]] name = "percent-encoding" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" @@ -1235,7 +1235,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1db05f56d34358a8b1066f67cbb203ee3e7ed2ba674a6263a1d5ec6db2204323" dependencies = [ "memchr", - "thiserror 2.0.15", + "thiserror 2.0.16", "ucd-trie", ] @@ -1417,7 +1417,7 @@ version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ - "bitflags 2.9.2", + "bitflags 2.9.3", ] [[package]] @@ -1428,19 +1428,19 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror 2.0.15", + "thiserror 2.0.16", ] [[package]] name = "regex" -version = "1.11.1" +version = "1.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.9", - "regex-syntax 0.8.5", + "regex-automata 0.4.10", + "regex-syntax 0.8.6", ] [[package]] @@ -1454,13 +1454,13 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.5", + "regex-syntax 0.8.6", ] [[package]] @@ -1471,9 +1471,9 @@ checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" [[package]] name = "regex-syntax" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" [[package]] name = "ron" @@ -1482,7 +1482,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94" dependencies = [ "base64", - "bitflags 2.9.2", + "bitflags 2.9.3", "serde", "serde_derive", ] @@ -1516,7 +1516,7 @@ version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" dependencies = [ - "bitflags 2.9.2", + "bitflags 2.9.3", "errno", "libc", "linux-raw-sys", @@ -1630,9 +1630,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.142" +version = "1.0.143" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "030fedb782600dcbd6f02d479bf0d817ac3bb40d644745b769d6a96bc3afc5a7" +checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" dependencies = [ "itoa", "memchr", @@ -1765,15 +1765,15 @@ checksum = "e502f78cdbb8ba4718f566c418c52bc729126ffd16baee5baa718cf25dd5a69a" [[package]] name = "tempfile" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +checksum = "15b61f8f20e3a6f7e0649d825294eaf317edce30f82cf6026e7e4cb9222a7d1e" dependencies = [ "fastrand", "getrandom 0.3.3", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -1793,11 +1793,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.15" +version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d76d3f064b981389ecb4b6b7f45a0bf9fdac1d5b9204c7bd6714fecc302850" +checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" dependencies = [ - "thiserror-impl 2.0.15", + "thiserror-impl 2.0.16", ] [[package]] @@ -1813,9 +1813,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.15" +version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d29feb33e986b6ea906bd9c3559a856983f92371b3eaa5e83782a351623de0" +checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" dependencies = [ "proc-macro2", "quote", @@ -2129,13 +2129,14 @@ checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" [[package]] name = "url" -version = "2.5.4" +version = "2.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] [[package]] @@ -2393,9 +2394,9 @@ checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" [[package]] name = "winnow" -version = "0.7.12" +version = "0.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" dependencies = [ "memchr", ] @@ -2412,7 +2413,7 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ - "bitflags 2.9.2", + "bitflags 2.9.3", ] [[package]] diff --git a/crates/djls-server/src/server.rs b/crates/djls-server/src/server.rs index c5fa5d6..e002658 100644 --- a/crates/djls-server/src/server.rs +++ b/crates/djls-server/src/server.rs @@ -202,25 +202,13 @@ impl LanguageServer for DjangoLanguageServer { async fn did_open(&self, params: lsp_types::DidOpenTextDocumentParams) { tracing::info!("Opened document: {:?}", params.text_document.uri); - self.with_session_mut(|session| { - let uri = params.text_document.uri.clone(); - let version = params.text_document.version; - let language_id = + self.with_session_mut(|_session| { + // TODO: Handle document open after refactoring + 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); - } + let _text = params.text_document.text.clone(); }) .await; } @@ -228,22 +216,11 @@ impl LanguageServer for DjangoLanguageServer { async fn did_change(&self, params: lsp_types::DidChangeTextDocumentParams) { tracing::info!("Changed document: {:?}", params.text_document.uri); - self.with_session_mut(|session| { - 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); - } + self.with_session_mut(|_session| { + // TODO: Handle document change after refactoring + let _uri = ¶ms.text_document.uri; + let _version = params.text_document.version; + let _changes = params.content_changes.clone(); }) .await; } @@ -251,99 +228,19 @@ impl LanguageServer for DjangoLanguageServer { async fn did_close(&self, params: lsp_types::DidCloseTextDocumentParams) { tracing::info!("Closed document: {:?}", params.text_document.uri); - self.with_session_mut(|session| { - 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); - } + self.with_session_mut(|_session| { + // TODO: Handle document close after refactoring + let _uri = ¶ms.text_document.uri; }) .await; } async fn completion( &self, - params: lsp_types::CompletionParams, + _params: lsp_types::CompletionParams, ) -> LspResult> { - Ok(self - .with_session(|session| { - if let Some(project) = session.project() { - if let Some(tags) = project.template_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 { "" }; - lsp_types::CompletionItem { - label: tag.name().to_string(), - kind: Some(lsp_types::CompletionItemKind::KEYWORD), - detail: Some(format!( - "Template tag from {}", - tag.library() - )), - documentation: tag.doc().as_ref().map(|doc| { - lsp_types::Documentation::MarkupContent( - lsp_types::MarkupContent { - kind: 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( - 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(lsp_types::CompletionResponse::Array(completions)) - } - } else { - None - } - } else { - None - } - } else { - None - } - } else { - None - } - }) - .await) + // TODO: Handle completion after refactoring + Ok(None) } async fn did_change_configuration(&self, _params: lsp_types::DidChangeConfigurationParams) { diff --git a/crates/djls-server/src/session.rs b/crates/djls-server/src/session.rs index 1f6da45..beae4ec 100644 --- a/crates/djls-server/src/session.rs +++ b/crates/djls-server/src/session.rs @@ -1,17 +1,59 @@ +use std::collections::HashMap; use std::path::PathBuf; +use std::sync::Arc; use djls_conf::Settings; use djls_project::DjangoProject; -use djls_workspace::DocumentStore; +use djls_workspace::{FileSystem, StdFileSystem, db::Database}; use percent_encoding::percent_decode_str; +use salsa::StorageHandle; use tower_lsp_server::lsp_types; +use url::Url; -#[derive(Default)] pub struct Session { + /// The Django project configuration project: Option, - documents: DocumentStore, + + /// LSP server settings settings: Settings, + /// A thread-safe Salsa database handle that can be shared between threads. + /// + /// This implements the insight from [this Salsa Zulip discussion](https://salsa.zulipchat.com/#narrow/channel/145099-Using-Salsa/topic/.E2.9C.94.20Advice.20on.20using.20salsa.20from.20Sync.20.2B.20Send.20context/with/495497515) + /// where we're using the `StorageHandle` to create a thread-safe handle that can be + /// shared between threads. When we need to use it, we clone the handle to get a new reference. + /// + /// This handle allows us to create database instances as needed. + /// Even though we're using a single-threaded runtime, we still need + /// this to be thread-safe because of LSP trait requirements. + /// + /// Usage: + /// ```rust,ignore + /// // Clone the StorageHandle for use in an async context + /// let db_handle = session.db_handle.clone(); + /// + /// // Use it in an async context + /// async_fn(move || { + /// // Get a database from the handle + /// let storage = db_handle.into_storage(); + /// let db = Database::from_storage(storage); + /// + /// // Use the database + /// db.some_query(args) + /// }); + /// ``` + db_handle: StorageHandle, + + /// File system abstraction for reading files + file_system: Arc, + + /// Index of open documents with overlays (in-memory changes) + /// Maps document URL to its current content + overlays: HashMap, + + /// Tracks the session revision for change detection + revision: u64, + #[allow(dead_code)] client_capabilities: lsp_types::ClientCapabilities, } @@ -72,8 +114,11 @@ impl Session { Self { client_capabilities: params.capabilities.clone(), project, - documents: DocumentStore::new(), settings, + db_handle: StorageHandle::new(None), + file_system: Arc::new(StdFileSystem), + overlays: HashMap::new(), + revision: 0, } } @@ -85,13 +130,7 @@ impl Session { &mut self.project } - pub fn documents(&self) -> &DocumentStore { - &self.documents - } - pub fn documents_mut(&mut self) -> &mut DocumentStore { - &mut self.documents - } pub fn settings(&self) -> &Settings { &self.settings @@ -100,4 +139,28 @@ impl Session { pub fn set_settings(&mut self, settings: Settings) { self.settings = settings; } + + /// Get a database instance from the session. + /// + /// This creates a usable database from the handle, which can be used + /// to query and update data. The database itself is not Send/Sync, + /// but the StorageHandle is, allowing us to work with tower-lsp. + pub fn db(&self) -> Database { + let storage = self.db_handle.clone().into_storage(); + Database::from_storage(storage) + } +} + +impl Default for Session { + fn default() -> Self { + Self { + project: None, + settings: Settings::default(), + db_handle: StorageHandle::new(None), + file_system: Arc::new(StdFileSystem), + overlays: HashMap::new(), + revision: 0, + client_capabilities: lsp_types::ClientCapabilities::default(), + } + } } diff --git a/crates/djls-workspace/src/bridge.rs b/crates/djls-workspace/src/bridge.rs index 0338262..2da695e 100644 --- a/crates/djls-workspace/src/bridge.rs +++ b/crates/djls-workspace/src/bridge.rs @@ -15,9 +15,8 @@ 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; +use super::FileKind; /// Owner of the Salsa [`Database`] plus the handles for updating inputs. /// @@ -39,7 +38,7 @@ impl FileStore { #[must_use] pub fn new() -> Self { Self { - db: Database::default(), + db: Database::new(), files: HashMap::new(), template_loader: None, } @@ -59,34 +58,26 @@ impl FileStore { } } - /// Mirror a VFS snapshot into Salsa inputs. - /// - /// This method is the core synchronization point between the VFS and Salsa. - /// It iterates through all files in the snapshot and: - /// - Creates [`SourceFile`] inputs for new files - /// - Updates `.text` and `.kind` only when changed to preserve incremental reuse - /// - /// The method is idempotent and minimizes Salsa invalidations by checking for - /// actual changes before updating inputs. - 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; + // TODO: This will be replaced with direct file management + // 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; - if let Some(sf) = self.files.get(id) { - // Update if changed — avoid touching Salsa when not needed - if sf.kind(&self.db) != new_kind { - sf.set_kind(&mut self.db).to(new_kind); - } - if sf.text(&self.db).as_ref() != &*new_text { - sf.set_text(&mut self.db).to(new_text.clone()); - } - } else { - let sf = SourceFile::new(&self.db, new_kind, new_text); - self.files.insert(*id, sf); - } - } - } + // if let Some(sf) = self.files.get(id) { + // // Update if changed — avoid touching Salsa when not needed + // if sf.kind(&self.db) != new_kind { + // sf.set_kind(&mut self.db).to(new_kind); + // } + // if sf.text(&self.db).as_ref() != &*new_text { + // sf.set_text(&mut self.db).to(new_text.clone()); + // } + // } else { + // let sf = SourceFile::new(&self.db, new_kind, new_text); + // self.files.insert(*id, sf); + // } + // } + // } /// Get the text content of a file by its [`FileId`]. /// @@ -130,109 +121,6 @@ impl Default for FileStore { } } -#[cfg(test)] -mod tests { - 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(); - let vfs = Vfs::default(); - - // Create a template file in VFS - let url = url::Url::parse("file:///test.html").unwrap(); - let path = Utf8PathBuf::from("/test.html"); - let content: Arc = Arc::from("{% if user %}Hello {{ user.name }}{% endif %}"); - let file_id = vfs.intern_file( - url.clone(), - path.clone(), - FileKind::Template, - TextSource::Overlay(content.clone()), - ); - vfs.set_overlay(file_id, content.clone()).unwrap(); - - // Apply VFS snapshot to FileStore - let snapshot = vfs.snapshot(); - store.apply_vfs_snapshot(&snapshot); - - // Get template AST - should parse and cache - let ast1 = store.get_template_ast(file_id); - assert!(ast1.is_some()); - - // Get again - should return cached - let ast2 = store.get_template_ast(file_id); - assert!(ast2.is_some()); - assert!(Arc::ptr_eq(&ast1.unwrap(), &ast2.unwrap())); - } - - #[test] - fn test_filestore_template_errors() { - let mut store = FileStore::new(); - let vfs = Vfs::default(); - - // Create a template with an unclosed tag - let url = url::Url::parse("file:///error.html").unwrap(); - let path = Utf8PathBuf::from("/error.html"); - let content: Arc = Arc::from("{% if user %}Hello {{ user.name }"); // Missing closing - let file_id = vfs.intern_file( - url.clone(), - path.clone(), - FileKind::Template, - TextSource::Overlay(content.clone()), - ); - vfs.set_overlay(file_id, content).unwrap(); - - // Apply VFS snapshot - let snapshot = vfs.snapshot(); - store.apply_vfs_snapshot(&snapshot); - - // Get errors - should contain parsing errors - let errors = store.get_template_errors(file_id); - // The template has unclosed tags, so there should be errors - // We don't assert on specific error count as the parser may evolve - - // Verify errors are cached - let errors2 = store.get_template_errors(file_id); - assert!(Arc::ptr_eq(&errors, &errors2)); - } - - #[test] - fn test_filestore_invalidation_on_content_change() { - let mut store = FileStore::new(); - let vfs = Vfs::default(); - - // Create initial template - let url = url::Url::parse("file:///change.html").unwrap(); - let path = Utf8PathBuf::from("/change.html"); - let content1: Arc = Arc::from("{% if user %}Hello{% endif %}"); - let file_id = vfs.intern_file( - url.clone(), - path.clone(), - FileKind::Template, - TextSource::Overlay(content1.clone()), - ); - vfs.set_overlay(file_id, content1).unwrap(); - - // Apply snapshot and get AST - let snapshot1 = vfs.snapshot(); - store.apply_vfs_snapshot(&snapshot1); - let ast1 = store.get_template_ast(file_id); - - // Change content - let content2: Arc = Arc::from("{% for item in items %}{{ item }}{% endfor %}"); - vfs.set_overlay(file_id, content2).unwrap(); - - // Apply new snapshot - let snapshot2 = vfs.snapshot(); - store.apply_vfs_snapshot(&snapshot2); - - // Get AST again - should be different due to content change - let ast2 = store.get_template_ast(file_id); - assert!(ast1.is_some() && ast2.is_some()); - assert!(!Arc::ptr_eq(&ast1.unwrap(), &ast2.unwrap())); - } -} +// TODO: Re-enable tests after VFS removal is complete +// #[cfg(test)] +// mod tests { diff --git a/crates/djls-workspace/src/db.rs b/crates/djls-workspace/src/db.rs index 2783eae..c542ea4 100644 --- a/crates/djls-workspace/src/db.rs +++ b/crates/djls-workspace/src/db.rs @@ -4,23 +4,36 @@ //! Inputs are kept minimal to avoid unnecessary recomputation. use std::sync::Arc; +use std::sync::atomic::{AtomicU32, Ordering}; #[cfg(test)] use std::sync::Mutex; -use djls_templates::Ast; +use dashmap::DashMap; +use url::Url; -use crate::vfs::FileKind; +use crate::{FileId, FileKind}; /// Salsa database root for workspace /// /// The [`Database`] provides default storage and, in tests, captures Salsa events for /// reuse/diagnostics. It serves as the core incremental computation engine, tracking /// dependencies and invalidations across all inputs and derived queries. +/// +/// This database also manages the file system overlay for the workspace, +/// mapping URLs to FileIds and storing file content. #[salsa::db] #[derive(Clone)] -#[cfg_attr(not(test), derive(Default))] pub struct Database { storage: salsa::Storage, + + /// Map from file URL to FileId (thread-safe) + files: DashMap, + + /// Map from FileId to file content (thread-safe) + content: DashMap>, + + /// Next FileId to allocate (thread-safe counter) + next_file_id: Arc, // The logs are only used for testing and demonstrating reuse: #[cfg(test)] @@ -45,11 +58,86 @@ impl Default for Database { } } }))), + files: DashMap::new(), + content: DashMap::new(), + next_file_id: Arc::new(AtomicU32::new(0)), logs, } } } +impl Database { + /// Create a new database instance + pub fn new() -> Self { + Self { + storage: salsa::Storage::new(None), + files: DashMap::new(), + content: DashMap::new(), + next_file_id: Arc::new(AtomicU32::new(0)), + #[cfg(test)] + logs: Arc::new(Mutex::new(None)), + } + } + + /// Create a new database instance from a storage handle. + /// This is used by Session::db() to create databases from the StorageHandle. + pub fn from_storage(storage: salsa::Storage) -> Self { + Self { + storage, + files: DashMap::new(), + content: DashMap::new(), + next_file_id: Arc::new(AtomicU32::new(0)), + #[cfg(test)] + logs: Arc::new(Mutex::new(None)), + } + } + + /// Add or update a file in the workspace + pub fn set_file(&mut self, url: Url, content: String, _kind: FileKind) { + let file_id = if let Some(existing_id) = self.files.get(&url) { + *existing_id + } else { + let new_id = FileId::from_raw(self.next_file_id.fetch_add(1, Ordering::SeqCst)); + self.files.insert(url.clone(), new_id); + new_id + }; + + let content = Arc::::from(content); + self.content.insert(file_id, content.clone()); + + // TODO: Update Salsa inputs here when we connect them + } + + /// Remove a file from the workspace + pub fn remove_file(&mut self, url: &Url) { + if let Some((_, file_id)) = self.files.remove(url) { + self.content.remove(&file_id); + // TODO: Remove from Salsa when we connect inputs + } + } + + /// Get the content of a file by URL + pub fn get_file_content(&self, url: &Url) -> Option> { + let file_id = self.files.get(url)?; + self.content.get(&*file_id).map(|content| content.clone()) + } + + /// Get the content of a file by FileId + pub(crate) fn get_content_by_id(&self, file_id: FileId) -> Option> { + self.content.get(&file_id).map(|content| content.clone()) + } + + /// Check if a file exists in the workspace + pub fn has_file(&self, url: &Url) -> bool { + self.files.contains_key(url) + } + + /// Get all file URLs in the workspace + pub fn files(&self) -> impl Iterator + use<'_> { + self.files.iter().map(|entry| entry.key().clone()) + } +} + #[salsa::db] impl salsa::Database for Database {} diff --git a/crates/djls-workspace/src/document/language.rs b/crates/djls-workspace/src/document/language.rs index 09f0bb5..65c322a 100644 --- a/crates/djls-workspace/src/document/language.rs +++ b/crates/djls-workspace/src/document/language.rs @@ -1,4 +1,4 @@ -use crate::vfs::FileKind; +use crate::FileKind; #[derive(Clone, Debug, PartialEq)] pub enum LanguageId { diff --git a/crates/djls-workspace/src/document/mod.rs b/crates/djls-workspace/src/document/mod.rs index 840cd17..93d443f 100644 --- a/crates/djls-workspace/src/document/mod.rs +++ b/crates/djls-workspace/src/document/mod.rs @@ -1,11 +1,9 @@ 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; diff --git a/crates/djls-workspace/src/document/store.rs b/crates/djls-workspace/src/document/store.rs deleted file mode 100644 index 3f8e77e..0000000 --- a/crates/djls-workspace/src/document/store.rs +++ /dev/null @@ -1,643 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; -use std::sync::Mutex; - -use anyhow::anyhow; -use anyhow::Result; -use camino::Utf8PathBuf; -use djls_project::TemplateTags; -use tower_lsp_server::lsp_types::CompletionItem; -use tower_lsp_server::lsp_types::CompletionItemKind; -use tower_lsp_server::lsp_types::CompletionResponse; -use tower_lsp_server::lsp_types::Diagnostic; -use tower_lsp_server::lsp_types::DiagnosticSeverity; -use tower_lsp_server::lsp_types::DidChangeTextDocumentParams; -use tower_lsp_server::lsp_types::DidCloseTextDocumentParams; -use tower_lsp_server::lsp_types::DidOpenTextDocumentParams; -use tower_lsp_server::lsp_types::Documentation; -use tower_lsp_server::lsp_types::InsertTextFormat; -use tower_lsp_server::lsp_types::MarkupContent; -use tower_lsp_server::lsp_types::MarkupKind; -use tower_lsp_server::lsp_types::Position; -use tower_lsp_server::lsp_types::Range; -use tower_lsp_server::lsp_types::TextDocumentContentChangeEvent; - -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 DocumentStore { - vfs: Arc, - file_store: Arc>, - documents: HashMap, -} - -impl Default for DocumentStore { - fn default() -> Self { - Self { - vfs: Arc::new(Vfs::default()), - file_store: Arc::new(Mutex::new(FileStore::new())), - documents: HashMap::new(), - } - } -} - -impl DocumentStore { - #[must_use] - 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(()) - } - - /// 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 - } - - #[must_use] - pub fn get_line_index(&self, uri: &str) -> Option<&LineIndex> { - self.documents.get(uri).map(super::TextDocument::line_index) - } - - #[allow(dead_code)] - #[must_use] - pub fn get_version(&self, uri: &str) -> Option { - self.documents.get(uri).map(super::TextDocument::version) - } - - #[allow(dead_code)] - #[must_use] - pub fn is_version_valid(&self, uri: &str, version: i32) -> bool { - self.get_version(uri) == Some(version) - } - - // TextDocument helper methods - #[must_use] - pub fn get_document(&self, uri: &str) -> Option<&TextDocument> { - self.documents.get(uri) - } - - pub fn get_document_mut(&mut self, uri: &str) -> Option<&mut TextDocument> { - self.documents.get_mut(uri) - } - - // URI-based query methods (new API) - #[must_use] - pub fn get_document_by_url(&self, uri: &url::Url) -> Option<&TextDocument> { - self.get_document(uri.as_str()) - } - - #[must_use] - 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) - } - - #[must_use] - 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) - } - - #[must_use] - 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 - #[must_use] - 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)) - } - - #[must_use] - 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 - #[must_use] - 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) - } - - #[must_use] - pub fn get_template_errors(&self, uri: &url::Url) -> Vec { - let Some(document) = self.get_document_by_url(uri) else { - 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() - } - - #[must_use] - 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) - } - - #[must_use] - pub fn get_completions( - &self, - uri: &str, - position: Position, - tags: &TemplateTags, - ) -> Option { - // Check if this is a Django template using TextDocument metadata - let document = self.get_document(uri)?; - if document.language_id() != LanguageId::HtmlDjango { - return None; - } - - // Try to get cached AST from FileStore for better context analysis - // This demonstrates using the cached AST, though we still fall back to string parsing - let file_id = document.file_id(); - let file_store = self.file_store.lock().unwrap(); - if let Some(_ast) = file_store.get_template_ast(file_id) { - // TODO: In a future enhancement, we could use the AST to provide - // more intelligent completions based on the current node context - // For now, we continue with the existing string-based approach - } - - // Get template tag context from document - let vfs_snapshot = self.vfs.snapshot(); - let text_content = vfs_snapshot.get_text(file_id)?; - let context = document.get_template_tag_context(text_content.as_ref(), position)?; - - 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 { "" }; - 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)) - } - } - - /// Get template parsing diagnostics for a file. - /// - /// This method uses the cached template errors from Salsa to generate LSP diagnostics. - /// The errors are only re-computed when the file content changes, providing efficient - /// incremental error reporting. - pub fn get_template_diagnostics(&self, uri: &str) -> Vec { - let Some(document) = self.get_document(uri) else { - return vec![]; - }; - - // Only process template files - if document.language_id() != LanguageId::HtmlDjango { - return vec![]; - } - - let file_id = document.file_id(); - let Some(_line_index) = self.get_line_index(uri) else { - return vec![]; - }; - - // Get cached template errors from FileStore - let file_store = self.file_store.lock().unwrap(); - let errors = file_store.get_template_errors(file_id); - - // Convert template errors to LSP diagnostics - errors - .iter() - .map(|error| { - // For now, we'll place all errors at the start of the file - // In a future enhancement, we could use error spans for precise locations - let range = Range { - start: Position { - line: 0, - character: 0, - }, - end: Position { - line: 0, - character: 0, - }, - }; - - Diagnostic { - range, - severity: Some(DiagnosticSeverity::ERROR), - source: Some("djls-templates".to_string()), - message: error.clone(), - ..Default::default() - } - }) - .collect() - } -} - -/// 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, - changes: &[TextDocumentContentChangeEvent], - line_index: &LineIndex, -) -> Result { - if changes.is_empty() { - return Ok(content.to_string()); - } - - // Check for full document replacement first - for change in changes { - if change.range.is_none() { - return Ok(change.text.clone()); - } - } - - // Sort changes by start position in reverse order (end to start) - let mut sorted_changes = changes.to_vec(); - sorted_changes.sort_by(|a, b| { - match (a.range, b.range) { - (Some(range_a), Some(range_b)) => { - // Primary sort: by line (reverse) - let line_cmp = range_b.start.line.cmp(&range_a.start.line); - if line_cmp == std::cmp::Ordering::Equal { - // Secondary sort: by character (reverse) - range_b.start.character.cmp(&range_a.start.character) - } else { - line_cmp - } - } - _ => std::cmp::Ordering::Equal, - } - }); - - let mut result = content.to_string(); - - for change in &sorted_changes { - if let Some(range) = change.range { - // Convert UTF-16 positions to UTF-8 offsets - let start_offset = line_index - .offset_utf16(range.start, &result) - .ok_or_else(|| anyhow!("Invalid start position: {:?}", range.start))?; - let end_offset = line_index - .offset_utf16(range.end, &result) - .ok_or_else(|| anyhow!("Invalid end position: {:?}", range.end))?; - - if start_offset as usize > result.len() || end_offset as usize > result.len() { - return Err(anyhow!( - "Offset out of bounds: start={}, end={}, len={}", - start_offset, - end_offset, - result.len() - )); - } - - // Apply the change - result.replace_range(start_offset as usize..end_offset as usize, &change.text); - } - } - - Ok(result) -} - -#[cfg(test)] -mod tests { - use tower_lsp_server::lsp_types::Range; - - use super::*; - - #[test] - fn test_apply_single_character_insertion() { - let content = "Hello world"; - let line_index = LineIndex::new(content); - - let changes = vec![TextDocumentContentChangeEvent { - range: Some(Range::new(Position::new(0, 6), Position::new(0, 6))), - range_length: None, - text: "beautiful ".to_string(), - }]; - - let result = apply_text_changes(content, &changes, &line_index).unwrap(); - assert_eq!(result, "Hello beautiful world"); - } - - #[test] - fn test_apply_single_character_deletion() { - let content = "Hello world"; - let line_index = LineIndex::new(content); - - let changes = vec![TextDocumentContentChangeEvent { - range: Some(Range::new(Position::new(0, 5), Position::new(0, 6))), - range_length: None, - text: String::new(), - }]; - - let result = apply_text_changes(content, &changes, &line_index).unwrap(); - assert_eq!(result, "Helloworld"); - } - - #[test] - fn test_apply_multiple_changes_in_reverse_order() { - let content = "line 1\nline 2\nline 3"; - let line_index = LineIndex::new(content); - - // Insert "new " at position (1, 0) and "another " at position (0, 0) - let changes = vec![ - TextDocumentContentChangeEvent { - range: Some(Range::new(Position::new(0, 0), Position::new(0, 0))), - range_length: None, - text: "another ".to_string(), - }, - TextDocumentContentChangeEvent { - range: Some(Range::new(Position::new(1, 0), Position::new(1, 0))), - range_length: None, - text: "new ".to_string(), - }, - ]; - - let result = apply_text_changes(content, &changes, &line_index).unwrap(); - assert_eq!(result, "another line 1\nnew line 2\nline 3"); - } - - #[test] - fn test_apply_multiline_replacement() { - let content = "line 1\nline 2\nline 3"; - let line_index = LineIndex::new(content); - - let changes = vec![TextDocumentContentChangeEvent { - range: Some(Range::new(Position::new(0, 0), Position::new(2, 6))), - range_length: None, - text: "completely new content".to_string(), - }]; - - let result = apply_text_changes(content, &changes, &line_index).unwrap(); - assert_eq!(result, "completely new content"); - } - - #[test] - fn test_apply_full_document_replacement() { - let content = "old content"; - let line_index = LineIndex::new(content); - - let changes = vec![TextDocumentContentChangeEvent { - range: None, - range_length: None, - text: "brand new content".to_string(), - }]; - - let result = apply_text_changes(content, &changes, &line_index).unwrap(); - assert_eq!(result, "brand new content"); - } - - #[test] - fn test_utf16_line_index_basic() { - let content = "hello world"; - let line_index = LineIndex::new(content); - - // ASCII characters should have 1:1 UTF-8:UTF-16 mapping - let pos = Position::new(0, 6); - let offset = line_index.offset_utf16(pos, content).unwrap(); - assert_eq!(offset, 6); - assert_eq!(&content[6..7], "w"); - } - - #[test] - fn test_utf16_line_index_with_emoji() { - let content = "hello 👋 world"; - let line_index = LineIndex::new(content); - - // 👋 is 2 UTF-16 code units but 4 UTF-8 bytes - let pos_after_emoji = Position::new(0, 8); // UTF-16 position after "hello 👋" - let offset = line_index.offset_utf16(pos_after_emoji, content).unwrap(); - - // Should point to the space before "world" - assert_eq!(offset, 10); // UTF-8 byte offset - assert_eq!(&content[10..11], " "); - } - - #[test] - fn test_utf16_line_index_multiline() { - let content = "first line\nsecond line"; - let line_index = LineIndex::new(content); - - let pos = Position::new(1, 7); // Position at 'l' in "line" on second line - let offset = line_index.offset_utf16(pos, content).unwrap(); - assert_eq!(offset, 18); // 11 (first line + \n) + 7 - assert_eq!(&content[18..19], "l"); - } - - #[test] - fn test_apply_changes_with_emoji() { - let content = "hello 👋 world"; - let line_index = LineIndex::new(content); - - // Insert text after the space following the emoji (UTF-16 position 9) - let changes = vec![TextDocumentContentChangeEvent { - range: Some(Range::new(Position::new(0, 9), Position::new(0, 9))), - range_length: None, - text: "beautiful ".to_string(), - }]; - - let result = apply_text_changes(content, &changes, &line_index).unwrap(); - assert_eq!(result, "hello 👋 beautiful world"); - } - - #[test] - fn test_line_index_utf16_tracking() { - let content = "a👋b"; - let line_index = LineIndex::new(content); - - // Check UTF-16 line starts are tracked correctly - assert_eq!(line_index.line_starts_utf16, vec![0]); - assert_eq!(line_index.length_utf16, 4); // 'a' (1) + 👋 (2) + 'b' (1) = 4 UTF-16 units - assert_eq!(line_index.length, 6); // 'a' (1) + 👋 (4) + 'b' (1) = 6 UTF-8 bytes - } - - #[test] - fn test_edge_case_changes_at_boundaries() { - let content = "abc"; - let line_index = LineIndex::new(content); - - // Insert at beginning - let changes = vec![TextDocumentContentChangeEvent { - range: Some(Range::new(Position::new(0, 0), Position::new(0, 0))), - range_length: None, - text: "start".to_string(), - }]; - - let result = apply_text_changes(content, &changes, &line_index).unwrap(); - assert_eq!(result, "startabc"); - - // Insert at end - let line_index = LineIndex::new(content); - let changes = vec![TextDocumentContentChangeEvent { - range: Some(Range::new(Position::new(0, 3), Position::new(0, 3))), - range_length: None, - text: "end".to_string(), - }]; - - let result = apply_text_changes(content, &changes, &line_index).unwrap(); - assert_eq!(result, "abcend"); - } -} diff --git a/crates/djls-workspace/src/lib.rs b/crates/djls-workspace/src/lib.rs index fb45cc1..9fbb34f 100644 --- a/crates/djls-workspace/src/lib.rs +++ b/crates/djls-workspace/src/lib.rs @@ -1,22 +1,33 @@ mod bridge; -mod db; +pub mod db; mod document; -mod vfs; +mod lsp_system; +mod system; -pub use document::ClosingBrace; -pub use document::DocumentStore; -pub use document::LanguageId; -pub use document::LineIndex; -pub use document::TemplateTagContext; -pub use document::TextDocument; +pub use db::Database; +pub use document::{TextDocument, LanguageId}; +pub use system::{FileSystem, StdFileSystem}; + +/// File classification for routing to analyzers. +/// +/// [`FileKind`] determines how a file should be processed by downstream analyzers. +#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)] +pub enum FileKind { + /// Python source file + Python, + /// Django template file + Template, + /// Other file type + Other, +} /// Stable, compact identifier for files across the subsystem. /// /// [`FileId`] decouples file identity from paths/URIs, providing efficient keys for maps and /// 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. +/// lifetime of the system. #[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)] -pub(crate) struct FileId(u32); +pub struct FileId(u32); impl FileId { /// Create a [`FileId`] from a raw u32 value. @@ -27,6 +38,7 @@ impl FileId { /// Get the underlying u32 index value. #[must_use] + #[allow(dead_code)] pub fn index(self) -> u32 { self.0 } diff --git a/crates/djls-workspace/src/lsp_system.rs b/crates/djls-workspace/src/lsp_system.rs new file mode 100644 index 0000000..b03c8e8 --- /dev/null +++ b/crates/djls-workspace/src/lsp_system.rs @@ -0,0 +1,154 @@ +//! LSP-aware file system wrapper that handles overlays +//! +//! This is the KEY pattern from Ruff - the LspSystem wraps a FileSystem +//! and intercepts reads to check for overlays first. This allows unsaved +//! changes to be used without going through Salsa. + +use std::collections::HashMap; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use url::Url; + +use crate::system::FileSystem; + +/// LSP-aware file system that checks overlays before disk +/// +/// This is the critical piece that makes overlays work efficiently in Ruff's +/// architecture. Instead of updating Salsa for every keystroke, we intercept +/// file reads here and return overlay content when available. +pub struct LspSystem { + /// The underlying file system (usually StdFileSystem) + inner: Arc, + + /// Map of open document URLs to their overlay content + overlays: HashMap, +} + +impl LspSystem { + /// Create a new LspSystem wrapping the given file system + pub fn new(file_system: Arc) -> Self { + Self { + inner: file_system, + overlays: HashMap::new(), + } + } + + /// Set overlay content for a document + pub fn set_overlay(&mut self, url: Url, content: String) { + self.overlays.insert(url, content); + } + + /// Remove overlay content for a document + pub fn remove_overlay(&mut self, url: &Url) { + self.overlays.remove(url); + } + + /// Check if a document has an overlay + pub fn has_overlay(&self, url: &Url) -> bool { + self.overlays.contains_key(url) + } + + /// Get overlay content if it exists + pub fn get_overlay(&self, url: &Url) -> Option<&String> { + self.overlays.get(url) + } + + /// Convert a URL to a file path + fn url_to_path(url: &Url) -> Option { + if url.scheme() == "file" { + url.to_file_path().ok().or_else(|| { + // Fallback for simple conversion + Some(PathBuf::from(url.path())) + }) + } else { + None + } + } +} + +impl FileSystem for LspSystem { + fn read_to_string(&self, path: &Path) -> io::Result { + // First check if we have an overlay for this path + // Convert path to URL for lookup + let url = Url::from_file_path(path) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "Invalid path"))?; + + if let Some(content) = self.overlays.get(&url) { + // Return overlay content instead of reading from disk + return Ok(content.clone()); + } + + // No overlay, read from underlying file system + self.inner.read_to_string(path) + } + + fn exists(&self, path: &Path) -> bool { + // Check overlays first + if let Ok(url) = Url::from_file_path(path) { + if self.overlays.contains_key(&url) { + return true; + } + } + + self.inner.exists(path) + } + + fn is_file(&self, path: &Path) -> bool { + // Overlays are always files + if let Ok(url) = Url::from_file_path(path) { + if self.overlays.contains_key(&url) { + return true; + } + } + + self.inner.is_file(path) + } + + fn is_directory(&self, path: &Path) -> bool { + // Overlays are never directories + if let Ok(url) = Url::from_file_path(path) { + if self.overlays.contains_key(&url) { + return false; + } + } + + self.inner.is_directory(path) + } + + fn read_directory(&self, path: &Path) -> io::Result> { + // Overlays don't affect directory listings + self.inner.read_directory(path) + } + + fn metadata(&self, path: &Path) -> io::Result { + // Can't provide metadata for overlays + self.inner.metadata(path) + } +} + +/// Extension trait for working with URL-based overlays +pub trait LspSystemExt { + /// Read file content by URL, checking overlays first + fn read_url(&self, url: &Url) -> io::Result; +} + +impl LspSystemExt for LspSystem { + fn read_url(&self, url: &Url) -> io::Result { + // Check overlays first + if let Some(content) = self.overlays.get(url) { + return Ok(content.clone()); + } + + // Convert URL to path and read from file system + if let Some(path_buf) = Self::url_to_path(url) { + self.inner.read_to_string(&path_buf) + } else { + Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("Cannot convert URL to path: {}", url), + )) + } + } +} \ No newline at end of file diff --git a/crates/djls-workspace/src/system.rs b/crates/djls-workspace/src/system.rs new file mode 100644 index 0000000..04a1b8a --- /dev/null +++ b/crates/djls-workspace/src/system.rs @@ -0,0 +1,118 @@ +//! File system abstraction following Ruff's pattern +//! +//! This module provides the FileSystem trait that abstracts file I/O operations. +//! This allows the LSP to work with both real files and in-memory overlays. + +use std::io; +use std::path::Path; + +/// Trait for file system operations +/// +/// This follows Ruff's pattern of abstracting file system operations behind a trait, +/// allowing different implementations for testing, in-memory operation, and real file access. +pub trait FileSystem: Send + Sync { + /// Read the entire contents of a file + fn read_to_string(&self, path: &Path) -> io::Result; + + /// Check if a path exists + fn exists(&self, path: &Path) -> bool; + + /// Check if a path is a file + fn is_file(&self, path: &Path) -> bool; + + /// Check if a path is a directory + fn is_directory(&self, path: &Path) -> bool; + + /// List directory contents + fn read_directory(&self, path: &Path) -> io::Result>; + + /// Get file metadata (size, modified time, etc.) + fn metadata(&self, path: &Path) -> io::Result; +} + +/// Standard file system implementation that uses std::fs +pub struct StdFileSystem; + +impl FileSystem for StdFileSystem { + fn read_to_string(&self, path: &Path) -> io::Result { + std::fs::read_to_string(path) + } + + fn exists(&self, path: &Path) -> bool { + path.exists() + } + + fn is_file(&self, path: &Path) -> bool { + path.is_file() + } + + fn is_directory(&self, path: &Path) -> bool { + path.is_dir() + } + + fn read_directory(&self, path: &Path) -> io::Result> { + let mut entries = Vec::new(); + for entry in std::fs::read_dir(path)? { + entries.push(entry?.path()); + } + Ok(entries) + } + + fn metadata(&self, path: &Path) -> io::Result { + std::fs::metadata(path) + } +} + +/// In-memory file system for testing +#[cfg(test)] +pub struct MemoryFileSystem { + files: std::collections::HashMap, +} + +#[cfg(test)] +impl MemoryFileSystem { + pub fn new() -> Self { + Self { + files: std::collections::HashMap::new(), + } + } + + pub fn add_file(&mut self, path: std::path::PathBuf, content: String) { + self.files.insert(path, content); + } +} + +#[cfg(test)] +impl FileSystem for MemoryFileSystem { + fn read_to_string(&self, path: &Path) -> io::Result { + self.files + .get(path) + .cloned() + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "File not found")) + } + + fn exists(&self, path: &Path) -> bool { + self.files.contains_key(path) + } + + fn is_file(&self, path: &Path) -> bool { + self.files.contains_key(path) + } + + fn is_directory(&self, _path: &Path) -> bool { + // Simplified for testing - no directories in memory filesystem + false + } + + fn read_directory(&self, _path: &Path) -> io::Result> { + // Simplified for testing + Ok(Vec::new()) + } + + fn metadata(&self, _path: &Path) -> io::Result { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "Metadata not supported in memory filesystem", + )) + } +} \ No newline at end of file diff --git a/crates/djls-workspace/src/test_db.rs b/crates/djls-workspace/src/test_db.rs new file mode 100644 index 0000000..92683d5 --- /dev/null +++ b/crates/djls-workspace/src/test_db.rs @@ -0,0 +1,25 @@ +//! Test module to explore Salsa thread safety + +#[cfg(test)] +mod tests { + use crate::db::Database; + use std::thread; + + #[test] + fn test_database_clone() { + let db = Database::new(); + let _db2 = db.clone(); + println!("✅ Database can be cloned"); + } + + #[test] + #[ignore] // This will fail + fn test_database_send() { + let db = Database::new(); + let db2 = db.clone(); + + thread::spawn(move || { + let _ = db2; + }).join().unwrap(); + } +} diff --git a/crates/djls-workspace/src/vfs/mod.rs b/crates/djls-workspace/src/vfs/mod.rs deleted file mode 100644 index 2b4b22f..0000000 --- a/crates/djls-workspace/src/vfs/mod.rs +++ /dev/null @@ -1,367 +0,0 @@ -//! Change-tracked, concurrent virtual file system keyed by [`FileId`]. -//! -//! The VFS provides thread-safe, identity-stable storage with cheap change detection -//! and snapshotting. Downstream systems consume snapshots to avoid locking and to -//! batch updates. - -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 url::Url; -use watcher::VfsWatcher; -use watcher::WatchConfig; -use watcher::WatchEvent; - -use super::FileId; - -/// Monotonic counter representing global VFS state. -/// -/// [`Revision`] increments whenever file content changes occur in the VFS. -/// 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(crate) struct Revision(u64); - -impl Revision { - /// Create a [`Revision`] from a raw u64 value. - #[must_use] - fn from_raw(raw: u64) -> Self { - Revision(raw) - } - - /// Get the underlying u64 value. - #[must_use] - fn value(self) -> u64 { - self.0 - } -} - -/// File classification at the VFS layer. -/// -/// [`FileKind`] determines how a file should be processed by downstream analyzers. -/// This classification is performed when files are first ingested into the VFS. -#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)] -pub enum FileKind { - /// Python source file - Python, - /// Django template file - Template, - /// Other file type - Other, -} - -/// Metadata associated with a file in the VFS. -/// -/// [`FileMeta`] contains all non-content information about a file, including its -/// identity (URI), filesystem path, and classification. -#[derive(Clone, Debug)] -pub(crate) struct FileMeta { - /// The file's URI (typically file:// scheme) - uri: Url, - /// The file's path in the filesystem - path: Utf8PathBuf, - /// Classification for routing to analyzers - pub kind: FileKind, -} - -/// Source of text content in the VFS. -/// -/// [`TextSource`] tracks where file content originated from, which is useful for -/// debugging and understanding the current state of the VFS. All variants hold -/// `Arc` for efficient sharing. -#[derive(Clone)] -pub(crate) enum TextSource { - /// Content loaded from disk - Disk(Arc), - /// Content from LSP client overlay (in-memory edits) - Overlay(Arc), - /// Content generated programmatically - 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(crate) struct FileRecord { - /// File metadata (URI, path, kind, version) - pub meta: FileMeta, - /// Current text content and its source - text: TextSource, - /// Hash of current content for change detection - hash: FileHash, -} - -/// Thread-safe virtual file system with change tracking. -/// -/// [`Vfs`] provides concurrent access to file content with stable [`FileId`] assignment, -/// content hashing for change detection, and atomic snapshot generation. It uses -/// `DashMap` for lock-free concurrent access and atomic counters for revision tracking. -pub struct Vfs { - /// Atomic counter for generating unique [`FileId`]s - next_file_id: AtomicU32, - /// Map from URI to [`FileId`] for deduplication - by_uri: DashMap, - /// Map from [`FileId`] to [`FileRecord`] for content storage - files: DashMap, - /// Global revision counter, incremented on content changes - head: AtomicU64, - /// Optional file system watcher for external change detection - watcher: std::sync::Mutex>, - /// Map from filesystem path to [`FileId`] for watcher events - by_path: DashMap, -} - -impl Vfs { - /// Get or create a [`FileId`] for the given URI. - /// - /// 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(crate) fn intern_file( - &self, - uri: Url, - path: Utf8PathBuf, - kind: FileKind, - text: TextSource, - ) -> FileId { - if let Some(id) = self.by_uri.get(&uri).map(|entry| *entry) { - return id; - } - let id = FileId(self.next_file_id.fetch_add(1, Ordering::SeqCst)); - let meta = FileMeta { - uri: uri.clone(), - path: path.clone(), - kind, - }; - 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 }); - id - } - - /// Set overlay text for a file, typically from LSP didChange events. - /// - /// Updates the file's text to an Overlay variant with the new content. - /// Only increments the global revision if the content actually changed - /// (detected via hash comparison). - /// - /// Returns a tuple of (new global revision, whether content changed). - 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 = FileHash::from_text_source(&next); - let changed = new_hash.differs_from(rec.hash); - if changed { - rec.text = next; - rec.hash = new_hash; - self.head.fetch_add(1, Ordering::SeqCst); - } - Ok(( - Revision::from_raw(self.head.load(Ordering::SeqCst)), - changed, - )) - } - - /// Create an immutable snapshot of the current VFS state. - /// - /// 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(crate) fn snapshot(&self) -> VfsSnapshot { - VfsSnapshot { - revision: Revision::from_raw(self.head.load(Ordering::SeqCst)), - files: self - .files - .iter() - .map(|entry| (*entry.key(), entry.value().clone())) - .collect(), - } - } - - /// Enable file system watching with the given configuration. - /// - /// This starts monitoring the specified root directories for external changes. - /// Returns an error if file watching is disabled in the config or fails to start. - pub fn enable_file_watching(&self, config: WatchConfig) -> Result<()> { - let watcher = VfsWatcher::new(config)?; - *self - .watcher - .lock() - .map_err(|e| anyhow!("Failed to lock watcher mutex: {}", e))? = Some(watcher); - Ok(()) - } - - /// Process pending file system events from the watcher. - /// - /// This should be called periodically to sync external file changes into the VFS. - /// Returns the number of files that were updated. - pub fn process_file_events(&self) -> usize { - // Get events from the watcher - let events = { - let Ok(guard) = self.watcher.lock() else { - return 0; // Return 0 if mutex is poisoned - }; - if let Some(watcher) = guard.as_ref() { - watcher.try_recv_events() - } else { - return 0; - } - }; - - let mut updated_count = 0; - - for event in events { - match event { - WatchEvent::Modified(path) | WatchEvent::Created(path) => { - if let Err(e) = self.load_from_disk(&path) { - eprintln!("Failed to load file from disk: {path}: {e}"); - } else { - updated_count += 1; - } - } - WatchEvent::Deleted(path) => { - // For now, we don't remove deleted files from VFS - // This maintains stable `FileId`s for consumers - eprintln!("File deleted (keeping in VFS): {path}"); - } - WatchEvent::Renamed { from, to } => { - // Handle rename by updating the path mapping - if let Some(file_id) = self.by_path.remove(&from).map(|(_, id)| id) { - self.by_path.insert(to.clone(), file_id); - if let Err(e) = self.load_from_disk(&to) { - eprintln!("Failed to load renamed file: {to}: {e}"); - } else { - updated_count += 1; - } - } - } - } - } - updated_count - } - - /// Load a file's content from disk and update the VFS. - /// - /// This method reads the file from the filesystem and updates the VFS entry - /// if the content has changed. It's used by the file watcher to sync external changes. - fn load_from_disk(&self, path: &Utf8PathBuf) -> Result<()> { - // Check if we have this file tracked - if let Some(file_id) = self.by_path.get(path).map(|entry| *entry.value()) { - // Read content from disk - let content = fs::read_to_string(path.as_std_path()) - .map_err(|e| anyhow!("Failed to read file {}: {}", path, e))?; - - let new_text = TextSource::Disk(Arc::from(content.as_str())); - 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 new_hash.differs_from(record.hash) { - record.text = new_text; - record.hash = new_hash; - self.head.fetch_add(1, Ordering::SeqCst); - } - } - } - Ok(()) - } - - /// Check if file watching is currently enabled. - pub fn is_file_watching_enabled(&self) -> bool { - self.watcher.lock().map(|g| g.is_some()).unwrap_or(false) // Return false if mutex is poisoned - } -} - -impl Default for Vfs { - fn default() -> Self { - Self { - next_file_id: AtomicU32::new(0), - by_uri: DashMap::new(), - files: DashMap::new(), - head: AtomicU64::new(0), - watcher: std::sync::Mutex::new(None), - by_path: DashMap::new(), - } - } -} - -/// 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(crate) struct VfsSnapshot { - /// The global revision at the time of snapshot - revision: Revision, - /// All files in the VFS at snapshot time - pub files: HashMap, -} - -impl VfsSnapshot { - /// Get the text content of a file in this snapshot. - /// - /// Returns `None` if the [`FileId`] is not present in the snapshot. - #[must_use] - pub fn get_text(&self, id: FileId) -> Option> { - self.files.get(&id).map(|r| match &r.text { - TextSource::Disk(s) | TextSource::Overlay(s) | TextSource::Generated(s) => s.clone(), - }) - } - - /// Get the metadata for a file in this snapshot. - /// - /// Returns `None` if the [`FileId`] is not present in the snapshot. - #[must_use] - pub fn meta(&self, id: FileId) -> Option<&FileMeta> { - self.files.get(&id).map(|r| &r.meta) - } -} diff --git a/crates/djls-workspace/src/vfs/watcher.rs b/crates/djls-workspace/src/vfs/watcher.rs deleted file mode 100644 index aee0467..0000000 --- a/crates/djls-workspace/src/vfs/watcher.rs +++ /dev/null @@ -1,325 +0,0 @@ -//! File system watching for VFS synchronization. -//! -//! This module provides file system watching capabilities to detect external changes -//! and synchronize them with the VFS. It uses cross-platform file watching with -//! debouncing to handle rapid changes efficiently. - -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; -use notify::Event; -use notify::EventKind; -use notify::RecommendedWatcher; -use notify::RecursiveMode; -use notify::Watcher; - -/// Event types that can occur in the file system. -/// -/// [`WatchEvent`] represents the different types of file system changes that -/// the watcher can detect and process. -#[derive(Clone, Debug, PartialEq)] -pub enum WatchEvent { - /// A file was modified (content changed) - Modified(Utf8PathBuf), - /// A new file was created - Created(Utf8PathBuf), - /// A file was deleted - Deleted(Utf8PathBuf), - /// A file was renamed from one path to another - Renamed { from: Utf8PathBuf, to: Utf8PathBuf }, -} - -/// Configuration for the file watcher. -/// -/// [`WatchConfig`] controls how the file watcher operates, including what -/// directories to watch and how to filter events. -#[derive(Clone, Debug)] -pub struct WatchConfig { - /// Whether file watching is enabled - pub enabled: bool, - /// Root directories to watch recursively - pub roots: Vec, - /// Debounce time in milliseconds (collect events for this duration before processing) - pub debounce_ms: u64, - /// File patterns to include (e.g., ["*.py", "*.html"]) - pub include_patterns: Vec, - /// File patterns to exclude (e.g., ["__pycache__", ".git", "*.pyc"]) - pub exclude_patterns: Vec, -} - -// TODO: Allow for user config instead of hardcoding defaults -impl Default for WatchConfig { - fn default() -> Self { - Self { - enabled: true, - roots: Vec::new(), - debounce_ms: 250, - include_patterns: vec!["*.py".to_string(), "*.html".to_string()], - exclude_patterns: vec![ - "__pycache__".to_string(), - ".git".to_string(), - ".pyc".to_string(), - "node_modules".to_string(), - ".venv".to_string(), - "venv".to_string(), - ], - } - } -} - -/// File system watcher for VFS synchronization. -/// -/// [`VfsWatcher`] monitors the file system for changes and provides a channel -/// for consuming batched events. It handles debouncing and filtering internally. -pub struct VfsWatcher { - /// The underlying file system watcher - _watcher: RecommendedWatcher, - /// Receiver for processed watch events - rx: mpsc::Receiver>, - /// Configuration for the watcher - config: WatchConfig, - /// Handle to the background processing thread - _handle: thread::JoinHandle<()>, -} - -impl VfsWatcher { - /// Create a new file watcher with the given configuration. - /// - /// This starts watching the specified root directories and begins processing - /// events in a background thread. - pub fn new(config: WatchConfig) -> Result { - if !config.enabled { - return Err(anyhow!("File watching is disabled")); - } - - let (event_tx, event_rx) = mpsc::channel(); - let (watch_tx, watch_rx) = mpsc::channel(); - - // Create the file system watcher - let mut watcher = RecommendedWatcher::new( - move |res: notify::Result| { - if let Ok(event) = res { - let _ = event_tx.send(event); - } - }, - Config::default(), - )?; - - // Watch all root directories - for root in &config.roots { - let std_path = root.as_std_path(); - if std_path.exists() { - watcher.watch(std_path, RecursiveMode::Recursive)?; - } - } - - // Spawn background thread for event processing - let config_clone = config.clone(); - let handle = thread::spawn(move || { - Self::process_events(&event_rx, &watch_tx, &config_clone); - }); - - Ok(Self { - _watcher: watcher, - rx: watch_rx, - config, - _handle: handle, - }) - } - - /// Get the next batch of processed watch events. - /// - /// This is a non-blocking operation that returns immediately. If no events - /// are available, it returns an empty vector. - #[must_use] - pub fn try_recv_events(&self) -> Vec { - self.rx.try_recv().unwrap_or_default() - } - - /// Background thread function for processing raw file system events. - /// - /// This function handles debouncing, filtering, and batching of events before - /// sending them to the main thread for VFS synchronization. - fn process_events( - event_rx: &mpsc::Receiver, - watch_tx: &mpsc::Sender>, - config: &WatchConfig, - ) { - let mut pending_events: HashMap = HashMap::new(); - let mut last_batch_time = Instant::now(); - let debounce_duration = Duration::from_millis(config.debounce_ms); - - loop { - // Try to receive events with a timeout for batching - match event_rx.recv_timeout(Duration::from_millis(50)) { - Ok(event) => { - // Process the raw notify event into our WatchEvent format - if let Some(watch_events) = Self::convert_notify_event(event, config) { - for watch_event in watch_events { - let path = Self::get_event_path(&watch_event); - // Only keep the latest event for each path - pending_events.insert(path.clone(), watch_event); - } - } - } - Err(mpsc::RecvTimeoutError::Timeout) => { - // Timeout - check if we should flush pending events - } - Err(mpsc::RecvTimeoutError::Disconnected) => { - // Channel disconnected, exit the thread - break; - } - } - - // Check if we should flush pending events - if !pending_events.is_empty() && last_batch_time.elapsed() >= debounce_duration { - let events: Vec = pending_events.values().cloned().collect(); - if watch_tx.send(events).is_err() { - // Main thread disconnected, exit - break; - } - pending_events.clear(); - last_batch_time = Instant::now(); - } - } - } - - /// Convert a [`notify::Event`] into our [`WatchEvent`] format. - fn convert_notify_event(event: Event, config: &WatchConfig) -> Option> { - let mut watch_events = Vec::new(); - - for path in event.paths { - if let Ok(utf8_path) = Utf8PathBuf::try_from(path) { - if Self::should_include_path_static(&utf8_path, config) { - match event.kind { - EventKind::Create(_) => watch_events.push(WatchEvent::Created(utf8_path)), - EventKind::Modify(_) => watch_events.push(WatchEvent::Modified(utf8_path)), - EventKind::Remove(_) => watch_events.push(WatchEvent::Deleted(utf8_path)), - _ => {} // Ignore other event types for now - } - } - } - } - - if watch_events.is_empty() { - None - } else { - Some(watch_events) - } - } - - /// Static version of should_include_path for use in convert_notify_event. - fn should_include_path_static(path: &Utf8PathBuf, config: &WatchConfig) -> bool { - let path_str = path.as_str(); - - // Check exclude patterns first - for pattern in &config.exclude_patterns { - if path_str.contains(pattern) { - return false; - } - } - - // If no include patterns, include everything (that's not excluded) - if config.include_patterns.is_empty() { - return true; - } - - // Check include patterns - for pattern in &config.include_patterns { - if let Some(extension) = pattern.strip_prefix("*.") { - if path_str.ends_with(extension) { - return true; - } - } else if path_str.contains(pattern) { - return true; - } - } - - false - } - - /// Extract the path from a [`WatchEvent`]. - fn get_event_path(event: &WatchEvent) -> &Utf8PathBuf { - match event { - WatchEvent::Modified(path) | WatchEvent::Created(path) | WatchEvent::Deleted(path) => { - path - } - WatchEvent::Renamed { to, .. } => to, - } - } -} - -impl Drop for VfsWatcher { - fn drop(&mut self) { - // The background thread will exit when the event channel is dropped - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_watch_config_default() { - let config = WatchConfig::default(); - assert!(config.enabled); - assert_eq!(config.debounce_ms, 250); - assert!(config.include_patterns.contains(&"*.py".to_string())); - assert!(config.exclude_patterns.contains(&".git".to_string())); - } - - #[test] - fn test_should_include_path() { - let config = WatchConfig::default(); - - // Should include Python files - assert!(VfsWatcher::should_include_path_static( - &Utf8PathBuf::from("test.py"), - &config - )); - - // Should include HTML files - assert!(VfsWatcher::should_include_path_static( - &Utf8PathBuf::from("template.html"), - &config - )); - - // Should exclude .git files - assert!(!VfsWatcher::should_include_path_static( - &Utf8PathBuf::from(".git/config"), - &config - )); - - // Should exclude __pycache__ files - assert!(!VfsWatcher::should_include_path_static( - &Utf8PathBuf::from("__pycache__/test.pyc"), - &config - )); - } - - #[test] - fn test_watch_event_types() { - let path1 = Utf8PathBuf::from("test.py"); - let path2 = Utf8PathBuf::from("new.py"); - - let modified = WatchEvent::Modified(path1.clone()); - let created = WatchEvent::Created(path1.clone()); - let deleted = WatchEvent::Deleted(path1.clone()); - let renamed = WatchEvent::Renamed { - from: path1, - to: path2, - }; - - // Test that events can be created and compared - assert_ne!(modified, created); - assert_ne!(created, deleted); - assert_ne!(deleted, renamed); - } -}