diff --git a/crates/djls-server/src/server.rs b/crates/djls-server/src/server.rs index 3977ef6..833646f 100644 --- a/crates/djls-server/src/server.rs +++ b/crates/djls-server/src/server.rs @@ -10,6 +10,7 @@ use tower_lsp_server::lsp_types::DidChangeConfigurationParams; 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::DidSaveTextDocumentParams; use tower_lsp_server::lsp_types::InitializeParams; use tower_lsp_server::lsp_types::InitializeResult; use tower_lsp_server::lsp_types::InitializedParams; @@ -243,6 +244,17 @@ impl LanguageServer for DjangoLanguageServer { .await; } + async fn did_save(&self, params: DidSaveTextDocumentParams) { + tracing::info!("Saved document: {:?}", params.text_document.uri); + + self.with_session_mut(|session| { + if let Err(e) = session.documents_mut().handle_did_save(¶ms) { + tracing::error!("Failed to handle did_save: {}", e); + } + }) + .await; + } + async fn completion(&self, params: CompletionParams) -> LspResult> { Ok(self .with_session(|session| { diff --git a/crates/djls-server/src/workspace/fs.rs b/crates/djls-server/src/workspace/fs.rs index 8c6b84a..86b81dd 100644 --- a/crates/djls-server/src/workspace/fs.rs +++ b/crates/djls-server/src/workspace/fs.rs @@ -295,7 +295,7 @@ mod tests { let test_file = temp_dir.path().join("test.html"); fs::write(&test_file, "physical content").unwrap(); - let vfs = FileSystem::new(temp_dir.path()).unwrap(); + let vfs = FileSystem::new(temp_dir.path()); let content = vfs.read_to_string("test.html").unwrap(); assert_eq!(content, "physical content"); @@ -304,7 +304,7 @@ mod tests { #[test] fn test_write_string_and_read_to_string() { let temp_dir = TempDir::new().unwrap(); - let vfs = FileSystem::new(temp_dir.path()).unwrap(); + let vfs = FileSystem::new(temp_dir.path()); vfs.write_string("test.html", "memory content").unwrap(); let content = vfs.read_to_string("test.html").unwrap(); @@ -318,7 +318,7 @@ mod tests { let test_file = temp_dir.path().join("test.html"); fs::write(&test_file, "physical content").unwrap(); - let vfs = FileSystem::new(temp_dir.path()).unwrap(); + let vfs = FileSystem::new(temp_dir.path()); // First read should get physical content assert_eq!(vfs.read_to_string("test.html").unwrap(), "physical content"); @@ -340,7 +340,7 @@ mod tests { let test_file = temp_dir.path().join("test.html"); fs::write(&test_file, "physical content").unwrap(); - let vfs = FileSystem::new(temp_dir.path()).unwrap(); + let vfs = FileSystem::new(temp_dir.path()); // Write to memory vfs.write_string("test.html", "memory content").unwrap(); @@ -359,7 +359,7 @@ mod tests { let test_file = temp_dir.path().join("physical.html"); fs::write(&test_file, "content").unwrap(); - let vfs = FileSystem::new(temp_dir.path()).unwrap(); + let vfs = FileSystem::new(temp_dir.path()); // Physical file exists assert!(vfs.exists("physical.html").unwrap()); @@ -375,7 +375,7 @@ mod tests { #[test] fn test_discard_changes_nonexistent() { let temp_dir = TempDir::new().unwrap(); - let vfs = FileSystem::new(temp_dir.path()).unwrap(); + let vfs = FileSystem::new(temp_dir.path()); // Should not error when clearing non-existent memory file vfs.discard_changes("nonexistent.html").unwrap(); @@ -387,7 +387,7 @@ mod tests { let test_file = temp_dir.path().join("trait_test.html"); fs::write(&test_file, "trait physical content").unwrap(); - let filesystem = FileSystem::new(temp_dir.path()).unwrap(); + let filesystem = FileSystem::new(temp_dir.path()); // Test our trait implementation directly instead of through VfsPath // VfsPath would create absolute paths which our security validation rejects @@ -428,7 +428,7 @@ mod tests { fs::write(test_dir.join("physical1.txt"), "content").unwrap(); fs::write(test_dir.join("physical2.txt"), "content").unwrap(); - let filesystem = FileSystem::new(temp_dir.path()).unwrap(); + let filesystem = FileSystem::new(temp_dir.path()); // Create memory layer files using the trait methods use vfs::FileSystem as VfsFileSystemTrait; diff --git a/crates/djls-server/src/workspace/mod.rs b/crates/djls-server/src/workspace/mod.rs index 37a725e..f97e559 100644 --- a/crates/djls-server/src/workspace/mod.rs +++ b/crates/djls-server/src/workspace/mod.rs @@ -18,6 +18,7 @@ //! ### LSP Integration //! //! The workspace module is designed around LSP lifecycle events: +//! //! - `textDocument/didOpen`: Files are tracked but not immediately loaded into memory //! - `textDocument/didChange`: Changes are stored in the memory layer //! - `textDocument/didSave`: Memory layer changes can be discarded (editor handles disk writes) @@ -34,10 +35,9 @@ //! language server operations. mod document; +mod fs; mod store; mod utils; -mod fs; pub use store::Store; pub use utils::get_project_path; -pub use fs::FileSystem; diff --git a/crates/djls-server/src/workspace/store.rs b/crates/djls-server/src/workspace/store.rs index e6a86ce..271fa20 100644 --- a/crates/djls-server/src/workspace/store.rs +++ b/crates/djls-server/src/workspace/store.rs @@ -12,6 +12,8 @@ use tower_lsp_server::lsp_types::CompletionResponse; 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::DidSaveTextDocumentParams; +use tower_lsp_server::lsp_types::TextDocumentContentChangeEvent; use tower_lsp_server::lsp_types::Documentation; use tower_lsp_server::lsp_types::InsertTextFormat; use tower_lsp_server::lsp_types::MarkupContent; @@ -20,6 +22,7 @@ use tower_lsp_server::lsp_types::Position; use super::document::ClosingBrace; use super::document::LanguageId; +use super::document::LineIndex; use super::document::TextDocument; use super::utils::uri_to_pathbuf; @@ -34,7 +37,7 @@ pub struct Store { impl Store { pub fn new>(root_path: P) -> anyhow::Result { let root_path = root_path.as_ref().to_path_buf(); - let vfs = FileSystem::new(&root_path)?; + let vfs = FileSystem::new(&root_path); Ok(Store { documents: HashMap::new(), @@ -63,6 +66,22 @@ impl Store { let uri = params.text_document.uri.to_string(); let version = params.text_document.version; + let content = ¶ms.text_document.text; + + // Convert URI to relative path for VFS + if let Some(absolute_path) = uri_to_pathbuf(¶ms.text_document.uri) { + // Make path relative to workspace root + if let Ok(relative_path) = absolute_path.strip_prefix(&self.root_path) { + // Write content to FileSystem (memory layer for opened file) + if let Err(e) = self.vfs.write_string( + &relative_path.to_string_lossy(), + content + ) { + eprintln!("Warning: Failed to write file to VFS: {}", e); + // Continue with normal processing despite VFS error + } + } + } let document = TextDocument::from_did_open_params(db, params); @@ -84,6 +103,50 @@ impl Store { let uri = params.text_document.uri.as_str().to_string(); let version = params.text_document.version; + // Convert URI to relative path for VFS + if let Some(absolute_path) = uri_to_pathbuf(¶ms.text_document.uri) { + if let Ok(relative_path) = absolute_path.strip_prefix(&self.root_path) { + let relative_path_str = relative_path.to_string_lossy(); + + // Read current content from VFS + let current_content = match self.vfs.read_to_string(&relative_path_str) { + Ok(content) => content, + Err(e) => { + eprintln!("Warning: Failed to read from VFS, falling back to TextDocument: {}", e); + // Fallback to existing TextDocument approach + let document = self + .get_document(&uri) + .ok_or_else(|| anyhow!("Document not found: {}", uri))?; + let new_document = document.with_changes(db, ¶ms.content_changes, version); + self.documents.insert(uri.clone(), new_document); + self.versions.insert(uri, version); + return Ok(()); + } + }; + + // Apply text changes to VFS content + let updated_content = self.apply_changes_to_content(current_content, ¶ms.content_changes)?; + + // Write updated content back to VFS + if let Err(e) = self.vfs.write_string(&relative_path_str, &updated_content) { + eprintln!("Warning: Failed to write to VFS: {}", e); + } + + // Create new TextDocument with updated content for backward compatibility + let index = LineIndex::new(&updated_content); + let language_id = self.get_document(&uri) + .map(|doc| doc.language_id(db)) + .unwrap_or(LanguageId::HtmlDjango); + + let new_document = TextDocument::new(db, uri.clone(), updated_content.clone(), index, version, language_id); + self.documents.insert(uri.clone(), new_document); + self.versions.insert(uri, version); + + return Ok(()); + } + } + + // Fallback to original implementation if path conversion fails let document = self .get_document(&uri) .ok_or_else(|| anyhow!("Document not found: {}", uri))?; @@ -97,9 +160,84 @@ impl Store { } pub fn handle_did_close(&mut self, params: &DidCloseTextDocumentParams) { + // Only process files within the workspace for VFS cleanup + if self.is_workspace_file(¶ms.text_document.uri) { + // Convert URI to relative path for VFS + if let Some(absolute_path) = uri_to_pathbuf(¶ms.text_document.uri) { + if let Ok(relative_path) = absolute_path.strip_prefix(&self.root_path) { + let relative_path_str = relative_path.to_string_lossy(); + + // Discard any unsaved changes in VFS (clean up memory layer) + if let Err(e) = self.vfs.discard_changes(&relative_path_str) { + eprintln!("Warning: Failed to discard VFS changes on close: {}", e); + // Continue with document removal despite VFS error + } + } + } + } + + // Remove document from Store tracking (always do this regardless of VFS status) self.remove_document(params.text_document.uri.as_str()); } + pub fn handle_did_save(&mut self, params: &DidSaveTextDocumentParams) -> Result<()> { + // Only process files within the workspace + if !self.is_workspace_file(¶ms.text_document.uri) { + // Return Ok to avoid errors for files outside workspace + return Ok(()); + } + + // Convert URI to relative path for VFS + if let Some(absolute_path) = uri_to_pathbuf(¶ms.text_document.uri) { + if let Ok(relative_path) = absolute_path.strip_prefix(&self.root_path) { + let relative_path_str = relative_path.to_string_lossy(); + + // Discard changes in VFS (clear memory layer so reads return disk content) + if let Err(e) = self.vfs.discard_changes(&relative_path_str) { + eprintln!("Warning: Failed to discard VFS changes on save: {}", e); + // Continue normally - this is not a critical error + } + } + } + + Ok(()) + } + + /// Apply text changes to content (similar to TextDocument::with_changes but for strings) + fn apply_changes_to_content( + &self, + mut content: String, + changes: &[TextDocumentContentChangeEvent], + ) -> Result { + for change in changes { + if let Some(range) = change.range { + // Incremental change with range + let index = LineIndex::new(&content); + + if let (Some(start_offset), Some(end_offset)) = ( + index.offset(range.start).map(|o| o as usize), + index.offset(range.end).map(|o| o as usize), + ) { + let mut updated_content = String::with_capacity( + content.len() - (end_offset - start_offset) + change.text.len(), + ); + + updated_content.push_str(&content[..start_offset]); + updated_content.push_str(&change.text); + updated_content.push_str(&content[end_offset..]); + + content = updated_content; + } else { + return Err(anyhow!("Invalid range in text change")); + } + } else { + // Full document replacement + content.clone_from(&change.text); + } + } + Ok(content) + } + fn add_document(&mut self, document: TextDocument, uri: String) { self.documents.insert(uri, document); }