diff --git a/Cargo.lock b/Cargo.lock index b48cdd9..35c50ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -462,11 +462,13 @@ dependencies = [ "salsa", "serde", "serde_json", + "tempfile", "tokio", "tower-lsp-server", "tracing", "tracing-appender", "tracing-subscriber", + "vfs", ] [[package]] @@ -549,6 +551,18 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "filetime" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586" +dependencies = [ + "cfg-if", + "libc", + "libredox", + "windows-sys 0.59.0", +] + [[package]] name = "fluent-uri" version = "0.1.4" @@ -809,6 +823,7 @@ checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3" dependencies = [ "bitflags 2.9.2", "libc", + "redox_syscall", ] [[package]] @@ -1877,6 +1892,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vfs" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e723b9e1c02a3cf9f9d0de6a4ddb8cdc1df859078902fe0ae0589d615711ae6" +dependencies = [ + "filetime", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/Cargo.toml b/Cargo.toml index 2a674a3..c6dc33c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ toml = "0.9" tracing = "0.1" tracing-appender = "0.2" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "time"] } +vfs = "0.12.2" which = "8.0" # testing diff --git a/crates/djls-server/Cargo.toml b/crates/djls-server/Cargo.toml index 396f0f3..372554f 100644 --- a/crates/djls-server/Cargo.toml +++ b/crates/djls-server/Cargo.toml @@ -23,9 +23,13 @@ tower-lsp-server = { workspace = true } tracing = { workspace = true } tracing-appender = { workspace = true } tracing-subscriber = { workspace = true } +vfs = { workspace = true } [build-dependencies] djls-dev = { workspace = true } +[dev-dependencies] +tempfile = { workspace = true } + [lints] workspace = true diff --git a/crates/djls-server/src/session.rs b/crates/djls-server/src/session.rs index d4e8aaf..3d8fcde 100644 --- a/crates/djls-server/src/session.rs +++ b/crates/djls-server/src/session.rs @@ -7,7 +7,7 @@ use tower_lsp_server::lsp_types::InitializeParams; use crate::db::ServerDatabase; use crate::workspace::Store; -#[derive(Default)] + pub struct Session { project: Option, documents: Store, @@ -65,7 +65,7 @@ impl Session { Self { client_capabilities: params.capabilities.clone(), project, - documents: Store::default(), + documents: Store::new(project_path.as_ref().unwrap_or(&std::env::current_dir().unwrap())).unwrap(), settings, db_handle: StorageHandle::new(None), } diff --git a/crates/djls-server/src/workspace/fs.rs b/crates/djls-server/src/workspace/fs.rs new file mode 100644 index 0000000..6cef3fc --- /dev/null +++ b/crates/djls-server/src/workspace/fs.rs @@ -0,0 +1,362 @@ +use std::path::Path; +use vfs::{MemoryFS, PhysicalFS, VfsPath, VfsResult, VfsMetadata, SeekAndRead, SeekAndWrite}; +use std::time::SystemTime; + +/// A file system for managing workspace file content with manual layer management. +/// +/// The file system uses two separate layers: +/// - Memory layer: for unsaved edits and temporary content +/// - Physical layer: for disk-based files +/// +/// When reading, the memory layer is checked first, falling back to physical layer. +/// Write operations go to memory layer only, preserving original files on disk. +/// Clearing memory layer allows immediate fallback to physical layer without whiteout markers. +#[derive(Debug)] +pub struct FileSystem { + memory: VfsPath, + physical: VfsPath, +} + +impl FileSystem { + /// Create a new FileSystem with separate memory and physical layers + pub fn new>(root_path: P) -> VfsResult { + let memory = VfsPath::new(MemoryFS::new()); + let physical = VfsPath::new(PhysicalFS::new(root_path.as_ref())); + + Ok(FileSystem { memory, physical }) + } + + /// Read content from the file system + /// Checks memory layer first, then falls back to physical layer + pub fn read(&self, path: &str) -> VfsResult { + let memory_path = self.memory.join(path)?; + if memory_path.exists()? { + return memory_path.read_to_string(); + } + + let physical_path = self.physical.join(path)?; + physical_path.read_to_string() + } + + /// Write content to memory layer only + /// This preserves the original file on disk while allowing edits + pub fn write_memory(&self, path: &str, content: &str) -> VfsResult<()> { + let memory_path = self.memory.join(path)?; + + // Ensure parent directories exist in memory layer + let parent = memory_path.parent(); + if !parent.is_root() && !parent.exists()? { + parent.create_dir_all()?; + } + + memory_path.create_file()?.write_all(content.as_bytes())?; + Ok(()) + } + + /// Clear memory layer content for a specific path + /// After clearing, reads will fall back to physical layer + /// No whiteout markers are created - direct memory layer management + pub fn clear_memory(&self, path: &str) -> VfsResult<()> { + let memory_path = self.memory.join(path)?; + + // Only remove if it exists in memory layer + if memory_path.exists()? { + memory_path.remove_file()?; + } + + Ok(()) + } + + /// Check if a path exists in either layer + /// Checks memory layer first, then physical layer + pub fn exists(&self, path: &str) -> VfsResult { + let memory_path = self.memory.join(path)?; + if memory_path.exists()? { + return Ok(true); + } + + let physical_path = self.physical.join(path)?; + physical_path.exists() + } + + /// Get memory layer root for advanced operations + pub fn memory_root(&self) -> VfsPath { + self.memory.clone() + } + + /// Get physical layer root for advanced operations + pub fn physical_root(&self) -> VfsPath { + self.physical.clone() + } + + /// Get root for backward compatibility (returns memory root) + pub fn root(&self) -> VfsPath { + self.memory.clone() + } +} + +// Implement vfs::FileSystem trait to make our FileSystem compatible with VfsPath +impl vfs::FileSystem for FileSystem { + fn read_dir(&self, path: &str) -> VfsResult + Send>> { + // Check memory layer first, then physical + let memory_path = self.memory.join(path)?; + if memory_path.exists()? { + return Ok(Box::new(memory_path.read_dir()?.map(|p| p.filename()))); + } + + let physical_path = self.physical.join(path)?; + Ok(Box::new(physical_path.read_dir()?.map(|p| p.filename()))) + } + + fn create_dir(&self, path: &str) -> VfsResult<()> { + // Create directory in memory layer only + let memory_path = self.memory.join(path)?; + memory_path.create_dir() + } + + fn open_file(&self, path: &str) -> VfsResult> { + // Check memory layer first, then physical + let memory_path = self.memory.join(path)?; + if memory_path.exists()? { + return memory_path.open_file(); + } + + let physical_path = self.physical.join(path)?; + physical_path.open_file() + } + + fn create_file(&self, path: &str) -> VfsResult> { + // Create file in memory layer only + let memory_path = self.memory.join(path)?; + + // Ensure parent directories exist in memory layer + let parent = memory_path.parent(); + if !parent.is_root() && !parent.exists()? { + parent.create_dir_all()?; + } + + memory_path.create_file() + } + + fn append_file(&self, path: &str) -> VfsResult> { + // For append, we need to check if file exists and copy to memory if needed + let memory_path = self.memory.join(path)?; + + if !memory_path.exists()? { + // Copy from physical to memory first if it exists + let physical_path = self.physical.join(path)?; + if physical_path.exists()? { + let content = physical_path.read_to_string()?; + self.write_memory(path, &content)?; + } + } + + memory_path.append_file() + } + + fn metadata(&self, path: &str) -> VfsResult { + // Check memory layer first, then physical + let memory_path = self.memory.join(path)?; + if memory_path.exists()? { + return memory_path.metadata(); + } + + let physical_path = self.physical.join(path)?; + physical_path.metadata() + } + + fn set_creation_time(&self, path: &str, time: SystemTime) -> VfsResult<()> { + // Set on memory layer if exists, otherwise physical + let memory_path = self.memory.join(path)?; + if memory_path.exists()? { + return memory_path.set_creation_time(time); + } + + let physical_path = self.physical.join(path)?; + physical_path.set_creation_time(time) + } + + fn set_modification_time(&self, path: &str, time: SystemTime) -> VfsResult<()> { + // Set on memory layer if exists, otherwise physical + let memory_path = self.memory.join(path)?; + if memory_path.exists()? { + return memory_path.set_modification_time(time); + } + + let physical_path = self.physical.join(path)?; + physical_path.set_modification_time(time) + } + + fn set_access_time(&self, path: &str, time: SystemTime) -> VfsResult<()> { + // Set on memory layer if exists, otherwise physical + let memory_path = self.memory.join(path)?; + if memory_path.exists()? { + return memory_path.set_access_time(time); + } + + let physical_path = self.physical.join(path)?; + physical_path.set_access_time(time) + } + + fn exists(&self, path: &str) -> VfsResult { + // Use our existing method which already handles layer checking + self.exists(path) + } + + fn remove_file(&self, path: &str) -> VfsResult<()> { + // Only remove from memory layer - this aligns with our LSP semantics + let memory_path = self.memory.join(path)?; + if memory_path.exists()? { + memory_path.remove_file()?; + } + Ok(()) + } + + fn remove_dir(&self, path: &str) -> VfsResult<()> { + // Only remove from memory layer - this aligns with our LSP semantics + let memory_path = self.memory.join(path)?; + if memory_path.exists()? { + memory_path.remove_dir()?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn test_new_vfs() { + let temp_dir = TempDir::new().unwrap(); + let vfs = FileSystem::new(temp_dir.path()).unwrap(); + + // Should be able to get roots + let _memory_root = vfs.memory_root(); + let _physical_root = vfs.physical_root(); + let _root = vfs.root(); + } + + #[test] + fn test_read_physical_file() { + let temp_dir = TempDir::new().unwrap(); + 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 content = vfs.read("test.html").unwrap(); + + assert_eq!(content, "physical content"); + } + + #[test] + fn test_write_memory_and_read() { + let temp_dir = TempDir::new().unwrap(); + let vfs = FileSystem::new(temp_dir.path()).unwrap(); + + vfs.write_memory("test.html", "memory content").unwrap(); + let content = vfs.read("test.html").unwrap(); + + assert_eq!(content, "memory content"); + } + + #[test] + fn test_memory_layer_priority() { + let temp_dir = TempDir::new().unwrap(); + let test_file = temp_dir.path().join("test.html"); + fs::write(&test_file, "physical content").unwrap(); + + let vfs = FileSystem::new(temp_dir.path()).unwrap(); + + // First read should get physical content + assert_eq!(vfs.read("test.html").unwrap(), "physical content"); + + // Write to memory layer + vfs.write_memory("test.html", "memory content").unwrap(); + + // Now read should get memory content (higher priority) + assert_eq!(vfs.read("test.html").unwrap(), "memory content"); + + // Physical file should remain unchanged + let physical_content = fs::read_to_string(&test_file).unwrap(); + assert_eq!(physical_content, "physical content"); + } + + #[test] + fn test_clear_memory_fallback() { + let temp_dir = TempDir::new().unwrap(); + let test_file = temp_dir.path().join("test.html"); + fs::write(&test_file, "physical content").unwrap(); + + let vfs = FileSystem::new(temp_dir.path()).unwrap(); + + // Write to memory + vfs.write_memory("test.html", "memory content").unwrap(); + assert_eq!(vfs.read("test.html").unwrap(), "memory content"); + + // Clear memory + vfs.clear_memory("test.html").unwrap(); + + // Should now read from physical layer + assert_eq!(vfs.read("test.html").unwrap(), "physical content"); + } + + #[test] + fn test_exists() { + let temp_dir = TempDir::new().unwrap(); + let test_file = temp_dir.path().join("physical.html"); + fs::write(&test_file, "content").unwrap(); + + let vfs = FileSystem::new(temp_dir.path()).unwrap(); + + // Physical file exists + assert!(vfs.exists("physical.html").unwrap()); + + // Memory file exists after writing + vfs.write_memory("memory.html", "content").unwrap(); + assert!(vfs.exists("memory.html").unwrap()); + + // Non-existent file + assert!(!vfs.exists("nonexistent.html").unwrap()); + } + + #[test] + fn test_clear_memory_nonexistent() { + let temp_dir = TempDir::new().unwrap(); + let vfs = FileSystem::new(temp_dir.path()).unwrap(); + + // Should not error when clearing non-existent memory file + vfs.clear_memory("nonexistent.html").unwrap(); + } + + #[test] + fn test_vfs_trait_implementation() { + let temp_dir = TempDir::new().unwrap(); + 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 vfs_path = VfsPath::new(filesystem); + + // Test that our FileSystem works as a vfs::FileSystem trait + assert!(vfs_path.join("trait_test.html").unwrap().exists().unwrap()); + + // Test reading through trait + let content = vfs_path.join("trait_test.html").unwrap().read_to_string().unwrap(); + assert_eq!(content, "trait physical content"); + + // Test creating file through trait + let new_file = vfs_path.join("new_trait_file.html").unwrap(); + new_file.create_file().unwrap().write_all(b"trait memory content").unwrap(); + + // Should read from memory layer + let memory_content = new_file.read_to_string().unwrap(); + assert_eq!(memory_content, "trait memory content"); + + // Physical file should not exist (memory layer only) + let physical_new_file = temp_dir.path().join("new_trait_file.html"); + assert!(!physical_new_file.exists()); + } +} diff --git a/crates/djls-server/src/workspace/mod.rs b/crates/djls-server/src/workspace/mod.rs index fb15df9..5411c53 100644 --- a/crates/djls-server/src/workspace/mod.rs +++ b/crates/djls-server/src/workspace/mod.rs @@ -1,6 +1,8 @@ mod document; 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 3ec2109..928fd65 100644 --- a/crates/djls-server/src/workspace/store.rs +++ b/crates/djls-server/src/workspace/store.rs @@ -1,4 +1,6 @@ +use super::fs::FileSystem; use std::collections::HashMap; +use std::path::PathBuf; use anyhow::anyhow; use anyhow::Result; @@ -20,13 +22,26 @@ use super::document::ClosingBrace; use super::document::LanguageId; use super::document::TextDocument; -#[derive(Debug, Default)] +#[derive(Debug)] pub struct Store { documents: HashMap, versions: HashMap, + vfs: FileSystem, + root_path: PathBuf, } 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)?; + + Ok(Store { + documents: HashMap::new(), + versions: HashMap::new(), + vfs, + root_path, + }) + } pub fn handle_did_open(&mut self, db: &dyn Database, params: &DidOpenTextDocumentParams) { let uri = params.text_document.uri.to_string(); let version = params.text_document.version;