From 2b5e3c64a94ba17e99b8f55653d9507d0f918d33 Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 25 Sep 2025 10:08:52 -0500 Subject: [PATCH 1/2] move `FileSystem` trait and impls to source crate, simpilify workspace fs --- crates/djls-project/src/python.rs | 8 +- crates/djls-semantic/src/blocks/tree.rs | 4 +- crates/djls-server/src/db.rs | 4 +- crates/djls-source/src/lib.rs | 4 + crates/djls-source/src/system.rs | 102 +++++ crates/djls-workspace/src/db.rs | 3 +- crates/djls-workspace/src/fs.rs | 329 -------------- crates/djls-workspace/src/lib.rs | 5 - crates/djls-workspace/src/workspace.rs | 557 +++++++++++++++++------- 9 files changed, 505 insertions(+), 511 deletions(-) create mode 100644 crates/djls-source/src/system.rs delete mode 100644 crates/djls-workspace/src/fs.rs diff --git a/crates/djls-project/src/python.rs b/crates/djls-project/src/python.rs index 00e642d70..b511ac7c2 100644 --- a/crates/djls-project/src/python.rs +++ b/crates/djls-project/src/python.rs @@ -276,9 +276,7 @@ mod tests { mod env_discovery { use system::mock::MockGuard; - use system::mock::{ - self as sys_mock, - }; + use system::mock::{self as sys_mock}; use which::Error as WhichError; use super::*; @@ -601,8 +599,8 @@ mod tests { use std::sync::Arc; use std::sync::Mutex; - use djls_workspace::FileSystem; - use djls_workspace::InMemoryFileSystem; + use djls_source::FileSystem; + use djls_source::InMemoryFileSystem; use super::*; use crate::inspector::pool::InspectorPool; diff --git a/crates/djls-semantic/src/blocks/tree.rs b/crates/djls-semantic/src/blocks/tree.rs index c05602b13..4296f09d4 100644 --- a/crates/djls-semantic/src/blocks/tree.rs +++ b/crates/djls-semantic/src/blocks/tree.rs @@ -216,11 +216,11 @@ mod tests { use camino::Utf8Path; use djls_source::File; + use djls_source::FileSystem; + use djls_source::InMemoryFileSystem; use djls_source::Span; use djls_templates::parse_template; use djls_templates::Node; - use djls_workspace::FileSystem; - use djls_workspace::InMemoryFileSystem; use super::*; use crate::blocks::grammar::TagIndex; diff --git a/crates/djls-server/src/db.rs b/crates/djls-server/src/db.rs index 20094bf1f..be901b433 100644 --- a/crates/djls-server/src/db.rs +++ b/crates/djls-server/src/db.rs @@ -15,9 +15,9 @@ use djls_project::Project; use djls_semantic::Db as SemanticDb; use djls_semantic::TagSpecs; use djls_source::Db as SourceDb; +use djls_source::FileSystem; use djls_templates::db::Db as TemplateDb; use djls_workspace::db::Db as WorkspaceDb; -use djls_workspace::FileSystem; /// Concrete Salsa database for the Django Language Server. /// @@ -48,7 +48,7 @@ pub struct DjangoDatabase { #[cfg(test)] impl Default for DjangoDatabase { fn default() -> Self { - use djls_workspace::InMemoryFileSystem; + use djls_source::InMemoryFileSystem; let logs = >>>>::default(); Self { diff --git a/crates/djls-source/src/lib.rs b/crates/djls-source/src/lib.rs index ce6d8295b..0b5d1c307 100644 --- a/crates/djls-source/src/lib.rs +++ b/crates/djls-source/src/lib.rs @@ -4,6 +4,7 @@ mod file; mod line; mod position; mod protocol; +mod system; pub use collections::FxDashMap; pub use collections::FxDashSet; @@ -15,3 +16,6 @@ pub use position::LineCol; pub use position::Offset; pub use position::Span; pub use protocol::PositionEncoding; +pub use system::FileSystem; +pub use system::InMemoryFileSystem; +pub use system::OsFileSystem; diff --git a/crates/djls-source/src/system.rs b/crates/djls-source/src/system.rs new file mode 100644 index 000000000..e89e13f0a --- /dev/null +++ b/crates/djls-source/src/system.rs @@ -0,0 +1,102 @@ +use camino::Utf8Path; +use camino::Utf8PathBuf; +use rustc_hash::FxHashMap; +use std::io; + +pub trait FileSystem: Send + Sync { + fn read_to_string(&self, path: &Utf8Path) -> io::Result; + fn exists(&self, path: &Utf8Path) -> bool; +} + +pub struct InMemoryFileSystem { + files: FxHashMap, +} + +impl InMemoryFileSystem { + #[must_use] + pub fn new() -> Self { + Self { + files: FxHashMap::default(), + } + } + + pub fn add_file(&mut self, path: Utf8PathBuf, content: String) { + self.files.insert(path, content); + } +} + +impl Default for InMemoryFileSystem { + fn default() -> Self { + Self::new() + } +} + +impl FileSystem for InMemoryFileSystem { + fn read_to_string(&self, path: &Utf8Path) -> io::Result { + self.files + .get(path) + .cloned() + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "File not found")) + } + + fn exists(&self, path: &Utf8Path) -> bool { + self.files.contains_key(path) + } +} + +/// Standard file system implementation that uses [`std::fs`]. +pub struct OsFileSystem; + +impl FileSystem for OsFileSystem { + fn read_to_string(&self, path: &Utf8Path) -> io::Result { + std::fs::read_to_string(path) + } + + fn exists(&self, path: &Utf8Path) -> bool { + path.exists() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod in_memory { + use super::*; + + #[test] + fn test_read_existing_file() { + let mut fs = InMemoryFileSystem::new(); + fs.add_file("/test.py".into(), "file content".to_string()); + + assert_eq!( + fs.read_to_string(Utf8Path::new("/test.py")).unwrap(), + "file content" + ); + } + + #[test] + fn test_read_nonexistent_file() { + let fs = InMemoryFileSystem::new(); + + let result = fs.read_to_string(Utf8Path::new("/missing.py")); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), io::ErrorKind::NotFound); + } + + #[test] + fn test_exists_returns_true_for_existing() { + let mut fs = InMemoryFileSystem::new(); + fs.add_file("/exists.py".into(), "content".to_string()); + + assert!(fs.exists(Utf8Path::new("/exists.py"))); + } + + #[test] + fn test_exists_returns_false_for_nonexistent() { + let fs = InMemoryFileSystem::new(); + + assert!(!fs.exists(Utf8Path::new("/missing.py"))); + } + } +} diff --git a/crates/djls-workspace/src/db.rs b/crates/djls-workspace/src/db.rs index 84a027c98..131c363e1 100644 --- a/crates/djls-workspace/src/db.rs +++ b/crates/djls-workspace/src/db.rs @@ -24,10 +24,9 @@ use std::sync::Arc; use djls_source::Db as SourceDb; use djls_source::File; +use djls_source::FileSystem; use salsa::Setter; -use crate::FileSystem; - /// Base database trait that provides file system access for Salsa queries #[salsa::db] pub trait Db: SourceDb { diff --git a/crates/djls-workspace/src/fs.rs b/crates/djls-workspace/src/fs.rs deleted file mode 100644 index c1bb958e4..000000000 --- a/crates/djls-workspace/src/fs.rs +++ /dev/null @@ -1,329 +0,0 @@ -//! Virtual file system abstraction -//! -//! 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::sync::Arc; - -use camino::Utf8Path; -use camino::Utf8PathBuf; -use rustc_hash::FxHashMap; - -use crate::buffers::Buffers; -use crate::paths; - -pub trait FileSystem: Send + Sync { - fn read_to_string(&self, path: &Utf8Path) -> io::Result; - fn exists(&self, path: &Utf8Path) -> bool; -} - -pub struct InMemoryFileSystem { - files: FxHashMap, -} - -impl InMemoryFileSystem { - #[must_use] - pub fn new() -> Self { - Self { - files: FxHashMap::default(), - } - } - - pub fn add_file(&mut self, path: Utf8PathBuf, content: String) { - self.files.insert(path, content); - } -} - -impl Default for InMemoryFileSystem { - fn default() -> Self { - Self::new() - } -} - -impl FileSystem for InMemoryFileSystem { - fn read_to_string(&self, path: &Utf8Path) -> io::Result { - self.files - .get(path) - .cloned() - .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "File not found")) - } - - fn exists(&self, path: &Utf8Path) -> bool { - self.files.contains_key(path) - } -} - -/// Standard file system implementation that uses [`std::fs`]. -pub struct OsFileSystem; - -impl FileSystem for OsFileSystem { - fn read_to_string(&self, path: &Utf8Path) -> io::Result { - std::fs::read_to_string(path) - } - - fn exists(&self, path: &Utf8Path) -> bool { - path.exists() - } -} - -/// LSP file system that intercepts reads for buffered files. -/// -/// This implements a two-layer architecture where Layer 1 (open [`Buffers`]) -/// takes precedence over Layer 2 (Salsa database). When a file is read, -/// this system first checks for a buffer (in-memory content from -/// [`TextDocument`](crate::document::TextDocument)) and returns that content. -/// If no buffer exists, it falls back to reading from disk. -/// -/// ## Overlay Semantics -/// -/// Files in the overlay (buffered files) are treated as first-class files: -/// - `exists()` returns true for overlay files even if they don't exist on disk -/// - `read_to_string()` returns the overlay content -/// -/// This ensures consistent behavior across all filesystem operations for -/// buffered files that may not yet be saved to disk. -/// -/// This type is used by the database implementations to ensure all file reads go -/// through the buffer system first. -pub struct WorkspaceFileSystem { - /// In-memory buffers that take precedence over disk files - buffers: Buffers, - /// Fallback file system for disk operations - disk: Arc, -} - -impl WorkspaceFileSystem { - #[must_use] - pub fn new(buffers: Buffers, disk: Arc) -> Self { - Self { buffers, disk } - } -} - -impl FileSystem for WorkspaceFileSystem { - fn read_to_string(&self, path: &Utf8Path) -> io::Result { - if let Some(url) = paths::path_to_url(path) { - if let Some(document) = self.buffers.get(&url) { - return Ok(document.content().to_string()); - } - } - self.disk.read_to_string(path) - } - - fn exists(&self, path: &Utf8Path) -> bool { - paths::path_to_url(path).is_some_and(|url| self.buffers.contains(&url)) - || self.disk.exists(path) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - mod in_memory { - use super::*; - - #[test] - fn test_read_existing_file() { - let mut fs = InMemoryFileSystem::new(); - fs.add_file("/test.py".into(), "file content".to_string()); - - assert_eq!( - fs.read_to_string(Utf8Path::new("/test.py")).unwrap(), - "file content" - ); - } - - #[test] - fn test_read_nonexistent_file() { - let fs = InMemoryFileSystem::new(); - - let result = fs.read_to_string(Utf8Path::new("/missing.py")); - assert!(result.is_err()); - assert_eq!(result.unwrap_err().kind(), io::ErrorKind::NotFound); - } - - #[test] - fn test_exists_returns_true_for_existing() { - let mut fs = InMemoryFileSystem::new(); - fs.add_file("/exists.py".into(), "content".to_string()); - - assert!(fs.exists(Utf8Path::new("/exists.py"))); - } - - #[test] - fn test_exists_returns_false_for_nonexistent() { - let fs = InMemoryFileSystem::new(); - - assert!(!fs.exists(Utf8Path::new("/missing.py"))); - } - } - - mod workspace { - use url::Url; - - use super::*; - use crate::buffers::Buffers; - use crate::document::TextDocument; - use crate::language::LanguageId; - - // Helper to create platform-appropriate test paths - fn test_file_path(name: &str) -> Utf8PathBuf { - #[cfg(windows)] - return Utf8PathBuf::from(format!("C:\\temp\\{name}")); - #[cfg(not(windows))] - return Utf8PathBuf::from(format!("/tmp/{name}")); - } - - #[test] - fn test_reads_from_buffer_when_present() { - let disk = Arc::new(InMemoryFileSystem::new()); - let buffers = Buffers::new(); - let fs = WorkspaceFileSystem::new(buffers.clone(), disk); - - // Add file to buffer - let path = test_file_path("test.py"); - let url = Url::from_file_path(&path).unwrap(); - let doc = TextDocument::new("buffer content".to_string(), 1, LanguageId::Python); - buffers.open(url, doc); - - assert_eq!(fs.read_to_string(&path).unwrap(), "buffer content"); - } - - #[test] - fn test_reads_from_disk_when_no_buffer() { - let mut disk_fs = InMemoryFileSystem::new(); - let path = test_file_path("test.py"); - disk_fs.add_file(path.clone(), "disk content".to_string()); - - let buffers = Buffers::new(); - let fs = WorkspaceFileSystem::new(buffers, Arc::new(disk_fs)); - - assert_eq!(fs.read_to_string(&path).unwrap(), "disk content"); - } - - #[test] - fn test_buffer_overrides_disk() { - let mut disk_fs = InMemoryFileSystem::new(); - let path = test_file_path("test.py"); - disk_fs.add_file(path.clone(), "disk content".to_string()); - - let buffers = Buffers::new(); - let fs = WorkspaceFileSystem::new(buffers.clone(), Arc::new(disk_fs)); - - // Add buffer with different content - let url = Url::from_file_path(&path).unwrap(); - let doc = TextDocument::new("buffer content".to_string(), 1, LanguageId::Python); - buffers.open(url, doc); - - assert_eq!(fs.read_to_string(&path).unwrap(), "buffer content"); - } - - #[test] - fn test_exists_for_buffer_only_file() { - let disk = Arc::new(InMemoryFileSystem::new()); - let buffers = Buffers::new(); - let fs = WorkspaceFileSystem::new(buffers.clone(), disk); - - // Add file to buffer only - let path = test_file_path("buffer_only.py"); - let url = Url::from_file_path(&path).unwrap(); - let doc = TextDocument::new("content".to_string(), 1, LanguageId::Python); - buffers.open(url, doc); - - assert!(fs.exists(&path)); - } - - #[test] - fn test_exists_for_disk_only_file() { - let mut disk_fs = InMemoryFileSystem::new(); - let path = test_file_path("disk_only.py"); - disk_fs.add_file(path.clone(), "content".to_string()); - - let buffers = Buffers::new(); - let fs = WorkspaceFileSystem::new(buffers, Arc::new(disk_fs)); - - assert!(fs.exists(&path)); - } - - #[test] - fn test_exists_for_both_buffer_and_disk() { - let mut disk_fs = InMemoryFileSystem::new(); - let path = test_file_path("both.py"); - disk_fs.add_file(path.clone(), "disk".to_string()); - - let buffers = Buffers::new(); - let fs = WorkspaceFileSystem::new(buffers.clone(), Arc::new(disk_fs)); - - // Also add to buffer - let url = Url::from_file_path(&path).unwrap(); - let doc = TextDocument::new("buffer".to_string(), 1, LanguageId::Python); - buffers.open(url, doc); - - assert!(fs.exists(&path)); - } - - #[test] - fn test_exists_returns_false_when_nowhere() { - let disk = Arc::new(InMemoryFileSystem::new()); - let buffers = Buffers::new(); - let fs = WorkspaceFileSystem::new(buffers, disk); - - let path = test_file_path("nowhere.py"); - assert!(!fs.exists(&path)); - } - - #[test] - fn test_read_error_when_file_nowhere() { - let disk = Arc::new(InMemoryFileSystem::new()); - let buffers = Buffers::new(); - let fs = WorkspaceFileSystem::new(buffers, disk); - - let path = test_file_path("missing.py"); - let result = fs.read_to_string(&path); - assert!(result.is_err()); - assert_eq!(result.unwrap_err().kind(), io::ErrorKind::NotFound); - } - - #[test] - fn test_reflects_buffer_updates() { - let disk = Arc::new(InMemoryFileSystem::new()); - let buffers = Buffers::new(); - let fs = WorkspaceFileSystem::new(buffers.clone(), disk); - - let path = test_file_path("test.py"); - let url = Url::from_file_path(&path).unwrap(); - - // Initial buffer content - let doc1 = TextDocument::new("version 1".to_string(), 1, LanguageId::Python); - buffers.open(url.clone(), doc1); - assert_eq!(fs.read_to_string(&path).unwrap(), "version 1"); - - // Update buffer content - let doc2 = TextDocument::new("version 2".to_string(), 2, LanguageId::Python); - buffers.update(url, doc2); - assert_eq!(fs.read_to_string(&path).unwrap(), "version 2"); - } - - #[test] - fn test_handles_buffer_removal() { - let mut disk_fs = InMemoryFileSystem::new(); - let path = test_file_path("test.py"); - disk_fs.add_file(path.clone(), "disk content".to_string()); - - let buffers = Buffers::new(); - let fs = WorkspaceFileSystem::new(buffers.clone(), Arc::new(disk_fs)); - - let url = Url::from_file_path(&path).unwrap(); - - // Add buffer - let doc = TextDocument::new("buffer content".to_string(), 1, LanguageId::Python); - buffers.open(url.clone(), doc); - assert_eq!(fs.read_to_string(&path).unwrap(), "buffer content"); - - // Remove buffer - let _ = buffers.close(&url); - assert_eq!(fs.read_to_string(&path).unwrap(), "disk content"); - } - } -} diff --git a/crates/djls-workspace/src/lib.rs b/crates/djls-workspace/src/lib.rs index dffc8af9a..4b95804d0 100644 --- a/crates/djls-workspace/src/lib.rs +++ b/crates/djls-workspace/src/lib.rs @@ -16,7 +16,6 @@ mod buffers; pub mod db; mod document; pub mod encoding; -mod fs; mod language; pub mod paths; mod workspace; @@ -28,10 +27,6 @@ pub use document::TextDocument; pub use encoding::negotiate_position_encoding; pub use encoding::position_encoding_from_lsp; pub use encoding::position_encoding_to_lsp; -pub use fs::FileSystem; -pub use fs::InMemoryFileSystem; -pub use fs::OsFileSystem; -pub use fs::WorkspaceFileSystem; pub use language::LanguageId; pub use workspace::Workspace; pub use workspace::WorkspaceFileEvent; diff --git a/crates/djls-workspace/src/workspace.rs b/crates/djls-workspace/src/workspace.rs index e39d2f951..ea8d6a8d8 100644 --- a/crates/djls-workspace/src/workspace.rs +++ b/crates/djls-workspace/src/workspace.rs @@ -4,12 +4,15 @@ //! management and file system abstraction. The Salsa database is managed //! at the Session level, following Ruff's architecture pattern. +use std::io; use std::sync::Arc; use camino::Utf8Path; use camino::Utf8PathBuf; use djls_source::File; +use djls_source::FileSystem; use djls_source::FxDashMap; +use djls_source::OsFileSystem; use djls_source::PositionEncoding; use tower_lsp_server::lsp_types::TextDocumentContentChangeEvent; use url::Url; @@ -17,9 +20,6 @@ use url::Url; use crate::buffers::Buffers; use crate::db::Db; use crate::document::TextDocument; -use crate::fs::FileSystem; -use crate::fs::OsFileSystem; -use crate::fs::WorkspaceFileSystem; use crate::paths; /// Result of a workspace operation that affected a tracked file. @@ -44,6 +44,55 @@ impl WorkspaceFileEvent { } } } +/// +/// LSP file system that intercepts reads for buffered files. +/// +/// This implements a two-layer architecture where Layer 1 (open [`Buffers`]) +/// takes precedence over Layer 2 (Salsa database). When a file is read, +/// this system first checks for a buffer (in-memory content from +/// [`TextDocument`](crate::document::TextDocument)) and returns that content. +/// If no buffer exists, it falls back to reading from disk. +/// +/// ## Overlay Semantics +/// +/// Files in the overlay (buffered files) are treated as first-class files: +/// - `exists()` returns true for overlay files even if they don't exist on disk +/// - `read_to_string()` returns the overlay content +/// +/// This ensures consistent behavior across all filesystem operations for +/// buffered files that may not yet be saved to disk. +/// +/// This type is used by the database implementations to ensure all file reads go +/// through the buffer system first. +pub struct WorkspaceFileSystem { + /// In-memory buffers that take precedence over disk files + buffers: Buffers, + /// Fallback file system for disk operations + disk: Arc, +} + +impl WorkspaceFileSystem { + #[must_use] + pub fn new(buffers: Buffers, disk: Arc) -> Self { + Self { buffers, disk } + } +} + +impl FileSystem for WorkspaceFileSystem { + fn read_to_string(&self, path: &Utf8Path) -> io::Result { + if let Some(url) = paths::path_to_url(path) { + if let Some(document) = self.buffers.get(&url) { + return Ok(document.content().to_string()); + } + } + self.disk.read_to_string(path) + } + + fn exists(&self, path: &Utf8Path) -> bool { + paths::path_to_url(path).is_some_and(|url| self.buffers.contains(&url)) + || self.disk.exists(path) + } +} /// Workspace facade that manages buffers and file system. /// @@ -201,201 +250,377 @@ impl Default for Workspace { #[cfg(test)] mod tests { - use std::sync::Arc; + use super::*; - use camino::Utf8Path; - use camino::Utf8PathBuf; - use tempfile::tempdir; - use url::Url; + mod file_system { + use super::*; - use super::*; - use crate::LanguageId; + use camino::Utf8PathBuf; + use djls_source::InMemoryFileSystem; + use url::Url; + + use crate::buffers::Buffers; + use crate::document::TextDocument; + use crate::language::LanguageId; + + // Helper to create platform-appropriate test paths + fn test_file_path(name: &str) -> Utf8PathBuf { + #[cfg(windows)] + return Utf8PathBuf::from(format!("C:\\temp\\{name}")); + #[cfg(not(windows))] + return Utf8PathBuf::from(format!("/tmp/{name}")); + } + + #[test] + fn test_reads_from_buffer_when_present() { + let disk = Arc::new(InMemoryFileSystem::new()); + let buffers = Buffers::new(); + let fs = WorkspaceFileSystem::new(buffers.clone(), disk); + + // Add file to buffer + let path = test_file_path("test.py"); + let url = Url::from_file_path(&path).unwrap(); + let doc = TextDocument::new("buffer content".to_string(), 1, LanguageId::Python); + buffers.open(url, doc); + + assert_eq!(fs.read_to_string(&path).unwrap(), "buffer content"); + } + + #[test] + fn test_reads_from_disk_when_no_buffer() { + let mut disk_fs = InMemoryFileSystem::new(); + let path = test_file_path("test.py"); + disk_fs.add_file(path.clone(), "disk content".to_string()); + + let buffers = Buffers::new(); + let fs = WorkspaceFileSystem::new(buffers, Arc::new(disk_fs)); + + assert_eq!(fs.read_to_string(&path).unwrap(), "disk content"); + } + + #[test] + fn test_buffer_overrides_disk() { + let mut disk_fs = InMemoryFileSystem::new(); + let path = test_file_path("test.py"); + disk_fs.add_file(path.clone(), "disk content".to_string()); + + let buffers = Buffers::new(); + let fs = WorkspaceFileSystem::new(buffers.clone(), Arc::new(disk_fs)); + + // Add buffer with different content + let url = Url::from_file_path(&path).unwrap(); + let doc = TextDocument::new("buffer content".to_string(), 1, LanguageId::Python); + buffers.open(url, doc); + + assert_eq!(fs.read_to_string(&path).unwrap(), "buffer content"); + } + + #[test] + fn test_exists_for_buffer_only_file() { + let disk = Arc::new(InMemoryFileSystem::new()); + let buffers = Buffers::new(); + let fs = WorkspaceFileSystem::new(buffers.clone(), disk); + + // Add file to buffer only + let path = test_file_path("buffer_only.py"); + let url = Url::from_file_path(&path).unwrap(); + let doc = TextDocument::new("content".to_string(), 1, LanguageId::Python); + buffers.open(url, doc); + + assert!(fs.exists(&path)); + } + + #[test] + fn test_exists_for_disk_only_file() { + let mut disk_fs = InMemoryFileSystem::new(); + let path = test_file_path("disk_only.py"); + disk_fs.add_file(path.clone(), "content".to_string()); + + let buffers = Buffers::new(); + let fs = WorkspaceFileSystem::new(buffers, Arc::new(disk_fs)); + + assert!(fs.exists(&path)); + } + + #[test] + fn test_exists_for_both_buffer_and_disk() { + let mut disk_fs = InMemoryFileSystem::new(); + let path = test_file_path("both.py"); + disk_fs.add_file(path.clone(), "disk".to_string()); + + let buffers = Buffers::new(); + let fs = WorkspaceFileSystem::new(buffers.clone(), Arc::new(disk_fs)); + + // Also add to buffer + let url = Url::from_file_path(&path).unwrap(); + let doc = TextDocument::new("buffer".to_string(), 1, LanguageId::Python); + buffers.open(url, doc); + + assert!(fs.exists(&path)); + } + + #[test] + fn test_exists_returns_false_when_nowhere() { + let disk = Arc::new(InMemoryFileSystem::new()); + let buffers = Buffers::new(); + let fs = WorkspaceFileSystem::new(buffers, disk); + + let path = test_file_path("nowhere.py"); + assert!(!fs.exists(&path)); + } + + #[test] + fn test_read_error_when_file_nowhere() { + let disk = Arc::new(InMemoryFileSystem::new()); + let buffers = Buffers::new(); + let fs = WorkspaceFileSystem::new(buffers, disk); + + let path = test_file_path("missing.py"); + let result = fs.read_to_string(&path); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), io::ErrorKind::NotFound); + } + + #[test] + fn test_reflects_buffer_updates() { + let disk = Arc::new(InMemoryFileSystem::new()); + let buffers = Buffers::new(); + let fs = WorkspaceFileSystem::new(buffers.clone(), disk); + + let path = test_file_path("test.py"); + let url = Url::from_file_path(&path).unwrap(); + + // Initial buffer content + let doc1 = TextDocument::new("version 1".to_string(), 1, LanguageId::Python); + buffers.open(url.clone(), doc1); + assert_eq!(fs.read_to_string(&path).unwrap(), "version 1"); + + // Update buffer content + let doc2 = TextDocument::new("version 2".to_string(), 2, LanguageId::Python); + buffers.update(url, doc2); + assert_eq!(fs.read_to_string(&path).unwrap(), "version 2"); + } - #[salsa::db] - #[derive(Clone)] - struct TestDb { - storage: salsa::Storage, - fs: Arc, + #[test] + fn test_handles_buffer_removal() { + let mut disk_fs = InMemoryFileSystem::new(); + let path = test_file_path("test.py"); + disk_fs.add_file(path.clone(), "disk content".to_string()); + + let buffers = Buffers::new(); + let fs = WorkspaceFileSystem::new(buffers.clone(), Arc::new(disk_fs)); + + let url = Url::from_file_path(&path).unwrap(); + + // Add buffer + let doc = TextDocument::new("buffer content".to_string(), 1, LanguageId::Python); + buffers.open(url.clone(), doc); + assert_eq!(fs.read_to_string(&path).unwrap(), "buffer content"); + + // Remove buffer + let _ = buffers.close(&url); + assert_eq!(fs.read_to_string(&path).unwrap(), "disk content"); + } } - impl TestDb { - fn new(fs: Arc) -> Self { - Self { - storage: salsa::Storage::default(), - fs, + mod workspace { + use std::sync::Arc; + + use camino::Utf8Path; + use camino::Utf8PathBuf; + use tempfile::tempdir; + use url::Url; + + use super::*; + use crate::LanguageId; + + #[salsa::db] + #[derive(Clone)] + struct TestDb { + storage: salsa::Storage, + fs: Arc, + } + + impl TestDb { + fn new(fs: Arc) -> Self { + Self { + storage: salsa::Storage::default(), + fs, + } } } - } - #[salsa::db] - impl salsa::Database for TestDb {} + #[salsa::db] + impl salsa::Database for TestDb {} - #[salsa::db] - impl djls_source::Db for TestDb { - fn read_file_source(&self, path: &Utf8Path) -> std::io::Result { - self.fs.read_to_string(path) + #[salsa::db] + impl djls_source::Db for TestDb { + fn read_file_source(&self, path: &Utf8Path) -> std::io::Result { + self.fs.read_to_string(path) + } } - } - #[salsa::db] - impl crate::db::Db for TestDb { - fn fs(&self) -> Arc { - self.fs.clone() + #[salsa::db] + impl crate::db::Db for TestDb { + fn fs(&self) -> Arc { + self.fs.clone() + } } - } - #[test] - fn test_open_document() { - let mut workspace = Workspace::new(); - let mut db = TestDb::new(workspace.file_system()); - let url = Url::parse("file:///test.py").unwrap(); + #[test] + fn test_open_document() { + let mut workspace = Workspace::new(); + let mut db = TestDb::new(workspace.file_system()); + let url = Url::parse("file:///test.py").unwrap(); - let document = TextDocument::new("print('hello')".to_string(), 1, LanguageId::Python); - let event = workspace.open_document(&mut db, &url, document).unwrap(); + let document = TextDocument::new("print('hello')".to_string(), 1, LanguageId::Python); + let event = workspace.open_document(&mut db, &url, document).unwrap(); - match event { - WorkspaceFileEvent::Created { ref path, .. } => { - assert_eq!(path.file_name(), Some("test.py")); + match event { + WorkspaceFileEvent::Created { ref path, .. } => { + assert_eq!(path.file_name(), Some("test.py")); + } + WorkspaceFileEvent::Updated { .. } => panic!("expected created event"), } - WorkspaceFileEvent::Updated { .. } => panic!("expected created event"), + assert!(workspace.buffers.get(&url).is_some()); } - assert!(workspace.buffers.get(&url).is_some()); - } - #[test] - fn test_update_document() { - let mut workspace = Workspace::new(); - let mut db = TestDb::new(workspace.file_system()); - let url = Url::parse("file:///test.py").unwrap(); - - let document = TextDocument::new("initial".to_string(), 1, LanguageId::Python); - workspace.open_document(&mut db, &url, document); - - let changes = vec![TextDocumentContentChangeEvent { - range: None, - range_length: None, - text: "updated".to_string(), - }]; - let event = workspace - .update_document(&mut db, &url, changes, 2, PositionEncoding::Utf16) - .unwrap(); - - assert!(matches!(event, WorkspaceFileEvent::Updated { .. })); - let buffer = workspace.buffers.get(&url).unwrap(); - assert_eq!(buffer.content(), "updated"); - assert_eq!(buffer.version(), 2); - } + #[test] + fn test_update_document() { + let mut workspace = Workspace::new(); + let mut db = TestDb::new(workspace.file_system()); + let url = Url::parse("file:///test.py").unwrap(); + + let document = TextDocument::new("initial".to_string(), 1, LanguageId::Python); + workspace.open_document(&mut db, &url, document); + + let changes = vec![TextDocumentContentChangeEvent { + range: None, + range_length: None, + text: "updated".to_string(), + }]; + let event = workspace + .update_document(&mut db, &url, changes, 2, PositionEncoding::Utf16) + .unwrap(); + + assert!(matches!(event, WorkspaceFileEvent::Updated { .. })); + let buffer = workspace.buffers.get(&url).unwrap(); + assert_eq!(buffer.content(), "updated"); + assert_eq!(buffer.version(), 2); + } - #[test] - fn test_close_document() { - let mut workspace = Workspace::new(); - let mut db = TestDb::new(workspace.file_system()); - let url = Url::parse("file:///test.py").unwrap(); + #[test] + fn test_close_document() { + let mut workspace = Workspace::new(); + let mut db = TestDb::new(workspace.file_system()); + let url = Url::parse("file:///test.py").unwrap(); - let document = TextDocument::new("content".to_string(), 1, LanguageId::Python); - workspace.open_document(&mut db, &url, document.clone()); + let document = TextDocument::new("content".to_string(), 1, LanguageId::Python); + workspace.open_document(&mut db, &url, document.clone()); - let closed = workspace.close_document(&mut db, &url); - assert!(closed.is_some()); - assert!(workspace.buffers.get(&url).is_none()); - } + let closed = workspace.close_document(&mut db, &url); + assert!(closed.is_some()); + assert!(workspace.buffers.get(&url).is_none()); + } - #[test] - fn test_file_system_checks_buffers_first() { - let temp_dir = tempdir().unwrap(); - let file_path = temp_dir.path().join("test.py"); - std::fs::write(&file_path, "disk content").unwrap(); + #[test] + fn test_file_system_checks_buffers_first() { + let temp_dir = tempdir().unwrap(); + let file_path = temp_dir.path().join("test.py"); + std::fs::write(&file_path, "disk content").unwrap(); - let mut workspace = Workspace::new(); - let mut db = TestDb::new(workspace.file_system()); - let url = Url::from_file_path(&file_path).unwrap(); + let mut workspace = Workspace::new(); + let mut db = TestDb::new(workspace.file_system()); + let url = Url::from_file_path(&file_path).unwrap(); - let document = TextDocument::new("buffer content".to_string(), 1, LanguageId::Python); - workspace.open_document(&mut db, &url, document); + let document = TextDocument::new("buffer content".to_string(), 1, LanguageId::Python); + workspace.open_document(&mut db, &url, document); - let content = workspace - .file_system() - .read_to_string(Utf8Path::from_path(&file_path).unwrap()) - .unwrap(); - assert_eq!(content, "buffer content"); - } + let content = workspace + .file_system() + .read_to_string(Utf8Path::from_path(&file_path).unwrap()) + .unwrap(); + assert_eq!(content, "buffer content"); + } - #[test] - fn test_file_source_reads_from_buffer() { - let mut workspace = Workspace::new(); - let mut db = TestDb::new(workspace.file_system()); - - let temp_dir = tempdir().unwrap(); - let file_path = Utf8PathBuf::from_path_buf(temp_dir.path().join("template.html")).unwrap(); - std::fs::write(file_path.as_std_path(), "disk template").unwrap(); - let url = Url::from_file_path(file_path.as_std_path()).unwrap(); - - let document = TextDocument::new("line1\nline2".to_string(), 1, LanguageId::HtmlDjango); - let event = workspace - .open_document(&mut db, &url, document.clone()) - .unwrap(); - let file = event.file(); - - let source = file.source(&db); - assert_eq!(source.as_str(), document.content()); - - let line_index = file.line_index(&db); - assert_eq!( - line_index.to_line_col(djls_source::Offset::new(0)), - djls_source::LineCol::new(0, 0) - ); - assert_eq!( - line_index.to_line_col(djls_source::Offset::new(6)), - djls_source::LineCol::new(1, 0) - ); - } + #[test] + fn test_file_source_reads_from_buffer() { + let mut workspace = Workspace::new(); + let mut db = TestDb::new(workspace.file_system()); + + let temp_dir = tempdir().unwrap(); + let file_path = + Utf8PathBuf::from_path_buf(temp_dir.path().join("template.html")).unwrap(); + std::fs::write(file_path.as_std_path(), "disk template").unwrap(); + let url = Url::from_file_path(file_path.as_std_path()).unwrap(); + + let document = TextDocument::new("line1\nline2".to_string(), 1, LanguageId::HtmlDjango); + let event = workspace + .open_document(&mut db, &url, document.clone()) + .unwrap(); + let file = event.file(); + + let source = file.source(&db); + assert_eq!(source.as_str(), document.content()); + + let line_index = file.line_index(&db); + assert_eq!( + line_index.to_line_col(djls_source::Offset::new(0)), + djls_source::LineCol::new(0, 0) + ); + assert_eq!( + line_index.to_line_col(djls_source::Offset::new(6)), + djls_source::LineCol::new(1, 0) + ); + } - #[test] - fn test_update_document_updates_source() { - let mut workspace = Workspace::new(); - let mut db = TestDb::new(workspace.file_system()); - - let temp_dir = tempdir().unwrap(); - let file_path = Utf8PathBuf::from_path_buf(temp_dir.path().join("buffer.py")).unwrap(); - std::fs::write(file_path.as_std_path(), "disk").unwrap(); - let url = Url::from_file_path(file_path.as_std_path()).unwrap(); - - let document = TextDocument::new("initial".to_string(), 1, LanguageId::Python); - let event = workspace.open_document(&mut db, &url, document).unwrap(); - let file = event.file(); - - let changes = vec![TextDocumentContentChangeEvent { - range: None, - range_length: None, - text: "updated".to_string(), - }]; - workspace - .update_document(&mut db, &url, changes, 2, PositionEncoding::Utf16) - .unwrap(); - - let source = file.source(&db); - assert_eq!(source.as_str(), "updated"); - } + #[test] + fn test_update_document_updates_source() { + let mut workspace = Workspace::new(); + let mut db = TestDb::new(workspace.file_system()); + + let temp_dir = tempdir().unwrap(); + let file_path = Utf8PathBuf::from_path_buf(temp_dir.path().join("buffer.py")).unwrap(); + std::fs::write(file_path.as_std_path(), "disk").unwrap(); + let url = Url::from_file_path(file_path.as_std_path()).unwrap(); + + let document = TextDocument::new("initial".to_string(), 1, LanguageId::Python); + let event = workspace.open_document(&mut db, &url, document).unwrap(); + let file = event.file(); + + let changes = vec![TextDocumentContentChangeEvent { + range: None, + range_length: None, + text: "updated".to_string(), + }]; + workspace + .update_document(&mut db, &url, changes, 2, PositionEncoding::Utf16) + .unwrap(); + + let source = file.source(&db); + assert_eq!(source.as_str(), "updated"); + } - #[test] - fn test_close_document_reverts_to_disk() { - let mut workspace = Workspace::new(); - let mut db = TestDb::new(workspace.file_system()); + #[test] + fn test_close_document_reverts_to_disk() { + let mut workspace = Workspace::new(); + let mut db = TestDb::new(workspace.file_system()); - let temp_dir = tempdir().unwrap(); - let file_path = Utf8PathBuf::from_path_buf(temp_dir.path().join("close.py")).unwrap(); - std::fs::write(file_path.as_std_path(), "disk content").unwrap(); - let url = Url::from_file_path(file_path.as_std_path()).unwrap(); + let temp_dir = tempdir().unwrap(); + let file_path = Utf8PathBuf::from_path_buf(temp_dir.path().join("close.py")).unwrap(); + std::fs::write(file_path.as_std_path(), "disk content").unwrap(); + let url = Url::from_file_path(file_path.as_std_path()).unwrap(); - let document = TextDocument::new("buffer content".to_string(), 1, LanguageId::Python); - let event = workspace.open_document(&mut db, &url, document).unwrap(); - let file = event.file(); + let document = TextDocument::new("buffer content".to_string(), 1, LanguageId::Python); + let event = workspace.open_document(&mut db, &url, document).unwrap(); + let file = event.file(); - assert_eq!(file.source(&db).as_str(), "buffer content"); + assert_eq!(file.source(&db).as_str(), "buffer content"); - workspace.close_document(&mut db, &url); + workspace.close_document(&mut db, &url); - let source_after = file.source(&db); - assert_eq!(source_after.as_str(), "disk content"); + let source_after = file.source(&db); + assert_eq!(source_after.as_str(), "disk content"); + } } } From 0d2587e38115eb9f3498bf6f64e0bbceb1836507 Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 25 Sep 2025 10:09:21 -0500 Subject: [PATCH 2/2] clippy fmt lint --- crates/djls-project/src/python.rs | 4 +++- crates/djls-source/src/system.rs | 3 ++- crates/djls-workspace/src/workspace.rs | 3 +-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/djls-project/src/python.rs b/crates/djls-project/src/python.rs index b511ac7c2..3c6c202c5 100644 --- a/crates/djls-project/src/python.rs +++ b/crates/djls-project/src/python.rs @@ -276,7 +276,9 @@ mod tests { mod env_discovery { use system::mock::MockGuard; - use system::mock::{self as sys_mock}; + use system::mock::{ + self as sys_mock, + }; use which::Error as WhichError; use super::*; diff --git a/crates/djls-source/src/system.rs b/crates/djls-source/src/system.rs index e89e13f0a..2cb6f0061 100644 --- a/crates/djls-source/src/system.rs +++ b/crates/djls-source/src/system.rs @@ -1,7 +1,8 @@ +use std::io; + use camino::Utf8Path; use camino::Utf8PathBuf; use rustc_hash::FxHashMap; -use std::io; pub trait FileSystem: Send + Sync { fn read_to_string(&self, path: &Utf8Path) -> io::Result; diff --git a/crates/djls-workspace/src/workspace.rs b/crates/djls-workspace/src/workspace.rs index ea8d6a8d8..7b3bdb1c3 100644 --- a/crates/djls-workspace/src/workspace.rs +++ b/crates/djls-workspace/src/workspace.rs @@ -253,12 +253,11 @@ mod tests { use super::*; mod file_system { - use super::*; - use camino::Utf8PathBuf; use djls_source::InMemoryFileSystem; use url::Url; + use super::*; use crate::buffers::Buffers; use crate::document::TextDocument; use crate::language::LanguageId;